docs/reference/product-invariants.md
Rama main · réplica de lectura del repositorio original.
# Product invariants
These are deliberate product decisions that shape how FloCafe behaves and are not derivable from
reading the code. Each one names where it is enforced and how to check that the codebase still
complies, so an implementation that contradicts one is caught rather than assumed.
Read this before changing authorization, access control, defaults, or any behaviour listed here. If
a change appears to require deviating from an entry, confirm it rather than assuming the decision is
stale. If a decision genuinely changes, update this page in the same change that changes the
behaviour.
This page is a peer of the core invariants in [`AGENTS.md`](../../AGENTS.md), which carry the short
load-bearing list, not a replacement for it.
## How an entry is structured
**Rule** (what the system does) · **Reason** (why it is that way) · **Enforced by** (where a
violation would show up) · **How to verify** (a command that must return a specific result) ·
**Change policy** (what it takes to change the decision).
A decision without a checkable verification command is a decision that is easy to violate by
accident.
---
## Orders are never ownership-gated
**Rule:** Any staff role with order access (owner, manager, cashier, server; see
[roles and permissions](roles-and-permissions.md)) can view and act on **every** order, regardless
of who created it. No "this is my order" restriction exists anywhere in the system.
**Reason:** FloCafe is an open system by design. Restricting staff to the orders they personally
created adds friction without a security benefit for this product: a waiter covering a colleague's
table and a manager checking any order are both normal work. Accountability comes from knowing who
did what, not from hiding data between staff who already share a till and a kitchen.
**What restricts access instead:**
1. **Permission-based page and feature access.** Shipped defaults do not let a chef open the
Orders page or a cashier reach owner/manager-only settings; an owner may reconfigure either
(see [role templates and user permissions](#role-templates-and-user-permissions-are-owner-configurable)).
2. **Permission-based restriction on a specific action.** KDS stage transitions (marking an item
preparing, ready, or served) require `kitchen.status.update`, shipped chef/manager/owner-only,
narrowed further by the chef's assigned kitchen station and category. A server can place an
order but cannot do the kitchen's work on it.
3. **Audit attribution.** Every order and every write is recorded against the authenticated actor
(`user_id`, `created_by`). This serves the audit trail, not access gating.
**Enforced by:** `main/routes/orders.ts` (order list, `GET /:id`, item append, status update, item
cancel/void, edit, and restore), `main/routes/printers.ts` (`print-kot`). None of these compare
`order.user_id`, or an item's creator, against the requesting user.
**How to verify:** both commands must return nothing.
```sh
grep -rn "role === 'server'" main/routes/ | grep -i "user_id"
grep -rn "user_id !== " main/
```
A match is a reintroduction of the pattern. Treat it as a bug, not a feature, and confirm with the
user before keeping it.
**Change policy:** changing this needs an explicit product decision, because it is a deliberate
opening of the system rather than an oversight. Any new endpoint that filters a list by the
authenticated user is subject to the same review as a change to this entry.
---
## A selected backup is authoritative during restore
**Rule:** Restoring a valid, supported backup replaces the store state with that backup. FloCafe
does not merge users, staff activation, kitchen stations, station assignments, settings, inventory
history, or other operational records from the database being replaced. Older backups migrate
forward after replacement. Restore invalidates sessions and temporary pairing credentials, clears
the Google Drive device binding, and retains an automatic pre-restore recovery copy.
**Reason:** Backup restore is a deliberate disaster-recovery action. The operator expects the café
to return to the selected snapshot after a machine failure. Merging the failed installation with
the backup creates conflicts, makes valid backups unrestorable, and leaves the final state difficult
to predict. Easily reconfigured local state does not justify blocking recovery.
**Enforced by:** `restoreBackup` in [`main/db.ts`](../../main/db.ts), the file-selection and
authorization boundary in `main/routes/database.ts`, and the production restore tests in
`tests/backup-restore-production.test.ts`. See
[data and migrations](../architecture/data-and-migrations.md#backup-and-restore) for the replacement
journal, validation, migration, and rollback sequence.
**How to verify:**
```sh
npm run test:backup
```
The suite verifies that current-only users and kitchen stations do not override the backup, that
backup staff and settings remain authoritative, that supported older backups migrate forward, and
that integrity failures leave the replaced database recoverable.
**Change policy:** adding any cross-database merge or refusal rule to restore requires a demonstrated
material risk and explicit product approval. Authorization, confirmation, validation, transient
credential invalidation, migrations, and the pre-restore recovery copy remain mandatory safeguards.
---
## Role templates and user permissions are owner-configurable
**Rule:** FloCafe keeps its five fixed staff identities (owner, manager, cashier, server, chef),
but their ordinary feature permissions are configurable by an owner: role defaults can be
overridden, and a single user can additionally receive explicit allow/deny exceptions. For each
permission, the effective value resolves protected-owner rule, then user override, then role
override, then shipped default. `authorization.manage` (who can reach the permission editor) and
`staff.privileged.manage` (who can modify an owner or manager account) are the two exceptions:
both are protected, always resolve true for an active owner, and cannot be configured to anyone
else.
**Reason:** Stores need to delegate real operational responsibilities — letting a trusted cashier
take on a manager's reporting view, for example — without inventing more role identities, while an
offline-first, backend-authoritative install still needs a recoverable owner administration path
that no override can remove.
**Enforced by:** [`shared/permissions.ts`](../../shared/permissions.ts) (the permission catalog and
shipped defaults), [`main/services/authorization.ts`](../../main/services/authorization.ts) (the
resolver), schema migration v93 in `main/db.ts` (`role_permission_overrides`,
`user_permission_overrides`, `authorization_audit_log`), the owner-only `/api/authorization` API,
and `requirePermission`/`requireAnyPermission` middleware across the main API, KDS, and Server App.
See [authentication and authorization](../architecture/authentication-and-authorization.md#configurable-permissions)
for the resolution model and [roles and permissions](roles-and-permissions.md) for the editor and
the shipped-default matrix.
**How to verify:**
```sh
npm run test:authorization-permissions
```
A static audit test in the same suite rejects any newly introduced `requireRole(...)` runtime gate
under `main/routes/`; the remaining direct role checks in the codebase are reviewed context policy,
documented in [authentication and authorization](../architecture/authentication-and-authorization.md#configurable-permissions).
**Change policy:** the two protected permissions, and the precedence order overrides resolve in,
are structural — changing either needs an explicit product decision, because either one can create
an unrecoverable install (no owner able to manage authorization) or a privilege-escalation path
(a non-owner able to modify owner/manager accounts). Adding a new permission id to the catalog, or
changing a shipped default, does not need this entry updated, but does need
[roles and permissions](roles-and-permissions.md) updated in the same change.
---
## Applied charge snapshots stay with existing orders
**Rule:** Disabling or removing a charge definition affects new orders only. A charge already
applied to an existing order remains at its stored amount when the order is edited or discounted;
still-active definitions that apply to the order type continue to recalculate normally. Checkout
shows every applied charge. Active, applicable definitions control which optional charges staff can
add, remove, waive, or restore; retained snapshots without an applicable definition remain visible
but read-only. Amounts remain configured in Settings. Explicit cashier removal and order-type
conversion keep their existing behavior.
Existing unpaid, unsplit bills with no payments can be adjusted under `bills.discount.apply`.
**Reason:** Changing store settings should not retroactively alter an amount already applied to an
open order.
**Enforced by:** `main/services/charges.ts` retains applied snapshots for inactive or missing
definitions; order and bill mutation routes use that resolver. `GET /api/settings/charges` is
available to staff with `settings.view`, `orders.create`, or `bills.discount.apply`; changing fee
definitions still requires Settings management permission. `frontend/src/store/cart.ts` clears
waiver and opt-in choices when loading another order.
**How to verify:**
```sh
npm run test:charges-engine
npm run test:held-orders
npm run test:e2e:browser -- --grep "changing charge decisions after a failed order"
```
**Change policy:** changing how existing orders retain or recalculate applied charges needs an
explicit product decision.
---
## Refunds and Staff Approval PINs
**Rule:** The initial owner creates and confirms a separate 4 to 6 digit Staff Approval PIN during
first-run setup. It is stored as a bcrypt hash on the owner user record, independent of the device
Master PIN. Staff holding `refunds.initiate` can refund a bill that has already been paid - in full,
partially, or for a single item - with approval from an active owner, or from an active manager when
manager approval is enabled and within the configured manager approval window. The
refund can be paid back in a different method than the customer used, or issued as store credit.
1. **Selected approver.** Every refund identifies one approver through `approver_id`. `manager_id`
remains a compatibility alias; a missing id, or an `approver_id` that conflicts with
`manager_id`, is rejected. Only the selected active owner or manager is checked, and the
submitted PIN must be that user's Staff Approval PIN. The device Master PIN never authorizes a
refund.
2. **Ceiling.** A refund can never exceed `paid_amount` minus the sum of prior refunds for that
bill, not the order's gross total, so a partly refunded bill cannot be refunded past what is
outstanding. Enforced by `getRefundableBalance()` in `main/services/refund.ts`.
3. **Approval tiers**, keyed off `released_at || created_at` for released orders, the store's
business day (`dayBoundsInTimezone()` in `main/db.ts` from the tenant's configured timezone and
`business_day_start_time`), and the owner-configured refund policy settings
`refund_manager_enabled` (`'true'` | `'false'`, default `'true'`) and `refund_manager_window`
(`'30m'` | `'1h'` | `'2h'` | `'4h'` | `'same_business_day'`, default `'1h'`).
- When `refund_manager_enabled === 'true'`, an unreleased scheduled order in `release_status`
`scheduled` or `blocked`, or a released order within the configured `refund_manager_window` of
`released_at || created_at` and before the end of that anchor timestamp's business day, may be
approved with either the selected active owner's or manager's Staff Approval PIN.
- When `refund_manager_enabled === 'false'`, or once the configured manager approval window or
the anchor timestamp's business day has passed, only the selected active owner's Staff Approval
PIN is accepted. A manager PIN is rejected with HTTP 403.
- Owners have no age cutoff: an active owner can approve a refund on any eligible paid order from
any past business day using their Staff Approval PIN.
4. **Item eligibility** for a single-item refund is `REFUND_ITEM_ELIGIBLE_STATUSES`: `preparing`,
`ready`, `served`, `completed`.
5. **The refund payment method is independent of the original.** A card payment can be refunded in
cash and the reverse. This is deliberate, not a missing validation.
6. **Store credit** (`method: 'wallet'`) requires loyalty to be enabled and a customer attached to
the bill. It is recorded as a plain `credit` row in `loyalty_ledger`, the same mechanism cashback
uses, so it is immediately spendable. It does not double-count as cashback on respend, because
`calculateCashback()` in `main/routes/bills.ts` already excludes wallet-funded spend from the
cashback base.
7. **Accepted limitation.** A refund does not claw back cashback already credited on that sale at
payment time. Proportional cashback clawback was judged not worth the complexity at the product's
install-base scale.
8. **Refund initiation and manager approval window are owner-configurable.** `refunds.initiate`
(shipped owner/manager) controls who may start the workflow and is owner-editable per role or per
user, see [role templates and user permissions](#role-templates-and-user-permissions-are-owner-configurable).
Owners can also toggle manager refund approval (`refund_manager_enabled`) and set the manager
refund approval window (`refund_manager_window`) via `/api/settings/refunds`. The selected
approver must still independently satisfy the owner/manager and time-tier rules above - those are
enforced against the approver's role and the order's anchor timestamp regardless of the
initiator's grant.
9. **Product and supply inventory are never restored by a refund; single-item refunds restore
tracked add-on stock.** Whole-bill and partial-amount refunds never restore stock. A single-item
refund (`order_item_id`) leaves product stock and recipe supplies untouched, matching how
in-progress item voids behave, while restoring the item's recorded add-on stock deductions
(`order_item_addons.inventory_deducted_quantity > 0`) to `addons.stock_quantity` via a
`cancel_restore` movement in `inventory_movements` when the catalog add-on still exists.
**Amount storage.** `refunds.amount_cents` stores integer minor units for every currency, using
the factor returned by `getCurrencyMinorUnitFactor()` in `main/countries.ts`, which is ten to the
power of the currency's fraction digits. A zero-decimal currency such as JPY or KRW uses a factor
of 1 and stores whole currency units; a two-decimal currency such as USD, EUR, or INR uses 100;
a three-decimal currency such as KWD, BHD, or OMR uses 1000. The column name is historical; the
rule is minor units, not cents.
The tenant's business currency is chosen at setup and governs store-wide order, billing, and
settlement records. It is not changed on an active store; see
[regional settings](#regional-settings-come-from-signup-never-from-a-fallback).
**Reason:** A controlled way to reverse completed sales without reopening the order-editing
surface, while keeping the two things most exposed to misuse deliberately tight: how far back a
manager can approve a refund, and who can approve one.
**Enforced by:** `main/services/refund.ts` (`createRefund`, `checkIsLateRefund`,
`getRefundPolicySettings`, `resolveRefundApprover`, `getRefundableBalance`,
`REFUND_ITEM_ELIGIBLE_STATUSES`), `main/services/inventory.ts` (`adjustProductStock`),
`main/routes/refunds.ts`, `main/routes/settings.ts` (`/api/settings/refunds` and `/:key`), and the
first-run setup path in `main/routes/auth.ts`. Every refund also writes a `refund_issued` row to
`order_audit_log`.
**How to verify:**
```sh
npm run test:refunds # in-progress refund behaviour and product-stock non-restoration
npm run test:refund-completed-orders # business-day tiers, item eligibility, store credit, audit row
npm run test:addon-inventory-orders # post-settlement single-item refund add-on stock restoration
```
**Change policy:** the tier boundaries, the approver model, and the accepted cashback limitation are
product decisions. Implementation changes that keep them are not. Any change to the ceiling, the
tier boundaries, or store-credit accounting needs explicit confirmation.
---
## Regional settings come from signup, never from a fallback
**Rule:** The country selected during first-run setup, and the ISO 4217 currency selected alongside
it, are the only source of a store's regional identity. The country's currency is recommended, but
the owner may choose another supported currency. Currency symbol, symbol position, fraction digits,
number separators, and the default timezone are **derived** from those two values through
international conventions (CLDR via `Intl`, ISO 4217, IANA time zones).
There is no default country, no hard-coded currency symbol, and no per-store override of a derived
value. When regional settings are missing, code fails loudly with `RegionalNotConfiguredError` and
HTTP 409 rather than rendering a fallback currency.
`settings.timezone` is the single legitimate stored override. It is not a per-store override of a
derived country value; it is a configured zone within the resolved country.
**Post-setup currency changes are destructive.** A configured store cannot reinterpret existing
amounts in a new currency. Only an owner may change currency, through the Master-PIN-gated reset
flow at `POST /api/db-tools/currency-reset`. The app takes a full recovery backup, recreates the
local database, and preserves only categories, products, add-on groups, add-ons, and their
relationships. Product prices, product costs, stock balances, tax assignments, cashback
percentages, and add-on prices reset to zero or defaults, all other local data is erased, and
first-run setup is required again. Changing country alone changes the recommendation, never the
active currency. Ordinary business and wildcard settings writes reject an actual currency change
with `currency_change_requires_reset`.
**Reason:** The install seed used to write `'IN'`, `'INR'`, `'₹'`, and `'Asia/Kolkata'` as silent
fallbacks in more than a dozen places before the owner had chosen anything, and surfaces disagreed
about which symbol to print, so a non-Indian store could see one currency on a receipt and another
on screen. The owner's instruction was that the user picks country and currency at signup, it stays
consistent throughout the application, and the app follows existing conventions rather than
inventing overrides.
**What this rules out:** merchant-editable currency symbols and merchant-selectable symbol
placement. If a locale's rendering is wrong, the fix is the country profile in `main/countries.ts`,
which corrects every store in that country.
**Enforced by:** `resolveRegionalSnapshot()` in `main/countries.ts`, the first-run wizard requiring
a country, setup rejecting a missing country, `seedInstallDefaults()` in `main/db.ts` not writing
regional keys, and `POST /api/db-tools/currency-reset` as the only post-setup currency-change path.
**How to verify:**
```sh
npm run test:currency
```
The compliance grep must return nothing except two annotated one-time migration exceptions:
```sh
grep -rn "|| 'IN'\|?? 'IN'\||| 'INR'\|?? 'INR'\||| '₹'\|?? '₹'\||| 'Asia/Kolkata'\|?? 'Asia/Kolkata'\|getCountryByCode('IN')" \
main frontend/src shared --include='*.ts' --include='*.tsx'
```
The only permitted hits are `tenantCountryRow?.value || 'IN'` in migrations v23 and v24 in
`main/db.ts`, which normalize pre-existing customer phone numbers on an upgrading install. Both
carry a comment marking them as historical data cleanup, not a live store's regional identity. Any
other match is a reintroduced fallback and should be treated as a bug.
**Change policy:** the no-default-country rule and the derived-value model are structural. Adding a
per-store override or a fallback path requires a product decision, because it reintroduces the
silent disagreement the rule exists to prevent.
---
## Supplies stock and recipe depletion
**Rule:** Supplies, the ingredients and packaging tracked for recipe depletion, have their own
tables (`supplies`, `supply_movements`) with a signed ledger, separate from product
`inventory_movements`.
1. **Negative stock is allowed and never blocks order taking.** A rush of orders before a morning
delivery is logged still goes through the POS. Negative balances are flagged in the UI for
physical count reconciliation. There is no clamp and no 409.
2. **Depletion happens when a pending line is inserted** (order creation, item append, or an item
edit that adds a replacement or incremental pending line), when `recipe_snapshot`, a
JSON copy of the scaled recipe components, is written to `order_items`. The copy is rewritten
in place - scaled to the remaining quantity - when a started line's quantity decreases, so a
later cancel restores only the units the line still holds and wasted units stay out of stock. Pending
quantity reductions scale the original line's historical recipe snapshot and inventory deduction
onto the new pending line rather than re-evaluating modified catalog recipes. The scale is
`ingredient quantity x ordered quantity x portion / yield_quantity`, where the portion is the
ordered variant's `recipe_multiplier`: 1 is one base recipe portion, 0.5 half, 2 double. Parent
sales without a variant use a portion of 1, and the portion is scaled from the resolved catalog
variant, never from the order payload.
3. **Restoration is keyed off the snapshot, never the current recipe or portion.** Cancelling a
pending item (directly or by cancelling the order) restores exactly what the snapshot recorded,
even if the recipe or the variant's portion was edited since. Cancelling a half-portion sale
therefore returns half of every ingredient even after the variant was switched to double.
4. **Void or reduction after preparation is physical waste.** Items voided while in progress,
ready, or completed - and in-progress lines reduced by an item edit (replacing started lines
is disallowed) - restore neither supplies nor product stock. Only a `pending` cancel (including a `pending` line
superseded by an item edit) restores.
5. **Refunds never restore supplies**, matching product inventory behaviour.
6. **Product stock tracking and recipe depletion are independent** and may both be active on the
same product. A variant scales them with two different fields: `recipe_multiplier` scales the
product's own ingredient recipe, while `inventory_deduction_quantity` scales the linked product
stock pool (`inventory_product_id`). Neither substitutes for the other, and both may move once
per sale.
7. **A portion without a recipe has no effect.** A variant may store a portion while its product has
no active recipe. Sales without an active recipe create no recipe snapshot; the portion applies
to later sales after a recipe becomes active. Portions must be positive finite numbers; catalog
writes and database imports reject invalid supplied values, and recipe snapshot creation rejects
an invalid portion before depletion.
8. **Supply purchase costs use Weighted Average Cost (WAC) per base unit and stay separate from the
expenses ledger.** A supply's `unit_cost` is stored in major currency units per `base_unit`
(rounded to 10 decimal places so sub-minor-unit gram and millilitre costs never collapse to zero,
including 0-decimal currencies such as JPY). When a `receive` movement includes a batch cost
(`unit_cost` per movement unit or `total_cost` for the batch), it is normalized to `base_unit`.
If `stock_before <= 0` or `supplies.unit_cost IS NULL`, `unit_cost` resets to the new batch cost;
otherwise it blends by WAC (`(stock_before * old_cost + delta * batch_cost) / stock_after`).
Unpriced `receive` movements leave `supplies.unit_cost` unchanged. Recording a supply receive cost
updates inventory valuation only and never creates an `expenses` row.
9. **Order-item ingredient cost is recipe-derived, frozen at sale time, and never understated.**
When an order line is created, `buildRecipeSnapshot()` computes the line's ingredient cost from
its active recipe and variant `recipe_multiplier`, rounds the line total to the tenant currency's
fraction digits (`0`, `2`, or `3`), and freezes `ingredient_cost` and `ingredient_cost_known` on
`order_items` alongside `recipe_snapshot`. If a product has no active recipe or **any** recipe
component supply has `unit_cost IS NULL`, `ingredient_cost` is `NULL` and `ingredient_cost_known`
is `0` ("cost unknown" - never a partial cost and never 100% margin); legacy `products.cost` is
not used. Subsequent supply cost or recipe edits never alter existing `order_items`, and pending
or started quantity reductions scale the frozen cost from the line's held `recipe_snapshot`. In
sales reports and daily sales exports (gated by `reports.financial.view` and
`reports.daily-sales.export`), profit and margin are computed strictly over ex-tax net item sales
of known-cost lines (`known_cost_revenue`).
**Reason:** POS availability during a stockout matters more than ledger neatness. A store that
cannot sell because flour has not been counted in yet is worse than a negative row to reconcile
later. Snapshots keep historical cancellations and historical profitability reports accurate under
recipe or supply cost edits, mirroring the existing `tax_snapshot` pattern.
**Enforced by:** `applySupplyStockChange()` in `main/services/supplies.ts`, which does not clamp;
`buildRecipeSnapshot()`, `scaleRecipeSnapshot()`, and `applyRecipeSnapshot()` in
`main/services/recipes.ts`; the order
creation, item append, order and item cancel, item edit, and item restore paths in
`main/routes/orders.ts`; and profitability aggregation in `main/routes/reports.ts` and
`main/services/daily-sales-export.ts`. Restore acts only on a non-superseded `cancelled` item, re-deducts its
inventory and recipe components, and returns it to `pending`; a `voided` or superseded item is
never restored. The
portion comes from
`resolveOrderItemVariant()` in `main/services/product-variants.ts`, the same server-side resolution
that prices the line, and `buildRecipeSnapshot()` refuses a non-positive or non-finite portion
before depletion. The migration gives existing variants a default portion of 1 in
`product_variants.recipe_multiplier`.
**How to verify:**
```sh
npm run test:recipe-order-lifecycle # deplete, restore, void-no-restore, snapshot immutability
npm run test:ingredient-cost # WAC, negative-stock reset, frozen cost, profitability reports
npm run test:order-item-edit # pending vs started item edits, waste, superseded restore rejection
npm run test:supplies-service # negative stock, ledger, pagination
npm run test:product-variants-orders # half / whole / double portions across create, append, void
```
**Change policy:** allowing negative stock and snapshot-keyed restoration are the load-bearing
parts. Clamping stock or restoring from the live recipe or portion would break historical
correctness, so either needs explicit confirmation. `inventory_deduction_quantity` keeps its
linked-product meaning; scaling the ingredient recipe with it would merge two independent pools.
---
## A receipt and a courier slip are not secure artefacts
**Rule:** A printed receipt and a printed delivery slip are both **handouts, not secure
artefacts**. Neither is treated as a confidentiality boundary, and the system never gates printing
on the belief that the printed page is controlled.
Two consequences follow, and they are separate decisions:
1. **The delivery slip prints the customer's full phone number and delivery address whenever
either of the two number settings allows it.** A courier who cannot call the customer, or
cannot find the house, is the failure this document exists to prevent.
2. **Receipt phone masking depends on the renderer.** The backend thermal and browser-HTML paths
print the full number. The frontend receipt encoder masks it by default, shortening it to the
last four digits. This is privacy hygiene for a page the customer carries away, not an access
control.
A delivery document shows the number when **either** setting allows it. `bill_delivery_show
_customer_phone_always` ships **on**, so out of the box a delivery order and its courier slip show
the number even when `bill_show_customer_phone` is off. Turning the delivery override off hands
delivery documents back to `bill_show_customer_phone`, which is the point of the override: a
merchant who wants the number hidden on receipts has one control to reach for, and one control to
undo. The number is withheld only when both say hide. The two documents are resolved by the same
rule, so a delivery receipt and its courier slip cannot disagree.
The Printers settings panel, under Bill Content, states this beside the toggle it contradicts,
and the warning is always present rather than appearing only once the merchant has turned the
number off.
**Reason:** masking is inconsistent by construction and never was a boundary. `GET /api/customers`
returns every customer's `phone` **and** `address` to any role holding
`customers.view`, whose shipped defaults include `cashier` and `server`. An ordinary cashier can
therefore already read every customer phone number and address in the shop on one screen, and
`printing.execute` ships to the same roles. Adding a permission to the slip would close one door
in a room with three open ones while claiming a protection that does not exist.
**The documents remain intentionally distinct.** The slip has its own document kind and never
passes through `buildBillDocument`. Its API route and browser/WebUSB hook apply the same visibility
rule before building it: they include the phone when either `bill_delivery_show_customer_phone_always`
or `bill_show_customer_phone` is on, and omit it only when both are off. This keeps a delivery
receipt and the courier slip aligned. A merchant who turns off only `bill_show_customer_phone`
still gets the phone on delivery slips while the delivery override is on; the same Printers panel
controls both settings. Do not "fix" it by routing the slip through the receipt document, which
would conflate the two document defaults.
**Enforced by:** `shouldShowCustomerNumber()` in `shared/print/document.ts` for receipt customer-number
visibility; `main/routes/printers.ts` and `frontend/src/hooks/usePrinter.ts` apply the OR rule to
delivery-slip contact data before rendering; `buildDeliverySlipPrintData` consumes the caller-filtered
phone; `resolveReceiptPhone()` in `frontend/src/lib/printer/receipt-encoder.ts` keeps receipt masking
separate. The slip's route is `POST /api/printers/print-delivery-slip` in `main/routes/printers.ts`,
gated on the same `printing.execute` permission as every other print.
**How to verify:**
```sh
npm run test:delivery-slip # slip settings and frontend receipt masking stay separate
```
A legacy customer address written before the length boundary existed can be any length. The slip
bounds it so one row cannot monopolise the paper, and it prints how many characters were not shown
and where to look. Truncating in silence is not an available outcome: a courier holding a partial
address that looks complete is worse than one holding a marked one.
**Change policy:** making either document confidential is a product migration, not a flag.
`shared/permissions.ts` states the rule in the file: changing a shipped default is reviewed as a
product migration because store-local overrides live in sparse tables. It would also have to
address `customers.view`, which is the wider exposure. Raise it as its own issue rather than
narrowing one print route.
---
## Catalog CSV encoding and legacy apostrophe ambiguity
**Rule:** Catalog CSV exports (`categories`, `products`, and `addons`) carry a `flo_csv_encoding`
column set to `quote-v1`. Under `quote-v1`, an original leading apostrophe (`'`) on a non-numeric
field is doubled before `toCsvRow` applies spreadsheet formula protection (prefixing `'` onto
non-numeric values starting with `=`, `+`, `-`, `@`, or leading whitespace before those
characters).
1. **Marked imports (`flo_csv_encoding=quote-v1`).** The importer removes the single apostrophe
added by `quote-v1` encoding (either from doubling an original leading `'` or from formula
protection), making repeated export and import cycles lossless across formula-leading values,
leading whitespace before formula triggers, and genuine leading apostrophes.
2. **Unmarked legacy imports (missing or empty `flo_csv_encoding`).** The `quote-v1` decoding rule
is never applied to unmarked rows. On product or add-on rows carrying a stable ID that matches an
existing record, a leading apostrophe is removed only when comparison with that exact existing
record's stored value proves the apostrophe was added by formula protection. All other unmarked
values - including category-only rows without proof of identity, rows without a matching ID, and
existing records whose stored values already begin with `'` - are preserved as-is.
3. **Unsupported markers.** Any non-empty `flo_csv_encoding` value other than `quote-v1` is rejected
as a per-row import validation error.
**Reason:** Pre-`quote-v1` exports prefixed formula-triggering strings with `'` without escaping
genuine leading apostrophes, making `'=foo` in an unmarked file ambiguous between an escaped formula
and a literal apostrophe. Stripping leading apostrophes unconditionally on unmarked files would
corrupt genuine apostrophes, while leaving new exports unmarked would mutate formula-leading names
and spawn duplicate categories and add-ons on round-trip imports.
**Enforced by:** `toQuoteV1CsvRow()`, `encodeQuoteV1Field()`, `decodeQuoteV1Field()`, and
`decodeLegacyProvenField()` in [`main/lib/csv.ts`](../../main/lib/csv.ts), and the export and import
handlers in [`main/routes/menu-csv.ts`](../../main/routes/menu-csv.ts). Other CSV exporters continue
using `toCsvRow()` directly.
**How to verify:**
```sh
npm run test:issue-248-csv
```
**Change policy:** changing the `quote-v1` encoding rules or stripping unmarked apostrophes without
existing-record proof requires an explicit product decision, because either change can silently
rename catalog items or create duplicate categories and add-ons during CSV imports.
---
## Multi-counter ordering and prepayment lifecycle
**Rule:** Multi-counter ordering separates order capture ("Send to payment") from cashier settlement:
1. **Prepayment never prematurely marks kitchen work complete or frees dine-in tables:** Completing
payment across all applicable bills for an `on_payment` order transitions its `checkout_stage` to
`'paid'` and triggers release to the kitchen (if unscheduled or scheduled instant is reached), but
leaves its kitchen `status = 'pending'` while kitchen items remain to be prepared. The order
completes only when kitchen preparation concludes (or immediately upon release for takeaway orders
when KDS is disabled) and dine-in tables remain occupied (`occupied = 1`) until table service
concludes.
2. **Orders and bills are real database entities, never held-order JSON:** An order sent to payment is
stored immediately in `orders`, `order_items`, and `bills` with quoted pricing snapshots.
3. **Multi-counter orders are never ownership-gated:** Any staff member at any payment or combined
counter can view, search, and settle any awaiting-payment order.
4. **Committed payment survives release failures:** If inventory deduction or catalog validation
fails during kitchen release after payment, the collected payment is never rolled back or
discarded; the order transitions to `release_status = 'blocked'` (`paid-but-blocked`) and remains
clearly visible in the queue for staff resolution (manual release retry or refund/cancellation).
5. **Prepayment edits are locked after initial payment:** Item additions, item edits, discounts, and
customer adjustments are permitted on an `awaiting_payment` order only while no payments have
been collected.
**Reason:** In busy multi-counter environments (cafes, food courts, quick-service diners), prepaying
customers must not trigger kitchen completion notifications before food is prepared, and table
tracking for dine-in guests must not drop off POS maps while guests are dining. Payment transactions
cannot be rolled back due to kitchen inventory errors; staff need visibility into prepaid orders
needing stock resolution.
**Enforced by:** `main/routes/bills.ts` (`completeExactBalanceSettlement`, `applyPaymentBatch`,
`handlePostPaymentRelease`), `main/services/scheduled-orders.ts` (`releaseScheduledOrder`,
`assertScheduledOrderUnpaidForEdit`), `main/routes/orders.ts` (`POST /api/orders`,
`GET /api/orders/awaiting-payment`, `GET /api/orders/by-ticket/:ticket`).
**How to verify:**
```sh
npm run test:multi-counter-ordering
```
**Change policy:** Altering the prepayment lifecycle to auto-complete orders or bypass inventory
release gates requires an explicit product architecture review.