Implementation of Payment Hold on Website
Payment hold (authorization without debit) is mechanism where money blocked on customer card but not received by seller until explicit confirmation. Used where final amount or order completion unknown at payment time: delivery with payment on fact, booking with variable cost, pre-order.
How Two-Stage Payment Works
Process consists of two operations: authorize (block funds) and capture (actual debit). Arbitrary time gap between them, but limited: most banks remove hold after 7 days, some after 30. If capture doesn't happen — hold automatically removed, money returns.
Stripe: authorize + capture
In Stripe two-stage payment via capture_method: manual:
$paymentIntent = \Stripe\PaymentIntent::create([
'amount' => $order->total_cents,
'currency' => 'eur',
'capture_method' => 'manual', // don't debit immediately
'payment_method_types' => ['card'],
'metadata' => ['order_id' => $order->id],
]);
After 3DS confirmation by customer PaymentIntent goes to requires_capture status. Money blocked, not debited. When order ready to ship:
\Stripe\PaymentIntent::capture($order->stripe_payment_intent_id, [
'amount_to_capture' => $finalAmountCents, // can be less than authorized
]);
Capture can be done for amount less than blocked — e.g. if some goods unavailable. Cannot be more.
YooKassa: two-stage payment
$payment = $client->createPayment([
'amount' => ['value' => '1500.00', 'currency' => 'RUB'],
'capture' => false, // two-stage payment
'payment_method_data' => ['type' => 'bank_card'],
'confirmation' => ['type' => 'redirect', 'return_url' => $returnUrl],
'description' => 'Order #' . $order->id,
], uniqid('', true));
When ready to debit:
$client->capturePayment(
['amount' => ['value' => '1500.00', 'currency' => 'RUB']],
$order->yookassa_payment_id,
uniqid('', true)
);
Cancelling Hold (cancel authorization)
If order cancelled before capture — must explicitly cancel authorization, else money stays blocked until expiry:
// Stripe
\Stripe\PaymentIntent::cancel($order->stripe_payment_intent_id, [
'cancellation_reason' => 'abandoned',
]);
// YooKassa
$client->cancelPayment($order->yookassa_payment_id, uniqid('', true));
Monitoring Expiring Holds
Authorization expiry is critical parameter. Need background process finding holds close to expiry:
// Command run hourly
$expiringSoon = Order::where('payment_status', 'authorized')
->where('authorized_at', '<', now()->subDays(6)) // Stripe gives 7 days
->get();
foreach ($expiringSoon as $order) {
// Notify manager or auto-cancel
Notification::send($order->manager, new AuthorizationExpiring($order));
}
Store authorized_at in orders table — mandatory. Stripe limit 7 days for most cards, up to 31 for some. Better aim for 5–6 days as safe limit.
Partial Capture
Stripe supports partial capture — capture less than authorized. Difference automatically unblocked. Useful for variable shipping cost scenarios: authorize max, charge real cost by fact.
\Stripe\PaymentIntent::capture($intentId, [
'amount_to_capture' => $actualAmountCents,
]);
// Remainder ($authorizedAmount - $actualAmount) auto-unblocked
CloudPayments and most Russian providers also support partial capture, but syntax and behavior check in specific gateway docs — differences exist.







