Skip to main content
PARIXDocs

Lending & Credit Lines

Build revolving facilities with atomic draw reservations, receivables, repayments, reversals, and write-offs on Parix.

Overview

This guide implements an operational ledger for a revolving credit line. It keeps two different meanings separate:

  1. Facility capacity records the approved commitment, capacity available to draw, posted principal exposure, over-limit exposure, retired capacity, and charged-off exposure.
  2. USD money records performing and charged-off principal, interest, and fee receivables together with disbursement, collection, and unapplied-payment positions.

The examples approve a $5,000.00 facility, reserve a maximum $2,500.00 draw, post an actual $2,400.00 disbursement, accrue $48.00 of interest and $12.00 of fees, and allocate a $500.00 repayment. All amounts are positive integer cents. Replace every ledger, code, timeout, and ID with values from your governed lending program, and never reuse the example IDs in more than one database.

Parix and TigerBeetle provide durable balances, account constraints, linked atomic batches, pending-transfer lifecycles, and immutable history. They do not underwrite a borrower, calculate APR, determine a legally valid allocation order, execute a bank payment, service a loan, produce disclosures, or replace a general ledger.

This is a how-to manual for backend, ledger, lending-platform, finance, risk, and operations engineers. It focuses on revolving drawdowns, accrual positions, repayment allocation, returns, delinquency controls, write-offs, and recovery. Installment schedules, collateral, securitization, regulatory capital, tax, and jurisdiction-specific lending rules remain outside its scope.

Architecture and ownership

Keep regulated and distributed-system boundaries explicit:

ComponentOwns
Lending applicationBorrower and facility state, underwriting, approved limits, draw eligibility, stable business IDs, repayment allocation, schedules, delinquency, statements, notices, and customer-facing balances
Pricing and servicing policyAPR, day-count convention, compounding, grace periods, caps, fee eligibility, allocation order, rounding, maturity, forbearance, and policy versions
Identity, risk, and complianceKYC/KYB, sanctions, fraud, affordability, adverse-action workflows, consent, disclosures, collections rules, and evidence retention
Payout and collection providersExternal disbursement, repayment execution, reversals, finality, settlement files, and provider event IDs
ParixAuthenticated public API routing and the managed TigerBeetle database
TigerBeetleAccount constraints, pending and posted balances, linked same-request atomicity, immutable transfers, and ordered history
Finance and lending operationsCash and receivable reconciliation, suspense, charge-off approval, recovery treatment, allowance methodology, general-ledger export, and incident repair

Store borrower identity, application data, credit decisions, bank details, provider payloads, loan agreements, schedules, disclosures, and case notes outside TigerBeetle. Ledger IDs and user-data fields must contain only opaque correlations.

The server integration path is:

lending service -> Parix HTTPS API -> authenticated private gateway -> managed TigerBeetle

A linked chain makes only its TigerBeetle transfers atomic. It cannot include an underwriting database transaction, payout-provider call, collection, notification, or general-ledger posting. Model those boundaries as a durable saga or outbox and reconcile each side by stable identifiers.

Ledger model

Units and ledgers

LedgerUnitPurpose
7501Credit-line capacity in USD centsApproved, available, drawn, over-limit, retired, charged-off, and recovered facility capacity
840Actual USD centsPerforming and charged-off principal, interest, and fees plus clearing and unapplied payments

The capacity ledger is a commitment measure, not cash. The USD ledger represents monetary positions. A linked chain may contain transfers from both ledgers, but each individual transfer's accounts and ledger must match.

The examples use account flag 8 for history, flag 2 for debits_must_not_exceed_credits, and flag 4 for credits_must_not_exceed_debits. A constrained credit-normal account therefore uses flags 10; a constrained debit-normal account uses flags 12.

Facility-capacity accounts

Create the borrower-specific accounts separately for every facility and currency.

RoleExample IDCodeFlagsBalance meaning
Program limit source970000000000000000014008Governed source for approved commitments
Available facility9700000000000000000241010Capacity that may still be drawn; pending debits reserve it
Drawn exposure9700000000000000000342010Posted principal backed by the approved facility
Over-limit exposure9700000000000000000442510Recreated or adjusted principal outside currently available capacity
Revoked or retired capacity9700000000000000000543010Limit removed or principal retired without reopening the facility
Charged-off exposure9700000000000000000644010Principal exposure written out of performing balances but not yet recovered
Reviewed adjustment source970000000000000000074508Controlled source for unavoidable over-limit reinstatements and reviewed repair
Recovered charged-off exposure9700000000000000000844510Net charged-off principal recovered without reopening drawable capacity

The available-facility constraint is the concurrent credit-limit control. An application balance read can improve an error message, but it cannot replace the constraint.

USD accounts

RoleExample IDCodeFlagsBalance meaning
Disbursement clearing970000000000000000115008Reconciled to payout-provider events and settlement files
Collection clearing970000000000000000125018Reconciled to repayment-provider events and settlement files
Principal receivable9700000000000000001351012Debit-normal posted principal owed by the borrower
Interest receivable9700000000000000001451112Debit-normal accrued and unpaid interest
Fee receivable9700000000000000001551212Debit-normal assessed and unpaid fees
Unapplied repayment9700000000000000001652010Confirmed collections that remain available for allocation
Interest accrual control9700000000000000001753010Constrained source for approved interest waivers and refunds
Fee assessment control9700000000000000001853110Constrained source for approved fee waivers and refunds
Charged-off principal receivable9700000000000000001954012Unrecovered principal removed from performing receivables
Charged-off interest receivable9700000000000000002054112Unrecovered interest removed from performing receivables
Charged-off fee receivable9700000000000000002154212Unrecovered fees removed from performing receivables
Lending suspense970000000000000000225508Reviewed discrepancies with an owner, reason, source evidence, and aging deadline

The debit-normal constraint on performing receivables prevents aggregate repayment, waiver, or write-off credits from exceeding the amount originated or accrued. The same constraint on charged-off receivables caps aggregate net recoveries and write-off reversals at the facility's current charged-off position. The application must still bind every adjustment to its original charge, receipt, or write-off and enforce its cumulative per-source cap. The unapplied-repayment constraint prevents concurrent allocators or refunds from consuming more confirmed cash than is available.

Clearing and suspense accounts are application-governed reconciliation boundaries whose balances may move on either side as external truth arrives. Accrual and assessment controls are instead credit-normal and constrained, which bounds aggregate waivers and refunds by the corresponding charges already posted. Per-charge uniqueness and cumulative adjustment limits remain application-owned unless the program deliberately creates a separate control account for every charge.

Balance formulas

Use TigerBeetle's separate pending and posted fields instead of collapsing lifecycle states:

available_to_draw =
  available.credits_posted
  - available.debits_posted
  - available.debits_pending

authorized_not_disbursed = principal_receivable.debits_pending

principal_outstanding =
  principal_receivable.debits_posted
  - principal_receivable.credits_posted

drawn_exposure =
  drawn.credits_posted
  - drawn.debits_posted

overlimit_exposure =
  overlimit.credits_posted
  - overlimit.debits_posted

charged_off_principal_outstanding =
  charged_off_principal.debits_posted
  - charged_off_principal.credits_posted

recovered_principal_exposure =
  recovered.credits_posted
  - recovered.debits_posted

unapplied_available =
  unapplied.credits_posted
  - unapplied.debits_posted
  - unapplied.debits_pending

For a normal open revolver without a reconciliation break, posted principal equals drawn exposure plus explicit over-limit exposure. Interest and fees remain separate and never reopen facility capacity unless the legal contract explicitly capitalizes them through a separately modeled event.

Transfer codes

Keep codes immutable even when several events use the same accounts.

LedgerCodeEventDebit → credit
75014000Approve or increase limitLimit source → available
75014010Draw authorization lifecycleAvailable → drawn
75014020Revolving principal repaymentDrawn → available
75014021Closed-line principal repaymentDrawn → revoked
75014022Over-limit principal repaymentOver-limit → adjustment source
75014030Reduce, freeze, or close unused limitAvailable → revoked
75014040Charge off principal exposureDrawn or over-limit → charged-off
75014041Recover charged-off principalCharged-off → recovered
75014050Reverse an approved charge-offCharged-off → drawn or over-limit
75014051Reverse a principal recoveryRecovered → charged-off
75014060Reconsume capacity after payment returnAvailable → drawn
75014061Record payment-return shortfallAdjustment source → over-limit
75014090Reviewed facility adjustmentApproved direction only
8405010Draw principal lifecyclePrincipal receivable → disbursement clearing
8405020Repayment receivedCollection clearing → unapplied repayment
8405030Allocate feeUnapplied repayment → fee receivable
8405031Allocate interestUnapplied repayment → interest receivable
8405032Allocate principalUnapplied repayment → principal receivable
8405040Accrue interestInterest receivable → interest accrual control
8405041Assess feeFee receivable → fee assessment control
8405042Waive unpaid interestInterest control → interest receivable
8405043Waive unpaid feeFee control → fee receivable
8405044Create paid-charge refund creditMatching control → unapplied repayment
8405045Pay approved charge refundUnapplied repayment → collection clearing
8405050Return a posted drawDisbursement clearing → principal receivable
8405060Write off principalCharged-off principal → principal receivable
8405061Write off interestCharged-off interest → interest receivable
8405062Write off feeCharged-off fee → fee receivable
8405065Reverse an approved write-offPerforming → matching charged-off receivable
8405070Recover charged-off principalCollection clearing → charged-off principal
8405071Recover charged-off interestCollection clearing → charged-off interest
8405072Recover charged-off feeCollection clearing → charged-off fee
8405075Reverse an approved recoveryCharged-off receivable → collection clearing
8405080Reverse principal allocationPrincipal receivable → unapplied repayment
8405081Reverse interest allocationInterest receivable → unapplied repayment
8405082Reverse fee allocationFee receivable → unapplied repayment
8405083Reverse repayment receiptUnapplied repayment → collection clearing
8405090Reviewed monetary adjustmentApproved direction only

Drawdown lifecycle

Persist the facility decision, draw request, maximum approved amount, exact two-leg reservation payload, provider reference, and every alternative terminal ID before calling Parix.

Reserve both lifecycle positions in one linked pair:

  1. Pending capacity moves available facility to drawn exposure.
  2. Pending USD principal moves principal receivable to disbursement clearing.

Only the constrained available-capacity leg admits or rejects the draw against the credit limit. The USD leg records authorized-but-not-disbursed principal and keeps the two internal lifecycle records atomic; it does not reserve bank, provider, or treasury liquidity. Add a separate constrained funding-capacity position only when the program intentionally needs that distinct control.

The application may dispatch the payout provider only after exact lookup proves that both reservations exist. When the provider outcome is final, either partial-post the exact positive amount to both pending transfers or void both original amounts. Every non-final item carries linked; the final item does not.

A partial post is terminal and releases both unused remainders. A void uses the original full amount. If the provider succeeds after the pending pair expires, do not invent a replacement authorization to conceal the lapse. Preserve the provider evidence and route the discrepancy to a reviewed late-disbursement or suspense workflow.

The pending timeout is seconds and represents only an in-flight draw reservation. It is not a payment due date, grace period, maturity, delinquency clock, or facility expiration.

Interest, fees, and repayment allocation

The application calculates finalized interest and fees with integer arithmetic and persists the contract version, rate inputs, day-count convention, accrual period, caps, rounding, and source event. TigerBeetle receives only the resulting positive cents:

  • interest debits interest receivable and credits interest accrual control;
  • a fee debits fee receivable and credits fee assessment control.

After verified collection finality, post collection clearing to unapplied repayment with one provider-event-derived stable ID. Then persist the exact allocation and submit its nonzero legs as one linked chain. The example allocation order is fee, interest, then principal; your legal contract and jurisdiction may require another order.

Only a principal allocation changes capacity:

  • an open revolver moves drawn exposure back to available capacity;
  • a closed or non-revolving line moves drawn exposure to revoked capacity;
  • an over-limit facility reduces explicit over-limit exposure before reopening any capacity.

Require allocated fee + interest + principal to be no greater than confirmed unapplied cash. Leave a residual unapplied until another approved allocation or refund workflow. Never allocate pending principal or an unconfirmed provider payment.

An unpaid interest or fee waiver debits its constrained control and credits the matching performing receivable, so the facility's aggregate charge budget and current receivable cannot be exceeded. Persist a unique original-charge-to-adjustment mapping and reject cumulative waivers and refunds above that individual charge. A charge already paid has no receivable left to credit. Refund it instead with a new approved linked chain from the matching control to unapplied repayment and then from unapplied repayment to collection clearing; the provider payout remains a separate reconciled saga.

Returns, delinquency, write-off, and recovery

A returned posted draw is a new linked compensation from disbursement clearing to principal receivable and from drawn exposure to available or revoked capacity. It never voids the original posted draw.

A repayment return can recreate debt after the borrower has already redrawn the restored capacity. Under a per-facility durable lock:

  1. reverse fee, interest, and principal allocations into unapplied repayment;
  2. reverse the unapplied receipt to collection clearing;
  3. split returned principal into backed = min(returned principal, available_to_draw) and overlimit = returned principal - backed;
  4. move backed capacity from available to drawn and any remainder from adjustment source to explicit over-limit exposure; and
  5. persist a new allocation-attempt version and submit every nonzero leg as one linked chain.

If a concurrent constraint rejection occurs, none of the chain commits. Reconcile the stable IDs, recompute from current balances under the facility lock, allocate new IDs for a new attempt version, and retain the rejected plan as evidence.

Delinquency, forbearance, and collections states remain application-owned. To make a draw freeze race-safe at the ledger boundary, move currently unused available capacity to revoked capacity with code 4030 after reconciling in-flight draws. Reinstatement requires a new, explicit underwriting decision and limit transfer.

A write-off chain debits each matching charged-off receivable, credits the performing receivable being removed, and moves the same principal from drawn or over-limit exposure to charged-off capacity. A full-close write-off may also retire unused available capacity after a separate approved limit decision; a partial accounting write-off moves only the affected exposure. It does not reopen the line, delete debt history, waive legal rights automatically, or decide general-ledger accounting.

A later recovery debits collection clearing and credits the matching constrained charged-off receivable, which caps aggregate net recovery at the facility's current charged-off position. Persist a unique write-off-to-recovery mapping and reject cumulative recovery above that original write-off. Principal recovery also moves the same amount from charged-off capacity to recovered charged-off exposure in the linked chain. Recovery never credits available capacity.

Reverse a recovery with new opposite-direction money and, for principal, capacity legs; never rewrite the original event. Before writing code 5075, lock the facility, require a unique original-recovery-to-reversal mapping, and reject cumulative reversal above that recovery's posted amount. The principal recovered-capacity constraint provides an additional aggregate backstop; interest and fee reversal caps are application-enforced unless those categories use separate constrained recovery counters.

Invariants

InvariantEnforcement
Concurrent draws cannot exceed unused capacityAvailable facility uses flags 10; reserve before provider dispatch
Principal and capacity move togetherReserve, post, void, posted-draw return, principal allocation, and write-off use one correctly terminated linked chain
Repayment cannot over-allocateUnapplied repayment uses flags 10; receivables use flags 12; allocation contains only positive, persisted legs
Only principal reopens a revolving lineFee and interest allocation remain entirely on ledger 840; principal adds the capacity leg
Limit decreases never erase debtLimit changes move only available capacity; they never alter principal, interest, or fee receivables
Recovery stays boundedApplication caps each source write-off; flags 12 cap aggregate net recovery; recovered principal is non-drawable
Pending timeout has one meaningIt releases only an unfinished draw reservation; the application owns every contractual date
Corrections remain immutableReturn, reversal, waiver, write-off, recovery, and adjustment use new stable IDs and named codes
External execution is not ledger-atomicProvider calls and application SQL state use durable saga, outbox, idempotency, and reconciliation
Retries preserve exact intentPersist payload order and all immutable fields; exact lookup precedes replay after conflict or ambiguity
Sensitive lending records stay outside the ledgerUse opaque correlations only; never store PII, bank details, decisions, documents, or unrestricted provider data

Before you begin

  1. Select an eligible environment. Developer and Dedicated Single Node are non-production plans. Confirm current production topology, quota, backup, pending-timeout, and support requirements in Plans and limits.
  2. Create a disposable database for this walkthrough, wait for Ready, and record its UUID. API and CLI paths use the UUID rather than the display name.
  3. Obtain program approval for the capacity and money ledgers, account directions, code registry, receivable and clearing conventions, write-off treatment, and general-ledger export.
  4. Define underwriting, credit-limit, draw, payout, repayment, delinquency, payment-return, write-off, recovery, and suspense state machines before accepting real funds.
  5. Define APR, interest, fee, day-count, allocation, rounding, overpayment, and late-event policies. Version the exact inputs used by every monetary event.
  6. Define provider-finality and reconciliation rules for disbursement and collection. A provider success and a ledger success are separate facts.
  7. Allocate opaque stable account and transfer IDs before the first write. Persist (facility, business event, leg kind, attempt version) -> transfer ID + immutable payload.
  8. Generate a database-scoped API key for the server application and keep it only in server-side secret storage. Dashboard and CLI users authenticate separately.
  9. Confirm that the active plan permits both external ledgers and the largest linked chain. Never split a chain merely to fit a lower event-per-request limit.
  10. Use decimal strings for wide JSON IDs and amounts. The public schema accepts batches of 1–8,190 items, while Shared and plan limits can be lower.

@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.

Dashboard walkthrough

Live-write warning: Query explorer writes persist immediately and cannot be edited or deleted. Confirm the organization, database UUID, and non-production environment before selecting Run.

  1. Open the intended database and select Query.
  2. Choose Create accounts, disable random-ID generation, and create the governed account registry. Use ledger 7501 for facility positions and 840 for USD positions.
  3. Use flags 10 for credit-normal constrained capacity, unapplied repayment, interest-accrual control, and fee-assessment control accounts. Use flags 12 for performing and charged-off debit-normal receivables. Use flags 8 only for the program and adjustment sources, clearing, and suspense accounts in this registry.
  4. Confirm an empty create-result array. For any nonempty result, map its index to the submitted account and then look up every stable ID before deciding whether it is a replay or a rejection.
  5. Approve the test limit with a posted code-4000 transfer from limit source to available facility.
  6. Create the two draw reservations in one request. The first transfer has flags 3 (pending | linked); the final transfer has flags 2 (pending). Use the same timeout and draw correlation.
  7. After the simulated payout outcome, create either the linked post pair or the linked void pair. Never execute both terminal alternatives.
  8. Use Lookup transfers to verify all stable IDs and fields. Use Query accounts separately with ledger 7501 and ledger 840; Shared queries reject a missing ledger.

The Parix Query explorer Create accounts form used to configure stable account IDs, ledgers, codes, and flags

Create every account with a reviewed stable ID. The screenshot illustrates the operator surface; the IDs and code shown in your browser depend on the selected database and input.

The Parix Query explorer Query accounts form used to inspect facility, receivable, and pending balances

Query capacity and USD independently. On Shared Developer, API and CLI query_* calls require an explicit ledger filter (plan_restricted if omitted). The Dashboard may offer All known ledgers as a fan-out convenience; still prefer an explicit governed ledger when you need a scoped smoke query.

The Dashboard is appropriate for controlled smoke tests and investigation. Production underwriting, servicing, payout, and collection services use scoped server credentials and durable application workflows.

CLI walkthrough

The commands use the latest published @parix/cli syntax. The CLI is an operator and development surface authenticated through browser OAuth; never reuse its local session as a production service credential.

Install, authenticate, and select the disposable database:

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

export PARIX_DATABASE_ID="db_replace_with_uuid"

Create the account registry

Save the following bare array as lending-accounts.json.

[
  { "id": "97000000000000000001", "ledger": 7501, "code": 400, "flags": 8 },
  { "id": "97000000000000000002", "ledger": 7501, "code": 410, "flags": 10 },
  { "id": "97000000000000000003", "ledger": 7501, "code": 420, "flags": 10 },
  { "id": "97000000000000000004", "ledger": 7501, "code": 425, "flags": 10 },
  { "id": "97000000000000000005", "ledger": 7501, "code": 430, "flags": 10 },
  { "id": "97000000000000000006", "ledger": 7501, "code": 440, "flags": 10 },
  { "id": "97000000000000000007", "ledger": 7501, "code": 450, "flags": 8 },
  { "id": "97000000000000000008", "ledger": 7501, "code": 445, "flags": 10 },
  { "id": "97000000000000000011", "ledger": 840, "code": 500, "flags": 8 },
  { "id": "97000000000000000012", "ledger": 840, "code": 501, "flags": 8 },
  { "id": "97000000000000000013", "ledger": 840, "code": 510, "flags": 12 },
  { "id": "97000000000000000014", "ledger": 840, "code": 511, "flags": 12 },
  { "id": "97000000000000000015", "ledger": 840, "code": 512, "flags": 12 },
  { "id": "97000000000000000016", "ledger": 840, "code": 520, "flags": 10 },
  { "id": "97000000000000000017", "ledger": 840, "code": 530, "flags": 10 },
  { "id": "97000000000000000018", "ledger": 840, "code": 531, "flags": 10 },
  { "id": "97000000000000000019", "ledger": 840, "code": 540, "flags": 12 },
  { "id": "97000000000000000020", "ledger": 840, "code": 541, "flags": 12 },
  { "id": "97000000000000000021", "ledger": 840, "code": 542, "flags": 12 },
  { "id": "97000000000000000022", "ledger": 840, "code": 550, "flags": 8 }
]
parix tb create-accounts "$PARIX_DATABASE_ID" --file ./lending-accounts.json --json

An empty result array means the create request reported no item errors. Look up and compare the intended account fields before treating a replay or ambiguous response as complete.

Approve the facility

Save facility-approval.json. This grants 500,000 capacity cents without creating cash or debt.

[
  {
    "id": "97100000000000000001",
    "debit_account_id": "97000000000000000001",
    "credit_account_id": "97000000000000000002",
    "amount": "500000",
    "pending_id": "0",
    "user_data_128": "97200000000000000001",
    "user_data_64": "973000000000000001",
    "user_data_32": 1,
    "timeout": 0,
    "ledger": 7501,
    "code": 4000,
    "flags": 0,
    "timestamp": "0"
  }
]

Single-leg facility grants can use flag-driven CLI fields. The file form below is equivalent for reviewed automation; do not submit both for the same stable ID. Optional user_data_* in the JSON is intentional only where this manual later queries or groups by facility request and policy version:

  • user_data_128 — facility or draw request identity (“what”)
  • user_data_64 — policy or contract version pointer
  • user_data_32 — small leg/role enum within a multi-leg chain when useful

Leave them zero when lookup-by-transfer-id is enough. See Optional user_data fields.

# Flag-driven form (user_data omitted = zero):
parix tb create-transfers "$PARIX_DATABASE_ID" \
  --id 97100000000000000001 \
  --from 97000000000000000001 \
  --to 97000000000000000002 \
  --amount 500000 \
  --ledger 7501 \
  --code 4000 \
  --json

# Or: parix tb create-transfers "$PARIX_DATABASE_ID" --file ./facility-approval.json --json
parix tb lookup-transfers "$PARIX_DATABASE_ID" --id 97100000000000000001 --json

Reserve and post a draw

Save draw-reserve.json. Timeout 600 means ten minutes and applies only to provider finalization.

[
  {
    "id": "97100000000000000010",
    "debit_account_id": "97000000000000000002",
    "credit_account_id": "97000000000000000003",
    "amount": "250000",
    "pending_id": "0",
    "user_data_128": "97200000000000000010",
    "user_data_64": "973000000000000002",
    "user_data_32": 1,
    "timeout": 600,
    "ledger": 7501,
    "code": 4010,
    "flags": 3,
    "timestamp": "0"
  },
  {
    "id": "97100000000000000011",
    "debit_account_id": "97000000000000000013",
    "credit_account_id": "97000000000000000011",
    "amount": "250000",
    "pending_id": "0",
    "user_data_128": "97200000000000000010",
    "user_data_64": "973000000000000002",
    "user_data_32": 2,
    "timeout": 600,
    "ledger": 840,
    "code": 5010,
    "flags": 2,
    "timestamp": "0"
  }
]
parix tb create-transfers "$PARIX_DATABASE_ID" --file ./draw-reserve.json --json
parix tb lookup-transfers "$PARIX_DATABASE_ID" \
  --id 97100000000000000010 \
  --id 97100000000000000011 \
  --json

Dispatch the payout only after both reservations exact-match. Assume the provider confirms 240,000 cents. Save draw-post.json; the partial posts release the unused 10,000 from each reservation.

[
  {
    "id": "97100000000000000012",
    "debit_account_id": "97000000000000000002",
    "credit_account_id": "97000000000000000003",
    "amount": "240000",
    "pending_id": "97100000000000000010",
    "user_data_128": "97200000000000000010",
    "user_data_64": "973000000000000002",
    "user_data_32": 1,
    "timeout": 0,
    "ledger": 7501,
    "code": 4010,
    "flags": 5,
    "timestamp": "0"
  },
  {
    "id": "97100000000000000013",
    "debit_account_id": "97000000000000000013",
    "credit_account_id": "97000000000000000011",
    "amount": "240000",
    "pending_id": "97100000000000000011",
    "user_data_128": "97200000000000000010",
    "user_data_64": "973000000000000002",
    "user_data_32": 2,
    "timeout": 0,
    "ledger": 840,
    "code": 5010,
    "flags": 4,
    "timestamp": "0"
  }
]
parix tb create-transfers "$PARIX_DATABASE_ID" --file ./draw-post.json --json
parix tb lookup-transfers "$PARIX_DATABASE_ID" \
  --id 97100000000000000012 \
  --id 97100000000000000013 \
  --json

If the provider definitively fails instead, create this alternative linked void pair with preallocated IDs. Do not submit it after a post has been attempted or accepted.

[
  {
    "id": "97100000000000000014",
    "debit_account_id": "97000000000000000002",
    "credit_account_id": "97000000000000000003",
    "amount": "250000",
    "pending_id": "97100000000000000010",
    "user_data_128": "97200000000000000010",
    "user_data_64": "973000000000000002",
    "user_data_32": 1,
    "timeout": 0,
    "ledger": 7501,
    "code": 4010,
    "flags": 9,
    "timestamp": "0"
  },
  {
    "id": "97100000000000000015",
    "debit_account_id": "97000000000000000013",
    "credit_account_id": "97000000000000000011",
    "amount": "250000",
    "pending_id": "97100000000000000011",
    "user_data_128": "97200000000000000010",
    "user_data_64": "973000000000000002",
    "user_data_32": 2,
    "timeout": 0,
    "ledger": 840,
    "code": 5010,
    "flags": 8,
    "timestamp": "0"
  }
]

Accrue and allocate a repayment

After the posted draw, save accrual-and-receipt.json. The interest, fee, and provider-confirmed receipt are independent events; the plain array is not atomic. Reconcile all three stable IDs by lookup.

[
  {
    "id": "97100000000000000020",
    "debit_account_id": "97000000000000000014",
    "credit_account_id": "97000000000000000017",
    "amount": "4800",
    "pending_id": "0",
    "user_data_128": "97200000000000000020",
    "user_data_64": "973000000000000003",
    "user_data_32": 1,
    "timeout": 0,
    "ledger": 840,
    "code": 5040,
    "flags": 0,
    "timestamp": "0"
  },
  {
    "id": "97100000000000000021",
    "debit_account_id": "97000000000000000015",
    "credit_account_id": "97000000000000000018",
    "amount": "1200",
    "pending_id": "0",
    "user_data_128": "97200000000000000021",
    "user_data_64": "973000000000000004",
    "user_data_32": 1,
    "timeout": 0,
    "ledger": 840,
    "code": 5041,
    "flags": 0,
    "timestamp": "0"
  },
  {
    "id": "97100000000000000022",
    "debit_account_id": "97000000000000000012",
    "credit_account_id": "97000000000000000016",
    "amount": "50000",
    "pending_id": "0",
    "user_data_128": "97200000000000000022",
    "user_data_64": "0",
    "user_data_32": 1,
    "timeout": 0,
    "ledger": 840,
    "code": 5020,
    "flags": 0,
    "timestamp": "0"
  }
]
parix tb create-transfers "$PARIX_DATABASE_ID" --file ./accrual-and-receipt.json --json
parix tb lookup-transfers "$PARIX_DATABASE_ID" \
  --id 97100000000000000020 \
  --id 97100000000000000021 \
  --id 97100000000000000022 \
  --json

The example policy allocates 1,200 fee cents, 4,800 interest cents, and 44,000 principal cents. Save the complete chain as repayment-allocation.json.

[
  {
    "id": "97100000000000000030",
    "debit_account_id": "97000000000000000016",
    "credit_account_id": "97000000000000000015",
    "amount": "1200",
    "pending_id": "0",
    "user_data_128": "97200000000000000030",
    "user_data_64": "973000000000000005",
    "user_data_32": 1,
    "timeout": 0,
    "ledger": 840,
    "code": 5030,
    "flags": 1,
    "timestamp": "0"
  },
  {
    "id": "97100000000000000031",
    "debit_account_id": "97000000000000000016",
    "credit_account_id": "97000000000000000014",
    "amount": "4800",
    "pending_id": "0",
    "user_data_128": "97200000000000000030",
    "user_data_64": "973000000000000005",
    "user_data_32": 2,
    "timeout": 0,
    "ledger": 840,
    "code": 5031,
    "flags": 1,
    "timestamp": "0"
  },
  {
    "id": "97100000000000000032",
    "debit_account_id": "97000000000000000016",
    "credit_account_id": "97000000000000000013",
    "amount": "44000",
    "pending_id": "0",
    "user_data_128": "97200000000000000030",
    "user_data_64": "973000000000000005",
    "user_data_32": 3,
    "timeout": 0,
    "ledger": 840,
    "code": 5032,
    "flags": 1,
    "timestamp": "0"
  },
  {
    "id": "97100000000000000033",
    "debit_account_id": "97000000000000000003",
    "credit_account_id": "97000000000000000002",
    "amount": "44000",
    "pending_id": "0",
    "user_data_128": "97200000000000000030",
    "user_data_64": "973000000000000005",
    "user_data_32": 4,
    "timeout": 0,
    "ledger": 7501,
    "code": 4020,
    "flags": 0,
    "timestamp": "0"
  }
]
parix tb create-transfers "$PARIX_DATABASE_ID" --file ./repayment-allocation.json --json
parix tb lookup-transfers "$PARIX_DATABASE_ID" \
  --id 97100000000000000030 \
  --id 97100000000000000031 \
  --id 97100000000000000032 \
  --id 97100000000000000033 \
  --json

parix tb query-accounts "$PARIX_DATABASE_ID" --ledger 7501 --limit 50 --json
parix tb query-accounts "$PARIX_DATABASE_ID" --ledger 840 --limit 50 --json

Expected posted positions after the successful path are:

PositionExpected cents
Available facility304,000
Drawn exposure196,000
Principal receivable196,000
Interest receivable0
Fee receivable0
Unapplied repayment0
Disbursement clearing credit240,000
Collection clearing debit50,000

These values prove the tutorial arithmetic only. Reconcile clearing balances to the provider and receivable/control balances to the lending application and finance systems.

Charge-off principal (secondary path)

After policy approval to charge off 10,000 cents of performing principal, submit one linked chain that moves money from principal receivable to charged-off principal and capacity from drawn exposure to charged-off capacity. Persist unique write-off IDs before the first write.

[
  {
    "id": "97100000000000000040",
    "debit_account_id": "97000000000000000019",
    "credit_account_id": "97000000000000000013",
    "amount": "10000",
    "ledger": 840,
    "code": 5060,
    "flags": 1
  },
  {
    "id": "97100000000000000041",
    "debit_account_id": "97000000000000000003",
    "credit_account_id": "97000000000000000006",
    "amount": "10000",
    "ledger": 7501,
    "code": 4040,
    "flags": 0
  }
]
parix tb create-transfers "$PARIX_DATABASE_ID" --file ./principal-write-off.json --json
parix tb lookup-transfers "$PARIX_DATABASE_ID" \
  --id 97100000000000000040 \
  --id 97100000000000000041 \
  --json

Recovery later debits collection clearing into the charged-off receivable and moves principal capacity from charged-off to recovered (never back to available). Use new stable IDs, unique write-off-to-recovery mapping, and the facility lock described in Returns, delinquency, write-off, and recovery.

Node.js implementation

The adapter returns TigerBeetle-shaped records over the Parix HTTPS gateway. It returns indexed create-conflict arrays (HTTP 409 + tbResults is unwrapped into those arrays) and does not expose the raw gateway response's persisted field, so this example requires exact lookup before and after every create. Full void of an unposted draw may use the original reserved amount or amount_max for remaining capacity.

import type { CreateTransferResult, Transfer } from '@parix/tigerbeetle-node';
import { CreateTransferError, TransferFlags, amount_max, createClient } from '@parix/tigerbeetle-node';

const client = createClient({
  baseUrl: process.env.PARIX_BASE_URL ?? 'https://parix.io',
  apiKey: process.env.PARIX_API_KEY!,
  databaseId: process.env.PARIX_DATABASE_ID!,
});

const accounts = {
  available: 97000000000000000002n,
  drawn: 97000000000000000003n,
  overlimit: 97000000000000000004n,
  adjustmentSource: 97000000000000000007n,
  disbursement: 97000000000000000011n,
  collection: 97000000000000000012n,
  principal: 97000000000000000013n,
  interest: 97000000000000000014n,
  fee: 97000000000000000015n,
  unapplied: 97000000000000000016n,
} as const;

function linkBatch(batch: readonly Transfer[]): Transfer[] {
  if (batch.length === 0) throw new Error('linked batch must not be empty');
  return batch.map((transfer, index) => ({
    ...transfer,
    flags: (transfer.flags & ~TransferFlags.linked) | (index < batch.length - 1 ? TransferFlags.linked : 0),
  }));
}

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 lookupExactBatch(batch: readonly Transfer[]): Promise<'none' | 'exact'> {
  const found = await client.lookupTransfers(batch.map(({ id }) => id));
  if (found.length === 0) return 'none';
  if (found.length !== batch.length) throw new Error('partial ledger visibility: reconcile manually');

  const byId = new Map(found.map((transfer) => [transfer.id, transfer]));
  if (
    !batch.every((intended) => {
      const actual = byId.get(intended.id);
      return actual !== undefined && sameTransfer(actual, intended);
    })
  ) {
    throw new Error('stable ID exists with different immutable fields');
  }
  return 'exact';
}

class TransferBatchRejected extends Error {
  constructor(readonly results: readonly CreateTransferResult[]) {
    const details = results
      .map(({ index, result }) => {
        const name =
          result === CreateTransferError.exceeds_credits
            ? 'exceeds_credits'
            : result === CreateTransferError.exceeds_debits
              ? 'exceeds_debits'
              : result === CreateTransferError.linked_event_failed
                ? 'linked_event_failed'
                : String(result);
        return `${index}:${name}`;
      })
      .join(', ');
    super(`TigerBeetle rejected transfer indexes ${details}`);
  }
}

async function createOrReconcile(batch: readonly Transfer[]): Promise<void> {
  if ((await lookupExactBatch(batch)) === 'exact') return;

  let results: CreateTransferResult[];
  try {
    results = await client.createTransfers([...batch]);
  } catch (error) {
    if ((await lookupExactBatch(batch)) === 'exact') return;
    throw error;
  }

  if ((await lookupExactBatch(batch)) === 'exact') return;
  if (results.length > 0) throw new TransferBatchRejected(results);
  throw new Error('create returned without an exact durable batch');
}

Reserve a draw before dispatching the provider:

const drawRequestId = 97200000000000000010n;
const drawPolicyVersion = 973000000000000002n;
const maximumDraw = 250_000n;

const capacityReserve: Transfer = {
  id: 97100000000000000010n,
  debit_account_id: accounts.available,
  credit_account_id: accounts.drawn,
  amount: maximumDraw,
  pending_id: 0n,
  user_data_128: drawRequestId,
  user_data_64: drawPolicyVersion,
  user_data_32: 1,
  timeout: 600,
  ledger: 7501,
  code: 4010,
  flags: TransferFlags.pending,
  timestamp: 0n,
};

const principalReserve: Transfer = {
  ...capacityReserve,
  id: 97100000000000000011n,
  debit_account_id: accounts.principal,
  credit_account_id: accounts.disbursement,
  user_data_32: 2,
  ledger: 840,
  code: 5010,
};

const drawReserveBatch = linkBatch([capacityReserve, principalReserve]);
await createOrReconcile(drawReserveBatch);
// Dispatch only after the exact lookup above confirms both reservations.

Post the exact provider-confirmed draw or explicitly void both reservations:

const actualDraw = 240_000n;
if (actualDraw < 0n || actualDraw > maximumDraw) {
  throw new Error('actual draw is outside the reserved amount');
}

const drawTerminalBatch =
  actualDraw === 0n
    ? linkBatch([
        {
          ...capacityReserve,
          id: 97100000000000000014n,
          amount: amount_max, // full remaining; maximumDraw also valid for unposted full void
          pending_id: capacityReserve.id,
          timeout: 0,
          flags: TransferFlags.void_pending_transfer,
        },
        {
          ...principalReserve,
          id: 97100000000000000015n,
          amount: amount_max,
          pending_id: principalReserve.id,
          timeout: 0,
          flags: TransferFlags.void_pending_transfer,
        },
      ])
    : linkBatch([
        {
          ...capacityReserve,
          id: 97100000000000000012n,
          amount: actualDraw,
          pending_id: capacityReserve.id,
          timeout: 0,
          flags: TransferFlags.post_pending_transfer,
        },
        {
          ...principalReserve,
          id: 97100000000000000013n,
          amount: actualDraw,
          pending_id: principalReserve.id,
          timeout: 0,
          flags: TransferFlags.post_pending_transfer,
        },
      ]);

await createOrReconcile(drawTerminalBatch);

Record a confirmed repayment first, then allocate only positive components:

const repaymentEventId = 97200000000000000022n;
const repaymentReceipt: Transfer = {
  id: 97100000000000000022n,
  debit_account_id: accounts.collection,
  credit_account_id: accounts.unapplied,
  amount: 50_000n,
  pending_id: 0n,
  user_data_128: repaymentEventId,
  user_data_64: 0n,
  user_data_32: 1,
  timeout: 0,
  ledger: 840,
  code: 5020,
  flags: 0,
  timestamp: 0n,
};

await createOrReconcile([repaymentReceipt]);

// Hold the per-facility allocation lock while reading and submitting this plan.
const [unappliedAccount] = await client.lookupAccounts([accounts.unapplied]);
if (!unappliedAccount) throw new Error('unapplied account is missing');
const unappliedAvailableSnapshot =
  unappliedAccount.credits_posted - unappliedAccount.debits_posted - unappliedAccount.debits_pending;

const allocationId = 97200000000000000030n;
const allocationPolicyVersion = 973000000000000005n;
const fee = 1_200n;
const interest = 4_800n;
const principal = 44_000n;
const allocated = fee + interest + principal;
if (allocated <= 0n || allocated > unappliedAvailableSnapshot) {
  throw new Error('allocation is outside confirmed unapplied cash');
}

const allocationCommon = {
  pending_id: 0n,
  user_data_128: allocationId,
  user_data_64: allocationPolicyVersion,
  timeout: 0,
  flags: 0,
  timestamp: 0n,
} as const;

const allocationLegs: Transfer[] = [];
if (fee > 0n) {
  allocationLegs.push({
    ...allocationCommon,
    id: 97100000000000000030n,
    debit_account_id: accounts.unapplied,
    credit_account_id: accounts.fee,
    amount: fee,
    user_data_32: 1,
    ledger: 840,
    code: 5030,
  });
}
if (interest > 0n) {
  allocationLegs.push({
    ...allocationCommon,
    id: 97100000000000000031n,
    debit_account_id: accounts.unapplied,
    credit_account_id: accounts.interest,
    amount: interest,
    user_data_32: 2,
    ledger: 840,
    code: 5031,
  });
}
if (principal > 0n) {
  allocationLegs.push(
    {
      ...allocationCommon,
      id: 97100000000000000032n,
      debit_account_id: accounts.unapplied,
      credit_account_id: accounts.principal,
      amount: principal,
      user_data_32: 3,
      ledger: 840,
      code: 5032,
    },
    {
      ...allocationCommon,
      id: 97100000000000000033n,
      debit_account_id: accounts.drawn,
      credit_account_id: accounts.available,
      amount: principal,
      user_data_32: 4,
      ledger: 7501,
      code: 4020,
    },
  );
}

await createOrReconcile(linkBatch(allocationLegs));

The snapshot is policy validation under the per-facility allocation lock and intentionally includes any earlier unapplied residual. The account constraint remains the concurrency backstop if another writer bypasses that lock. Persist every included leg and stable ID before the first allocation attempt. Always call client.destroy() in a finally block when the worker lifecycle ends.

Failure and retry handling

Signal or stateMeaningRequired action
Empty create result []The adapter reported no indexed item errorsStill require exact lookup before advancing durable state. The adapter does not expose gateway persisted.
Nonempty create resultOne or more indexed TigerBeetle conflictsMap every returned index and classify each result (for example CreateTransferError.exceeds_credits). HTTP 409 + tbResults is unwrapped into this array. Look up every submitted stable ID; classify capacity/receivable constraints separately from defects
Available-facility exceeds_creditsDraw or limit reduction exceeds unused capacityDecline or serialize the business operation; never dispatch the payout or retry as infrastructure work
Receivable exceeds_debitsAllocation, reversal, or write-off exceeds the posted receivableStop and reconcile policy inputs, prior allocations, and event ordering
Charged-off receivable exceeds_debitsRecovery or write-off reversal exceeds the remaining charged-off positionReject the complete chain; reconcile the original write-off, prior recoveries, reversals, and provider event
Unapplied exceeds_creditsAllocators attempted to consume more than confirmed collectionsReject the complete allocation chain and serialize/recompute under the facility lock
Timeout, disconnect, reset, 500, or 503The outcome may be absent or committedExact lookup every ID first; retry only a fully absent batch with identical order and fields
HTTP 400Strict payload, field, path, or batch validation failedFix the producer; do not retry unchanged
HTTP 401 or 403Credential, scope, database boundary, ledger, timeout, or plan policy rejected the callCorrect access or policy; never bypass it with operator credentials
HTTP 402Billing state blocks the operationRestore eligibility, preserve the intent, and look up before re-driving
HTTP 429Transient admission pressure or durable plan quotaDistinguish the reason; use bounded jitter only for transient pressure and never hot-loop a hard quota
Partial lookup or immutable-field mismatchThe stable-ID contract or assumed atomic batch is brokenQuarantine the facility event and reconcile manually; do not generate replacement IDs automatically
Draw post or void reports expired/already terminalProvider and pending lifecycle disagreePreserve provider truth and ledger history; route to late-disbursement or suspense review
Provider payout succeeds, ledger outcome is unknownCross-system saga is incompleteDo not call the provider again; reconcile the original pair and then follow the approved exception workflow
Collection succeeds, allocation failsCash remains confirmed but unappliedKeep the receipt immutable and retry a newly persisted allocation plan only after resolving deterministic conflicts
Repayment return cannot fully reconsume capacityBorrower redrew capacity restored by the original principal paymentSplit the new plan into backed and explicit over-limit exposure; never suppress recreated debt
Write-off chain failsReceivable, capacity, or close inputs disagreeCommit nothing, reconcile each source balance, and re-approve a new complete write-off plan
Natural pending expiryTigerBeetle released an unfinished reservationReconcile provider state and close application work explicitly; do not use expiry as a normal servicing scheduler

Stable TigerBeetle IDs are the idempotency boundary for operation payloads. Persist exact payloads and their order before first submission. Compare IDs, accounts, amount, pending ID, user data, timeout, ledger, code, and flags on every reconciliation lookup.

The gateway can return a bounded conflict list. An omitted item is not proof of success; resolve every submitted ID. A normal unlinked batch may partially succeed, while a correctly terminated linked chain succeeds or fails as one chain.

Test scenarios

Run these scenarios with synthetic borrowers in an isolated non-production database. Explicitly post or void pending test draws instead of relying on natural expiry.

ScenarioExpected result
Account bootstrap and replayAll 20 accounts exact-match; duplicate provisioning creates no second account
Limit approval and increaseAvailable capacity rises by the exact approved amount once
Limit reductionOnly unused capacity moves to revoked; principal and receivables remain unchanged
Concurrent draws at the boundaryAt most the affordable linked reservation pairs commit
Draw second-leg failureNeither pending capacity nor pending principal exists
Draw full postDrawn exposure and principal receivable rise by the same amount
Draw partial postExact actual amount posts to both ledgers and both unused remainders release
Draw explicit voidBoth reservations close atomically with no posted principal
Post and void raceOne terminal pair wins; the conflicting alternative enters reconciliation
Pending expiryBoth pending balances release, but provider and application state remain explicitly unresolved
Interest accrualReceivable and accrual control rise once under the persisted rate and rounding version
Fee assessment and unpaid waiverFee posts once; waiver cannot exceed either the assessed control or unpaid receivable
Paid fee refundA linked control-to-unapplied-to-clearing chain creates the refund without a negative receivable
Repayment receipt replayOne collection-to-unapplied transfer exists for the provider event
Allocation orderFee, interest, and principal match the persisted legal policy snapshot
Excess repaymentResidual remains unapplied; no receivable becomes negative
Concurrent allocationUnapplied and receivable constraints reject over-allocation atomically
Revolving principal paymentOnly principal moves drawn exposure back to available
Closed-line principal paymentPrincipal moves drawn exposure to revoked capacity instead of reopening the line
Over-limit principal paymentExplicit over-limit exposure reduces before capacity can reopen
Posted draw returnNew compensating legs reduce principal and drawn exposure without deleting the original
Repayment return before redrawReceivables and drawn exposure are reinstated in one new linked chain
Repayment return after redrawAvailable is reconsumed only as far as possible; the remainder becomes explicit over-limit exposure
Delinquency freezeReconciled unused capacity moves to revoked and stale callers cannot reserve it
Principal, interest, and fee write-offExact performing receivables move to charged-off positions; principal capacity follows atomically
Write-off replayOne immutable charge-off chain exists
RecoveryRecovery consumes the matching charged-off receivable; principal moves to recovered, never available
Excess or duplicate recoveryPer-write-off uniqueness and caps reject it; account constraints backstop aggregate concurrency
Recovery reversalNew opposite legs restore charged-off money and principal capacity positions without rewriting history
Excess recovery reversalPer-recovery uniqueness and caps reject it; no charged-off balance is inflated
Same ID with changed fieldAutomation quarantines the event instead of accepting it as idempotent
Ambiguous responseExact lookup precedes any identical retry
Partial or mismatched lookupAutomation stops and opens a reconciliation incident
Shared query without ledgerQuery is rejected; ledger-scoped queries succeed
Timeout above plan policyRequest is rejected before a TigerBeetle write
No PIILedger records and logs contain opaque references only

Schema validation proves only that the request matches current accepted fields and ranges. It does not prove underwriting authorization, account existence, ledger consistency, provider finality, linked-chain correctness, balance sufficiency, or jurisdictional compliance.

Production operations

  • Reconcile approved limit, available/drawn/over-limit/revoked/charged-off/recovered capacity, performing and charged-off principal/interest/fee receivables, unapplied cash, clearing accounts, provider events, servicing state, and general-ledger exports. Every edge must be traceable in both directions.
  • Run draw, allocation, payment-return, write-off, and recovery workers from durable intent records. Use unique business-event and attempt-version constraints before allocating stable transfer IDs.
  • Serialize state transitions that require a fresh allocation plan, especially payment returns, limit freezes, charge-offs, and facility closure. TigerBeetle constraints remain the concurrent backstop.
  • Reconcile every pending draw promptly with a post or void. Alert before timeout and on natural expiry. Pending timeout is an in-flight safety valve, not a servicing scheduler.
  • Version underwriting decisions, limits, contract terms, APR and day-count rules, fee policy, allocation order, rounding, delinquency, write-off, and recovery policy. Retain the exact versions used by every event.
  • Treat clearing, accrual, assessment, charged-off receivable, recovered exposure, and suspense accounts as operational positions until finance maps them to the approved general ledger. Do not label a raw balance as recognized revenue, cash, recovery income, or credit loss without that mapping.
  • Give every suspense item an owner, source evidence, reason, opened time, aging target, and approved resolution transfer. Alert on amount and age.
  • Monitor create-result codes, ambiguous requests, pending age, provider lag, allocation lag, over-limit exposure, delinquency transitions, reconciliation differences, write-off approvals, recovery, plan quotas, and backup posture separately.
  • Log sanitized facility, business-event, provider-event, database, ledger, account, transfer, policy-version, attempt-version, result-index, and result-code fields. Never log credentials, bank details, decision evidence, agreements, or unrestricted provider payloads.
  • Keep the linked chain below both the public batch maximum and the active plan's event-per-request limit. Never split one invariant across requests to work around a limit.
  • Rotate database-scoped keys with controlled overlap. Separate staging and production and narrow service responsibilities even when generated keys include both read and write scopes.
  • Exercise ambiguous-response, provider/ledger split-failure, repayment-return-after-redraw, write-off, restore, and reconciliation incident procedures on an eligible non-production environment before launch.
  • Use a production-eligible Parix topology and written support/SLA posture. Developer and Dedicated Single Node are not production plans or emergency production fallbacks.