Contents

Refund

Return a completed spend, reversing the posting that recorded it.

Source src/operations/refund.ts#L40refundsrc/operations/registry.ts#L60refund

API refund

refund reverses a completed spend. It returns the buyer the full price they paid, then unwinds the sale account by account.

You name the order, not the buyer or the amount. The handler reads those off the recorded sale. Pass the orderId from the original spend. A reason for the audit trail is optional:

const outcome = await economy.submit({
  kind: 'refund',
  idempotencyKey: 'idem_refund_1',
  actor: { kind: 'system', service: 'support' },
  orderId: 'ord_1',
  reason: 'changed mind',
});
// → { status: "committed", transaction: { id: "txn_…", … } }
curl -s https://economy.example/submit \
  -H 'content-type: application/json' \
  -d '{
    "kind": "refund",
    "idempotencyKey": "idem_refund_1",
    "actor": { "kind": "system", "service": "support" },
    "orderId": "ord_1",
    "reason": "changed mind"
  }'

The buyer gets the full price back. Each seller is debited only up to the balance they still hold. Any uncollectable remainder is booked as a debt to the platform.

Parameters

The payload is one refund variant of Operation. The payload fields, beyond the kind tag, are:

FieldTypeDefaultDescription
idempotencyKeystring(required)A retried submit with the same key runs at most once. See idempotency.
actorPrincipal(required)Who is asking. Must be system or operator, see Authorization.
orderIdstring(required)The order to reverse, from the original spend. A blank or whitespace-only value throws.
reasonstringOptional note recorded in the reversing transaction’s metadata.

Returns

refund resolves to an Outcome.

  • committed: the reversal posted; transaction is the reversing posting.
  • duplicate: the order was already reversed (by an earlier refund or a clawback); transaction is the recorded reversal.
  • rejected: no sale was found for the order. The only RejectionCode here is UNKNOWN_ORDER.

Postings

The reversal mirrors the sale’s double-entry posting leg for leg, in three groups: the buyer’s accounts raised in full, each seller’s earned and REVENUE clawed back but capped at what that account still holds, and whatever couldn’t be clawed back credited to SYSTEM.RECEIVABLE so debits and credits still cancel. If a seller already spent or paid out their cut, only the part still there comes back. The shortfall is always denominated in CREDIT, since a sale only moves CREDIT.

A sale charged under the accrual split reverses through its own accrual rows instead. The refund claims the order’s rows: a share still pending is clawed back out of the exact SETTLEMENT_ACCRUAL shard that holds it, and a share already drained is recovered through RECEIVABLE, repaid by the drain sweep’s netting. The path is data-driven — the rows say where the shares went — so it holds even when ACCRUAL_DRAIN has since been turned off.

Authorization

refund is platform-initiated only. An end user (actor.kind === "user") may never run it: a self-serve refund debits a seller’s earned balance, which is a fraud vector. Only a system service or a human operator may call it, see actors and authorization.

A user actor is declined with an AUTH.UNAUTHORIZED fault before any work begins.

Reason codes

refund returns one RejectionCode:

CodeWhen
UNKNOWN_ORDERNo sale was recorded for orderId.

A malformed request throws instead:

CodeWhen
OP.MALFORMEDA blank or whitespace-only orderId. It is not a rejection: it surfaces as a client error instead of degrading to a silent UNKNOWN_ORDER.

Preconditions and invariants

A refund and an order-tied clawback both reverse the same sale, so only one may run. Before posting, refund claims a second, order-scoped key, reversed:<orderId>. Whoever claims first reverses; the other path returns the recorded transaction as duplicate.

For a disputed sale, this makes the order of operations a runbook rule: refund first, then claw back the wallet. The refund is the only step that pulls the seller’s earnings back — see refund first, then claw back.

The order-scoped claim is recorded in the same database transaction as the reversal. If the refund rolls back, the claim rolls back too, so the order can still be reversed later.

After the reversal commits, refund revokes the buyer’s entitlement to the SKU (for a gift, the recipient’s). That revocation runs in the same database transaction. The entitlement it removes is what a UI checks through read.entitled. It is a no-op if that user was never granted the SKU; for a sale predating ownership-at-purchase, nothing is revoked.

The reversing posting balances to zero across each currency, exactly as the sale did, so conservation holds.

See also