Partial payments and top-ups
What happens when a customer sends less than they should, how your team approves part of a receipt, and how the rest is collected on the same payment.
Customers mistype amounts, and some pay in two transfers. With most card-to-card setups a short transfer is a dead end: the order is stuck and someone has to sort it out by phone. On Bezanbecard a payment keeps collecting deposits until they reach its amount.
How a short transfer is handled
Say a payment asks for 12,500,470 rial and the customer sends 10,000,000.
- The customer's receipt shows 10,000,000, and your team approves that amount. (Or the bank SMS of the short transfer is matched by the payer's card, or you attach the deposit by hand.)
- The payment becomes Partially paid
partially_paid:amount_receivedis 10,000,000 andamount_remainingis 2,500,000. - A top-up opens at once: a new unique
expected_amountfor the remainder, so the next transfer stands apart as clearly as the first. - The same
checkout_urlnow shows a progress bar, what arrived and the new amount to send. The customer pays it and sends a second receipt. - When the rest is approved, the payment becomes Succeeded
succeeded.
If the customer sends the original amount after all, it still matches: a payment remembers every amount it asked for. While a receipt your team approved for the first part still waits for its own bank SMS, a bank SMS of the original amount waits in Review and fix instead, suggested for this payment, since it may be that receipt's own transfer. Once the first part's own SMS is on the payment, a later SMS of the original amount is attached, and money past the amount becomes an overpayment to resolve.
amount 12,500,000
expected_amount 12,500,470 ← first transfer asked for
received 10,000,000 → partially_paid
expected_amount 2,500,820 ← top-up for the remainder
received 12,500,820 → succeededApproving part of a receipt
Approve what really reached you. In Receipts, change the amount before you press Approve receipt; in the API, send it as amount. Without an amount the approval takes the receipt's own amount, else the amount the payment asked for when the receipt was sent (for a receipt sent after a first partial approval, the top-up), else the amount the payment expects now, else what remains. A receipt that states no amount and that a bank SMS marks starts at that SMS's amount, and so does a copy of another pending receipt that states no amount, at the SMS that matched its original. On a payment your team closed with a shortfall, a part approved keeps it Succeededsucceeded and what is still missing stays its accepted shortfall.
curl https://a.bezanbecard.com/v1/receipts/rcp_…/approve \
-H "Authorization: Bearer sk_test_…" \
-H "Idempotency-Key: approve-rcp_…" \
-H "Content-Type: application/json" \
-d '{"amount":10000000}'A receipt the bank SMS already matched that states the SMS's amount is approved at the bank's amount only. Receipts and review has the other rules.
Telling the customer
The checkout page asks for the rest by itself. To remind the customer as well, listen to payment.partially_paid and send them back to checkout_url with what is left:
if (event.type === 'payment.partially_paid') {
const payment = event.data.object;
await sms.send(customerPhone, `Received ${payment.amount_received} rial. Pay the remaining ${payment.amount_remaining} here: ${payment.checkout_url}`);
}The store plugins do this for you: the order goes on hold with a note of what arrived and what is left.
When the checkout closes first
There is no automatic top-up after expires_at. A payment that is still short when its checkout closes, or that receives a short transfer after it closed, goes to Needs reviewrequires_review with review.reason expired_partial. You then choose:
| Action | API | Effect |
|---|---|---|
| Ask for the rest | POST /v1/payments/:id/topup with expires_in | A fresh unique amount for the remainder and a new expiry. Send the customer checkout_url again. |
| Accept what arrived | POST / with a reason | The payment succeeds; the difference is recorded as amount_shortfall. |
| Give up | POST /v1/payments/:id/cancel | The payment is canceled and the money received is recorded as owed back to the customer. |
curl https://a.bezanbecard.com/v1/payments/pay_…/topup \
-H "Authorization: Bearer sk_test_…" \
-H "Idempotency-Key: topup-1042-1" \
-H "Content-Type: application/json" \
-d '{"expires_in":1800}'Turning partial payments off
Partial payments are on by default. Turn them off for the whole account in the dashboard's payment settings, or for one payment with "allow_partial": false. A short transfer or a partial approval then puts the payment in Needs reviewrequires_review with review.reason partial_disabled, and a person decides.
Trying it in test mode
With receipts, no SMS needed:
- Create a test payment, open its
checkout_url, upload any image as the receipt and press "Send receipt". - In Receipts, approve it for 10,000,000 rial. The payment becomes Partially paid
partially_paidand the checkout shows the new amount. - Send a second receipt from the checkout and approve it with the default amount, the top-up. The payment succeeds.
With simulated bank deposits instead, simulate the short transfer, attach it, then simulate the rest:
const deposit = await bezanbecard.testHelpers.deposits.create({ payment: 'pay_…', amount: 10_000_000 });
await bezanbecard.payments.attachDeposit('pay_…', { deposit: deposit.id });
await bezanbecard.testHelpers.deposits.create({ payment: 'pay_…' }); // the new expected amountA short deposit with no clue stays unmatched, exactly as a real one would, which is why the sample attaches it. With automatic approval off (the default), the last deposit leaves the payment in review (auto_disabled) for you to approve, as a real bank SMS would.