POS Terminal — full-screen front-of-house checkout

The whole terminal reduces to: tap items into a shared cart, then checkoutSalesOrder the cart as an Order(kind=SalesInvoice). It is a thin, touch-friendly admin screen with zero backend of its own — the catalog comes from useItemState, the cart from useCartState (inventory/cart), and the charge from useSalesOrderState().checkoutSalesOrder (inventory/sales, the checkoutSalesInvoice mutation). Everything below the "Charge" button is documented in inventory/sales.md.

Source: Admin src/modules/pos/terminal/ (page.tsx, pos-item-card.tsx, pos-cart-panel.tsx, pos-payment-modal.tsx, pos-receipt-modal.tsx), page src/pages/pos/terminal/index.tsx, hook src/hooks/usePOSConfig.ts · reuses src/modules/inventory/cart (context+model), src/modules/inventory/sales/context.tsx, src/modules/item, src/modules/customers, src/modules/finance/account · BE none new (checkoutSalesInvoice).

Related: ./_overview.md (domain map + the full sale→GL flow) · inventory/sales.md (checkout/GL mechanics) · inventory/stock.md (Stock OUT) · plan pos-terminal.


1. Purpose & scope

The terminal at /pos/terminal is responsible for:

  • Item lookup — a paginated catalog grid (useItemState().fetchItemPage) with a keyword search box, category pills (useCategoryState), and an item-type filter row (useItemTypeState).
  • Cart building — tap an item card to add qty 1 (or enter grams for WEIGHT items); +/- quantity controls; clear cart. Cart state + totals live in the shared CartContext.
  • Customer — optional ApCustomerSelection (walk-in if none).
  • Tender / paymentPOSPaymentModal: CASH / BANK / SPLIT, cash-tendered with quick-amount buttons, live change/shortfall, optional account override + note, then checkoutSalesOrder.
  • ReceiptPOSReceiptModal: success summary + "View Invoice" (opens the sales-invoice print template) + "New Sale" (resets the cart).

What it explicitly does NOT do:

  • No hold/recall (parked sales). Not implemented — there is no save-draft/park button in the terminal; the only persistence is a completed checkout. (CartContext.mapOrder can load an existing order's lines into the cart, but the terminal never calls it — see §9.)
  • No discounts/tax entry UI in the terminal — the payload hardcodes discountValue: 0, discountValueType: 'AMOUNT'; tax flows only if the cart line already carries taxes/taxInclusive (POS item cards don't set taxes, so in practice POS lines are untaxed unless seeded upstream).
  • No backend logic — see inventory/sales.md for everything past "Charge".

2. Data model (admin-side; no DB collection)

POS has no collection. These are the in-memory shapes the terminal manipulates.

2.1 ICartItem (inventory/cart/model.tsx)

One cart line. The terminal sets the starred fields; the rest are optional/loaded.

field type set by terminal? description
stockId string First stock row's _id (stock?._id) or falls back to item._id. Cart de-dupes on stockId.
itemId string | undefined The product _id. Used by removeItem / findItem.
stock IStock | null item.stocks?.[0] if present, else null.
rate number | string Unit price = getPrice() (item.price for sales).
quantity number Units (or grams for WEIGHT). The net qty sent as netQuantity.
gross number Gross qty (terminal sets gross = quantity).
amount number rate × quantity.
soldBy string item.soldBy (ITEM / QUANTITY / WEIGHT).
name string item.name (for cart + receipt display).
wasteQuantity number default 0.
tax / taxes ITaxation / ITaxation[] not set by POS cards; mapped into the payload if present.
taxInclusive boolean default false.
customer, item, costCenter, analysisCode, class, store, branchId various optional passthrough.

2.2 ICartAmount (inventory/cart/model.tsx) — computed by CartContext.calcAmount()

field meaning
totalAmount Pre-tax subtotal = Σ (rate × quantity).
discount Resolved discount (AMOUNT or PERCENTAGE of subtotal). POS sends 0.
afterDiscount taxAdjustedTotal − discountthis is the "Total" / amount due the cart and modal display.
balance afterDiscount − (cashAmount + bankAmount).

taxAdjustedTotal comes from computeOrderLineTotals(lines) (inventory/order/order-line): VAT adds, WHT deducts. With untaxed POS lines, afterDiscount === totalAmount.

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

export interface IPOSReceiptData {
  invoiceId: string;     // returned Order._id
  items: { name: string; quantity: number; rate: number; amount: number }[];
  total: number;         // amount.afterDiscount at charge time
  cashTendered: number;  // effective cash (CASH: tendered; SPLIT: cash portion; BANK: 0)
  change: number;
  customerName?: string;
}

2.4 IPOSConfig (hooks/usePOSConfig.ts) — localStorage zyncount_pos_config

interface IPOSConfig { defaultCashAccountId: string; defaultBankAccountId: string; }
// usePOSConfig() → { config, updateConfig(partial), loaded }

3. API surface

The terminal makes no direct GraphQL calls. It goes through context methods only (per the use<Feature>State rule). The one network write is the checkout mutation.

Used method Source context Underlying op Notes
fetchItemPage(filter) useItemState (item/context) itemPage Catalog grid (filter: page,pageSize,keyword,category,type). Sets items, initLoading.
fetchCategoryPage(...) useCategoryState (item/category/context) itemCategoryPage Category pills (loaded once, pageSize:100).
fetchItemTypes() useItemTypeState (item/type/context) item-types query Type filter row.
addOrUpdateItem / removeItem / clearItems / findItem / findItems useCartState (inventory/cart/context) — (local) Cart mutation + totals.
setCustomer useCartState — (local) From ApCustomerSelection.
checkoutSalesOrder(payload, id?) useSalesOrderState (inventory/sales/context) mutation checkoutSalesInvoice($id, $checkout: SalesInvoiceCheckoutInput!) The sole write. Returns Order.
usePOSConfig() hook localStorage Seeds cash/bank account ids.

3.1 SalesInvoiceCheckoutInput (the payload — src/schema.gql)

The exact GraphQL input the POS payload must satisfy:

input SalesInvoiceCheckoutInput {
  branchId: ID
  ref: ID
  customerId: ID
  paymentMethod: OrderPaymentMethodTypes   # CASH | BANK | STOCK
  paymentType: OrderPaymentType            # CASH | CREDIT  (POS always CASH)
  note: String
  orderDate: Float
  exchangeRate: Float = 1
  discountValueType: OrderDiscountValueType # AMOUNT | PERCENTAGE
  discountValue: Float
  discountAmount: Float
  cashAmount: Float
  bankAmount: Float
  stockPaymentAmount: Float
  stockPaymentItemId: String
  workflowId: String
  kind: OrderKindTypes                      # POS sends SalesInvoice
  items: [CreateSalesInvoiceItemInput!]!
  paymentMethods: [OrderPaymentMethodInput!]
  fixed: Boolean                            # POS sends true
  cashAccountId: String = ""
  bankAccountId: String = ""
  status: OrderStatusTypes                  # SAVED | POSTED
}

POS-built line (CreateSalesInvoiceItemInput): { stockId, itemId, netQuantity, grossQuantity, amount, rate, wasteQuantity, taxIds, taxId, taxInclusive }.


4. Business rules & calculations (terminal-side)

4.1 Adding an item — POSItemCard

const getPrice = () =>
  (orderType === OrderKindTypes.PurchaseInvoice ? item?.cost : item?.price) || 0;  // POS: orderType=SalesInvoice → item.price
  • Tap (non-weight): handleAddSinglestock = item.stocks?.[0] ?? null; newQty = (existing?.quantity ?? 0) + 1; addOrUpdateItem({ quantity, gross: newQty, rate: price, itemId, name, soldBy, amount: price*newQty, stock, stockId: stock?._id ?? item._id }).
  • Weight items (soldBy === WEIGHT): the card is wrapped in an Ant Popover (WeightPopover) — enter grams, press Enter → handleWeightAdd(val) with quantity = (existing ?? 0) + val. The card click is disabled for weight items (entry only via the popover).
  • Badge: cartItems.length > 1"N stocks"; else if in cart ⇒ "×qty". cartItems comes from findItems(item._id) (lines whose stock.itemId === item._id).
  • Stock display: card shows Stock: <stockBalance> <soldIn> only when item.type.isInventoryItem.

4.2 Cart de-dup & quantity math — CartContext (inventory/cart/context.tsx)

  • Identity: addOrUpdateItem upserts on stockId (itemsList.find(i => i.stockId === item.stockId)). Two stock rows of the same item are two cart lines.
  • removeItem(_id) filters by itemId (not stockId) — removing zeroes out all lines of that item.
  • +/- in cart (POSCartPanel.handleQtyChange): newQty = max(0, quantity + delta); 0removeItem; else addOrUpdateItem({ ...item, quantity: newQty, gross: newQty, amount: rate*newQty }).
  • calcAmount (re-runs on [items, checkoutInfo]): totalAmount = Σ rate*quantity; taxAdjustedTotal = computeOrderLineTotals(lines).total; afterDiscount = taxAdjustedTotal − discount.

4.3 Payment math — POSPaymentModal

total = amount.afterDiscount. Mode ∈ CASH | BANK | SPLIT:

splitCash = max(0, total − bankAmount)
change   = CASH:  max(0, cashTendered − total)
           BANK:  0
           SPLIT: max(0, cashTendered + bankAmount − total)
isValid  = CASH:  cashTendered >= total
           BANK:  bankAmount   >= total
           SPLIT: cashTendered + bankAmount >= total
shortfall= max(0, total − tendered)            // shown red when !isValid
  • Quick amounts: smallest of [1,5,10,20,50,100,200,500,1000,2000,5000,10000] that are >= total (up to 5), with total itself unshifted to the front; sliced to 6.
  • Effective tender mapped to payload: effectiveCash = CASH→cashTendered, SPLIT→splitCash, BANK→0; effectiveBank = BANK→total, SPLIT→bankAmount, CASH→0.
  • Accounts: cashAccountId/bankAccountId seed from usePOSConfig, overridable via inline ApAccountSelection (filter ACCOUNT_CATEGORIES.BANK_AND_CASH). bankAccountId only sent when mode !== 'CASH'.

4.4 The checkout payload (verbatim shape — pos-payment-modal.tsx)

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',   // SPLIT → BANK
  paymentType: 'CASH',                                // always
  customerId: customer?._id,
  note: note || '', orderDate: DateUtils.todayDateOnly(),
  fixed: true,                                        // freezes price (skips price-level)
  cashAmount: effectiveCash, bankAmount: effectiveBank,
  cashAccountId: cashAccountId || undefined,
  bankAccountId: mode !== 'CASH' ? (bankAccountId || undefined) : undefined,
  stockPaymentAmount: 0,
  kind: 'SalesInvoice',
  discountValueType: 'AMOUNT', discountValue: 0,
};
await checkoutSalesOrder(payload, undefined);         // id = undefined → always a NEW invoice

fixed: true is the load-bearing flag: the BE sales engine skips price-level resolution and uses the cart's rate/amount as-is. id: undefined means the terminal always creates a new invoice (no in-place edit of an existing order). See inventory/sales.md §4.1.

4.5 What happens after checkoutSalesOrder (BE — summary)

Identical to a normal sales checkout — full detail in inventory/sales.md §4 and ./_overview.md §3: one transaction creates Order(kind=SalesInvoice) + OrderItems, writes Stock(type=OUT) per inventory line (on-hand −qty), posts DR AR / DR COGS / CR Revenue / CR Inventory / CR Output-tax + the cash/bank payment leg, asserts balanced, posts the invoice, emits NEW_ORDER. On a truthy result._id the modal builds IPOSReceiptData, clears cart + customer (clearItems(), setCustomer(null), onCheckoutUpdate(null)), and fires onSuccess. Any thrown error → toastSvc.error('Checkout failed. Please try again.').


5. Permissions

  • The page wrapper (pages/pos/terminal/index.tsx) gates with ApGuardBuilder.isAuth() + haveModuleAccess('/pos/terminal', '/select-module'); the /pos prefix maps to the POS_MODULE gate (route-guard.ts). Render is wrapped in POSLayout.
  • The checkout is gated server-side by the sales engine: checkoutSalesInvoice carries @ApGqlAuthorize() + @AuditMeta(...) + @ApBranchAuth({ branchIdRequired: true }) (inventory/sales/sales.resolver.ts). So the terminal additionally requires a resolved branch context and sales-invoice create permission — see ../../platform/permissions-access.md.
  • No per-action CASL is enforced inside the terminal component itself; access is gate-level + the BE mutation guard.

6. Flows

6.1 Happy path — cash sale

  1. Open /pos/terminal. On mount: fetchCategoryPage, fetchItemTypes; on filter change: fetchItemPage(filter) (guarded by itemsFetched to avoid a "No items found" flash). On unmount: clearItems().
  2. Search / pick category / pick type → updates filter (page reset to 1) → re-fetch.
  3. Tap item card(s) → cart accumulates; +/- adjust; optional ApCustomerSelection (else walk-in).
  4. Click CHARGE <total> (disabled when cart empty) → opens POSPaymentModal.
  5. Mode CASH; pick a quick amount or type cash tendered; change shows green; ChargecheckoutSalesOrder(payload).
  6. On success: cart/customer cleared, POSReceiptModal shows ref (invoiceId.slice(-8)), items, total, tendered, change.
  7. New Sale (handleNewSale) resets state; or View Invoice opens /templates/sales-invoice?_id=<id>&view=true in a new tab.

6.2 Bank / split

  • BANK: bank input is read-only = total; change = 0; payload paymentMethod: BANK, bankAmount: total, cashAmount: 0.
  • SPLIT: enter bank portion; cash portion = total − bank shown; cashier can over-tender cash for change; payload paymentMethod: BANK (because not pure CASH), cashAmount: splitCash, bankAmount.

6.3 Weight item

  • Tap a WEIGHT card → popover → enter grams → Enter → line added with quantity = grams. Card click is otherwise inert.

6.4 Unhappy paths

  • Tendered < total: isValid false → red "Shortfall −", Charge disabled; if forced, toastSvc.error('Amount tendered is less than total').
  • Empty cart: CHARGE button disabled.
  • Checkout throws / returns no _id: toastSvc.error('Checkout failed. Please try again.'); cart retained.
  • Out-of-stock: not blocked — the catalog shows stockBalance but the BE availability guard is a no-op; the sale posts and on-hand can go negative (inventory/stock.md §4.4).
  • Popup blocked (View Invoice): window.open returns null; handled silently (no toast).

7. Admin UI

  • Page wrapper: src/pages/pos/terminal/index.tsx<POSLayout><POSTerminalPage/></POSLayout> + getServerSideProps guard.
  • Layout (pos/terminal/page.tsx): full-height (h-[calc(100vh-60px)]) two-pane — left 68% catalog (header with MdOutlinePointOfSale title + search, category pill row, type filter row, item grid grid-cols-3 xl:grid-cols-4), right 32% POSCartPanel. Two ApModals: payment + receipt (width 480px).
  • POSItemCard — image (ApImageFill), name, stock line (inventory items only), price; tap-to-add or weight popover; qty/stocks badge.
  • POSCartPanelApCustomerSelection (kinds [Customer], ignoreFormik), line list with +/-, Clear, Subtotal + Total, big CHARGE button.
  • POSPaymentModal — dark amount-due header + customer, CASH/BANK/SPLIT pills, big numeric inputs, quick-amount buttons, change/shortfall card, collapsible Accounts (ApAccountSelection) + Note, Cancel / "Charge <total>" (loading state from useSalesOrderState().loading).
  • POSReceiptModal — green check, "Sale Complete!", ref, item list, totals (Total / Cash Tendered / Change), "View Invoice" + "New Sale".
  • State sources (no local data layer): useItemState, useCategoryState, useItemTypeState, useCartState, useSalesOrderState, useConfigState, usePOSConfig. The component is "dumb" per the context-owns-state convention — it never calls Apollo directly.

8. Dependencies & integrations

  • Consumes contexts: inventory/cart (cart + totals), inventory/sales (checkoutSalesOrdercheckoutSalesInvoice), item / item/category / item/type (catalog), customers (selection), finance/account (ApAccountSelection, ACCOUNT_CATEGORIES.BANK_AND_CASH), config (useConfigState), usePOSConfig (localStorage defaults), services.toastSvc, helper.toCurrency, DateUtils.
  • Writes (transitively): one Order(kind=SalesInvoice) + OrderItems + Stock(OUT) + GL legs per charge — all in the BE sales/order/stock modules. No POS-specific persistence.
  • External: opens the print template route /templates/sales-invoice?_id=&view=true for receipts. No hardware/printer/cash-drawer integration. No events/cron in POS itself (the BE emits NEW_ORDER).

9. Gotchas & project-specific rules

  • No hold/recall (parked sales). Despite the brief, the terminal has no park/save-draft/recall feature. The only flow is build-and-charge. CartContext.mapOrder(order) exists and would hydrate the cart from an existing order (it maps order.itemsICartItem[] and sets customer/discount), but nothing in the POS terminal calls it — it's used by the non-POS cart/checkout screens. A rebuild wanting recall would wire a "saved order" list to mapOrder.
  • Always a new invoice. checkoutSalesOrder(payload, undefined) passes id = undefined, so POS never edits an existing order in place.
  • Cart identity is stockId, removal is itemId. addOrUpdateItem upserts on stockId but removeItem filters on itemId — a quirk to preserve if porting (qty→0 removes the whole item, not just one stock line).
  • POS lines are fixed and (usually) untaxed. fixed: true freezes the price (no price-level lookup); POS item cards don't attach taxes, so taxIds/taxId are typically empty and afterDiscount === totalAmount.
  • Settings live in localStorage. Default cash/bank accounts (zyncount_pos_config) are per-device, not server-persisted; clearing browser storage loses them. Account override in the modal is per-transaction.
  • orderDate is date-only. Payment modal sends DateUtils.todayDateOnly() (an earlier plan draft used Date.now()); Session/Orders display inv.orderDate.
  • Availability not enforced (inherited). The catalog shows stockBalance but charging is never blocked on insufficient stock — the sales engine's guard is commented out (inventory/sales.md §4.5).
  • window.open may be popup-blocked on "View Invoice" — handled silently, no fallback toast.