# Domain Knowledge — TinyPOS (`tnx-pos`)

> Produced by the Reversa **Detective** (phase: interpretation) · doc_level: `complete`
> Generated on 2026-09-18

**Confidence scale:** 🟢 CONFIRMED (read directly from code) · 🟡 INFERRED (pattern-based, may be wrong) · 🔴 GAP (needs human validation)

This document captures the *why* of the system — the implicit business knowledge extracted from the code, the migrations, the seeders and the Git history. It complements the Archaeologist's structural excavation (`code-analysis.md`, `data-dictionary.md`, `flowcharts/`) with domain semantics, a shared vocabulary, and the retroactive design decisions recorded as ADRs in `adrs/`.

TinyPOS is a single-store point-of-sale and back-office for a Vietnamese neighbourhood shop (product labels are in Vietnamese). It runs on Laravel 5.5/5.6 with the `salipropham/laravel55-admin` fork of Encore\Admin providing auth, RBAC scaffolding and the reference-data CRUD screens. The cashier terminal (`/pos`) is the daily-use surface; everything else is back-office.

---

## Ubiquitous language (glossary)

Terms are given in the code identifier, the Vietnamese UI label where one exists, and the business meaning.

| Term | UI / code | Meaning | Confidence |
|------|-----------|---------|------------|
| **POS / sales terminal** | `/pos`, `PosController` | The cashier screen where a cart is built and an order is submitted. Root `/` 301-redirects here. | 🟢 |
| **Order** | `orders`, `Order` | A single sale. Lives as a `draft` (parked/held cart) or `done` (finalised sale). No soft-delete — deletion is permanent. | 🟢 |
| **Draft order** | `status = draft` | A held/parked cart that can be recalled in the POS draft picker (`OrderController::scan`) and finalised later. No points, no debt posted yet. | 🟢 |
| **Done order** | `status = done` | A finalised sale. Points are awarded and any POS debt is posted to the customer ledger at this moment. | 🟢 |
| **Line / order line** | `order_product` pivot | One product+unit row on an order, with `qty`, `price` (captured at sale time), `unit_id`, `conversion_qty`. | 🟢 |
| **`count`** | `orders.count` | Number of distinct **lines** on the order, *not* the sum of quantities. | 🟢 |
| **Customer type** | `customers.type` | Pricing tier: `khach_le` (retail / walk-in — the default when null), `si_1` (wholesale tier 1), `si_2` (wholesale tier 2). Drives which price a line gets. | 🟢 |
| **Retail price** | `sale_price` (fallback `price`) | The price charged to `khach_le` customers and the default when no wholesale price is set. | 🟢 |
| **Wholesale price** | `wholesale_prices` (JSON keyed by type) | Per-type price overrides for `si_1` / `si_2`. Exposed in the product form only for the wholesale types (all `Customer::$types` except `khach_le`). | 🟢 |
| **Base unit** | `products.unit` (string) | The product's primary unit of measure, stored **by name** (e.g. "hộp", "lon"). Populated from `Unit::all()->pluck('name','name')`. | 🟢 |
| **Conversion unit** | `product_units` (`unit_id`, `conversion_qty`) | Alternative sale units for a product, stored **by FK** to `units`. `conversion_qty` = how many base units one conversion unit contains. | 🟢 |
| **Conversion quantity** | `conversion_qty` | Divisor applied to the price when a non-base unit is sold: `unitPrice = basePrice / conversion_qty` (guarded `conversion_qty > 0`). | 🟢 |
| **Reward point** | `products.reward_point`, `orders.earned_point`, `customers.points` | Loyalty points. A product carries a per-unit `reward_point`; an order accrues `earned_point`; a customer accumulates a `points` balance. | 🟢 |
| **Points awarded** | `orders.points_awarded_at` | Idempotency guard timestamp — set when an order's points are first credited so that reopen/re-finalise cannot double-award. | 🟢 |
| **Gift** | `gifts`, "Quà tặng" | A loyalty reward redeemable with points. Has `points` cost, per-customer `limit` (0 = unlimited), stock `quantity`, and running `used`. | 🟢 |
| **Available stock** | `quantity_available` (computed) | `quantity - used`, not stored. Out-of-stock test is strict `=== 0`. | 🟢 |
| **Debt (Nợ / Công nợ)** | `customers.debt_total`, `customer_debts`, `/debts` | Customer accounts-receivable. An append-only ledger of entries plus a running `debt_total` balance on the customer. | 🟢 |
| **POS debt** | `customer_debts.type = pos_debt` | Debt created automatically when a `done` order is submitted with `debt_amount > 0`. Posted exactly once per order (frozen by `debt_locked`). | 🟢 |
| **Manual debt** | `type = manual_debt` | Debt added by staff outside a sale (`CustomerController::storeDebt`). | 🟢 |
| **Repayment** | `type = repayment` | A customer paying down their balance (`storeRepayment`). Cannot exceed `debt_total`. | 🟢 |
| **Debt void** | `type = debt_void` | Reversal entry written when a debt-bearing order is deleted; clamped by the current balance. | 🟢 |
| **Category** | `categories`, "Danh mục" | Product classification. Two-level: top-level group (`parent_id` null) → child (assignable to products). Only children appear in the product dropdown. | 🟢 |
| **Brand** | `brands`, "Nhãn hiệu" | Product label/manufacturer reference data. | 🟢 |
| **Unit** | `units`, "Đơn vị" | Unit-of-measure reference data (see base vs conversion unit above). | 🟢 |
| **Customer statistics** | `customer_order_summary`, `CustomerController::statis` | A **nightly precomputed** per-customer rollup (order count, spend, points, per-category breakdown). Not live. | 🟢 |
| **Milk / Medicine special cases** | category ids **1** (Sữa) and **8** (Thuốc) | Two categories singled out in the statistics rollup; everything else is aggregated as "other". | 🟢 |
| **Administrator** | `admin_users`, Encore\Admin | The only kind of system user. There is no customer login — customers are CRM records, not accounts. | 🟢 |

---

## Core domain rules

Rules are grounded in the code the Archaeologist mapped; locations point at the authoritative source. Confidence reflects whether the rule is enforced in code (🟢) or is an interpretation of intent (🟡).

### Pricing

1. **Price resolution by customer type.** A line's unit price is `wholesale_prices[customerType]` when that type has an entry, otherwise `sale_price` (which itself falls back to `price`). When a non-base unit is chosen, the price is divided by that unit's `conversion_qty` (guarded `> 0`). 🟢 — `app/Models/Product.php:90-106`.
2. **Retail is the default tier.** A customer with `type` null is treated as `khach_le`; walk-in POS sales with no selected customer price at retail. 🟢 — `customers.type` null→`khach_le`.
3. **Prices are captured at sale time.** `order_product.price` and `conversion_qty` are snapshotted onto the line; later catalogue price changes do not rewrite historical orders. 🟢 — `data-dictionary.md` / `OrderController::store`.

### Order totalling & lifecycle

4. **Line totalling.** Per line: `subtotal += round(price,1) * qty`; `earned_point += round(reward_point,1) * qty`; `count` counts lines. `total = subtotal - discount_amount`; `paid = total`. Unknown product codes on submit are **silently skipped**. 🟢 — `OrderController::store:92-119`.
5. **Points are awarded only on `done`, and only once.** Customer `points += earned_point` and `points_awarded_at` is stamped when an order reaches `done`; the timestamp makes award idempotent across `done → draft → done` cycles. 🟢 — `OrderController`, `app/Models/Order.php:53-67`.
6. **Editability window.** An order is editable while `draft`, or while `done` and within `limit_hours_editable` (24h) of `updated_at`. After that it is read-only. 🟢 — `Order::is_editable`.
7. **Deletion reverses side effects then hard-deletes.** `destroy` runs in a transaction: reverse earned points (clamped ≥ 0), void any `pos_debt` (clamped by current balance, shortfall noted), then permanently delete; `order_product` cascades and `customer_debts.order_id` is set null. 🟢 — `OrderController::destroy:362-383`, `CustomerDebt::voidForOrder`.

### Debt (accounts receivable)

8. **Debt requires a customer and cannot exceed the order total.** On submit, `debt_amount > 0` requires a selected customer and must be ≤ `total`. 🟢 — `OrderController` / `PosController:308-334`.
9. **POS debt posts exactly once and then freezes.** Once a `pos_debt` ledger row exists for an order, the order is `debt_locked` — the debt input is disabled and the server ignores further debt changes. Prevents double-posting across reopen/re-finalise. 🟢 — `Order::debt_locked`, `PosController:549-554`.
10. **The ledger is append-only with a locked running balance.** `CustomerDebt::record` locks the customer row, rejects a repayment exceeding `debt_total`, adjusts `debt_total`, and writes `balance_after`. `pos_debt`/`manual_debt` add; `repayment` subtracts; `debt_void` reverses. 🟢 — `app/Models/CustomerDebt.php:38-64`.
11. **The debts page is a pure projection.** `/debts` lists customers with `debt_total > 0`; it performs no writes — its action buttons post to the customer debt/repayment endpoints. 🟢 — `DebtController`, `debts.blade.php`.

### Loyalty (points & gifts)

12. **Gift redemption is gated on three conditions.** The gift must exist and not be out of stock (`quantity_available !== 0`); the customer's prior redemptions of that gift must be under `gift.limit` (0 = unlimited); and `gift.points <= customer.points`. 🟢 — `app/Models/Customer.php:64-89` (`checkGiftAvailable`).
13. **Redemption is atomic.** Attaching the gift pivot, incrementing `gift.used`, and decrementing `customer.points` happen inside a transaction with `lockForUpdate` on customer and gift and a re-check of availability under the lock. 🟢 — `CustomerController::redeemRewardPoints` (made transactional 2026-09-17; see ADR-0007).
14. **Point claw-back on order deletion.** Deleting an order reverses its earned points via `reversePointsForOrder`, clamped so the customer balance never goes negative (`max(0, points - earned_point)`), guarded by `points_awarded_at`. 🟢 — `app/Models/Customer.php:97-112`.

### Reference data & catalogue

15. **Product codes.** `code` is unique among **non-deleted** products; auto-generated as `'P' + category_id + pad(max(id)+1, 6)` when left blank. 🟡 The `max(id)+1` scheme is not concurrency-safe (see gaps). 🟢 — `ProductController`, `Product::generateCode`.
16. **Soft-deleted products are renamed.** On soft-delete the `deleted` model hook renames the row to `'(DELETED) ' + name`. 🟢 — `app/Models/Product.php:27-39`.
17. **Only child categories are product-assignable.** The product dropdown is `Category::whereNotNull('parent_id')`; top-level categories act as group headers. `category_id` is required. 🟢 — `ProductController:73,157`.
18. **Protected default reference rows.** Reference-data grids disable delete on all rows; brand id 1 and unit id 1 are edit-locked as the default/fallback rows. The category id-1 edit-lock is **commented out** (see GAP-C1). 🟢 — Brand/Unit/Category controllers.

### Statistics & reporting

19. **Statistics are a nightly snapshot, not live.** `customer_order_summary` is rebuilt by `Order::summaryLogging` on a daily scheduler; `CustomerController::statis` reads the snapshot. Same-day sales are therefore not reflected until the next run. 🟢 — `Order::summaryLogging`, `app/Console/Kernel.php` (see ADR-0005).
20. **Milk and medicine are reported separately.** Category ids 1 (milk) and 8 (medicine) get their own buckets in the rollup; all other categories collapse into "other". These ids were later extracted into class variables (`refactor(order): extract hardcoded statistic category_id`). 🟢 — `Order::summaryLogging`, `CustomerController::statis:213-260` (see ADR-0006).
21. **Dashboard KPIs are day-scoped for orders only.** The "orders" KPI counts `done` orders created *today*; "products"/"customers" are total row counts. The day-range chart deliberately excludes 00:00–06:00 (store closed overnight). 🟢 — `DashboardController` (confirmed intentional with the team).

---

## Open gaps for human validation 🔴

Carried forward from excavation, these are business-intent questions the Detective cannot settle from code alone.

- **GAP-C1 — Category parent linkage.** The UI never sets `category_id`'s `parent_id` (absent from the widget form and `form()`), yet only categories with a non-null `parent_id` are assignable to products. A UI-created category can therefore never be attached to a product; parent/child linkage must be done directly in the DB. **Pending:** is this intentional pre-provisioning, or an unfinished feature? 🔴
- **GAP-C2 — Category default lock disabled.** Unlike brands/units, the category id-1 edit-lock is commented out. **Pending:** intentional (categories have no protected default) or an oversight? 🔴
- **GAP-U1 — Dead `units.category_id`.** Declared but never read or written. Appears vestigial. 🔴 (low risk)
- **GAP-U2 — Base-unit vs conversion-unit modelling.** Base units are stored by name (`products.unit` string) while conversions are stored by FK (`product_units.unit_id`). Renaming a unit would desync existing base-unit strings. 🟡 documented inconsistency; confirm whether unit renames are expected.
- **GAP-O1 — Hard-deleted orders vs retained debt trail.** ✅ Confirmed with the team (2026-09-18): orders being hard-deleted while `debt_void` ledger entries persist matches reporting/audit expectations — acceptable as designed. 🟢
- **GAP-O2 — Unused discount model.** `discounts` table and `orders.discount_id` FK exist, but no code applies a discount by id — only free-form `discount_amount`. **Resolved 2026-09-23:** kept intentionally for future use, not abandoned — do not drop the table/FK/relation on migration or rewrite. 🟢
- **GAP-P1 — Picture-cleanup path bug.** `update()` unlinks from `app/public2` (wrong root), so old images are never deleted. Team confirming whether `public2` was intentional before fixing. 🟡 CONFIRMED bug, fix pending.

See `adrs/` for the retroactive architecture decisions, `state-machines.md` for entity lifecycles, and `permissions.md` for the access-control model.
