POS Settings & Session — device-local config + the daily sales view

The whole POS settings/session surface reduces to: POS has no backend of its own. "Settings" is a two-field, localStorage-only record of default cash/bank account IDs read by the payment modal; a "Session" is not a stored entity — it is a read-only dashboard that pages today's SalesInvoices (Order(kind=SalesInvoice)) and sums their totals. There is no PosSession/PosSetting collection, no opening float, no cash-count entry, and no variance/reconciliation logic anywhere in the codebase.

Source: BE none (no POS module) · Admin src/modules/pos/settings, src/modules/pos/session, src/hooks/usePOSConfig.ts, src/pages/pos/{settings,session}

Related: order-flows.md (POS terminal checkout & order list) · sales (the SalesInvoice engine the session reads) · finance/account (the cash/bank accounts referenced) · domain overview


1. Purpose & scope

This doc covers the two "operations" tabs of the admin POS module that are not the terminal itself:

  • POS Settings (/pos/settings) — picks a default cash account and default bank account so the cashier doesn't re-select them on every payment. Stored in browser localStorage, per device, never sent to the server.
  • POS Session (/pos/session) — a "Today's Session" dashboard: count of today's sales, total revenue, average sale, and a list of today's transactions. It is a thin re-skin of the Sales Invoice page query scoped to today.

What this explicitly does NOT do (verified absent in code — do not assume otherwise):

  • No backend POS module. grep for PosSession/PosSetting/openingFloat/cashCount/cashUp/reconcile/variance over zerp-be/src returns no POS-related schema, resolver, or service. The only variance/cashUp hits are in unrelated finance/manufacturing accounting and project reports.
  • No cashier session lifecycle. There is no open-session / close-session action, no openedBy/closedBy, no session start/end timestamps. "Session" = the current calendar day, computed client-side from new Date().
  • No opening float / cash drawer count. Nothing prompts for a starting cash amount, and nothing captures a counted closing amount.
  • No cash-up reconciliation / variance. There is no expected cash = opening float + Σ cash sales vs. counted cash comparison, and no variance record. If this is required, it is a greenfield feature — none of it exists today.

Treat every "open/close session", "opening float", "cash count", and "reconciliation/variance" requirement as Not implemented. This doc documents what the code does (a config blob + a daily report), and marks the rest as gaps so a rebuild knows it must be added, not ported.


2. Data model

2.1 POS Settings — localStorage, not a collection

There is no Mongo collection. Settings live in one localStorage key, managed by the usePOSConfig hook (hooks/usePOSConfig.ts):

const STORAGE_KEY = 'zyncount_pos_config';

interface IPOSConfig {
  defaultCashAccountId: string;   // Finance account _id used for CASH legs
  defaultBankAccountId: string;   // Finance account _id used for BANK/transfer legs
}

const defaults: IPOSConfig = { defaultCashAccountId: '', defaultBankAccountId: '' };
Field Type Required? Description
defaultCashAccountId string (Account._id) no (default '') Pre-selects the cash account in the payment modal. FK by value to a Finance Account whose category is BANK_AND_CASH.
defaultBankAccountId string (Account._id) no (default '') Pre-selects the bank/transfer account for BANK and SPLIT payments. Same FK target.

Notes:

  • Persistence is device-local. Stored as JSON under zyncount_pos_config; cleared if the browser storage is cleared. Two cashiers on two machines have independent settings. The UI even says so: "Settings are saved locally on this device." (settings/page.tsx).
  • No tenant/branch scoping — it is not in the DB, so multi-tenancy/branch rules don't apply; isolation is purely "this browser".
  • The hook's load() merges saved JSON over defaults, swallows parse/localStorage errors (returns defaults), and exposes a loaded flag so consumers wait for the first useEffect read before rendering.
// usePOSConfig() returns:
{ config: IPOSConfig, updateConfig: (partial: Partial<IPOSConfig>) => void, loaded: boolean }

2.2 POS Session — no entity; a derived view over orders

A "session" has no schema. The session page reads the same SalesInvoice documents (Order(kind=SalesInvoice) in the orders collection) that the terminal writes, filtered to the current day. The only "model" is the in-memory IPOSReceiptData shape produced by a single checkout (defined in terminal/pos-payment-modal.tsx, consumed by the receipt modal):

export interface IPOSReceiptData {
  invoiceId: string;       // SalesInvoice._id returned by checkout
  items: { name: string; quantity: number; rate: number; amount: number }[];
  total: number;           // cart amount.afterDiscount
  cashTendered: number;    // effective cash for the chosen mode
  change: number;          // tendered − total (CASH/SPLIT only)
  customerName?: string;
}

Fields the session derives client-side from the day's invoices (no stored aggregate):

Derived value Source Computation
Transactions totalRecords from salesInvoicePage server page total for today's filter
Total Revenue salesInvoices[] Σ inv.totalAmount
Avg. Sale both totalRevenue / totalRecords (0 when no records)
Per-row line inv ref (or last-6 of _id), `customer?.name

Enums: there are no POS-specific enums. The session reuses paymentStatus from the sales engine (PAID / pending). Payment mode in the terminal is a local UI union, not a persisted enum: type PaymentMode = 'CASH' | 'BANK' | 'SPLIT'.


3. API surface

POS settings/session add no new GraphQL operations. Everything reuses existing surfaces:

Operation Type Input Returns Used by Permission
salesInvoicePage query { page, pageSize, fromDate?, toDate? } paged SalesInvoice[] + totalRecords session page (today filter) & order list @ApGqlAuthorize on SalesInvoiceResolver; page scoped to user.branchId (see sales §3)
findAccount query { _id } Account settings page (pre-populate saved cash/bank account objects) finance account resolver auth
(settings save) none updateConfig writes localStorage only; no network call
  • The session query passes fromDate = todayStart (00:00:00) and toDate = todayEnd (23:59:59.999) computed from local new Date() (session/page.tsx), pageSize: 100.
  • Saving settings calls no APIhandleSubmitupdateConfig({...})localStorage.setItemtoastSvc.success('POS settings saved').
  • The checkout that produces session rows is checkoutSalesInvoice — documented in order-flows.md and sales §4.1.

4. Business rules & calculations

4.1 Settings save/load

  1. On mount, usePOSConfig reads localStorage[zyncount_pos_config], merges over defaults, sets loaded = true.
  2. The settings page waits for loaded, then findAccount({_id}) for each saved id to hydrate the two ApAccountSelection inputs (both filtered to categories: [BANK_AND_CASH]). Failures fall back to null (.catch(() => null)).
  3. On submit, it persists { defaultCashAccountId: posCashAccount?._id ?? '', defaultBankAccountId: posBankAccount?._id ?? '' }.
  4. The payment modal reads posConfig and, on change, seeds cashAccountId/bankAccountId so the cashier's chosen accounts default in (overridable per sale).

4.2 Session metrics (the only "calculations")

Computed in session/page.tsx, no server aggregation:

totalRevenue = Σ salesInvoices[i].totalAmount
avgSale      = totalRecords > 0 ? totalRevenue / totalRecords : 0

totalAmount itself is a SalesInvoice resolve-field (getAmountWithTax − discountAmount) — see sales §3. The session does not recompute tax/discount; it trusts the invoice total.

4.3 Cash reconciliation / cash-up — Not implemented

There is no opening-float prompt, no drawer count, and no variance computation in the code. For a rebuild, the canonical cash-up math would be:

expectedCash = openingFloat + Σ (cash-tendered − change) over the session's CASH/SPLIT sales
variance     = countedCash − expectedCash      // < 0 short, > 0 over

Today none of openingFloat, countedCash, the session boundary, or variance exist as fields, inputs, or records. Flag this as the primary gap for any "real POS session" requirement. The closest existing signal is per-sale cashTendered/change in the transient IPOSReceiptData, which is not persisted beyond the receipt modal.

4.4 Side effects

  • Settings: none beyond localStorage (no GL, no audit, no notification).
  • Session: read-only; emits nothing. (The sales it lists each posted GL legs at checkout time — see sales §4.3 — but viewing the session writes nothing.)

5. Permissions

Guarded at the page level via getServerSidePropsApGuardBuilder (pages/pos/*):

await guard.isAuth();
await guard.haveModuleAccess('/pos/session', '/select-module');   // (settings uses '/pos')
  • /pos/session requires module access to /pos/session; settings requires access to /pos (note: settings.tsx checks '/pos', not '/pos/settings'); the dashboard checks /pos. Unauthorized → redirect to /select-module.
  • The underlying salesInvoicePage query is @ApGqlAuthorize() and branch-scoped on the resolver; there is no separate POS permission module — POS reuses the sales/account permissions. See permissions-access.

6. Flows

6.1 Configure default POS accounts

  1. Cashier opens /pos/settings (guarded). POSSettingsPage renders inside POSLayout.
  2. usePOSConfig loads localStorage; once loaded, findAccount hydrates the saved cash/bank account objects into two ApAccountSelection inputs (both filtered to BANK_AND_CASH).
  3. Cashier picks accounts → Save SettingsupdateConfig writes JSON to localStorage → toast "POS settings saved".
  4. Next time the payment modal opens, cashAccountId/bankAccountId default to these ids (still overridable inline via the modal's "Accounts" toggle).
  • Unhappy paths: corrupt/empty localStoragedefaults (''/''); a deleted account id → findAccount rejects → input shows empty (no error surfaced). With both ids blank, checkout sends cashAccountId: undefined, letting the sales engine fall back to its own account resolution.

6.2 View today's session

  1. Cashier opens /pos/session (guarded). useEffect calls salesInvoicePage({ page: 1, pageSize: 100, fromDate: todayStart, toDate: todayEnd }).
  2. Page renders three cards (Transactions = totalRecords, Total Revenue = Σ totalAmount, Avg. Sale) and a transaction list (ref, customer-or-"Walk-in", time, total, paymentStatus badge).
  3. "Open Terminal" links to /pos/terminal to start selling (see order-flows.md).
  • Unhappy paths: no sales today → empty state with a "Start a sale →" link; loading → spinner. There is no "close session"/"cash up" action — the flow simply ends.

7. Admin UI

  • Pages (thin wrappers, POSLayout + guard): pages/pos/index.tsx (dashboard with Terminal/Sessions/Orders/Settings cards), pages/pos/settings/index.tsx, pages/pos/session/index.tsx, pages/pos/order/index.tsx, pages/pos/terminal/index.tsx. Each getServerSideProps runs isAuth() + haveModuleAccess(...).
  • Settings screen (modules/pos/settings/page.tsx): Formik (ApForm) with two ApAccountSelection fields (posCashAccount, posBankAccount, filtered BANK_AND_CASH), a single Save Settings button, an ApLoader until initialValues hydrate, and an info banner explaining device-local storage. Drives state through usePOSConfig (not a context.tsx — there is no POS context module) and useAccountState().findAccount.
  • Session screen (modules/pos/session/page.tsx): consumes useSalesOrderState() (the shared sales context — salesInvoicePage, salesInvoices, totalRecords, loading). Header shows the long-form local date; three summary cards; transaction list with status pills; "Open Terminal" CTA.
  • Notable UX: quick-amount chips, split tender, and the per-sale receipt all live in the terminal (see order-flows.md); settings/session are intentionally minimal. No print/PDF on the session view (PDF is per-invoice via the terminal's "View Invoice" → /templates/sales-invoice?_id=...).

8. Dependencies & integrations

  • Sales engine — session and order list both call salesInvoicePage from modules/inventory/sales/context.tsx (useSalesOrderState). The terminal's checkout calls checkoutSalesOrdercheckoutSalesInvoice. See sales.
  • Finance accounts — settings and the payment modal use ApAccountSelection + useAccountState().findAccount from modules/finance/account, filtered to ACCOUNT_CATEGORIES.BANK_AND_CASH. The selected account ids flow into the GL cash/bank legs at checkout. See finance/account.
  • Layout/guardPOSLayout (modules/layout) and ApGuardBuilder (@/guard).
  • No cron/jobs, no events, no external services for settings/session. No S3/mail/zoom involvement.
  • Config module — if device-local settings were promoted to shared DB-backed config, the natural home is the backend config module (see subscription-config domain). Not used today.

9. Gotchas & project-specific rules

  • POS is admin-only — there is no backend POS module. Anyone porting this must not look for zerp-be POS code; it doesn't exist. POS is a thin UI over the existing sales-invoice + finance-account GraphQL.
  • Settings are device-local, not tenant-scoped. Clearing browser storage loses them; they do not roam between machines/users. If multi-cashier shared config is required, it must be moved into a DB-backed config (e.g. the config module) — a rebuild change, not a port.
  • "Session" ≠ a cashier shift. It is literally "all SalesInvoices dated today". No open/close, no operator binding, no float, no count, no reconciliation/variance (§4.3) — all Not implemented. The §4.3 formula is provided as the target for that greenfield work, not as existing behavior.
  • Settings permission gap: pages/pos/settings/index.tsx guards on module path '/pos' (not '/pos/settings'), unlike session/order which guard their own sub-paths. Intentional-or-bug is unclear; documented as-is.
  • cashTendered/change are ephemeral — captured only in the in-memory IPOSReceiptData for the receipt modal; nothing persists tendered/change, so a true cash drawer total cannot be reconstructed from stored data today.
  • Account fallback: blank settings → cashAccountId/bankAccountId: undefined at checkout, deferring to the sales engine's default account resolution; a stale (deleted) saved id silently hydrates to empty.