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
releaseHoldexisted, 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
holdIdmust 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 likedid-order:<orderId>orkyc:<submissionId>keeps the ledger readable.- The authorize debit is stamped under the derived key
hold:<holdId>. A duplicateplaceHoldfor the sameholdIdreplays the cached result (idempotentReplay: true) and does not charge twice — so your own retry after a transport timeout is safe. - Options:
ledgerOptionsforwards attribution to the ledger row;solvencyFloorCentsis a post-authorize probe that reportsbelowSolvencyFloor: 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.
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
capturedAmount is the caller-attested settlement figure.
Validation, enforced with a thrown error:
- Both amounts must be positive integer cents.
capturedAmountoverauthorizedAmountthrows — a caller bug (settle at or below the hold; if the carrier upsold mid-flight, release and re-authorize).holdIdmust be the same tenant-safe slug passed at authorize time.
releaseHold — refund a failed leg
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:
chargeIdempotencyKeymust be the byte-identical stringplaceHoldstamped —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.amountmust 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.
3. Worked example — a DID purchase order
A number-purchase order that waits on a carrier plus a compliance review is the canonical fit: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
captureHoldsucceeded, do not also callreleaseHoldfor 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.
chargeIdempotencyKeymust be the exact string authorize stamped, e.g.hold:did-order:ord_7f3. Passing the bareholdId(did-order:ord_7f3) ornullmeans 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.
placeHoldfollowed by a flow that can abandon the branch without reaching release strands the funds until a human reconciles. Wrap the whole downstream body intry { } 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-phasedeductBalance remains correct wherever the entire outcome is decided in-process.
Reach for hold/capture/release when:
- There is a genuinely long third-party leg (a carrier provision, a KYC review, an eSIM profile push) between commitment and confirmation.
- The order is price-committed before the leg, so the wallet must be locked against concurrent purchases.
- A failure must mechanically refund, not queue for manual reconciliation.
- 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.