POS — Process flows
Architecture & lifecycle auto-generated from code (dispatch, CHECK status enums, SET status writes). Sequences & notes are authored.
Architecture
%%{init: {"theme":"base","themeVariables":{"darkMode":true,"background":"#1e1e1e","primaryColor":"#2d2d30","primaryBorderColor":"#569cd6","primaryTextColor":"#d4d4d4","lineColor":"#6796c6","secondaryColor":"#3a3d41","secondaryBorderColor":"#dcdcaa","tertiaryColor":"#252526","tertiaryBorderColor":"#569cd6","clusterBkg":"#252526","clusterBorder":"#569cd6","titleColor":"#569cd6","noteBkgColor":"#3a3d41","noteTextColor":"#dcdcaa"},"flowchart":{"useMaxWidth":false},"state":{"useMaxWidth":false}}}%%
flowchart LR
C["Client"] --> A["Pages Fn pos<br/>bolt_session auth · company_id scope"]
A --> D["dispatch<br/>open_session · session_open · submit_order · sale · order · cash_movement · pay_in · pay_out …"]
D --> K["Kernel<br/>erp_document + journal<br/>atomic GL / stock"]
K --> DB[("D1 tables")]
D --> DB
DB -.-> T["pos_session<br/>pos_cash_count<br/>pos_order<br/>pos_order_line<br/>pos_payment<br/>pos_settlement_expectation<br/>pos_cash_movement"]
Lifecycle
Kernel document lifecycle (erp_document.docstatus)
Every money/stock-moving document in this module posts through the shared kernel, walking a fixed docstatus:
%%{init: {"theme":"base","themeVariables":{"darkMode":true,"background":"#1e1e1e","primaryColor":"#2d2d30","primaryBorderColor":"#569cd6","primaryTextColor":"#d4d4d4","lineColor":"#6796c6","secondaryColor":"#3a3d41","secondaryBorderColor":"#dcdcaa","tertiaryColor":"#252526","tertiaryBorderColor":"#569cd6","clusterBkg":"#252526","clusterBorder":"#569cd6","titleColor":"#569cd6","noteBkgColor":"#3a3d41","noteTextColor":"#dcdcaa"},"flowchart":{"useMaxWidth":false},"state":{"useMaxWidth":false}}}%%
stateDiagram-v2
[*] --> Draft: createDocument (docstatus 0)
Draft --> Submitted: submitDocument (docstatus 1, journal posted)
Submitted --> Cancelled: cancelDocument (docstatus 2, reversed)
Submitted --> [*]
Cancelled --> [*]
pos_terminal.status
States: active (initial) · suspended · retired
pos_payment_method.status
States: active (initial) · retired
Key flow: submit_order (sale posting)
Kernel-first, then one atomic D1 batch. The kernel is idempotent on operation_group = pos-order:<idempotency_key>, so a failed attempt writes nothing durable and a retry replays the same document instead of double-posting.
%%{init: {"theme":"base","themeVariables":{"darkMode":true,"background":"#1e1e1e","primaryColor":"#2d2d30","primaryBorderColor":"#569cd6","primaryTextColor":"#d4d4d4","lineColor":"#6796c6","actorBkg":"#2d2d30","actorBorder":"#569cd6","actorTextColor":"#d4d4d4","signalColor":"#9cdcfe","signalTextColor":"#d4d4d4","noteBkgColor":"#3a3d41","noteTextColor":"#dcdcaa","labelBoxBkgColor":"#3a3d41","labelBoxBorderColor":"#dcdcaa"},"sequence":{"useMaxWidth":false}}}%%
sequenceDiagram
participant C as Terminal
participant P as Pages Fn (submitOrder)
participant D as D1 (pos tables)
participant K as Kernel (GL + Stock)
C->>P: POST submit_order (idempotency_key, session_id, lines, payments)
P->>D: replay? order WHERE idempotency_key AND state=posted
alt already posted
D-->>P: row
P-->>C: ok replay=true
else new
P->>D: load session (state=open) + profile
P->>D: resolveLineItem + tax_code; unit_cost = projectedAvcoCost (sale) / originalReturnCost (return)
Note over P: AVCO issue THROWS if location on-hand qty <= 0
Note over P: compute discounts, netTotal, vatTotal, cogsTotal
P->>P: normalizePayments (cash vs settlement); legs must sum to gross
P->>K: createDocument doctype=pos-sale (stockLines qty_delta=-qty, journal)
P->>K: submitDocument (post stock movement + GL journal atomically)
K-->>P: doc_no, kernel_document_id
P->>D: DB.batch { order(posted) + lines + payments + settlement_expectation }
alt UNIQUE(idempotency_key) race
D-->>P: constraint abort
P-->>C: ok replay=true (idempotent)
else committed
D-->>P: order row
P-->>C: ok order + submitted
end
end
Notes
- Purpose: till-side sales, returns and exchanges plus cash-drawer control (open/close session, pay-in/pay-out, cash counts) for a multi-tenant retail ERP; each sale posts revenue, VAT output, COGS and inventory in one balanced kernel journal.
- Idempotency is mandatory:
submit_orderrequiresidempotency_key(offline_id) and the replay guard only matchesstate = 'posted'. In the current code the order row is inserted only aspostedinside the single atomic batch, so a failed attempt leaves no order row at all; thestate='posted'guard therefore both serves genuine replays and refuses to treat any non-posted remnant as a success (no phantom/lost sale on offline retry). - Money-safety ordering: kernel post (stock + GL) happens before any POS row; order + lines + payments + settlement expectation are then a single
env.DB.batchtransaction.erp_pos_paymentis append-only (a compensating DELETE is aborted by theerp_pos_payment_no_deletetrigger, verified in migration0107_erp_pos.sql), so nothing is ever rolled back by hand — retries replay the same kernel document. - Payment split: cash tenders post with
transaction_kind = 'cash'; non-cash tenders resolve the method'ssettlement_posting_group_codeand, whensettlement_expected = 1, accrue anerp_pos_settlement_expectation(stateopen/matched/disputed, per-session-per-method, upserted on conflict) so card/PromptPay receivables are reconciled later. - Oversell is bounded, not unconditional: a POS sale is not blocked merely because on-hand is less than the quantity sold — the kernel posts the stock movement and on-hand goes negative, to be corrected by a cycle count; do not add a hard "sell-qty > on-hand" gate. But a sale line still fails with
insufficient stock for AVCO issuewhen the location's on-hand qty is<= 0, becauseprojectedAvcoCostcannot derive an AVCO unit cost from a non-positive balance. So overselling is only possible starting from a positive on-hand balance. (Return lines instead cost viaoriginalReturnCostagainst the original posted sale line.) - Session close = Z-report math:
expected_cash = opening_float + cash sales + pay_in − pay_out − cash refunds(cash sales/refunds summed fromerp_pos_paymentwheretender_kind = 'cash'onpostedorders of the session);variance = counted − expectedis stored with a closingcash_countand az_report_jsonsnapshot on the session. Tax rate/posting group are resolved fromerp_tax_code(defaultVAT7); discounts are capped perprofile.policy_json(discount_cap_percent).