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