> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Authorize wallet holds for multi-step purchases

> Lock funds with placeHold before a third-party leg starts, settle with captureHold, and refund with releaseHold when the leg fails — the two-phase wallet primitive that keeps DID purchases, KYC submissions, and orders from over-drawing the wallet.

# 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

```ts theme={null}
import { placeHold } from "@devotel/billing";

const hold = await placeHold({
  tenantId,
  amount: 1250,               // positive integer cents
  holdId: "did-order:ord_7f3", // caller-supplied, unique per reservation
  reason: "did-purchase",
});

// → { idempotencyKey: "hold:did-order:ord_7f3",
//     remaining: <balance after the debit, cents>,
//     idempotentReplay: false }
```

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

```ts theme={null}
import { captureHold } from "@devotel/billing";

const settled = await captureHold({
  tenantId,
  capturedAmount: 1250,      // the figure you actually settled for
  authorizedAmount: 1250,    // what placeHold authorized (the upper bound)
  holdId: "did-order:ord_7f3",
  reason: "did-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

```ts theme={null}
import { releaseHold } from "@devotel/billing";

const refund = await releaseHold({
  tenantId,
  amount: 1250,                                 // MUST equal the authorized amount
  chargeIdempotencyKey: "hold:did-order:ord_7f3", // the EXACT authorize key
  reason: "did-purchase",                        // optional free-text suffix
});

// → { released: true, remaining: <balance after the credit, in cents> }
```

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:

```ts theme={null}
import { placeHold, captureHold, releaseHold } from "@devotel/billing";

const holdId = `did-order:${order.id}`;
const authorized = order.quoteAmountCents;

// 1. AUTHORIZE the moment the customer confirms the order.
await placeHold({ tenantId, amount: authorized, holdId, reason: "did-purchase" });

try {
  const provision = await carrier.buyAndKyc(order);   // slow third-party legs
  // 2. CAPTURE when the carrier confirms — settle at the authorized figure
  //    (or the carrier's final, if it came back lower).
  await captureHold({
    tenantId,
    capturedAmount: provision.finalAmountCents,
    authorizedAmount: authorized,
    holdId,
    reason: "did-purchase",
  });
  if (provision.finalAmountCents < authorized) {
    // Give the unused remainder back — still exactly-once.
    await releaseHold({
      tenantId,
      amount: provision.finalAmountCents,
      chargeIdempotencyKey: `hold:${holdId}`,
      reason: "did-purchase-partial",
    });
  }
} catch (err) {
  // 3. RELEASE in full when anything terminal fails after the authorize.
  await releaseHold({
    tenantId,
    amount: authorized,
    chargeIdempotencyKey: `hold:${holdId}`,
    reason: "did-purchase",
  });
  throw err;
}
```

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](/billing/overview) 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.
