Skip to main content
PARIXDocs

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

ComponentOwnsDoes not own
Rewards applicationCampaigns, members, coupon-code hashes, eligibility, earning formulas, expiry policy, stacking, claims, and stable IDsAuthoritative posted entitlement balances
ParixCampaign pools, member/coupon balances, immutable movements, balance constraints, and linked redemption atomicityCoupon lookup, checkout price calculation, or messaging
Checkout or order serviceBasket, merchandise eligibility, order total, tax, tender, and the durable redemption claimIndependently mutable reward or coupon balances
Scheduler or workflowExpiry scans, award delivery, reversal workflows, and retry stateChanging a committed transfer in place
Finance and reconciliationPromotional liability, campaign funding, break review, and approved adjustmentsSilent 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

LedgerUnitPurpose
7201Rewards pointsCampaign budgets, member expiry buckets, redemption, expiry, and reversal
7202Promotional centsFixed-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.

RoleExample IDLedgerCodeFlagsBalance meaning
Reward program source91000000000000000172011008Reviewed source for campaign funding
Reward campaign pool910000000000000002720111010Remaining funded points available to award
Member reward expiry bucket910000000000000003720112010One member's points for one expiry policy or campaign
Reward redemption clearing910000000000000004720113010Aggregate redeemed points that can fund approved reversals
Reward expiry sink910000000000000005720114010Aggregate expired points that can fund approved reinstatements
Coupon program source91000000000000000672022008Reviewed source for promotional campaign funding
Coupon campaign pool910000000000000007720221010Remaining funded promotional cents
Issued coupon grant910000000000000008720222010One fixed-value grant; the balance is its unused value
Coupon redemption clearing910000000000000009720223010Aggregate redeemed coupon value that can fund reversals
Coupon expiry sink910000000000000010720224010Aggregate 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

CodeEventDebitCredit
10Fund reward campaignReward program sourceReward campaign pool
11Fund coupon campaignCoupon program sourceCoupon campaign pool
100Award pointsReward campaign poolMember expiry bucket
110Redeem pointsMember expiry bucketReward redemption clearing
120Expire pointsMember expiry bucketReward expiry sink
130Reverse points awardMember expiry bucketReward campaign pool
140Reverse points redemptionReward redemption clearingMember expiry bucket
150Reinstate expired pointsReward expiry sinkGoverned replacement bucket
200Issue fixed-value couponCoupon campaign poolIssued coupon grant
210Redeem couponIssued coupon grantCoupon redemption clearing
220Expire couponIssued coupon grantCoupon expiry sink
230Cancel unredeemed couponIssued coupon grantCoupon campaign pool
240Reverse coupon redemptionCoupon redemption clearingGoverned replacement grant
250Reinstate expired couponCoupon expiry sinkGoverned replacement grant
900Reviewed adjustmentReviewed source accountReviewed 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:

  1. lock or claim the expiry job by its stable business ID;
  2. check whether the bucket or coupon was already redeemed, cancelled, or expired;
  3. determine the remaining posted balance;
  4. submit a stable transfer from the constrained grant account to the appropriate expiry sink; and
  5. 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

InvariantEnforcement
Campaign issuance is boundedFund a constrained campaign pool and award or issue only from that pool
A member cannot redeem more points than heldApply debits_must_not_exceed_credits to each member bucket
A single-use coupon cannot be consumed twiceInsert one unique application claim keyed by grant ID, then consume or close the whole grant in one linked chain
Combined redemption is all-or-nothingPut every allowed reward/coupon leg in one ordered batch; set linked on every non-final leg
Expiry is explainablePost a new stable transfer to an expiry sink; never mutate an award or issuance
Reversal cannot create units silentlyUniquely map the original event to one validated compensation and debit a constrained clearing or expiry account
Eligibility remains authoritativeValidate campaign, member, product, time, stacking, and usage limits in the application before ledger submission
Duplicate events cannot duplicate valuePersist 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

  1. Open the intended organization and database. Confirm Ready, plan, database UUID, and effective event quotas.
  2. Select Connect and generate a Specific database API key for the rewards service. Store the one-time secret in a server-side secret manager.
  3. Open Query and select Query accounts. Run a bounded query for ledger 7201, then 7202.
  4. Verify campaign-pool, member-bucket, and coupon-grant codes against the application mapping.
  5. Use Create accounts only in the intended development database. Review every account ID, ledger, code, and flag before selecting Run.
  6. 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 Parix Query explorer create-accounts form with account ID, ledger, code, and flags fields

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_accounts and create_transfers write 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 --json

Fund 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 \
  --json

Award 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 \
  --json

Assume 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 --json

A 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 conditionMeaningAction
Empty create result []Adapter reported no item conflicts; persisted is not exposedLookup every submitted ID and exact immutable field; advance only after the exact batch is found
Nonempty create resultOne or more items were rejectedHTTP 409 + tbResults is unwrapped into this array. Lookup every submitted ID; do not infer outcomes from only the first ten conflict entries
Campaign pool insufficientAward or issuance exceeds funded remaining unitsReject or pause campaign issuance; fund only through an approved budget event
Member or coupon balance insufficientValue was redeemed, expired, cancelled, or never issuedReconcile competing stable IDs; do not retry unchanged
Redeem and expire raceTwo workflows attempted to consume the same constrained valueAccept the committed winner and mark the loser from lookup evidence
Eligibility or stacking failsApplication policy disallows the claimDo not submit a ledger write
Checkout definitively rejects after redemptionEntitlement committed but order did notUniquely map each original transfer to one validated, preallocated compensation before submission
Different reversal ID for one original eventThe application attempted a second compensationReject through the original-transfer uniqueness constraint; never rely on aggregate clearing alone
Checkout outcome ambiguousThe order may have committedQuery the durable order/claim before any compensation
Award reversal exceeds unspent pointsThe member spent some or all awarded valueDo not disable the balance constraint; apply the documented clawback or future-earn policy
HTTP 400Strict schema, ledger, flag, code, or field validation failedFix the request; do not retry unchanged
HTTP 401, 402, 403, or 404Credential, billing, scope, environment, or database is wrongStop and correct configuration
HTTP 429Transient admission pressure or durable quota exhaustionBack off only for transient limits; wait for reset or change workload/plan for durable exhaustion
Timeout, disconnect, HTTP 500, or 503Ledger outcome may be ambiguousLookup 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.

ScenarioSetup/actionExpected result
Account bootstrapCreate program, pool, member/grant, clearing, and expiry accountsEmpty result; exact retry does not create another account
Fund reward campaignMove 100000 points from source to constrained poolPool has funded issuance capacity
Award pointsAward 500 points with a stable source-event IDMember bucket increases once
Duplicate award deliveryRedeliver the same ID and immutable payloadOnly one award exists
Campaign exhaustedAward more points than the pool holdsTransfer rejects; member receives nothing
Issue couponMove 2500 promotional cents into a new constrained grantOne grant contains the exact fixed value
Duplicate coupon issuanceReuse the issuance ID and payloadOne coupon value exists
Stacked redemptionRedeem points and coupon in one linked chainBoth legs commit or neither commits
Coupon double redemptionSubmit a second redemption against the fully consumed grantConstraint rejects it; clearing receives no second credit
Partial single-use requestAttempt to redeem less than the grant's full valueApplication rejects before Parix; the grant remains unchanged
Distinct-ID single-use raceSubmit two full-value claims with different redemption IDsUnique grant claim admits one; its ledger leg leaves the grant at zero
Coupon eligibility failureUse coupon on a disallowed productApplication rejects before Parix
Redeem/expire raceConcurrently redeem and expire one coupon grantAt most one consumes the value; loser reconciles the winner
Points expiry bucketExpire only the remaining balance of one due bucketDue value moves to expiry sink; other buckets remain unchanged
Redemption reversalDefinitively reject checkout after a committed redemptionStable reversal restores an approved reward bucket or new governed coupon grant
Different-ID reversal retryAllocate a second reversal ID for the same original transferUnique original-to-reversal record rejects it before any ledger write
Expiry reinstatementApprove reversal of one points or coupon expiryCode 150 or 250 restores the exact amount into one governed replacement
Reinstatement retryRestart after reinstatement commitsExact stable-ID lookup finds one compensation; no second value is created
Ambiguous checkout outcomeCommit order but drop the application responseClaim lookup prevents an incorrect reversal
Partial award clawbackSpend part of an award, then request a full award reversalConstraint blocks over-clawback; documented policy handles the shortfall
ID collisionReuse an ID with a different amount, campaign, account, or flagsExact-field reconciliation rejects the collision
Shared query without ledgerQuery Developer without a ledgerRejected; queries for 7201 and 7202 succeed
Expiry retry after restartStop after expiry commit but before application acknowledgmentStable-ID lookup finds the exact expiry; no second movement
Non-persistent stub responseGateway returns an empty payload without persisting the batchPost-write lookup stays empty; workflow does not advance
Environment isolationRun sample IDs with test credentialsOnly 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.