Design Rationale — Pos
Non-obvious design choices for the pos module — the WHY behind each decision.
POS (locked 2026-06-10)
Offline-first: client_uuid + idempotency_key + sync_status
Decision: POS is the only offline-first module. sale and sale_payment carry client_uuid (UUID generated on the device, before the server is reachable), idempotency_key (server-side dedup key), and sync_status. A UNIQUE (tenant_id, client_uuid) on sale enforces server-side dedup.
Why: A register must complete sales with no network. The device generates identity offline; the server deduplicates on sync so a retried upload does not create duplicate sales.
Guard: The client_uuid, idempotency_key, and sync_status columns that appear on POS tables but nowhere else in the system are the offline contract — do not "normalize" them away or flag them as redundant. They exist because POS is the only module that operates without a server.
Stock decrement timing: immediate for online sales, on-sync for offline
Decision: Online sales call InventoryService to decrement stock immediately at completion. Offline sales decrement on sync. When two offline tills each sell the last unit of stock, the second sync creates a pos_sync_conflict row for manager resolution rather than silently allowing negative inventory.
Why: The server can't be reached to check or decrement stock while offline. Reconcile at sync; surface conflicts explicitly rather than silently overselling.
gift_card and store_credit kept separate — not one "stored value" table
Decision: Two tables with distinct schemas, not a unified stored_value or prepaid_balance table.
Why: A gift card is a bearer instrument — whoever holds the code can spend it; no customer identity required; transferable. Store credit is a customer liability — tied to a specific customer, RLS-scoped, non-transferable. The identity model, lifecycle, FK targets, and business rules are fundamentally different.
Guard: They look mergeable ("both are prepaid balances") but wire differently at every level. This is the canonical example of the merge-vs-separate principle — do not merge. A single table would require nullable customer_id on gift cards (leaking store-credit semantics) and no-customer-check on store credit (leaking gift-card semantics).
Refunds: dedicated sale_refund + sale_refund_line, not a negative-mirror sale
Decision: Refunds are their own tables. sale_refund_line carries a restock_decision per line (restock / damage-out / other). An earlier design used negative-amount sale lines to represent returns.
Why: A refund requires a per-line decision about whether the returned item goes back to stock. A negative-mirror sale cannot carry that per-line decision, and it pollutes the sale dataset with non-sale rows.
Rejected: Negative-sale approach — couldn't carry restock semantics; polluted sale/sale_line data; made revenue reporting require filtering.
Guard: This was a deliberate reversal of an earlier design direction — do not revert to negative sales for returns.
Comped items are a line_type, not a separate table
Decision: A comped (free) item is a sale_line with line_type = 'comp' and unit_price_cents = 0. No separate comp or manager_override table.
Why: A comp is a line-level pricing outcome — discriminator-over-sprawl. The same actor-stamping and approval-FK columns already on sale_line provide the audit trail.
Plant guarantee is a generic mechanism, not nursery-specific columns
Decision: Guarantee terms live on inventory.item_variant.guarantee_terms (text); sale_line.has_guarantee (bool) flags whether the guarantee was included on that line. No nursery-named columns anywhere.
Why: Generic-first — a "guarantee" is a generic retail concept (product warranty). The nursery is the first user; future verticals (hardscape, equipment) may use the same mechanism for their own warranty terms.
Actor stamped on every action; no shift or clock tables
Decision: Every POS action stamps the acting user (cashier_id, approved_by, etc.). There are no shift, clock-in, or labor tables in POS.
Why: Audit requires "who did this action" — POS provides that. Full HR shift management is permanently out of scope for Vrida (see Admin module scope decision).
Guard: The absence of shift tables is a deliberate scope cut, not an oversight. Do not add shift/clock tables to POS.
sale_line_tax is an append-only child table (multi-jurisdiction)
Decision: Tax breakdown per sale line is a separate append-only child table (sale_line_tax), with one row per jurisdiction per line. No updated_at / deleted_at.
Why: A single line may be taxed by multiple jurisdictions simultaneously (state + county + city). The breakdown is the immutable legal record of exactly what tax was charged per jurisdiction (as returned by Stripe Tax). A single tax_cents column on the line cannot capture multi-jurisdiction breakdowns.
tax_exemption_cert_id stays text, not a UUID FK — even though the cert table now exists
Decision: sale.tax_exemption_cert_id is text (the certificate number as entered or scanned), not a UUID FK to crm.customer_tax_certificate.
Why: POS is offline-first. An offline sale references the cert by value (the number the cashier enters or scans at the till), which survives offline. A UUID FK lookup against crm.customer_tax_certificate requires a server round-trip that is impossible while offline. Reconciliation to the cert table is service-layer, after sync.
Guard: Do NOT "upgrade" this column to a UUID FK now that customer_tax_certificate exists. The text seam is correct for offline, not a leftover placeholder. The original seam note's "add FK when CRM adds cert table" instruction was superseded when the offline-first reasoning was applied.
tip_amount_cents on sale; total_cents excludes tip
Decision: sale.tip_amount_cents is a separate column; sale.total_cents = subtotal − discount + tax, and does NOT include the tip. Full amount charged to the card = total_cents + tip_amount_cents.
Why: Terminal tips are part of the card charge but not part of the sale subtotal. Separating them allows revenue reporting to distinguish product revenue from gratuity. Mirrors payments.payment_intent.tip_amount_cents for reconciliation.