Clawback
Recover funds after a dispute, driven by a processor dispute webhook.
Source src/operations/clawback.ts#L45 · handleClawbacksrc/webhooks.ts#L209 · toClawback
Clawback
clawback reclaims credits from a user's spendable balance after a bank chargeback or fraud recovery. The dollars themselves move back at the payment Processor, outside this ledger. The handler books only the credit side of the loss.
You name the user and how much to reclaim. When the dispute is tied to a purchase, you also name the disputed orderId:
const outcome = await economy.submit({
kind: "clawback",
idempotencyKey: "whk:evt_5521",
actor: { kind: "system", service: "webhook:billing" },
userId: "usr_a1",
amount: { currency: "CREDIT", minor: 5000n },
orderId: "ord_8821",
reason: "fraudulent_charge",
});
// → { status: "committed", transaction: { id: "txn_…", … } }
In practice you rarely build this by hand. A verified dispute webhook maps to a clawback through toClawback, so the inbound chargeback callback is the usual caller.
Parameters
The payload fields, beyond the idempotencyKey and actor every operation carries:
| Field | Type | Default | Description |
|---|---|---|---|
userId | string | — | The user whose spendable credits the chargeback reclaims. |
amount | Amount | — | How much to reclaim. Must be CREDIT and positive. |
orderId | string | — (untied) | The disputed order, when the provider names one. Ties the clawback to a refund of the same order so the two stay mutually exclusive. |
key | string | — | A free-form reference recorded on the posting (e.g. the network's case id). |
reason | string | — | A free-form reason recorded on the posting (e.g. the chargeback reason code). |
A present-but-blank orderId is malformed. Every blank id collapses to the same reversal key, which would falsely tie unrelated chargebacks together. The handler rejects it as a fault rather than claiming that key.
Returns
clawback resolves to an Outcome. A fresh reclaim returns committed with the posted transaction.
When the order was already reversed — by a refund or an earlier clawback of the same orderId — it returns duplicate, carrying that earlier reversal's transaction unchanged rather than reversing the order twice.
clawback returns no RejectionCode: every "no" it can give is a thrown fault, not a declined Outcome. See the reason codes below.
Postings
Every leg is in CREDIT, so there is no currency mixing. The handler splits amount into the part still recoverable from the user's spendable balance and the part already spent.
It debits recovered from spendable, capped at the current balance so the debit can't drive the account below zero. It books the leftover shortfall as a debt the platform is owed in RECEIVABLE.
The full amount is then credited to STORED_VALUE — the same account the original top-up raised when it issued these credits. The loss un-issues those credits rather than booking REVENUE the platform never earned.
| Account | Side | Amount |
|---|---|---|
spendable(userId) | debit | recovered |
RECEIVABLE | debit | shortfall |
STORED_VALUE | credit | amount |
The two debits sum to amount, and STORED_VALUE is credited that same amount, so the posting nets to zero.
A zero piece is omitted, not posted as a zero line. A clawback fully covered by the balance writes no RECEIVABLE leg. One with nothing left to reclaim writes no spendable debit.
Authorization
clawback is restricted to a system or operator Actor; an end user may never call it. It takes money out of an account the caller need not own, which the ownership rule that governs ordinary user operations does not cover. So, like adjust and reverse, it is platform-initiated only.
An end-user actor is refused with an AUTH.UNAUTHORIZED fault before any work begins. The dispute-webhook path satisfies the rule: toClawback builds the operation with a system actor (webhook:${provider}).
Reason codes
clawback returns no RejectionCode. Its inputs are either valid or a programming error, so each bad input throws a fault rather than declining as data:
| Code | When |
|---|---|
OP.MALFORMED | amount is not CREDIT, or orderId is present but blank, or the handler received the wrong operation kind. |
MONEY.INVALID_AMOUNT | amount is zero or negative. |
Preconditions and invariants
clawback holds these regardless of how much of the disputed amount is still in the user's balance:
- The
spendabledebit is capped at the current balance, so a clawback never drives a user account below zero. - The posting nets to zero in
CREDIT: thespendableandRECEIVABLEdebits together equal theSTORED_VALUEcredit. - The loss un-issues circulating credits against
STORED_VALUE;REVENUEis untouched. - For an order-tied dispute, reversing the order once — by
clawbackorrefund— blocks the other through the sharedreversed:${orderId}key.