# 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.