DEV Community

Cover image for Manual Capture in Production: Holds, Buffers, Split Payments, and the Seven-Day Clock
Dinesh Wijethunga
Dinesh Wijethunga

Posted on • Originally published at dineshstack.com

Manual Capture in Production: Holds, Buffers, Split Payments, and the Seven-Day Clock

Manual capture means the number you authorize and the number you capture are different numbers, and the gap between them is where production bugs live. The rules that matter: you may capture less than you authorized (the remainder releases automatically), you may never capture more, the hold dies on its own after about seven days, and a card portion that a split payment pushes below Stripe's per-currency minimum fails with amount_too_small. Everything in this post is a consequence of those four facts, learned on a platform where the final amount almost never matches the estimate.

This is Part 4 of the Stripe in Production series — the flow is the one from Part 1: authorize at checkout, capture on fulfilment, void on cancellation. Part 3 covered what happens when cancellation races the authorization; this part assumes the order survived.

Authorize the estimate plus a buffer — on the card only

If you authorize exactly the estimate, every order whose final total runs slightly over diverts into a second payment step — the exact friction manual capture exists to avoid. So authorize with headroom:

// GOOD: buffer absorbs ordinary variance; only the excess needs a second payment
public static function withBuffer(float $cardPortion, ?float $bufferPercent): float
{
    // Negative or null => no buffer. Never authorize LESS than the
    // estimate — that guarantees a second payment on every order.
    $percent = max(0.0, (float) ($bufferPercent ?? 0));

    return round($cardPortion * (1 + $percent / 100), 2);
}

The subtlety that cost us a support queue: apply the buffer to the card portion only, never to a store-credit lock. A card hold with headroom inconveniences nobody; a credit lock with headroom blocks the customer whose balance exactly covers their order — the system tells them they cannot afford a thing they can afford.

The split that dips under the minimum

Store credit plus card is a normal split until the credit side eats almost everything and leaves the card 0.30 USD — below the floor from Part 1's Mistake #5. Handle it at split time, not at API-error time:

// GOOD: rebalance the split before Stripe ever sees it
$minimum = config('payment.stripe.minimums.'.strtoupper($currency)); // e.g. 0.50 USD

if ($cardPortion > 0 && $cardPortion < $minimum) {
    $shortfall = round($minimum - $cardPortion, 2);

    if ($creditPortion >= $shortfall) {
        // Lift the card to the floor by giving credit back to the customer
        $creditPortion = round($creditPortion - $shortfall, 2);
        $cardPortion   = $minimum;
    } else {
        throw new PaymentProviderException('stripe', 'Split below card minimum', [],
            null, customerMessage: "The minimum card payment is {$minimum} {$currency}.");
    }
}

Rebalancing in the customer's favour (they keep more credit for later, the card carries the floor) turns an error path into a silent adjustment. The error branch survives only for the customer with almost no credit at all.

Capture day: three outcomes, three different calls

// GOOD: fulfilment settles the hold — final vs authorized decides the call
$creditUsed  = min($hold->credit_amount, $finalTotal);
$cardPortion = max(0.0, $finalTotal - $creditUsed);

if ($cardPortion <= 0) {
    // Credit covered everything: capturing zero is an error — VOID instead,
    // releasing the customer's card hold immediately.
    $stripe->paymentIntents->cancel($hold->capture_reference);
} elseif ($cardPortion <= $hold->authorized_amount) {
    // The normal case, including partial capture — remainder auto-releases.
    $stripe->paymentIntents->capture($hold->capture_reference, [
        'amount_to_capture' => $this->toMinorUnits($cardPortion, $currency),
    ], ['idempotency_key' => "capture_{$order->id}"]);
} else {
    // Final exceeded even the buffer: capture what the hold allows,
    // move the order to balance-due for the excess. Stripe will not
    // stretch a hold — do not retry the capture with a bigger number.
    $stripe->paymentIntents->capture($hold->capture_reference, [
        'amount_to_capture' => $this->toMinorUnits($hold->authorized_amount, $currency),
    ], ['idempotency_key' => "capture_{$order->id}"]);
    $order->transitionTo(OrderStatus::BalanceDue, $finalTotal - $hold->authorized_amount);
}

Three notes. The idempotency key means a retried fulfilment job cannot double-capture. The zero-card branch voids rather than captures — a zero capture is an API error, and the void is also the kind gesture, releasing the hold now instead of in seven days. And the balance-due branch is where the minimum guard matters again: the excess is a delta, exactly the kind of small amount that dips under the floor.

The seven-day clock

An uncaptured authorization expires on its own after about seven days, and expiry is silent — the capture just fails when you finally call it. Two design consequences: if your fulfilment window can exceed the auth window, you need a re-authorization step in the flow, not a longer hope; and the expiry is also your last-resort cleanup, which is why Part 1's reconciliation hunts for holds older than an hour — anything you find at day six was a bug for six days.

A trick the hold enables: the micro-authorization

Manual capture gives you a free card validity check: authorize the per-currency minimum, then void it immediately. No charge, no capture, a held-and-released minimum — and you learn the card is real and funded before the order commits to it. Tag these distinctly in metadata so your webhook handlers skip them (they have no order behind them), and remember the floor: the probe is the minimum, not a symbolic 0.01.

The checklist

  • Authorize estimate + buffer; buffer the card portion only, never store credit
  • Rebalance splits before the API call: lift the card to the minimum from the credit side
  • Capture ≤ authorized; excess goes to a balance-due payment, never a bigger capture
  • Zero card portion => void, not a zero capture
  • Idempotency key on every capture
  • Fulfilment longer than the auth window => re-authorize by design

Next in this series

Part 5 is the layer under all of this: payment webhooks that don't lie — replay guards, why you mark a webhook processed only after success, and the difference between delivered and true. [LINK WHEN LIVE: /en/payment-webhook-replay-reconciliation]

Cheap audit before then: find your capture call and check what happens when the final amount exceeds the authorization. If the answer is "we capture the final amount", that call has been failing on every over-estimate order and something downstream is eating the error.

I post each part natively on LinkedIn with the story that didn't fit — follow there for Part 5. I read every reply.

Top comments (0)