Sales POS — domain overview

The whole POS domain reduces to one idea: POS is an admin-only front-of-house skin over the existing inventory sales engine. A POS sale is not a new document type — it is an Order(kind=SalesInvoice) created through the same checkoutSalesInvoice GraphQL mutation the regular sales screen uses. The POS module adds no backend: every "POS order" is a Sales Invoice, every cart total is computed by the shared cart context, and every charge writes the same Stock(type=OUT) row + AR/Revenue/COGS/Inventory GL legs + cash/bank payment leg that inventory/sales.md documents.

Source: Admin src/modules/pos/* (terminal, order, session, settings), pages src/pages/pos/*, hook src/hooks/usePOSConfig.ts, reusing src/modules/inventory/cart, src/modules/inventory/sales, src/modules/item · BE none newinventory/sales + inventory/order engine (checkoutSalesInvoice in inventory/sales/sales.resolver.ts).

This _overview.md is the entry point for the Sales-POS domain. It states what POS is (and is not), maps the admin entities, traces how a POS sale becomes an Order(kind=SalesInvoice) + Stock OUT + payment


1. What POS is — and is not

POS module
Is A full-screen admin terminal (/pos/terminal) for fast walk-in retail checkout: tap items into a cart, pick a customer (or stay walk-in), tender cash/bank/split, print a receipt. Plus thin "today's session" and "all orders" list views and a local settings page.
Sits on The inventory sales engine — it calls checkoutSalesInvoice (admin context method checkoutSalesOrder) to create Order(kind=SalesInvoice).
Is NOT A new backend module. There is no pos module in zerp-be and no Pos* GraphQL type (grep of src/schema.gql returns none). There is no PosSession/PosTerminal/PosOrder collection.
Is NOT A separate cart engine. It reuses CartContextProvider (inventory/cart/context.tsx) for line state and totals, and SalesOrderContextProvider (inventory/sales/context.tsx) for the checkout mutation.
Is NOT A true session/shift system. "Sessions" is a read-only view of today's Sales Invoices (date-filtered salesInvoicePage); there is no cash-drawer open/close, no Z-report, no session document. "Orders" is just a paginated salesInvoicePage.

The three implementation plans are the historical record of this build: pos-layout (module shell + nav + route guard), pos-terminal (terminal + cart + payment + receipt), and pos-settings (localStorage default accounts). The plans' explicit goal: "with no backend changes."


2. Entity map

POS owns no collections. Everything it touches already exists in the inventory + finance + user domains. The "entities" of POS are admin-side state objects and the inventory documents they produce.

  POS Terminal (/pos/terminal)
  ┌──────────────────────────────────────────────────────────────────────┐
  │  Item catalog (left)          Cart (right)            Charge → Receipt │
  │  useItemState().items   ──┐                                            │
  │  (IItem, item.stocks,     │   POSItemCard.handleAddSingle              │
  │   item.stockBalance,      └──▶ addOrUpdateItem(ICartItem)              │
  │   item.price/cost)             │                                       │
  │                                ▼                                       │
  │                          CartContext.items: ICartItem[]               │
  │                          (rate, quantity, gross, stockId, soldBy,     │
  │                           taxes, taxInclusive)                         │
  │   ApCustomerSelection ───▶ CartContext.customer: ICustomer (optional) │
  │                                │  calcAmount() → ICartAmount           │
  │                                ▼  (totalAmount, discount, afterDiscount)│
  │                          POSPaymentModal builds checkout payload       │
  └────────────────────────────────┼──────────────────────────────────────┘
                                    │ checkoutSalesOrder(payload)
                                    ▼
            useSalesOrderState().checkoutSalesOrder
                                    │  mutation checkoutSalesInvoice(checkout: SalesInvoiceCheckoutInput!)
                                    ▼
  ┌──────────────────────── BE inventory/sales engine ────────────────────┐
  │  Order(kind=SalesInvoice)  ─┬─▶ OrderItem(s) (kind=SalesInvoiceItem)   │
  │                             ├─▶ Stock(type=OUT, kind=SalesInvoice)     │
  │                             ├─▶ GL: DR AR, DR COGS, CR Revenue,        │
  │                             │       CR Inventory, CR Output-tax        │
  │                             └─▶ GL payment leg (cash/bank) if CASH     │
  └────────────────────────────────────────────────────────────────────────┘

  POS Sessions (/pos/session)   = salesInvoicePage(today)   → read-only list + KPIs
  POS Orders   (/pos/order)     = salesInvoicePage(paged)   → read-only list
  POS Settings (/pos/settings)  = usePOSConfig (localStorage: defaultCash/BankAccountId)

Key admin-side shapes (no DB collection — see ./pos-terminal.md §2 for full tables):

  • ICartItem (inventory/cart/model.tsx) — one cart line: stockId, itemId, rate, quantity, gross, amount, soldBy, taxes, taxInclusive, stock.
  • ICartAmount — computed totals: totalAmount (pre-tax subtotal), discount, afterDiscount (tax-adjusted total − discount), balance.
  • IPOSReceiptData (pos/terminal/pos-payment-modal.tsx) — post-sale receipt: invoiceId, items[], total, cashTendered, change, customerName.
  • IPOSConfig (hooks/usePOSConfig.ts) — localStorage key zyncount_pos_config: { defaultCashAccountId, defaultBankAccountId }. Device-local, not persisted server-side.

3. How a POS sale becomes Order + Stock OUT + payment + GL

This is the single most important flow in the domain. The POS payload is just a SalesInvoiceCheckoutInput with the cart lines mapped into CreateSalesInvoiceItemInput[] and the tender split into cashAmount/ bankAmount. The BE side is identical to a normal sales checkout — see inventory/sales.md §4.1–§4.3 for the authoritative mechanics. The numbered path:

  1. Cart build (admin). Cashier taps POSItemCards → CartContext.addOrUpdateItem accumulates ICartItems; calcAmount() recomputes ICartAmount on every change. Price comes from item.price (sales) via getPrice().
  2. Charge (admin). POSPaymentModal.handleCharge builds the payload and calls checkoutSalesOrder(payload)mutation checkoutSalesInvoice(checkout: SalesInvoiceCheckoutInput!). The payload always sets kind: 'SalesInvoice', paymentType: 'CASH', fixed: true, orderDate: today, and paymentMethod = 'CASH' (cash/split) or 'BANK' (bank-only).
  3. Resolver dispatch (BE). SalesInvoiceResolver.checkoutSalesInvoice (@ApBranchAuth({branchIdRequired:true})) — if checkout.kind === PurchaseInvoice routes to purchaseSvc.checkout (POS never does this), else salesSvc.checkout({ ...checkout, orderId: id }).
  4. Sales checkout (BE, one transaction withRetryTransaction("checkout_sales_invoice")): fixPaymentAmountvalidateStockAvailability (soft no-op, §5 caveat) → create header Order(kind=SalesInvoice) → per line salesItemSvc.addInvoiceItem.
  5. Per inventory ITEM line: resolve price (skipped here — POS lines are fixed, so the entered rate/ amount is frozen) → resolve COGS by item.costingMethod → UOM→base → write Stock(type=OUT, kind=SalesInvoice) and back-link orderItem.stockId. On-hand for item+branch falls by netQuantity. Non-inventory lines write no stock row.
  6. GL legs (per line + header): addBill posts DR Accounts-Receivable + CR Output-tax; per inventory line CR Revenue / DR COGS / CR Inventory. Because POS always sends paymentType: CASH, updateOrderPayment posts the cash/bank payment leg (CREDIT/DEBIT the chosen cashAccountId / bankAccountId).
  7. Post + emit: CASH + POSTED{status: POSTED, paymentStatus: PAID}; validateBalanced(ref); if POSTED → postInvoice(id) flips the ledger to POSTED; emits NEW_ORDER.
  8. Receipt (admin). On the returned Order._id, the modal shows POSReceiptModal with items, total, cash tendered, and change. "View Invoice" opens /templates/sales-invoice?_id=<id>&view=true.

Mnemonic for one POS line: DR AR, DR COGS, CR Revenue, CR Inventory, CR Output-tax, plus a CR/DR cash-or-bank settlement leg (because POS is always paid CASH paymentType). On-hand −qty.

Payment shape (admin → BE)

Admin mode (POSPaymentModal) paymentMethod cashAmount bankAmount Validation
CASH CASH cashTendered 0 cashTendered >= total
BANK BANK 0 total bankAmount >= total
SPLIT CASH max(0, total − bankAmount) bankAmount cash + bank >= total

change (CASH/SPLIT) = max(0, cashTendered + bankAmount − total). cashAccountId / bankAccountId default from usePOSConfig (localStorage), overridable inline. (OrderPaymentMethodTypes = CASH | BANK | STOCK; OrderPaymentType = CASH | CREDIT — POS always uses paymentType: CASH.)


4. Sub-modules

Sub-module Doc Admin path What it is Backs onto
Terminal ./pos-terminal.md modules/pos/terminal/*, page pages/pos/terminal Full-screen checkout: catalog grid + search/category/type filters, cart panel, payment modal (cash/bank/split + change), receipt modal. The only writer in the domain. inventory/cart, inventory/sales (checkoutSalesOrder), item
Orders this overview §5 modules/pos/order/page.tsx, page pages/pos/order Read-only paginated list of all Sales Invoices (salesInvoicePage). Columns: ref, customer (or "Walk-in"), date, total, paymentStatus. inventory/sales
Session this overview §5 modules/pos/session/page.tsx, page pages/pos/session Read-only "today's session": KPI cards (Transactions / Total Revenue / Avg. Sale) + today's transactions, via salesInvoicePage({fromDate: todayStart, toDate: todayEnd}). No shift/drawer lifecycle. inventory/sales
Settings this overview §5 modules/pos/settings/page.tsx, page pages/pos/settings, hooks/usePOSConfig.ts Pick default cash + bank accounts (Formik + ApAccountSelection, filter ACCOUNT_CATEGORIES.BANK_AND_CASH). Saved to localStorage (zyncount_pos_config), read by the payment modal. finance/account, localStorage

5. Orders / Session / Settings (thin views)

These three are deliberately thin — they hold no business logic, only read or store-locally:

  • Orders (order/page.tsx)salesInvoicePage({ page, pageSize: DEFAULT_PAGE_SIZE }); paginated table keyed off useSalesOrderState() state (salesInvoices, totalRecords, loading). Status badge from inv.paymentStatus (PAID green / else yellow). "New Sale" links to /pos/terminal.
  • Session (session/page.tsx) — same salesInvoicePage but date-bounded to local-midnight→23:59:59, pageSize: 100. totalRevenue = Σ inv.totalAmount; Avg. Sale = totalRevenue / totalRecords. Purely a reporting convenience over the day's invoices.
  • Settings (settings/page.tsx + usePOSConfig.ts)usePOSConfig exposes { config, updateConfig, loaded } backed by localStorage[zyncount_pos_config]. The page pre-populates the two ApAccountSelections by findAccount({_id}) once loaded, then updateConfig on submit. The payment modal seeds cashAccountId/bankAccountId from this config (overridable per transaction). Caveat: localStorage means defaults are per-browser/device, not per-user or per-tenant.

6. Module shell, routing & permissions

  • Module selector: POS is registered in select-module.tsx MODULE_REGISTRY under gate key POS_MODULE ("Point of Sale"). Route guard maps '/pos': 'POS_MODULE' in helper/route-guard.ts ROUTE_TO_FEATURE_MAP (covers all /pos/*).
  • Layout & nav: POSLayout (in modules/layout.tsx) + getPOSLayoutNavItems (in components/navbar/config.tsx) provide the sidebar: POS Dashboard, Sessions, Orders, Settings (+ Terminal card on the dashboard).
  • Permissions: every page's getServerSideProps runs ApGuardBuilder.isAuth() + haveModuleAccess('/pos/...', '/select-module'). The plans define UserAccess keys POS_SESSIONS, POS_ORDERS, POS_SETTINGS (each with view/create/update/delete or void actions) used for the nav items. The checkout itself is gated server-side by the sales engine: checkoutSalesInvoice is @ApGqlAuthorize() + @AuditMeta(...) + @ApBranchAuth({branchIdRequired:true}) — see ../../platform/permissions-access.md and inventory/sales.md §3.

7. Domain-wide gotchas

  • POS has no backend. Don't look for a pos BE module, a Pos* GraphQL type, or a pos_* collection — there are none. Every POS write goes through checkoutSalesInvoice. A rebuild only needs the admin UI layer on top of an existing sales engine.
  • POS orders ARE Sales Invoices. The Orders/Session lists are filtered views of the same salesInvoicePage that the inventory sales screen uses. There is no POS-only flag distinguishing a POS-created invoice from a back-office one (both are Order(kind=SalesInvoice)).
  • POS lines are fixed: true. The payload sets fixed: true, so the BE skips price-level resolution and freezes the cart's entered rate/amount. The cart price is item.price, not a customer price level. (Contrast the normal sales screen, which resolves price levels.)
  • paymentType is always CASH. POS never creates a credit/AR-only sale; the payment leg always posts. A "BANK" sale is paymentMethod: BANK but still paymentType: CASH.
  • Availability is NOT hard-enforced at checkout. Inherited from the sales engine — validateStockAvailability is commented out, so POS can drive on-hand negative (the catalog shows item.stockBalance, but charging is not blocked). See inventory/stock.md §4.4 and inventory/sales.md §4.5.
  • Settings are device-local. usePOSConfig is pure localStorage — no server persistence, no multi-tenant scoping, lost on cache clear. The info box on the settings page says as much.
  • branchId must resolve. Checkout requires a branch (@ApBranchAuth({branchIdRequired:true})); it flows from the user's branch context. The POS modal does not surface a branch picker — it relies on the session's active branch.