Skip to content

Powered by Grav CMS + Helios Theme

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:

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:

  1. The signed-in account needs api.access and kahunacart.pos.use (403).
  2. A write carries the nonce GET pos/me handed out, in X-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.
  3. Every route except me, registers, devices/pair and staff/* needs X-POS-Device for a live device paired to a register that is switched on (403 with device: unpaired).
  4. 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 423 with locked: true. The person's own permissions decide (403).
  5. An override is a 403 with override: <kind>. The till asks a manager and sends the request again with override: {username, pin}. A wrong PIN answers attempts_left or locked_until. The approval is written on the order as a history entry of type pos.override.
  6. 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 answers 200 with replayed: true, and a request still running answers 409. 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 as pos:<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 422 saying 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 409 and 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.

onKahunaCartAdminSectionslistens

Contribute an admin screen (§8)

Payload sections

onKahunaCartDeclareFeatureCompatibilitylistens

Declare which core features your add-on understands

Payload features, codes

onKahunaCartPosCashMovementfires

Cash was put in or taken out of a drawer

Payload movement, shift, register

onKahunaCartPosSaleChargedfires

A 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

onKahunaCartPosShiftClosedfires

A shift was closed and its Z report frozen

Payload shift, register, report (the Z report as frozen)

onKahunaCartPosShiftOpenedfires

A shift was opened on a register

Payload shift, register

onKahunaCartRegisterExportslistens

Register 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)

onKahunaCartRegisterJobHandlerslistens

Register a background job type of your own

Payload registry

onKahunaCartRegisterMigrationslistens

Migrations are being collected. Append your own migrations directory

Payload paths

onKahunaCartRegisterNotificationslistens

Register the emails your plugin sends so they appear on the Notifications screen with a switch and a template picker

Payload registry

onKahunaCartRegisterResetTableslistens

Declare 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)

onKahunaCartRegisterZoneslistens

Declare 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)

onKahunaCartWorkerTicklistens

Put 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.