POS domain model → Appwrite
Purpose: the authoritative entity list and how each maps onto Appwrite collections, including the decisions Appwrite changed vs. the original TypeORM design. Status: as of 2026-09-26 (R9 B4, api #967:
POST /pos/depositsrefuses channel E —CARD_TERMINAL— from any client with a coded 400, closing a hole that credited the merchant wallet with no card sale behind it; channel E is written only internally. R9 second wave: approval freshness — void/refund/discount/shift-variance now expire in 10 minutes like the cash-out approval; VAS single-connection debit + crash-safe; VASCANCELLEDorder status; productcostPricewired end-to-end. R9 first wave: shift gate admin-only + coded refusals, cashier cash-out approval verified server-side, VAS not voidable; shift-close expected-cash rule 2026-09-09, gap #18/Q-n86-4; shift-required-to-sell rule 2026-08-12; wallet domain group 2026-07-11 per PDR-0003/TDR-0004; core model 2026-06-30). Source:terminales-api/docs/pos/{1.README, 2.MVP_ROADMAP, 3.REUSE_ANALYSIS}.md(REUSE/EXTEND/RENAME/NEW matrix), re-interpreted for Appwrite.
How Appwrite changes the original design
Section titled “How Appwrite changes the original design”The original POS plan extended TypeORM entities and split queries (GraphQL) from commands (REST). Appwrite changes several of those decisions:
| Original (TypeORM/BetterAuth era) | Appwrite-era treatment |
|---|---|
Legacy Product → rename to LoanProduct to free Product for POS SKU (risky migration) |
Conflict dissolves. POS Product is a new Appwrite collection in its own namespace; the legacy loan Product stays in Postgres/TypeORM untouched until its own later migration. No rename, no FK rewrite. |
Single Transaction ledger extended with SALE/VOID |
New Appwrite transactions collection (POS ledger) with salesOrderId/shiftId/terminalId/employeeId + clientId. Legacy financial Transaction stays in TypeORM until migrated. |
Reuse PaymentIntent (extend) |
New Appwrite payment_intents collection (PSP-agnostic shape); the PSP adapter stays NestJS code. |
Reuse AuditLog table |
New Appwrite audit_log collection (action/entity/entityId/before/after JSON). |
Merchant → Store operational scope |
Store = an Appwrite Team (tenant boundary); Merchant = a merchants collection grouping stores (stores.merchantId). Document permissions scope by store team. (Updated 2026-06-19: store=team, not merchant=team.) |
| BetterAuth org role ↔ per-store POS role bridge | POS role = store-team membership role (cashier/supervisor/manager); role→permission map is a code matrix. No pos_roles/pos_permissions collections. (Updated 2026-06-19.) |
| “GraphQL for queries, REST for commands” | Changed: simple reads/writes = Appwrite SDK directly from clients under RLS (no NestJS CRUD proxy); complex/durable commands = NestJS REST/DBOS. GraphQL dropped. (Updated 2026-06-19: clarified no-proxy.) |
Reuse dbos for durable flows |
Unchanged — settlement, fiscal stamping, PSP retries stay DBOS workflows in NestJS. |
Offline mode post-MVP via clientId |
Promoted — offline-first from the start; clientId idempotency baked into sales collections. |
Collections by domain
Section titled “Collections by domain”These are the AppwriteData epic’s domain groups (one ticket per group). See ../diagrams/data-model.md for the ER view.
catalog
Section titled “catalog”categories, products (POS SKU), variants (single-axis), barcodes, tax_rates.
foundations
Section titled “foundations”merchants (org), merchant_settings (per-merchant prefs — notifications/biometric/language/currency), merchant_themes (receipt/branding theme), stores (carry storeTeamId + merchantId), terminals (storeId / kind / pairingCode / lastSeenAt / currentShiftId), audit_log.
inventory
Section titled “inventory”stock_levels ((variantId, storeId) → qty), stock_movements (append-only ledger).
carts, line_items, discounts, sales_orders, customers (customer directory — sales_orders.customerId), order_payments, transactions (POS ledger), payment_intents, receipts (thin artifact).
employees (POS profile linked 1:1 to the Auth user: userId + storeTeamId + pinHash), shifts, cash_movements, approvals. (2026-06-19: pos_roles/pos_permissions dropped — roles are store-team memberships + a code matrix.)
reporting
Section titled “reporting”shift_reports (frozen Z-report snapshot per closed shift), daily_sales_rollups (incremental per-store/day aggregate). (2026-06-24: added for the hybrid reporting strategy — see reporting.md.) Both are derived & reconcilable from the transactions ledger, never the source of truth, and key on the store-local business date (stores.timezone).
wallet (merchant balance) (2026-07-11: added per PDR-0003 / TDR-0004)
Section titled “wallet (merchant balance) (2026-07-11: added per PDR-0003 / TDR-0004)”merchant_balances (one snapshot row per merchant — availableBalance/pendingBalance/totalEarnings/totalWithdrawals; owners-only visibility, owner = the additive merchants.ownerId column decided 2026-07-12), balance_movements (append-only ledger: type CREDIT/DEBIT/COMMISSION/WITHDRAWAL/DEPOSIT/TRANSFER_IN/TRANSFER_OUT/REFUND, balanceAfter, undoFor), plus the Deposit/Charge/Payment trio: deposits (customer “payments”/top-ups, channels A–E, status pending → declined | available → applied → canceling | canceled | refunded; client-registerable only for A–D since api #967 — POST /pos/deposits refuses channel E, CARD_TERMINAL, with a coded 400 deposit_channel_not_allowed regardless of caller role; E is written only internally, in-process, by sale finalize / settlement-open / the card-charge workflow — see card-settlement-model.md), charges (reference existing line_items — additive, no cart refactor), payments (Deposit↔Charge application records; split payments = N deposits × M charges of one cart). The trio coexists with the shipped carts → sales_orders → order_payments/transactions flow. All wallet mutations are serialized per merchant through the DBOS wallet_processing partitioned queue — never written directly (TDR-0004). The snapshot is a cache of the ledger; a recompute job asserts Σ movements == balance.
value_added_services (partly built — see value-added-services.md)
Section titled “value_added_services (partly built — see value-added-services.md)”providers (carriers/billers/PIN brands mirror), provider_products (denomination/price mirror), serialized_units (chips/SIM inventory) are live. service_fulfillments (external-call record) and pin_vouchers (PIN custody) are still to build, and the line_items/transactions VAS field additions are not yet applied. VAS product kinds surface through the unified POS flow (a kind discriminator on products/line_items).
notifications
Section titled “notifications”notifications (type/title/body/refType/refId/audience/contextJson), notification_reads (per-user read/dismiss).
config / platform
Section titled “config / platform”meta (_meta, global key/value config), feature_flags (rollout flags).
Reference diagram — with caveats
Section titled “Reference diagram — with caveats”../diagrams/POS Conceptual Model.png (PO-drawn system overview) is a useful relationship map (Merchant → Balance → Movements + Transfer, the polymorphic cart line — TAE/PIN/Service/Product, Client ⇢ Cart, Cancel Order → Approval). Caveats (PO, 2026-07-12) — do not implement these parts as drawn:
- The auth cluster (Users → Auth → Members/Passkeys/Sessions/Accounts → Teams/Organizations) still reflects the decommissioned BetterAuth model; identity is Appwrite Auth (see auth-and-multitenancy.md).
- “Pay thru Balance” is NOT a general Order Payment path in MVP — the wallet debit is scoped to VAS fulfillment only (PDR-0003, Track K wiring).
- Payment intents are not generalized to all three tender paths —
payment_intentsstays card-specific; cash tenders keep the shippedorder_payments/transactionspath. - The deposit channels A–E (PDR-0003 Decision 3) are not drawn yet — Balance Movement is fed by Transfer only in the diagram.
Money units — integer minor units (×10000)
Section titled “Money units — integer minor units (×10000)”Every monetary value in the system — Appwrite integer columns (prices, openingFloat,
countedCash, expectedCash, variance, cash_movements.amount, tender/settlement
amounts, commissions, wallet balances), the NestJS DTOs, both clients, the wire payloads
(request and response), and every test fixture — is an integer in minor units:
MINOR_SCALE = 10_000, so $1.00 = 10000 and $0.05 = 500. Rationale: integers only,
never floats, so money math can’t drift.
- Convert only at the human edge — parse pesos→minor on input, format minor→pesos on render. Nothing in storage or on the wire is ever a peso/major-unit or decimal value.
- Per repo: web brands the type
Minor(terminales-web/src/core/money.ts) so a peso-vs-minor mix-up is a compile error; API money DTO fields are@IsInt(they reject decimals at the boundary); Android holds money as cents internally (…Cents: Long) and crosses to minor units only at the wire boundary (ShiftAmount.centsToMinor/minorToCents) — never as a major-unitDouble. - Sole exception:
denominationCountskeys (shift close) are major-unit face values ("500","0.5") which the server converts withtoMinor. Rates/ratios (taxRate0.16) are fractional, not money amounts. - Cross-repo contract, learned the hard way: a stale “all money is major units”
docstring on the API’s shift-close snapshot led the Android client to serialize shift
money as major-unit
Doubles — a 10000× mis-scale that also 400’d@IsIntopen-shift. Postmortem + the three-repo fix map:terminales-web/docs/pos/shift-model-c.md§8.
This rule is also stated in ../AI_CONTEXT.md (hard rule 9) so every
agent session loads it.
Atomicity & consistency
Section titled “Atomicity & consistency”- Simple multi-attribute updates use Appwrite transactions where sufficient.
- Multi-step, must-succeed flows (stock decrement on sale, multi-tender settlement, PSP capture + retry, fiscal stamping) stay NestJS/DBOS durable workflows. See
nestjs-dbos. - Offline writes carry a
clientId; the server dedups so re-sync never double-applies. See offline-and-idempotency.md.
Reporting-driven field additions
Section titled “Reporting-driven field additions”To make reports correct and cheap without later backfills, several sales/staff/catalog collections carry extra fields beyond the minimal checkout contract. Historical reports must reflect point-in-time values, so reporting-relevant attributes are snapshotted at write time rather than joined from mutable catalog rows. Authoritative field lists live in reporting.md §“Data-model adjustments”; the headline additions:
line_items:productId,categoryId,productName,sku,unitCost(margin without joining catalog).sales_orders:shiftId,terminalId,finalizedAt,businessDate,voidedAt,voidedBy,voidReason,refundedAmount,rolledUpAt(R9 C7, api #970 — stamped once a delivered VAS sale’s contribution lands indaily_sales_rollups, so a backfill or workflow replay never double-counts).transactions:commission,netAmount,paymentMethod,provider,entryMode,cardBrand,completedAt, statusREFUNDED.shifts:storeId,variance(counted − expected).discounts:appliedBy,approvalId(loss-prevention / exceptions report).products:costPrice— wired end-to-end since R9 C3 (api #968).POST /pos/products/PATCH /pos/products/:idaccept it (whole centavos, minor units, not negative) and persist it; before #968 the field was silently refused and never stored, so the web’s «Costo» input (§14.8.1 of the technical manual) had no effect. Used for margin and inventory valuation.
Same-day void (supervisor-approved reversal)
Section titled “Same-day void (supervisor-approved reversal)”A finalized ticket can be reversed within the current shift — a same-day void, distinct from a refund (refunds are post-MVP). Rules (web-terminal #61, api same-day-void + staff-approval-audit):
- Supervisor-approved. Every void requires a supervisor PIN, sent to the backend for server-side verification only — never compared in the browser. The PIN authorizes an
approvalsrow (type: 'void', amount) that the backend re-verifies before applying the void. - Free-text reason required (bounded length); it lands on
sales_orders.voidReasonwithvoidedAt/voidedBy. - CARD tickets additionally cancel the linked
payment_intentsrecord as part of the void. - VAS sales are never voided or refunded (PO, R9 2026-09-26): 409
vas_sale_not_voidable— see value-added-services.md § VAS and the shift drawer. - A failed/reversed VAS sale’s order ends
CANCELLED, notFINALIZED(PO ruling, R9 C8; terminales-api #971):sales_orders.statusgains a fourth value,CANCELLED— a sale that never happened, distinct fromVOID(which reverses a sale that did complete and did move money). The fulfillment workflow sets it idempotently as the last step of its failure path;void/refundstill answervas_sale_not_voidable(409) exactly as before. Clients show it as «No completada» (web #1146, Android #502), excluded from revenue the same way aVOIDis. Backfill:pnpm backfill:vas-failed-cancelled(dry-run by default) moves pre-#971 failed VAS orders offFINALIZED. - Endpoint shape: approve via the POS approvals flow, then
POST pos/sales-orders/:id/voidwith{ reason, supervisorPin }. Built mock-first on web (src/app/terminal/voids/), to be re-validated against the generated DTOs whenpnpm api-typesruns.
Shift required to sell (turno gates the sale)
Section titled “Shift required to sell (turno gates the sale)”Decision (PO, 2026-08-12): finalizing a sale requires an open shift (turno) — for all sales, cash and card — gated behind feature_flag_pos_staff_shifts. This resolves the previously open design question in ../design/screens/35-fab-acceso-rapido.md (“qué acciones se ocultan/deshabilitan sin turno”): the sale is one of them.
- Why. The shift is the cash-drawer accountability envelope (opening float →
cash_movements→ corte Zvariance = counted − expected). A cash sale with no drawer session cannot be reconciled at close, and a same-day void is already bound to the current shift (§ above) — a shiftless sale would produce an unbindable void. Applying the rule to card sales too keeps one mental model and complete per-shift reporting (thetransactionsledger stays the source of truth; the shift is the accountability overlay). - Enforcement — add where
shiftIdalready flows (today@IsOptionalon every sale DTO):- api — a guard in the
pos/orders/finalizepreamble (mirroring the VAS guards insales.service.ts) rejects a finalize when the store has no open shift;held-orders(create-held-order.dto.ts, requiredshiftId) is the precedent. - web —
TotalsPanel.tsxcanTenderincludesshift != null. - mobile — the checkout path guards on
shiftSessionStore.currentShift.
- api — a guard in the
- UX — never a dead-end. With no open shift the sale CTA/checkout routes into the open-shift flow instead of showing an advisory banner over an enabled “Cobrar”. On web the open-shift step is a split-window screen (blue brand panel + right card, mirroring the PIN “Acceso rápido” layout) shown after PIN login when
shift === null. - When the flag is off (a merchant not running shifts) sales proceed unguarded; per Hard rule 1 (all flags ON for MVP) the guard is active in MVP.
- Update 2026-09-26 (PO ruling, R9 A2; terminales-api #964): a POS sale always requires an open shift. The flag is now the runtime setting
pos.shiftRequiredToSell(defaulttrue), admin-only (editableBy: 'admin') — an owner can no longer switch it off. The key stays registered only because the web and Android still read it; removing it (unconditional guard) is a cross-repo follow-up under feature-flag minimalism. The refusal is coded: 400sale_requires_open_shift(«La venta requiere un turno abierto; abre un turno para continuar») on finalize and settlement-open, and 400sale_shift_terminal_mismatch(«La venta usa un turno de otra terminal») underpos.shiftTerminalStrict. The web raises the open-shift gate over the kept cart from «Cobrar» (web #1130/#1133); Android routes a cash refusal to «Abrir turno» (terminales #500). VAS sales follow the same rule (value-added-services.md).
Cash-out approval (Salida) — verified server-side
Section titled “Cash-out approval (Salida) — verified server-side”Decision (PO, R9 A1, 2026-09-26; terminales-api #964). A cash-out (PAY_OUT / DROP) recorded by a caller below supervisor needs a supervisor approval that the api re-checks from the approvals row: action cash_movement, refId = the shift (same store), minted within the last 10 minutes, authorizedAmount ≥ the movement amount, and unused (the movement is written under cma_<approvalId>, so a replay or a concurrent second use collides and is refused). Any failure → 403 cash_movement_approval_required («La salida de efectivo requiere la autorización de un supervisor.») and nothing is written. Supervisors/managers need none (one they send is still verified). Cash-ins (PAY_IN, PICKUP) and the in-process cash-direct deposit need none. Before #964 the controller dropped approvalId, so any cashier could lower the drawer. Clients: web #1133 sends authorizedAmount and re-asks the PIN on the 403; Android #500 asks the supervisor PIN on every Salida. Staff PINs are 4–6 digits on every keypad.
Supervisor approval freshness — 10 minutes, per action (R9 C6)
Section titled “Supervisor approval freshness — 10 minutes, per action (R9 C6)”Decision (PO ruling in the R9 findings; terminales-api #969). The freshness rule the cash-out approval already used (§ above — minted within the last 10 minutes, approvals row re-checked server-side) now covers every other supervisor approval that gates a POS action: void (SALE_VOID, including cancel of an open order, POST /pos/orders/:id/cancel), refund (SALE_REFUND), discount over threshold (DISCOUNT_OVER_THRESHOLD) and shift-variance close (SHIFT_VARIANCE). Before #969 none of these four had an age check — an approval minted hours earlier still worked. A void approval is also single-use: its ledger row is keyed on the approvalId, so a second sale attempting to reuse the same approval collides and is refused, mirroring the cash-out’s cma_<approvalId> key.
Each refusal is a coded 403 (not a generic rejection), carrying the same es-MX sentence the action already used:
| Action | Code |
|---|---|
| Void (and cancel of an open order) | sale_void_approval_required |
| Refund | sale_refund_approval_required |
| Discount over threshold | discount_approval_required |
| Shift-variance close | shift_variance_approval_required |
Clients (web #1144, Android #501) react the same way to any of these four codes: re-open the supervisor PIN sheet for the same action (same order/sale/shift, same amount/reason) and retry once a fresh approval is granted — never a dead-end error, which is what happened before (e.g. a stale void approval answered «Autorizado pero la anulación falló. Reconcilia.»).
Shift close — expected cash (corte Z)
Section titled “Shift close — expected cash (corte Z)”Decision (Alfredo, 2026-09-09; gap #18 / Q-n86-4). At close the drawer’s expected cash is the textbook drawer expectation, in integer minor units (×10000):
expected cash = opening float + cash pay-ins − cash pay-outs + cash tenders of FINALIZED non-voided orders on this shift − cash change given. Voids reverse their cash (a voided order contributes nothing).
- The sales term is not optional. A prior gap (#18) computed expected cash as
opening float + net(cash_movements)only — omitting the shift’s cash sales — so a finalized cash sale was silently missing from the close (N86/qa-design: a $3 cash sale did not appear; the close read500 + 200 − 125 = $575instead of$578). The corrected figure includes the shift’s CASH tenders. - Source of truth = the same aggregation the Z-report uses. Collect the shift’s orders by
sales_orders.shiftId, drop VOID orders, and sum the CASHorder_payments(joined bysalesOrderId;order_paymentscarries noshiftIdby design — the shift link lives onsales_orders).sales_orders.shiftIdis stamped by both the legacy finalize path and the unified settlement path, so both are counted. The close derivation and the Z-report must read the one shared formula so they cannot drift. - Change given is already netted in the tender amount on the unified path (the recorded
order_payments.amountis the applied/net cash that stayed in the drawer); the “− cash change given” clause must be honoured without double-counting — only subtractchangeGivenwhere the storedamountis the gross tendered figure. - Minor units throughout — opening float,
cash_movements.amount, andorder_payments.amountare all minor units; never re-scale (toMinor) an already-minor value. Cf. Hard rule 9 + the shift-model-c postmortem (§ Money above). - One figure, many readers.
variance = countedCash − expectedCash, the frozenshift_reports(corte Z) snapshot, and the web “Cerrar turno” preview (pos/shifts/:id/close-snapshot) all read this single derived value; the API recomputes it authoritatively at close (the client preview is advisory only).
Nuance (PO, 2026-09-10, api PR #697): the sales term counts NON-VOID orders exactly as the Z report does — including an in-flight order whose cash leg is already recorded at the close instant — so the close snapshot and the Z report can never disagree; the cash is physically in the drawer.
Out of immediate scope (later track)
Section titled “Out of immediate scope (later track)”Migrating the legacy non-POS entities (lending/deposits/transfers/merchant-balance, etc.) to Appwrite. The active backlog is POS; TypeORM keeps serving legacy domains until the Epic G decommission ticket’s blockers are all done.
Value-added services (TAE / Pines / Services / Chips): no longer “deferred, undecided”. As of 2026-06-24 these are slated to migrate into the Appwrite POS model via the unified POS flow (with a
kinddiscriminator + a fulfillment collection; chips as serialized physical inventory). The recommended entities, fields and orchestration are specified in value-added-services.md and the legacy contracts inapitae-webservice. Collections are added to the authoritative lists above only when their tickets are created.
Tenant & store scoping (summary)
Section titled “Tenant & store scoping (summary)”Every tenant-owned document carries the owning team (merchant) and, where relevant, a store attribute. Document-level permissions enforce that a user only reads/writes their team’s data, and cashiers are further scoped to their store. Details in auth-and-multitenancy.md.