# Business Rules — TinyPOS (`tnx-pos`)

> Extracted from verified Reversa artifacts: `_reversa_sdd/domain.md`, `_reversa_sdd/permissions.md`, `_reversa_sdd/state-machines.md`. All rules are 🟢 CONFIRMED (read directly from code) unless noted otherwise.

---

## Pricing

| # | Rule | Source |
|---|------|--------|
| PR-1 | A line's unit price is `wholesale_prices[customerType]` when the customer type has an entry in that JSON field; otherwise `sale_price` (falling back to `price`). | `app/Models/Product.php:90-106` |
| PR-2 | When a non-base unit is chosen for a line, the resolved price is divided by that unit's `conversion_qty`; `conversion_qty` is guarded to be `> 0`. | `app/Models/Product.php:90-106` |
| PR-3 | A customer with `type = null` is treated as `khach_le` (retail/walk-in), which is the default pricing tier. | `domain.md` · `customers.type` |
| PR-4 | `order_product.price` and `conversion_qty` are snapshotted onto the order line at sale time; later catalogue price changes do not rewrite historical orders. | `OrderController::store` |

---

## Order Totalling

| # | Rule | Source |
|---|------|--------|
| OT-1 | Per-line subtotal: `subtotal += round(price, 1) * qty`; per-line points: `earned_point += round(reward_point, 1) * qty`. | `OrderController::store:92-119` |
| OT-2 | `orders.count` counts the number of distinct **lines** on the order, not the sum of quantities. | `domain.md` · ubiquitous language |
| OT-3 | Order total: `total = subtotal - discount_amount`; `paid = total`. | `OrderController::store:92-119` |
| OT-4 | Product codes not found at submission time are **silently skipped** (unknown codes do not abort the sale). | `OrderController::store:92-119` |

---

## Order Lifecycle & Editability

| # | Rule | Source |
|---|------|--------|
| OL-1 | An order is editable while `draft`, or while `done` and within `limit_hours_editable` (24 hours) of `updated_at`. After that it is read-only. | `Order::is_editable` |
| OL-2 | Reopening a `done` order to `draft` does not clear `points_awarded_at`; the award stays in place so re-finalising never double-credits points. | `state-machines.md` |
| OL-3 | Deleting an order runs in a transaction: reverses earned points (clamped ≥ 0), voids any `pos_debt` (clamped by current balance, shortfall noted), then permanently hard-deletes the order. `order_product` cascades; `customer_debts.order_id` is set null. | `OrderController::destroy:362-383`, `CustomerDebt::voidForOrder` |
| OL-4 | Orders have no soft-delete; deletion is permanent. | `domain.md` |

---

## Loyalty Points

| # | Rule | Source |
|---|------|--------|
| LP-1 | Customer `points` are increased (`+= earned_point`) and `points_awarded_at` is stamped **only when an order transitions to `done`** for the first time. | `OrderController`, `app/Models/Order.php:53-67` |
| LP-2 | `points_awarded_at` is an idempotency guard; once set it prevents re-awarding across `done → draft → done` cycles. | `app/Models/Order.php:53-67` |
| LP-3 | Deleting an order reverses its earned points via `reversePointsForOrder`, clamped so the customer balance never goes negative: `points = max(0, points - earned_point)`. The reversal is guarded by `points_awarded_at` (draft orders with no award trigger no reversal). | `app/Models/Customer.php:97-112` |

---

## Gift Redemption

| # | Rule | Source |
|---|------|--------|
| GR-1 | Gift redemption is gated on three conditions: (1) the gift must exist and not be out of stock (`quantity_available !== 0`; strict equality); (2) the customer's prior redemptions of that gift must be below `gift.limit` (0 = unlimited); (3) `gift.points <= customer.points`. | `app/Models/Customer.php:64-89` (`checkGiftAvailable`) |
| GR-2 | Redemption is atomic: attaching the pivot, incrementing `gift.used`, and decrementing `customer.points` execute in a transaction with `lockForUpdate` on both customer and gift, plus a re-check of availability under the lock. | `CustomerController::redeemRewardPoints` |
| GR-3 | `quantity_available` is computed as `quantity - used` (not stored). The out-of-stock test is strict `=== 0`, so a hypothetical `used > quantity` (negative available) would read as in-stock. 🟡 | `domain.md`, `state-machines.md` |
| GR-4 | A gift's `quantity` field cannot be set below its current `used` value (dynamic `min:{used}` validation rule). | `state-machines.md` |

---

## Debt (Accounts Receivable)

| # | Rule | Source |
|---|------|--------|
| DR-1 | Recording a debt requires a selected customer; `debt_amount` must be `> 0` and must be `≤ order total`. | `OrderController`, `PosController:308-334` |
| DR-2 | A `pos_debt` ledger row is created automatically when a `done` order is submitted with `debt_amount > 0`. Once that row exists the order is marked `debt_locked`: the debt input is disabled and the server ignores further debt changes, preventing double-posting across reopen/re-finalise cycles. | `Order::debt_locked`, `PosController:549-554` |
| DR-3 | A `manual_debt` entry is added by staff outside a sale via `CustomerController::storeDebt`. | `domain.md` |
| DR-4 | A `repayment` entry decreases `debt_total`. A repayment is rejected if its amount exceeds the current `debt_total`. | `app/Models/CustomerDebt.php:38-64` |
| DR-5 | A `debt_void` reversal entry is written when a debt-bearing order is deleted; the amount is clamped by the current balance. | `CustomerDebt::voidForOrder` |
| DR-6 | `CustomerDebt::record` locks the customer row (pessimistic lock) for every balance mutation; every entry writes `balance_after`, providing a full audit trail even after the originating order is hard-deleted. | `app/Models/CustomerDebt.php:38-64` |
| DR-7 | The `/debts` page lists customers with `debt_total > 0` and performs no writes itself; actions post to the customer debt/repayment endpoints. | `DebtController`, `debts.blade.php` |

---

## Product Catalogue

| # | Rule | Source |
|---|------|--------|
| PC-1 | `products.code` is unique among non-deleted products. When left blank it is auto-generated as `'P' + category_id + pad(max(id)+1, 6)`. 🟡 The `max(id)+1` scheme is not concurrency-safe. | `ProductController`, `Product::generateCode` |
| PC-2 | On soft-delete, the model `deleted` hook renames the product to `'(DELETED) ' + name`. | `app/Models/Product.php:27-39` |
| PC-3 | Only **child** categories (those with a non-null `parent_id`) are assignable to products; top-level categories act as group headers. `category_id` is required on a product. | `ProductController:73,157` |
| PC-4 | Reference-data deletion is disabled on all brand, category, and unit rows in their admin grids. Brand id 1 and unit id 1 are additionally edit-locked as the default/fallback rows. The equivalent lock for category id 1 is commented out (see GAP-C2). | Brand/Unit/Category controllers |

---

## Statistics & Reporting

| # | Rule | Source |
|---|------|--------|
| SR-1 | `customer_order_summary` is a **nightly** precomputed snapshot rebuilt by `Order::summaryLogging` on a daily scheduler. `CustomerController::statis` reads this snapshot; same-day sales are not reflected until the next nightly run. | `Order::summaryLogging`, `app/Console/Kernel.php` |
| SR-2 | In the statistics rollup, category id 1 (Sữa / milk) and category id 8 (Thuốc / medicine) each get their own bucket; all other categories collapse into "other". | `Order::summaryLogging`, `CustomerController::statis:213-260` |
| SR-3 | Dashboard "orders" KPI counts `done` orders created **today**; "products" and "customers" are total row counts. The day-range chart deliberately excludes 00:00–06:00 (store closed overnight). | `DashboardController` |

---

## Access Control

| # | Rule | Source |
|---|------|--------|
| AC-1 | All application routes require an authenticated administrator (`admin` session guard). Anonymous requests are redirected to the login screen. | `routes/web.php:20-91`, `config/admin.php:29` |
| AC-2 | The route middleware stack is `['web', 'admin']` (authentication only); the package's `admin.permission` per-route check middleware is **not** applied. Effective authorization collapses to: authenticated = full access. | `routes/web.php`, `config/admin.php` |
| AC-3 | Every authenticated administrator has full CRUD over all resources: products, customers, orders, POS, debts, reference data, dashboard, and the Encore\Admin `/auth/*` screens. There is no cashier/manager role separation. 🟡 Confirmed intentional for a single-operator shop. | `permissions.md` |
| AC-4 | There is no customer-facing login; customers are CRM records, not system accounts. | `permissions.md` |
| AC-5 | The RBAC tables (roles, permissions) are seeded and present but are not enforced at the route level. The seeded Administrator role holds the `*` wildcard permission covering only the package's own `/auth/*` screens. | `permissions.md` |
