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'sSalesInvoices (Order(kind=SalesInvoice)) and sums their totals. There is noPosSession/PosSettingcollection, 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 browserlocalStorage, 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.
grepforPosSession/PosSetting/openingFloat/cashCount/cashUp/reconcile/varianceoverzerp-be/srcreturns no POS-related schema, resolver, or service. The onlyvariance/cashUphits 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 fromnew 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 salesvs.counted cashcomparison, 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 overdefaults, swallows parse/localStorageerrors (returnsdefaults), and exposes aloadedflag so consumers wait for the firstuseEffectread 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)andtoDate = todayEnd (23:59:59.999)computed from localnew Date()(session/page.tsx),pageSize: 100. - Saving settings calls no API —
handleSubmit→updateConfig({...})→localStorage.setItem→toastSvc.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
- On mount,
usePOSConfigreadslocalStorage[zyncount_pos_config], merges overdefaults, setsloaded = true. - The settings page waits for
loaded, thenfindAccount({_id})for each saved id to hydrate the twoApAccountSelectioninputs (both filtered tocategories: [BANK_AND_CASH]). Failures fall back tonull(.catch(() => null)). - On submit, it persists
{ defaultCashAccountId: posCashAccount?._id ?? '', defaultBankAccountId: posBankAccount?._id ?? '' }. - The payment modal reads
posConfigand, on change, seedscashAccountId/bankAccountIdso 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 getServerSideProps → ApGuardBuilder (pages/pos/*):
await guard.isAuth();
await guard.haveModuleAccess('/pos/session', '/select-module'); // (settings uses '/pos')/pos/sessionrequires module access to/pos/session; settings requires access to/pos(note:settings.tsxchecks'/pos', not'/pos/settings'); the dashboard checks/pos. Unauthorized → redirect to/select-module.- The underlying
salesInvoicePagequery 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
- Cashier opens
/pos/settings(guarded).POSSettingsPagerenders insidePOSLayout. usePOSConfigloadslocalStorage; onceloaded,findAccounthydrates the saved cash/bank account objects into twoApAccountSelectioninputs (both filtered toBANK_AND_CASH).- Cashier picks accounts → Save Settings →
updateConfigwrites JSON tolocalStorage→ toast "POS settings saved". - Next time the payment modal opens,
cashAccountId/bankAccountIddefault to these ids (still overridable inline via the modal's "Accounts" toggle).
- Unhappy paths: corrupt/empty
localStorage→defaults(''/''); a deleted account id →findAccountrejects → input shows empty (no error surfaced). With both ids blank, checkout sendscashAccountId: undefined, letting the sales engine fall back to its own account resolution.
6.2 View today's session
- Cashier opens
/pos/session(guarded).useEffectcallssalesInvoicePage({ page: 1, pageSize: 100, fromDate: todayStart, toDate: todayEnd }). - Page renders three cards (Transactions =
totalRecords, Total Revenue =Σ totalAmount, Avg. Sale) and a transaction list (ref, customer-or-"Walk-in", time, total,paymentStatusbadge). - "Open Terminal" links to
/pos/terminalto 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. EachgetServerSidePropsrunsisAuth()+haveModuleAccess(...). - Settings screen (
modules/pos/settings/page.tsx): Formik (ApForm) with twoApAccountSelectionfields (posCashAccount,posBankAccount, filteredBANK_AND_CASH), a single Save Settings button, anApLoaderuntilinitialValueshydrate, and an info banner explaining device-local storage. Drives state throughusePOSConfig(not acontext.tsx— there is no POS context module) anduseAccountState().findAccount. - Session screen (
modules/pos/session/page.tsx): consumesuseSalesOrderState()(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
salesInvoicePagefrommodules/inventory/sales/context.tsx(useSalesOrderState). The terminal's checkout callscheckoutSalesOrder→checkoutSalesInvoice. See sales. - Finance accounts — settings and the payment modal use
ApAccountSelection+useAccountState().findAccountfrommodules/finance/account, filtered toACCOUNT_CATEGORIES.BANK_AND_CASH. The selected account ids flow into the GL cash/bank legs at checkout. See finance/account. - Layout/guard —
POSLayout(modules/layout) andApGuardBuilder(@/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
configmodule (seesubscription-configdomain). 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-bePOS 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.tsxguards 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/changeare ephemeral — captured only in the in-memoryIPOSReceiptDatafor 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: undefinedat checkout, deferring to the sales engine's default account resolution; a stale (deleted) saved id silently hydrates to empty.