Clawback
Recover funds after a dispute, driven by a processor dispute webhook.
Source src/operations/clawback.ts#L37clawbacksrc/webhooks.ts#L216toClawback
API 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: toAmount('CREDIT', 5000n),
orderId: 'ord_8821',
reason: 'fraudulent_charge',
});
// → { status: "committed", transaction: { id: "txn_…", … } }curl -s https://economy.example/submit \
-H 'content-type: application/json' \
-d '{
"kind": "clawback",
"idempotencyKey": "whk:evt_5521",
"actor": { "kind": "system", "service": "webhook:billing" },
"userId": "usr_a1",
"amount": "CREDIT:50.00",
"orderId": "ord_8821",
"reason": "fraudulent_charge"
}'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 kind tag, are:
| Field | Type | Default | Description |
|---|---|---|---|
idempotencyKey | string | (required) | A retried submit with the same key runs at most once. See idempotency. |
actor | Principal | (required) | Who is asking. Restricted to a system or operator actor; an end user may never call it. |
userId | string | (required) | The user whose spendable credits the chargeback reclaims. |
amount | Amount | (required) | How much to reclaim. Must be CREDIT and positive. |
orderId | string | — | The disputed order, when the provider names one; without it the clawback is untied to any order. 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 blank orderId would collapse unrelated chargebacks onto one reversal key, so the handler rejects it as a fault.
Returns
clawback resolves to an Outcome.
committed: a fresh reclaim, with the postedtransaction.duplicate: the order was already reversed (by arefundor an earlier clawback of the sameorderId); the outcome carries that earlier reversal’s transaction unchanged rather than reversing the order twice.
clawback returns no rejected outcome. A malformed request throws instead; see reason codes.
Postings
Every leg is in CREDIT. 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 |
SYSTEM.RECEIVABLE | debit | shortfall |
SYSTEM.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 for 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 reason codes: it has no rejected path. It fails only by throwing, whether the caller is forbidden or the input is a programming error:
| Code | When |
|---|---|
AUTH.UNAUTHORIZED | The actor is an end user rather than system or operator; refused before any work begins. |
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 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.
Disputed sales: refund first, then claw back
Order matters when the disputed money funded a purchase. The runbook is two steps:
refundthe disputed order. This pulls the seller’s earnings back, returns the buyer’s credits, and claims the sharedreversed:${orderId}key.clawbackthe wallet for the disputed amount, with noorderId— the order is already reversed, so an order-tied clawback would returnduplicateand reclaim nothing.
import { refund, clawback, systemActor, toAmount } from '@pwngh/economy-lab';
// step 1 — the refund is the only step that pulls the seller's earnings back
await economy.submit(
refund({
idempotencyKey: `refund:${disputed.orderId}`,
actor: systemActor('disputes'),
orderId: disputed.orderId,
reason: 'chargeback',
}),
);
// step 2 — now claw back the returned credits, untied from the order
await economy.submit(
clawback({
idempotencyKey: `clawback:${disputed.orderId}`,
actor: systemActor('disputes'),
userId: disputed.buyerId,
amount: toAmount('CREDIT', disputed.minor),
reason: 'chargeback',
}),
);
Clawing back first books the worst version of the same loss. A wallet that already paid for the sale is empty, so the whole disputed amount lands in RECEIVABLE as a debt; the seller keeps the sale’s proceeds; and the shared key now blocks the refund that would have pulled them back.