# User Stories — Point of Sale (Checkout)

> Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
> Generated on 2026-09-21

**Confidence scale:** 🟢 CONFIRMED · 🟡 INFERRED · 🔴 GAP

**Actor:** Store Administrator (cashier).
**Owning units:** `pos-terminal`, `pos-scan`, `products-pricing`, `customers-scan`, `orders-crud` (store/update), `orders-scan`, `orders-print`.

The POS terminal (`GET /pos`) is the daily-use surface and the app's effective landing page (root `/` 301→`/pos`). The server action is thin — it renders `pages.pos` and injects a ~630-line in-browser cart engine; the cart's behaviour is owned by the client and confirmed against the live UI. Finalising an order is where a cart becomes a durable financial record (points + debt), and that lives in `orders-crud`. 🟢

---

### US-POS-1 — Open the terminal

**As a** cashier, **I want** the terminal to open ready to sell, **so that** I can start ringing up items immediately.

- **Given** I am authenticated
- **When** I open `/` or `/pos`
- **Then** the cashier screen loads (header "Bán hàng"), an empty cart, and the scan box focused, wired to the collaborator endpoints (`/pos/scan`, `/customers/scan`, `/orders/scan?status=draft`) 🟢

Traces to: `pos-terminal/` (`PosController::index`, `routes/web.php:22,81`)

---

### US-POS-2 — Add a product by scanning a barcode

**As a** cashier, **I want** to scan a barcode and have the exact product added, **so that** checkout is fast and unambiguous.

- **Given** the cursor is in the scan box
- **When** I scan/enter a code and the client calls `GET /pos/scan?q=<code>`
- **Then** an exact `code` match returns `{ is_barcode: true, data: <product> }` and the product is added as a cart line 🟢
- **Given** the scanned line already exists (same `code` + selling unit)
- **When** I scan it again
- **Then** the existing line's quantity increments rather than adding a duplicate line (line key = `code + '__' + (unit_id || 'base')`) 🟢

Notes / gaps:
- Soft-deleted products are excluded from lookups. 🟢
- The lookup response is a **discriminated union** on `is_barcode` (object for a barcode hit, array for a name search) — consumers must branch. 🟢

Traces to: `pos-scan/` (`PosController::scan`), `pos-terminal/` (client cart engine)

---

### US-POS-3 — Find a product by name when the barcode misses

**As a** cashier, **I want** to type part of a product name and pick from matches, **so that** I can sell items whose barcode won't scan.

- **Given** no exact `code` match
- **When** the client falls back to the name search
- **Then** `GET /pos/scan?q=<text>` returns `{ is_barcode: false, data: [ ≤10 products ] }` (empty ⇒ `data: []`) and I choose one to add 🟢

Notes / gaps:
- Leading-wildcard `LIKE '%q%'` can't use an index — degrades at catalogue scale. 🟡
- Empty/missing `q` behaviour (`LIKE '%%'`) is incidental and unconfirmed. 🔴

Traces to: `pos-scan/` (`PosController::scan` name fallback)

---

### US-POS-4 — Sell in a different unit of measure

**As a** cashier, **I want** to change a line's selling unit (e.g. box vs. can), **so that** the price reflects how the customer is buying.

- **Given** a product with conversion units
- **When** I change the line's unit
- **Then** the units payload always offers the base unit first (`unit_id` null, `conversion_qty` 1) plus each `ProductUnit`, exactly one marked default 🟢
- **And** the line is re-priced synchronously via `GET /products/get-price`, dividing the base price by the unit's `conversion_qty` (guarded `> 0`) 🟢

Notes / gaps:
- Pricing is a **synchronous per-line** call (`async:false`) — it blocks the UI briefly per line. 🟡

Traces to: `products-pricing/` (`ProductController::getPriceByCustomerType`), `pos-terminal/`

---

### US-POS-5 — Attach a customer and apply their price tier

**As a** cashier, **I want** to attach an existing customer to the sale, **so that** the correct wholesale/retail prices and their loyalty account apply.

- **Given** I type in the customer search box
- **When** the client calls `GET /customers/scan?q=<text>`
- **Then** I get up to 10 matching customers (phone or name) and pick one; the client keys the selection by **phone**, not id 🟢
- **Given** a customer is attached
- **When** their tier is `si_1` / `si_2`
- **Then** the **whole cart re-prices** for that tier (`wholesale_prices[type]`, falling back to `sale_price` when the tier has no configured price) 🟢
- **Given** no customer is selected
- **Then** lines price at retail (`khach_le`) 🟢

Notes / gaps:
- Which parameter the live client actually sends to get-price (`id` vs `phone`) needs confirmation — the client remaps select2 value to phone. 🟡

Traces to: `customers-scan/` (`CustomerController::scan`), `products-pricing/`, `pos-terminal/`

---

### US-POS-6 — Quick-add a new customer mid-sale

**As a** cashier, **I want** to create a customer without leaving the terminal, **so that** a first-time shopper can be recorded and served in one flow.

- **Given** the customer isn't found
- **When** I use the quick-add modal (which posts to `POST /customers` with `Accept: application/json`)
- **Then** the new customer is created and returned as JSON, then attached to the cart 🟢

Notes / gaps:
- A quick-added customer is always `khach_le` (tier can't be set at create time — only on edit). 🔴 (flagged in `customers-crud`)

Traces to: `customers-crud/` (`store`, content-negotiated), `pos-terminal/`

---

### US-POS-7 — Apply a discount and see running totals

**As a** cashier, **I want** to apply a discount and see the total update live, **so that** I can quote the shopper an accurate price.

- **Given** a cart with lines
- **When** I enter a `discount_amount`
- **Then** the running total shows `total = subtotal − discount_amount` (client-side; recomputed authoritatively on submit) 🟢

Notes / gaps:
- The free-form discount is **uncapped** against subtotal, so a total can go negative. 🟡 (flagged in `orders-crud`)
- The `discounts` table / `orders.discount_id` FK exist but no code applies a discount by id — only free-form `discount_amount`. 🔴 (GAP-O2)

Traces to: `pos-terminal/`, `orders-crud/` (totalling engine)

---

### US-POS-8 — Take partial payment on credit (debt)

**As a** cashier, **I want** to let a known customer owe part of the total, **so that** I can extend store credit at checkout.

- **Given** a cart total and an attached customer
- **When** I enter a `debt_amount > 0`
- **Then** the debt is accepted only if a customer is selected and `debt_amount ≤ total` 🟢
- **Given** no customer is attached
- **Then** the debt input is cleared/disabled (debt requires a customer) 🟢
- **Given** the order already posted debt once
- **Then** the debt input is **locked** (`debt_locked`) and further changes are ignored 🟢

Notes / gaps:
- Debt validation is duplicated client- and server-side; a single source of truth is desired. 🔴 (flagged in `pos-terminal`)
- On a `done` order with `debt_amount > 0`, exactly one `pos_debt` ledger row is posted and the customer's `debt_total` increases — see [accounts-receivable.md](accounts-receivable.md). 🟢

Traces to: `pos-terminal/`, `orders-crud/` (`store`, debt guard), `customers-debt-actions/` (ledger)

---

### US-POS-9 — Park the cart as a draft

**As a** cashier, **I want** to hold an unfinished cart, **so that** I can serve another shopper and come back to it.

- **Given** a cart in progress
- **When** I submit with status `draft` to `POST /orders`
- **Then** the order is saved as `draft` (no points awarded, no debt posted), a toastr confirms, and I return to `/pos` 🟢

Traces to: `orders-crud/` (`store`, draft path)

---

### US-POS-10 — Resume a parked draft

**As a** cashier, **I want** to reopen a saved draft, **so that** I can finish a held sale.

- **Given** parked drafts exist
- **When** the client calls `GET /orders/scan?status=draft`
- **Then** up to 10 newest drafts return as a JSON array, each with full order + customer + line pivots to repopulate the cart in one round-trip 🟢
- **When** I select one
- **Then** the terminal rehydrates the cart and repoints the form to `PUT /orders/{id}` 🟢

Notes / gaps:
- The unguarded `?id=` preload path (`Order::find`) is a flagged risk. 🔴 (in `pos-terminal`)
- A customer-less draft can't be found by a `q` search (the `whereHas('customer')` filter), only via the `status=draft` list. 🟡 (in `orders-scan`)

Traces to: `orders-scan/` (`OrderController::scan`), `pos-terminal/`, `orders-crud/` (`update`)

---

### US-POS-11 — Finalise the sale

**As a** cashier, **I want** to complete the sale, **so that** the money, points, and any debt are recorded.

- **Given** a cart (new or a resumed draft)
- **When** I submit with status `done` to `POST /orders` (or `PUT /orders/{id}`)
- **Then** each line is priced by tier+unit, `subtotal`/`total`/`earned_point` are computed, line prices are snapshotted, and the order is saved `done` 🟢
- **And** loyalty points are awarded to the customer **exactly once** (guarded by `points_awarded_at`, surviving `done → draft → done`) 🟢
- **And** any `debt_amount > 0` posts a single `pos_debt` ledger row and raises `debt_total` 🟢
- **And** the printable receipt (`pages.pos-print`) renders 🟢

Notes / gaps:
- Unknown product codes on submit are **silently skipped**. 🟢 (documented behaviour)
- `store`/`update` are **not** wrapped in a DB transaction (unlike `destroy`), so a mid-finalise failure can leave partial state. 🟡 (flagged in `orders-crud`)
- A `done` order can only be edited within 24h of `updated_at`; after that it is read-only ("Không thể cập nhật đơn hàng hoàn thành quá 24h"). 🟢

Traces to: `orders-crud/` (`store` / `update`, totalling + points + debt)

---

### US-POS-12 — Print / re-print the receipt

**As a** cashier, **I want** to print an 80mm receipt, **so that** the shopper gets a printed record.

- **Given** a finalised order
- **When** the done path renders, or I open `GET /orders/{order}/print`
- **Then** `pages.pos-print` renders the store block, order code (`#QT78-{id}`), date, an optional customer block (live current points), per-line unit label + `qty × price`, discount, total, and a `window.print()` control 🟢

Notes / gaps:
- `printOrder` uses `Order::findOrFail`: an unknown id returns a clean `404`. ✅ Fixed 2026-09-21 (was `Order::find`, which dereferenced null and fataled). 🟢 (flagged in `orders-print`)
- Per-line `Unit::find` in the print loop is an N+1. 🟡
- Any order prints, including a draft (no status guard). 🟡
- The customer block shows **live** current points, which may differ from the sale-time balance on a re-print. 🟡

Traces to: `orders-print/` (`OrderController::printOrder`), shared `pages.pos-print`
