Payment Methods
A payment method is a checkout choice you configure in the admin: "Visa", "Klarna", "Bank transfer".
Before you begin
- Payment methods are edited under KahunaCart → Setup → Payment methods.
- Every route on this page needs
kahunacart.settings. - At least one payment provider must be registered.
A method is not a provider
A provider is a credentialed integration written against the provider contract: it holds the API keys, handles webhooks and takes the money. Stripe is one, and so is the built-in offline gateway. There are thirteen, they live in code, and you cannot create one from the admin.
A payment method is a checkout option you configure. It has a label, a logo, a description, instructions, a fee and conditions, and it names the provider that settles it. "Visa" and "Klarna" can both run over one Stripe provider. "Cash", "Cheque" and "Bank transfer" can all run over the one offline provider, each with its own account details.
Methods are optional. A store with no methods offers its providers directly, with one radio button per provider.
Add a method
- Open Payment methods in the KahunaCart admin.
- Click New method.
- Choose the gateway: one of the registered providers. This is who takes the money.
- Give it a code: a short handle, unique within that gateway, such as
bank_transfer,klarnaorcash. - Give it a label: what the customer reads. Type the words, or a
PLUGIN_…translation key on a multilanguage store. - Optionally add a logo, a description, a fee, instructions and conditions.
- Click Save.
The list order is the checkout order; Move up and Move down on each row change it. As soon as one method is enabled, checkout shows methods instead of bare providers.
Fees, and how they are taxed and refunded
A fee is a fixed amount plus a percentage. Either half can be zero; both at zero charges nothing and shows no fee line.
| Field | What it is |
|---|---|
fee_fixed_minor |
The fixed half, in minor units, in the base currency. 350 is £3.50 in a GBP store. |
fee_percent |
The percentage half, as a decimal string: 1.5, not 0.015 and never a float. |
fee_tax_class |
Which tax class the fee is taxed under. Empty means the store's default class. |
The percentage is charged on the order's taxable base — goods, less every discount, plus the postage — and not on the order total.
The fee lands on the order as its own adjustment, of type fee.
The fee beside each method uses the same percentage base, fixed amount and currency rounding as the order’s fee line. It follows the store’s tax display: tax included when prices include tax, otherwise excluded. Choosing a method refreshes the fee, tax and total before Place order. With JavaScript off, a note explains that the chosen method’s fee is added to the total shown.
- It is taxed like any other line. The tax stage reads the fee's tax class from the adjustment's audit snapshot, frozen when the fee was charged, so changing a method's tax class next month does not restate last month's orders. The rate applies even if no other line uses that class. See Tax.
- It is refunded proportionally. The refund allocator spreads an order-level adjustment across the line buckets in proportion to what is in them, so refunding half an order gives back half the fee.
- It is explainable. The adjustment carries a source snapshot naming the method, both halves of the fee, the base they were computed on and the tax class.
A cash-on-delivery surcharge is a fee on an offline method. There is no separate surcharge concept.
On a store that sells in more than one currency the fixed half has an amount for each currency. See In more than one currency.
In more than one currency
A store that sells in more than one currency can say which currencies a method is offered in and what its fixed fee is in each.
Currencies. The method editor has a Currencies list with a tick per currency. Tick the ones the method is offered in. Leave every box unticked, which is how a method starts, to offer it in all of them. A method limited to euro is not offered on a dollar order, and checkout shows it as unavailable with the reason currency ("Not available for orders in this currency.", PLUGIN_KAHUNACART.PAYMENT_METHOD_UNAVAILABLE_CURRENCY) like any other method that fails a check. The list narrows what the gateway already allows and never widens it: a gateway that cannot take yen is not made to by ticking JPY here. Through the API it is currencies, a list of codes, and an empty list is every currency.
The fixed fee. The editor shows a Fixed fee box for each currency besides the base one, and the API carries them as currency_amounts, {"EUR": {"fee_fixed": "2.50"}}. With typed pricing, a method with a fixed fee in the base currency and none in the order's currency charges only its percentage there. It is never the base amount and never zero for the whole fee, because 3.50 dollars is not 3.50 euro and a percentage fee is still owed. A currency set to convert works the fixed fee out from the base fee instead, to that currency's own decimal places, and a fee you typed still wins. A fee of 0.00 is a real zero: a method can charge nothing fixed in euro and 3.50 in dollars. The percentage is the same in every currency.
The fee line on the order is in the order's currency, and its source snapshot records the fixed amount that was charged, so an order says what it paid after the fee is changed. A store with one currency sees no currency_amounts key, and a request that sends one is refused with a 422, as is a currency the store does not sell in. Deleting a method deletes its amounts.
A different account for a currency. A method limited to one currency can use its own gateway account. A Stripe card method for GBP and another for EUR can each take payment into a different Stripe account, and the order's currency can pick a different company for the invoice. See Selling through more than one company. A gateway that supports this says so in its provider contract.
Instructions
instructions is the block a customer reads once they pick the method: where to send the money, what reference to quote, who to make the cheque out to. It appears on the order receipt and in the order emails, and replaces the offline.instructions config string for any order paid by a method.
It is Markdown, not Twig: markup is escaped at source, javascript: and data: links are defused, and nothing is executed. Instead you get a closed set of placeholders, substituted verbatim.
| Placeholder | Becomes |
|---|---|
{order_number} |
The order number |
{order_total} |
The total, formatted in the order's currency and language |
{currency} |
The currency code |
{customer_email} |
The address on the order |
{config.<key>} |
Any key of the method's own settings, such as {config.account_number} or {config.sort_code} |
An unrecognised placeholder stays exactly as you typed it, so a typo shows as a typo rather than a gap where your bank details should be.
The method settings (config) are a free-form map for the account details. Set account_number, sort_code and payee there, then write Pay {config.payee}, account {config.account_number}, sort code {config.sort_code}, quoting {order_number}. in the instructions.
Offline methods
The offline provider takes no money. It places the order awaiting payment and waits for you to mark it paid. Give it as many methods as you want.
| Method | config holds |
Typically scoped to |
|---|---|---|
| Cash | Nothing | Local delivery, or a collection postcode |
| Cheque | Payee name, posting address | Nothing |
| Bank transfer | Account number, sort code, IBAN, payee | Orders over a threshold, or a business group |
| Cash on delivery | Nothing; carries a fee | A shipping country, physical goods |
All four settle the same way: the order is placed with payment_status pending, and you mark it paid. Two methods over one gateway settle through that gateway's own webhook path if it has one, deduped as before.
The offline.label and offline.instructions config keys still work, and still apply to any order with no method on it.
Scope a method with a condition
Any method can carry a condition set — the same nested all / any / none builder sales and coupons use, written against the same published attribute vocabulary.
- Open the method.
- Click Add condition.
- Pick an attribute.
cart_subtotal,order_country,customer_group,customer_email_domain,customer_order_count,line_categoryand the two cart-contents attributes,cart_productandcart_variant, are all available. - Pick an operator and type the value.
- Click Save.
Conditions nest three levels deep. A method with no conditions is offered to everyone.
A condition on the customer (customer_group, customer_email_domain, customer_order_count) counts only for a customer who is signed in. A guest who types a Trade member's email at checkout is judged as a guest, so "invoice for Trade customers" is offered to a Trade customer once they sign in. See Whose customer data a rule reads.
The value box is the same one a sale uses, so a product, category or tag is picked from a list and a variant is found by typing part of its product name, its own name or its SKU. A saved condition shows names, not ids. The two cart attributes read Cart (by product) and Cart (by variant), with the operators contains, doesn't contain, contains any of and contains none of. So "pay by invoice only while the Trade pack is in the cart" is one condition. A free gift never counts as being in the cart. See Rules and conditions for how they read.
A condition saying is one of or is none of used to save its values as one line of text and match only the first. It now saves a list. A condition saved the old way still opens as a list and is fixed the next time you save it.
A condition on an amount of money, such as cart_subtotal, is in one currency and is false on an order in any other, so a method scoped to "subtotal over 500.00" is only offered on orders in the base currency. The editor shows a currency picker beside the amount on a store with more than one currency. Its list is "every condition must hold", so it cannot say "over 500.00 dollars or over 450.00 euro": for that, write an any group through the API, or make a second method for the other currency. See Rules and conditions.
An attribute the store does not publish is refused when you save, by name — never at checkout and never silently. A method whose conditions cannot be evaluated is left out of checkout, with the reason logged, so the checkout stays up.
The reason is available to the template
Every method reaches the checkout template with a verdict on it.
{% for pay_method in payment_methods %}
{% if pay_method.available %}
<input type="radio" name="payment_method" value="{{ pay_method.key }}"> {{ pay_method.label }}
{% else %}
<p class="unavailable">{{ pay_method.label }} — {{ pay_method.reason_message }}</p>
{% endif %}
{% endfor %}
The shipped templates draw unavailable ones as a muted line, so the customer reads "Bank transfer is available on orders over £500" and can act on it. To hide them, filter on pay_method.available.
The reasons are condition, zero_total_only, provider, unevaluable and disabled. Each has a translated sentence in PAYMENT_METHOD_UNAVAILABLE_*.
Zero-total methods
zero_total_only marks a method that may only be chosen when there is nothing left to pay. Use it for carts discounted to zero by a 100%-off coupon: they still have to complete, and asking a card gateway to authorise £0.00 fails at the last step.
The selection is re-checked every time
The customer's choice is stamped on the order, and nothing trusts it. The calculator re-resolves the method on every recalculation, and the checkout submit re-asks whether it is still offered. A method switched off, deleted, or no longer matching its condition stops being chosen — no fee, no error — and the customer picks again.
The checkout page, the checkout submit, the pay page and the pay submit all use one resolution path, so a method offered on the page is chargeable on the submit and a method refused on the submit was never offered.
Methods from add-ons
A plugin can contribute methods with no row in the table, such as a store-credit balance or a purchase-order flow, through onKahunaCartRegisterPaymentMethods:
public function onKahunaCartRegisterPaymentMethods(Event $event): void
{
$methods = $event['methods'];
$methods[] = new \Grav\Plugin\KahunaCart\Payment\Method\PaymentMethod(
0, // no row id: this one is built in memory
'offline', // the gateway that settles it
'purchase_order',
'PLUGIN_MYPLUGIN.PURCHASE_ORDER'
);
$event['methods'] = $methods;
}
Contributed methods are appended to your own rather than replacing them. They appear in the admin list marked as contributed, and you cannot edit them there because there is no row to edit.
If a listener throws, only its own methods are lost. The failure is logged and the checkout carries on.
Admin API
| Route | Does |
|---|---|
GET /kahunacart/payment-methods |
Every method, the registered gateways, and which methods are contributed by add-ons |
POST /kahunacart/payment-methods |
Create one |
PATCH /kahunacart/payment-methods/{id} |
Change one. Sending conditions replaces the whole set |
DELETE /kahunacart/payment-methods/{id} |
Delete one and its conditions. Orders paid by it are untouched |
POST /kahunacart/payment-methods/reorder |
Put the checkout list in the order given |
The MCP tools are list_payment_methods, create_payment_method, update_payment_method, delete_payment_method and reorder_payment_methods.
Events
| Event | Carries | For |
|---|---|---|
onKahunaCartRegisterPaymentMethods |
methods |
Contribute a payment method from an add-on |
Storefront variables
The checkout and pay pages both receive:
| Variable | What it holds |
|---|---|
payment_methods |
Every method, available or not, each with key, label, description, logo, fee_minor, available, reason and reason_message |
default_payment_method |
The key the radio list starts on: the customer's stamped choice, or the first available method |
The order receipt receives offline_instructions: the chosen method's instructions with placeholders filled in, falling back to the offline.instructions config for orders placed before methods existed.
A provider and code together identify one method. Creating a duplicate answers 409. Updating a method to use another method’s provider and code answers 422 and names the existing method. Nothing is saved on refusal. Adding or changing a condition also keeps the unsaved gateway, code, label, fees and currency choices you typed.
Related
- Payment providers — the fourteen integrations methods run over, and how to configure each
- Provider contract — writing a provider, and why a method is not one
- Rules and conditions — the condition builder and the published attribute vocabulary
- Tax — tax classes, and how a fee is charged
- Money conventions — minor units, and how the refund allocator spreads an order-level adjustment
- Orders and the admin — marking an offline order paid
- Currencies — selling in more than one currency
- Selling through more than one company: a company and a gateway account for each currency