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 written 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 that talks to the plugin's own API endpoints. The nav collapses to icons, and the choice is remembered.
| Section | What it does |
|---|---|
| Dashboard | Headline sales numbers and a revenue chart for a date range. |
| Orders | The order list, with an expandable detail panel per order. Plus New order. |
| Customers | Everyone who has bought, with order counts, lifetime totals and sign-in accounts. |
| Products | Product list and editor: variants, images, categories, downloadable files. |
| Categories | The category tree. |
| Tags | The flat half of the taxonomy. |
| Coupons | Discount codes and their usage. |
| 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. |
Provider plugins can add sections after these ten, fetched on load and mounted as web components. See Admin sections.
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 when the oldest waiting job is older than jobs.stale_after_minutes, default 15. Set it to 0 to switch the warning off.
Permissions
| Permission | Grants |
|---|---|
kahunacart.orders.view |
The admin screen at all, the order list and detail, download grants, store config. |
kahunacart.orders.manage |
Create an order by hand, mark paid, mark as shipped, cancel an order, revoke download grants. |
kahunacart.orders.refund |
Issue refunds. |
kahunacart.customers.view |
The Customers list, its search, and one customer's detail with their orders. |
kahunacart.customers.manage |
A customer's sign-in account: set-password link, clearing a lost two-factor secret, disabling or enabling. |
kahunacart.products.manage |
Products and variants. |
kahunacart.settings |
Categories, coupons, tax, shipping, product files. |
kahunacart.reports |
Reports, the CSV export, and the dashboard widget. |
Order statuses
An order carries three independent statuses. The order list shows all three as columns, 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.
Order status
status is the cart lifecycle.
| Value | Meaning |
|---|---|
cart |
Not an order yet. Never appears in the admin list. |
completed |
Placed. Stock deducted, order number assigned, totals frozen. |
canceled |
Called off before the money arrived. Set by Cancel order. |
archived |
Defined in the schema. |
Note
archived is not reachable. Nothing in the plugin sets it.
Payment status
payment_status is the money.
| Value | Meaning |
|---|---|
pending |
Placed but not paid. Offline methods, and anything awaiting a webhook. |
paid |
Funds captured. |
partially_refunded |
Some money returned. |
refunded |
Fully returned. |
failed |
The payment attempt failed. |
Fulfillment status
fulfillment_status is the goods.
| Value | Set when |
|---|---|
na |
The order was digital-only. Set at completion. |
unfulfilled |
The order contained something physical. Set at completion. |
partial |
Defined in the schema. |
fulfilled |
The goods went out. Set by Mark as shipped. |
Note
partial is not reachable. Fulfillment is all-or-nothing: one click marks the whole order shipped. There is no per-line fulfillment record, so track a part-shipped order elsewhere.
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.
- 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 webhook redelivery repeats neither.
Find an order
- In the admin, open Orders.
- Filter with the Status, Payment and Fulfillment chips above the list.
- Page through the list with the pager below it.
- Click a row to expand its detail panel.
The chips filter the orders loaded for the current page, so clear a filter before paging. To find every order for one person, search by email in Customers.
Read the order detail
The detail panel shows the order row, line items, adjustments, the transaction ledger, the timeline, and download grants.
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. It records refunds issued in a provider's own dashboard too.
A refund row that has not settled reads awaiting confirmation, and the detail shows an Awaiting confirmation chip beside the refunded total. That total stays confirmed-only.
The order timeline
The timeline is computed from rows that already exist, and rides on GET /kahunacart/orders/{id} as a timeline array.
| 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, so two actions have none: marking as shipped writes fulfillment_status only, and canceling writes status only. Both remain visible as the order's current status.
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.
- Search the catalog and add products with their quantities.
- Add a note for your own records if you want one.
- Click Create.
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, and 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.
The form refuses an unpublished product and an out-of-stock variant. Adjust the quantity, or turn backorders on for that variant. Prices always come from the catalog; there is no arbitrary-price field.
The note is stored in data_json under admin_note, alongside a manual flag, and is never shown to the customer.
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 reading Order payment is not pending.
Mark an order as shipped
Requires kahunacart.orders.manage.
- Open the order in Orders.
- Click Mark as shipped.
The fulfillment status moves from unfulfilled to fulfilled and the shipped email is queued. Nothing is given back, so no confirmation is asked. Four cases are refused with a 409:
| Condition | Message |
|---|---|
The order is not completed |
Only a completed order can be fulfilled |
Fulfillment status is na |
This order is digital-only — there is nothing to ship |
Fulfillment status is fulfilled |
Order is already fulfilled |
| Fulfillment status is anything else | Only an unfulfilled order can be marked as shipped |
Payment status is not consulted, so you can ship before the money arrives.
Note
There is no un-ship. Fulfillment moves one way only.
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 and out of every live-order action.
Cancel is offered only for a completed order with payment pending or failed. Asking on a paid order returns a 409 reading 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.
- Open the order in Orders.
- Click Refund…, or Refund {amount} on a provider that only does full refunds.
- Enter an amount, or leave it blank to refund the whole remaining balance.
- Confirm.
The plugin checks four things before calling the provider:
| Check | Message when it fails |
|---|---|
The order is completed and 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. |
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.
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 amount field. |
Declares refunds only |
A single Refund {amount} button for the whole remaining balance. No amount field. |
| 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.
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 a rejection 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. A reversal never touches money.
Important
A 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. Adjust stock by hand, and revoke individual grants from the order detail.
A full refund also blocks downloads on its own, because the download check reads the order's payment status.
Revoke a download grant
Revoke a grant after a chargeback, or when a link turns up somewhere it should not have. 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.
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 filters by email. 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 order table below lists everything, refunded and canceled included. A customer with no qualifying orders still 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.
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, and 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 UTC day, continuous. Days with no orders come back as zeros. Capped at 3660 days. |
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. |
The response also echoes the resolved from and to dates.
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 store currency, so a mixed-currency store should filter by currency before reading these 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, date, email, status, payment_status, provider, currency, items_total, adjustments_total, total. Dates are ISO 8601 UTC, for example 2026-01-15T14:32:00Z.
The export differs from the summary twice over. It includes refunded orders that the summary drops, with a payment_status column to filter on, and money is written as decimal strings in each order's own currency, with currency naming the unit per row.
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.
The widget is gated on kahunacart.reports, so an admin without that permission sees nothing. 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.
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, the site's languages and default_language, the plugin version, job-queue health under jobs, and providers keyed by slug:
"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/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
POST /kahunacart/orders takes {email, name?, note?, items: [{variant_id, qty}]} 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 timeline alongside the order, its items, adjustments and transactions, plus refunded_minor and pending_refund_minor.
POST /kahunacart/orders/{id}/refund answers 200 with a status of confirmed or pending and both refund totals. A refused refund is a 422.
Every admin endpoint runs autoMigrate() first, so an admin request on a store with database.auto_migrate set to auto applies pending schema changes.