POS Boundaries (KVS Limitation)
Configurable, KVS-driven guardrails on numeric inputs in the POS app (payment amounts, refund amounts, line prices/quantities, invoice taxes). The goal is to catch fat-finger errors (5000 instead of 50.00, missed decimal point, wrong sign on a refund) without hard-blocking legitimate large transactions.
How it works
Each guarded input has a boundary context (e.g. payment, refund, globalLinePrice). For each context, the app holds:
- a default max hardcoded in
src/configs/PosBoundaries/index.ts, - an optional KVS-overridden max saved per user (each user keeps their own value),
- an optional expected sign (
positivefor payments,negativefor refunds) — non-zero magnitude is always required.
On submit, useBoundaryGuardedSubmit calls usePosBoundaries(context).isOutOfRange(value). If out of range, it either warns the user (modal or inline) or blocks the submit outright, depending on the current user’s warning preferences.
The boundary check is configurable and only fires warnings by default — it is not a hard block. A separate, hardcoded absolute cap (ABSOLUTE_MAX_PAYMENT_AMOUNT = 1_000_000_000) protects against absurd inputs in the payment form via Zod validation; see #617 .
Supported boundary contexts
| Context | Default max | Expected sign | Used by |
|---|---|---|---|
payment | 500 | positive | Payment form on /invoice/[id]/payment |
refund | 200 | negative | Same payment form, when invoice total to pay is < 0 |
globalLinePrice | 200 | any non-zero | Inline price edit and manual-line dialog (/lines) |
inlineLineQuantity | 50 | any non-zero | Inline quantity edit |
globalLineQuantity | 50 | any non-zero | Manual-line dialog (/lines) |
invoiceTax | 100 | any non-zero | Add/Edit taxes dialog |
Source of truth: POS_BOUNDARY_SECTIONS.
KVS keys
One KVS row per boundary, with keys built via getFQCN():
| Boundary | KVS key |
|---|---|
payment | pfqcn_phprPos_PosBoundaries_config_payment_amount_max |
refund | pfqcn_phprPos_PosBoundaries_config_refund_amount_max |
globalLinePrice | pfqcn_phprPos_PosBoundaries_config_global_line_price_max |
inlineLineQuantity | pfqcn_phprPos_PosBoundaries_config_inline_line_quantity_max |
globalLineQuantity | pfqcn_phprPos_PosBoundaries_config_global_line_quantity_max |
invoiceTax | pfqcn_phprPos_PosBoundaries_config_invoice_tax_amount_max |
Plus one row for warning preferences:
pfqcn_phprPos_PosBoundaries_config_warning_preferences— JSON value:{ "displayStyle": "modal" | "inline", "behavior": "warn" | "block" }
The value field stores a numeric string (e.g. "500") for the boundaries; the warning preferences row stores a JSON string.
Scoping
The Parameters page saves boundaries and warning preferences as user-specific rows: every save writes user = <current user> and corporation = -1 (the “all corporations” sentinel, set so the rows are also visible via useKVS calls that inject corporation=-1 as a filter). Each user keeps their own settings.
The read path uses pickScopedKvsEntry, which resolves the most specific match in this order: user > corporation > unscoped. The currentCorporationIds array passed to the picker is [], so corp matching only resolves the -1 “all corps” sentinel.
Why per-key (and not one consolidated JSON)
#633 originally proposed consolidating the 6 boundary keys into a single JSON-payload row to save round-trips through useKVS (which fetches one key at a time). We kept per-key entries instead and load them in parallel via a server action (loadAllBoundaryMaxes), because per-key gives:
- simpler save semantics (PUT vs POST per row),
- easier ad-hoc admin edits via the generic KVS CRUD UI.
Warning preferences
Two independent dimensions, both stored in the per-user warning_preferences KVS row:
| Setting | Values | Effect |
|---|---|---|
displayStyle | modal | inline | Where the warning appears: blocking dialog vs inline message under the input. |
behavior | warn | block | warn lets the user confirm and proceed. block rejects the submit entirely. |
Defaults : { displayStyle: "modal", behavior: "warn" }.
Parameters page : /parameters/pos/boundaries
The boundaries config form (PosBoundariesConfigForm.tsx) is URL-driven. Query string params are the authoritative form state:
- on mount, KVS values seed the URL for any missing param,
- typing in a field pushes to the URL,
- clicking a profile preset pushes the full set to the URL,
- on save, current URL values are written back to KVS.
This makes any form state deep-linkable (useful for support, demos, repro of edge cases) and refresh-safe.
Supported query strings
Boundary maxes (the context name is the param name) :
payment— payment maxrefund— refund max (absolute value; stored value is the positive magnitude)globalLinePriceinlineLineQuantityglobalLineQuantityinvoiceTax
Warning preferences :
displayStyle—modalorinlinebehavior—warnorblock
Example :
/parameters/pos/boundaries?payment=2000&refund=1000&globalLinePrice=1000&behavior=blockPre-built profiles
Three pre-built tiers fill all maxes coherently (POS_BOUNDARY_PROFILES) :
| Profile | Payment | Refund | Line price | Inline qty | Global qty | Tax |
|---|---|---|---|---|---|---|
| Tier 2 | 2000 | 1000 | 1000 | 20 | 20 | 300 |
| Tier 3 | 25000 | 25000 | 20000 | 5 | 5 | 3000 |
| Tier 4 | 250000 | 100000 | 200000 | 5 | 5 | 30000 |
Clicking a profile pushes its values into the URL. The user can then fine-tune individual fields before saving.
Code integration
To add a boundary check to a new form :
-
Pick or add a context in
src/configs/PosBoundaries/index.ts(BoundaryContextunion + a preset under the rightBoundarySubSection). -
Wire
useBoundaryGuardedSubmitin the form hook :const { submit, pending, confirmPending, cancelPending } = useBoundaryGuardedSubmit<FormValues>({ context: "payment", // or whichever context applies pickValue: (v) => parseFloat(v?.amount ?? "0"), onSubmit: handleSubmit, // your real submit forceCheck: () => totalToPay === 0, // optional: always check (e.g. zero-total guardrail) format: "currency", // controls toast formatting }); -
Render
<BoundaryWarning />below the form to display the modal/inline confirmation whenpending !== null. -
Use
submit(not your ownhandleSubmit) as the form’s submit handler. The hook intercepts, runs the boundary check, and either callsonSubmitimmediately or stages a pending confirmation.
usePosBoundaries(context) can also be used directly if you need to read { max, isOutOfRange, isLoaded, expectedSign } without going through the guarded-submit flow.
Related
- POS KVS overview
- POS query strings
- #624 — feature request
- #633 — KVS key naming + URL prefill
- #617 — hard absolute cap on payment amount