Coupon and Rewards System
Implement funded reward campaigns, single-use coupons, atomic redemption, expiry, reversal, and reconciliation on Parix.
Overview
Use Parix as the entitlement ledger behind a coupon and rewards system when customers earn points or promotional value that must be issued, redeemed, expired, and reversed exactly once.
This guide models two distinct non-cash units:
- rewards points in ledger
7201; and - fixed-value promotional cents in ledger
7202.
Keeping them separate prevents an application from treating one point as one cent. A funded campaign pool limits how much value can be issued, constrained customer or coupon accounts prevent aggregate overdraft, and the application's unique grant claim prevents multiple use of a single-use coupon.
The application still owns campaign rules: coupon codes, eligibility, stacking, tiers, earning formulas, validity windows, per-customer limits, fraud decisions, and checkout claims. Parix owns the durable movement of the resulting integer units.
Architecture and ownership
| Component | Owns | Does not own |
|---|---|---|
| Rewards application | Campaigns, members, coupon-code hashes, eligibility, earning formulas, expiry policy, stacking, claims, and stable IDs | Authoritative posted entitlement balances |
| Parix | Campaign pools, member/coupon balances, immutable movements, balance constraints, and linked redemption atomicity | Coupon lookup, checkout price calculation, or messaging |
| Checkout or order service | Basket, merchandise eligibility, order total, tax, tender, and the durable redemption claim | Independently mutable reward or coupon balances |
| Scheduler or workflow | Expiry scans, award delivery, reversal workflows, and retry state | Changing a committed transfer in place |
| Finance and reconciliation | Promotional liability, campaign funding, break review, and approved adjustments | Silent deletion of issuance or redemption history |
A Parix write and an order-database write are not one distributed transaction. Use a durable claim and outbox/inbox workflow keyed by the same redemption ID. If the checkout and entitlement ledgers are in the same Parix database, linked transfers can make their Parix legs atomic, but they still cannot atomically commit an unrelated SQL order row.
Ledger model
Ledgers
| Ledger | Unit | Purpose |
|---|---|---|
7201 | Rewards points | Campaign budgets, member expiry buckets, redemption, expiry, and reversal |
7202 | Promotional cents | Fixed-value coupon grants, redemption, expiry, and reversal |
Promotional cents are a discount entitlement, not settled cash. Do not transfer them into a USD cash ledger or report their account balance as customer money. A percentage coupon also needs application-owned price calculation and capping; the example covers a fixed-value coupon whose issued value is known in advance. If the product needs only a one-use token rather than a value balance, use a separate unit-1 ledger or keep that count exclusively in the application database.
On a shared Developer database, each new external ledger can allocate a project-ledger mapping and consume quota. Plan both ledgers before the first query or write.
Accounts
history is flag 8. debits_must_not_exceed_credits is flag 2. Accounts that hold spendable or reversible units use both flags (10) so concurrent redemptions cannot overdraw them.
| Role | Example ID | Ledger | Code | Flags | Balance meaning |
|---|---|---|---|---|---|
| Reward program source | 910000000000000001 | 7201 | 100 | 8 | Reviewed source for campaign funding |
| Reward campaign pool | 910000000000000002 | 7201 | 110 | 10 | Remaining funded points available to award |
| Member reward expiry bucket | 910000000000000003 | 7201 | 120 | 10 | One member's points for one expiry policy or campaign |
| Reward redemption clearing | 910000000000000004 | 7201 | 130 | 10 | Aggregate redeemed points that can fund approved reversals |
| Reward expiry sink | 910000000000000005 | 7201 | 140 | 10 | Aggregate expired points that can fund approved reinstatements |
| Coupon program source | 910000000000000006 | 7202 | 200 | 8 | Reviewed source for promotional campaign funding |
| Coupon campaign pool | 910000000000000007 | 7202 | 210 | 10 | Remaining funded promotional cents |
| Issued coupon grant | 910000000000000008 | 7202 | 220 | 10 | One fixed-value grant; the balance is its unused value |
| Coupon redemption clearing | 910000000000000009 | 7202 | 230 | 10 | Aggregate redeemed coupon value that can fund reversals |
| Coupon expiry sink | 910000000000000010 | 7202 | 240 | 10 | Aggregate expired value that can fund approved reinstatements |
Use one reward account per member and expiry bucket when points expire on different schedules. If points never expire, one member account per rewards program may be enough. Store the bucket-to-expiry mapping in the application database.
Use one constrained grant account for each fixed-value coupon issuance. The balance constraint prevents aggregate overdraft, but it does not make the grant single-use: two different transfer IDs can each redeem part of the balance. For the single-use model in this guide, the application must atomically insert one unique claim keyed by grant ID before the ledger write, and the approved redemption must consume the whole grant. Reject an application that would leave a partial balance. If unused value should remain spendable, model the product as a multi-use promotional balance instead; if it should be forfeited, define and reconcile an explicit breakage destination and linked remainder leg before adapting this design. The application database maps the grant account to a salted coupon-code hash, campaign, member, validity, eligibility, and claim status. Never put the redeemable coupon secret or customer PII in a TigerBeetle ID or user-data field.
The unconstrained program sources are modeling boundaries. Production funding must be reviewed, limited by application policy, and reconciled to the approved campaign budget.
For each ledger, reconcile program-source issuance to the sum of remaining campaign pools, outstanding member/grant value, redeemed value, and expired value. Reward points or promotional cents are not automatically an accounting liability: finance must decide whether the corresponding money-denominated liability, contra-revenue, or marketing expense belongs in a separate accounting system.
Transfer codes
| Code | Event | Debit | Credit |
|---|---|---|---|
| 10 | Fund reward campaign | Reward program source | Reward campaign pool |
| 11 | Fund coupon campaign | Coupon program source | Coupon campaign pool |
| 100 | Award points | Reward campaign pool | Member expiry bucket |
| 110 | Redeem points | Member expiry bucket | Reward redemption clearing |
| 120 | Expire points | Member expiry bucket | Reward expiry sink |
| 130 | Reverse points award | Member expiry bucket | Reward campaign pool |
| 140 | Reverse points redemption | Reward redemption clearing | Member expiry bucket |
| 150 | Reinstate expired points | Reward expiry sink | Governed replacement bucket |
| 200 | Issue fixed-value coupon | Coupon campaign pool | Issued coupon grant |
| 210 | Redeem coupon | Issued coupon grant | Coupon redemption clearing |
| 220 | Expire coupon | Issued coupon grant | Coupon expiry sink |
| 230 | Cancel unredeemed coupon | Issued coupon grant | Coupon campaign pool |
| 240 | Reverse coupon redemption | Coupon redemption clearing | Governed replacement grant |
| 250 | Reinstate expired coupon | Coupon expiry sink | Governed replacement grant |
| 900 | Reviewed adjustment | Reviewed source account | Reviewed destination account |
Every reversal or reinstatement needs an application record with a unique constraint on the original transfer ID. Before allocating the compensating transfer ID, validate the original event, exact reversible amount, approved destination, and policy version. The clearing and expiry accounts constrain only their aggregate balances; they cannot stop two different reversal IDs from compensating the same original event. Route a reversed single-use coupon into a newly governed replacement grant; the original grant's unique claim remains permanent. Likewise, reinstate expired value into a newly governed bucket or grant with an explicit validity policy rather than silently reopening the expired account.
Optional user_data_* fields are secondary query indexes, not required on every transfer. When this product needs to query awards and redemptions by external business identity, use user_data_128 as the opaque “who/what” (campaign, grant, purchase, or claim). Keep the full business record and PII outside TigerBeetle. Leave unused user_data_* fields at zero and omit the corresponding CLI flags.
For percentage coupons, calculate the approved discount with integer minor units and persist the exact rounded result before the write. For example:
const discountMinor = (eligibleSubtotalMinor * BigInt(rateBasisPoints)) / 10_000n;Persist the basis-point rate, cap, eligible subtotal, rounding rule, rule version, and resulting promotional amount. Never convert a wide integer amount through JavaScript Number.
Expiry is a posted business event
Do not use a pending transfer timeout as the expiry mechanism for posted points or coupons. A pending timeout releases a reservation that has not been posted; it does not move an existing posted balance into an expiry sink.
Before submitting expiry, atomically make the bucket or grant non-redeemable in the application, drain or resolve in-flight claims, and persist the exact expiry transfer. Then the scheduler must:
- lock or claim the expiry job by its stable business ID;
- check whether the bucket or coupon was already redeemed, cancelled, or expired;
- determine the remaining posted balance;
- submit a stable transfer from the constrained grant account to the appropriate expiry sink; and
- reconcile the result before marking the application job complete.
If redeem and expire race, the account constraint serializes the available balance. One can consume the value; the other receives an insufficient-balance result and must reconcile the winning transfer.
Invariants
| Invariant | Enforcement |
|---|---|
| Campaign issuance is bounded | Fund a constrained campaign pool and award or issue only from that pool |
| A member cannot redeem more points than held | Apply debits_must_not_exceed_credits to each member bucket |
| A single-use coupon cannot be consumed twice | Insert one unique application claim keyed by grant ID, then consume or close the whole grant in one linked chain |
| Combined redemption is all-or-nothing | Put every allowed reward/coupon leg in one ordered batch; set linked on every non-final leg |
| Expiry is explainable | Post a new stable transfer to an expiry sink; never mutate an award or issuance |
| Reversal cannot create units silently | Uniquely map the original event to one validated compensation and debit a constrained clearing or expiry account |
| Eligibility remains authoritative | Validate campaign, member, product, time, stacking, and usage limits in the application before ledger submission |
| Duplicate events cannot duplicate value | Persist IDs before submission and reconcile exact immutable account/transfer fields before retry |
Before you begin
You need:
- a Parix database in Ready state and its immutable database UUID;
- approved reward and promotional units, ledger IDs, account codes, transfer codes, and account-ID mapping;
- durable campaign, coupon-grant, member-bucket, purchase, redemption-claim, and expiry-job records;
- stable account and transfer IDs allocated before their first write;
- an explicit earning, rounding, stacking, validity, expiry, cancellation, and reversal policy;
- an OAuth session for CLI work and a specific-database API key for the application; and
- reconciliation ownership for campaign budgets, outstanding liability, redemption claims, and adjustments.
Developer is a shared, quota-limited environment for learning and integration testing. Dedicated Single Node is isolated but non-HA. Use Production HA, Production 6, or contract-defined Enterprise placement for production workloads.
The public schema accepts at most 8,190 records in one account, transfer, or lookup array, while the active plan may impose a lower events-per-request limit. Keep an entire linked redemption inside one request.
Dashboard walkthrough
- Open the intended organization and database. Confirm Ready, plan, database UUID, and effective event quotas.
- Select Connect and generate a Specific database API key for the rewards service. Store the one-time secret in a server-side secret manager.
- Open Query and select Query accounts. Run a bounded query for ledger
7201, then7202. - Verify campaign-pool, member-bucket, and coupon-grant codes against the application mapping.
- Use Create accounts only in the intended development database. Review every account ID, ledger, code, and flag before selecting Run.
- After a test issuance or redemption, use Lookup transfers with the stable transfer IDs rather than inferring success only from a cached application status.

The form writes live accounts to the selected database. It does not create campaign, coupon-code, eligibility, or expiry records in your application database.
Live-write warning:
create_accountsandcreate_transferswrite immediately. Verify the selected environment, database UUID, stable IDs, ledgers, codes, flags, amounts, and linked order before selecting Run.
CLI walkthrough
The examples use the latest published @parix/cli package. The CLI uses browser OAuth and the active organization; production services must use a server-side API key instead of a copied CLI session.
npm install --global @parix/cli@latest
parix --version
parix auth login
parix auth status
parix database list --json
export PARIX_DATABASE_ID="db_replace_with_uuid"Create rewards-accounts.json as a bare JSON array.
[
{ "id": "910000000000000001", "ledger": 7201, "code": 100, "flags": 8 },
{ "id": "910000000000000002", "ledger": 7201, "code": 110, "flags": 10 },
{ "id": "910000000000000003", "ledger": 7201, "code": 120, "flags": 10 },
{ "id": "910000000000000004", "ledger": 7201, "code": 130, "flags": 10 },
{ "id": "910000000000000005", "ledger": 7201, "code": 140, "flags": 10 },
{ "id": "910000000000000006", "ledger": 7202, "code": 200, "flags": 8 },
{ "id": "910000000000000007", "ledger": 7202, "code": 210, "flags": 10 },
{ "id": "910000000000000008", "ledger": 7202, "code": 220, "flags": 10 },
{ "id": "910000000000000009", "ledger": 7202, "code": 230, "flags": 10 },
{ "id": "910000000000000010", "ledger": 7202, "code": 240, "flags": 10 }
]Submit and inspect the response:
parix tb create-accounts "$PARIX_DATABASE_ID" --file ./rewards-accounts.json --jsonFund the two constrained campaign pools. These are controlled test-funding events; production funding must come from a reviewed campaign-budget workflow.
parix tb create-transfers "$PARIX_DATABASE_ID" \
--id 920000000000000001 \
--from 910000000000000001 \
--to 910000000000000002 \
--amount 100000 \
--ledger 7201 \
--code 10 \
--json
parix tb create-transfers "$PARIX_DATABASE_ID" \
--id 920000000000000002 \
--from 910000000000000006 \
--to 910000000000000007 \
--amount 50000 \
--ledger 7202 \
--code 11 \
--jsonAward 500 points and issue one 2500-promotional-cent coupon. The application has already persisted campaign and grant records and maps transfer IDs 920000000000000003 and 920000000000000004 to correlations 930000000000000001 and 930000000000000002. Pass only the non-zero correlations you will query: here user_data_128 is the grant “what”, and user_data_32 is a small convention version shared with the Node sample. Omit --user-data-64 (zero is the default and is not a queryable filter). Use a reviewed JSON file for linked or multi-item batches.
parix tb create-transfers "$PARIX_DATABASE_ID" \
--id 920000000000000003 \
--from 910000000000000002 \
--to 910000000000000003 \
--amount 500 \
--ledger 7201 \
--code 100 \
--user-data-128 930000000000000001 \
--user-data-32 1 \
--json
parix tb create-transfers "$PARIX_DATABASE_ID" \
--id 920000000000000004 \
--from 910000000000000007 \
--to 910000000000000008 \
--amount 2500 \
--ledger 7202 \
--code 200 \
--user-data-128 930000000000000002 \
--user-data-32 1 \
--jsonAssume application policy allows the customer to stack 200 points with the fixed-value coupon on purchase 930000000000000003. This tutorial treats the coupon as single-use and consumes its entire 2500-cent grant. Before submission, the application atomically inserts a unique claim keyed by grant account 910000000000000008; the stable transfer ID deduplicates only this ledger event and does not replace that uniqueness constraint. Create stacked-redemption.json. The first leg uses linked (1); the final leg closes the chain with flags 0.
[
{
"id": "920000000000000005",
"debit_account_id": "910000000000000003",
"credit_account_id": "910000000000000004",
"amount": "200",
"user_data_128": "930000000000000003",
"user_data_64": "930000000000000001",
"user_data_32": 1,
"ledger": 7201,
"code": 110,
"flags": 1
},
{
"id": "920000000000000006",
"debit_account_id": "910000000000000008",
"credit_account_id": "910000000000000009",
"amount": "2500",
"user_data_128": "930000000000000003",
"user_data_64": "930000000000000002",
"user_data_32": 1,
"ledger": 7202,
"code": 210,
"flags": 0
}
]Here non-zero user_data_* values are intentional query keys: user_data_128 groups the purchase redemption (“what”), user_data_64 points at the points grant or coupon grant being redeemed, and user_data_32 versions the correlation convention. They are application-defined non-secret integers, not a replacement for the transfer id.
Submit and reconcile both entitlements:
parix tb create-transfers "$PARIX_DATABASE_ID" --file ./stacked-redemption.json --json
parix tb lookup-transfers "$PARIX_DATABASE_ID" --ids 920000000000000005,920000000000000006 --json
parix tb query-accounts "$PARIX_DATABASE_ID" --ledger 7201 --limit 20 --json
parix tb query-accounts "$PARIX_DATABASE_ID" --ledger 7202 --limit 20 --jsonA successful create has persisted: true and an empty responsePayload. An item conflict is returned as HTTP 409 with tbResults. The gateway currently exposes at most the first ten conflict entries, so lookup every stable ID after any non-empty result.
The CLI's --json output is terminal-logger decorated rather than guaranteed clean stdout. Use the raw HTTPS API or an application adapter for machine-readable automation.
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.
The example below performs lookup-first and post-write reconciliation for accounts and transfers. It accepts an already authorized campaign award, coupon issuance, and stacked redemption whose stable IDs were persisted before the first call. The post-write lookup is required because the adapter returns only the response payload and does not expose the gateway envelope's persisted field.
import type {
Account,
CreateAccountResult,
CreateTransferResult,
Transfer,
} from '@parix/tigerbeetle-node';
import { AccountFlags, CreateTransferError, TransferFlags, createClient } from '@parix/tigerbeetle-node';
const client = createClient({
baseUrl: process.env.PARIX_BASE_URL ?? 'https://parix.io',
apiKey: mustGetEnv('PARIX_API_KEY'),
databaseId: mustGetEnv('PARIX_DATABASE_ID'),
});
const ledger = { points: 7201, promotions: 7202 } as const;
const accountId = {
rewardSource: 910000000000000001n,
rewardPool: 910000000000000002n,
memberBucket: 910000000000000003n,
rewardRedemption: 910000000000000004n,
rewardExpiry: 910000000000000005n,
couponSource: 910000000000000006n,
couponPool: 910000000000000007n,
couponGrant: 910000000000000008n,
couponRedemption: 910000000000000009n,
couponExpiry: 910000000000000010n,
} as const;
function account(id: bigint, ledgerId: number, code: number, flags: number): Account {
return {
id,
debits_pending: 0n,
debits_posted: 0n,
credits_pending: 0n,
credits_posted: 0n,
user_data_128: 0n,
user_data_64: 0n,
user_data_32: 0,
reserved: 0,
ledger: ledgerId,
code,
flags,
timestamp: 0n,
};
}
function transfer(input: {
id: bigint;
debitAccountId: bigint;
creditAccountId: bigint;
amount: bigint;
ledger: number;
code: number;
flags?: number;
correlation128?: bigint;
correlation64?: bigint;
conventionVersion?: 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.correlation128 ?? 0n,
user_data_64: input.correlation64 ?? 0n,
user_data_32: input.conventionVersion ?? 0,
timeout: 0,
ledger: input.ledger,
code: input.code,
flags: input.flags ?? TransferFlags.none,
timestamp: 0n,
};
}
function getHttpStatus(cause: unknown): number | undefined {
if (!cause || typeof cause !== 'object' || !('status' in cause)) return undefined;
const status = (cause as { status?: unknown }).status;
return typeof status === 'number' ? status : undefined;
}
function wasDefinitelyRejected(cause: unknown): boolean {
const status = getHttpStatus(cause);
return status !== undefined && [400, 401, 402, 403, 404, 409, 429].includes(status);
}
function sameAccount(actual: Account, intended: Account): boolean {
return (
actual.id === intended.id &&
actual.user_data_128 === intended.user_data_128 &&
actual.user_data_64 === intended.user_data_64 &&
actual.user_data_32 === intended.user_data_32 &&
actual.reserved === intended.reserved &&
actual.ledger === intended.ledger &&
actual.code === intended.code &&
actual.flags === intended.flags
);
}
function sameTransfer(actual: Transfer, intended: Transfer): boolean {
return (
actual.id === intended.id &&
actual.debit_account_id === intended.debit_account_id &&
actual.credit_account_id === intended.credit_account_id &&
actual.amount === intended.amount &&
actual.pending_id === intended.pending_id &&
actual.user_data_128 === intended.user_data_128 &&
actual.user_data_64 === intended.user_data_64 &&
actual.user_data_32 === intended.user_data_32 &&
actual.timeout === intended.timeout &&
actual.ledger === intended.ledger &&
actual.code === intended.code &&
actual.flags === intended.flags
);
}
async function accountsExistExactly(batch: Account[]): Promise<boolean> {
const found = await client.lookupAccounts(batch.map(({ id }) => id));
if (found.length === 0) return false;
const intendedById = new Map(batch.map((item) => [item.id, item]));
if (
found.length !== batch.length ||
!found.every((item) => {
const intended = intendedById.get(item.id);
return intended !== undefined && sameAccount(item, intended);
})
) {
throw new Error('Reward account IDs are partially present or have different immutable fields');
}
return true;
}
async function transfersExistExactly(label: string, batch: Transfer[]): Promise<boolean> {
const found = await client.lookupTransfers(batch.map(({ id }) => id));
if (found.length === 0) return false;
const intendedById = new Map(batch.map((item) => [item.id, item]));
if (
found.length !== batch.length ||
!found.every((item) => {
const intended = intendedById.get(item.id);
return intended !== undefined && sameTransfer(item, intended);
})
) {
throw new Error(`${label} IDs are partially present or have different immutable fields`);
}
return true;
}
async function createAccountsOrReconcile(batch: Account[]): Promise<void> {
if (await accountsExistExactly(batch)) return;
let results: CreateAccountResult[] | CreateTransferResult[];
try {
results = await client.createAccounts(batch);
} catch (cause) {
if (wasDefinitelyRejected(cause)) throw cause;
if (await accountsExistExactly(batch)) return;
throw new Error('Account outcome is ambiguous; retry only with the same IDs and fields', { cause });
}
if (await accountsExistExactly(batch)) 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 rejected and lookup found no exact batch: ${JSON.stringify(results)}`,
);
}
async function createTransfersOrReconcile(label: string, batch: Transfer[]): Promise<void> {
if (await transfersExistExactly(label, batch)) return;
let results: CreateAccountResult[] | CreateTransferResult[];
try {
results = await client.createTransfers(batch);
} catch (cause) {
if (wasDefinitelyRejected(cause)) throw cause;
if (await transfersExistExactly(label, batch)) return;
throw new Error(`${label} outcome is ambiguous; retry only with the same IDs and fields`, { cause });
}
if (await transfersExistExactly(label, batch)) return;
if (results.length === 0) {
throw new Error(`${label} was not confirmed by lookup; do not advance or change the payload`);
}
throw new Error(
`${label} rejected${results.some((item) => item.result === CreateTransferError.exceeds_credits) ? ' (capacity)' : ''}` +
` and lookup found no exact batch: ${JSON.stringify(results)}`,
);
}
async function main(): Promise<void> {
const constrainedHistory = AccountFlags.history | AccountFlags.debits_must_not_exceed_credits;
await createAccountsOrReconcile([
account(accountId.rewardSource, ledger.points, 100, AccountFlags.history),
account(accountId.rewardPool, ledger.points, 110, constrainedHistory),
account(accountId.memberBucket, ledger.points, 120, constrainedHistory),
account(accountId.rewardRedemption, ledger.points, 130, constrainedHistory),
account(accountId.rewardExpiry, ledger.points, 140, constrainedHistory),
account(accountId.couponSource, ledger.promotions, 200, AccountFlags.history),
account(accountId.couponPool, ledger.promotions, 210, constrainedHistory),
account(accountId.couponGrant, ledger.promotions, 220, constrainedHistory),
account(accountId.couponRedemption, ledger.promotions, 230, constrainedHistory),
account(accountId.couponExpiry, ledger.promotions, 240, constrainedHistory),
]);
await createTransfersOrReconcile('fund reward campaign', [
transfer({
id: 920000000000000001n,
debitAccountId: accountId.rewardSource,
creditAccountId: accountId.rewardPool,
amount: 100000n,
ledger: ledger.points,
code: 10,
}),
]);
await createTransfersOrReconcile('fund coupon campaign', [
transfer({
id: 920000000000000002n,
debitAccountId: accountId.couponSource,
creditAccountId: accountId.couponPool,
amount: 50000n,
ledger: ledger.promotions,
code: 11,
}),
]);
await createTransfersOrReconcile('award points', [
transfer({
id: 920000000000000003n,
debitAccountId: accountId.rewardPool,
creditAccountId: accountId.memberBucket,
amount: 500n,
ledger: ledger.points,
code: 100,
correlation128: 930000000000000001n,
conventionVersion: 1,
}),
]);
await createTransfersOrReconcile('issue coupon', [
transfer({
id: 920000000000000004n,
debitAccountId: accountId.couponPool,
creditAccountId: accountId.couponGrant,
amount: 2500n,
ledger: ledger.promotions,
code: 200,
correlation128: 930000000000000002n,
conventionVersion: 1,
}),
]);
await createTransfersOrReconcile('stacked purchase redemption', [
transfer({
id: 920000000000000005n,
debitAccountId: accountId.memberBucket,
creditAccountId: accountId.rewardRedemption,
amount: 200n,
ledger: ledger.points,
code: 110,
flags: TransferFlags.linked,
correlation128: 930000000000000003n,
correlation64: 930000000000000001n,
conventionVersion: 1,
}),
transfer({
id: 920000000000000006n,
debitAccountId: accountId.couponGrant,
creditAccountId: accountId.couponRedemption,
amount: 2500n,
ledger: ledger.promotions,
code: 210,
flags: TransferFlags.none,
correlation128: 930000000000000003n,
correlation64: 930000000000000002n,
conventionVersion: 1,
}),
]);
}
function mustGetEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
}
try {
await main();
} finally {
client.destroy();
}Before calling this ledger workflow, the application must atomically claim the purchase redemption in its own database, including a unique grant-ID claim for each single-use coupon. On an ambiguous application-database outcome, query that claim by the stable purchase ID before compensating or retrying. Advance the workflow only after the exact post-write lookup succeeds.
Failure and retry handling
| Signal or condition | Meaning | Action |
|---|---|---|
Empty create result [] | Adapter reported no item conflicts; persisted is not exposed | Lookup every submitted ID and exact immutable field; advance only after the exact batch is found |
| Nonempty create result | One or more items were rejected | HTTP 409 + tbResults is unwrapped into this array. Lookup every submitted ID; do not infer outcomes from only the first ten conflict entries |
| Campaign pool insufficient | Award or issuance exceeds funded remaining units | Reject or pause campaign issuance; fund only through an approved budget event |
| Member or coupon balance insufficient | Value was redeemed, expired, cancelled, or never issued | Reconcile competing stable IDs; do not retry unchanged |
| Redeem and expire race | Two workflows attempted to consume the same constrained value | Accept the committed winner and mark the loser from lookup evidence |
| Eligibility or stacking fails | Application policy disallows the claim | Do not submit a ledger write |
| Checkout definitively rejects after redemption | Entitlement committed but order did not | Uniquely map each original transfer to one validated, preallocated compensation before submission |
| Different reversal ID for one original event | The application attempted a second compensation | Reject through the original-transfer uniqueness constraint; never rely on aggregate clearing alone |
| Checkout outcome ambiguous | The order may have committed | Query the durable order/claim before any compensation |
| Award reversal exceeds unspent points | The member spent some or all awarded value | Do not disable the balance constraint; apply the documented clawback or future-earn policy |
HTTP 400 | Strict schema, ledger, flag, code, or field validation failed | Fix the request; do not retry unchanged |
HTTP 401, 402, 403, or 404 | Credential, billing, scope, environment, or database is wrong | Stop and correct configuration |
HTTP 429 | Transient admission pressure or durable quota exhaustion | Back off only for transient limits; wait for reset or change workload/plan for durable exhaustion |
Timeout, disconnect, HTTP 500, or 503 | Ledger outcome may be ambiguous | Lookup all stable IDs and exact immutable fields; retry the same absent payload only |
The Node adapter returns item conflicts as result arrays rather than transport exceptions. Keep result reconciliation outside the catch path. Treat [] without an exact lookup match as unconfirmed, including when a development gateway is in non-persistent stub mode.
Test scenarios
Use an isolated non-production database and application fixture store.
| Scenario | Setup/action | Expected result |
|---|---|---|
| Account bootstrap | Create program, pool, member/grant, clearing, and expiry accounts | Empty result; exact retry does not create another account |
| Fund reward campaign | Move 100000 points from source to constrained pool | Pool has funded issuance capacity |
| Award points | Award 500 points with a stable source-event ID | Member bucket increases once |
| Duplicate award delivery | Redeliver the same ID and immutable payload | Only one award exists |
| Campaign exhausted | Award more points than the pool holds | Transfer rejects; member receives nothing |
| Issue coupon | Move 2500 promotional cents into a new constrained grant | One grant contains the exact fixed value |
| Duplicate coupon issuance | Reuse the issuance ID and payload | One coupon value exists |
| Stacked redemption | Redeem points and coupon in one linked chain | Both legs commit or neither commits |
| Coupon double redemption | Submit a second redemption against the fully consumed grant | Constraint rejects it; clearing receives no second credit |
| Partial single-use request | Attempt to redeem less than the grant's full value | Application rejects before Parix; the grant remains unchanged |
| Distinct-ID single-use race | Submit two full-value claims with different redemption IDs | Unique grant claim admits one; its ledger leg leaves the grant at zero |
| Coupon eligibility failure | Use coupon on a disallowed product | Application rejects before Parix |
| Redeem/expire race | Concurrently redeem and expire one coupon grant | At most one consumes the value; loser reconciles the winner |
| Points expiry bucket | Expire only the remaining balance of one due bucket | Due value moves to expiry sink; other buckets remain unchanged |
| Redemption reversal | Definitively reject checkout after a committed redemption | Stable reversal restores an approved reward bucket or new governed coupon grant |
| Different-ID reversal retry | Allocate a second reversal ID for the same original transfer | Unique original-to-reversal record rejects it before any ledger write |
| Expiry reinstatement | Approve reversal of one points or coupon expiry | Code 150 or 250 restores the exact amount into one governed replacement |
| Reinstatement retry | Restart after reinstatement commits | Exact stable-ID lookup finds one compensation; no second value is created |
| Ambiguous checkout outcome | Commit order but drop the application response | Claim lookup prevents an incorrect reversal |
| Partial award clawback | Spend part of an award, then request a full award reversal | Constraint blocks over-clawback; documented policy handles the shortfall |
| ID collision | Reuse an ID with a different amount, campaign, account, or flags | Exact-field reconciliation rejects the collision |
| Shared query without ledger | Query Developer without a ledger | Rejected; queries for 7201 and 7202 succeed |
| Expiry retry after restart | Stop after expiry commit but before application acknowledgment | Stable-ID lookup finds the exact expiry; no second movement |
| Non-persistent stub response | Gateway returns an empty payload without persisting the batch | Post-write lookup stays empty; workflow does not advance |
| Environment isolation | Run sample IDs with test credentials | Only the test database changes |
Production operations
- Keep coupon codes as salted hashes and campaign/member/eligibility data in the application database. Do not expose secrets or PII through ledger IDs or metadata.
- Reconcile funded campaign pools, issued points/coupons, outstanding member and grant balances, redemption clearing, expiry sinks, reversals, and application claims.
- Monitor pool runway, issuance and redemption velocity, expiry backlog, duplicate-event conflicts, insufficient-balance races, ambiguous outcomes, and adjustment volume.
- Partition member rewards into explicit expiry buckets when campaign dates differ. Keep a durable mapping from each bucket to its policy and scheduler job.
- Treat redemption clearing and expiry sinks as aggregate operational positions, not garbage accounts. Their balances prove consumed and expired value and bound total reversals, while the application uniquely authorizes each original-to-compensation relationship.
- Require reviewed operator workflows for campaign funding, manual awards, expiry reversal, coupon replacement, and adjustments.
- Use distinct databases, API keys, ID namespaces, campaign sources, alerts, and reconciliation jobs for test and production.
- Run production workloads on Production HA, Production 6, or a contract-defined Enterprise plan. Developer and Dedicated Single Node are non-production.
- Batch below both the public 8,190-item maximum and the active plan limit. Keep each linked redemption chain in one request.
- Practice checkout outages, redeem/expire races, scheduler replay, credential rotation, ambiguous-write reconciliation, and campaign-pool exhaustion before launch.
Related documentation
Currency Exchange
Implement atomic currency conversion, explicit fees, liquidity controls, quote replay, reconciliation, and FX exposure monitoring on Parix.
AI Credits
Build model-weighted prepaid credits and weekly plus rolling-window limits with period rollover and rolling-hour restoration on Parix.