POS Terminal — full-screen front-of-house checkout
The whole terminal reduces to: tap items into a shared cart, then
checkoutSalesOrderthe cart as anOrder(kind=SalesInvoice). It is a thin, touch-friendly admin screen with zero backend of its own — the catalog comes fromuseItemState, the cart fromuseCartState(inventory/cart), and the charge fromuseSalesOrderState().checkoutSalesOrder(inventory/sales, thecheckoutSalesInvoicemutation). Everything below the "Charge" button is documented ininventory/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
WEIGHTitems); +/- quantity controls; clear cart. Cart state + totals live in the sharedCartContext. - Customer — optional
ApCustomerSelection(walk-in if none). - Tender / payment —
POSPaymentModal: CASH / BANK / SPLIT, cash-tendered with quick-amount buttons, live change/shortfall, optional account override + note, thencheckoutSalesOrder. - Receipt —
POSReceiptModal: 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.mapOrdercan 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 carriestaxes/taxInclusive(POS item cards don't set taxes, so in practice POS lines are untaxed unless seeded upstream). - No backend logic — see
inventory/sales.mdfor 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 − discount — this 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):
handleAddSingle—stock = 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 AntPopover(WeightPopover) — enter grams, press Enter →handleWeightAdd(val)withquantity = (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".cartItemscomes fromfindItems(item._id)(lines whosestock.itemId === item._id). - Stock display: card shows
Stock: <stockBalance> <soldIn>only whenitem.type.isInventoryItem.
4.2 Cart de-dup & quantity math — CartContext (inventory/cart/context.tsx)
- Identity:
addOrUpdateItemupserts onstockId(itemsList.find(i => i.stockId === item.stockId)). Two stock rows of the same item are two cart lines. removeItem(_id)filters byitemId(not stockId) — removing zeroes out all lines of that item.- +/- in cart (
POSCartPanel.handleQtyChange):newQty = max(0, quantity + delta);0⇒removeItem; elseaddOrUpdateItem({ ...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), withtotalitself 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/bankAccountIdseed fromusePOSConfig, overridable via inlineApAccountSelection(filterACCOUNT_CATEGORIES.BANK_AND_CASH).bankAccountIdonly sent whenmode !== '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: trueis the load-bearing flag: the BE sales engine skips price-level resolution and uses the cart'srate/amountas-is.id: undefinedmeans the terminal always creates a new invoice (no in-place edit of an existing order). Seeinventory/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 withApGuardBuilder.isAuth()+haveModuleAccess('/pos/terminal', '/select-module'); the/posprefix maps to thePOS_MODULEgate (route-guard.ts). Render is wrapped inPOSLayout. - The checkout is gated server-side by the sales engine:
checkoutSalesInvoicecarries@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
- Open
/pos/terminal. On mount:fetchCategoryPage,fetchItemTypes; onfilterchange:fetchItemPage(filter)(guarded byitemsFetchedto avoid a "No items found" flash). On unmount:clearItems(). - Search / pick category / pick type → updates
filter(page reset to 1) → re-fetch. - Tap item card(s) → cart accumulates; +/- adjust; optional
ApCustomerSelection(else walk-in). - Click CHARGE
<total>(disabled when cart empty) → opensPOSPaymentModal. - Mode
CASH; pick a quick amount or type cash tendered; change shows green; Charge →checkoutSalesOrder(payload). - On success: cart/customer cleared,
POSReceiptModalshows ref (invoiceId.slice(-8)), items, total, tendered, change. - New Sale (
handleNewSale) resets state; or View Invoice opens/templates/sales-invoice?_id=<id>&view=truein a new tab.
6.2 Bank / split
- BANK: bank input is read-only
= total;change = 0; payloadpaymentMethod: BANK,bankAmount: total,cashAmount: 0. - SPLIT: enter bank portion; cash portion =
total − bankshown; cashier can over-tender cash for change; payloadpaymentMethod: BANK(because not pure CASH),cashAmount: splitCash,bankAmount.
6.3 Weight item
- Tap a
WEIGHTcard → popover → enter grams → Enter → line added withquantity = grams. Card click is otherwise inert.
6.4 Unhappy paths
- Tendered < total:
isValidfalse → 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
stockBalancebut 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.openreturns null; handled silently (no toast).
7. Admin UI
- Page wrapper:
src/pages/pos/terminal/index.tsx→<POSLayout><POSTerminalPage/></POSLayout>+getServerSidePropsguard. - Layout (
pos/terminal/page.tsx): full-height (h-[calc(100vh-60px)]) two-pane — left 68% catalog (header withMdOutlinePointOfSaletitle + search, category pill row, type filter row, item gridgrid-cols-3 xl:grid-cols-4), right 32%POSCartPanel. TwoApModals: payment + receipt (width480px). POSItemCard— image (ApImageFill), name, stock line (inventory items only), price; tap-to-add or weight popover; qty/stocks badge.POSCartPanel—ApCustomerSelection(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 fromuseSalesOrderState().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(checkoutSalesOrder→checkoutSalesInvoice),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=truefor receipts. No hardware/printer/cash-drawer integration. No events/cron in POS itself (the BE emitsNEW_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 mapsorder.items→ICartItem[]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 tomapOrder. - Always a new invoice.
checkoutSalesOrder(payload, undefined)passesid = undefined, so POS never edits an existing order in place. - Cart identity is
stockId, removal isitemId.addOrUpdateItemupserts onstockIdbutremoveItemfilters onitemId— a quirk to preserve if porting (qty→0 removes the whole item, not just one stock line). - POS lines are
fixedand (usually) untaxed.fixed: truefreezes the price (no price-level lookup); POS item cards don't attach taxes, sotaxIds/taxIdare typically empty andafterDiscount === 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. orderDateis date-only. Payment modal sendsDateUtils.todayDateOnly()(an earlier plan draft usedDate.now()); Session/Orders displayinv.orderDate.- Availability not enforced (inherited). The catalog shows
stockBalancebut charging is never blocked on insufficient stock — the sales engine's guard is commented out (inventory/sales.md§4.5). window.openmay be popup-blocked on "View Invoice" — handled silently, no fallback toast.