Developers
Everything the Point of Sale add-on registers, in one place. It's for a developer who builds on the till, reads its data from a script or listens to what it does. The tables marked as generated are written from the plugin's own source by the docs tooling, so they can't drift from the code.
KahunaCart owns the money. The add-on calls KahunaCart's services in process with the person serving, so every payment, refund and receipt goes through the same code, and fires the same events, as one made over KahunaCart's own REST routes.
Permissions
| Permission | Plugin | Label | Unlocks | Routes | Tools |
|---|---|---|---|---|---|
kahunacart.pos.use |
Point of sale | Sell at the till | — | 0 | 0 |
kahunacart.pos.refund |
Point of sale | Take returns and refunds at the till | — | 0 | 0 |
kahunacart.pos.manage |
Point of sale | Manage point of sale registers, staff, shifts and reports | Other | 20 | 20 |
manage includes use and refund. refund doesn't include use. Every account that signs in at the till also needs Grav's api.access.
The till checks the permissions of the person serving, which is the staff member who signed in with a PIN, never only the account the tablet is signed in as. A manager's override covers one request that the person serving may not do. See Staff and PINs.
REST routes
Every route is under {api}/kahunacart/pos, where {api} is the API plugin's base, /api/v1 by default. Answers are wrapped in data. Errors use the same layout as KahunaCart's own, with a detail sentence and a 4xx status. Money is in minor units, named *_minor. Times are Unix seconds.
The routes fall into two groups.
The manager's routes
These take kahunacart.pos.manage and each is an MCP tool. They run from a session, an API key or a token, like KahunaCart's own admin routes. They are documented one group to a page, with every parameter:
- Point of sale registers and devices: registers, their card readers and the browsers paired to them.
- Point of sale staff: PINs, unlocking and who may sell.
- Point of sale favourites: the buttons on the Sell screen and the product search that picks them.
- Point of sale shifts and reports: shifts, the counter sale behind an order, and the sales report.
The till's routes
These are what the app at /pos calls. They aren't MCP tools, because they need a browser at a counter: a device paired to a register and a person serving who typed their PIN. A model has neither. They only answer to a signed-in session. An API key or token reaches the manager's routes and nothing else.
Every till route goes through the same guards, in this order:
- The signed-in account needs
api.accessandkahunacart.pos.use(403). - A write carries the nonce
GET pos/mehanded out, inX-POS-Nonce(403). It is an HMAC of the session and the account, so another site can make a browser send the cookie but can't read the nonce. - Every route except
me,registers,devices/pairandstaff/*needsX-POS-Devicefor a live device paired to a register that is switched on (403withdevice: unpaired). - The person serving is read from the server, kept against the session and the device. It is never read from the request body. Locked or idle means
423withlocked: true. The person's own permissions decide (403). - An override is a
403withoverride: <kind>. The till asks a manager and sends the request again withoverride: {username, pin}. A wrong PIN answersattempts_leftorlocked_until. The approval is written on the order as a history entry of typepos.override. - A write that moves money or makes an order takes an
idempotency_key. The answer is stored under the key, scoped to the register and the thing it acts on. A repeat answers200withreplayed: true, and a request still running answers409. See Retries.
Who is at the till
| Route | Permission | What it does |
|---|---|---|
GET /kahunacart/pos/me |
use |
Who is signed in, who is serving, their permissions, the staff list for the lock screen, the nonce and its header names, the labels, the store (name, currency, formats, tax display, cash rounding, timezone), the pos.* settings, the device's register and its open shift, and bookings ({desk_url} when the Bookings add-on and its desk are installed and on, else null). |
GET /kahunacart/pos/registers |
use |
The registers this person may use, with each one's open-shift state. |
POST /kahunacart/pos/devices/pair |
manage or override |
Pairs this browser to register_id with a label. Answers the token once and revokes this browser's old token. |
POST /kahunacart/pos/staff/switch |
use |
Changes the person serving, with username and pin. A wrong PIN answers attempts_left, a lockout answers locked_until. |
POST /kahunacart/pos/staff/lock |
use |
Locks the till. |
POST /kahunacart/pos/staff/pin |
use |
The person serving changes their own PIN, with old_pin and pin. |
Shifts
| Route | Permission | What it does |
|---|---|---|
POST /kahunacart/pos/shifts |
use |
Opens a shift on the device's register with float_minor. 409 if one is open. |
GET /kahunacart/pos/shifts/current |
use |
The open shift and its live X report. 404 if none. |
POST /kahunacart/pos/shifts/current/cash |
use |
Cash in or out, with type (in or out), amount_minor and reason. A retry of one that failed after the movement was written answers that movement. |
POST /kahunacart/pos/shifts/current/close |
use |
Closes the shift with counted_minor, note and, when counted by denomination, counts as {minor: qty}, which has to add up to counted_minor. Answers the frozen Z report. 409 while a sale with items is open, and 409 naming the orders as "Order #…" in orders (each with owes, card_on_reader, in_progress, not_on_shift) while a sale rung up on the shift still owes money, a card is still on the reader for any order the shift took money for, a card payment or return is still going through, or a counter order completed on the register is not on the shift. A refund still waiting at a card provider does not hold the close: the Z lists it in refunds_waiting, and the drawer is not touched by it. A card payment or return in flight longer than any request can live (3 minutes) is retired by the close, and the request it belonged to can write nothing more for it. |
GET /kahunacart/pos/shifts/{id}/report |
use for the device's register, else manage |
The X or Z report as JSON, or as an 80 mm HTML page with ?layout=till. Money taken for orders not sold on the shift is in earlier {count, total_minor, orders [{order_id, number, amount_minor}]} and in the tender and cash figures, never in sales. Refunds given on the shift that are still waiting at their card provider are in refunds_waiting {count, total_minor, orders [{order_id, number, amount_minor}]} ("Card refunds still waiting"), never in refunds or the drawer; each one counts in the period report once its provider confirms it. A ledger row counts toward a shift only by the pos_shift_id the till stamped on it; the one exception is a payment with no stamp (a pay link paid online, a payment taken in the admin) on an order sold on the shift, which counts there if it came in before the shift closed. |
Finding things
| Route | Permission | What it does |
|---|---|---|
GET /kahunacart/pos/lookup |
use |
Looks up a scanned code: an exact GTIN, then an exact SKU, then an order number. Answers kind: variant, order or none. |
GET /kahunacart/pos/products |
use |
Searches published products with q and limit. Each variant has its title, options, price at this location, stock and image. |
GET /kahunacart/pos/favourites |
use |
The location's favourites, then the ones for every location. |
GET /kahunacart/pos/customers |
use |
Finds customers by q, at least two characters and ten at most. Rate limited per staff member. |
POST /kahunacart/pos/customers |
use |
Creates a customer from an email and an optional name (201). An email that is already a customer is a lookup (200): it counts against the search allowance (429 past it) and gives what a search gives. |
The sale
| Route | Permission | What it does |
|---|---|---|
POST /kahunacart/pos/sales |
use |
Opens a counter cart on the device's register. Needs an open shift. 201. |
GET /kahunacart/pos/sales |
use |
With parked=1, the sales parked on this register. |
GET /kahunacart/pos/sales/{cart} |
use |
The priced counter cart. |
DELETE /kahunacart/pos/sales/{cart} |
use |
Voids a sale that wasn't charged. |
POST /kahunacart/pos/sales/{cart}/lines |
use |
Adds a line, with variant_id and qty. |
PATCH /kahunacart/pos/sales/{cart}/lines/{item} |
use |
Sets a line's qty. |
DELETE /kahunacart/pos/sales/{cart}/lines/{item} |
use |
Removes a line. |
POST /kahunacart/pos/sales/{cart}/coupon |
use |
Applies a coupon code. |
DELETE /kahunacart/pos/sales/{cart}/coupon/{code} |
use |
Takes a coupon off. |
POST /kahunacart/pos/sales/{cart}/discount |
use or override |
A staff discount: item_id (left out for the whole sale), kind (percent in basis points, or amount), value, reason. Over discount_limit_percent for a non-manager needs override. |
DELETE /kahunacart/pos/sales/{cart}/discount |
use |
Takes the sale's discount off, or a line's with item_id. |
POST /kahunacart/pos/sales/{cart}/customer |
use |
Attaches customer_id, or null for a walk-in sale. |
POST /kahunacart/pos/sales/{cart}/park |
use |
Parks the sale. Any later write to it picks it up again. |
POST /kahunacart/pos/sales/{cart}/charge |
use |
Completes the cart into an order with the person serving and writes the sale against the shift in the same transaction, then answers the order with what is owed. 201, and 200 with replayed on a repeat. A completed order the till has no sale for is put on the shift open when it completed, as whoever completed it. |
Every write answers the priced cart: core's CounterCart::priced() with order.parked, each adjustment's source decoded (kind is staff_discount, coupon or the adjustment's own type), coupons and customer.
Paying, the receipt and returns
| Route | Permission | What it does |
|---|---|---|
GET /kahunacart/pos/orders |
use |
Searches orders with q, for the return screen. |
GET /kahunacart/pos/orders/{id} |
use |
The order with its lines, adjustments, tenders, outstanding_minor and returnable lines. |
GET /kahunacart/pos/orders/{id}/pay |
use |
An order the till did not sell that still owes money (a booking's balance, an order placed online), ready for Pay: what GET orders/{id} answers plus earlier (true when it was not sold on this register's open shift). 409 with a sentence when no shift is open, the order is cancelled, it has nothing left to pay, a card payment on it is still on a reader, it has a card hold, or its currency is not the shift's. A counter sale of another register is 404. |
POST /kahunacart/pos/orders/{id}/tenders |
use |
A tender in core's body: cash, store_credit, gift_card or pay_link. For a counter sale rung up on this register, or any order from another channel (a booking, the shop online). Needs the open shift and is stamped with it (pos_shift_id); an order not sold on the shift is written down in kahunacart_pos_payments. The idempotency_key reaches core scoped to the register (pos:<register>:<key>), so the same key on another register is another tender. Once a keyed tender has taken money, a retry with a different body is refused with 422. |
POST /kahunacart/pos/orders/{id}/card |
use |
Starts a payment on the register's reader with amount_minor, for the same orders as tenders, stamped with the shift the same way. The reader is asked without holding the register; a close waits while the start is going through. |
GET /kahunacart/pos/orders/{id}/card/{tid} |
use |
The reader payment's status. Makes no writes. |
POST /kahunacart/pos/orders/{id}/card/{tid}/refresh |
use |
Asks the platform and applies the answer. |
POST /kahunacart/pos/orders/{id}/card/{tid}/cancel |
use |
Cancels a pending reader payment. |
POST /kahunacart/pos/orders/{id}/card/{tid}/abandon |
use |
Gives up on a reader payment the platform can't answer for. Allowed only after it was asked again. |
GET /kahunacart/pos/orders/{id}/receipt |
use |
Core's till receipt as HTML with the shop's footer line, from ?layout=till. |
POST /kahunacart/pos/orders/{id}/receipt/email |
use |
Queues the receipt to an email. It doesn't attach a customer. 409 if the notification row is off. |
POST /kahunacart/pos/orders/{id}/return/preview |
use |
What core would refund for these lines and refund, per payment: refund_to, lines_minor, fee_minor, refund_minor, refunds and short_minor. |
POST /kahunacart/pos/orders/{id}/return |
refund or override |
Core's counter return, in process, taken by the person serving. lines, reason, note, refund {to, amount_minor?}. Each refund leg carries the register, the shift, the staff member and who approved it from the moment it is written, before any platform is asked, and the platform is asked without holding the register. A retry with the same key after a failure answers the return that request took (replayed: true), and only that one, never a second and never another key's. A request that stalls so long that a close retires it writes nothing more: if it had already received the goods, they stay received with no refund, the answer is 422 saying so ("The goods are back … Refund … from the order"), and the order's history gets a pos.refund_stopped entry. |
Retries
A double tap or a request sent again records one order, one payment, one return, one cash movement. The mechanics, for someone building on the till:
- The key claims the work.
(scope, idempotency_key)is claimed under a unique index before the work runs. A claim is never given back. Work that fails part way marks it interrupted, and so does a claim still running after three minutes, and the next retry takes it over. - A retry finds what the first try did. Every card start, return, cash movement and keyed tender is written down as an operation before the money moves, and the key is tied to it in the same transaction. The retry reconciles from that: it answers the return, the refunds or the movement with
replayed: true, writes what the first try missed, and only runs the work again when nothing happened. A return's retry never answers another key's return. - A tender key keeps its first body once money has moved. The same key with a different body is refused with
422, and the key reaches core aspos:<register>:<key>, so the same key on another register is another tender. - A request that outlived its shift writes nothing more. A close retires an operation still pending after three minutes. Every ledger row a card start or a return opens is written only while its operation is still pending under that request and the shift is open, so a stalled request can't land after the close. A return that already received its goods keeps them and answers
422saying the refund is owed from the order. - A write holds the register's lease for 30 seconds at most. Past that, or when another tablet took the lease over, it is refused with
409and writes nothing. No payment provider is asked while the lease is held.
MCP tools
Point of sale publishes 20 tools over 20 admin routes, behind 1 permission. An assistant calls each one by its name below with kahunacart_pos_ in front of it.
| Tool | Route | Permission | Arguments | What it does |
|---|---|---|---|---|
list_registers |
GET /kahunacart/pos/admin/registers |
kahunacart.pos.manage |
none | Every till register in the store: its name, its short ref (printed on orders and receipts), the pickup location it sells at, its card reader, how it offers receipts, whether it is switched on, the shift open on it now and the devices paired to it |
get_register |
GET /kahunacart/pos/admin/registers/{id} |
kahunacart.pos.manage |
id, required |
One register, as list_registers gives it |
create_register |
POST /kahunacart/pos/admin/registers |
kahunacart.pos.manage |
name, ref, location_id required; reader_provider, reader_id, receipt_mode, enabled optional |
Add a register |
update_register |
PATCH /kahunacart/pos/admin/registers/{id} |
kahunacart.pos.manage |
id required; name, ref, location_id, reader_provider, reader_id, receipt_mode, enabled optional |
Change a register |
delete_register |
DELETE /kahunacart/pos/admin/registers/{id} |
kahunacart.pos.manage |
id, required |
Delete a register that has never had a shift, and revoke the devices paired to it |
list_devices |
GET /kahunacart/pos/admin/devices |
kahunacart.pos.manage |
register_id, revoked |
The browsers paired as tills: their label, their register, who paired them and when, and when each was last used |
revoke_device |
DELETE /kahunacart/pos/admin/devices/{id} |
kahunacart.pos.manage |
id, required |
Stop a paired browser from working as a till, for a lost or replaced tablet |
list_staff |
GET /kahunacart/pos/admin/staff |
kahunacart.pos.manage |
none | Every account that may sell at the till or already has a PIN: whether it has a PIN, whether it is locked out after wrong PINs and until when, and what it may do at the till (use, refund, manage) |
set_staff_pin |
PUT /kahunacart/pos/admin/staff/{username}/pin |
kahunacart.pos.manage |
username, pin, all required |
Set or replace somebody's till PIN, 4 to 8 numbers |
clear_staff_pin |
DELETE /kahunacart/pos/admin/staff/{username}/pin |
kahunacart.pos.manage |
username, required |
Remove somebody's till PIN, so they cannot sign in at a till until they have a new one |
unlock_staff |
POST /kahunacart/pos/admin/staff/{username}/unlock |
kahunacart.pos.manage |
username, required |
Let somebody locked out after five wrong PINs try again now, rather than after the five minutes |
list_favourites |
GET /kahunacart/pos/admin/favourites |
kahunacart.pos.manage |
none | The product buttons on the till's Sell screen, per pickup location (location_id) or for every location (location_id null), in order, each with the variant it rings up |
add_favourite |
POST /kahunacart/pos/admin/favourites |
kahunacart.pos.manage |
variant_id required; location_id optional |
Add a variant as a button on the Sell screen, at the end of the list, for one pickup location or (without location_id) for every location |
reorder_favourites |
POST /kahunacart/pos/admin/favourites/order |
kahunacart.pos.manage |
ids, required |
Put favourites in a new order: their ids, first to last |
remove_favourite |
DELETE /kahunacart/pos/admin/favourites/{id} |
kahunacart.pos.manage |
id, required |
Take a button off the Sell screen |
find_till_products |
GET /kahunacart/pos/admin/products |
kahunacart.pos.manage |
q required; limit optional |
Find products to put on the till's favourites grid, the way the till's own search finds them (title, SKU or barcode) |
list_shifts |
GET /kahunacart/pos/admin/shifts |
kahunacart.pos.manage |
register_id, from, to, page, per_page |
Till shifts, newest first: register, who opened and closed each and when, the float, and for a closed shift the cash counted, expected and the difference |
get_shift |
GET /kahunacart/pos/admin/shifts/{id} |
kahunacart.pos.manage |
id, required |
One shift in full: its Z report as it closed (or the X report so far while it is open), with sales and tenders, refunds given at the till, discounts, tax, sales per staff member, voided and parked sales, the cash worked out (float, cash taken, cash refunds, cash in and out, expected, counted, difference), each cash movement with its reason, and every sale rung up on it |
get_counter_sale |
GET /kahunacart/pos/admin/orders/{id} |
kahunacart.pos.manage |
id, required |
What the till knows about one order: the register it was rung up on, the shift, who served and when |
sales_report |
GET /kahunacart/pos/reports |
kahunacart.pos.manage |
group, from, to |
Till sales over a period, grouped by register, staff member, location or tender: how many sales and what they came to, refunds given at a till, discounts and tax, with totals and the cash difference of every shift closed in the period |
Events
The add-on fires four events of its own, listed below with the events it listens to. KahunaCart's tender, counter-return and paid events still fire as they do for the payments the till makes.
onKahunaCartAdminSectionslistensContribute an admin screen (§8)
Payload sections
onKahunaCartDeclareFeatureCompatibilitylistensDeclare which core features your add-on understands
Payload features, codes
onKahunaCartPosCashMovementfiresCash was put in or taken out of a drawer
Payload movement, shift, register
onKahunaCartPosSaleChargedfiresA sale was charged into an order. Once per order, and a charge answered again from its first answer doesn't fire it
Payload order_id, order, shift_id, register, staff_user
onKahunaCartPosShiftClosedfiresA shift was closed and its Z report frozen
Payload shift, register, report (the Z report as frozen)
onKahunaCartPosShiftOpenedfiresA shift was opened on a register
Payload shift, register
onKahunaCartRegisterExportslistensRegister your add-on's data as an export set, usually a Transfer\TableExportSet naming your tables, so it is in a store bundle and comes back in a restore
Payload registry (a Transfer\ExportRegistry to call add() on)
onKahunaCartRegisterJobHandlerslistensRegister a background job type of your own
Payload registry
onKahunaCartRegisterMigrationslistensMigrations are being collected. Append your own migrations directory
Payload paths
onKahunaCartRegisterNotificationslistensRegister the emails your plugin sends so they appear on the Notifications screen with a switch and a template picker
Payload registry
onKahunaCartRegisterResetTableslistensDeclare your add-on's transactional tables so a pre-launch reset takes them too, and leave your configuration tables out of it
Payload registry (an Ops\ResetTableRegistry to call add() or addAll() on)
onKahunaCartRegisterZoneslistensDeclare a zone on a screen of your own so a fourth party can extend it, or mount a widget into a catalogued zone
Payload registry (a Extension\Zone\ZoneRegistry to call declare() and mount() on)
onKahunaCartWorkerTicklistensPut recurring work of your own on the store's own worker clock
Payload queue, now
Jobs
| Type | When | What it does |
|---|---|---|
pos.receipt_email |
Email receipt at the till | Sends the till receipt to the address typed, with order_id and to. A worker that runs it twice sends it once. |
pos.cleanup |
Booked an hour ahead on every worker tick, one at a time | Removes finished idempotency answers after 7 days (an interrupted or running claim stays until its key is retried), finished operations after 7 days once no claim names them, a session's acting row once it ended or sat idle a day (not while it counts out a lockout), and customer lookups after 30 minutes. Needs the job worker running. |
Notifications
| Code | Audience | When |
|---|---|---|
pos.cash_difference |
Store owner, high priority | A shift closes with a difference bigger than difference_alert_minor. Once per shift. Links to /pos/shifts/{id} in the admin. |
pos.receipt |
Customer, template pos-receipt |
The till emails a receipt on request. Never sent on an event, so its switch decides whether the Email receipt button sends anything. |
Tables
All are prefixed kahunacart_pos_.
| Table | What it holds |
|---|---|
kahunacart_pos_registers |
One row per register: name, ref, pickup location, the card reader's provider and id, receipt_mode and whether it is on. |
kahunacart_pos_staff |
One row per account given a PIN: the PIN hash, failed tries and when it is locked until. |
kahunacart_pos_shifts |
One row per shift, with the float, who opened and closed it, the count, expected cash, the difference and the frozen Z report. A unique index on a nullable open key keeps it to one open shift per register. |
kahunacart_pos_cash_movements |
Cash in and out against a shift, with the reason and who did it. |
kahunacart_pos_sales |
One row per counter sale, linking its order to the shift, the register and the person who rang it up. |
kahunacart_pos_payments |
One row per older order a shift took money for (a booking's balance, an order placed online, a counter sale from another day). The X and Z show these as payments on earlier orders, never as sales. |
kahunacart_pos_devices |
The paired browsers: the token's hash, label, who paired it and when, when it was last seen, when it was revoked, and its wrong-PIN count. |
kahunacart_pos_favourites |
The buttons on the Sell screen, per location or for every location, in order. |
kahunacart_pos_acting |
Who is serving, per signed-in session and device, and since when. |
kahunacart_pos_requests |
The answers stored under idempotency keys, with a status (0 running, -1 interrupted, otherwise the answer's) and the operation the key started. |
kahunacart_pos_operations |
A card payment, return, cash movement or keyed tender the till set going, written before the money moves: its kind, shift, register, order, staff member, approver, status (pending, done, failed) and what it did. |
kahunacart_pos_customer_access |
Which customers each person serving was shown by a search or added, so a sale can only take a customer they found. |
Migrations pos_0001_tables to pos_0005_operation_fences, run by KahunaCart's migrator, create them on SQLite, MySQL and PostgreSQL.
Export sets, reset and merge
The till's data travels with a store export bundle in two sets:
| Set | What it holds |
|---|---|
pos.registers |
The registers and the favourites. A merge matches a register on its ref and a favourite on its location and variant. |
pos.shifts |
The shifts, their cash movements, the sales that link orders to them and the payments they took on earlier orders. Orders and variants are rewritten through the id map. |
Paired devices, PINs, who is serving, the operations, the customer lookups and the idempotency answers never travel. A device token belongs to one browser on one site, a PIN hash is a credential, and the accounts belong to the site. After a restore, staff set their PINs again and each tablet is paired again.
A merge points every payment and refund it brought across at its shift's id in this store. A row whose shift stayed out loses its stamp and is marked, so no shift's report counts it, and the report says how many. A full restore keeps the shift ids. Both drop the operation each row carried. Restore the shifts with the orders: KahunaCart's orders set restored without pos.shifts keeps the other store's shift ids on its rows, because core gives a set no hook to run after the others.
bin/plugin kahunacart reset --orders empties the shifts, cash movements, sales, payments on earlier orders, acting rows, idempotency answers, operations and customer lookups with the orders. The registers, devices, PINs and favourites stay, because they are the shop's setup and a reset that unpaired every tablet the night before opening would leave nobody able to sell.