Orders and the admin
This page covers the day-to-day work of running a store: reading an order, confirming payment, shipping, canceling, refunding and reporting. It is for a store administrator.
Before you begin
- The admin lives at /plugin/kahunacart. Open it from the KahunaCart entry in the Grav admin sidebar.
- You need
kahunacart.orders.vieworadmin.superto open it. Individual actions need the permissions under Permissions. - Grav's scheduler must be running. Confirmation emails, payment-received emails and download grants are queued jobs. See Troubleshooting.
The admin at a glance
The admin is a single-page app on the plugin's own API endpoints. The nav collapses to icons, and that choice is remembered.
| Section | What it does |
|---|---|
| Dashboard | Headline sales numbers and a revenue chart for a date range. |
| Analytics | The same range compared against another one, narrowed by filters and cut by a dimension, with the definition of every figure printed on it. See Analytics. |
| Orders | The order list, with an expandable detail panel per order. Plus New order, bulk actions, export and quotes. |
| Customers | Everyone who has bought, with order counts, lifetime totals and sign-in accounts. Its Export… menu downloads a customers CSV. |
| Customer groups | The groups a pricing rule, a coupon condition or a shipping rule points at. See Customer groups. |
| Products | Product list and editor: variants, images, categories, downloadable files. Its Export… menu downloads the catalog file or a products CSV. |
| Categories | The category tree. |
| Tags | The flat half of the taxonomy. |
| Reviews | The review queue with its approve, reject and delete actions. |
| Search | The catalog index's status and rebuild actions, plus the store's search synonyms. |
| Merchandising | Bulk edit and the related products picker. |
| Import & Export | Bring products, categories, variants, customers and orders in from CSV, export the whole store into one bundle, restore a bundle, and the history of both. See Importing and Export and restore. |
| Coupons | Discount codes and their usage. |
| Payment methods | The merchandised checkout choices over each registered provider. See Payment methods. |
| Tax | Tax zones and rates, plus the EU VAT preset import. |
| Shipping | Shipping zones and their methods. |
| Reports | Range summary, top products, per-provider totals, CSV export. |
| Order statuses, Features | The status editor and the Features screen, both under the Setup band. |
| Settings | The plugin's own settings, drawn on this page rather than on a page of their own. See Settings, on this page. |
Provider plugins can add sections after these, fetched on load and mounted as web components. See Admin sections.
The nav, and its seven bands
The plugin draws its own navigation down the left of its page, inside the Grav admin's. Its thirty-nine screens sit in seven collapsible bands, with a search box above them.
| Band | Screens |
|---|---|
| Store | Dashboard, Orders, Customers, Customer groups, Store credit, Recovery |
| Catalogue | Products, Categories, Tags, Attributes, Reviews, Search, Merchandising, Import & Export, Revisions |
| Promotions | Coupons, Sales, Price lists |
| Setup | Tax, Shipping, Pickup locations, Shipping profiles, Payments, Order statuses, Features, Payment methods, Checkout |
| Operations | Notices, Extensions, Jobs, Webhooks, Health, Notifications, Object templates, Email log, Deposits, Hooks |
| Insights | Reports, Analytics |
| Add-ons | Whatever an add-on registered. See Admin sections |
The navigation group for the current screen stays open. One section at a time, below the navigation, is enabled by default. When enabled, opening a group closes the others. When disabled, groups open and close independently. The browser remembers this setting, which groups are open, and whether you selected icons-only navigation with the « control.
Below about 900px the nav goes full width above the content.
Settings, on this page
The plugin's settings are a screen of this page at #/settings, drawn by the Grav admin's own blueprint form. /plugins/kahunacart takes you here.
The plugin's card on the Plugins list still updates, removes and enables the plugin. A disabled KahunaCart opens the admin's own plugin page, which has the Enable button.
Save settings, beside Settings in the page header, is the only Save this screen has. It lights up the moment you change a field and spins while it writes, and stays greyed on every other screen. The revert arrow beside an overridden setting puts that one setting back.
Finding a setting or a screen
Find a setting or screen is the search box at the top of the nav. Typing hides every row that does not match and opens whichever bands still hold one. Clearing it puts the bands back.
While the Settings screen is open, the same box also filters the settings themselves. Every field whose label, help text or name does not match folds away, and so do the tabs and sections left empty. Typing "tax" finds the Tax screen and every tax setting at once.
It searches what a setting is called, not what it is about. The sign-in link settings under Accounts are labelled Sign-in links, so "sign-in" finds them and "magic link" does not, even though customers.magic_link_enabled is the name underneath.
The sidebar badge
The KahunaCart sidebar entry carries a badge counting completed orders still awaiting payment: status completed with payment_status pending. In practice those are offline payments you have not confirmed yet.
Two actions clear the badge: marking an order paid and canceling it. Canceling leaves the completed status, so the payment status stays pending as the record that the money never arrived.
If the badge stays at zero while you know offline orders exist, check that those orders are completed and not still carts.
The badge endpoint also reports job-queue health. The admin warns in the alert strip when the oldest waiting job is older than jobs.stale_after_minutes, default 15. Set it to 0 to switch the warning off.
The alert strip
Above every screen sits a strip saying what needs attention now: unpaid orders, a queue that is not draining, products running low, a report rollup that predates cost of goods. Each check runs on its own schedule. Hiding one hides it for you alone, and it comes back while the thing it warned about is still true.
An add-on can register its own alert on the same framework. See Dashboard alerts.
One warning keeps its own box and cannot be dismissed: "No payment method can sell your shippable products".
Permissions
| Permission | Grants |
|---|---|
kahunacart.orders.view |
The admin screen at all, the order list and detail, download grants, store config, order statuses, order changes and versions, credit accounts and gift cards, bulk run reports, exports and credit notes. |
kahunacart.orders.manage |
Create an order by hand, mark paid, record a shipment, print a packing slip, cancel an order, revoke download grants, edit order statuses, stage and confirm an order change, unlock an order, set a feature, run a bulk status change, save an export column set, and send, accept or decline a quote. |
kahunacart.orders.refund |
Issue refunds, refund to store credit, adjust a credit balance, and mint, redeem or void a gift card. |
kahunacart.customers.view |
The Customers list, its search, one customer's detail with their orders, their wishlists, and the retention panel. |
kahunacart.customers.manage |
A customer's sign-in account, their group, reassigning their orders, erasing them, and running the retention sweep. |
kahunacart.products.manage |
Products and variants, and moderating reviews. |
kahunacart.settings |
Categories, coupons, tax, shipping, product files, customer groups and payment methods. |
kahunacart.reports |
Reports, the CSV export, and the dashboard widget. |
kahunacart.export |
Export the store into a bundle, download and delete bundles, and the Export… menus on Products and Customers. Each part of a bundle also needs its own permission: the customers need kahunacart.customers.view, the orders kahunacart.orders.view, and so on. |
kahunacart.restore |
Upload, inspect and restore bundles. The same per-part permissions apply. |
Order statuses
An order carries three independent statuses, shown as columns in the order list with a filter chip group for each. Chips appear only for values present among the orders on the current page, and the order-status group needs at least two distinct values.
The values on each axis are rows you own, not a fixed list in code. KahunaCart seeds the thirteen below, and you can add, rename, recolour, reorder and switch off more. Every status carries a flag — new, complete or cancelled — and that flag, never the label, is what reports, retention rules and add-ons read. See Order statuses.
Order status
status is the cart lifecycle.
| Value | Meaning | Flag |
|---|---|---|
cart |
Not an order yet. Never appears in the admin list. | new |
completed |
Placed. Stock deducted, order number assigned, totals frozen. | complete |
canceled |
Called off before the money arrived. Set by Cancel order. | cancelled |
archived |
Placed and filed away. Still counts as a sale in the reports. | complete |
Payment status
payment_status is the money.
| Value | Meaning | Flag |
|---|---|---|
pending |
Placed but not paid. Offline methods, and anything awaiting a webhook. | new |
paid |
Funds captured. | complete |
partially_refunded |
Some money returned. | complete |
refunded |
Fully returned. Drops out of every revenue total. | cancelled |
failed |
The payment attempt failed. Still counted, because a declined card the customer retried is not a lost sale. | new |
A failed attempt does not move the order's payment_status on its own — the customer may still pay by another route, and this is the attempt rather than the order. What it does do is tell both of you. You get an owner.payment_failed message on whichever channels your row ticks, deduped per order per day; the customer gets the payment-failed email with the reason the provider gave, if there is one safe to show, and a button to pay again — deduped per order over six hours, so a shopper retrying a declining card three times in a minute gets one message rather than three.
Three things decide what that message says by the time the worker picks it up:
- The order has been paid, or the money is held on an authorisation. Nothing is sent at all. They have their receipt, and an email saying nothing has been charged arriving after it is the worst thing a store can say to somebody who has just paid. The skip is written to Grav's log and nothing is written to the email log, because no message was composed.
- The order can no longer be paid — you cancelled it, you archived it, a deadline passed. The message still goes, because nothing was charged, and it carries the shop link instead of a button that would only refuse them.
- The payment failed during checkout, so the order is still a cart. The button carries a pay link with no deadline on it. A cart is the customer's own basket rather than an invoice: stamping
data_json.pay_link_expires_aton one would take their ability to check out away for good onceorders.pay_link_dayspassed, whichever route they came back by. A placed order is different, and is issued its link with the deadline the pay page enforces, exactly as the pay-link email does it.
Fulfillment status
fulfillment_status is the goods.
| Value | Meaning | Flag |
|---|---|---|
na |
The order was digital-only. Set at completion, and it can never ship. | complete |
unfulfilled |
The order contained something physical and nothing has shipped. | new |
partial |
Some units have gone and some have not. | new |
fulfilled |
Every shippable unit has gone. fulfilled_at is stamped at that moment. |
complete |
Fulfillment status is computed, never assigned. It is recomputed from the order's shipments on every write. Nothing writes the column directly.
A line's shippable quantity is the quantity ordered, or zero for a digital line. A mixed order reaches fulfilled once its physical lines have gone.
Change a placed order
A placed order is edited by staging a change and confirming it, never by recalculating it in place. Confirming writes a new version and leaves every earlier version readable.
An order in a status flagged final — canceled, archived, or anything you flagged that way — needs an audited Unlock for editing first. That unlock is never refused, and it writes a history entry naming whoever pressed it.
The same mechanism carries returns, exchanges and claims. See Order changes for the action vocabulary, the preview, the versioning and returns.
Order changes are off until you turn on Order versioning under Settings → Features, which refuses while an installed add-on has not declared it understands versioned orders.
What order completion does
Completion runs in one database transaction:
- Stock is deducted for every stock-managed line, honoring the backorder policy and skipping quantity held for other orders.
- The order's own stock holds are released.
- Coupon redemption is recorded.
- A sequential order number is assigned from an atomic sequence, and an invoice number from a second one.
- The order is stamped
completedwith its payment status, fulfillment status, provider and completion time.
If any step fails, the transaction rolls back and the order stays a cart. The confirmation email and the completion event follow afterwards, only on the call that completed the order, so a redelivered webhook repeats neither.
Order numbers and invoice numbers
An order carries two numbers.
The order number is the handle you quote on the phone. It starts wherever order_number_start says, default 1000.
The invoice number is the fiscal document identifier an accountant reconciles against. It comes from its own counter, allocated in the same transaction as the order number, and is formatted by invoice.format: INV-{year}-{number:5} prints INV-2026-00042. See Configuration → Invoices for the format and the yearly reset.
Both invoices — the printable page and the PDF — lead with the invoice number and print the order number beneath it.
Note
An order placed before invoice numbers existed has none. It prints its order number in that slot and is never backfilled.
The invoice number travels with the order everywhere: the order detail, GET /kahunacart/orders/{id}, and its own invoice_number column in the CSV export.
Find an order
- In the admin, open Orders.
- Filter with the Status, Payment and Fulfillment chips above the list.
- Type into the search box.
- Page through the list with the pager below it.
- Click a row to expand its detail panel.
Search composes with the filter chips rather than replacing them. The chips filter the orders loaded for the current page, so clear a filter before paging.
What the search box matches
?q= on GET /kahunacart/orders matches seven things about an order, always all seven. 1002 finds the order numbered 1002 and any address containing it.
| What is searched | Where it comes from |
|---|---|
| Order number | kahunacart_orders.number |
| Invoice number | kahunacart_orders.invoice_number |
| Email address | kahunacart_orders.email |
| Customer name | name on the billing or shipping address |
| Company | company on either address |
| City | city on either address |
| Phone number | phone on either address |
Terms are matched with AND, so jane london finds Jane in London. The last word you typed is matched as a prefix, so the list narrows while you type. Case and accents are folded: bjorn finds Björn, and so does björn.
Street address, postcode, region and country are not indexed.
How the search stays fast
Everything searchable is folded into one indexed column, kahunacart_orders.search_text, when the order is written, so nothing scans the orders table for a search term. On top of it, KahunaCart builds the best text index the database engine offers. Migration 0031 picks one and records the choice in kahunacart_settings.
| Engine | Index | Notes |
|---|---|---|
| SQLite | FTS5 external-content table with triggers | Needs FTS5 compiled in, which every stock build has. |
| MySQL / MariaDB | FULLTEXT index on search_text |
Terms shorter than innodb_ft_min_token_size, 3 by default, fall back to LIKE. |
| PostgreSQL | GIN index over a tsvector expression |
Uses the simple dictionary, so names and emails match exactly rather than being stemmed. |
| Any | LIKE on search_text |
Always available. Correct, and the only one that scans. Every term is prefix-matched on this path, not only the last. |
Run bin/plugin kahunacart status to see which one the store is using.
Important
A database dumped from one engine and restored onto another keeps the recorded index name, which no longer belongs to the engine it now sits on. The search box falls back to the LIKE scan until you run bin/plugin kahunacart orders:reindex.
Rebuild the order search
Run this after an import that wrote orders straight into the tables, after restoring onto a different database engine, or after a release that changed what is searchable.
- Run
bin/plugin kahunacart orders:reindex. - Read the last line. It names the index the store ended up with.
The search box now matches every order. See CLI for the flags.
Choosing what a list shows
The orders, products and customers lists each carry a Columns control. Tick what you want to see; the choice is yours alone.
Each screen has one column that cannot be turned off — the order number, the product title, the customer's email — drawn ticked and disabled.
The orders list can also show where the order came from and its invoice number, both off by default. The products list can show cost and updated. Margin is not an order column; it is on the Analytics screen.
A saved choice is stored as a diff of what you turned off, so a column added by a later release appears for everybody. Reset to the defaults puts the code defaults back.
Renaming a field
The What this store calls things panel on the Features screen renames a field everywhere the admin names it: the list, the column picker and the export. Renaming is behind kahunacart.settings.
Overrides are stored per language with the shipped key underneath, so renaming a field in English leaves the shipped translation in place everywhere else. Clearing a box puts the shipped name back.
Read the order detail
The detail panel shows, top to bottom: the order summary, its line items and adjustments, the transaction ledger, its shipments, its history, and its notes. Download grants have their own tab.
The transaction ledger is the audit trail and the source of the refund arithmetic. Every provider interaction gets a row, first pending and then success or failed, with parent and child links tying refunds back to the capture they credit. Refunds issued in a provider's own dashboard land here too. On a store that sells in more than one currency, a charge the payment provider converted shows one more line under its amount, such as "Paid out as $100.90 at 1.1202": what reached your account after the provider's fee. See What the payment provider paid out.
A refund row that has not settled reads awaiting confirmation, and the detail shows an Awaiting confirmation chip beside the refunded total, which stays confirmed-only.
On a store that sells in more than one currency, every amount on the detail is in the order's own currency, and the one exception is a line's cost (cogs_minor), which is in the store's own currency. See Orders in another currency.
Order history
The History panel lists everything that happened to this order, oldest first: a parcel, a refund, a note, an email, a status moved by hand. Entries are stored in the kahunacart_order_history table and ride on GET /kahunacart/orders/{id} as a history array.
Each entry carries a dotted type, who did it, when, and where there is one a message and a block of structured detail.
| Type | Recorded when |
|---|---|
order.completed |
A cart became an order. Carries the number, provider and payment status |
order.canceled |
The sale was called off |
order.archived |
The order was filed away |
order.fulfilled |
Every shippable unit has gone |
order.partially_fulfilled |
Part of the order went out |
order.reversed |
Stock, coupon redemption and download grants were given back |
order.created_manually |
The order was entered by hand or minted by an add-on |
order.address_changed |
An address was changed on an order already placed |
shipment.created |
A parcel went out. Carries the carrier, tracking number and link, the internal note, and the lines in it |
payment.pending |
A failed payment went back to awaiting money |
payment.received |
The money landed |
payment.failed |
The provider refused a payment |
refund.requested |
A refund was submitted to the provider |
refund.pending |
The provider took the request and has not settled it |
refund.succeeded |
The money went back. Carries the amount, the refunded total, whether it was full, and the lines it covered |
refund.failed |
The provider rejected it, with the reason |
email.queued / email.sent / email.resent |
A message was queued, delivered, or queued again by hand. Each carries the kind |
note.internal / note.customer |
A note was written. See Order notes |
Every entry names an actor, one of four kinds: system (completion, a webhook, the queue worker), admin (a person pressed something, with their username and display name as they were at the time), customer, and plugin (an add-on wrote it).
Nothing writes a status without writing history. Two things follow:
- A repeated write records nothing. The new value is compared against the stored one first, so a double-clicked Cancel or a redelivered webhook makes one entry.
- A value that is not news records nothing.
fulfillment_statusreachingunfulfilledornaat completion is already covered byorder.completed.
The computed timeline
An order placed before the history table existed has no entries, and nothing is backfilled. Those orders render a computed timeline instead, assembled from the order row, the transaction ledger and the reversal stamp in data_json. The rule is per order: entries where there are entries, computed where there are none.
| Computed entry | Read from |
|---|---|
| Cart started | orders.created_at |
| Order placed | orders.completed_at, with the provider |
| Payment started / Payment received / Payment failed | A purchase transaction's created_at, then its updated_at once it settles |
| Refund submitted / Refund confirmed / Refund rejected | A refund transaction, the same way |
| Stock, discount and downloads given back | The reversed_at stamp a full refund or a cancel writes |
An entry appears only where a real timestamp exists. When an old order was canceled is still missing, because cancel wrote status and nothing else; it shows as the order's current status instead.
GET /kahunacart/orders/{id} carries the computed timeline as timeline alongside history, and the admin draws it only when history is empty.
Order notes
An order carries one notes thread: what you wrote to yourself and what you wrote to the customer. Reading needs kahunacart.orders.view; writing needs kahunacart.orders.manage.
- Internal notes (
note.internal) stay in the admin. The customer-facing history is filtered in SQL against an allowlist, so an internal note never leaves the database on a storefront request. - Notes to the customer (
note.customer) are emailed straight away and shown on the customer's receipt and in their account. See the customer-note email.
To write one:
- Open the order and scroll to Notes.
- Type the note.
- Tick Also email this to the customer if the customer should see it.
- Click Save note, or Save and send when the tick is on.
The note appears in the thread immediately. A note to the customer also queues the email.
Important
Only internal notes can be deleted. Asking to delete a note to the customer answers 409, because the customer already has it in their inbox.
An order entered by hand carries its data_json.admin_note as the first internal note in the thread, with no id and no Remove button.
Enter an order by hand
Use this for orders taken by phone, at a market stall, or by email. Requires kahunacart.orders.manage.
- Open Orders and click New order.
- Enter the customer email, and a name if you have one.
- Fill in the billing address. Ship to the billing address is ticked to start with; untick it to type a different shipping address. See Addresses on a hand-entered order.
- Search the catalog and add products with their quantities.
- For a business buying under reverse charge, fill in Customer VAT ID and tick Don't charge tax in the Tax section. See Selling without tax under reverse charge.
- Add a note for your own records if you want one.
- Click Create order.
The order is built by the same cart and checkout services a customer's own checkout uses: same number sequence, same line-item snapshots, confirmation email queued, completion event fired, stock deducted.
Two values are set for you. The provider is offline, so the detail offers no API refund. The payment is pending, so the order sits on the sidebar badge until you mark it paid or the customer pays through a pay link.
The form refuses an unpublished product and an out-of-stock variant. Adjust the quantity, or turn backorders on for that variant. Prices come from the catalog; there is no arbitrary-price field.
A set whose parts the shopper picks can't be entered on this screen yet. The form adds a product's variant and a quantity and has nowhere to choose a part's variant, so a grouped product with a part the shopper picks is refused with Pick which "Easel" you want. Over the API the same order works: a line takes parts, as below.
A customer on file pays what they would pay at their own checkout. When the email belongs to a customer, the order is priced for them: their customer group's price list and any sale for their group apply to the lines, so a trade customer ordering by phone gets the trade price. An email nobody has ordered with yet is priced like a guest's cart.
The note is stored in data_json under admin_note, alongside a manual flag. The customer never sees it, and it appears as the order's first internal note.
The currency of a hand-entered order
On a store that sells in more than one currency the form asks for Currency before anything else, and the order is placed in it. It is the store's own currency until you pick another. Every product on the order is priced in the chosen currency, from the amounts the product has there, so a euro order is a sum of euro prices and never a dollar price relabelled.
- A product with no price in the currency is greyed out in the picker with "Not sold in EUR" beside it, and cannot be added. If you change the currency of an order you have started, the lines are priced again in the new currency, and any line with no price there is taken off with a note naming it.
- The server refuses the same things.
POST /kahunacart/orderstakescurrency. A code the store does not sell in, or a product with no price in the currency, answers422and creates nothing. Left out, the currency is the store's own, so a script that never sent one keeps working. - The note under the field says what the customer last ordered in, when they have ordered before. It is only a note: the currency stays the one you chose.
- The currency of an order cannot change once it is placed. A customer who wants it in another currency gets a new order.
The addresses, the tax, the postage and the rest work as they do on any hand-entered order, in the order's currency.
Addresses on a hand-entered order
Tax and shipping are worked out from the addresses, exactly as at checkout. With tax zones set up, an order for a customer in Germany is charged Germany's rate, and with shipping zones set up, the zone the shipping address falls in prices the delivery. An order with no address matches no zone, so it carries no zone tax and no postage, though a flat tax rate still applies. Leave the addresses empty only for an order that needs neither.
- Once the email matches a customer, the form fills both addresses from their default billing and shipping addresses, and a Saved address list beside each one lets you pick another from their address book. An address you have already typed is never replaced. Reading the book needs
kahunacart.customers.view; without it the form works the same, just without the book. - Country is a list, and so is State for countries that have states. A country or state that is not on the list is refused, naming the address, because an order with an address no zone can match would be charged no tax and nobody would be told.
- Save to the customer's address book as their defaults is off to start with. Ticked, the addresses are saved to the book when the order is placed, the way the "save this address" tick at checkout saves them. An address you picked from the book is not saved a second time. Saving changes the customer's record, so it needs
kahunacart.customers.manageas well as the orders permission. Without it the tick is not shown, and the API answerssave_addresseswith a403naming the permission. - The shipping method is the first one the zone offers, the same one a checkout starts on. Through the API,
shipping_method_idpicks another, and it is checked against what the zone offers.
The items total on the form is before tax and shipping. The order you land on after Create order shows the whole of it.
Through the API, billing and shipping are objects of the address fields (name, company, address1, address2, city, region, postcode, country, phone). Leave shipping out, or send it empty, when it is the billing address. country is the two-letter code, and GET /kahunacart/countries lists them with their states. A body with no address builds the order it always did.
POST /kahunacart/orders
{
"email": "[email protected]",
"name": "Handel GmbH",
"billing": { "address1": "Hauptstraße 1", "city": "Berlin", "postcode": "10115", "country": "DE" },
"save_addresses": true,
"items": [{ "variant_id": 42, "qty": 3 }]
}
To keep a customer's saved addresses up to date between orders, see Customers → A customer's address book.
Selling without tax under reverse charge
The Tax section of New order has Customer VAT ID and Don't charge tax, for a business in another EU country that buys without VAT and accounts for it at home. Don't charge tax is off to start with. Ticked, the order is charged no tax, and a price that includes tax is charged without it, exactly as for a customer outside every tax zone.
Check the VAT number on VIES before you tick it. The store takes it as you type it (spaces taken out, in capitals) and does not check it.
The VAT number is printed on the receipt, the invoice and credit notes whether or not tax is charged, and an order sold without tax also prints a reverse-charge line. The order view shows the VAT ID under the customer's email and "Not charged (reverse charge)" beside Tax. See Tax → Orders entered with "Don't charge tax".
Through the API, send tax_exempt: true and vat_id. A vat_id that is not text, is longer than 32 characters, or holds anything but letters, digits and . + * / - is refused with a 422. A body with neither builds the order it always did.
Change a status without telling the customer
Notify customer sits above the order's action buttons, ticked by default. There is one tick per order, not one per dialog.
- Open the order in Orders.
- Untick Notify customer.
- Run the action.
The next action — mark paid, create a shipment, ship all remaining, cancel, refund — does everything it normally does except send the customer's message. Use it for a correction: goods that went out last week, or money confirmed a month ago.
The history entry carries a quiet flag, drawn as a quiet tag. Nothing is queued, so there is no email.queued entry. Canceling quietly also holds back the merchant notification.
On the API, each of those endpoints takes notify in the body, defaulting to true. The strings "false", "0" and "off" all mean false, and an explicit null falls back to the default.
Mark an order paid
Use this when an offline payment arrives. Requires kahunacart.orders.manage.
- Open the order in Orders.
- Click Mark paid.
The payment status becomes paid and the order-paid event fires. That event mints download grants when downloads.grant_on is paid, so a digital sale over bank transfer delivers the moment you confirm the money. It also queues the payment-received email.
Both are guarded on the status changing, so a double click or a redelivered webhook produces one of each. A payment that is not pending is refused with a 409: Order payment is not pending. The exception is a Part paid order, so a customer who paid a deposit by card and sent the balance by bank transfer can be settled by hand. See Deposits.
Tip
Untick Notify customer first to settle an order whose money you confirmed weeks ago without sending a fresh receipt.
Disputes
When a customer disputes a payment, the order shows a red Disputed badge in the Orders list and on the order screen. The alert strip at the top of the admin says how many orders have an open dispute, and Review the disputes opens the list filtered to them. Once any order has had a dispute, the list's filter row has a Dispute filter for open, won or lost.
The badge follows the dispute:
- Stripe and PayPal say how it ended. The badge turns into Dispute won (you keep the money) or Dispute lost (it went back to the customer) by itself, the order's history says which, and you get the Dispute won or lost message.
- Paddle and Mollie don't say. When the provider has decided, pick the outcome from the select beside the badge on the order screen: Won: we kept the money or Lost: the money went back. The same select puts right one that was reported wrong.
Answer the dispute itself in the provider's dashboard. Nothing about the order's own statuses changes: a dispute isn't a refund, and an order you win stays exactly as it was.
Setting the outcome needs kahunacart.orders.manage. Through the API it's POST /kahunacart/orders/{id}/dispute with {"outcome": "won"} (or lost, or open), or the MCP tool set_order_dispute.
Collect the balance on a part-paid order
An order sitting at Part paid has had a deposit taken and owes the rest. Collect balance asks for it: it opens the balance session, sends the customer the balance request email, records the send on the order's history, and hands back the pay link the message carries.
The link is the order's ordinary pay link, which charges whatever is outstanding rather than the order total. The same action sits on each row of the Deposits screen.
Requires kahunacart.orders.manage. It is refused on an order with no deposit, one paid in full, one carrying no email address, and when the balance request is switched off on the Notifications screen. See Deposits → Ask for the balance.
Refund is offered on a part-paid order too, bounded by what the provider actually took rather than the order total: a £1000 order with a £300 deposit has £300 to give back. Refunding part of the deposit lowers what the order comes to rather than adding to the balance, and refunding all of it puts the order back to Pending. See What a refund does to the balance.
Pay links
Every order that still has something to pay carries a Payment link on its detail, beside the customer's email, with a copy button. It is an absolute URL, ready to paste into a message, and it opens a storefront page where the customer picks a payment method and pays, with the methods checkout would offer for that basket.
GET {route}/order/{hash}/pay?t=…
The t parameter is a signature over the order hash and this page, minted from a per-site secret. It is the authorization, so a guest order needs no login. An admin holding kahunacart.orders.view and a signed-in customer who owns the order open the page with no token. See Hardening → Order links expire.
The field is absent on an order with nothing to pay. Four things stop a link working, and the page says which:
| Reason | What it means |
|---|---|
| Already paid | Including refunded and partially refunded. Money has moved on this order |
| Cancelled | Or archived. The sale was called off |
| Expired | data_json.pay_link_expires_at on the order has passed |
| No payment method | The store has nothing configured that can sell this basket |
Paying a cart completes it, as checkout does. Paying a completed order marks it paid. The provider that took the money replaces offline on the order.
The two deadlines
A pay link has two expiries, and they stop different things.
| Deadline | Set from | Stops |
|---|---|---|
| The token's signature | orders.pay_link_days, default 7 |
A URL from a forwarded email working forever |
data_json.pay_link_expires_at |
orders.pay_link_days, written when a link is issued |
The order being payable at all, however the customer arrives — including from the admin's own session |
The stamp is written whenever a link is issued on purpose: by the pay-link email job, by the Reissue buttons, or by an add-on. A malformed value reads as no deadline at all.
Reissue a pay link
Do this when a customer says their link no longer works. Requires kahunacart.orders.manage.
- Open the order in Orders.
- Click Reissue beside the Payment link, or Reissue & email to send it to the customer as well.
POST /kahunacart/orders/{id}/pay-link
The deadline moves forward by orders.pay_link_days, a fresh signature is minted, and the field updates to the new URL. An expired deadline is not a refusal here. An order that cannot be paid at all is a 409, and asking to email one on an order with no address is a 422.
Shipments and partial fulfillment
A shipment is one parcel: quantities taken off the order's lines, handed to a carrier on a date, optionally with a tracking number. An order has as many as it took to send it, and its fulfillment status is computed from them.
The Shipments panel on the order detail lists every parcel with its date, carrier, tracking link and contents, and holds both ways to record one. Both need kahunacart.orders.manage.
Ship everything still owed
- Open the order in Orders.
- Click Ship all remaining.
One parcel is recorded covering every unit not yet gone, with no carrier named, and no confirmation is asked. The same action is a bulk action on the order list.
Create a shipment
Use this when the box does not hold the whole order, or when you have a tracking number.
- Open the order in Orders.
- On the Shipments panel, click Create shipment.
- Set the quantity on each line to what actually went in the box.
- Choose a Carrier, and type a name if you chose Other.
- Enter the Tracking number.
- Click Record shipment.
| Field | Notes |
|---|---|
| Ship now | Defaults to the line's remainder. A line with nothing left, and a digital line, is fixed at zero. |
| Carrier | From the carrier list. Other reveals a free-text name. |
| Tracking number | As printed on the label. |
| Tracking URL | Filled in from the carrier's template as you type the number. Type your own to override it. |
| Note | Internal. The customer never sees it. |
The fulfillment status is recomputed, shipment.created is written to the history, and the shipped email is queued for this parcel.
Both actions refuse five things, writing nothing when they do:
| Condition | Message |
|---|---|
The order is not completed |
Only a completed order can be shipped |
Fulfillment status is na |
This order is digital-only — there is nothing to ship |
| A quantity above a line's remainder | "Enamel mug" has 3 left to ship, not 4. |
| A digital line | "Reef field guide" is a download — there is nothing to ship. |
| Nothing left to ship, on Ship all remaining | Every line on this order has already shipped. |
Payment status is not consulted, so you can ship before the money arrives.
Important
A shipment cannot be deleted or voided, and there is no un-ship. Correct an under-shipment by shipping the remainder.
What a shipment records
| Column | Notes |
|---|---|
status |
shipped, the only value today. Delivery is recorded in delivered_at rather than here. |
carrier |
A carrier code, not a name: ups, royal-mail, other. |
tracking_number |
As entered. |
tracking_url |
Stored, not recomputed on read, so a link already emailed never changes. |
note |
Internal. |
shipped_at |
When it went. |
delivered_at |
When it arrived, or null. Set once, by Mark delivered. |
created_by_type / created_by |
admin plus the Grav username, where the request has one. |
Mark a shipment delivered
Mark delivered on a shipment row records that the parcel arrived. The row then shows Delivered with the date under its shipping date, and the button goes away.
- Open the order and find the shipment under Shipments.
- Click Mark delivered.
- The time is now, or
delivered_at(unix seconds) over the API. A time in the future is taken as now, and one before the parcel shipped is raised to the shipping time. - It is set once. A second click, or a carrier add-on marking the same parcel, changes nothing, sends nothing and answers the time already recorded.
- The first call fires
onKahunaCartShipmentDeliveredwithorder_idandshipment. For an order an agent placed through UCP, that sends the platform adeliveredorder webhook, which Google's order lifecycle expects. See UCP checkout → Orders and webhooks. - It does not change the order's fulfillment status, which counts what shipped, and it sends the customer no email.
A carrier add-on that tracks parcels calls FulfillmentService::markDelivered() itself; see Provider developer guide → A parcel arrived. The API route is POST /kahunacart/orders/{id}/shipments/{sid}/delivered, MCP tool mark_shipment_delivered, with kahunacart.orders.manage.
Where the customer sees a shipment
Shipments appear on the customer's receipt page and in their order history with the tracking link, and in the shipped email for that parcel. The internal note never leaves the admin.
Print a packing slip
A packing slip is the document that goes in the box: the store's letterhead, the delivery address, the order number, and the lines with their SKUs and quantities. No prices anywhere. The customer's own checkout message is printed on it.
- Open the order in Orders.
- Click Packing slip on the Shipments panel for the whole order, or on a shipment row for that one parcel.
| Button | Document |
|---|---|
| On the Shipments panel | The whole order. Every physical line at the quantity ordered, noting what has already gone. |
| On a shipment row | That parcel only, at its quantities, with its carrier and tracking number in the header. |
The PDF downloads. Requires kahunacart.orders.manage. Override templates/kahunacart-packing-slip.html.twig in your theme to restyle it. Paper size follows invoice.paper.
Cancel an order
Cancel calls off a sale whose money never landed and gives back everything completion took. Requires kahunacart.orders.manage.
- Open the order in Orders.
- Click Cancel order….
- Read the confirmation, which lists what is about to be given back.
- Confirm.
In one transaction, the status becomes canceled and the reversal runs: stock goes back on the shelf, the coupon redemption is released, and every live download grant is revoked. The order comes off the sidebar badge. Both are recorded in the history, as order.canceled and order.reversed.
Cancel is offered only for a completed order with payment pending or failed. Asking on a paid order returns a 409: A paid order is refunded, never canceled — issue a refund instead.
Important
Canceling is not deleting. The order keeps its number, totals, line items and ledger, and stays in the list under the canceled status. Its detail panel shows what happened in place of the mark-paid, ship and refund buttons.
Refund an order
Refund calls the provider that took the payment. Requires kahunacart.orders.refund.
There are two kinds of refund, and the difference is whether the refund says what it was for.
- A per-line refund names the lines that came back. It moves money and gives the goods back. This is what the Refund form issues.
- An unallocated refund is an amount and nothing else: a goodwill credit, a price adjustment, or a refund recorded to match one issued in the provider's own dashboard. It says nothing about the goods.
POST .../refundwith only anamount, or with nothing at all, does this.
To issue one:
- Open the order in Orders.
- Click Refund…, or Refund {amount} on a provider that only does full refunds.
- Set a quantity on each line that came back, and a shipping amount if postage is going back too.
- Enter a Reason for your own records.
- Click Refund.
The plugin checks five things before calling the provider:
| Check | Message when it fails |
|---|---|
The order is a sale rather than a basket: a lifecycle status flagged complete, which is completed and archived on a store that has added none, plus any status you add and flag the same way |
Order is not refundable. |
| The order has a successful purchase transaction | No captured payment to refund. |
The provider is registered and declares refunds |
This payment method cannot refund automatically — record a manual refund instead. |
| The amount is positive and within the refundable balance | Refund amount exceeds the refundable balance. |
A partial refund needs partial_refunds |
This payment method only supports full refunds. |
A per-line refund is checked three more times:
| Check | Message when it fails |
|---|---|
The provider declares partial_refunds, whatever the lines come to |
This payment method only supports full refunds — refund the whole remaining balance instead of individual lines. |
| Every line is on this order and none is refunded past what is left of it | Only 2 of "Kelp Shirt" can still be refunded. |
| The shipping refund is within what the order has left to give back | The shipping refund is more than this order still has to give back. |
Quantities already refunded count whether confirmed or merely submitted, so the same unit cannot go back twice while the first refund is in flight. The refundable balance is the captured amount, minus confirmed refunds, minus refunds awaiting confirmation.
If the provider settles immediately, the ledger row is marked successful and the payment status is recomputed: refunded once confirmed refunds meet the captured amount, partially_refunded below it.
The refund form
The form lists the order's own lines, each with a quantity picker running from zero to what is left of it. Under them:
A refund is in the order's own currency from the amount typed to the provider's answer. On a yen order the form takes whole yen, on a euro order euro with two decimals, and the provider is asked to send back that amount in that currency. A refund recorded by hand, and the credit note either one earns, are in the order's currency too.
| Field | What it does |
|---|---|
| Shipping | Postage to return, capped at what has not already gone back. Shown only for an order that charged any. |
| Total | Computed from the pickers as you change them, and editable. Type an amount to override it; the lines still record what the refund was about. |
| Reason | Kept on the refund's ledger row. Never shown to the customer. |
| Restock returned items | Puts the units back on the shelf. Offered only when a stock-managed line is selectable. |
A line already refunded in full keeps its row and loses its picker. Once anything has been refunded, the order's line table gains a Returned column showing how many of each line have gone back.
Whether the customer hears about it is set by the Notify customer tick above the order's buttons, not by this form.
What a per-line refund gives back
All of it happens when the provider confirms the refund: at once for a provider that settles on the spot, on the confirmation webhook for one that does not.
- Stock goes back for the lines you ticked, for stock-managed variants only. Each line is claimed before its units move, so a redelivered confirmation gives nothing back twice.
- Downloads on a digital line are revoked when that line's refunded quantity reaches the quantity bought, and not before. See Digital products → Refunds.
- The coupon and the reversal wait for the whole order. A refund that brings the order to fully refunded runs the same reversal a full refund runs.
onKahunaCartRefundAppliedfires for add-ons, carrying the lines with the quantity refunded so far against the quantity bought. See Writing a provider.- The customer is emailed the refund notice, listing the lines and the postage this refund covered, unless you unticked Notify customer.
- The history records one
refund.succeededentry, with the amount, the order's refunded total, and the lines that came back and whether each was restocked.
A coupon writes its discount as one adjustment per line, so a refund of one line gives back what the customer paid for it: the goods, less its share of the code. See Coupons and Money conventions.
The reversal and the per-line restock cannot double up. The reversal gives back only the units no refund line has already claimed, so a line refunded with Restock returned items off is not overruled later.
Which refund controls the admin shows
The admin reads provider capabilities from the config endpoint and draws the order detail to match.
| The provider that took the payment | What you see |
|---|---|
Declares refunds and partial_refunds |
The Refund… button and the per-line form behind it. |
Declares refunds only |
A single Refund {amount} button for the whole remaining balance. No form. |
| Declares neither | No button. A muted note naming the provider. Refund it in the provider's own dashboard. This is what an offline order shows. |
| Is not registered any more | The button, unchanged. Capabilities are unknown, so the backend decides. Re-enable the provider plugin to make the refund work. |
Pending refunds
Many providers accept a refund without settling it. The toast then reads Refund submitted — waiting for the provider to confirm it.
Until the provider answers:
- The ledger row sits at awaiting confirmation, and the refunded total excludes it.
- The payment status does not move, and a full refund reverses nothing yet: no restock, no coupon release, no revoked downloads.
- The amount is held out of the refundable balance, so the same money cannot be refunded twice.
- The history records
refund.pending, and no email is sent.
Then one of two webhooks lands:
- Confirmed. The waiting row is marked successful, no second row is created, and the payment status is recomputed. A refund that brings the order to fully refunded gives everything back at that point.
- Rejected or failed. The row is marked failed with the provider's reason, the payment status stays put, nothing is reversed, and the held balance is released so you can try again.
A rejection naming an already-confirmed refund changes nothing and is written to logs/grav.log, as is one for a refund issued in the provider's own dashboard.
What a full refund reverses
A refund that brings the order to fully refunded reverses what completion did, in one pass:
- Stock goes back on the shelf, for stock-managed variants only.
- The coupon redemption is released, so the code is available again and the customer's per-user count drops back. See Coupons.
- Every live download grant is revoked. The rows stay as audit, stamped. See Digital products → Refunds.
This happens once per order, however the refund was issued and however many times the webhook is redelivered. It records order.reversed with which of the three steps ran. A reversal never touches money.
Important
An unallocated partial refund gives nothing back. Nothing in the ledger says which items the money was for, so stock, coupon usage and download grants are left alone. Use the per-line form when you want the goods returned with the money.
A full refund also blocks downloads on its own, because the download check reads the order's payment status.
Refund to store credit
A refund can go back to the provider, to the customer's store credit, or be split between them.
A refund to credit goes into the customer's credit account in the order's currency: a euro order credits the euro account and a yen order the yen account, and a customer has one account for each currency they hold credit in. The amount is read with that currency's decimal places.
POST /kahunacart/credit/refunds takes the same body the order's own refund route takes, plus order_id. It is a refund in every other way: same refundable balance, same restocking, same download grants revoked, same onKahunaCartRefundApplied, same credit note, same refund email.
To split one, call the order's refund route for the card's share and this one for the rest. The order then carries two refund rows, each recording where its money went.
An order paid with store credit is refunded back to store credit. The store-credit payment method declares no refund capability, so the provider refund button is not offered for it.
Credit notes
A credit note is the numbered document a refund earns, and in most of the EU issuing one is not optional. KahunaCart issues one automatically whenever a refund is confirmed, never when one is submitted. One per refund, so a partially refunded order accumulates one document per refund. A redelivered confirmation finds the note already there and mints nothing.
The order detail lists an order's credit notes with a link to each PDF. Reading them needs kahunacart.orders.view.
| Route | Answers |
|---|---|
GET /kahunacart/orders/{id}/credit-notes |
Every note on the order, oldest first, with its number, total and tax. |
GET /kahunacart/orders/{id}/credit-notes/{cnid}.pdf |
One of them as a PDF, no-store. |
Credit-note numbers come from their own gapless sequence, separate from invoice numbers, and the format is configurable. See Store credit → Credit notes.
Resend an order email
Use this when a customer says a message never arrived. Requires kahunacart.orders.manage.
- Open the order in Orders.
- Click Send email….
- Click the message you want to send again.
The menu lists every message this order can carry, with the last time each was queued and each was sent. "Queued 09:14, never sent" is what a broken mail transport looks like.
The message is re-rendered against the order as it stands now, and re-queued with the payload the last message of that kind carried: a refund email resends for the amount it was about, a note resends with its note, and Shipped repeats the last parcel's contents and tracking number.
Balance request is on the menu of every order, and refused on one with no deposit. See Collect the balance on a part-paid order.
Refund and Note to the customer cannot be composed from the order row alone. Resending either before one was ever sent answers 422, and the menu greys the button out. Shipped falls back to the order's physical lines with no carrier named.
The resend records email.resent in the history. See Emails → Resending.
Revoke a download grant
Revoke a grant after a chargeback, or when a link turns up somewhere it should not. Requires kahunacart.orders.manage.
- Open the order in Orders.
- Go to Downloads, which lists every grant with its file, download count, expiry and status.
- Click Revoke, then confirm.
The grant is stamped and the row stays as audit. The customer's order page stops showing it.
Caution
There is no un-revoke. Reinstating access means granting again.
Bulk edit
Bulk edit changes one field across many products at once, at KahunaCart → Merchandising → Bulk edit. Requires kahunacart.products.manage.
- Open Merchandising and stay on the Bulk edit tab.
- Search the catalog and tick the products to change.
- Choose the field, the mode, the unit and the value.
- Click Preview.
- Read the preview, then click Apply.
The whole run happens in one transaction, so it either all applies or none of it does.
Preview writes nothing. It says how many products and variants would change, and shows the first ten before-and-after values.
Fields
| Field | Changes | Kind |
|---|---|---|
price |
What the customer is charged | Amount |
compare_price |
The struck-through "was" price | Amount |
stock_quantity |
The variant's stock, through the stock service | Whole number |
status |
published, draft or archived |
Word |
visibility |
listed or unlisted |
Word |
featured |
On or off | Flag |
min_qty |
The smallest quantity one order may hold | Limit |
max_qty |
The largest | Limit |
qty_step |
The multiple the quantity has to be | Limit |
sold_individually |
On or off | Flag |
exclude_order_rules |
On or off | Flag |
exclude_category_rules |
On or off | Flag |
The first three are variant fields, so a product with twelve variants has twelve prices. The rest are product fields.
A limit is a whole number that can also be absent, and absent is what sends the product back to the store default. A bulk edit cannot send a blank, so 0 is how a limit comes off. Limits can only be changed to a value: "increase every minimum by ten percent" is not a sentence anybody means. See Quantity rules.
WooCommerce's regular price is KahunaCart's compare_price, and its sale price is KahunaCart's price. Scheduled, rule-driven discounting is a different feature — see Sales.
Modes
| Mode | Means | Units |
|---|---|---|
| Change to | The field becomes the value | Fixed only |
| Increase by | Add to what is there | Fixed amount or percentage |
| Decrease by | Take away from what is there | Fixed amount or percentage |
Only the numeric fields can be increased and decreased. A percentage cannot be combined with Change to. Percentages are decimal strings — 10, 12.5 — and a percentage of money is computed in minor units so it rounds once. See Money conventions.
Results are clamped at zero. A £10 decrease on a 50p item is free, not minus £9.50.
Stock changes go through the stock service, so no write to a stock column is invisible to an add-on. A variant that does not manage stock is skipped.
The row cap
admin.bulk.max_rows caps how many products one run may change, default 1000. A run selecting more is refused with a message naming the cap, never silently cut short. See Configuration.
Product history
Every product carries its own change log, in the same form as the order history: a dotted type, an actor, a timestamp, a message and a block of structured detail.
| Type | Recorded when |
|---|---|
product.bulk_edit |
A bulk edit changed this product. Carries each field with its before and its after |
product.import_created |
An import created this product |
product.import_updated |
An import updated this product |
One entry per product per run, not one per variant. A 10% rise across a product with twelve variants is one entry carrying twelve changes.
Entries are append-only; nothing here is edited or recomputed. Each entry names its actor: system, admin with the username as it was at the time, or plugin.
Read the log with GET /kahunacart/products/{id}/history, or with the get_product_history MCP tool. The admin does not yet draw a panel for it.
Related products
KahunaCart → Merchandising → Related products curates the three hand-picked lists a product carries: related products, cross-sells and up-sells. Search for the product, add products under the kind you want, drag the rows into order, and click Save. The storefront renders them in that order, and nothing is derived.
Rebuild cache clears the cached lists for the whole store and sweeps rows pointing at deleted products, reporting how many orphaned rows it swept and how many links remain. Saving a single list already clears the cache, so use Rebuild cache after a database restore or a bulk deletion rather than as routine.
See Related products, cross-sells and up-sells for what each kind means, where each renders, and the config keys behind them.
The Search screen
KahunaCart → Search reads and manages the catalog index and the store's search vocabulary. Requires kahunacart.products.manage.
The top of the screen reports the index: how many products are in it, how many rows (one per buyable variant), how many are waiting to be re-indexed, which text-matching method this database engine got, and whether the index is being read right now and why.
Two actions sit in the header:
| Action | Does |
|---|---|
| Index what changed | Indexes the products waiting, and nothing else. |
| Rebuild everything | Reads and writes every product again, after a confirmation naming the count. Listings keep working throughout, on the live queries. |
Rebuild by hand after an import that wrote products around the API, after restoring a database, or when a listing shows something no longer true. A very large catalog may not finish in one request; the screen then tells you to finish it with bin/plugin kahunacart index --rebuild.
Search synonyms
The lower half of the screen is a table of synonyms. When a shopper searches for the word on the left, the catalog is also searched for the words on the right.
- Open Search and click Add a word.
- Type the word a shopper searches for.
- Type the words it should also find, separated by commas. At most twelve.
- Click Save synonyms.
They work on the very next search. Nothing is re-indexed.
Synonyms go one way only. Jumper finding sweaters does not make sweater find jumpers; add that row too if you want it. A row needs both a word and at least one word it should also find, or the save is refused.
Use this when searches for a word keep coming back empty and you know what the shopper meant.
See The catalog index for what the index holds and when it is read, and Storefront for the search box the shopper uses.
Import a catalog
KahunaCart → Import & Export brings products, categories, variants, customers and orders in from CSV files, with column mappings you can save and re-use, a dry run that writes nothing, and a downloadable report of the rows that failed.
The same screen exports the whole store into one bundle and restores a bundle into another store. See Export and restore.
Import customers before orders. An orders file carrying email addresses needs no customers file: the importer resolves each order's customer by email and creates one where there is none.
Importing customers or orders needs kahunacart.orders.manage on top of kahunacart.products.manage.
Important
Importing an order does not send emails, reduce stock, grant downloads or fire an order-completed event. It carries no adjustments, tax lines or shipping lines, and the file's total is stored as authoritative, so an imported order's totals need not add up from its lines.
The procedure, the fields each entity accepts, the value formats and the retention rule for uploaded files are on Importing.
Customers
Customers lists everyone who has bought from the store: email, name, the Grav account they log in with if they have claimed one, order count and lifetime spend. Search matches email, name or Grav username. Requires kahunacart.customers.view. Opening a customer shows those figures plus every order they have.
The order count and lifetime total count placed orders that were not refunded in full, the same set the Reports revenue figure uses, and a partially refunded order keeps its full total. The table below lists everything, refunded and canceled included. A customer with no qualifying orders appears with zeros.
Note
The Customers section is read-only. The email is the identity, the name arrives with an order, and the account link is made at login, so none of them is editable here.
Sign-in accounts
On a store running store accounts, set with account.db_identity, each row carries a Sign-in state: No account, Invited, Active, 2FA on or Disabled. Opening a customer adds a Sign-in account panel with three actions, behind kahunacart.customers.manage.
This is the only screen that can manage those accounts: a customer whose credentials live in the store database never appears in Grav's admin Users list. The panel never shows a hash, a secret or a live token. On a store keeping its customers in Grav accounts, the column and the panel are absent.
See Customer accounts for what each action does.
Customer groups
Customer groups is its own screen, directly under Customers. A group is a code, a label and an optional flat discount percent, and one group may stand in for everybody in no group. A customer's group is set from their own record. The screen is behind kahunacart.settings; putting a person in a group needs kahunacart.customers.manage.
Deleting a group is allowed with members in it. Nothing about those people or their orders changes, and the answer names how many were freed. See Customer groups.
The review queue
Reviews is its own screen, under Catalogue. It lists reviews oldest first, filtered by state, each with its rating, its text, who wrote it and a Verified purchase badge where the reviewer has a paid order carrying that product. Approve publishes a review and recomputes that badge. Reject keeps the record with a timestamp and the moderator's name. Delete leaves nothing behind and is for spam. You cannot write or edit a review here. Requires kahunacart.products.manage. See Reviews and ratings.
Wishlists on a customer
Opening a customer shows their saved lists, read-only: the name of each, whether it is shared, and what is on it. You cannot edit somebody else's list. Requires kahunacart.customers.view.
Reassign a customer's orders
Reassign orders moves every order from this customer to another, with its history, notes, refunds and download grants. Each order gains a history entry naming the move.
Run it as a dry run first: the dialog reports which orders would move and writes nothing. bin/plugin kahunacart orders:reassign --from --to --dry-run does the same from a terminal, taking an id or an email address on either side. Requires kahunacart.customers.manage.
Erase a customer
Erase customer answers a right-to-erasure request. It collects deletion blockers first and refuses with each named: an order still open, a download the customer can still fetch, and whatever add-ons report holding on their behalf. The confirm dialog shows that list.
Erasure anonymises rather than deletes. No order is removed and no amount changes, so the sales figures are unchanged. The customer's orders, address book, sign-in account and wishlists are anonymised, and so is the identity behind their reviews. The review text stays. Requires kahunacart.customers.manage.
Caution
Erasure has no undo. Take any export the customer asked for before you run it.
Retention
The Retention panel shows the four data-retention windows and what each rule last did, dry runs included. Every window is off until you set one.
| Rule | Does |
|---|---|
| Trash pending orders | Archives placed orders whose payment never arrived. |
| Trash failed orders | Archives orders whose payment failed. |
| Anonymise completed orders | Replaces the customer details on finished sales and leaves every amount alone. |
| Delete inactive customers | Erases inactive customers, through the same blockers an operator faces. |
Run now runs the sweep on demand instead of waiting for the worker tick, and takes a dry run. Reading the panel needs kahunacart.customers.view; running it needs kahunacart.customers.manage.
A row is written per rule per run, dry runs included. Trashing means archiving; the two trash rules delete nothing. See The retention schedule for the windows, their units and the dry-run-first rule.
Reports
Both report endpoints take ?from=YYYY-MM-DD&to=YYYY-MM-DD and default to the last 30 days. Requires kahunacart.reports.
Dates are UTC days, and to means the end of that day, so a single-day range works. A range the wrong way round is swapped; a malformed date falls back to the default.
Summary figures
| Figure | What it counts |
|---|---|
revenue_minor |
Total of completed orders whose payment is not fully refunded. |
orders |
How many of those there were. |
aov_minor |
Revenue divided by orders, integer division, zero-safe. |
items |
Units sold across those orders. |
refunded_minor |
Successful refund transactions raised in the range, whenever the order they credit was completed. |
by_day |
Revenue and order count per bucket, continuous. Buckets with no orders come back as zeros. |
bucket |
day or month. A range over 400 days buckets by calendar month, and date reads 2026-08 instead of 2026-08-14. |
top_products |
Top 10 by revenue. Titles come from the order-item snapshot, so a renamed or deleted product shows the name it sold under. |
by_provider |
Orders and revenue per payment provider. Orders with no provider group under an empty string. |
There is no cap on the range. The response also echoes the resolved from and to dates.
Reports read a daily rollup rather than scanning the orders table. See Reports and the rollup for what the rollup holds, when its numbers are exact, and how to rebuild it.
A fully refunded order is dropped from revenue outright. A partially refunded one keeps its full total in revenue_minor and has its credit counted in refunded_minor. Netting the two is your decision.
All money is minor units in the currency the report is in, which is the base currency unless the request names another with ?currency=EUR, and each answer says which in currency. Orders in other currencies are counted under other_currencies and never added in. See Reports and totals.
Export orders to CSV
- Open Reports.
- Set the date range.
- Click Export CSV.
GET /kahunacart/reports/export?from=2026-01-01&to=2026-01-31
The file downloads as kahunacart-orders-{from}-to-{to}.csv, following RFC 4180: header row, CRLF line endings, fields quoted when they contain a comma, quote, CR or LF, and embedded quotes doubled.
Columns: number, invoice_number, date, email, status, payment_status, provider, currency, items_total, adjustments_total, total. Dates are ISO 8601 UTC, for example 2026-01-15T14:32:00Z. invoice_number is empty for an order placed before invoice numbers existed.
The export differs from the summary twice: it includes refunded orders that the summary drops, with a payment_status column to filter on, and writes money as decimal strings in each order's own currency, with currency naming the unit per row.
Bulk actions on orders
Tick the boxes in the order list and act on the lot: change their status, print their invoices, or export them. The selection survives a page of filtering, and the bar at the bottom says how many are picked.
Change the status of many orders
- Tick the orders you want, or the box in the header to take the whole page.
- In the bulk bar, choose a status: Completed, Canceled or Archived.
- Type a message if you want one. It is recorded on every history entry the run writes.
- Untick Tell the customer to change the statuses quietly. The history records the run as quiet.
- Click Apply.
The work runs in the background as one job, not one job per order.
What it guarantees:
- Every order is its own transaction. A failure on one order neither rolls back the others nor half-applies itself. The run carries on and the report names it.
- Every change writes history. Bulk changes go through the same door a single-order change does.
cartis not offered. Un-completing a placed order would un-reserve stock and orphan its order number.- An order already in that status is skipped, not failed.
Read the report
bulk_order_status answers with a job id, and the report is read back from it.
GET /kahunacart/orders/bulk/{jobId}
The MCP tools are bulk_order_status to start a run and get_bulk_run to read it. finished is false until the worker has run it. The report carries counts of changed, skipped and failed, plus one entry per order with its outcome and, for a failure, the reason. The admin shows the failures under the bulk bar when the run finishes.
The cap
orders.bulk.max_orders, 500 by default, is how many orders one run may touch. Past it the run is refused with a message naming the cap, before anything is queued.
Bulk invoices
POST /kahunacart/orders/bulk/pdf renders the invoices for a selection, in one of two modes.
| Mode | What comes back | For |
|---|---|---|
zip |
One PDF per order in a ZIP, named by invoice number | Filing, such as handing a folder to an accountant |
collated |
One PDF holding all of them, page-broken | Printing a hundred invoices in one print job |
The ZIP mode needs PHP's zip extension. Where it is missing the request is refused rather than falling back to a collated file.
The collated document reuses the single invoice's own template and styles, so restyling kahunacart-invoice-pdf.html.twig in your theme restyles both. kahunacart-invoices-collated.html.twig is the thin shell around the batch.
This is the one route here with no MCP tool.
Export orders with your own columns
Two exports answer different questions. The reports CSV is a fixed eleven-column ledger over a date range. This one lets you choose the columns and save the choice.
Two scopes
| Scope | One row per | Answers |
|---|---|---|
orders |
Order | "What did we sell, to whom, for how much" |
line_items |
Line | "Which SKUs moved" |
They are two files, not one with a flag.
Export a selection
- Tick the orders in the list, or leave nothing ticked to export by filter.
- Click Export.
- Choose the scope, the columns and the format.
- Narrow it by date range and by status if you want.
- Click Download.
Saved column sets
A column set is a named list of columns, per scope. Save one and the same export comes out the same way next month, column order preserved.
GET /kahunacart/export-column-sets
POST /kahunacart/export-column-sets
PATCH /kahunacart/export-column-sets/{id}
DELETE /kahunacart/export-column-sets/{id}
The MCP tools are list_export_column_sets, create_export_column_set, update_export_column_set and delete_export_column_set.
GET /kahunacart/export-column-sets also serves the whole column vocabulary for both scopes, and the defaults you get having saved nothing. A column the vocabulary does not publish is refused by name when you save the set, not dropped when the file is written.
What comes out
POST /kahunacart/orders/bulk/export takes a body — a column list, a date range, a status filter and possibly two hundred ids — and writes nothing. format: csv hands back the file's text in body; format: json hands back the rows in data.
Conventions, all shared with the reports CSV:
- Refunded orders are included. The default population is every order that is not a cart, and
payment_statusis a column you can filter on yourself. - Money is decimal strings in each order's own currency, never minor units, because that is what spreadsheets and accounting imports expect. Every row carries
currency. The one exception issettlement_amount, which is insettlement_currency. On a store that sells in more than one currency, every export carriesfx_to_baseand the three settlement columns, what the payment provider paid out (see Currencies). A store that sells in one currency leaves all four out, even when a request names them. - Dates are ISO 8601 UTC.
- CSV is RFC 4180: header row, CRLF line endings, fields quoted when they contain a comma, a quote, CR or LF, embedded quotes doubled.
orders.export.max_rows, 5000 by default, caps a run. Past it the export is refused with a message suggesting a narrower range, never truncated.
The same export runs from the command line. See CLI → export.
Refund requests
The refund half of this chapter is about you deciding to send money back. This is about the customer asking — which until now they could only do by finding an address to write to, which meant you heard about it in an inbox rather than on the order.
A request is a question, not a refund. Nothing moves until you say so, and approving one runs exactly the refund described above.
What the customer sees
On their receipt — the signed link, so a guest gets it too — and on every row of their order history if they have an account, there is one spot that says one of four things:
| Request a refund | A reason picked off a fixed list, an optional sentence, a button. Offered while the order is paid, inside the window, not already refunded, and has no request open on it already. |
| You asked for a refund on 12 September | A request is open. The form is not offered again. |
| The outcome, in a sentence | It was answered. A decline shows what you told them underneath. |
| Get in touch about this order | The window has closed, or the order is already refunded — and you have somewhere for them to write. |
Nothing at all is drawn on an order there is nothing to refund, or on a store that takes no requests and names no contact route. A heading over an empty box is worse than no heading.
The six reasons are fixed, and that is the point: "why are people asking for their money back" is a question you ask of a hundred requests at once, and a hundred sentences do not answer it.
| Code | What the customer picks |
|---|---|
changed_mind |
I changed my mind |
not_working |
It arrived damaged or does not work |
not_as_described |
It is not what was described |
bought_by_mistake |
I bought it by mistake |
duplicate |
I was charged twice |
other |
Something else |
The free-text box is still there beside the code, because the code says what kind of problem it is and the sentence says what happened. It is trimmed to 2000 characters and stored as plain text — it is never rendered as markup anywhere.
The window
orders.refund_window_days — 14 by default — counted from when the order was placed rather than from when the money arrived. That is the date the customer has in their head and the date on their receipt, and a store that took three days to bank an offline payment should not be shortening its own window by three days without telling anybody.
Setting it to 0 switches the customer-facing request off entirely. A store that wants an unlimited window sets a large number rather than zero.
orders.contact_url is where a customer is sent when they cannot ask — a page address, or a mailto:. Leave it empty and no Contact us link is drawn at all. Nothing is guessed from your email settings: the Bcc on the confirmation and the Email plugin's From are addresses the store uses for its own machinery, and a shop sending from no-reply@ would have been handing customers a link to a mailbox nobody opens.
One open request per order
Pressing the button twice is not two questions. The second press is answered with You have already asked for a refund on this order, and we are looking at it — a sentence, not an error page. The same is true of a form posted a month after the page it was drawn on: every rule is re-checked at the moment the request arrives, so a stale page cannot open a request the store would not take today.
What you get
- An email to the customer — We have your refund request — quoting back the reason and their own words, with no date and no promise attached to it. It is an ordinary order email: it has a row on the Notifications screen, it appears on the Send email… menu, and you can switch it off.
- A message to you, on whichever channels your
owner.refund_requestedrow ticks, carrying the order number, the amount, the reason and what they wrote. Never deduped — two requests are two customers — and sent at urgent priority, because somebody is waiting. - A dashboard alert,
refund_requests, counting the ones nobody has answered, with a link straight to them. See Dashboard alerts. - A filter on the Orders list — Refund requested, above the status chips. It is the one filter on that screen the server answers rather than the browser, because five people waiting are easily on five different pages.
onKahunaCartRefundRequestedfires for add-ons, carrying the order, the request id, the reason code and the message. See the events table.
Answering one
The order screen grows a Refund requested panel with what they asked and two buttons.
Approve and refund… opens the refund form you already know, with the whole remaining balance ready to go. Partial approval is a real answer — keeping the postage is a normal outcome — so nothing forces the full amount. The request is marked agreed only once the refund itself answers, so a row saying agreed is never a row over a refund the provider refused.
Decline… asks for a sentence and sends it. It is required, by the endpoint as well as by the form, because it is what the customer is sent: a decision they never hear about is the thing this feature exists to replace. It goes out as the ordinary note-to-the-customer email and is saved on the order as a note to them, so it lands in the same thread as every other message you have sent them.
Either way the order's history records it — refund_request.opened when they asked, refund_request.approved or refund_request.declined when you answered — and all three are things the customer may read on their own receipt, because all three are their own side of a conversation they are part of.
The endpoints
GET /kahunacart/orders/{id}/refund-request # kahunacart.orders.view
POST /kahunacart/orders/{id}/refund-request/approve # kahunacart.orders.refund
POST /kahunacart/orders/{id}/refund-request/decline # kahunacart.orders.manage
The MCP tools are get_refund_request, approve_refund_request and decline_refund_request. Approving takes the same body refund_order takes, so it can approve less than was asked for. Declining requires note.
Declining takes kahunacart.orders.manage rather than kahunacart.orders.refund on purpose: saying no moves no money, and somebody who may answer customers is not necessarily somebody who may issue refunds.
The storefront's own door is POST {base}/order/{hash}/refund-request, behind the same signed-link check that opens the receipt plus the storefront nonce. It is POST-only — a GET answers 405 — because the form lives on the receipt and in the account, and a second URL drawing a copy of the receipt would be a page nobody asked for.
Returns
A request can also be a return: the customer picks the items and how many of each, and sends them back before any money moves. It is the same request, in the same spot on the receipt and the same panel on the order screen, so there is one thing for a customer to ask and one place for you to answer it. See Returns below.
Returns
A return is a refund request with items in it. The customer picks what they want to send back and how many of each, you tell them how to send it, you check the parcel in when it arrives, and then you refund it. Nothing refunds by itself: receiving a return opens the refund form already filled in, and you press the button.
No exchanges in this version. A customer who wants a different size sends the first one back and places a new order.
Which orders can be returned
Returns are on when Returns Accepted is on in the store's return policy (policies.returns.accepted, under Settings → Agentic Commerce → Returns) and the window is more than 0 days. An item can go back when:
- it is a physical item, something that needed a parcel. Downloads, services and bookings cannot be sent back;
- its product is not marked final sale;
- the order is paid and not already refunded in full;
- there is something left of it once earlier refunds and returns are taken off;
- the window is still open:
policies.returns.window_days, counted from when the parcel was delivered, else from when it shipped, else from when the order was placed; - and, when
policies.returns.countriesnames countries, the order shipped to one of them. Empty takes returns from anywhere.
When an order has items that can go back, the customer's receipt and their account order history offer Return items instead of Request a refund: each returnable item with a quantity picker, the six reasons, a message, and how to send it back when you let them choose. Items that cannot go back are listed with why (Final sale, Download, Not out yet). A pre-order whose release date has not come cannot go back: the record is not in a box yet, so the line is listed as Not out yet and leaves the returnable ones. It can be returned once the product is out, like any other line. An order with nothing returnable still offers the refund request, under orders.refund_window_days exactly as before.
What the customer sees
The return lives in the same spot on the receipt and on each row of the account order history as the refund request, and says one thing at a time:
- Return items: a quantity for each item that can go back (from 0 up to what is left of it), the items that cannot go back and why, the six reasons, a message, By mail or In store when you let them choose, the last day they can ask, and the fee when you charge one (or that the postage is theirs to pay).
- Requested: "Return R1042-1 requested on 12 October.", the items and how many, and that you will email them. While nobody has answered it yet the customer can press Withdraw this return; it becomes
cancelled, the order's history records it as the customer's doing, andonKahunaCartReturnCancelledand thereturn.cancelledwebhook carryby: customer. Once you have approved it, changing it is a conversation with you. - Approved: the return number in large type to write on the parcel, the items, your note, Print your return label when there is one, the tracking number, the address and your instructions. A return brought back in store shows no address or label, just "Bring the items to the store and give us the return number."
- Received: "We received your return R1042-1 on 20 October.", what arrived (and how many were damaged), and that the refund follows.
- Refunded, declined or cancelled: one sentence, with your note under a decline or a cancellation.
A form that is refused (more of an item than is left, nothing picked, the window closed in the meantime) comes back to the same spot with the reason in a sentence and everything the customer typed still in it; a reload shows the form fresh. The form is a plain post and needs no JavaScript. The markup is templates/partials/kahunacart-return-request.html.twig, included by partials/kahunacart-refund-request.html.twig for a return only, so a theme that overrides the receipt or the account history keeps returns with the one include it already has. Its classes are kahunacart-return-*, and every string is a PLUGIN_KAHUNACART.RETURN_REQUEST_* or RETURN_STATE_* language key.
Each return has a number
Every return gets a number the customer writes on the parcel and you read out on the phone: R plus the order number plus the return's place on that order, so the first return on order 1042 is R1042-1 and a second one later is R1042-2. A second return is allowed once the first is finished, for whatever is still returnable.
How it goes
| Status | What it means | What you can do |
|---|---|---|
open |
The customer asked. | Approve or Decline |
approved |
You said yes; the goods are on their way back. | Mark received, Cancel, attach a label |
received |
The parcel arrived and the sound items are back in stock. | Refund, Mark refunded |
refunded |
The money went back. Done. | |
declined |
You said no, and the customer was told why. | |
cancelled |
You stopped waiting for the parcel, or the customer withdrew it. |
Approve authorises the return. It takes the method (when you let customers choose), an optional note to the customer, and a label. The customer gets the return-approved email with the return number, the items, where to send them or that they bring them to the store, your instructions, the label, and the fee if there is one. Their order page says the same while the return is approved. Approving moves nothing, and records a return_request change on the order's change history with the quantities you authorised.
Decline needs a sentence, exactly like declining a refund request, and sends it in the return-declined email.
Mark received asks, for each item, how many arrived and how many of those are damaged. It starts at what you approved with nothing damaged. The items that arrived sound go back in stock, once; damaged ones never do. Pressing it twice does nothing the second time. The customer gets the return-received email.
Refund opens the refund form filled in with the items that arrived at what the customer paid for them, damaged ones included (whether to refund a damaged item is your call, and you can lower it), with Restock returned items off because receiving already did that, no postage, and the return fee taken off when you charge one. When that refund is confirmed the return closes as refunded and links to it. A refund to store credit closes it the same way. If you settled it some other way, Mark refunded closes it with no money moving, so a return never sits open for ever.
Cancel calls off an open or approved return. A return whose parcel already arrived is closed by refunding it instead. The customer can withdraw their own return from the order page while it is still open.
Your return policy, as the return form reads it
policies.returns in kahunacart.yaml was written for structured data and feeds; the return form now follows it too.
| Key | What it does for returns |
|---|---|
accepted |
Off, and nobody can start a return. |
window_days |
How long they have, counted from delivery. |
countries |
Only orders shipped to these countries can be returned. Empty means anywhere. |
method |
by_mail, in_store, or by_mail_or_in_store to let the customer pick. An in-store return gets no address and no label. |
fees |
free; customer_pays, and the approval email says the postage is theirs; or fixed, and fee comes off the refund. |
fee |
The fixed fee, in the store's currency or as a map per currency, read the way every other configured amount is on a store that sells in more than one. |
instructions |
How to pack and send it, in Markdown, printed in the approval email and on the order page. |
address |
Where returns go, one line per line. Empty uses invoice.store_address. |
Return labels
When you approve a return, or any time before it arrives, you can give it a label: upload a PDF (5 MB at most), give a URL, or both, with a tracking number and a carrier. The customer opens it from the approval email and from their order page. An uploaded PDF is kept in user/data/kahunacart/return-labels/, under a name made from the return's id, and is only ever served to the order's customer (by the signed receipt link or their account) at {base}/order/{hash}/return-label, and to you at GET /kahunacart/orders/{id}/refund-request/label.pdf. On the order screen, an approved return's Label… button adds or replaces the label and, with its tick on, emails it to the customer again.
KahunaCart does not buy labels itself. It asks: onKahunaCartReturnLabelProviders lets a shipping add-on offer to, and when one does, the approve dialog shows Buy return label, which fires onKahunaCartBuyReturnLabel for that add-on with the return, the order, the parcel's items and the return address. The add-on answers with label_pdf (the bytes) or label_url, a tracking_number and a carrier, or an error sentence, and the label is stored exactly like an uploaded one.
public function onKahunaCartReturnLabelProviders(Event $event): void
{
$event['providers']->add('myship', 'MyShip');
}
public function onKahunaCartBuyReturnLabel(Event $event): void
{
if ($event['provider'] !== 'myship') {
return;
}
$label = $this->client->returnLabel($event['address'], $event['lines']);
$event['label_url'] = $label->url;
$event['tracking_number'] = $label->tracking;
$event['carrier'] = 'MyShip';
}
What you get
- The Orders list filter for refund requests now has three queues, shown as the Returns & refunds chips above the filter row: Open (
open, asked and not answered, refunds and returns alike), Awaiting goods (awaiting, approved returns whose goods have not arrived) and Received (received, returns that arrived and are waiting for a refund). A chip only shows when something is in it.#/orders?refund_request=awaitingis a list of parcels you are waiting for; nothing expires on its own. - The dashboard alert for refund requests also counts received returns waiting for their refund, and when there are some it carries a second link, Refund received returns, straight to the Received queue.
- A message to you on the same
owner.refund_requestedrow a refund request uses, naming the return number and the items. - Four emails to the customer:
return-requested,return-approved,return-declinedandreturn-received, each with its own row on the Notifications screen and on the Send email… menu. The refund itself sends the ordinary refund email. - History entries for every step —
return.requested,return.approved,return.declined,return.received,return.refunded,return.cancelled— which the customer can read on their receipt, andreturn.label_attached, which they cannot. - Events for add-ons:
onKahunaCartReturnRequested,onKahunaCartReturnApproved,onKahunaCartReturnReceived,onKahunaCartReturnCancelledandonKahunaCartReturnRefunded, each carrying the order, the request, itsreturn_numberand itslines. See the events table. - Webhook topics
return.requested,return.approved,return.received,return.cancelledandreturn.refunded, orreturn.*for all five. See Outbound webhooks.
The endpoints
GET /kahunacart/returns # kahunacart.orders.view
GET /kahunacart/orders/{id}/refund-request # kahunacart.orders.view
POST /kahunacart/orders/{id}/refund-request/approve # kahunacart.orders.refund
POST /kahunacart/orders/{id}/refund-request/decline # kahunacart.orders.manage
POST /kahunacart/orders/{id}/refund-request/receive # kahunacart.orders.refund
POST /kahunacart/orders/{id}/refund-request/cancel # kahunacart.orders.refund
POST /kahunacart/orders/{id}/refund-request/mark-refunded # kahunacart.orders.refund
POST /kahunacart/orders/{id}/refund-request/label # kahunacart.orders.refund
POST /kahunacart/orders/{id}/refund-request/label/buy # kahunacart.orders.refund
GET /kahunacart/orders/{id}/refund-request/label.pdf # kahunacart.orders.view
The MCP tools are list_returns, receive_return, cancel_return, mark_return_refunded, attach_return_label and buy_return_label, beside get_refund_request, approve_refund_request and decline_refund_request, which handle a return when the open request is one. Every step answers 404 for an order that is not there, 409 when the return is not in the state the step needs (receiving one that was never approved, approving one twice), and 422 for what was sent (more items than were approved, more damaged than arrived, a file that is not a PDF).
GET …/refund-request gives each request its kind and lines, plus active (the request still waiting on somebody), can_return, returnable and not_returnable, and for a received return suggested_refund: the items, restock: false, shipping_minor and fee_minor to refund it with, the amount_minor they come to, the return_id, and actionable. When actionable is true, send items (each as item_id and quantity), restock, shipping_minor, amount_minor and return_id to POST …/refund, always with the amount. When it is false nothing is left to refund (nothing arrived, or the fee takes it all), so close the return with Mark refunded. A refund that names return_id and no lines, no postage and no amount is refused with a 422 rather than refunding the whole order; a return_id that is not a received return on that order answers 404 or 409.
The storefront's door is the refund request's: POST {base}/order/{hash}/refund-request with lines[<item_id>]=<quantity>, reason, message and, when the customer may choose, method. A post with lines opens a return and one without opens a refund request, except that the return form also sends kind=return, so a press with nothing picked is told to choose an item rather than becoming a refund request for the whole order. action=withdraw_return with request_id withdraws the customer's open return; it only acts on the return the page was drawn for, and only while it is open. Every rule is checked again when it arrives, and every refusal is a sentence on the page rather than an error. Both answer with a redirect to #kahunacart-refund-request-<order id> on the page they came from.
Quotes
A quote is an order you have offered and the customer has not yet accepted. Over a manual order with a pay link, it adds an explicit state, an enforced deadline, and a way for the customer to say no.
Send a quote
- Enter the order by hand.
- On the order detail, click Send as quote.
- Set how long it is good for, or take the default from
orders.quote_expiry_days, which is 30 days. - Tick Email it to send the customer the quote message with a link to review it.
The five states
| State | Means |
|---|---|
draft |
Written, not sent. Nobody outside the store has seen it. |
sent |
Offered, and payable until the deadline. |
accepted |
The customer said yes. Still unpaid, because accepting is not paying. |
declined |
The customer said no. Not payable again without a new quote. |
expired |
The deadline passed with no answer. |
These live in their own column, not on the order's status axis. To stock, the ledger, the sidebar badge and every report, sent and accepted are both "a placed order awaiting payment".
There is also a quote status on the lifecycle axis, for display and reporting. It arrives through the same contribution event an add-on would use, so you cannot edit or delete the row. It is flagged new and marked as not reducing stock, so an offer nobody has accepted holds no inventory, and a new status is never offered a bulk action.
The deadline
The quote's deadline is mirrored onto the pay link, so there is one enforcement point. The pay page refuses an order whose pay-link deadline has passed, with a translated message, however the customer arrives.
A pay link's expiry moves every time you reissue one; a quote's does not. Reissuing a pay link, or sending the quote email again, does not extend the quote.
A quote whose deadline has passed reads as expired immediately. The worker settles it in the database on its next tick.
Accept and decline
The customer gets two buttons on their own order page, behind the same signed link that opened it. Accepting records the answer and leaves the order payable. Declining closes the offer, and the pay page then says the quote was declined rather than expired.
You can record either answer from the order detail, for an answer that arrived by phone or email. Same guards, same history entry, different actor.
When a customer answers, you hear about it: Quote answered goes to the channels ticked on that row under Notifications → Store owner, with the order and the total. An answer you record yourself sends nothing, because you were there. See the messages that come to you.
POST /kahunacart/orders/{id}/quote # kahunacart.orders.manage
POST /kahunacart/orders/{id}/quote/accept # kahunacart.orders.manage
POST /kahunacart/orders/{id}/quote/decline # kahunacart.orders.manage
The MCP tools are quote_order, accept_quote and decline_quote.
Four history entries record the conversation — quote.sent, quote.accepted, quote.declined, quote.expired — and all four are customer-safe, so the customer sees the same timeline you do.
The Store Sales dashboard widget
KahunaCart registers a Store Sales widget for the site-wide Grav admin dashboard: a seven-day revenue mini-chart plus headline numbers, from the same summary endpoint the plugin's own Dashboard reads. It is gated on kahunacart.reports, and its colours come from the admin's CSS custom properties.
Currency and decimal places
The admin never assumes two decimal places. It reads currency_exponent from the store config endpoint — 2 for USD, 0 for JPY, 3 for KWD — and formats and validates every money field against it. Provider plugins writing their own admin sections get the same exponent in their component context. An amount in another currency (an order placed in a currency the store sells in besides its base) is formatted with that currency's own decimal places, taken from currencies in the same answer.
Reference: the store config endpoint
GET /kahunacart/config is the store-level read the admin does first. It returns currency, currency_exponent, the storefront route, store_url (the link to the storefront's front page, with the folder and language on it, which the admin's storefront links use), the site's languages and default_language, the plugin version, job-queue health under jobs, and providers keyed by slug:
Once currencies.enabled lists a second currency the answer also carries currencies: base and enabled, each as { "code": "EUR", "exponent": 2 }, so a client can format and validate an amount in any of them. A single-currency store answers exactly the keys above and no currencies.
"providers": {
"offline": { "label": "Bank Transfer", "capabilities": [] },
"stripe": { "label": "Card", "capabilities": ["refunds", "partial_refunds", "hosted_checkout"] }
}
A provider absent from the map is not registered. See the last row of Which refund controls the admin shows.
Reference: admin API endpoints
GET /kahunacart/config # kahunacart.orders.view
GET /kahunacart/badge # kahunacart.orders.view
GET /kahunacart/orders # kahunacart.orders.view
POST /kahunacart/orders # kahunacart.orders.manage
GET /kahunacart/orders/{id} # kahunacart.orders.view
POST /kahunacart/orders/{id}/refund # kahunacart.orders.refund
POST /kahunacart/orders/{id}/mark-paid # kahunacart.orders.manage
POST /kahunacart/orders/{id}/fulfill # kahunacart.orders.manage
POST /kahunacart/orders/{id}/cancel # kahunacart.orders.manage
GET /kahunacart/orders/{id}/downloads # kahunacart.orders.view
POST /kahunacart/orders/{id}/downloads/{grantId}/revoke # kahunacart.orders.manage
GET /kahunacart/orders/{id}/credit-notes # kahunacart.orders.view
GET /kahunacart/orders/{id}/credit-notes/{cnid}.pdf # kahunacart.orders.view
POST /kahunacart/orders/bulk/status # kahunacart.orders.manage
GET /kahunacart/orders/bulk/{jobId} # kahunacart.orders.view
POST /kahunacart/orders/bulk/export # kahunacart.orders.view
POST /kahunacart/orders/bulk/pdf # kahunacart.orders.view
GET /kahunacart/export-column-sets # kahunacart.orders.view
POST /kahunacart/export-column-sets # kahunacart.orders.manage
PATCH /kahunacart/export-column-sets/{id} # kahunacart.orders.manage
DEL /kahunacart/export-column-sets/{id} # kahunacart.orders.manage
POST /kahunacart/orders/{id}/quote # kahunacart.orders.manage
POST /kahunacart/orders/{id}/quote/accept # kahunacart.orders.manage
POST /kahunacart/orders/{id}/quote/decline # kahunacart.orders.manage
GET /kahunacart/credit/accounts # kahunacart.orders.view
GET /kahunacart/credit/accounts/{id} # kahunacart.orders.view
GET /kahunacart/credit/accounts/{id}/transactions # kahunacart.orders.view
POST /kahunacart/credit/accounts/{id}/adjust # kahunacart.orders.refund
POST /kahunacart/credit/refunds # kahunacart.orders.refund
GET /kahunacart/gift-cards # kahunacart.orders.view
POST /kahunacart/gift-cards # kahunacart.orders.refund
POST /kahunacart/gift-cards/redeem # kahunacart.orders.refund
POST /kahunacart/gift-cards/{id}/void # kahunacart.orders.refund
GET /kahunacart/payment-methods # kahunacart.settings
POST /kahunacart/payment-methods # kahunacart.settings
PATCH /kahunacart/payment-methods/{id} # kahunacart.settings
DEL /kahunacart/payment-methods/{id} # kahunacart.settings
POST /kahunacart/payment-methods/reorder # kahunacart.settings
GET /kahunacart/customers # kahunacart.customers.view
GET /kahunacart/customers/{id} # kahunacart.customers.view
POST /kahunacart/customers/{id}/invite # kahunacart.customers.manage
POST /kahunacart/customers/{id}/clear-2fa # kahunacart.customers.manage
POST /kahunacart/customers/{id}/state # kahunacart.customers.manage
GET /kahunacart/reports/summary # kahunacart.reports
GET /kahunacart/reports/export # kahunacart.reports
GET /kahunacart/reports/low-stock # kahunacart.reports
GET /kahunacart/admin/sections # kahunacart.orders.view
The shipment, packing-slip, history, note, pay-link, resend, order-status, order-change, feature, customer-group, address-book, country, wishlist, review and retention routes are in the REST API reference.
POST /kahunacart/orders takes {email, name?, note?, items: [{variant_id, qty}]}, where a line for a grouped product or a kit with a part the shopper picks also takes parts, an object of kit item id to variant id such as {"812": 311} (a missing pick refuses the order with Pick which "Easel" you want.), plus the optional billing, shipping, shipping_method_id, save_addresses, tax_exempt and vat_id described under Enter an order by hand, and answers 201 with the created order and its line items. An unpublished or out-of-stock variant is a 422 carrying the reason.
GET /kahunacart/orders/{id} carries history, notes, emails, timeline and refund_lines alongside the order, its items, adjustments and transactions, plus refunded_minor, pending_refund_minor and customer_tax: exempt, vat_id, and note, the reverse-charge line an exempt order prints.
POST /kahunacart/orders/{id}/refund answers 200 with a status of confirmed or pending and both refund totals. A refused refund is a 422.
POST .../refund, .../mark-paid, .../fulfill and .../cancel all take notify in the body, defaulting to true, and answer with notified alongside their usual payload.
Every admin endpoint runs autoMigrate() first, so an admin request on a store with database.auto_migrate set to auto applies pending schema changes.
Order currency filters and sorting by Total still work after extra currencies are disabled. The list groups amounts by currency before sorting within each group. Foreign-currency history keeps those controls available.
Related
- Order statuses
- Order changes
- Customers, wishlists, reviews and retention
- Store credit, gift cards and credit notes
- Payment methods
- Reports and the rollup
- Analytics
- Dashboard alerts
- Importing
- Emails
- CLI
- Hardening
- Shipping
- Digital products
- Customer accounts
- Products and catalog
- The catalog index
- Coupons
- Admin sections
- REST API
- Troubleshooting
- Agentic commerce