Skip to main content
PARIXDocs

Subscriptions

Track recurring allowances, usage, adjustments, refunds, and invoice inputs with Parix.

Overview

This manual models a subscription with two independent quantities:

  • a usage ledger grants and consumes integer product units; and
  • a money ledger records integer minor-currency units after pricing or payment-provider events have been decided elsewhere.

The separation matters. One API request is not one cent, rates can change, and an invoice can combine fixed fees, usage, discounts, tax, and credits. A usage transfer is therefore an invoice input, not revenue and not an invoice document.

Parix provides the durable ledger and managed TigerBeetle operation surface. Your subscription system still owns plans, periods, entitlement rules, price books, invoice documents, tax, collection, dunning, and cancellation state. This page concerns subscriptions you build on Parix; it is separate from billing for the Parix service.

Architecture and ownership

ComponentOwnsRequired durable identity
Subscription databaseCustomer, plan/version, status, period boundaries, late-event policy, and invoice recordsSubscription ID, billing-period ID, invoice ID
Period schedulerOpening/closing periods and requesting one allowance grant per periodScheduler job ID mapped to period/grant transfer ID
Usage ingestion serviceRaw source event, integer quantity, event time, and period assignmentSource-event ID mapped to usage transfer ID
Pricing and invoicingPrice-book version, tiering, proration, discounts, tax, rounding, and invoice documentInvoice ID and immutable input snapshot
Payment providerPayment method, authorization/capture, settlement, disputes, and provider-side refundProvider event/object ID
Provider webhook receiverSignature/authentication check, deduplication, and provider-event-to-money-transfer mappingProvider event ID mapped to transfer ID
Parix API gatewayAuthentication, database scope, strict validation, plan enforcement, and routingDatabase UUID and request context
TigerBeetle ledgerUsage and money account balances, transfer history, constraints, and ID idempotencyAccount and transfer IDs

Applications use the versioned Parix HTTP gateway rather than connecting a native client to replica addresses. The Node adapter shown below preserves TigerBeetle-shaped objects while making authenticated gateway HTTP requests.

The scheduler, payment provider, and webhook receiver form separate reliability boundaries:

  1. The scheduler persists a period and grant transfer ID before requesting the grant.
  2. The usage service assigns each event under the documented period/late-event policy and persists its transfer ID before writing.
  3. The invoice job queries confirmed usage, snapshots its inputs and price-book version, and creates the invoice outside the ledger.
  4. The payment service calls the provider. It never holds a ledger operation open while waiting on that external call.
  5. The webhook receiver verifies and durably deduplicates the provider event, then records the corresponding money movement with a stable transfer ID.

Provider webhooks are application integrations. They are not the same as Parix-managed TigerBeetle CDC webhooks described in the platform integration documentation.

Ledger model

The example uses usage ledger 7300 for API-request units and money ledger 840 for USD cents. Accounts on different ledgers never transfer directly to one another. The pricing service connects them by retaining a versioned calculation and its source transfer IDs.

Usage accounts

AccountID in examplesCodeBalance rulePurpose
Grant source810000000000000000011101UnconstrainedSupplies period allowance grants
Subscriber allowance810000000000000000021102Debits must not exceed creditsReceives the period grant and is debited by usage
Usage sink810000000000000000031103UnconstrainedAccumulates consumed units; it is not revenue

Money accounts

This example chooses a prepaid USD-cent model. Adapt the account directions with your finance team if the product is postpaid, uses receivables, or requires a formal general-ledger chart of accounts.

AccountID in examplesCodeBalance rulePurpose
Collection source810000000000000000111201UnconstrainedRepresents externally confirmed funds entering this model
Subscriber prepaid balance810000000000000000121202Debits must not exceed creditsHolds available USD cents
Billing clearing810000000000000000131203UnconstrainedReceives invoice charges; it is not automatically recognized revenue

The billing clearing balance is an operational ledger position. Revenue recognition, processor settlement, tax, and general-ledger export remain separate accounting responsibilities.

Event codes

LedgerCodeNameDirectionStable key
Usage 73002101grantGrant source to subscriber allowanceSubscription + period + grant version
Usage 73002102consumeSubscriber allowance to usage sinkSource usage-event ID
Usage 73002103adjustApproved direction for the correctionAdjustment/case ID + target period
Usage 73002104refundUsage sink to subscriber allowanceRefund ID + original usage-event ID
Money 8403101funds_receivedCollection source to prepaid balanceVerified provider event ID
Money 8403102invoice_chargePrepaid balance to billing clearingInvoice ID + charge version
Money 8403103adjustApproved direction for the correctionAdjustment/case ID
Money 8403104refundBilling clearing to prepaid balanceProvider refund ID + original charge ID

An adjustment never edits or deletes the original transfer. Post a new, named event in the correct direction and keep the reason, actor, original event, and policy version in the subscription database.

Period, event, and late-arrival policy

Persist these relationships before issuing writes:

RecordExampleLedger relationship
Subscriptionsub_510001Resolves the subscriber's account IDs
Billing periodperiod_2026_08Maps to one stable 2101 grant transfer; optionally encoded in user_data_64
Usage eventusage_evt_01J...Maps one-to-one to a 2102 transfer ID
Invoiceinv_2026_08_510001Stores queried transfer IDs, price-book version, totals, and a stable 3102 ID
Provider eventevt_provider_...Maps one-to-one to a 3101 or 3104 money transfer ID

Choose one late-event rule and store it with each assigned event:

  • keep the period open through a documented lateness window, then close it;
  • assign an event received after close to the next open invoice while retaining its actual event time and source period; or
  • reopen through an explicit adjustment workflow and issue a revised invoice/credit note according to policy.

Never silently move a late event, change its transfer amount, or regenerate its ID. Persist event_time, received_at, assigned_period_id, and late_event_policy_version so support and finance can explain the outcome.

Before you begin

  • Use Developer for learning, prototypes, and SDK tests. Use Dedicated Single Node for isolated development, staging, and non-HA work. Neither plan is production-allowed.
  • Use Production HA, Production 6, or a contract-defined Enterprise topology for production. Confirm current provider, region, feature, and written-SLA availability rather than inferring it from a plan name.
  • Confirm the database profile is active and copy its UUID. The UUID, not the display name in a route, is the API and CLI positional database ID.
  • Decide whether the money example's prepaid convention fits your product. Have finance approve ledgers, account directions, clearing/reconciliation, refund treatment, and general-ledger export.
  • Define integer base units and rounding. Store usage as units such as requests or bytes and money as minor units such as cents; do not send floating-point ledger amounts.
  • Allocate immutable ledger and code registries. A transfer's debit and credit accounts must be on the same ledger as the transfer.
  • Persist account, period, source-event, invoice, provider-event, and transfer-ID mappings before a write can be retried.
  • Create a database-scoped API key for the production service and store it in a secret manager. Current generated keys include both read and write scopes, so isolate callers and environments.
  • Raw HTTP represents wide integer fields as decimal strings. Create and lookup batches contain 1–8,190 items. Never allow a JSON parser to round a 128-bit ID through a JavaScript number.
  • A plain multi-item batch is not atomic. Only a correctly terminated linked chain provides all-or-nothing behavior for its chain; independent ledger writes and an external provider call cannot be made one transaction.
  • Shared Developer queries require a ledger. Query usage with 7300 and money with 840; do not request an unscoped shared scan. The first use of an external ledger can allocate a Parix project-ledger mapping and consume one of the plan's ledger slots even when the TigerBeetle operation is a read, so reuse governed application ledgers.

Use a disposable database or replace every example ID. The CLI and Node walkthroughs demonstrate alternative client surfaces and should not both be run against the same IDs without expecting conflicts.

Dashboard walkthrough

  1. Open the intended database and select Query.
  2. Choose Query accounts, select the governed ledger 7300, and use a small limit. This is a TigerBeetle read, but on Shared Developer the ledger's first use can also allocate its Parix namespace mapping and consume one ledger slot.
  3. Choose Create accounts and create the grant source, subscriber allowance, and usage sink on ledger 7300. Then create the three money accounts on ledger 840.
  4. Choose Create transfers and grant the period allowance with code 2101. Submit a usage event with code 2102 only after its source-event mapping is durable.
  5. Query transfers by ledger 7300, code 2102, and the intended customer/period user-data values. Export or compare those results with the invoice input snapshot.
  6. Query ledger 840 separately when reconciling provider funding, invoice charges, adjustments, or refunds.

The Query page operates on the selected live database. create_accounts and create_transfers mutate it immediately when you select Run. Generated IDs are useful for disposable exploration only; production schedulers and webhook receivers must use their persisted period/event mappings.

Query accounts results in the Parix query explorer with ledger and limit controls

This screenshot shows the account-query surface. It does not show subscription periods, an invoice calculation, or a successful transfer.

The dashboard is an operator and development surface. Production subscription, usage, invoice, and webhook workers should call the gateway with server-side credentials.

CLI walkthrough

These commands use the latest published @parix/cli package.

Install and authenticate

npm install --global @parix/cli@latest
parix --version
parix auth login
parix auth status
parix database list --json

The CLI defaults to https://parix.io. If you select another environment with --base-url, use it consistently. OAuth login is intended for a human-operated terminal. Production workers should use a database-scoped API key and not depend on the CLI's local OAuth session.

Set the database UUID returned by the list command:

export PARIX_DATABASE_ID="db_replace_with_database_uuid"

Create usage and money accounts

Save the following bare array as subscription-accounts.json. It intentionally contains accounts from two ledgers; each later transfer still uses accounts from only one ledger.

[
  {
    "id": "81000000000000000001",
    "debits_pending": "0",
    "debits_posted": "0",
    "credits_pending": "0",
    "credits_posted": "0",
    "user_data_128": "0",
    "user_data_64": "0",
    "user_data_32": 0,
    "reserved": 0,
    "ledger": 7300,
    "code": 1101,
    "flags": 8,
    "timestamp": "0"
  },
  {
    "id": "81000000000000000002",
    "debits_pending": "0",
    "debits_posted": "0",
    "credits_pending": "0",
    "credits_posted": "0",
    "user_data_128": "510001",
    "user_data_64": "202608",
    "user_data_32": 1,
    "reserved": 0,
    "ledger": 7300,
    "code": 1102,
    "flags": 10,
    "timestamp": "0"
  },
  {
    "id": "81000000000000000003",
    "debits_pending": "0",
    "debits_posted": "0",
    "credits_pending": "0",
    "credits_posted": "0",
    "user_data_128": "0",
    "user_data_64": "0",
    "user_data_32": 0,
    "reserved": 0,
    "ledger": 7300,
    "code": 1103,
    "flags": 8,
    "timestamp": "0"
  },
  {
    "id": "81000000000000000011",
    "debits_pending": "0",
    "debits_posted": "0",
    "credits_pending": "0",
    "credits_posted": "0",
    "user_data_128": "0",
    "user_data_64": "0",
    "user_data_32": 0,
    "reserved": 0,
    "ledger": 840,
    "code": 1201,
    "flags": 8,
    "timestamp": "0"
  },
  {
    "id": "81000000000000000012",
    "debits_pending": "0",
    "debits_posted": "0",
    "credits_pending": "0",
    "credits_posted": "0",
    "user_data_128": "510001",
    "user_data_64": "0",
    "user_data_32": 840,
    "reserved": 0,
    "ledger": 840,
    "code": 1202,
    "flags": 10,
    "timestamp": "0"
  },
  {
    "id": "81000000000000000013",
    "debits_pending": "0",
    "debits_posted": "0",
    "credits_pending": "0",
    "credits_posted": "0",
    "user_data_128": "0",
    "user_data_64": "0",
    "user_data_32": 840,
    "reserved": 0,
    "ledger": 840,
    "code": 1203,
    "flags": 8,
    "timestamp": "0"
  }
]

Review the IDs, codes, ledgers, and flags, then submit the file:

parix tb create-accounts "$PARIX_DATABASE_ID" \
  --file ./subscription-accounts.json \
  --json

A clean create has responsePayload: []. A conflict carries indexed results that must be matched to the original array and resolved by lookup.

Grant the period and record usage

The scheduler must persist the grant ID with period_2026_08 before running this command. Optional user_data_* is set because later invoice queries filter on the same values (omit any field you will not query):

  • user_data_128 — subscription correlation (“who”)
  • user_data_64 — period key 202608
  • user_data_32 — usage unit enum 1 (not the ledger; ledger is already 7300)
parix tb create-transfers "$PARIX_DATABASE_ID" \
  --id 82000000000000000001 \
  --from 81000000000000000001 \
  --to 81000000000000000002 \
  --amount 100000 \
  --ledger 7300 \
  --code 2101 \
  --user-data-128 510001 \
  --user-data-64 202608 \
  --user-data-32 1 \
  --json

Save the next bare array as subscription-usage-events.json. Each ID must already be persisted next to its source event and assigned period.

[
  {
    "id": "82000000000000000002",
    "debit_account_id": "81000000000000000002",
    "credit_account_id": "81000000000000000003",
    "amount": "1250",
    "pending_id": "0",
    "user_data_128": "510001",
    "user_data_64": "202608",
    "user_data_32": 1,
    "timeout": 0,
    "ledger": 7300,
    "code": 2102,
    "flags": 0,
    "timestamp": "0"
  },
  {
    "id": "82000000000000000003",
    "debit_account_id": "81000000000000000002",
    "credit_account_id": "81000000000000000003",
    "amount": "750",
    "pending_id": "0",
    "user_data_128": "510001",
    "user_data_64": "202608",
    "user_data_32": 1,
    "timeout": 0,
    "ledger": 7300,
    "code": 2102,
    "flags": 0,
    "timestamp": "0"
  }
]
parix tb create-transfers "$PARIX_DATABASE_ID" \
  --file ./subscription-usage-events.json \
  --json

The two items have flags: 0 and are independent. One can succeed while the other fails; preserve the file ordering and reconcile every returned result index.

Record priced money events separately

After the provider event is verified and mapped to transfer 83000000000000000001, record confirmed prepaid funds. On the money ledger, user_data_32 is this product’s currency unit enum (840 = USD cents), not a substitute for the ledger field:

parix tb create-transfers "$PARIX_DATABASE_ID" \
  --id 83000000000000000001 \
  --from 81000000000000000011 \
  --to 81000000000000000012 \
  --amount 4900 \
  --ledger 840 \
  --code 3101 \
  --user-data-128 510001 \
  --user-data-32 840 \
  --json

After the invoice service has persisted invoice inv_2026_08_510001, its input transfer IDs, its price-book version, and transfer 83000000000000000002, apply the charge:

parix tb create-transfers "$PARIX_DATABASE_ID" \
  --id 83000000000000000002 \
  --from 81000000000000000012 \
  --to 81000000000000000013 \
  --amount 4900 \
  --ledger 840 \
  --code 3102 \
  --user-data-128 510001 \
  --user-data-64 202608 \
  --user-data-32 840 \
  --json

The usage and money operations are deliberately separate. A provider call, invoice transaction, and two ledger requests do not become atomic merely because one worker coordinates them; use durable states, an outbox, and reconciliation.

Adjustments and refunds

Corrections are new transfers with their own stable IDs. They never edit the original usage or charge record. After policy approval, post a usage adjustment (code 2103) or a usage refund from sink to allowance (code 2104):

# Credit 100 units back to the allowance for a reviewed overcharge case.
parix tb create-transfers "$PARIX_DATABASE_ID" \
  --id 82000000000000000010 \
  --from 81000000000000000003 \
  --to 81000000000000000002 \
  --amount 100 \
  --ledger 7300 \
  --code 2104 \
  --user-data-128 510001 \
  --user-data-64 202608 \
  --user-data-32 1 \
  --json

A money refund after a provider refund is confirmed uses code 3104 (billing clearing → prepaid). Keep the original charge transfer ID and refund case in the application database.

Query the usage ledger for invoice input and the money ledger for invoice reconciliation:

parix tb query-transfers "$PARIX_DATABASE_ID" \
  --ledger 7300 \
  --code 2102 \
  --user-data-128 510001 \
  --user-data-64 202608 \
  --user-data-32 1 \
  --limit 100 \
  --json

parix tb query-transfers "$PARIX_DATABASE_ID" \
  --ledger 840 \
  --code 3102 \
  --user-data-128 510001 \
  --user-data-64 202608 \
  --user-data-32 840 \
  --limit 100 \
  --json

The CLI prints the full Parix envelope with --json. Treat responsePayload as the TigerBeetle result. Both queries filter non-zero user_data_* correlations (subscription 510001, period 202608); user_data_32 distinguishes this product’s usage unit enum 1 from its money unit enum 840 within each ledger. Snapshot the usage IDs used by the invoice—a later query is not a substitute for the immutable invoice input record—and keep stable transfer IDs as the primary deduplication keys.

Node.js implementation

@parix/tigerbeetle-node is the published Node.js client for TigerBeetle-shaped operations on the Parix HTTPS gateway. It does not open a native TigerBeetle connection. Pin a package version approved for your environment, configure { baseUrl, apiKey, databaseId } with a database-scoped API key, keep the client behind an application boundary, and validate behavior before production traffic. It is an authenticated HTTP client for the Parix gateway, not a direct native connection to TigerBeetle replicas.

The literal IDs represent records already persisted by the subscription database. Generate each ID once outside the retry loop, store it with its period, source event, invoice, or provider event, and reload it for every attempt.

import type {
  Account,
  Client,
  CreateAccountResult,
  CreateTransferResult,
  Transfer,
} from '@parix/tigerbeetle-node';
import {
  AccountFlags,
  CreateTransferError,
  QueryFilterFlags,
  TransferFlags,
  createClient,
} from '@parix/tigerbeetle-node';

const USAGE_LEDGER = 7300;
const MONEY_LEDGER = 840;
const ACCOUNT_CODE = {
  grantSource: 1101,
  subscriberAllowance: 1102,
  usageSink: 1103,
  collectionSource: 1201,
  prepaidBalance: 1202,
  billingClearing: 1203,
} as const;
const TRANSFER_CODE = {
  grant: 2101,
  consume: 2102,
  usageAdjust: 2103,
  usageRefund: 2104,
  fundsReceived: 3101,
  invoiceCharge: 3102,
  moneyAdjust: 3103,
  moneyRefund: 3104,
} as const;

interface PersistedSubscriptionIds {
  subscriptionId: bigint;
  periodId: bigint;
  sourceEventId: string;
  providerEventId: string;
  invoiceId: string;
  grantSourceAccountId: bigint;
  subscriberAllowanceAccountId: bigint;
  usageSinkAccountId: bigint;
  collectionSourceAccountId: bigint;
  prepaidBalanceAccountId: bigint;
  billingClearingAccountId: bigint;
  grantTransferId: bigint;
  usageTransferId: bigint;
  fundsReceivedTransferId: bigint;
  invoiceChargeTransferId: bigint;
}

// Replace these demo literals with one durable subscription-period record and
// its source-event, provider-event, and invoice mappings.
const persisted: PersistedSubscriptionIds = {
  subscriptionId: 510001n,
  periodId: 202608n,
  sourceEventId: 'usage_evt_01JEXAMPLE',
  providerEventId: 'provider_evt_01JEXAMPLE',
  invoiceId: 'inv_2026_08_510001',
  grantSourceAccountId: 81000000000000000001n,
  subscriberAllowanceAccountId: 81000000000000000002n,
  usageSinkAccountId: 81000000000000000003n,
  collectionSourceAccountId: 81000000000000000011n,
  prepaidBalanceAccountId: 81000000000000000012n,
  billingClearingAccountId: 81000000000000000013n,
  grantTransferId: 82000000000000000001n,
  usageTransferId: 82000000000000000002n,
  fundsReceivedTransferId: 83000000000000000001n,
  invoiceChargeTransferId: 83000000000000000002n,
};

function account(input: {
  id: bigint;
  ledger: number;
  code: number;
  flags: number;
  ownerId?: bigint;
  periodId?: bigint;
  unitOrCurrency?: number;
}): Account {
  return {
    id: input.id,
    debits_pending: 0n,
    debits_posted: 0n,
    credits_pending: 0n,
    credits_posted: 0n,
    user_data_128: input.ownerId ?? 0n,
    user_data_64: input.periodId ?? 0n,
    user_data_32: input.unitOrCurrency ?? 0,
    reserved: 0,
    ledger: input.ledger,
    code: input.code,
    flags: input.flags,
    timestamp: 0n,
  };
}

function transfer(input: {
  id: bigint;
  debitAccountId: bigint;
  creditAccountId: bigint;
  amount: bigint;
  ledger: number;
  code: number;
  ownerId?: bigint;
  periodId?: bigint;
  unitOrCurrency?: number;
}): Transfer {
  return {
    id: input.id,
    debit_account_id: input.debitAccountId,
    credit_account_id: input.creditAccountId,
    amount: input.amount,
    pending_id: 0n,
    user_data_128: input.ownerId ?? 0n,
    user_data_64: input.periodId ?? 0n,
    user_data_32: input.unitOrCurrency ?? 0,
    timeout: 0,
    ledger: input.ledger,
    code: input.code,
    flags: TransferFlags.none,
    timestamp: 0n,
  };
}

function sameAccount(actual: Account, expected: Account): boolean {
  return (
    actual.id === expected.id &&
    actual.ledger === expected.ledger &&
    actual.code === expected.code &&
    actual.flags === expected.flags &&
    actual.reserved === expected.reserved &&
    actual.user_data_128 === expected.user_data_128 &&
    actual.user_data_64 === expected.user_data_64 &&
    actual.user_data_32 === expected.user_data_32
  );
}

function sameTransfer(actual: Transfer, expected: Transfer): boolean {
  return (
    actual.id === expected.id &&
    actual.debit_account_id === expected.debit_account_id &&
    actual.credit_account_id === expected.credit_account_id &&
    actual.amount === expected.amount &&
    actual.pending_id === expected.pending_id &&
    actual.timeout === expected.timeout &&
    actual.ledger === expected.ledger &&
    actual.code === expected.code &&
    actual.flags === expected.flags &&
    actual.user_data_128 === expected.user_data_128 &&
    actual.user_data_64 === expected.user_data_64 &&
    actual.user_data_32 === expected.user_data_32
  );
}

async function ensureAccounts(client: Client, expected: Account[]): Promise<void> {
  let results: CreateAccountResult[] | CreateTransferResult[];
  try {
    results = await client.createAccounts(expected);
  } catch (error) {
    const actual = await client.lookupAccounts(expected.map((item) => item.id));
    const byId = new Map(actual.map((item) => [item.id, item]));
    const allMatch =
      actual.length === expected.length &&
      expected.every((item) => {
        const found = byId.get(item.id);
        return found !== undefined && sameAccount(found, item);
      });
    if (allMatch) return;
    throw error;
  }

  // Adapter omits gateway `persisted`; require exact post-write lookup always.
  const actual = await client.lookupAccounts(expected.map((item) => item.id));
  const byId = new Map(actual.map((item) => [item.id, item]));
  const mismatch = expected.find((item) => {
    const found = byId.get(item.id);
    return !found || !sameAccount(found, item);
  });
  if (!mismatch) return;
  if (results.length === 0) {
    throw new Error('Account write was not confirmed by lookup; do not advance or change the payload');
  }
  throw new Error(`createAccounts conflict at account ${mismatch.id}`);
}

async function transferAlreadyExists(client: Client, expected: Transfer): Promise<boolean> {
  const [actual] = await client.lookupTransfers([expected.id]);
  if (!actual) {
    return false;
  }
  if (!sameTransfer(actual, expected)) {
    throw new Error(`transfer ID ${expected.id} exists with different fields`);
  }
  return true;
}

async function createTransferWithLookup(client: Client, expected: Transfer): Promise<void> {
  // Every queue, scheduler, and webhook retry enters through this lookup.
  if (await transferAlreadyExists(client, expected)) {
    return;
  }

  try {
    const results = await client.createTransfers([expected]);
    // Adapter omits gateway `persisted`; confirm by exact lookup before advancing.
    if (await transferAlreadyExists(client, expected)) {
      return;
    }
    if (results.length === 0) {
      throw new Error('Transfer write was not confirmed by lookup; do not advance or change the payload');
    }
    throw new Error(
      `createTransfers rejected input 0 with result ${results[0]?.result ?? 'unknown'}` +
        (results[0]?.result === CreateTransferError.exceeds_credits ? ' (exceeds_credits)' : ''),
    );
  } catch (error) {
    // The HTTP response can be lost after acceptance. Never replace the ID.
    if (await transferAlreadyExists(client, expected)) {
      return;
    }
    throw error;
  }
}

async function run(client: Client): Promise<void> {
  await ensureAccounts(client, [
    account({
      id: persisted.grantSourceAccountId,
      ledger: USAGE_LEDGER,
      code: ACCOUNT_CODE.grantSource,
      flags: AccountFlags.history,
      unitOrCurrency: 1,
    }),
    account({
      id: persisted.subscriberAllowanceAccountId,
      ledger: USAGE_LEDGER,
      code: ACCOUNT_CODE.subscriberAllowance,
      flags: AccountFlags.history | AccountFlags.debits_must_not_exceed_credits,
      ownerId: persisted.subscriptionId,
      periodId: persisted.periodId,
      unitOrCurrency: 1,
    }),
    account({
      id: persisted.usageSinkAccountId,
      ledger: USAGE_LEDGER,
      code: ACCOUNT_CODE.usageSink,
      flags: AccountFlags.history,
      unitOrCurrency: 1,
    }),
    account({
      id: persisted.collectionSourceAccountId,
      ledger: MONEY_LEDGER,
      code: ACCOUNT_CODE.collectionSource,
      flags: AccountFlags.history,
      unitOrCurrency: 840,
    }),
    account({
      id: persisted.prepaidBalanceAccountId,
      ledger: MONEY_LEDGER,
      code: ACCOUNT_CODE.prepaidBalance,
      flags: AccountFlags.history | AccountFlags.debits_must_not_exceed_credits,
      ownerId: persisted.subscriptionId,
      unitOrCurrency: 840,
    }),
    account({
      id: persisted.billingClearingAccountId,
      ledger: MONEY_LEDGER,
      code: ACCOUNT_CODE.billingClearing,
      flags: AccountFlags.history,
      unitOrCurrency: 840,
    }),
  ]);

  await createTransferWithLookup(
    client,
    transfer({
      id: persisted.grantTransferId,
      debitAccountId: persisted.grantSourceAccountId,
      creditAccountId: persisted.subscriberAllowanceAccountId,
      amount: 100_000n,
      ledger: USAGE_LEDGER,
      code: TRANSFER_CODE.grant,
      ownerId: persisted.subscriptionId,
      periodId: persisted.periodId,
      unitOrCurrency: 1,
    }),
  );

  // usageTransferId is stored with sourceEventId and its assigned period.
  await createTransferWithLookup(
    client,
    transfer({
      id: persisted.usageTransferId,
      debitAccountId: persisted.subscriberAllowanceAccountId,
      creditAccountId: persisted.usageSinkAccountId,
      amount: 1_250n,
      ledger: USAGE_LEDGER,
      code: TRANSFER_CODE.consume,
      ownerId: persisted.subscriptionId,
      periodId: persisted.periodId,
      unitOrCurrency: 1,
    }),
  );

  const invoiceInput = await client.queryTransfers({
    user_data_128: persisted.subscriptionId,
    user_data_64: persisted.periodId,
    user_data_32: 1,
    ledger: USAGE_LEDGER,
    code: TRANSFER_CODE.consume,
    timestamp_min: 0n,
    timestamp_max: 0n,
    limit: 100,
    flags: QueryFilterFlags.none,
  });

  // The invoice service persists invoiceInput IDs, price-book version, and the
  // resulting amount. It does not derive cents inside the usage ledger.
  const persistedInvoiceAmountCents = 4_900n;

  // This event is written only after providerEventId has been verified and
  // durably deduplicated by the provider webhook receiver.
  await createTransferWithLookup(
    client,
    transfer({
      id: persisted.fundsReceivedTransferId,
      debitAccountId: persisted.collectionSourceAccountId,
      creditAccountId: persisted.prepaidBalanceAccountId,
      amount: persistedInvoiceAmountCents,
      ledger: MONEY_LEDGER,
      code: TRANSFER_CODE.fundsReceived,
      ownerId: persisted.subscriptionId,
      unitOrCurrency: 840,
    }),
  );

  await createTransferWithLookup(
    client,
    transfer({
      id: persisted.invoiceChargeTransferId,
      debitAccountId: persisted.prepaidBalanceAccountId,
      creditAccountId: persisted.billingClearingAccountId,
      amount: persistedInvoiceAmountCents,
      ledger: MONEY_LEDGER,
      code: TRANSFER_CODE.invoiceCharge,
      ownerId: persisted.subscriptionId,
      periodId: persisted.periodId,
      unitOrCurrency: 840,
    }),
  );

  console.log({
    sourceEventId: persisted.sourceEventId,
    providerEventId: persisted.providerEventId,
    invoiceId: persisted.invoiceId,
    invoiceInput,
  });
}

const baseUrl = process.env.PARIX_BASE_URL;
const apiKey = process.env.PARIX_API_KEY;
const databaseId = process.env.PARIX_DATABASE_ID;
if (!baseUrl || !apiKey || !databaseId) {
  throw new Error('PARIX_BASE_URL, PARIX_API_KEY, and PARIX_DATABASE_ID are required');
}

const client = createClient({ baseUrl, apiKey, databaseId });
try {
  await run(client);
} finally {
  client.destroy();
}

All TigerBeetle-shaped wide fields use bigint; the adapter serializes them as decimal strings over HTTP. Successful creates return []. Conflicts are returned as indexed result arrays and must be checked. The lookup-first wrapper ensures that a scheduler retry, usage redelivery, or provider webhook replay uses the original persisted object.

If a timeout leaves neither a create result nor a conclusive lookup, keep the durable job pending and retry the same function later. Do not mark the invoice paid, grant a second allowance, or allocate another transfer ID merely because the response was lost.

Failure and retry handling

Signal or boundaryMeaningAction
Create returns []Adapter reported no item conflictsStill look up every stable ID and exact fields before advancing. The adapter does not expose gateway persisted.
Nonempty create resultIndexed account/transfer conflictsMatch every index to the original batch; classify each result. HTTP 409 + tbResults is unwrapped into this array. Lookup and compare immutable fields
Duplicate scheduler executionPeriod open/grant may already existLoad the period's original grant ID and lookup before resubmitting
Duplicate provider webhookProvider may redeliver a verified eventDeduplicate by provider event ID and reuse its money transfer ID
Usage exceeds_creditsSubscriber has insufficient allowanceApply the product's overage/grace policy; do not retry unchanged as infrastructure work
Money exceeds_creditsPrepaid balance cannot cover the chargeEnter the documented collection/dunning state; do not call it a transport failure
Validation 400Strict schema, range, or required-field errorFix the producer; preserve the rejected event for investigation
401/403Invalid credential, missing scope, or wrong database boundaryCorrect configuration before retrying
402Plan or billing eligibility blocks the operationResolve the returned plan condition before retrying
429Rate, admission, or configured quota reachedRetry only transient pressure with bounded jitter; wait for a renewable window or change capacity for a hard quota. Retain all IDs.
Timeout, reset, or 5xx/503Acceptance is uncertainLookup the original IDs first; retry only absent items with identical fields
Provider succeeds, ledger write is pendingCross-system saga is incompleteKeep a durable reconciliation state; never charge the provider again blindly
Invoice stored, provider failsCollection did not completeKeep invoice/payment attempts distinct; run dunning from provider state
Late usage after closeEvent falls under the chosen late policyAssign next period or post an explicit adjustment/revision; never rewrite history
Unlinked batch has mixed resultsIndependent items partially succeededReconcile by input index and ID; do not resend the whole batch

For a true multi-transfer ledger invariant, use a correctly formed linked chain with linked on every item except its final item. Linked flags do not make a provider API call, application database transaction, or a second Parix request part of the same atomic operation.

The current gateway can expose only the first ten create-conflict entries. In a failed larger batch, an input omitted from the conflict array is not proven successful. Resolve every stable ID by lookup or use smaller batches until every outcome is known.

Test scenarios

ScenarioSetup and actionExpected invariant and evidence
Period openRun the scheduler twice for one subscription/periodOne grant transfer exists and both runs resolve to the same period/grant ID
Normal usageGrant 100000, consume 1250Usage balance decreases once; the source event maps to one 2102 transfer
Concurrent usageRace requests near the allowance boundaryPosted debits never exceed available credits
Duplicate usage deliveryRedeliver the same event after success and after an ambiguous timeoutLookup resolves the original transfer; no second consumption appears
AdjustmentPost an approved increase/decrease with code 2103Original usage remains; reason, actor, target period, and new transfer are auditable
Usage refundPost code 2104 against a known original eventUnits return once; original consumption is not deleted
Period boundaryEvent time is before close but receipt is inside the lateness windowEvent is assigned according to the persisted policy version
Late event after final closeDeliver the same shape after the cutoffNext-period assignment or explicit revision occurs; no silent reassignment
Invoice input snapshotQuery 7300/2102, price it, then receive later usageExisting invoice retains its transfer-ID snapshot and price-book version
Ledger separationQuery usage and USD ledgers independentlyNo transfer mixes usage accounts with money accounts; usage is not labeled revenue
Provider replaySend the same signed provider webhook repeatedlyOne provider-event mapping and one 3101/3104 transfer exist
Forged provider eventSend an invalid signature/authentication valueReceiver rejects it before any money transfer or subscription-state change
Provider/ledger split failureSimulate provider success and gateway timeoutReconciliation lookup completes the existing money event without a second provider charge
Prepaid shortfallCharge more cents than availableMoney constraint rejects the charge and dunning state is entered once
Partial batchInclude one valid and one invalid unlinked itemResults reconcile by index; successful items are not duplicated
Numeric and size limitsUse 128-bit IDs and then submit 8,191 create itemsDecimal values round-trip exactly; the oversized request is split or rejected before submission
Shared queryOmit and then include ledger on DeveloperMissing-ledger query fails; scoped query returns only the selected domain

Run concurrency, webhook replay, ambiguous-response, and period-boundary tests against a disposable integration environment. Mock-only tests cannot prove ledger constraints or gateway outcome handling.

Production operations

  • Reconcile usage source events, period grants, ledger transfers, invoice input snapshots, invoice totals, provider objects/events, and money-ledger transfers. Every edge should be traceable in both directions.
  • Run the period scheduler from durable period state. Use a unique subscription/period constraint and one persisted grant ID so overlapping workers cannot grant twice.
  • Verify provider webhook authenticity against the raw request as required by that provider. Acknowledge only after durable deduplication and work acceptance.
  • Keep provider calls, invoice persistence, and ledger writes as an explicit saga/outbox with observable intermediate states and repair jobs. Do not hide split failures behind a generic retry counter.
  • Monitor database health, API status classes, rate/plan quotas, scheduler lag, ingestion lag, late-event counts, invoice close lag, webhook retries, dunning state, and reconciliation differences separately.
  • Log sanitized subscription, period, invoice, provider-event, source-event, database, ledger, transfer ID, and result index/code fields. Never log credentials, payment instruments, or unrestricted provider payloads.
  • Version plan rules, price books, tax inputs, rounding, code registries, and late-event policy. Retain the exact versions used for each invoice.
  • Keep batches at or below 8,190 items and below operational timeout limits. Preserve ordering until all result indexes have been resolved. Use linked chains only for well-defined same-request invariants.
  • Rotate database-scoped API keys through a controlled overlap/cutover. Separate production, staging, scheduler, usage, and webhook credentials where operationally possible.
  • Exercise backup/restore and incident procedures on plans that support them. Developer and Dedicated Single Node are not production plans; do not use them as a production fallback.
  • Use Parix CDC only when an eligible dedicated plan and runtime configuration fit a projection workflow. CDC consumers must still deduplicate events, and CDC does not replace the subscription/provider webhook boundary.