Marketplaces
Implement seller holds, atomic order allocation, fees, reserves, refunds, chargebacks, and payouts on Parix.
Overview
Use Parix as the value ledger behind a marketplace when one captured payment must fund seller proceeds, platform fees, reserves, refunds, chargebacks, and payouts without being duplicated by retries.
This guide uses integer cents in ledger 7001. One order for 10000 cents is captured into buyer clearing and then allocated atomically as:
| Allocation | Amount | Destination |
|---|---|---|
| Seller proceeds | 8500 | Seller pending |
| Platform fee | 1000 | Platform fee |
| Reserve | 500 | Reserve |
The three allocation transfers form one linked TigerBeetle chain. If any leg fails, none of the three legs is committed.
Architecture and ownership
Parix is the system of record for value movement. It is not the order, payment, identity, tax, or bank-payout system.
| Component | Owns | Does not own |
|---|---|---|
| Marketplace application | Orders, seller eligibility, release policy, fee calculation, stable ID mapping, sagas | Authoritative posted ledger balances |
| Parix | Account and transfer records, balance constraints, linked-batch atomicity | Order status, delivery evidence, refund policy, seller identity |
| Payment processor | Authorization, capture, refund, dispute, and processor-settlement state | Seller subledger allocation |
| Payout provider or bank | External payout instruction and bank-settlement status | Seller available balance |
| Reporting and reconciliation | Cross-system matching, break investigation, reviewed adjustments | Silent mutation or deletion of previously committed ledger history |
Use a durable outbox or workflow to move between these systems. A Parix commit and an external processor or bank call are not one distributed transaction.
Ledger model
Ledgers
| Ledger | Unit | Purpose |
|---|---|---|
7001 | USD cents | Order capture, seller balances, platform fees, reserves, refunds, chargebacks, and payouts |
Use a different ledger for each currency or non-fungible unit. Never transfer between accounts with different ledgers. Currency conversion is an application-owned business event with separately priced legs.
Accounts
The example treats seller balances as credit-normal: available value is credits_posted - debits_posted. history is flag 8; debits_must_not_exceed_credits is flag 2; together they are 10.
| Role | Example ID | Code | Flags | Balance meaning and ownership |
|---|---|---|---|---|
| Processor settlement | 710000000000000001 | 100 | 8 | External capture/settlement source; reconcile to processor reports |
| Buyer clearing | 710000000000000002 | 101 | 10 | Captured amount waiting for allocation; should return to zero per order |
| Seller pending | 710000000000000003 | 201 | 10 | Posted seller proceeds not yet eligible for payout |
| Seller available | 710000000000000004 | 202 | 10 | Posted seller proceeds eligible for payout |
| Platform fee | 710000000000000005 | 301 | 10 | Platform fee position; cannot fund refunds beyond posted fees |
| Reserve | 710000000000000006 | 302 | 10 | Amount retained under marketplace reserve policy; no silent overdraft |
| Payout clearing | 710000000000000007 | 401 | 10 | Ledger-approved payouts waiting for external bank settlement |
| Refund/chargeback clearing | 710000000000000008 | 402 | 10 | Outbound refunds and disputes awaiting processor reconciliation |
In a real marketplace, create one pending and one available account for each seller and currency. Store seller identity and the account-ID mapping in the application database; do not put personally identifiable information in TigerBeetle IDs or user-data fields.
Transfer codes
| Code | Event | Debit | Credit |
|---|---|---|---|
100 | Processor capture | Processor settlement | Buyer clearing |
110 | Order seller allocation | Buyer clearing | Seller pending |
111 | Order platform fee | Buyer clearing | Platform fee |
112 | Order reserve | Buyer clearing | Reserve |
120 | Release seller proceeds | Seller pending | Seller available |
130 | Initiate payout | Seller available | Payout clearing |
140 | Refund | Seller pending/available, fee, or reserve | Refund clearing |
141 | Chargeback | Seller available or reserve | Refund/chargeback clearing |
150 | Reviewed reconciliation | Account with excess position | Account with deficient position |
Keep capture, allocation, release, payout, refund, chargeback, and reviewed adjustment codes distinct even when they move value between the same accounts.
Invariants
| Invariant | Enforcement |
|---|---|
| An order allocation conserves captured value | Require seller proceeds + fee + reserve = captured amount before submission |
| An allocation is all-or-nothing | Send every allocation leg in one batch; set linked on every non-final leg and never on the final leg |
| Seller pending and available cannot overspend | Apply debits_must_not_exceed_credits to both seller accounts |
| Fee, reserve, and clearing cannot overspend | Apply the same no-overdraft flag to platform fee, reserve, payout clearing, and refund clearing |
| A business event is applied once | Persist one stable transfer ID per event/leg before the first request and reuse it for lookup and retry |
| History remains explainable | Refunds, chargebacks, payout returns, and reconciliation fixes are new compensating transfers |
| Clearing accounts converge | Reconcile buyer, refund/chargeback, and payout clearing against order, processor, and bank records |
| Currency does not cross a ledger boundary | Give every account and transfer in one currency the same ledger |
Seller pending account versus a pending transfer
Seller pending in this model is an ordinary account containing posted credits. It represents a marketplace release policy: the seller owns proceeds, but the application has not made them payout-eligible.
A TigerBeetle pending transfer is different. It uses TransferFlags.pending, affects pending debit/credit fields, has a timeout, and must later be posted or voided with another transfer that references pending_id. Use that mechanism for a genuine two-phase event such as payment authorization. Do not mark the allocation transfer pending merely because its destination account is named seller pending.
Lifecycle movements
| Event | Ledger action |
|---|---|
| Release | Debit seller pending and credit seller available with one stable release ID |
| Payout | Debit seller available and credit payout clearing before enqueueing the external payout; compensate a definitive payout failure |
| Refund | Reverse the original seller, fee, and reserve allocation as policy requires, using a linked compensating batch |
| Chargeback | Debit seller available and/or reserve into chargeback clearing; route any shortfall to a reviewed receivable policy, not silent overdraft |
| Reconcile | Match order allocations and clearing positions to processor/bank records; post only approved, uniquely identified adjustment transfers |
For a partial refund, calculate each compensating leg with a documented rounding rule and ensure the legs sum to the refund amount. Never delete or rewrite the original allocation.
Before you begin
You need:
- a Parix database in Ready state and its immutable database UUID;
- an integer unit, ledger IDs, account codes, transfer codes, and balance convention approved by engineering and finance;
- a durable mapping from each order, capture, allocation leg, release, refund, chargeback, and payout to a stable 128-bit transfer ID;
- an OAuth session for CLI work and a specific-database API key for server-side application traffic; and
- a reconciliation owner and procedures for processor, bank, and ledger breaks.
Choose the plan for the workload, not just for the dashboard features:
| Plan | Workload posture |
|---|---|
| Developer | Learning, prototypes, and integration tests only; shared and quota-limited |
| Dedicated Single Node | Isolated development, staging, or non-HA work; not a production plan |
| Production HA/Production 6 | Production workloads requiring a supported production topology |
| Enterprise | Contract-defined production topology, networking, compliance, or support |
All plans use the Parix HTTPS gateway. Do not configure replica addresses or a native TigerBeetle protocol connection.
The public schema accepts at most 8,190 accounts, transfers, or lookup IDs per array request. The active plan can impose a lower events-per-request limit, so read the selected database dashboard and batch below the lower limit.
On a shared Developer database, query_accounts and query_transfers require a ledger filter. Use ledger: 7001 in API/SDK filters or --ledger 7001 in the CLI. A dedicated database may omit the ledger when an intentionally unscoped query is appropriate.
Dashboard walkthrough
- Select the organization, open the database, and confirm Ready, the intended plan, and the database UUID.
- Review effective quotas and events-per-request on the dashboard.
- Select Connect. Generate a Specific database API key for the marketplace service and store the one-time secret in a secret manager.
- Open Query, select Query accounts, set ledger
7001, and run a ledger-scoped smoke query. On Shared, the first query for an unseen external ledger can allocate its project-ledger mapping and consume quota, so use the planned test ledger rather than treating the query as side-effect-free. - Switch to Create accounts only in the intended development or test database and review every ID, ledger, code, and flag before selecting Run.
The create-accounts form is a live write surface for the selected database. It does not show an order-specific marketplace model and it is not a dry run.
Live-write warning: Query can execute
create_accountsandcreate_transfers. Those operations write immediately to the selected database. Generated IDs are conveniences for manual testing, not your production idempotency strategy. Use a non-production database and verify the selected database, stable IDs, ledger, codes, flags, and amounts before running a write.
CLI walkthrough
The commands below use the latest published @parix/cli package. The CLI is an operator/developer tool: it uses browser OAuth and the active organization. Production services must use a server-side API key, not the CLI session file.
Install the exact version globally, sign in to the intended environment, and record the database UUID:
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"parix --version should report the installed package version. The CLI defaults to https://parix.io. Use --base-url <other-origin> only when intentionally targeting another deployment; sessions and database IDs are not interchangeable across environments.
Create marketplace-accounts.json. The request body is a bare JSON array, not { "accounts": [...] }.
[
{ "id": "710000000000000001", "ledger": 7001, "code": 100, "flags": 8 },
{ "id": "710000000000000002", "ledger": 7001, "code": 101, "flags": 10 },
{ "id": "710000000000000003", "ledger": 7001, "code": 201, "flags": 10 },
{ "id": "710000000000000004", "ledger": 7001, "code": 202, "flags": 10 },
{ "id": "710000000000000005", "ledger": 7001, "code": 301, "flags": 10 },
{ "id": "710000000000000006", "ledger": 7001, "code": 302, "flags": 10 },
{ "id": "710000000000000007", "ledger": 7001, "code": 401, "flags": 10 },
{ "id": "710000000000000008", "ledger": 7001, "code": 402, "flags": 10 }
]Submit and inspect the result:
parix tb create-accounts "$PARIX_DATABASE_ID" --file ./marketplace-accounts.json --jsonFor this example, record the processor capture into buyer clearing:
parix tb create-transfers "$PARIX_DATABASE_ID" \
--id 720000000000000001 \
--from 710000000000000001 \
--to 710000000000000002 \
--amount 10000 \
--ledger 7001 \
--code 100 \
--jsonCreate marketplace-order-split.json. Every non-final leg has flag 1 (linked); the final leg has flag 0. Reordering the array changes which leg must omit linked.
[
{
"id": "720000000000000002",
"debit_account_id": "710000000000000002",
"credit_account_id": "710000000000000003",
"amount": "8500",
"ledger": 7001,
"code": 110,
"flags": 1
},
{
"id": "720000000000000003",
"debit_account_id": "710000000000000002",
"credit_account_id": "710000000000000005",
"amount": "1000",
"ledger": 7001,
"code": 111,
"flags": 1
},
{
"id": "720000000000000004",
"debit_account_id": "710000000000000002",
"credit_account_id": "710000000000000006",
"amount": "500",
"ledger": 7001,
"code": 112,
"flags": 0
}
]Submit the atomic allocation, verify the stable transfer IDs, and query the ledger:
parix tb create-transfers "$PARIX_DATABASE_ID" --file ./marketplace-order-split.json --json
parix tb lookup-transfers "$PARIX_DATABASE_ID" --ids 720000000000000002,720000000000000003,720000000000000004 --json
parix tb query-accounts "$PARIX_DATABASE_ID" --ledger 7001 --limit 20 --jsonA successful create response has persisted: true and an empty responsePayload ([]). An empty create-result array means every item succeeded. HTTP 200 with persisted: false is not a committed write. A non-empty create-result array identifies rejected items by array index and numeric TigerBeetle result code; through the public HTTP route that conflict is returned as HTTP 409 with tbResults. An unlinked batch can have successful and rejected items, while a correctly linked chain commits or rejects as a unit. Public conflict details are currently capped at the first 10 results, so reconcile every submitted stable ID; an unlisted item must not be assumed successful or failed.
Release proceeds only after the marketplace release condition is durable:
parix tb create-transfers "$PARIX_DATABASE_ID" \
--id 720000000000000005 \
--from 710000000000000003 \
--to 710000000000000004 \
--amount 8500 \
--ledger 7001 \
--code 120 \
--jsonFor a partial refund while seller proceeds still sit in seller available, return 1000 cents from seller available, 200 from platform fee, and 100 from reserve into refund clearing. Save marketplace-partial-refund.json. Every non-final leg is linked.
Do this before a full payout. Constrained accounts (flags: 10) reject a debit that would overspend posted credits, so a refund that tries to pull 1000 from seller available after you have already paid out the full 8500 fails with exceeds_credits and the linked chain aborts.
[
{
"id": "720000000000000007",
"debit_account_id": "710000000000000004",
"credit_account_id": "710000000000000008",
"amount": "1000",
"ledger": 7001,
"code": 140,
"flags": 1
},
{
"id": "720000000000000008",
"debit_account_id": "710000000000000005",
"credit_account_id": "710000000000000008",
"amount": "200",
"ledger": 7001,
"code": 140,
"flags": 1
},
{
"id": "720000000000000009",
"debit_account_id": "710000000000000006",
"credit_account_id": "710000000000000008",
"amount": "100",
"ledger": 7001,
"code": 140,
"flags": 0
}
]parix tb create-transfers "$PARIX_DATABASE_ID" --file ./marketplace-partial-refund.json --json
parix tb lookup-transfers "$PARIX_DATABASE_ID" --ids 720000000000000007,720000000000000008,720000000000000009 --jsonAfter the refund, seller available holds 7500 cents (8500 − 1000). Initiate payout for that remaining balance only after the release is durable, the refund is durable, and the external payout instruction is ready:
parix tb create-transfers "$PARIX_DATABASE_ID" \
--id 720000000000000006 \
--from 710000000000000004 \
--to 710000000000000007 \
--amount 7500 \
--ledger 7001 \
--code 130 \
--jsonThe constrained fee and reserve accounts also reject a refund leg that would overspend their posted balances; treat that create conflict as a business decline and open a reviewed receivable path rather than weakening the flags. If the product must refund after a full payout, fund the seller leg from payout clearing (or another funded position), not from an empty seller available account.
The numeric IDs above are stable sample values. Allocate and persist unique IDs for your own business events; never reuse these values for unrelated events or generate a new ID merely because a request timed out. These CLI steps leave user_data_* at zero; set a non-zero correlation only when you will query by order or claim ID. See Optional user_data fields.
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 adapter accepts { baseUrl, apiKey, databaseId }, converts bigint fields to decimal strings on the wire, and converts bigint response fields back to bigint. It does not expose the raw gateway envelope field persisted, so durable workflows require exact post-write lookup of every stable ID before advancing. Use full TigerBeetle objects in application code even though the HTTP schema permits some zero-valued fields to be omitted.
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 = 7001;
const accountId = {
processorSettlement: 710000000000000001n,
buyerClearing: 710000000000000002n,
sellerPending: 710000000000000003n,
sellerAvailable: 710000000000000004n,
platformFee: 710000000000000005n,
reserve: 710000000000000006n,
payoutClearing: 710000000000000007n,
refundClearing: 710000000000000008n,
} as const;
// Persist these IDs with the order before the first write. Never allocate IDs inside a retry loop.
const transferId = {
capture: 720000000000000001n,
sellerAllocation: 720000000000000002n,
feeAllocation: 720000000000000003n,
reserveAllocation: 720000000000000004n,
} as const;
function account(id: bigint, 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,
code,
flags,
timestamp: 0n,
};
}
function transfer(input: {
id: bigint;
debitAccountId: bigint;
creditAccountId: bigint;
amount: bigint;
code: number;
flags?: 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: 0n,
user_data_64: 0n,
user_data_32: 0,
timeout: 0,
ledger,
code: input.code,
flags: input.flags ?? TransferFlags.none,
timestamp: 0n,
};
}
function assertEmptyResults(operation: string, results: CreateAccountResult[] | CreateTransferResult[]): void {
if (results.length === 0) return;
const capacityDecline = results.some((item) => item.result === CreateTransferError.exceeds_credits);
throw new Error(
`${operation} rejected${capacityDecline ? ' (capacity)' : ''}: ${JSON.stringify(results)}`,
);
}
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 hasSameImmutableAccountFields(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 hasSameImmutableFields(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((item) => item.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 && hasSameImmutableAccountFields(item, intended);
})
) {
throw new Error('Account IDs are only partially present or exist with different immutable fields');
}
return true;
}
async function transfersExistExactly(label: string, batch: Transfer[]): Promise<boolean> {
const found = await client.lookupTransfers(batch.map((item) => item.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 && hasSameImmutableFields(item, intended);
})
) {
throw new Error(`${label} IDs are only partially present or exist with different immutable fields`);
}
return true;
}
async function createAccountsOrResolveAmbiguity(batch: Account[]): Promise<void> {
// This lookup makes a new process safe after an earlier process committed but
// stopped before persisting the HTTP response.
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('Accounts were not found; retry only with the same IDs and payload', { 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');
}
assertEmptyResults('createAccounts', results);
}
async function createTransfersOrResolveAmbiguity(label: string, batch: Transfer[]): Promise<void> {
// Stable-ID lookup is also the first step after a worker or process restart.
if (await transfersExistExactly(label, batch)) return;
let results: CreateAccountResult[] | CreateTransferResult[];
try {
results = await client.createTransfers(batch);
} catch (cause) {
if (wasDefinitelyRejected(cause)) throw cause;
// A timeout, network failure, 500, or 503 can occur after commit. Lookup before retrying.
if (await transfersExistExactly(label, batch)) return;
throw new Error(`${label} was not found; retry only with the same IDs and payload`, { 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`);
}
assertEmptyResults(label, results);
}
async function main(): Promise<void> {
const constrainedHistory = AccountFlags.history | AccountFlags.debits_must_not_exceed_credits;
const accounts = [
account(accountId.processorSettlement, 100, AccountFlags.history),
account(accountId.buyerClearing, 101, constrainedHistory),
account(accountId.sellerPending, 201, constrainedHistory),
account(accountId.sellerAvailable, 202, constrainedHistory),
account(accountId.platformFee, 301, constrainedHistory),
account(accountId.reserve, 302, constrainedHistory),
account(accountId.payoutClearing, 401, constrainedHistory),
account(accountId.refundClearing, 402, constrainedHistory),
];
await createAccountsOrResolveAmbiguity(accounts);
await createTransfersOrResolveAmbiguity('capture', [
transfer({
id: transferId.capture,
debitAccountId: accountId.processorSettlement,
creditAccountId: accountId.buyerClearing,
amount: 10000n,
code: 100,
}),
]);
const allocation = [
transfer({
id: transferId.sellerAllocation,
debitAccountId: accountId.buyerClearing,
creditAccountId: accountId.sellerPending,
amount: 8500n,
code: 110,
flags: TransferFlags.linked,
}),
transfer({
id: transferId.feeAllocation,
debitAccountId: accountId.buyerClearing,
creditAccountId: accountId.platformFee,
amount: 1000n,
code: 111,
flags: TransferFlags.linked,
}),
transfer({
id: transferId.reserveAllocation,
debitAccountId: accountId.buyerClearing,
creditAccountId: accountId.reserve,
amount: 500n,
code: 112,
flags: TransferFlags.none,
}),
];
await createTransfersOrResolveAmbiguity('order allocation', allocation);
}
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();
}For retries, persist the exact payload as well as its IDs. If lookup finds an existing ID, compare its immutable fields with the intended event before declaring success. An ID that exists with different fields is a reconciliation incident, not an idempotent success.
Failure and retry handling
| Signal | Meaning | Action |
|---|---|---|
Empty create result [] | Adapter reported no item conflicts | Still look up every stable ID and exact immutable fields before advancing. The adapter does not expose gateway persisted. |
| Nonempty create result | Indexed items were rejected; unlinked items may still have committed | Reconcile every submitted ID. The adapter unwraps HTTP 409 + tbResults into this array; only the first 10 conflict details may be returned. |
| Thrown HTTP error without item results | Auth, schema, plan, bare 409, or transport failure | Classify by status. For ambiguous outcomes (5xx, timeout, disconnect), look up before retrying identical IDs. |
HTTP 400 | Invalid strict payload or unsupported value | Correct the payload; do not retry unchanged |
HTTP 401 or 403 | Invalid credential, scope, organization, or database | Fix credentials or resource scope; do not retry in a loop |
HTTP 402 | Developer billing state blocks the operation | Restore the subscription/billing state; do not retry unchanged |
HTTP 404 | Database, profile, route, or visible resource is absent | Verify environment and database UUID; do not retry blindly |
HTTP 429 | Rate/admission saturation or a durable plan quota | Back off with jitter only for transient rate/admission limits; for quota exhaustion, wait for reset or change the plan/workload |
Timeout, disconnect, HTTP 500 or 503 | Outcome may be ambiguous | Lookup every stable transfer ID; accept exact matches, investigate partial visibility, retry same IDs only when absent |
| Insufficient seller balance result | Release, refund, chargeback, or payout violates constraint | Stop the business action; apply reserve/receivable policy through reviewed transfers |
| External payout definitively failed | Ledger payout clearing committed but bank did not | Submit a uniquely identified compensating transfer from payout clearing back to seller available |
Linked does not mean “continue after an error.” A linked-chain failure rejects the chain. Treat any unexpected observation of only part of a linked business operation as an incident and halt automated compensation until reconciled.
Test scenarios
Run these scenarios against a dedicated non-production database with the same schema and limits expected in production.
| Scenario | Setup/action | Expected result |
|---|---|---|
| Happy-path capture and split | Capture 10000; allocate 8500 + 1000 + 500 | Empty result arrays; buyer clearing returns to zero; three destinations credited |
| Duplicate delivery | Submit the exact same split IDs and payload again | Conflict/existing results; no second allocation |
| Same ID, changed amount | Resubmit one transfer ID with a different amount | Conflict; reconciliation alert; never accepted as success |
| Linked middle-leg failure | Use an invalid fee account in the three-leg chain | No allocation leg commits |
| Open linked chain | Set linked on the final leg | Chain rejected; no allocation leg commits |
| Release before eligibility | Ask application workflow to release an ineligible order | Application rejects before a ledger write |
| Double release | Deliver the release event twice with the same stable ID | Only the original release exists |
| Refund before release | Reverse seller pending, fee, and reserve in a linked batch | Refund clearing receives exact refund; available is unchanged |
| Refund after release | Reverse available and fee/reserve legs before full payout | Exact compensating history; seller available reduced; remaining balance can pay out |
| Refund after full payout | Debit empty seller available after paying out all proceeds | Linked chain rejected (exceeds_credits); fund seller leg from payout clearing instead |
| Chargeback after payout | Apply seller/reserve recovery after external dispute | Chargeback clearing matches processor event; any shortfall is explicit |
| Ambiguous write response | Drop the client connection after sending a batch | Lookup determines committed/absent before any same-ID retry |
| Payout provider failure | Commit payout clearing, then return a definitive provider failure | Unique compensation restores seller available |
| Shared query without ledger | Query a Developer database without ledger | Request rejected; adding ledger 7001 succeeds |
| Plan batch boundary | Test at effective plan limit and one item above it | At-limit request is handled; above-limit request is rejected before business retry |
Production operations
- Use Production HA, Production 6, or a contract-defined Enterprise plan for production. Developer and Dedicated Single Node are non-production plans.
- Keep the API key in server-side secret storage, scope it to the marketplace database, rotate it, and never expose it to browser or mobile clients.
- Persist business-event IDs and exact intended payloads before submission. Include IDs—not secrets or sensitive full payloads—in structured logs.
- Serialize state-machine transitions per order or make every transition compare-and-set safe. Ledger idempotency does not prevent an application from choosing the wrong next business event.
- Reconcile buyer clearing per order, processor settlement per settlement period, payout clearing per provider payout, and refund/chargeback clearing per processor event.
- Alert on non-empty result arrays, ambiguous outcomes, partial linked-batch observations, stale clearing balances, overdue seller-pending balances, and payout/refund mismatches.
- Keep reviewed adjustment permissions separate from normal order processing. Adjustments use dedicated codes, approvals, and immutable business references.
- Batch below both the 8,190 schema maximum and the active plan limit. Preserve linked chains within one request; never split one chain across HTTP requests.
- Document backup, restore, recovery, region, support, and reconciliation objectives for the selected production plan.
