Docs/Use casesv0.0.1-preview

Refunds and reversals

Full and partial refunds as first-class reverse transfers — correlated to the original by convention, applied exactly once, netting out on the books.

On this page
  1. The flow
  2. The correlation convention
  3. What the engine does and doesn’t enforce
  4. Disputes and chargebacks
  5. Related

A refund is not an edit — the original payment happened, and the books should say so. On a ledger, a refund is a reverse transfer: a new durable record moving value back, leaving both the payment and its reversal in the permanent history. The snippets use the Python SDK.

The flow

R = c.create_ledger(LedgerInstance(ledger_id=0, currency=840)).ledger_id
CUSTOMER, MERCHANT, PSP = 11001, 12001, 1   # integer ids
for a in (CUSTOMER, MERCHANT):
    c.create_account(AccountCreate(id=a, ledger_id=R, currency=840,
                                   limit_low=-10**12, limit_high=10**12))

# The original payment
c.transfer(TransferCreate(source_id=PSP, ledger_id=R, currency=840,
        debit_account_id=CUSTOMER, credit_account_id=MERCHANT,
        amount=2_500, txn_id="order-9"))

# A partial refund: reverse direction, its own identity NAMING the original
c.transfer(TransferCreate(source_id=PSP, ledger_id=R, currency=840,
        debit_account_id=MERCHANT, credit_account_id=CUSTOMER,
        amount=1_000, txn_id="refund-order-9-1"))

# A second partial refund is simply the next identity in the series
c.transfer(TransferCreate(..., amount=800, txn_id="refund-order-9-2"))

Outcomes: both refunds post, the merchant’s net lands at exactly 2500 − 1800 = 700, and replaying refund-order-9-1 returns OK_DUPLICATE — a redelivered refund webhook cannot refund twice. That last property is the one that matters most: refund double-processing is one of the most expensive bugs in payments, and here it’s structurally impossible.

The correlation convention

The link between refund and original is the txn_id naming scheme, and it works in both directions because lookups are by key:

c.get_transaction_by_key(PSP, R, 840, "order-9")           # the original, amount 2500
c.get_transaction_by_key(PSP, R, 840, "refund-order-9-1")  # the refund, amount 1000

Adopt one scheme and enforce it in your refund service: refund-{original_txn_id}-{n}. The counter n gives each partial refund a distinct identity (so retries of refund #2 don’t collide with refund #1), and makes “list all refunds for order-9” a deterministic key walk. This is a convention, not an engine-enforced reference — an engine-level linked-reference field is on the roadmap; until then the naming scheme plus by-ref queries carries refund flows.

What the engine does and doesn’t enforce

  • Enforced: each refund applies exactly once; every record is durable and immutable; the merchant account can be floored (limit_low) so refunds can’t drive it below an allowed exposure.
  • Yours to enforce: that refunds don’t exceed the original (sum(refunds) ≤ 2500). The engine sees independent transfers; your refund service checks the remaining refundable amount before submitting — and because client-port reads are linearizable by default, that check reads current truth, not a stale replica. Make it on the client port, or enable linearizable query-port reads.

Disputes and chargebacks

A chargeback that moves money is this same reverse-transfer pattern with its own naming (cb-{txn_id}). A refusal that doesn’t move money — you decline the refund request itself — is an explicit reject with your reason code, so the denial is on the books too.

Card payments · Idempotency · Queries