Skip to main content

Authorize wallet holds for multi-step purchases

A purchase that takes seconds or hours — provisioning a phone number from a carrier, waiting on a KYC document review, fulfilling an order through a third-party API — has a window between “the customer commits” and “the provider confirms.” One-phase debits handle that window badly in both directions: charge before the provider call and a failed leg leaves money stranded with no mechanical refund; charge after and two concurrent purchases can both pass the balance check and over-draw the wallet. Two-phase authorization solves both. placeHold locks the funds up front with a real atomic debit, captureHold settles the purchase at or below the authorized amount once the provider confirms, and releaseHold puts the money back exactly once when the leg fails. Use the three together — a placeHold call without a guarantee that some path reaches one of the other two is the leak this pattern exists to close. These primitives are exported from @devotel/billing. The full contract shipped in commit 96a95b43da, building on the release primitive that landed earlier (reference: commit 96a95b43da).

1. The race one-phase debit creates

deductBalance(tenantId, amount, reason, idempotencyKey) is the wallet’s one-phase charge. It is the right tool for a flow whose entire success is decided in-process — an SMS submit, a voice-minute settlement — but a multi-step purchase breaks it:
  • Charge up front, then call the provider. If the carrier rejects, or the KYC reviewer denies, the wallet is debited with no delivery. Until releaseHold existed, that charge had no programmatic refund path; it accumulated as drift that finance reconciled by hand.
  • Call the provider, then charge. The whole provider-call window becomes a solvency race: any number of concurrent purchases all read the old balance as sufficient, all spend, and the wallet over-draws.
placeHold charges up front — atomically, against the same Redis balance key every solvency check uses — and releaseHold supplies the missing refund leg, so the second option above (debit first, settle later) becomes safe. captureHold is a validation step only; it never moves the wallet (more below).

2. The three primitives

placeHold — authorize the debit

This runs through the same atomic check-and-decrement as a normal charge, so it succeeds exactly when the wallet covered the hold at authorize time. From that point the funds are locked: every other flow competes against the remaining balance, because all solvency checks hit the same balance key. Rules for the call:
  • holdId must match ^[a-z0-9][a-z0-9:_-]{0,119}$ (no spaces, 1–120 chars) so the hold/release key pair stays collision-free. A structured slug like did-order:<orderId> or kyc:<submissionId> keeps the ledger readable.
  • The authorize debit is stamped under the derived key hold:<holdId>. A duplicate placeHold for the same holdId replays the cached result (idempotentReplay: true) and does not charge twice — so your own retry after a transport timeout is safe.
  • Options: ledgerOptions forwards attribution to the ledger row; solvencyFloorCents is a post-authorize probe that reports belowSolvencyFloor: true (hold still succeeded — pure observability for e.g. wholesale-rate misconfig alerts).
  • The stored ledger reason is hold:<reason>:<holdId>, which distinguishes the authorize row from a one-phase charge.
A throw out of placeHold means the hold was not placed (or its status is genuinely ambiguous — the same contract the existing debit paths give). Treat a retry against the same holdId as safe per the replay rule above.

captureHold — settle the purchase

Capture is deliberately a validation step, not a second ledger movement. The funds already left the wallet at authorize time; if capture wrote another debit or a balancing credit pair, the purchase would be double-counted, and the charge readers that map one charge key to one wallet row (the refundable-amount stamping used by the refund controller, and finance reconciliation) would disagree with the ledger. capturedAmount is the caller-attested settlement figure. Validation, enforced with a thrown error:
  • Both amounts must be positive integer cents.
  • capturedAmount over authorizedAmount throws — a caller bug (settle at or below the hold; if the carrier upsold mid-flight, release and re-authorize).
  • holdId must be the same tenant-safe slug passed at authorize time.
Call capture as the terminal success marker of the multi-step flow. It returns the idempotency key for the pair’s record.

releaseHold — refund a failed leg

Release credits the wallet exactly once per hold, even under concurrent retries: it names its own one-shot key hold-release:<chargeIdempotencyKey>, so the first successful release wins and all others report released: false without moving the balance. The one-shot key is what makes release safe to call from every failure branch of the flow at once. Rules for the call:
  • chargeIdempotencyKey must be the byte-identical string placeHold stamped — hold:<holdId> verbatim. Get the wrong text and a wrong amount means no release; a wrong key means the release retries forever. holdIdempotencyKey(holdId) is exported if you prefer deriving it programmatically.
  • amount must match the authorized figure. There is deliberately no per-charge refund cap, so an incorrect amount is not caught — it would make the release a silent wrong-valued credit.
  • The authorize debit row and the release credit row both stay in the ledger (type debit / refund), so finance reconciliation always sees the pair.
On a Redis outage the release inherits the wallet’s registered DB fallback, so wallet + ledger land together or not at all. With no fallback wired, the wallet error is re-thrown; catch, log, and keep your retry armed — exactly the posture the DLR refund paths use.

3. Worked example — a DID purchase order

A number-purchase order that waits on a carrier plus a compliance review is the canonical fit:
Operator visibility: the authorize row appears in the wallet ledger (Dashboard → Billing & payments → transactions ledger, or GET /api/v1/billing/transactions) as a debit whose reason reads hold:did-purchase:did-order:<orderId>. A failed order adds the matching refund-type credit beside it; a settled order shows only the authorize row, because capture never writes. Use the Billing & payments page rather than treating a lone hold:* debit row as a settled purchase — capture is the settle marker.

4. Common mistakes

These four mistakes are the ones that turn a correct-looking hold integration into drift or a missing refund:
  • Releasing after settle. If captureHold succeeded, do not also call releaseHold for the same hold; release is the compensating branch of failure paths, and a second credit after a successful settle double-refunds. If your figure came back lower, release only the difference — never the whole authorized amount after capture.
  • Passing the wrong key to release. chargeIdempotencyKey must be the exact string authorize stamped, e.g. hold:did-order:ord_7f3. Passing the bare holdId (did-order:ord_7f3) or null means every release retries forever because its own one-shot key never matches the authorize.
  • Re-capture with a different amount. Capture succeeds once and only validates; calling it again tells you nothing. If the provider upsold, release the original hold and re-authorize at the new figure.
  • Authorize without a guaranteed terminal call. placeHold followed by a flow that can abandon the branch without reaching release strands the funds until a human reconciles. Wrap the whole downstream body in try { } catch { releaseHold(...); throw; }.

5. When to use two-phase authorization

Tenant posture: two-phase is a choice you make per money-flow, not a platform-mandated billing mode — one-phase deductBalance remains correct wherever the entire outcome is decided in-process. Reach for hold/capture/release when:
  1. There is a genuinely long third-party leg (a carrier provision, a KYC review, an eSIM profile push) between commitment and confirmation.
  2. The order is price-committed before the leg, so the wallet must be locked against concurrent purchases.
  3. A failure must mechanically refund, not queue for manual reconciliation.
Do not use it when:
  • The whole flow completes synchronously in one request — take the one-phase debit.
  • The amount is computed only after the leg runs — you cannot authorize what you have not priced. Verify first, then authorize a known figure.
Either way, the wallet still answers to the same balance and the same ledger read surface; the only difference is whether the funds are locked while the provider thinks.