Skip to content

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_order requires idempotency_key (offline_id) and the replay guard only matches state = 'posted'. In the current code the order row is inserted only as posted inside the single atomic batch, so a failed attempt leaves no order row at all; the state='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.batch transaction. erp_pos_payment is append-only (a compensating DELETE is aborted by the erp_pos_payment_no_delete trigger, verified in migration 0107_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's settlement_posting_group_code and, when settlement_expected = 1, accrue an erp_pos_settlement_expectation (state open / 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 issue when the location's on-hand qty is <= 0, because projectedAvcoCost cannot 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 via originalReturnCost against 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 from erp_pos_payment where tender_kind = 'cash' on posted orders of the session); variance = counted − expected is stored with a closing cash_count and a z_report_json snapshot on the session. Tax rate/posting group are resolved from erp_tax_code (default VAT7); discounts are capped per profile.policy_json (discount_cap_percent).