API
REST/JSON under /api/v1. Laravel 13 + Sanctum 4.3.
Ground rules
- Money is integer cents in JSON:
{"total_cents": 1234}. Never a string, never a float, never pre-formatted. The_centssuffix is mandatory on every monetary field — it makes a unit error visible in code review instead of in a customer's total. - Quantities are strings:
{"qty": "0.500"}.numeric(12,3)does not survive a round-trip through IEEE-754, and JSnumberis IEEE-754. This is the one place we accept string-typed numerics, and it's deliberate. - Timestamps are ISO-8601 with offset.
- IDs are UUIDv7 strings.
- The client never sends a total. It sends intent ("add 2 of this variant"); the server computes and returns the money. A client-supplied total is a price-tampering vector and a source of drift, and there is no case where we need one.
Auth
Two layers for the register (device, then staff), plus a third, independent tier for
the back office — see 01-architecture.md.
POST /api/v1/registers/activate # unauthenticated — the activation code IS the credential
{ "activation_code": "XXXXX-XXXXX" }
→ { register: { id, name, mode, screen_keyboard_enabled }, device_token } # token is long-lived; store on the device
screen_keyboard_enabled rides here, and again on staff/login below, because the
client persists this register object (tokens.setRegisterInfo) the moment activation
succeeds — before any staff session exists. That's what lets the PIN screen itself show
a keyboard on a keyboard-less terminal: it reads the flag off the activation response,
not the login response that comes after.
This is also the boundary of what the flag can do: the activation screen where the
code above is typed in has no register to read yet — no device token means no way to
even ask the server which register this will become — so it always assumes a physical
keyboard is attached. That's deliberate, not an oversight: a keyboard-less terminal's
first setup needs a keyboard connected once (or the code typed on a phone and pasted),
same as it needs power and a network connection once. Every request after activation has
a register to read the flag from.
Activation codes are issued per-register in the back office
(POST /admin/registers/{id}/activation-code, below), are single-use, and expire after 7
days. The server never stores the plaintext code — only a keyed HMAC-SHA256 (same
reasoning as users.pin_lookup, see 02-data-model.md) — so a database dump alone can't
be turned into working codes. Redemption is throttled by IP (throttle:activate, 5/min),
since the code is the only credential the endpoint checks. An unknown code, an
already-redeemed code, and a deactivated register all answer the same
401 invalid_activation_code — deliberately one error, so the endpoint can't be used to
probe which codes exist or which registers are live. An otherwise-valid but expired code
is the one case that answers differently: 401 activation_code_expired.
Every subsequent request carries Authorization: Bearer <device_token>.
POST /api/v1/staff/login # device token + PIN
{ "pin": "1234" }
→ { staff_token, expires_at,
user: { id, name, is_admin, permissions[] },
register: { id, name, mode, screen_keyboard_enabled } }
permissions[] here is the union of every role-derived and directly-granted permission
this user holds at the requesting register's location — 05-rbac.md's "direct
per-location grants" resolve at login exactly like role permissions, no register-tier
code changed to make that true.
Requests that act on behalf of a person send both:
Authorization: Bearer <device_token>
X-Staff-Token: <staff_token>
The device token alone can read the catalog — a terminal showing the menu before anyone clocks in is normal. It cannot touch money, and (correction, M4) it cannot look up orders either; order lookup needs a staff session, per Orders below.
POST /api/v1/staff/logout
PIN attempts are rate-limited per register: 5 failures → 60s lockout, logged to
audit_log. The PIN keyspace is small, so this limiter is load-bearing rather than
decorative.
Back office
A third, independent tier (M6) — no device, no location, no PIN:
POST /api/v1/admin/login
{ "email": "owner@example.com", "password": "..." }
→ { token, user: { id, name, email, is_admin }, sections[], report_location_ids,
currency } # 401 invalid_credentials
POST /api/v1/admin/logout
Permission-based, not admin-only (RBAC v2). Through M6 this tier was is_admin-only;
now any active user holding at least one admin-tier permission — catalog.manage,
user.manage, location.manage, register.enroll, audit.view, report.sales.view,
report.stock.view, settings.manage, role.manage, day.close,
payment_method.manage, shift.approve_variance — anywhere, via a role or a direct
grant, may sign in. Wrong email, wrong password, deactivated, and
zero-admin-tier-permissions all still answer identically (401 invalid_credentials) —
the same user-enumeration defense as PIN login, now covering a wider set of users who can
pass it. is_admin is unaffected and still the only unconditional, every-location,
every-section tier. Full rule (the "holds it anywhere" resolution, and why) in
05-rbac.md.
sections[] is the admin-tier permissions this session holds, in canonical order
(is_admin ⇒ every section) — the back-office sidebar renders only these; the API
refuses the rest regardless of what the client tries to render.
report_location_ids is null for an admin (every location) or the union of every
location where report.sales.view/report.stock.view is held — the location switcher
filters its options to this set, since holding a report permission at one store doesn't
mean holding it everywhere even though back-office login itself is "anywhere."
Every request under /admin/* (below) carries this token the same way every other tier
carries its own:
Authorization: Bearer <admin token>
The gate checks the token's ability, not just its holder's permissions. A register
staff-session token and an admin-login token can both authenticate as the same
underlying user, so EnsureBackOffice additionally requires the presented token to
carry the admin Sanctum ability (only AdminLogin mints that) — otherwise a
supervisor's staff token, which already holds report.sales.view, would pass the
permission check alone. See 05-rbac.md's Back-office access section for the full
reasoning.
Errors
One envelope (01-architecture.md):
{
"error": {
"code": "insufficient_stock",
"message": "Only 2 units of SKU-1234 remain.",
"details": { "variant_id": "0199...", "requested": 5, "available": 2 }
}
}
| HTTP | code examples |
|---|---|
| 400 | validation_failed |
| 401 | invalid_device_token, invalid_pin, staff_session_expired, invalid_credentials, invalid_activation_code, activation_code_expired |
| 403 | forbidden, requires_supervisor, discount_needs_supervisor, wrong_location (mostly structural — location scoping yields 404s; reserved for record/register location disagreements) |
| 404 | not_found |
| 409 | order_version_conflict, insufficient_stock, shift_already_open, order_closed, idempotency_key_reused, no_open_shift, shift_already_closed, shift_has_open_orders, line_already_voided, payment_already_voided, payment_shift_closed, day_closed, day_has_open_shifts, day_has_open_orders, day_not_closed, day_already_closed |
| 422 | payment_exceeds_balance, refund_exceeds_original, refund_amount_zero, modifier_group_required, modifier_not_applicable, line_total_negative, transfer_target_no_shift, transfer_same_shift, variance_already_approved, variance_approval_not_required, insufficient_tender, order_has_payments, discount_scope_mismatch, order_not_zero, pin_already_in_use, split_too_fine, self_lockout, role_template_in_use, role_template_is_system, payment_method_unknown, payment_method_inactive, refund_method_not_refundable |
| 429 | too_many_pin_attempts, too_many_requests |
code is stable forever once shipped; clients branch on it. message is for humans and
may change freely.
Idempotency
Idempotency-Key: <uuidv4> — honored on the routes carrying the idempotent middleware:
add-line, payments, refunds, shift close, cash movements, and split. The middleware
itself is header-presence-driven (it no-ops when the header is absent); required,
enforced by request validation, only on /payments, /refunds, and /shifts/close.
Add-line, split, and cash movements carry the middleware but validate nothing about the
header — sending one is honored and recommended, omitting one is accepted. Semantics in
01-architecture.md: replay with a matching body returns the stored response without
re-executing; replay with a different body is 409 idempotency_key_reused. The key is a global primary key, not scoped per route or per
order — reusing one on a genuinely different request anywhere in the system collides the
same way (01-architecture.md).
Optimistic locking
Mutating an order requires the version you read:
PATCH /api/v1/orders/{id}/lines/{lineId}
If-Match: 7
Stale version → 409 order_version_conflict with the current state in details, so the
client can refetch and reapply without a second round-trip. Every successful order
mutation returns the incremented version.
Catalog (read-mostly, device token sufficient)
GET /api/v1/catalog?location_id=&updated_since=
→ { categories[], products[], variants[], modifier_groups[], modifiers[], tax_rates[],
discounts[], payment_methods[], currency }
One denormalized payload, not five REST resources. A register needs the whole menu to
render, and five round-trips on a cold start is five chances to half-load a menu.
updated_since makes the warm path a small delta.
Prices in this payload are already resolved for the requested location
(variant_location_prices applied), so the register never implements price resolution.
Pricing logic living in exactly one place is worth the denormalization.
As of M4, discounts[] carries the location's active catalog discounts — what a
supervisor can apply, not what's already applied. Applied discounts live on the order
(see Discounts, below).
payment_methods[] is the tender buttons the till renders:
{ id, code, name, group_code, group_name, driver, sort_order }. Active methods in
active groups only — an archived group hides every method under it without touching
their rows — ordered group sort_order → group code → method sort_order → method
code, a total order so two rows sharing a sort value never render in a different
sequence per request. driver rides along so the register knows which tender flow
(cash vs. a bare capture) a code will trigger without a second lookup.
currency is config('pos.currency') (POS_CURRENCY) — the server's ISO-4217 code, so
the register formats every amount it renders instead of hardcoding one. It's also on the
admin login response (above), since the back office has no catalog fetch of its own.
v1 notes: the location is taken from the enrolled register, never from
location_id— a device that could choose its pricing location would be a tampering vector. The parameter exists for the back office (M6).updated_sinceis not yet implemented (modifiershas noupdated_atto diff on); registers full-sync.
GET /api/v1/catalog/lookup?barcode=012345678905&location_id=
→ { variant } # 404 not_found
The scanner path. Separate and narrow because it's the hottest read in retail and must stay a single indexed lookup.
Back-office catalog CRUD lives under /api/v1/admin/* — see Back office, below. (An
earlier draft of this doc sketched it here as /api/v1/products etc. under an admin
role and with DELETE; neither survived contact with 05-rbac.md's correction that
admin isn't a role, or with M6's archive-never-delete decision. There is no DELETE
route anywhere in /admin/*.)
Shifts
POST /api/v1/shifts/open
{ "opening_float_cents": 20000 }
→ { shift } # 409 shift_already_open
GET /api/v1/shifts/current
→ { shift, expected_cash_cents, sales_summary }
POST /api/v1/shifts/{id}/cash-movements
{ "kind": "payout", "amount_cents": 1500, "reason": "Window cleaner" }
→ { cash_movement } # supervisor
POST /api/v1/shifts/{id}/close # Idempotency-Key required
{ "counted_cash_cents": 48750, "note": "" }
→ { shift, expected_cash_cents, variance_cents, requires_approval }
Close never rejects a variance — it records it (see the cash accountability section
of 02-data-model.md). If |variance| exceeds the threshold,
requires_approval: true comes back and a supervisor confirms via:
POST /api/v1/shifts/{id}/approve-variance # supervisor, location-scoped (not register-scoped)
{}
→ { shift } # shift.variance_approved_by / variance_approved_at now set
# 422 variance_approval_not_required, variance_already_approved
The shift is already closed by then. Approval is an audit event, not a gate — blocking the close is how you end up with terminals unplugged mid-count and no data at all.
Approving from the register that just closed 401s. CloseShift revokes every staff
session bound to that register the moment it closes, and approval needs a staff session
like any other write. In practice this means a supervisor approves from a different
register at the same location — the check is on location, not on the specific register.
GET /admin/variances (below) is where a supervisor finds out which shift needs this,
without logging into every register in turn to look — it only lists; approval itself is
still done here, from another till. This is expected behaviour, not a bug to route around.
Closing a shift with open orders → 409 listing them in details. Those orders must be
closed or transferred first; a tab cannot outlive the drawer that's accountable for it.
Stock
POST /api/v1/stock/adjustments
{ "variant_id": "...", "qty_delta": "-2.000", "reason": "adjustment", "note": "" }
→ { level: { variant_id, qty } } # supervisor; reason: adjustment | waste
POST /api/v1/stock/receipts
{ "variant_id": "...", "qty": "24.000", "note": "PO 4471" }
→ { level: { variant_id, qty } } # supervisor
POST /api/v1/stock/counts
{ "variant_id": "...", "counted_qty": "18.000", "note": "" }
→ { level: { variant_id, qty } } # supervisor
GET /api/v1/stock/movements?variant_id=
→ { movements[], level } # last 50 movements, plus the current level
The three write endpoints return the resulting level, not the movement they just
inserted — the register wants a fresh number to render, and the movement row is an
audit artifact, not something the caller needs echoed back.
All location-scoped to the acting register — stock is per-location, and a register only
ever touches the location it's enrolled at. Every write goes through the stock ledger
(02-data-model.md): movements are inserts, never updates, so the level is always a sum
of history, never a mutable counter that can drift from it. Gated supervisor throughout
— an adjustment moves sellable value out of the count the same way a void moves cash out
of the drawer, and letting receiving or counting bypass that would just move the hole
somewhere less audited.
Orders
The lifecycle both retail and food service travel, at different speeds
(00-overview.md).
POST /api/v1/orders
{ "table_ref": "12", "customer_id": null } # both optional
→ { order } # status: open, version: 0
PATCH /api/v1/orders/{id} # If-Match
{ "table_ref": "14" } # or null to clear
→ { order }
Retail opens this implicitly on first scan; the cashier never sees it. Food service opens it explicitly and names a table, and can rename it later — a party moves tables, the tab doesn't move with a new order.
GET /api/v1/orders?number=&status=&location_id=
GET /api/v1/orders/{id}
A targeted, location-scoped lookup — a receipt number for a refund, recovering an
order the register lost track of, or (query status=open) the set of open tabs a floor
view renders. There is no separate browsing/paginated endpoint: the floor view is the
same lookup with status=open, capped at the last 20 open orders per location. Both
routes require a staff session, per Auth above.
Every order in the response carries what a floor view needs to render a tab card without
a second round-trip: table_ref, opened_by_name, opened_at, and due_cents
(max(0, total_cents - paid_cents) — the server does the subtraction so the client never
computes a balance it then trusts).
Lines
POST /api/v1/orders/{id}/lines # If-Match
{ "variant_id": "...", "qty": "1", "modifiers": ["<modifier_id>", ...] }
→ { order, line }
PATCH /api/v1/orders/{id}/lines/{lineId} # If-Match
{ "qty": "3" }
→ { order, line }
PATCH /api/v1/orders/{id}/lines/{lineId}/prep # no If-Match — see below
{ "state": "pending" | "in_progress" | "ready" }
→ { order, line }
DELETE /api/v1/orders/{id}/lines/{lineId} # If-Match — voids, never deletes
{ "reason": "Customer changed mind" }
Adding a line does all of this in one transaction: resolve the location price, snapshot
name/SKU/price/tax-rate onto the line, validate modifier group min_select/max_select,
lock and decrement stock if tracked, recompute order totals, bump version. modifiers
is a flat list of modifier IDs — repeats are legal (a double shot is the same modifier
twice, not a distinct "double shot" catalog entry) and order is preserved. A modifier
that doesn't belong to the variant's product is 422 modifier_not_applicable; a group
whose min_select/max_select isn't satisfied by the selection is
422 modifier_group_required; a selection whose deltas would take the line negative is
422 line_total_negative rather than a negative receipt line.
The whole order comes back on every line mutation. It's slightly more bytes than returning the line alone, and it means the register's totals are incapable of drifting from the server's — there is no client-side total to be stale.
PATCH .../lines/{lineId} sets the line's absolute quantity (not a delta); the stock
ledger sees only the difference. Shrinking a line already fired to the kitchen is the
same fraud surface as voiding a sent line and takes the same permission — decreasing
qty on a line whose prep_state is in_progress or ready without the void-a-line
permission is 403 forbidden. Increasing a fired line's quantity needs no such gate; a
kitchen wanting a bigger portion isn't a fraud path. An already-voided line is
409 line_already_voided.
PATCH .../prep is the coursing verb the kitchen taps: pending (held) →
in_progress (fired) → ready (on the pass). Deliberately no If-Match and no
version bump — the kitchen marking food ready must never invalidate a till mid-tender,
and prep state is a KDS concern orthogonal to money. order_lines.prep_state was reserved
in the schema back at M2; this is the first action that writes it.
DELETE voids (voided_at), per 02-data-model.md. Removing an already-sent line
requires supervisor.
As of M4, the add-line and void responses also carry the order's applied discounts rows
— {id, discount_id, order_line_id, name, amount_cents, reason} — not just totals, so the
register can offer removal without a separate round-trip.
Transfer and split
POST /api/v1/orders/{id}/transfer # If-Match, supervisor
{ "register_id": "<target register>" }
→ { order } # register_id and shift_id now the target's
# 422 transfer_target_no_shift, transfer_same_shift
Hands a tab to another drawer — the accountability unit is the shift, not the person, so
transferring moves the order onto the target register's open shift. The acting
register doesn't have to be either side of the transfer: a supervisor at any register in
the location can move a tab between two others. The target register must have an open
shift to receive it (422 transfer_target_no_shift); transferring onto the shift the
order is already on is a no-op refused rather than silently accepted
(422 transfer_same_shift). Payments already taken keep the shift_id that physically
took them — a transfer never rewrites history, only where the rest of the tab is going.
This is also why closing a shift with open orders lists them: they have to be transferred
or closed first, per Shifts above.
POST /api/v1/orders/{id}/split # If-Match, Idempotency-Key recommended
{ "ways": 3 } # 2..10
→ { orders: [ ... ] } # N new open orders; the original is voided
Splits evenly: every line's qty, tax, discount, and modifier total is divided into
ways parts with Money::allocate/the milli-quantity equivalent — the same
earliest-absorbs-the-remainder rule as any other split in this system
(01-architecture.md), never a per-child recompute (recomputing 1/N of a tax would mint
pennies that don't sum back). Child totals always sum exactly to the original. Stock is
untouched — it left the ledger when the lines were first added, and the children inherit
that claim — so the original order is closed out voided without restock, not through
VoidOrder (which does restock by design). The children are independent orders from the
moment they're created: each closes on its own tender, and nothing about them refers back
to the parent except the audit trail. Any applied discounts are frozen at the split: each
child inherits its allocated share of a discount as a fixed amount, so mutating a child
afterwards (adding a line, changing a qty) does not re-scale it off the live discount —
the share only ever clamps down if the base it sits on shrinks. Refused if the order
already has payments (422 order_has_payments — split what's owed, not what's already
been paid), or if any line's qty in thousandths is smaller than ways and so cannot
divide into that many non-zero parts (422 split_too_fine).
Discounts
POST /api/v1/orders/{id}/discounts # If-Match, floor: order.line.add
{ "discount_id": "...", "order_line_id": null, "reason": "Manager comp" }
→ 403 discount_needs_supervisor # if the discount's own flag says so
DELETE /api/v1/orders/{id}/discounts/{discountId}
Sending order_line_id: null makes it order-level. The server resolves percent → cents
and stores the resolved amount.
Whether this needs a supervisor depends on the discount row, not the route (RBAC
v2). The route only enforces the floor, order.line.add — any staffer who can ring up
a sale can attempt one. discounts.requires_supervisor (default true) is checked
after the row loads, inside the transaction: a cashier-safe discount (flag false)
succeeds for anyone at the floor permission; a normal one requires
order.discount.apply too, and failing that check is 403 discount_needs_supervisor,
not the generic forbidden. See 05-rbac.md for why this lives in the action.
Closing
POST /api/v1/orders/{id}/void # If-Match, supervisor
{ "reason": "Walkout" }
→ { order } # restocks tracked lines
An order closes automatically when captured payments reach total_cents — there is no
"close" endpoint, because a manual close would be a second, disagreeing definition of
"paid in full."
POST /api/v1/orders/{id}/settle # If-Match
→ { order } # 422 order_not_zero
Closes a zero-total order — 100% comped, fully discounted — without a tender. Valid only
when total_cents == 0 and the order has lines; otherwise 422 order_not_zero. This is
not a second, competing definition of "closed" — it's the same "captured payments reach
the total" rule evaluated at a total of zero, where there is no payment left to capture.
POST /api/v1/orders/{id}/reopen # supervisor
For food service: a customer orders another round after settling. Audited.
Payments
POST /api/v1/orders/{id}/payments # Idempotency-Key REQUIRED, If-Match
{ "payment_method_code": "CASH", "amount_cents": 5000, "tendered_cents": 6000 }
→ { payment: { status: "captured", change_cents: 1000,
payment_method_code: "CASH", payment_method_name: "Cash" },
order: { paid_cents: 5000, status: "closed", version: 8 } }
payment_method_code names a per-location tender (02-data-model.md), not a driver —
driver is resolved from the method's group and comes back on the payment, it is never
sent. An unknown code at this location is 422 payment_method_unknown; an archived
method or an archived group is 422 payment_method_inactive.
Change is computed server-side, in integers. The client displays what it's told; it never does the subtraction itself.
amount_cents and tendered_cents are separate fields, and the distinction is
load-bearing: on a $50 bill, handing over $60 is not a $60 payment — it is $50 applied and
$10 change. Tendering less than the amount applied is 422 insufficient_tender, which
is a different thing from underpaying the order (that's just a partial payment, and the
order stays open).
A method whose group drives external_card:
{ "payment_method_code": "VISA", "amount_cents": 5000, "reference": "auth 004321" }
Recorded as captured immediately — we're a ledger for it, not a processor
(01-architecture.md).
Splitting is just several payments; the order closes when they sum to the total. Under-
paying leaves it open. Over-paying is 422 payment_exceeds_balance — for cash, the
overage is change, not a payment, which is exactly why tendered_cents and
amount_cents are different fields.
POST /api/v1/payments/{id}/void # supervisor; before shift close only
A future async driver (Stripe Terminal) returns status: "pending" from this same
endpoint and settles via webhook + GET /api/v1/payments/{id}. The shape does not change
— that's the point of authorize/capture in the driver contract.
Refunds
POST /api/v1/refunds # Idempotency-Key required, supervisor
{
"original_order_id": "...",
"payment_method_code": "CASH",
"reason": "Faulty",
"lines": [ { "original_order_line_id": "...", "qty": "1", "restock": true } ]
}
→ { refund }
Amounts are derived from the original lines, never sent by the client — a
client-specified refund amount is an open till. Validated inside the transaction against
prior refunds on each line (422 refund_exceeds_original). A derived amount of zero — a
fully discounted line, or a quantity that rounds to nothing — is refused
(422 refund_amount_zero) rather than writing a no-op refund. Refundability comes from
the method's driver capability, not the method itself — a method whose group drives
external_card is refused with 422 refund_method_not_refundable, because that money
never came through us.
The original order is never modified.
Back office (/api/v1/admin/*, permission-gated)
Every route below requires the admin-login bearer token from Auth, above, and the
route's own permission (each section below names it) — is_admin bypasses every check.
All of it is conventional CRUD — GET lists (unpaginated; v1's tables are seed-sized,
not production-scale), POST creates, PATCH applies only the keys it's sent — with one
deliberate exception: there is no DELETE route anywhere under /admin/*. Catalog
rows, locations, and registers are archived, never deleted — PATCH { "is_active": false } — because an order line, a receipt, or a report from last month still points at
that row by id, and a hard delete would either cascade into history or leave a dangling
reference. Role templates are the one exception, and even then not through the DELETE
verb (POST /admin/roles/{id}/delete, below) — a template genuinely has nothing left
pointing at it once unassigned. Every mutation writes one audit_log row
(admin.<entity>.create / admin.<entity>.update / admin.<entity>.delete), which is
what the audit viewer below reads.
Catalog
GET|POST /api/v1/admin/categories PATCH /api/v1/admin/categories/{id}
GET|POST /api/v1/admin/tax-rates PATCH /api/v1/admin/tax-rates/{id}
GET|POST /api/v1/admin/products PATCH /api/v1/admin/products/{id}
GET|POST /api/v1/admin/variants PATCH /api/v1/admin/variants/{id}
GET|POST /api/v1/admin/modifier-groups PATCH /api/v1/admin/modifier-groups/{id}
GET|POST /api/v1/admin/modifiers PATCH /api/v1/admin/modifiers/{id}
GET|POST /api/v1/admin/discounts PATCH /api/v1/admin/discounts/{id}
PUT /api/v1/admin/products/{id}/modifier-groups
{ "group_ids": ["<modifier_group_id>", ...] }
→ { product }
PUT, not PATCH, on the attach endpoint: it replaces the product's entire
modifier-group set in one call (ordered by array position), the same full-set-replace
shape as roles on Users, below — there is no add-one/remove-one pair. A product
response's modifier_group_ids (a plain ordered id array) is present on every read, not
only the ones that eager-load the richer modifier_groups shape — an attach editor
seeded from a response that omits it would save back an empty set and silently detach
everything the product had.
Users
GET|POST /api/v1/admin/users PATCH /api/v1/admin/users/{id}
{ "name": "...", "email": "...", "pin": "...", "is_admin": false,
"roles": [ { "location_id": "...", "role": "cashier" } ],
"permissions": [ { "location_id": "...", "permission": "report.sales.view" } ] }
Gated user.manage.
roles and permissions each replace every existing assignment of their own kind for
that user — full-set-replace, never an add/remove pair, the same shape for both.
Omitting either key from a PATCH leaves that kind of assignment untouched; sending
[] clears every one of that kind. roles.role validates against the current set of
role-template names (RBAC v2 — no longer a hardcoded cashier/supervisor pair, see
05-rbac.md); permissions.permission validates against the permission catalog
(GET /admin/permissions, below). An admin cannot demote or deactivate themselves
through this endpoint — 422 self_lockout — because with is_admin remaining the only
unconditional tier, there's no guaranteed second admin online to undo it.
Roles (RBAC v2)
GET /api/v1/admin/roles
→ { items: [ { id, name, is_system, permissions[], assigned_users } ] }
POST /api/v1/admin/roles
{ "name": "shift-lead", "permissions": ["order.line.void", "..."] }
PATCH /api/v1/admin/roles/{id}
{ "name": "...", "permissions": [...] } # 422 role_template_is_system on a name change
POST /api/v1/admin/roles/{id}/delete
→ { deleted: true } # 422 role_template_in_use, role_template_is_system
GET /api/v1/admin/permissions
→ { groups: [ { label, permissions[] } ] } # the catalog, grouped for the UI
All gated role.manage, except the two read endpoints — GET /admin/roles and
GET /admin/permissions — which also accept user.manage — the user editor's role
and direct-grant pickers need the same lists without needing role-editing rights.
role_templates.name is unique; cashier and
supervisor are is_system (permissions editable, name and existence pinned — every
seed and doc assumes they exist under those names). A PATCH or delete on a system
template's name/existence is 422 role_template_is_system; deleting a custom template
still assigned somewhere is 422 role_template_in_use, with assigned_users in
details — unassign everywhere first. Editing a template's permission set (or renaming
a custom one) re-materializes it at every location in the same request; a location
created afterward gets the current template set automatically (CreateLocation calls
the same provisioning). See 05-rbac.md for the full model.
Locations and registers
GET|POST /api/v1/admin/locations PATCH /api/v1/admin/locations/{id}
{ "name": "...", "code": "...", "timezone": "...", "prices_include_tax": false,
"receipt_header": "...", "receipt_footer": "...", "is_active": true,
"variance_approval_threshold_cents": null, "low_stock_threshold": null }
GET|POST /api/v1/admin/registers PATCH /api/v1/admin/registers/{id}
{ "mode": "retail" | "food", # picks the register's UI
"is_active": true, "screen_keyboard_enabled": false } # per-till on-screen keyboard, default false
POST /api/v1/admin/registers/{id}/activation-code
→ { activation_code, expires_at } # shown exactly once
The location endpoints are gated location.manage, except GET /admin/locations,
which accepts any admin-tier section — location names are low-sensitivity reference
data every permitted section (the location switcher, the user editor, reports)
composes from, not something worth gating behind location.manage specifically. The
register endpoints — including activation-code issuance — are gated register.enroll,
not location.manage: enrolling and re-keying terminals is its own trust decision,
separate from editing location settings.
Per-location thresholds (RBAC v2). variance_approval_threshold_cents (integer,
>= 0) and low_stock_threshold (decimal string, >= 0) override the deployed config
default (pos.shifts.variance_approval_threshold_cents, pos.stock.low_threshold) for
this location alone. null explicitly clears an override back to the config
default — sent as null on create or update, it is stored/kept as null, not
coerced to the config value at write time; ApproveVariance, CloseShiftResource, and
the stock report all resolve location->column ?? config(...) at read time instead,
so a later config change takes effect at every location that never set an override,
without a data migration. Omitting either key from a PATCH leaves it untouched, same
as every other partial update in this API.
Admins see and handle only the opaque activation code — the raw device token is minted
directly to the terminal by POST /registers/activate and never crosses the admin
surface at all.
Issuing (or reissuing) a code is the enrollment and the lost/stolen-terminal path in one:
it stores a new single-use code and, in the same transaction, deletes every device
token for the register and every staff session bound to it — the till goes dark
immediately and shows its "activation code disabled" screen until someone types the new
code in. There is never a window where a lost credential and its replacement are both
live. GET /api/v1/admin/registers items carry activation: { state, code_expires_at },
where state is one of enrolled, code_pending, code_expired, not_enrolled and
code_expires_at is set only for code_pending. registers.mode is the one schema
addition M5 needed (06-roadmap.md) and simply picks which UI the register app renders.
GET /api/v1/registers/open-shifts # staff tier, not admin
→ { items: [ { register_id, register_name, shift_id, opened_by_name }, ... ] }
Not under /admin on purpose — this is the register app asking about other active,
currently-open-shift registers at its own location, excluding itself (populating a
transfer picker, or finding another open register to approve a variance from), which is
a staff action even though it never touches money or the back office. It needs a staff
session like any other staff-tier route, not an admin one.
Payment methods — payment_method.manage
GET /api/v1/admin/payment-method-groups?location_id=
POST /api/v1/admin/payment-method-groups { location_id, code, name, driver, sort_order }
PATCH /api/v1/admin/payment-method-groups/{group} { name?, sort_order?, is_active? }
GET /api/v1/admin/payment-methods?location_id=
POST /api/v1/admin/payment-methods { location_id, group_id, code, name, sort_order }
PATCH /api/v1/admin/payment-methods/{method} { name?, sort_order?, is_active? }
code and driver (group) and code and group_id (method) are absent from the PATCH
bodies because they are immutable after create — changing a group's driver would change
how every method under it behaves and retroactively re-bucket history; moving a method to
another group would do the same at the method level. A client that sends one anyway is
silently ignored, the same shape every admin PATCH here has, rather than a 422.
Codes are normalized to uppercase and unique per location — the same code at a different
location is legal and expected. Both endpoints check the given (or the row's own)
location_id against where the caller actually holds payment_method.manage, same as
the report endpoints below: holding the permission somewhere is what gets a non-admin
into the section, not a blank check on every store's tenders.
Settings (RBAC v2)
GET /api/v1/admin/settings
→ { settings: [ { key, value, source: "db" | "config" }, ... ] }
PATCH /api/v1/admin/settings
{ "settings": { "business.name": "...", "business.tax_id": null } }
→ { settings: [ ... ] } # the full registry, post-write
Gated settings.manage. The registry today is business.name, business.address,
business.tax_id — the business-identity fields receipts read (02-data-model.md).
source tells the caller whether a value is a database override ("db") or the
deployed config fallback ("config") — config is what engineers deploy, the database is
what admins change at runtime (04-backend-conventions.md), and this response is how
the UI shows which one is currently in effect. Sending null for a key explicitly
clears its override, falling back to config again — there is no way to store an
explicit null value, because a stored null would pin source: "db" forever with no path
back to config. An unregistered key in the PATCH body is 422 validation_failed.
Every write is audited (admin.settings.update, with changed/cleared key lists),
even though no single key is itself money-moving.
End of day
GET /api/v1/admin/locations/{location}/day?date=YYYY-MM-DD
→ { business_date, location_today, snapshot, open_shifts[], open_orders_count,
unapproved_variance_count, closable, record }
POST /api/v1/admin/locations/{location}/day/close
{ "deposit_cents": 40000, "checklist": { "cash_drop_confirmed": true,
"spoilage_note": "", "next_day_note": "" }, "note": null }
→ { business_day } # 409 day_has_open_shifts, day_has_open_orders, day_already_closed
POST /api/v1/admin/locations/{location}/day/reopen # is_admin only
{ "reason": "miscount" }
→ { business_day } # 409 day_not_closed
GET /api/v1/admin/locations/{location}/days
→ { items: [ { business_date, closed_by, closed_at, net_sales_cents,
variance_cents, deposit_cents, reopened_at }, ... ] }
The location-scoped layer above a shift: reconcile every drawer, record the bank deposit
and a fixed checklist, and freeze an immutable day record. GET .../day powers the
End-Of-Day screen — snapshot is computed live off the ledgers while the day is still
open, and read straight from the persisted business_days row's own eight columns once
closed (never recomputed against the live ledger again) — plus closable (no open
shifts, no open orders) and the blockers/warnings that explain why not:
open_shifts/open_orders_count block the close, an unapproved variance over the
location's variance-approval threshold only warns (same non-blocking philosophy as
variance itself; a variance at or under threshold isn't even approvable, so it's never
counted here). date defaults to the location's local today when omitted; location_today
is always the location's local today (Y-m-d) regardless of what date was requested,
for a UI that needs it without trusting the browser's own clock/timezone.
Closing rejects a date later than the location's local today (400 validation_failed),
and rejects re-closing a day that's already closed and hasn't been reopened since
(409 day_already_closed) — the persisted record is immutable once closed; only a
reopen followed by a new close may change it, and that re-close re-snapshots the whole
row and clears reopened_at/reopened_by.
snapshot/the close record mix two bases: gross_sales_cents/refunds_cents/
net_sales_cents are ledger-basis, tax_cents is order-basis, and a refund lowers the
former without lowering the latter — the two do not reconcile against each other inside
one record by design (02-data-model.md).
GET|POST .../day and GET .../days are gated day.close, location-scoped like the
report endpoints below. Reopen is is_admin only — it is the sole action that
un-forbids opening a shift on a closed date, and reason is required and audited. Every
route here still goes through AuthorizesBackOffice::allowsBackOffice(), never a bare
can() (05-rbac.md).
The one write-path effect outside this section: POST /shifts/open now checks for a
closed, un-reopened business_days row at the register's location and today's date,
refusing with 409 day_closed if one exists. Nothing else changes — approving a
variance, refunds, and reports all stay legal on a closed day.
Variances
GET /api/v1/admin/variances # gated shift.approve_variance
→ { items: [ { shift_id, register_id, register_name, location_id, location_name,
opened_by_name, opened_at, closed_at, expected_cash_cents, counted_cash_cents,
variance_cents, threshold_cents } ] }
A queue, not a report: closed shifts whose drawer variance is over threshold and not yet
signed off, so a supervisor can see which drawers need approval without logging into
each register in turn to find out. No location_id parameter — the list is already
scoped to every location where the caller holds shift.approve_variance, the same "holds
it anywhere" rule as back-office login itself (is_admin: every location; 05-rbac.md).
Unpaginated, ordered by closed_at descending — the most recent close is the one a
supervisor is most likely acting on.
A row is pending under exactly the three conditions ApproveVariance itself guards on
(above), so there is one definition of "needs approval" rather than two that can drift:
closed_at is set (an open shift has no count yet), abs(variance_cents) is strictly
greater than threshold_cents, and variance_approved_at is still null. The boundary
is deliberate — POST /shifts/{shift}/approve-variance rejects abs(...) <= threshold
with 422 variance_approval_not_required, so listing an at-threshold row would offer an
approval the API refuses. threshold_cents is
locations.variance_approval_threshold_cents, falling back to
config('pos.shifts.variance_approval_threshold_cents') — resolved per row, because
unlike GET .../day above this list spans locations, each of which may override the
threshold differently.
Read-only, gated shift.approve_variance (now in AdminAccess::SECTIONS — 05-rbac.md).
Approval itself is unchanged and stays the register action above, POST /shifts/{shift}/approve-variance — not ported to the back office, because the audit trail
is register-attributed (ApproveVariance audits with a registerId, and an admin session
has no register to attribute to). This endpoint only answers which shift; it never
approves one.
Reports
GET /api/v1/reports/z?shift_id= # staff tier
→ { shift, sales_by_method, sales_by_group, refunds_by_method, refunds_by_group,
movements, orders_closed, orders_voided, orders_split, expected_cash_cents }
GET /api/v1/admin/reports/sales?location_id=&from=&to=&group_by=day|category|user|payment_method
→ { rows[], totals, basis } # gated report.sales.view
GET /api/v1/admin/reports/stock?location_id=&low_only=true
→ { rows[] } # gated report.stock.view
GET /api/v1/admin/audit?entity_type=&entity_id=&user_id=&action=&from=&to=&page=
→ { rows[], page, has_more } # 50 rows/page
Both report endpoints additionally check the requested location_id against where the
caller actually holds the permission (RBAC v2) — holding report.sales.view or
report.stock.view somewhere is what gets a non-admin into the back office at all
(sections[], above); it is not a blank check to query every location's numbers. A
location_id outside that set is refused even for an otherwise-valid back-office
session; is_admin is exempt (every location). See 05-rbac.md.
All date filtering is on business_date (02-data-model.md), so a report means the same
thing regardless of the timezone of whoever runs it. The Z-report's orders_split (M6)
counts the originals POST /orders/{id}/split leaves behind (voided, not closed)
separately from ordinary voids, so a busy split day at the till doesn't read as a wave of
walkouts.
group_by=day, group_by=user, and group_by=payment_method are LEDGER-basis —
summed straight from captured payments and refunds, i.e. money that actually moved,
who moved it, and what it was moved on; payment_method groups on the snapshot columns
(payment_method_code/payment_method_name), not a join to the live payment_methods
table, for the same reason receipts read snapshots — a renamed or archived method must not
reshape a historical report. group_by=category is LINE-basis — summed from
non-voided lines of closed orders, joined to the live catalog for a human-readable
category name (a report is allowed to do that join; a receipt never is, since it must
reprint identically to what it said on the day it was made). The response's basis field
names which kind of number a given group_by produced. The two bases are not required
to reconcile with each other — a line-level discount changes what a line's total was
without changing what tender captured it — and that's a fact about what each slice
measures, not a bug to chase down.
The Z-report's sales_by_method/refunds_by_method and sales_by_group/
refunds_by_group replace the M4-era sales_by_driver/refunds_by_driver — a per-shift
cash count needs to see GCash separately from Visa even though both drive
external_card, which a driver-keyed breakdown could never show. Both are keyed on the
snapshot code/name, same reasoning as above.
The audit viewer is read-only (no audit_log row for reading the audit log) and
paginates at 50 rows. entity_type/entity_id, user_id, and action are each covered
by a dedicated index on audit_log; only a bare date range with none of those three set
falls back to a sequential scan, an accepted cost for a back-office read at this scale.
Drawer
POST /api/v1/drawer/no-sale # open the drawer with no sale attached
Requires drawer.no_sale (supervisor). reason is mandatory and the opening is bound to
the register's open shift — no open shift is 409 no_open_shift. Moves no money, so
there is no table: the audit row is the record, and the back office's audit viewer reads
it. Only the desktop shell can act on the response; a browser has no drawer to open.
Receipts
GET /api/v1/orders/{id}/receipt # → structured JSON, rendered client-side
Built entirely from snapshot columns — never joined to the live catalog. Reprinting a receipt from 2024 next year must produce identical bytes, which is the whole reason those columns exist.
Rate limits
| Scope | Limit |
|---|---|
| PIN attempts | 5 / 60s per register |
| Catalog full sync | 10 / min per register |
| Everything else | 300 / min per device token |
Deliberately loose: a busy lunch rush is not an attack, and a POS that rate-limits a queue of real customers has failed at being a POS.