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 samecheckoutSalesInvoiceGraphQL 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 sameStock(type=OUT)row + AR/Revenue/COGS/Inventory GL legs + cash/bank payment leg thatinventory/sales.mddocuments.
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 new — inventory/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
- GL, and lists the sub-modules. The terminal UI/flow detail lives in
./pos-terminal.md; the underlying movement/GL mechanics live ininventory/sales.mdandinventory/stock.md.
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: (module shell + nav + route guard), pos-layout (terminal + cart + payment + receipt), and pos-terminal (localStorage default accounts). The plans' explicit goal: "with no backend changes."pos-settings
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 keyzyncount_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:
- Cart build (admin). Cashier taps
POSItemCards →CartContext.addOrUpdateItemaccumulatesICartItems;calcAmount()recomputesICartAmounton every change. Price comes fromitem.price(sales) viagetPrice(). - Charge (admin).
POSPaymentModal.handleChargebuilds the payload and callscheckoutSalesOrder(payload)→mutation checkoutSalesInvoice(checkout: SalesInvoiceCheckoutInput!). The payload always setskind: 'SalesInvoice',paymentType: 'CASH',fixed: true,orderDate: today, andpaymentMethod='CASH'(cash/split) or'BANK'(bank-only). - Resolver dispatch (BE).
SalesInvoiceResolver.checkoutSalesInvoice(@ApBranchAuth({branchIdRequired:true})) — ifcheckout.kind === PurchaseInvoiceroutes topurchaseSvc.checkout(POS never does this), elsesalesSvc.checkout({ ...checkout, orderId: id }). - Sales checkout (BE, one transaction
withRetryTransaction("checkout_sales_invoice")):fixPaymentAmount→validateStockAvailability(soft no-op, §5 caveat) → create headerOrder(kind=SalesInvoice)→ per linesalesItemSvc.addInvoiceItem. - Per inventory ITEM line: resolve price (skipped here — POS lines are
fixed, so the enteredrate/amountis frozen) → resolve COGS byitem.costingMethod→ UOM→base → writeStock(type=OUT, kind=SalesInvoice)and back-linkorderItem.stockId. On-hand for item+branch falls bynetQuantity. Non-inventory lines write no stock row. - GL legs (per line + header):
addBillposts DR Accounts-Receivable + CR Output-tax; per inventory line CR Revenue / DR COGS / CR Inventory. Because POS always sendspaymentType: CASH,updateOrderPaymentposts the cash/bank payment leg (CREDIT/DEBIT the chosencashAccountId/bankAccountId). - Post + emit:
CASH+POSTED⇒{status: POSTED, paymentStatus: PAID};validateBalanced(ref); if POSTED →postInvoice(id)flips the ledger to POSTED; emitsNEW_ORDER. - Receipt (admin). On the returned
Order._id, the modal showsPOSReceiptModalwith 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
CASHpaymentType). 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 offuseSalesOrderState()state (salesInvoices,totalRecords,loading). Status badge frominv.paymentStatus(PAID green / else yellow). "New Sale" links to/pos/terminal. - Session (
session/page.tsx) — samesalesInvoicePagebut 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) —usePOSConfigexposes{ config, updateConfig, loaded }backed bylocalStorage[zyncount_pos_config]. The page pre-populates the twoApAccountSelections byfindAccount({_id})onceloaded, thenupdateConfigon submit. The payment modal seedscashAccountId/bankAccountIdfrom 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.tsxMODULE_REGISTRYunder gate keyPOS_MODULE("Point of Sale"). Route guard maps'/pos': 'POS_MODULE'inhelper/route-guard.tsROUTE_TO_FEATURE_MAP(covers all/pos/*). - Layout & nav:
POSLayout(inmodules/layout.tsx) +getPOSLayoutNavItems(incomponents/navbar/config.tsx) provide the sidebar: POS Dashboard, Sessions, Orders, Settings (+ Terminal card on the dashboard). - Permissions: every page's
getServerSidePropsrunsApGuardBuilder.isAuth()+haveModuleAccess('/pos/...', '/select-module'). The plans define UserAccess keysPOS_SESSIONS,POS_ORDERS,POS_SETTINGS(each withview/create/update/deleteorvoidactions) used for the nav items. The checkout itself is gated server-side by the sales engine:checkoutSalesInvoiceis@ApGqlAuthorize()+@AuditMeta(...)+@ApBranchAuth({branchIdRequired:true})— see../../platform/permissions-access.mdandinventory/sales.md§3.
7. Domain-wide gotchas
- POS has no backend. Don't look for a
posBE module, aPos*GraphQL type, or apos_*collection — there are none. Every POS write goes throughcheckoutSalesInvoice. 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
salesInvoicePagethat the inventory sales screen uses. There is no POS-only flag distinguishing a POS-created invoice from a back-office one (both areOrder(kind=SalesInvoice)). - POS lines are
fixed: true. The payload setsfixed: true, so the BE skips price-level resolution and freezes the cart's enteredrate/amount. The cart price isitem.price, not a customer price level. (Contrast the normal sales screen, which resolves price levels.) paymentTypeis alwaysCASH. POS never creates a credit/AR-only sale; the payment leg always posts. A "BANK" sale ispaymentMethod: BANKbut stillpaymentType: CASH.- Availability is NOT hard-enforced at checkout. Inherited from the sales engine —
validateStockAvailabilityis commented out, so POS can drive on-hand negative (the catalog showsitem.stockBalance, but charging is not blocked). Seeinventory/stock.md§4.4 andinventory/sales.md§4.5. - Settings are device-local.
usePOSConfigis pure localStorage — no server persistence, no multi-tenant scoping, lost on cache clear. The info box on the settings page says as much. branchIdmust 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.