Skip to content

Point of Sale

The POS plugin adds terminals, shifts, transactions, and multi-method payment splitting to any Porulle instance. It does not require a storefront — it’s designed for brick-and-mortar retail, pop-up shops, and hybrid online+in-store operations.

For a complete step-by-step walkthrough, see the Tea Shop POS tutorial.

Terminal window
bun add @porulle/plugin-pos
commerce.config.ts
import { posPlugin } from "@porulle/plugin-pos";
export default defineConfig({
plugins: [posPlugin()],
});

Update drizzle.config.ts:

drizzle.config.ts
schema: [
"./node_modules/@porulle/core/src/kernel/database/schema.ts",
"./node_modules/@porulle/core/src/auth/auth-schema.ts",
"./node_modules/@porulle/plugin-pos/src/schema.ts",
],

Push the new tables and restart:

Terminal window
bunx drizzle-kit push --config drizzle.config.ts
bun run src/server.ts

This adds six tables: pos_terminals, pos_shifts, pos_transactions, pos_payments, pos_cash_events, pos_return_items.

Terminal — represents a physical register or device. Created once and reused across shifts.

Shift — a cashier session tied to one terminal. Tracks opening float, cash events, sales totals, and cash variance. Must be opened before transactions can be created.

Transaction — a sale linked to a shift. Accumulates payments until completed or voided. Voided transactions do not count toward shift totals.

Payment — one payment method entry on a transaction. Multiple payments on a single transaction = split payment.

| Operation | Endpoint | |-----------|----------| | Register terminal | POST /api/pos/terminals | | Open shift | POST /api/pos/shifts/open | | Create transaction | POST /api/pos/transactions | | Add payment | POST /api/pos/transactions/:id/payments | | Complete transaction | POST /api/pos/transactions/:id/complete | | Void transaction | POST /api/pos/transactions/:id/void | | Close shift | POST /api/pos/shifts/:id/close | | Z-report | GET /api/pos/shifts/:id/report |

All monetary values are integers in the smallest currency unit (cents for USD).

To split a transaction across multiple payment methods, POST multiple payments before completing:

Terminal window
curl -X POST http://localhost:4000/api/pos/transactions/$TXN_ID/payments \
-H "x-api-key: dev-staff-key" \
-d '{"method": "cash", "amount": 3500}'
curl -X POST http://localhost:4000/api/pos/transactions/$TXN_ID/payments \
-H "x-api-key: dev-staff-key" \
-d '{"method": "card", "amount": 1500, "reference": "****4321"}'
curl -X POST http://localhost:4000/api/pos/transactions/$TXN_ID/complete \
-H "x-api-key: dev-staff-key"

Cashiers share a terminal but authenticate individually with a numeric PIN. A successful PIN login mints a short-lived, per-shift API key scoped to pos:operate that the terminal then sends as x-api-key for that operator’s session. PINs live in the pos_operator_pins table, hashed with PBKDF2-SHA256 via Web Crypto (Workers-safe) — never stored in plaintext.

Set or rotate an operator’s PIN with PUT /api/pos/auth/pin (permission pos:admin). pin is 4–8 digits (/^\d{4,8}$/); set canOverride to allow this operator to approve manager overrides:

Terminal window
curl -X PUT http://localhost:4000/api/pos/auth/pin \
-H "x-api-key: dev-staff-key" \
-d '{"operatorId": "'$OPERATOR_ID'", "pin": "4821", "canOverride": true}'

Returns { data: { operatorId, canOverride } }.

The operator logs in with POST /api/pos/auth/pin-login (permission pos:operate — the terminal’s device credential authorizes the login). The operator must have an open shift:

Terminal window
curl -X POST http://localhost:4000/api/pos/auth/pin-login \
-H "x-api-key: dev-staff-key" \
-d '{"operatorId": "'$OPERATOR_ID'", "pin": "4821"}'

Returns { data: { operatorId, shiftId, terminalId, apiKey, expiresAt } }. apiKey is the raw minted key — scoped to pos:operate, prefixed pos_shift_, with a short TTL (default 12h, minimum 15 min). The terminal sends it as x-api-key for the rest of the session:

Terminal window
curl -X POST http://localhost:4000/api/pos/transactions \
-H "x-api-key: pos_shift_..."

Approve a restricted action with POST /api/pos/auth/override (permission pos:operate). action is a short string describing what’s being approved (max 200). Only operators whose PIN was set with canOverride: true may approve:

Terminal window
curl -X POST http://localhost:4000/api/pos/auth/override \
-H "x-api-key: dev-staff-key" \
-d '{"operatorId": "'$MANAGER_ID'", "pin": "9930", "action": "void completed transaction"}'

Returns { data: { approved: true, operatorId, action, approvedAt } }.

Service failures here — a bad PIN or no open shift — surface as 500 with the message, not 401/422; handle them on the 500 path.

When closing a shift, pass the cashier’s physical count as closingCount. The engine calculates:

expected cash = opening float + all cash payments across completed transactions
cash variance = closing count - expected cash

A negative variance means cash is short. A positive variance means there’s more cash than expected.

Every core order carries free-text notes and a merged activity timeline — useful at the counter for flagging a held order or reviewing what happened to a return. These are core endpoints (permission orders:read to read, orders:update to write), not POS-specific.

Add a note (optionally pinned to the top) and list them (pinned-first, then newest-first):

Terminal window
curl -X POST http://localhost:4000/api/orders/$ORDER_ID/notes \
-H "x-api-key: dev-staff-key" \
-d '{ "body": "Customer to collect Saturday", "pinned": true }'
curl http://localhost:4000/api/orders/$ORDER_ID/notes \
-H "x-api-key: dev-staff-key"

The timeline merges status changes, notes, and refund events (including undos) newest-first:

Terminal window
curl http://localhost:4000/api/orders/$ORDER_ID/timeline \
-H "x-api-key: dev-staff-key"
{ "data": [{ "type": "refund", "at": "2026-07-02T10:15:00Z", "actor": "user_9", "summary": "Refund of 1800", "data": {} }] }

Delete a note with DELETE /api/orders/{id}/notes/{noteId}.