Deposits and part payment
Take part of an order up front and collect the rest later. This page is for store administrators selling made-to-order goods, bookings, or anything else where the customer pays twice, and for developers writing an add-on that decides the deposit at checkout.
Before you begin
- Switch on Deposits under KahunaCart → Settings → Commerce → Deposits and part payment. That sets
checkout.deposits.enabled, which isfalseby default. - Deposits need a payment provider that can be charged twice. Every provider KahunaCart ships with can be.
The vocabulary
| Term | What it is |
|---|---|
| Payment collection | One per order. What the order owes, and how much of it has moved |
| Session | One attempt to pay part of what is owed. A collection has several |
deposit |
The session taken up front |
balance |
The session for what is left, taken later |
refund |
Money going back, as its own session |
A collection is open while money is still owed, settled when everything owed has been captured, and refunded when everything captured has gone back.
A deposit, a balance and a refund are three rows against one order, so "refund the deposit but not the balance" is something the store can record.
A part-paid order is not a paid order
Its payment status is Part paid, and that status's counts_as_paid flag is false. Every report, fulfillment queue and add-on reads that flag, so none of them has to know deposits exist to get the right answer.
| Captured, less anything refunded | Payment status | Counts as paid |
|---|---|---|
| Nothing | Pending | No |
| Part of what is owed | Part paid | No |
| Everything owed | Paid | Yes |
What is owed is always what the collection is for, less what it is actually holding: captures less refunds. Hand part of a deposit back and that amount comes off what the collection is for, so the balance stays where it was. Hand all of it back and the order owes its whole total again. The pay link, the balance request and the payment status all read the one figure. See What a refund does to the balance.
In the reports, a part-paid order counts for the money it actually took. A 30% deposit on a £1,000 booking puts £300 in the dashboard and the revenue reports, not £1,000, and the rest arrives in the figures when the balance does.
Part paid is contributed at runtime rather than written into the order-status table, exactly as Quote is. It cannot be edited or deleted from the status editor. A store that trialled deposits and switched them off keeps the status for as long as it has a collection on its books, so an order that took a deposit can still settle.
An order can be completed and not paid
A part-paid order really has been placed: it has its number, its stock is off the shelf and its confirmation has gone out. So onKahunaCartOrderCompleted fires for it. What has not happened yet is all of the money.
An add-on that spends money or gives something away when an order completes has to ask whether the order counts as paid. $orders->statuses()->countsAsPaid($order['payment_status']) answers that for every store, including one that has never taken a deposit. Core's own listeners already ask it.
The one setting to watch is downloads.grant_on. Set to completed, it hands files over the moment the order is placed, which on a part-paid order means on the deposit. That can be what you want, such as course notes that go out on booking, but it is a choice. The default, paid, waits for the balance.
How much is due up front
Checked in this order:
- A fixed deposit amount on any product in the order.
- Otherwise the largest deposit percentage any product in the order declares.
- Otherwise
checkout.deposits.default_percent, which is empty ('') by default and means no deposit.
A deposit is never more than the order total and never less than zero. A 50% deposit on a zero-total order is zero, not an error. Percentages are decimal strings end to end, so no deposit is ever computed from a float.
Two ways an order is paid in parts
A merchant takes a deposit from the admin on an order that already exists. That is Take a deposit below.
An add-on decides the deposit at checkout, before anything has been paid: a holiday let takes 30% now and the rest a fortnight before arrival, a workshop takes a fee to hold a place, a made-to-order sofa takes half up front. The number depends on what the customer chose, so core asks once on the way into the payment and the add-on answers. See A deposit decided at checkout.
Either way the result is the same collection, chased and settled the same way. Rentals is the add-on that does it today: a holiday let asks for thirty per cent when the guest books and names the day the rest falls due, so the checkout page reads Due today and To pay by before anything is paid for.
Take a deposit
- Open the order in the admin.
- Press Take a deposit, or call
POST /kahunacart/orders/{id}/deposit. - Leave the amount empty to use the store's policy, or give an
amountor apercent.
The collection is opened, the deposit session is recorded, and the order moves to Part paid when the money is captured. Asking twice is safe: the call is idempotent on the order.
A deposit decided at checkout
An add-on answers onKahunaCartOrderPaymentSchedule:
public static function getSubscribedEvents(): array
{
return ['onKahunaCartOrderPaymentSchedule' => ['scheduleTheStay', 0]];
}
public function scheduleTheStay(Event $event): void
{
// Thirty per cent now, the rest fourteen days before they arrive.
$event['schedule']->answer(
PaymentSchedule::percent('30', balanceDueAt: $this->arrival($event['order_id']) - 14 * 86400),
'my-plugin'
);
}
PaymentSchedule::percent('30') takes a percentage of whatever the order comes to, with the same arithmetic the admin's own deposit uses. PaymentSchedule::amount(295_00) takes a figure the add-on worked out itself. Both take an optional balanceDueAt, and an optional remindAt when the balance request should go out before the day it is due.
What the customer sees:
- They are charged the deposit rather than the order total, at checkout and on the pay link.
- The checkout page, the pay page and the receipt say Due today, Paid so far, and what is left to pay with the date it is due by.
- The order completes Part paid, and
onKahunaCartOrderPaiddoes not fire until the balance clears.
Three rules to know before you write a listener:
- At most one add-on may schedule an order. A second answer is refused by name, both names, because what a customer pays today must never be settled by plugin load order. An add-on that has nothing to say about an order does not answer.
- With
checkout.deposits.enabledoff, the answer is ignored, with one line in the Grav log naming the plugin. A store that has not switched deposits on charges every order in full. - A schedule can change until money moves. The question is asked on every attempt to pay, so a shopper who goes back and adds a night is charged a deposit worked out from the basket they actually bought. Once anything has been captured, the schedule is fixed. A deposit a merchant typed in by hand is never rewritten by an answer.
An order an add-on builds itself asks through the builder with askForAPaymentSchedule(). It is off unless asked for, because a manual order is one a merchant typed in and wants paid in full.
Collect the balance
The balance is collected through the pay link the order already has. {base}/order/{hash}/pay?t=… charges whatever the collection says is due next: the deposit before it has been paid, the balance afterwards. A part-paid order stays payable because Part paid is not a settled payment status.
When the balance is captured, the order moves to Paid and counts as paid everywhere. A customer who sends the balance another way, such as a bank transfer after paying the deposit by card, can be settled by hand with Mark paid, which accepts a part-paid order.
The balance asks for itself
When the deposit came from a schedule that named a due date, two dates go in the diary the moment the deposit lands, and nobody has to remember either of them.
On the reminder date, which is the due date unless the add-on asked for an earlier one, core queues the same balance request the Collect balance button sends. It is the same message, it leaves the same email.queued entry on the order, and it obeys the same switch on the Notifications screen: turn the balance request off and nothing is sent behind your back. It stays silent on an order that has been paid in the meantime.
Once the due date has passed and the balance is still owed, core fires onKahunaCartOrderBalanceOverdue and does nothing else. What a late balance costs a customer is the add-on's decision, not the shop's: a holiday let releases the week, a workshop rings the customer, a furniture maker carries on building. The event carries order_id, order, balance_minor, balance_due_at, currency, days_over, look and again.
Chasing a late balance
The first firing comes a second after the deadline, so days_over is 0 on it. An add-on that wants to act the moment a balance is late acts there.
For a grace period, such as "leave it three days, then say it plainly, then once more a week later", a listener asks for another look through again:
public function onBalanceOverdue(Event $event): void
{
$daysOver = (int)$event['days_over'];
if ($daysOver < 3) {
// Still inside the grace period. Come back after it.
$event['again']->inDays(3 - $daysOver);
return;
}
$this->releaseTheDates((int)$event['order_id']);
}
inDays() counts from now, and at() takes a moment of your own. A later look goes silent the moment the order is cancelled, refunded or paid, exactly as the first one does. Core books at most 12 further looks on one order and never more than a year ahead, so a listener that always asks again cannot chase a customer forever. look is 0 on the first firing and counts up from there. Two add-ons asking for another look get one look, at the earlier of the two times.
Ask for the balance
Collect balance sends the customer a balance request and records the send on the order.
- Open the order in the admin. A part-paid order carries a Collect balance action beside the other order actions, and every row of the Deposits screen carries the same action.
- Press it.
The balance session is opened if one is not open already, the message is queued, and the response hands back the pay link the message carries, so you can read it out to a customer on the phone. The order's history gains an email.queued entry naming the kind and the amount asked for.
The balance request is its own email, not the pay-link email with different words. It says what the order comes to, what the customer has already paid, any refund on its own Refunded to you line, and what is left. The figures are read when the message renders, so a balance chased twice asks for what is outstanding now.
The action is refused, with the reason, on an order with no deposit, on one paid in full, on one with no email address, and when the balance request email is switched off on the Notifications screen.
The action is POST /kahunacart/orders/{id}/collect-balance and the tool is collect_order_balance. Both need kahunacart.orders.manage. You can resend it from the order's Send email menu like every other order email. Resending on an order with no deposit is refused the same way.
How the money reaches the collection
However a payment settles, whether an instant capture at checkout, a webhook from a hosted checkout, or a capture against a held authorisation, the order's collection is asked first what the money does to it, and that answer decides the payment status.
- Money that finishes the order goes the usual way: Paid,
onKahunaCartOrderPaid, and the payment-received email. - Money that only starts it leaves the order Part paid and fires
onKahunaCartOrderPartPaidinstead, carryingorder_id,order,provider,paid_minor,captured_minor,balance_minor,balance_due_atandcurrency. That is the event an add-on uses to confirm what a deposit bought. It fires before the confirmation email is queued, so anything a listener queues has run by the time the receipt goes out.
onKahunaCartPaymentCaptured fires where it always did, including for a capture that only part pays an order, and onKahunaCartRefundApplied records a refund session.
If money arrives when no session is waiting for it, such as a customer paying more than the collection expected, a balance session is written for the money that moved. Two payments landing at the same moment are settled one at a time, so nothing is dropped and nothing is taken twice. A capture for a different figure than was asked, such as £250 taken of a £300 hold, re-prices the part still waiting, so the two parts always add up to what the order comes to.
Refund the deposit on its own
- Refund the deposit amount the ordinary way, from the order's Refund action. It is offered on a part-paid order the same as on a paid one. A customer can also ask for their deposit back themselves, with the refund request on their order page.
- The money goes back through the same refund allocator every other refund uses.
- A
refundsession is recorded against the collection, so the books say the deposit came back rather than the balance.
The refund is bounded by what the provider actually took, not what the order comes to. A £1,000 order with a £300 deposit has £300 to give back, and asking for more is refused. The admin shows the same figure the endpoint will accept.
What a refund does to the balance
A refund on an order being paid in parts counts as money off the price, never as something the customer now owes back. Take a £1,000 order with a £300 deposit paid and £700 to come:
| What goes back | What the order now comes to | Paid so far | Left to pay | Payment status |
|---|---|---|---|---|
| Nothing | £1,000 | £300 | £700 | Part paid |
| £100 of the deposit | £900 | £200 | £700 | Part paid |
| All £300 of the deposit | £1,000 | £0 | £1,000 | Pending |
| £100 after the balance was paid | £900 | £900 | £0 | whatever the refund set, such as Partially refunded |
- Part of the deposit back lowers the balance by the same amount. The £100 comes off what the order comes to, so the balance request still asks for £700 and paying it marks the order Paid. The customer is never asked for more than the total, less refunds, less what they have paid. The balance request prints the refund on its own Refunded to you line, so its figures add up.
- All of the deposit back starts the order over. A refund that leaves the shop holding nothing is the step before cancelling, so nothing comes off the price: the order goes back to Pending, owing its whole total, which is the state Cancel accepts. No balance request goes out to a customer who has paid nothing. If part was refunded earlier, that earlier amount stays off the price.
- A refund after the balance was paid is an ordinary refund. The amount comes off the price so nothing chases the customer for it, and the payment status is the Partially refunded or Refunded the refund wrote.
The order's own total stays as it was, so the invoice still says what was sold and the refund shows as a refund. What moves is what the collection is for, which is the figure every balance is worked out from.
A duplicated webhook does not double-capture
A session is moved forward by a conditional update, the same primitive the transaction ledger uses. A redelivered webhook loses the update and captures nothing a second time, and cannot rewrite a settled amount either.
The collection's three running totals are recomputed from its sessions on every change. A store whose totals have drifted repairs itself on the next write.
The payment contract does not change
Nothing here widens a provider signature or adds an interface. A driver is handed an amount to charge and answers whether it took it; that the amount is a deposit is something only the store knows. A payment collection sits beside the provider classes: it reads what the ledger recorded and records which part of the order that money was. See Payment providers.
API reference
| Route | MCP tool | What it does |
|---|---|---|
GET /kahunacart/payment-collections |
list_payment_collections |
The part-paid orders, with what each still owes |
POST /kahunacart/orders/{id}/deposit |
create_deposit |
Open a collection and take a deposit |
POST /kahunacart/orders/{id}/collect-balance |
collect_order_balance |
Send the balance request and record the send |
Events
| Event | What it is for |
|---|---|
onKahunaCartOrderPaymentSchedule |
Asks whether this order is paid in parts. At most one add-on may answer |
onKahunaCartOrderPartPaid |
Money landed and there is still a balance to come. Confirm what the deposit bought |
onKahunaCartOrderPaid |
The order has been paid in full. For a part-paid order, that is when the balance clears |
onKahunaCartOrderBalanceOverdue |
The balance was due and is still owed. Core has sent the request and does nothing else, unless a listener asks for another look |
onKahunaCartPaymentCollectionChanged |
A deposit, a balance or a refund moved. Carries order_id, collection_id, session_id, kind, amount_minor and status. For observation only |
See Events for the full payloads.
Configuration
| Key | Default | Effect |
|---|---|---|
checkout.deposits.enabled |
false |
Whether an order may be part paid at all |
checkout.deposits.default_percent |
'' |
The percentage a product declaring no deposit falls back to. Empty means no deposit |
Sorting Owed or Balance groups deposits by currency before comparing amounts. The grouping remains after extra currencies are disabled, so historical deposits keep their own currency.
Related
- Orders and the admin - refunds and the order screen a collection appears on
- Order statuses - the
counts_as_paidflag, and why Part paid does not have it - Emails - the balance request beside every other message the store sends, and how to change its template
- Payment providers - the contract a deposit sits beside rather than inside
- Configuration - the two keys in their table