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
| Component | Owns | Required durable identity |
|---|---|---|
| Subscription database | Customer, plan/version, status, period boundaries, late-event policy, and invoice records | Subscription ID, billing-period ID, invoice ID |
| Period scheduler | Opening/closing periods and requesting one allowance grant per period | Scheduler job ID mapped to period/grant transfer ID |
| Usage ingestion service | Raw source event, integer quantity, event time, and period assignment | Source-event ID mapped to usage transfer ID |
| Pricing and invoicing | Price-book version, tiering, proration, discounts, tax, rounding, and invoice document | Invoice ID and immutable input snapshot |
| Payment provider | Payment method, authorization/capture, settlement, disputes, and provider-side refund | Provider event/object ID |
| Provider webhook receiver | Signature/authentication check, deduplication, and provider-event-to-money-transfer mapping | Provider event ID mapped to transfer ID |
| Parix API gateway | Authentication, database scope, strict validation, plan enforcement, and routing | Database UUID and request context |
| TigerBeetle ledger | Usage and money account balances, transfer history, constraints, and ID idempotency | Account 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:
- The scheduler persists a period and grant transfer ID before requesting the grant.
- The usage service assigns each event under the documented period/late-event policy and persists its transfer ID before writing.
- The invoice job queries confirmed usage, snapshots its inputs and price-book version, and creates the invoice outside the ledger.
- The payment service calls the provider. It never holds a ledger operation open while waiting on that external call.
- 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
| Account | ID in examples | Code | Balance rule | Purpose |
|---|---|---|---|---|
| Grant source | 81000000000000000001 | 1101 | Unconstrained | Supplies period allowance grants |
| Subscriber allowance | 81000000000000000002 | 1102 | Debits must not exceed credits | Receives the period grant and is debited by usage |
| Usage sink | 81000000000000000003 | 1103 | Unconstrained | Accumulates 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.
| Account | ID in examples | Code | Balance rule | Purpose |
|---|---|---|---|---|
| Collection source | 81000000000000000011 | 1201 | Unconstrained | Represents externally confirmed funds entering this model |
| Subscriber prepaid balance | 81000000000000000012 | 1202 | Debits must not exceed credits | Holds available USD cents |
| Billing clearing | 81000000000000000013 | 1203 | Unconstrained | Receives 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
| Ledger | Code | Name | Direction | Stable key |
|---|---|---|---|---|
Usage 7300 | 2101 | grant | Grant source to subscriber allowance | Subscription + period + grant version |
Usage 7300 | 2102 | consume | Subscriber allowance to usage sink | Source usage-event ID |
Usage 7300 | 2103 | adjust | Approved direction for the correction | Adjustment/case ID + target period |
Usage 7300 | 2104 | refund | Usage sink to subscriber allowance | Refund ID + original usage-event ID |
Money 840 | 3101 | funds_received | Collection source to prepaid balance | Verified provider event ID |
Money 840 | 3102 | invoice_charge | Prepaid balance to billing clearing | Invoice ID + charge version |
Money 840 | 3103 | adjust | Approved direction for the correction | Adjustment/case ID |
Money 840 | 3104 | refund | Billing clearing to prepaid balance | Provider 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:
| Record | Example | Ledger relationship |
|---|---|---|
| Subscription | sub_510001 | Resolves the subscriber's account IDs |
| Billing period | period_2026_08 | Maps to one stable 2101 grant transfer; optionally encoded in user_data_64 |
| Usage event | usage_evt_01J... | Maps one-to-one to a 2102 transfer ID |
| Invoice | inv_2026_08_510001 | Stores queried transfer IDs, price-book version, totals, and a stable 3102 ID |
| Provider event | evt_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
linkedchain 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
7300and money with840; 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
- Open the intended database and select Query.
- 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. - Choose Create accounts and create the grant source, subscriber allowance, and usage sink on ledger
7300. Then create the three money accounts on ledger840. - Choose Create transfers and grant the period allowance with code
2101. Submit a usage event with code2102only after its source-event mapping is durable. - Query transfers by ledger
7300, code2102, and the intended customer/period user-data values. Export or compare those results with the invoice input snapshot. - Query ledger
840separately 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.

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 --jsonThe 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 \
--jsonA 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 key202608user_data_32— usage unit enum1(not the ledger; ledger is already7300)
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 \
--jsonSave 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 \
--jsonThe 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 \
--jsonAfter 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 \
--jsonThe 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 \
--jsonA 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 \
--jsonThe 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 boundary | Meaning | Action |
|---|---|---|
Create returns [] | Adapter reported no item conflicts | Still look up every stable ID and exact fields before advancing. The adapter does not expose gateway persisted. |
| Nonempty create result | Indexed account/transfer conflicts | Match every index to the original batch; classify each result. HTTP 409 + tbResults is unwrapped into this array. Lookup and compare immutable fields |
| Duplicate scheduler execution | Period open/grant may already exist | Load the period's original grant ID and lookup before resubmitting |
| Duplicate provider webhook | Provider may redeliver a verified event | Deduplicate by provider event ID and reuse its money transfer ID |
Usage exceeds_credits | Subscriber has insufficient allowance | Apply the product's overage/grace policy; do not retry unchanged as infrastructure work |
Money exceeds_credits | Prepaid balance cannot cover the charge | Enter the documented collection/dunning state; do not call it a transport failure |
Validation 400 | Strict schema, range, or required-field error | Fix the producer; preserve the rejected event for investigation |
401/403 | Invalid credential, missing scope, or wrong database boundary | Correct configuration before retrying |
402 | Plan or billing eligibility blocks the operation | Resolve the returned plan condition before retrying |
429 | Rate, admission, or configured quota reached | Retry 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/503 | Acceptance is uncertain | Lookup the original IDs first; retry only absent items with identical fields |
| Provider succeeds, ledger write is pending | Cross-system saga is incomplete | Keep a durable reconciliation state; never charge the provider again blindly |
| Invoice stored, provider fails | Collection did not complete | Keep invoice/payment attempts distinct; run dunning from provider state |
| Late usage after close | Event falls under the chosen late policy | Assign next period or post an explicit adjustment/revision; never rewrite history |
| Unlinked batch has mixed results | Independent items partially succeeded | Reconcile 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
| Scenario | Setup and action | Expected invariant and evidence |
|---|---|---|
| Period open | Run the scheduler twice for one subscription/period | One grant transfer exists and both runs resolve to the same period/grant ID |
| Normal usage | Grant 100000, consume 1250 | Usage balance decreases once; the source event maps to one 2102 transfer |
| Concurrent usage | Race requests near the allowance boundary | Posted debits never exceed available credits |
| Duplicate usage delivery | Redeliver the same event after success and after an ambiguous timeout | Lookup resolves the original transfer; no second consumption appears |
| Adjustment | Post an approved increase/decrease with code 2103 | Original usage remains; reason, actor, target period, and new transfer are auditable |
| Usage refund | Post code 2104 against a known original event | Units return once; original consumption is not deleted |
| Period boundary | Event time is before close but receipt is inside the lateness window | Event is assigned according to the persisted policy version |
| Late event after final close | Deliver the same shape after the cutoff | Next-period assignment or explicit revision occurs; no silent reassignment |
| Invoice input snapshot | Query 7300/2102, price it, then receive later usage | Existing invoice retains its transfer-ID snapshot and price-book version |
| Ledger separation | Query usage and USD ledgers independently | No transfer mixes usage accounts with money accounts; usage is not labeled revenue |
| Provider replay | Send the same signed provider webhook repeatedly | One provider-event mapping and one 3101/3104 transfer exist |
| Forged provider event | Send an invalid signature/authentication value | Receiver rejects it before any money transfer or subscription-state change |
| Provider/ledger split failure | Simulate provider success and gateway timeout | Reconciliation lookup completes the existing money event without a second provider charge |
| Prepaid shortfall | Charge more cents than available | Money constraint rejects the charge and dunning state is entered once |
| Partial batch | Include one valid and one invalid unlinked item | Results reconcile by index; successful items are not duplicated |
| Numeric and size limits | Use 128-bit IDs and then submit 8,191 create items | Decimal values round-trip exactly; the oversized request is split or rejected before submission |
| Shared query | Omit and then include ledger on Developer | Missing-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.