Skip to content

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/deposits refuses 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; VAS CANCELLED order status; product costPrice wired 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.

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.

These are the AppwriteData epic’s domain groups (one ticket per group). See ../diagrams/data-model.md for the ER view.

categories, products (POS SKU), variants (single-axis), barcodes, tax_rates.

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.

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

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

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.

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 (type/title/body/refType/refId/audience/contextJson), notification_reads (per-user read/dismiss).

meta (_meta, global key/value config), feature_flags (rollout flags).

../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_intents stays card-specific; cash tenders keep the shipped order_payments/transactions path.
  • 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-unit Double.
  • Sole exception: denominationCounts keys (shift close) are major-unit face values ("500", "0.5") which the server converts with toMinor. Rates/ratios (taxRate 0.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 @IsInt open-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.

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

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 in daily_sales_rollups, so a backfill or workflow replay never double-counts).
  • transactions: commission, netAmount, paymentMethod, provider, entryMode, cardBrand, completedAt, status REFUNDED.
  • 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/:id accept 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 approvals row (type: 'void', amount) that the backend re-verifies before applying the void.
  • Free-text reason required (bounded length); it lands on sales_orders.voidReason with voidedAt/voidedBy.
  • CARD tickets additionally cancel the linked payment_intents record 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, not FINALIZED (PO ruling, R9 C8; terminales-api #971): sales_orders.status gains a fourth value, CANCELLED — a sale that never happened, distinct from VOID (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/refund still answer vas_sale_not_voidable (409) exactly as before. Clients show it as «No completada» (web #1146, Android #502), excluded from revenue the same way a VOID is. Backfill: pnpm backfill:vas-failed-cancelled (dry-run by default) moves pre-#971 failed VAS orders off FINALIZED.
  • Endpoint shape: approve via the POS approvals flow, then POST pos/sales-orders/:id/void with { reason, supervisorPin }. Built mock-first on web (src/app/terminal/voids/), to be re-validated against the generated DTOs when pnpm api-types runs.

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 Z variance = 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 (the transactions ledger stays the source of truth; the shift is the accountability overlay).
  • Enforcement — add where shiftId already flows (today @IsOptional on every sale DTO):
    • api — a guard in the pos/orders/finalize preamble (mirroring the VAS guards in sales.service.ts) rejects a finalize when the store has no open shift; held-orders (create-held-order.dto.ts, required shiftId) is the precedent.
    • web — TotalsPanel.tsx canTender includes shift != null.
    • mobile — the checkout path guards on shiftSessionStore.currentShift.
  • 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 (default true), 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: 400 sale_requires_open_shift («La venta requiere un turno abierto; abre un turno para continuar») on finalize and settlement-open, and 400 sale_shift_terminal_mismatch («La venta usa un turno de otra terminal») under pos.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.»).

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 read 500 + 200 − 125 = $575 instead 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 CASH order_payments (joined by salesOrderId; order_payments carries no shiftId by design — the shift link lives on sales_orders). sales_orders.shiftId is 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.amount is the applied/net cash that stayed in the drawer); the “− cash change given” clause must be honoured without double-counting — only subtract changeGiven where the stored amount is the gross tendered figure.
  • Minor units throughout — opening float, cash_movements.amount, and order_payments.amount are 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 frozen shift_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.

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 kind discriminator + 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 in apitae-webservice. Collections are added to the authoritative lists above only when their tickets are created.

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.