Contents

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:

FieldTypeDefaultDescription
idempotencyKeystring(required)A retried submit with the same key runs at most once. See idempotency.
actorPrincipal(required)Who is asking. Restricted to a system or operator actor; an end user may never call it.
userIdstring(required)The user whose spendable credits the chargeback reclaims.
amountAmount(required)How much to reclaim. Must be CREDIT and positive.
orderIdstringThe 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.
keystringA free-form reference recorded on the posting (e.g. the network’s case id).
reasonstringA 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 posted transaction.
  • duplicate: the order was already reversed (by a refund or an earlier clawback of the same orderId); 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.

AccountSideAmount
spendable(userId)debitrecovered
SYSTEM.RECEIVABLEdebitshortfall
SYSTEM.STORED_VALUEcreditamount

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:

CodeWhen
AUTH.UNAUTHORIZEDThe actor is an end user rather than system or operator; refused before any work begins.
OP.MALFORMEDamount is not CREDIT, or orderId is present but blank, or the handler received the wrong operation kind.
MONEY.INVALID_AMOUNTamount 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 spendable debit 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; REVENUE is untouched.
  • For an order-tied dispute, reversing the order once — by clawback or refund — blocks the other through the shared reversed:${orderId} key.

Disputed sales: refund first, then claw back

Order matters when the disputed money funded a purchase. The runbook is two steps:

  1. refund the disputed order. This pulls the seller’s earnings back, returns the buyer’s credits, and claims the shared reversed:${orderId} key.
  2. clawback the wallet for the disputed amount, with no orderId — the order is already reversed, so an order-tied clawback would return duplicate and 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.

See also