POS Order Flows — terminal checkout, the order list, and POS↔︎sales-invoice identity

The whole POS order story reduces to one fact: a POS order is a SalesInvoice. The terminal builds a cart, the payment modal maps it to a checkoutSalesInvoice payload (kind: 'SalesInvoice', fixed: true), and checkoutSalesOrder passes it straight through to the same sales engine that writes Stock(type=OUT) and posts the GL legs. There is no POS order entity, no POS-specific list query, and no POS refund/void/cancel UI — the POS "Orders" tab just re-reads salesInvoicePage, and corrections happen through the standard Sales Return path.

Source: BE none (reuses src/modules/inventory/sales + inventory/order) · Admin src/modules/pos/terminal/*, src/modules/pos/order, src/modules/inventory/cart, src/modules/inventory/sales/context.tsx

Related: pos-settings-session.md (default accounts & the daily session view) · sales (the engine that actually runs) · stock ledger · pricing & costing · domain overview


1. Purpose & scope

Covers the transactional half of the admin POS module:

  • POS Terminal (/pos/terminal) — the touch-style register: item catalog (search + category + type filters), a cart panel, a payment modal (cash / bank / split tender), and a receipt modal.
  • POS Orders list (/pos/order) — a paginated read-only list of processed sales (= SalesInvoices).
  • The mapping from a POS sale to a standard Sales Invoice, and where refunds / voids actually live (they are not in the POS module).

What this explicitly does NOT do (verified absent):

  • No POS order schema/collection. grep for POS order types in schema.gql finds nothing; the terminal writes ordinary Order(kind=SalesInvoice) rows.
  • No POS refund / void / cancel / return. grep -niE "refund|void|cancel|return|reverse" over modules/pos/ returns only generic UI words (a "Cancel" button on the payment modal that dismisses it, a "Clear" cart button, the trash icon). There is no reversal mutation in POS. Corrections use the shared Sales Return.
  • No order detail/edit in the POS list. Rows in /pos/order are not clickable — no link to a detail page, no inline edit/delete. The terminal's receipt modal is the only place that links out (to the print template).

The single most important rule for a rebuilder: don't build a parallel POS order model. POS is a front-end skin over the existing sales-invoice engine. All stock, GL, tax, costing, and return logic is the sales engine's — documented once in sales.


2. Data model

POS adds no persisted schema. Three in-memory shapes drive the terminal:

2.1 ICartItem (cart line) — modules/inventory/cart/model

Built by POSItemCard/POSCartPanel, held in the shared cart context (modules/inventory/cart/context.tsx):

Field Type Description
itemId string the product (Item._id)
stockId string chosen serialized Stock._id, or falls back to item._id when no stock row
stock object the selected Stock row (for soldBy=ITEM serialized items)
name string display name
soldBy ItemSoldByTypes ITEM | WEIGHTWEIGHT opens a gram-entry popover
quantity / gross / wasteQuantity number net / gross / waste qty
rate number|string unit price (item.price for sales, item.cost for purchase mode)
amount number rate × quantity
taxes / tax / taxInclusive line taxes (carried into checkout)

Cart identity is stockId (addOrUpdateItem upserts by stockId; removeItem filters by itemId). The cart context is module-level mutable (let itemsList) — it persists across remounts within the session and is wiped by clearItems() (the terminal clears on mount, on unmount, and after a successful sale).

2.2 ICartAmount (derived totals) — recomputed on every cart/checkout change

// cart/context.tsx → calcAmount()
totalAmount   = Σ (rate × quantity)                 // pre-tax subtotal
taxAdjustedTotal = computeOrderLineTotals(lines).total  // VAT adds, WHT deducts
afterDiscount = taxAdjustedTotal − discount         // the "Total" / amount due
balance       = afterDiscount  (cashAmount + bankAmount)
discount = discountValueType==AMOUNT ? discountValue : (discountValue/100)×totalAmount

2.3 IPOSReceiptData (transient receipt) — terminal/pos-payment-modal.tsx

The only "result" object; consumed by the receipt modal then discarded (never persisted): { invoiceId, items[{name,quantity,rate,amount}], total, cashTendered, change, customerName? }.

Enums: none POS-specific. The terminal uses OrderKindTypes.{SalesInvoice, PurchaseInvoice} (cart orderType, default SalesInvoice), ItemSoldByTypes.{ITEM, WEIGHT}, and a local UI union type PaymentMode = 'CASH' | 'BANK' | 'SPLIT'. The persisted entity is a SalesInvoice — see its enums in sales / purchases §2.3.


3. API surface

POS introduces zero new GraphQL ops. It calls the sales context (useSalesOrderState, modules/inventory/sales/context.tsx):

POS action Context method GraphQL op Returns Notes
Charge a sale checkoutSalesOrder(payload, id?) checkoutSalesInvoice(id?, checkout) SalesInvoice (Order) the whole POS write path. Direct passthrough; id is undefined for new sales. Toast "Sale Checked Out Successfully".
List POS orders salesInvoicePage({ page, pageSize }) salesInvoicePage paged SalesInvoice[] + totalRecords branch-scoped on resolver.
Item catalog fetchItemPage(filter) item page query Item[] terminal left pane; filters by keyword/category/type.
Filters fetchCategoryPage, fetchItemTypes category/type queries lists category tabs + type chips.
View invoice (no GraphQL) window.open('/templates/sales-invoice?_id=…&view=true').

checkoutSalesOrder is verbatim a thin wrapper:

// sales/context.tsx
const checkoutSalesOrder = (checkout, id?) =>
  salesInvoiceQ.checkoutOrder({ variables: { id, checkout } })
    .then(res => res?.data?.checkoutSalesInvoice);   // ← same mutation the full sales screen uses

The checkoutSalesInvoice resolver itself (auth, @ApBranchAuth({branchIdRequired:true}), the PurchaseInvoice-vs-SalesInvoice routing) is documented in sales §3.


4. Business rules & calculations

4.1 The POS → SalesInvoice payload (the load-bearing mapping)

POSPaymentModal.handleCharge() builds this and hands it to checkoutSalesOrder (verbatim shape, trimmed):

const payload = {
  items: items.map(item => ({
    stockId: item.stockId, itemId: item.itemId,
    netQuantity: +item.quantity, grossQuantity: +(item.gross || 0),
    amount: +(item.amount || 0), rate: parseFloat(item.rate),
    wasteQuantity: +(item.wasteQuantity || 0),
    taxIds: (item.taxes||[]).map(t => t._id||t.value||t.taxId).filter(Boolean),
    taxId:  (item.taxes||[])[0]?._id || (item.taxes||[])[0]?.value || item.tax?._id,
    taxInclusive: !!item.taxInclusive,
  })),
  paymentMethod: mode === 'CASH' ? 'CASH' : 'BANK',   // BANK & SPLIT both → 'BANK'
  paymentType:  'CASH',                                // ← always CASH ⇒ invoice posts PAID
  customerId: customer?._id,                           // omitted ⇒ "Walk-in"
  note, orderDate: DateUtils.todayDateOnly(),
  fixed: true,                                         // ← freezes price; skips price-level resolution
  cashAmount: effectiveCash, bankAmount: effectiveBank,
  cashAccountId: cashAccountId || undefined,
  bankAccountId: mode !== 'CASH' ? (bankAccountId || undefined) : undefined,
  stockPaymentAmount: 0,
  kind: 'SalesInvoice',                                // ← persists into shared `orders`
  discountValueType: 'AMOUNT', discountValue: 0,       // POS sends no discount
};

Consequences a rebuilder must know:

  • fixed: true ⇒ the sales engine skips price-level resolution and takes the cart rate/amount as-is (sales §4.4; pricing). The cashier's screen price is final.
  • paymentType: 'CASH' ⇒ at checkout the invoice flips to {status: POSTED, paymentStatus: PAID} and posts the cash/bank payment legs immediately (sales §4.1). POS sales are never left as drafts.
  • kind: 'SalesInvoice' ⇒ writes one Stock(type=OUT) per inventory line and posts DR Accounts-Receivable, DR COGS, CR Revenue, CR Inventory, CR Output-Tax (sales §4.3). Non-inventory items skip stock + COGS/inventory legs.
  • discountValue: 0 — POS has no discount UI; every POS sale is full-price.
  • taxInclusive/taxIds flow through; tax math is the shared engine.

4.2 Tender math (client-side, pos-payment-modal.tsx)

total = amount.afterDiscount. For each mode:

Mode effectiveCash effectiveBank change valid when
CASH cashTendered 0 max(0, cashTendered − total) cashTendered ≥ total
BANK 0 total 0 bankAmount ≥ total
SPLIT splitCash = max(0, total − bankAmount) bankAmount max(0, cashTendered + bankAmount − total) cashTendered + bankAmount ≥ total

shortfall = max(0, total − tendered). The Charge button disables while !isValid or loading; an invalid amount also toasts "Amount tendered is less than total". quickAmounts proposes up to 6 round denominations ≥ total (plus exact total).

Note: paymentMethod collapses BANK and SPLIT both to 'BANK'; the actual cash/bank split reaches the engine via cashAmount/bankAmount, not paymentMethod.

4.3 Catalog / pricing on the card (pos-item-card.tsx)

  • Price shown = orderType === PurchaseInvoice ? item.cost : item.price (sales mode ⇒ item.price).
  • soldBy=WEIGHT ⇒ tapping opens a grams popover (Enter to add); soldBy=ITEM ⇒ tap adds qty 1, accumulating.
  • stockId defaults to the first item.stocks[0]?._id, else item._id (so non-serialized items still cart).
  • Inventory items show Stock: <balance> <uom>; a badge shows ×qty or "N stocks" when multiple serialized rows are carted.

4.4 Status / state — there is no POS state machine

A POS sale has exactly one outcome: a POSTED + PAID SalesInvoice. There is no draft, no hold/park, no resume. The cart is cleared after success (clearItems + setCustomer(null) + onCheckoutUpdate(null)); on failure the cart is kept and an error toast fires (try/retry).

4.5 Side effects

All side effects are the sales engine's, triggered by the single checkoutSalesInvoice call inside withRetryTransaction (atomic): Stock(OUT) rows, GL legs (revenue/COGS/inventory/AR/tax + cash/bank payment), validateBalanced, postInvoice, and a NEW_ORDER emit. See sales §4.1–§4.3. The POS UI itself emits nothing and runs no transaction of its own.


5. Refunds, voids & corrections — not in POS; use Sales Return

The POS module has no refund/void/cancel/reverse capability. To correct a POS sale you use the standard sales surfaces:

  • Refund / partial returnSales Return (SRT) via returnOrder({orderId, items[{itemId, quantity}], returnDate, paymentAccountId}). This writes Stock(type=IN, kind=OrderReturn) (goods back in), reverses revenue/COGS/inventory/tax, posts a settlement leg, and rolls up the source invoice's returnStatus (NOT/PARTIALLY/FULLY_RETURNED). Full detail: sales §5.
  • Void an unwanted sale → because every POS sale is POSTED, there is no "void draft". Deleting/returning an SIV line re-writes the stock ledger and self-corrects on-hand (quantity is never a stored number). The shared order module's delete/return semantics apply (sales §4, purchases §5).

A rebuild that needs in-terminal refund/void must add UI that calls returnOrder (or an order delete) — the backend already supports it; only the POS front-end omits it.


6. Flows

6.1 Process a sale at the terminal (the primary flow)

  1. /pos/terminal loads (guarded; POSLayout). useEffect fetches items, categories, item types; cart cleared on mount.
  2. Cashier searches/filters and taps item cards (POSItemCard) — qty for ITEM, grams popover for WEIGHT — building ICartItems in the cart context. POSCartPanel shows lines, qty steppers, subtotal/total, and a customer selector (optional ⇒ "Walk-in").
  3. CHARGE → opens the payment modal. Cashier picks CASH/BANK/SPLIT, enters tender (or a quick-amount chip), optionally overrides cash/bank accounts (defaulted from POS Settings) and adds a note. Live change/shortfall shown.
  4. ChargehandleCharge builds the payload (§4.1) → checkoutSalesOrder(payload, undefined)checkoutSalesInvoice mutation → sales engine runs the transaction (Stock OUT + GL, POSTED+PAID).
  5. On success: build IPOSReceiptData, clear cart/customer/checkout, open the receipt modal (success tick, line items, total/tendered/change). Cashier picks New Sale (reset) or View Invoice (window.open('/templates/sales-invoice?_id=…&view=true')).
  • Unhappy paths: tender < total ⇒ Charge disabled + toast, no call; checkout throws or returns no _id ⇒ "Checkout failed. Please try again." and cart preserved; non-inventory line ⇒ revenue+AR only (engine); popup blocked on View Invoice ⇒ silently falls through.

6.2 Review POS orders

  1. /pos/order (guarded) → salesInvoicePage({ page, pageSize: DEFAULT_PAGE_SIZE }).
  2. Read-only table: Reference (ref or last-6 of _id), Customer (or "Walk-in"), Date (orderDate), Total (totalAmount), Status (paymentStatus pill — PAID green, else yellow). Prev/Next pagination from totalRecords.
  3. + New Sale links back to /pos/terminal.
  • Note: rows are not clickable; there is no detail/edit/delete here. (The daily-scoped variant of this same list is the Session view.)

7. Admin UI

  • Pages (thin wrappers, POSLayout + ApGuardBuilder): pages/pos/terminal/index.tsx, pages/pos/order/index.tsx. Each getServerSideProps runs isAuth() + haveModuleAccess('/pos/terminal' | '/pos/order', '/select-module').
  • Terminal (modules/pos/terminal/page.tsx): two-pane layout — 68% catalog (search + category tabs + type chips + item grid of POSItemCard), 32% POSCartPanel. Hosts the payment and receipt ApModals. State via useItemState, useCategoryState, useItemTypeState, useCartState.
  • POSItemCard (pos-item-card.tsx): image, name, stock-on-hand, price; tap-to-add (Antd Popover for weight items); qty/stocks badge.
  • POSCartPanel (pos-cart-panel.tsx): customer ApCustomerSelection (filter UserKindTypes.Customer), line list with ± steppers (0 ⇒ remove), Clear, subtotal/total, CHARGE button (disabled on empty cart).
  • POSPaymentModal (pos-payment-modal.tsx): amount-due header, CASH/BANK/SPLIT tabs, big numeric inputs, quick-amount chips, change/shortfall banner, collapsible Accounts (ApAccountSelection, ignoreFormik, BANK_AND_CASH) + Note, Cancel/Charge. Reads defaults from usePOSConfig.
  • POSReceiptModal (pos-receipt-modal.tsx): success state, item lines, totals/tendered/change, View Invoice (print template) / New Sale.
  • Orders list (modules/pos/order/page.tsx): the read-only table + pagination above.
  • Context: POS reuses CartContextProvider/useCartState (cart math, item upsert) and useSalesOrderState (checkoutSalesOrder, salesInvoicePage). There is no modules/pos/context.tsx — POS holds no module-level data context of its own beyond local useState. Validation is the shared order/validation/* on the engine side; the terminal does client tender checks only.
  • Notable UX: split tender, quick-cash chips, weight popover, walk-in default, print via the sales-invoice template, no discount/refund/void controls.

8. Dependencies & integrations

  • Sales engine (modules/inventory/sales + inventory/order) — the entire write path; checkoutSalesInvoice, salesInvoicePage. See sales.
  • Cart (modules/inventory/cart) — cart state, line totals (computeOrderLineTotals), tax option mapping.
  • Item / category / type (modules/item/*) — catalog and filters; item.price/item.cost/item.stocks/stockBalance.
  • Customers (modules/customers) — optional buyer; absence ⇒ "Walk-in".
  • Finance accounts (modules/finance/account) — cash/bank account selection feeding GL legs; defaults from POS Settings. See finance/account.
  • Print template/templates/sales-invoice for the receipt/invoice view.
  • No POS-specific cron/jobs/events/external services. The NEW_ORDER event and any downstream are the sales engine's.

9. Gotchas & project-specific rules

  • POS order = SalesInvoice. Do not model a separate POS order. Everything is Order(kind=SalesInvoice) written by the shared engine. The POS "Orders"/"Session" lists are just salesInvoicePage re-skins.
  • fixed: true freezes price. POS bypasses price-level resolution — the cart price is final. If a rebuild wants POS to honor customer price levels, drop fixed (and accept the engine recomputing the price). (sales §4.4.)
  • Always PAID/POSTED. paymentType: 'CASH' forces immediate posting and paymentStatus: PAID. There are no parked/held/draft POS sales.
  • No refund/void in POS. Corrections go through Sales Return (or order delete) — backend supports it, POS UI doesn't expose it. This is a deliberate gap to flag for any POS-completeness requirement.
  • paymentMethod is lossy — BANK and SPLIT both send 'BANK'; the real split is in cashAmount/bankAmount. Don't infer tender from paymentMethod.
  • Availability is not hard-enforced at checkout — the sales engine's validateStockAvailability is a soft guard (commented-out balance checks); the terminal shows on-hand but does not block selling below zero. Known TODO inherited from the sales engine (sales §4.5).
  • Cart state is a module-level mutable (let itemsList) — shared across remounts; the terminal explicitly clears on mount/unmount/success to avoid leakage between sales.
  • Order rows aren't clickable — no detail/edit from the POS list; the only outbound link is the receipt's print template. Walk-in sales have no customer name ('Walk-in' fallback).