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 acheckoutSalesInvoicepayload (kind: 'SalesInvoice',fixed: true), andcheckoutSalesOrderpasses it straight through to the same sales engine that writesStock(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-readssalesInvoicePage, 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.
grepfor POS order types inschema.gqlfinds nothing; the terminal writes ordinaryOrder(kind=SalesInvoice)rows. - No POS refund / void / cancel / return.
grep -niE "refund|void|cancel|return|reverse"overmodules/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/orderare 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 | WEIGHT — WEIGHT 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(addOrUpdateItemupserts bystockId;removeItemfilters byitemId). The cart context is module-level mutable (let itemsList) — it persists across remounts within the session and is wiped byclearItems()(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)×totalAmount2.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 usesThe 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 cartrate/amountas-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 oneStock(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/taxIdsflow 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:
paymentMethodcollapses BANK and SPLIT both to'BANK'; the actual cash/bank split reaches the engine viacashAmount/bankAmount, notpaymentMethod.
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.stockIddefaults to the firstitem.stocks[0]?._id, elseitem._id(so non-serialized items still cart).- Inventory items show
Stock: <balance> <uom>; a badge shows×qtyor "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 return → Sales Return (SRT) via
returnOrder({orderId, items[{itemId, quantity}], returnDate, paymentAccountId}). This writesStock(type=IN, kind=OrderReturn)(goods back in), reverses revenue/COGS/inventory/tax, posts a settlement leg, and rolls up the source invoice'sreturnStatus(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)
/pos/terminalloads (guarded;POSLayout).useEffectfetches items, categories, item types; cart cleared on mount.- Cashier searches/filters and taps item cards (
POSItemCard) — qty forITEM, grams popover forWEIGHT— buildingICartItems in the cart context.POSCartPanelshows lines, qty steppers, subtotal/total, and a customer selector (optional ⇒ "Walk-in"). - 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. - Charge →
handleChargebuilds the payload (§4.1) →checkoutSalesOrder(payload, undefined)→checkoutSalesInvoicemutation → sales engine runs the transaction (Stock OUT + GL, POSTED+PAID). - 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
/pos/order(guarded) →salesInvoicePage({ page, pageSize: DEFAULT_PAGE_SIZE }).- Read-only table: Reference (
refor last-6 of_id), Customer (or "Walk-in"), Date (orderDate), Total (totalAmount), Status (paymentStatuspill —PAIDgreen, else yellow). Prev/Next pagination fromtotalRecords. - + 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. EachgetServerSidePropsrunsisAuth()+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 ofPOSItemCard), 32%POSCartPanel. Hosts the payment and receiptApModals. State viauseItemState,useCategoryState,useItemTypeState,useCartState. POSItemCard(pos-item-card.tsx): image, name, stock-on-hand, price; tap-to-add (AntdPopoverfor weight items); qty/stocks badge.POSCartPanel(pos-cart-panel.tsx): customerApCustomerSelection(filterUserKindTypes.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 fromusePOSConfig.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) anduseSalesOrderState(checkoutSalesOrder,salesInvoicePage). There is nomodules/pos/context.tsx— POS holds no module-level data context of its own beyond localuseState. Validation is the sharedorder/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-invoicefor the receipt/invoice view. - No POS-specific cron/jobs/events/external services. The
NEW_ORDERevent 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 justsalesInvoicePagere-skins. fixed: truefreezes price. POS bypasses price-level resolution — the cart price is final. If a rebuild wants POS to honor customer price levels, dropfixed(and accept the engine recomputing the price). (sales §4.4.)- Always PAID/POSTED.
paymentType: 'CASH'forces immediate posting andpaymentStatus: 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.
paymentMethodis lossy — BANK and SPLIT both send'BANK'; the real split is incashAmount/bankAmount. Don't infer tender frompaymentMethod.- Availability is not hard-enforced at checkout — the sales engine's
validateStockAvailabilityis 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).