# TinyPOS (tnx-pos) — API Reference

> Source: reverse-engineered from `routes/web.php` and the per-unit `contracts.md` files (23 units). Confidence markers: confirmed in code · inferred · gap (known unknown).

## Overview

TinyPOS is a Laravel 5.6 session-cookie back-office, **not a JSON API**. The vast majority of endpoints render server-side HTML (Blade views) or issue redirects. Only `*/scan`, `get-price`, `check-gift`, and the `destroy` actions return JSON.

The admin route prefix is **empty**, so all admin paths sit at the site root (e.g. `/pos`, `/orders`). HTTPS is expected in production.

**Access control:** Authentication-only. The `['web','admin']` middleware authenticates but does not authorise — any logged-in admin can reach every endpoint. No RBAC is enforced at the route layer.

---

## Authentication

All guarded endpoints require a `laravel_session` cookie issued by the `web` middleware group. Unauthenticated requests receive `302 → /auth/login`.

POST/PUT/PATCH/DELETE web forms additionally require a `_token` field (Laravel CSRF token).

---

## Shared Response Schemas

### DeleteResult

Returned by all `destroy` actions and the order hard-delete.

| Field | Type | Notes |
|---|---|---|
| `status` | boolean | `true` on success. For order delete, an unknown id returns `false` — not a 404. |
| `message` | string | Localised `admin.delete_succeeded` / `admin.delete_failed`. |

### CheckGiftResult

Advisory gift-availability verdict (no writes, no stock reservation).

| Field | Type | Notes |
|---|---|---|
| `status` | boolean | `true` = available. |
| `message` | string | Present only when `status=false`. One of: `Quà tặng không khả dụng`, `Đã vượt quá số lần đổi quà tối đa`, `Không đủ điều kiện để nhận quà`. |

### ProductScanResult

Discriminated union. Clients **must** branch on `is_barcode` before reading `data`.

| Field | Type | Notes |
|---|---|---|
| `is_barcode` | boolean | `true` = exact barcode match; `false` = name search. |
| `data` | Product **or** Product[] | Single object on barcode hit; array of ≤10 on name search (empty `[]` on no match). |

### GiftScanEnvelope

| Field | Type | Notes |
|---|---|---|
| `data` | Gift[] | Up to 10 items. |

---

## Data Schemas

### Product

A `products` row serialised via `toArray()` with appended accessors and an injected `units[]` array.

| Field | Type | Nullable | Notes |
|---|---|---|---|
| `id` | integer | no | |
| `code` | string | no | Barcode / SKU. Unique among live (non-deleted) rows. Auto-generated as `P{category_id}{6-digit}` when blank on create. |
| `name` | string | no | |
| `description` | string | yes | |
| `category_id` | integer | no | Must be a child (leaf) category. |
| `brand_id` | integer | no | |
| `price` | number | no | Cost / base price. |
| `sale_price` | number | no | Retail price; defaults to `price` on create. |
| `wholesale_prices` | object | yes | JSON-decoded tier map, e.g. `{"si_1": 6500, "si_2": 6000}`. Keys match customer `type`. |
| `qty` | number | yes | Stock quantity. |
| `unit` | string | no | Base-unit name (free string). |
| `attr_weight` | string | yes | |
| `reward_point` | number | yes | Points earned per unit sold. |
| `expiry_date` | string | yes | |
| `is_expired` | boolean | no | Appended accessor. |
| `promotion_note` | string | yes | |
| `units` | ScanUnitEntry[] | no | Injected selling-unit options (see below). |
| `created_at` | string | yes | |
| `updated_at` | string | yes | |
| `deleted_at` | string | yes | SoftDeletes. |

### ScanUnitEntry

One selling-unit option inside a product's `units[]`. The base unit (null `unit_id`, `conversion_qty` = 1) is always first; exactly one entry has `is_default = true`.

| Field | Type | Notes |
|---|---|---|
| `unit_id` | integer \| null | `null` for the base unit. |
| `label` | string | Display name (`products.unit` for base; `Unit.name` for conversion units). |
| `conversion_qty` | number | Base-units per this selling unit. Price divisor. Base unit = 1. |
| `is_default` | boolean | |

### Customer

A `customers` row (SoftDeletes) with appended `type` and `type_label`.

| Field | Type | Nullable | Notes |
|---|---|---|---|
| `id` | integer | no | |
| `fullname` | string | no | |
| `phone` | string | no | Business identity key; used for POS search and select. |
| `email` | string | yes | |
| `address` | string | yes | |
| `gender` | string | yes | `male`, `female`, or `other`. |
| `birthday` | string | yes | Display string (d/m/yyyy). |
| `birthday2` | string | yes | Parsed date (Y-m-d); used by the month filter. Populated only on create, not recomputed on update. |
| `dependant` | string | yes | |
| `points` | number | no | Current spendable loyalty points (live). |
| `debt_total` | number | no | Denormalised accounts-receivable balance (live). Maintained exclusively by `CustomerDebt::record`. |
| `type` | string | no | Pricing tier: `khach_le`, `si_1`, or `si_2`. Accessor defaults to `khach_le`. |
| `type_label` | string | no | Appended human-readable label. |
| `created_at` | string | yes | |
| `updated_at` | string | yes | |
| `deleted_at` | string | yes | SoftDeletes. |

### Order

An `orders` row with appended computed fields and (in scan responses) nested `customer` and `products`.

| Field | Type | Nullable | Notes |
|---|---|---|---|
| `id` | integer | no | |
| `customer_id` | integer | yes | `null` for a walk-in order. |
| `subtotal` | number | no | Σ `round(price, 1) × qty` across lines. |
| `discount_amount` | number | yes | |
| `total` | number | no | `subtotal − discount_amount`. |
| `paid` | number | no | |
| `debt_amount` | number | yes | POS credit (≤ total; requires a customer). |
| `earned_point` | number | no | Σ `round(reward_point, 1) × qty`. |
| `points_awarded_at` | string | yes | Idempotency guard — points are awarded exactly once. |
| `count` | integer | no | Number of order lines (not summed quantity). |
| `status` | string | no | `draft` or `done`. |
| `notes` | string | yes | |
| `code` | string | no | Appended `#QT78-{id}`. |
| `is_editable` | boolean | no | Appended — `true` if draft, or if done within 24 h of `updated_at`. |
| `debt_locked` | boolean | no | Appended — `true` when a `pos_debt` ledger row exists. |
| `customer` | Customer | yes | Nested; present in scan responses. |
| `products` | OrderLine[] | no | Nested; present in scan responses. |
| `created_at` | string | yes | |
| `updated_at` | string | yes | |

### OrderLine

A Product plus its `order_product` pivot (sale-time snapshot).

Inherits all Product fields, plus:

| Field | Type | Notes |
|---|---|---|
| `pivot.qty` | number | Quantity sold. |
| `pivot.price` | number | Sale-time unit price (snapshot). |
| `pivot.unit_id` | integer \| null | Selling unit at time of sale. |
| `pivot.conversion_qty` | number \| null | Conversion qty at time of sale. |

### CustomerDebt

An append-only `customer_debts` ledger entry.

| Field | Type | Nullable | Notes |
|---|---|---|---|
| `id` | integer | no | |
| `customer_id` | integer | no | |
| `order_id` | integer | yes | `null` after the source order is hard-deleted. |
| `related_debt_id` | integer | yes | For `debt_void` entries: points to the original `pos_debt` row. |
| `type` | string | no | `pos_debt`, `manual_debt`, `repayment`, or `debt_void`. |
| `amount` | number | no | `decimal(15,1)`. |
| `balance_after` | number | yes | Balance snapshot after this entry (audit trail). |
| `note` | string | yes | |
| `created_by` | integer | no | Admin user id (no FK constraint). |
| `created_at` | string | yes | |
| `updated_at` | string | yes | |

### Gift

A `gifts` row (SoftDeletes) with appended `quantity_available` and resolved `image` URL.

| Field | Type | Nullable | Notes |
|---|---|---|---|
| `id` | integer | no | |
| `name` | string | no | |
| `image` | string | yes | Accessor URL; falls back to `noimage.png` placeholder. |
| `points` | number | no | `decimal(8,1)` cost to redeem. |
| `limit` | integer | no | Per-customer redemption cap. `0` = unlimited. |
| `quantity` | integer | no | Total stock. |
| `used` | integer | no | Redeemed count. |
| `active` | integer | no | `0` or `1`. |
| `quantity_available` | integer | no | Appended: `quantity − used`. |
| `created_at` | string | yes | |
| `updated_at` | string | yes | |
| `deleted_at` | string | yes | |

---

## Endpoints

### Authentication (`/auth`)

---

#### `GET /auth/login`

Show login page. No authentication required.

**Responses**

| Status | Body | Notes |
|---|---|---|
| 200 | `text/html` | Login form with `username`, `password`, `_token` fields. |
| 302 | — | Already authenticated; redirects to landing. |

---

#### `POST /auth/login`

Authenticate. No authentication required.

**Request body** — `application/x-www-form-urlencoded`

| Field | Required | Notes |
|---|---|---|
| `username` | yes | Login identity (not email). |
| `password` | yes | |
| `_token` | yes | Laravel CSRF token. |

**Responses**

| Status | Notes |
|---|---|
| 302 | Success → intended URL (default `/pos`). Failure → back to `/auth/login` with `errors[username] = "These credentials do not match our records."` The same message is returned for both unknown user and wrong password (non-enumerating). No rate-limiting or failed-login logging is present. |

---

#### `GET /auth/logout`

End session. Calls `guard()->logout()`, invalidates the session, then redirects to the admin root. Logout is a GET request (CSRF-unprotected by package design).

**Responses**

| Status | Notes |
|---|---|
| 302 | Redirect to `/`. |

---

#### `GET /auth/setting`

Show own-account edit form.

**Responses**

| Status | Body | Notes |
|---|---|---|
| 200 | `text/html` | Form bound to the current user (`name`, `avatar`, `password`). |
| 302 | — | Unauthenticated → `/auth/login`. |

---

#### `PUT /auth/setting`

Update own account.

**Request body** — `multipart/form-data`

| Field | Required | Notes |
|---|---|---|
| `name` | yes | |
| `password` | yes | |
| `password_confirmation` | yes | Must match `password`. |
| `avatar` | no | Optional profile image (binary). |
| `_token` | yes | |

The `saving` hook re-bcrypts the password only when it differs from the stored hash. `password_confirmation` is dropped before save.

**Responses**

| Status | Notes |
|---|---|
| 302 | Redirect back to `/auth/setting` with a success toast, or back with a validation error bag. |

---

### Maintenance (`/artisan`)

---

#### `GET /artisan`

Run pending database migrations over HTTP.

> **Warning:** This is a side-effecting GET with no CSRF protection, no confirmation, and no audit trail. Any authenticated admin session (including a prefetch or crawler) can trigger production migrations. The exit code is discarded — a non-zero result is indistinguishable from success. Reviewed with the project owner 2026-09-21; accepted as a known risk with no change scheduled.

**Responses**

| Status | Body | Notes |
|---|---|---|
| 200 | `text/html` — `"Migrating completed<br>"` | Always returned regardless of actual outcome. |
| 500 | — | Uncaught migration exception. |

---

### Dashboard (`/dashboard`)

---

#### `GET /dashboard`

Analytics dashboard. Renders three live KPIs (product count, customer count, today's `done` orders), a sales-volume chart (order count per bucket, not revenue), and a top-10 best-selling-products list.

**Query parameters**

| Parameter | Values | Default | Notes |
|---|---|---|---|
| `range` | `day`, `week`, `month` | `month` | `day` = three 6-hour store windows (06:00–24:00). `week` = per calendar day. `month` = 5-day windows. Absent/unknown → `month`. |

**Responses**

| Status | Body | Notes |
|---|---|---|
| 200 | `text/html` | Dashboard page. |
| 302 | — | Unauthenticated → `/auth/login`. |

---

### POS Terminal (`/pos`)

---

#### `GET /`

Landing redirect. No authentication required.

**Responses**

| Status | Notes |
|---|---|
| 301 | `Location: /pos` |

---

#### `GET /pos`

POS terminal page. Renders the terminal with an injected ~630-line client-side cart engine. The server action performs no writes.

**Query parameters**

| Parameter | Type | Notes |
|---|---|---|
| `id` | integer | When present, pre-loads that order as a resumable draft. |

**Responses**

| Status | Body | Notes |
|---|---|---|
| 200 | `text/html` | POS terminal page. |
| 302 | — | Unauthenticated → `/auth/login`. |

---

#### `GET /pos/scan`

Product lookup for the terminal. Barcode-first logic: if `q` matches `products.code` exactly, returns a single product (`is_barcode: true`); otherwise performs a name search returning up to 10 results (`is_barcode: false`). Soft-deleted products are excluded. `q` is used verbatim (no trim or escape).

**Query parameters**

| Parameter | Type | Notes |
|---|---|---|
| `q` | string | Barcode or name fragment. |

**Responses**

| Status | Body | Notes |
|---|---|---|
| 200 | `application/json` — ProductScanResult | Clients must branch on `is_barcode` before reading `data`. |
| 302 | — | Unauthenticated → `/auth/login`. |

---

### Products (`/products`)

---

#### `GET /products`

Paginated, filterable product listing.

**Query parameters**

| Parameter | Values | Notes |
|---|---|---|
| `expiring` | `near`, `expired` | `near` = within `near_expiry_days`, sorted ascending. `expired` = past expiry, sorted descending. Absent/other = sorted by `id` desc. |
| `q` | string | Substring match on `code` or `name` (leading-wildcard LIKE). |
| `page` | integer | ≤30 products per page. |

**Responses**

| Status | Body |
|---|---|
| 200 | `text/html` — `pages.products` |

---

#### `GET /products/create`

Show create form.

**Responses**

| Status | Body |
|---|---|
| 200 | `text/html` — `pages.products-add` (child categories, brands, units, wholesale tier inputs). |

---

#### `POST /products`

Create a product and its conversion units.

`code` is auto-generated as `P{category_id}{6-digit max(id)+1}` when left blank. `sale_price` defaults to `price`. The `product_units` relationship is full-replaced via `syncProductUnits`. Not wrapped in a database transaction.

**Request body** — `multipart/form-data` — see ProductWrite schema below.

**Responses**

| Status | Notes |
|---|---|
| 302 | Redirect back with success or failure toast. |
| 422 | Validation errors. |

---

#### `GET /products/{id}`

Show (empty stub). The `show` method has no body. Do not rely on this endpoint.

---

#### `GET /products/{id}/edit`

Show edit form.

**Path parameters:** `id` (integer)

**Responses**

| Status | Body |
|---|---|
| 200 | `text/html` — `pages.products-edit` (eager-loaded product + dropdown data + productUnits). |
| 404 | Unknown product. |

---

#### `PUT /products/{id}`

Update a product. Same body as create, except `code` is not validated or updated (the barcode is effectively immutable after creation).

**Path parameters:** `id` (integer)

**Request body** — `multipart/form-data` — see ProductWrite schema below.

**Responses**

| Status | Notes |
|---|---|
| 302 | Redirect back with success or failure toast. |
| 422 | Validation errors. |
| 404 | Unknown product. |

---

#### `DELETE /products/{id}`

Soft-delete a product. The `deleted` model event prefixes the product `name` with `(DELETED) `. The `code` becomes reusable (the unique index applies to live rows only).

**Path parameters:** `id` (integer)

**Responses**

| Status | Body |
|---|---|
| 200 | `application/json` — DeleteResult |

---

#### `GET /products/get-price`

Return the effective unit price for a product, customer tier, and selling unit. This is the endpoint the POS terminal calls synchronously for each cart line.

Pricing logic: base price = `wholesale_prices[type]` when a tier is configured, otherwise `sale_price`. If a `unit_id` is given with `conversion_qty > 0`, the base price is divided by `conversion_qty`.

**Query parameters**

| Parameter | Required | Type | Notes |
|---|---|---|---|
| `product_id` | yes | integer | Absent or falsy → `400 "Product not found"`. |
| `phone` | no | string | Customer phone. Resolved via `->first()` (unknown phone = no error, retail pricing). Overrides `id` when both are present. The live POS client always sends `phone`. |
| `id` | no | integer | Customer id. Resolved via `findOrFail` — an unknown id throws → `400`. |
| `unit_id` | no | integer | Selling unit. Absent/falsy → base unit (no division). |

**Responses**

| Status | Body | Notes |
|---|---|---|
| 200 | `application/json` — bare number | Effective unit price. May be non-integer (e.g. `1666.6666666667`). |
| 400 | `application/json` — bare string | Raw exception message. Examples: `"Product not found"`, `"No query results for model [App\\Models\\Product] 999999"`. All error paths collapse to 400 regardless of cause. |
| 302 | — | Unauthenticated → `/auth/login`. |

**ProductWrite schema** (for `POST /products` and `PUT /products/{id}`)

| Field | Required | Type | Notes |
|---|---|---|---|
| `name` | yes | string | |
| `price` | yes | number | |
| `unit` | yes | string | Base-unit name. |
| `category_id` | yes | integer | `exists:categories,id` (child category). |
| `brand_id` | yes | integer | `exists:brands,id`. |
| `code` | no | string | Blank → auto-generated. Ignored on update. |
| `description` | no | string | |
| `sale_price` | no | number | Blank → `price`. |
| `qty` | no | number | Validated as numeric. |
| `pictures` | no | binary | `mimes:jpeg,jpg,png,webp`. |
| `attr_weight` | no | string | |
| `reward_point` | no | number | |
| `wholesale_prices` | no | object | Map `si_1`/`si_2` → price; JSON-encoded on save. |
| `expiry_date` | no | string | `date_format:Y-m-d`. |
| `promotion_note` | no | string | |
| `product_units[]` | no | array | Full-replace conversion units. Rows with falsy `unit_id` or `conversion_qty ≤ 0` are dropped. First `is_default=true` wins. |
| `product_units[].unit_id` | — | integer | |
| `product_units[].conversion_qty` | — | number | |
| `product_units[].is_default` | — | boolean | |
| `_token` | yes | string | CSRF token. |

---

### Customers (`/customers`)

---

#### `GET /customers/scan`

Customer autocomplete. Searches `phone LIKE %q%` OR `fullname LIKE %q%`, returning up to 10 records. Always returns `200` (the `204` branch is dead code). An empty or absent `q` returns the first 10 live rows.

**Query parameters**

| Parameter | Type | Notes |
|---|---|---|
| `q` | string | Phone or name fragment. |

**Responses**

| Status | Body | Notes |
|---|---|---|
| 200 | `application/json` — Customer[] | Empty array when no match. |
| 302 | — | Unauthenticated → `/auth/login`. |

---

#### `GET /customers`

Paginated customer listing.

**Query parameters**

| Parameter | Type | Notes |
|---|---|---|
| `q` | string | Exact `phone` match OR `fullname LIKE %q%`. Scoped to `month` when both are present. |
| `month` | integer (1–12) | Filters `MONTH(birthday2) = month`; sorts by day-of-month ascending. |
| `page` | integer | ≤30 per page. |

**Responses**

| Status | Body |
|---|---|
| 200 | `text/html` — `pages.customers` |

---

#### `GET /customers/create`

Show create form (no tier selector; new customers always start as `khach_le`).

**Responses**

| Status | Body |
|---|---|
| 200 | `text/html` — `pages.customer-add` |

---

#### `POST /customers`

Create customer. Content-negotiates on `Accept: application/json`:

- **JSON path** (POS quick-add): returns the created Customer as JSON, or `400 null` on failure. Does not accept `type`; new customers default to `khach_le`. Parses `birthday` (d/m/Y) into `birthday2`; a parse failure silently nulls `birthday`.
- **Web path**: redirects with a success or failure toast.

**Request body** — `application/x-www-form-urlencoded`

| Field | Required | Notes |
|---|---|---|
| `fullname` | yes | |
| `phone` | yes | `required|numeric|unique` among live rows. |
| `email` | no | |
| `address` | no | |
| `gender` | no | `male`, `female`, or `other`. |
| `birthday` | no | Format `d/m/yyyy`. Unparseable input silently nulls the field. |
| `dependant` | no | |
| `_token` | yes | |

**Responses**

| Status | Body | Notes |
|---|---|---|
| 200 | `application/json` — Customer | JSON-expecting request; created customer. |
| 400 | `application/json` — `null` | JSON path; `Customer::create` returned falsy. |
| 302 | — | Web path; success or failure toast. |
| 422 | — | Validation errors. |

---

#### `GET /customers/{customer}`

Show (empty stub). The `show` method has no body.

---

#### `GET /customers/{customer}/edit`

Show edit form (includes tier selector).

**Path parameters:** `customer` (integer)

**Responses**

| Status | Body |
|---|---|
| 200 | `text/html` — `pages.customer-edit` (item + `Customer::$types`). |
| 404 | Unknown or soft-deleted customer. |

---

#### `PUT /customers/{customer}`

Update a customer. This is the **only** endpoint that can set the pricing `type`. `phone` is validated as a string (not numeric as on create). `birthday2` is not recomputed from `birthday`.

**Path parameters:** `customer` (integer)

**Request body** — `application/x-www-form-urlencoded`

| Field | Required | Notes |
|---|---|---|
| `fullname` | yes | |
| `phone` | yes | `required|string|unique` among live rows, excluding self. |
| `gender` | yes | `male`, `female`, or `other`. Not nullable — an empty string fails validation. |
| `email` | no | |
| `address` | no | |
| `birthday` | no | Not re-parsed into `birthday2`. |
| `dependant` | no | |
| `type` | no | `khach_le`, `si_1`, or `si_2`. Nullable. |
| `_token` | yes | |

**Responses**

| Status | Notes |
|---|---|
| 302 | Redirect back with success or failure toast. |
| 422 | Validation errors. |
| 404 | Unknown customer. |

---

#### `DELETE /customers/{customer}`

Soft-delete a customer. No name-prefix side effect (unlike products).

**Path parameters:** `customer` (integer)

**Responses**

| Status | Body |
|---|---|
| 200 | `application/json` — DeleteResult |

---

#### `GET /customers/{customer}/orders`

Customer purchase history. Lists the customer's orders (both draft and done), newest first, paginated at 20. The lifetime spend total is computed unfiltered regardless of the `category` filter.

**Path parameters:** `customer` (integer)

**Query parameters**

| Parameter | Type | Notes |
|---|---|---|
| `category` | integer | Child-category id filter (truthy only). Dropdown lists only child categories (`whereNotNull('parent_id')`). |
| `page` | integer | |

**Responses**

| Status | Body |
|---|---|
| 200 | `text/html` — `pages.customer-orders` |
| 404 | Unknown or soft-deleted customer. |

---

#### `GET /customers/{customer}/statistic`

Customer quick-statistics. Reads the nightly `customer_order_summary` read model (stale up to ~24 h) plus live `points`/`debt_total`. Breaks category totals into milk (category id 1), medicine (category id 8), and other goods. Also shows annotated orders (`whereNotNull('notes')`), paginated at 10. A null summary row is indistinguishable from a genuinely all-zero customer.

**Path parameters:** `customer` (integer)

**Responses**

| Status | Body |
|---|---|
| 200 | `text/html` — `pages.customer-statis` |
| 404 | Unknown or soft-deleted customer. |

---

#### `GET /customers/{customer}/debt`

Customer debt ledger page. Shows live `debt_total` and the append-only `CustomerDebt` ledger (id desc, 20/page, eager-loaded with the source order).

**Path parameters:** `customer` (integer)

**Query parameters:** `page` (integer)

**Responses**

| Status | Body |
|---|---|
| 200 | `text/html` — `pages.customer-debt` |
| 404 | Unknown or soft-deleted customer. |

---

#### `POST /customers/{customer}/debts`

Record a manual debt. Calls `CustomerDebt::record(customer, 'manual_debt', amount, …)` in a locked transaction. Increases `debt_total` and appends a ledger row with `balance_after`. No upper bound on the amount.

**Path parameters:** `customer` (integer)

**Request body** — `application/x-www-form-urlencoded`

| Field | Required | Notes |
|---|---|---|
| `amount` | yes | `required|numeric|min:0.01`. Storage is `decimal(15,1)` — sub-0.1 values are rounded. |
| `note` | no | |
| `_token` | yes | |

**Responses**

| Status | Notes |
|---|---|
| 302 | Redirect back with success toast, or validation errors. |
| 404 | Unknown or soft-deleted customer. |

---

#### `POST /customers/{customer}/repayments`

Record a repayment. Calls `CustomerDebt::record(customer, 'repayment', amount, …)` under a row lock. Decreases `debt_total`. A repayment exceeding the current balance is rejected — no ledger row is written.

**Path parameters:** `customer` (integer)

**Request body** — `application/x-www-form-urlencoded`

| Field | Required | Notes |
|---|---|---|
| `amount` | yes | `required|numeric|min:0.01`. |
| `note` | no | |
| `_token` | yes | |

**Responses**

| Status | Notes |
|---|---|
| 302 | Redirect back with success toast, validation error, or over-balance error `"Số tiền thu nợ vượt quá số dư nợ hiện tại (<balance> ₫)"`. |
| 404 | Unknown or soft-deleted customer. |

---

#### `GET /customers/{customer}/check-gift`

Gift-availability pre-flight. Advisory only — no writes, no stock reservation. Checks stock, per-customer redemption limit, and points balance via `checkGiftAvailable`. A `true` result can still be rejected by the under-lock re-check in `redeem-points`.

**Path parameters:** `customer` (integer)

**Query parameters**

| Parameter | Required | Notes |
|---|---|---|
| `gift_id` | yes | Resolved via `Gift::active()->find`. |

**Responses**

| Status | Body |
|---|---|
| 200 | `application/json` — CheckGiftResult |
| 404 | Unknown or soft-deleted customer. |

---

#### `POST /customers/{id}/redeem-points`

Redeem loyalty points for a gift. The only place `customer.points` is spent. Runs in a `DB::transaction` with `lockForUpdate` on both the customer and the gift, performs an under-lock re-check, attaches a `customer_gift` pivot row (snapshotting `gift.points`), increments `gifts.used`, and debits `customer.points`. Any exception causes a full rollback. Not idempotent.

**Path parameters:** `id` (integer)

**Request body** — `application/x-www-form-urlencoded`

| Field | Required | Notes |
|---|---|---|
| `gift_id` | yes | integer |
| `note` | no | string |
| `_token` | yes | |

**Responses**

| Status | Notes |
|---|---|
| 302 | Success: `"Đổi quà thành công"`. Errors: validation, `"Quà tặng không khả dụng"` (no active gift), gate failure, or under-lock re-check failure. |
| 404 | Unknown or soft-deleted customer. |

---

#### `GET /customers/{customer}/gift-received`

Redeemed-gifts history. Lists the customer's redeemed gifts (id desc, 30/page), each carrying its pivot `points` and `note`. Optional `q` matches gift `name`.

**Path parameters:** `customer` (integer)

**Query parameters:** `q` (string), `page` (integer)

**Responses**

| Status | Body |
|---|---|
| 200 | `text/html` — `pages.customer-gift-received` |
| 404 | Unknown or soft-deleted customer. |

---

### Debts (`/debts`)

---

#### `GET /debts`

Accounts-receivable overview. Read-only list of customers with `debt_total > 0` (descending), paginated at 30. Balances read live from `customers.debt_total`. All mutations delegate to the customer debt-actions endpoints.

**Query parameters:** `q` (string — phone/fullname LIKE filter), `page` (integer)

**Responses**

| Status | Body |
|---|---|
| 200 | `text/html` — `pages.debts` |
| 302 | Unauthenticated → `/auth/login`. |

---

### Orders (`/orders`)

---

#### `GET /orders/scan`

Order/draft picker for the POS resume flow. Returns up to 10 orders (id desc) with eager-loaded `customer` and `products` (pivot). Optional `q` matches the related customer's phone or fullname (via `whereHas` — customerless orders never match a `q`). Optional `status` filter; `?status=draft` is the standard POS resume case. Always returns `200` (the `204` branch is dead code).

**Query parameters**

| Parameter | Values | Notes |
|---|---|---|
| `q` | string | Customer phone or fullname. |
| `status` | `draft`, `done` | |

**Responses**

| Status | Body | Notes |
|---|---|---|
| 200 | `application/json` — Order[] | Empty array when no match. |
| 302 | — | Unauthenticated → `/auth/login`. |

---

#### `GET /orders`

Order listing.

**Query parameters**

| Parameter | Values | Notes |
|---|---|---|
| `q` | string | `id = q` OR `#QT78-{id} = q` OR customer `phone LIKE %q%`. |
| `status` | `draft`, `done` | |
| `page` | integer | ≤30 per page, sorted by `updated_at` desc. |

**Responses**

| Status | Body |
|---|---|
| 200 | `text/html` — `pages.orders` |

---

#### `GET /orders/create`

Create (empty stub). Order creation is driven by the POS terminal, not this endpoint.

---

#### `POST /orders`

Create an order from the POS cart. Prices each line via `getPriceByCustomerType` (unknown product codes are silently skipped). Accumulates subtotal and earned points. Applies discount. Debt guard: `debt_amount > 0` requires a customer and must not exceed the order total. On `status=done` with a customer, awards points once (guarded by `points_awarded_at`) and posts a `pos_debt` ledger entry once (guarded by `debt_locked`). Not wrapped in a database transaction. A `draft` redirects to the POS; a `done` renders the printable receipt inline.

**Request body** — `application/x-www-form-urlencoded` — see OrderWrite schema below.

**Responses**

| Status | Body | Notes |
|---|---|---|
| 200 | `text/html` — `pages.pos-print` | When `status=done`. |
| 302 | — | When `status=draft`, or on debt-rule violation / save failure. |
| 422 | — | Validation errors (e.g. empty `items`). |

---

#### `GET /orders/{order}`

Order detail.

**Path parameters:** `order` (integer)

**Responses**

| Status | Body |
|---|---|
| 200 | `text/html` — `pages.orders-detail` |
| 404 | Unknown order (`findOrFail`). |

---

#### `GET /orders/{order}/edit`

Edit (empty stub). Order editing is driven by the POS terminal.

---

#### `PUT /orders/{order}`

Edit or finalise an order. Same body as create, plus `create_now_mode`.

Guards applied before any mutation:
- `is_editable` must be `true` (draft, or done within 24 h of `updated_at`) — otherwise rejected.
- Customer is immutable unless the order is in `draft` status.
- Debt is frozen when `debt_locked = true`.
- Points are awarded only if not already awarded (`points_awarded_at` is null).

When `create_now_mode = true`, items are rebuilt from the order's existing `order_product` pivots and re-priced at **current** catalog prices.

**Path parameters:** `order` (integer)

**Request body** — `application/x-www-form-urlencoded` — see OrderWrite schema below.

**Responses**

| Status | Body | Notes |
|---|---|---|
| 200 | `text/html` — `pages.pos-print` | When `status=done`. |
| 302 | — | When `status=draft`, or on guard/validation failure. |
| 422 | — | Validation errors. |
| 404 | Unknown order. |

---

#### `DELETE /orders/{order}`

Hard-delete an order with full reversal. Runs in a transaction: reverses awarded points (clamped ≥ 0), voids the `pos_debt` ledger entry (appends a `debt_void` row), then hard-deletes the order (`order_product` rows cascade; `customer_debts.order_id` → null). Uses `find` (not `findOrFail`) — an unknown id returns `{status: false}`, not a 404.

**Path parameters:** `order` (integer)

**Responses**

| Status | Body |
|---|---|
| 200 | `application/json` — DeleteResult |

---

#### `GET /orders/{order}/print`

Printable 80mm receipt for any order (no draft/done guard). Shows live current customer points, not sale-time points.

**Path parameters:** `order` (integer)

**Query parameters**

| Parameter | Notes |
|---|---|
| `ref` | When set to `orders`, the back control uses `history.back()`. |

**Responses**

| Status | Body |
|---|---|
| 200 | `text/html` — `pages.pos-print` |
| 404 | Unknown order id. |

---

#### `PUT /orders/{order}/note`

Edit an order's free-text note. Mutates exactly one column (`notes`). No status/editability guard — any order's note can be edited at any time.

**Path parameters:** `order` (integer)

**Request body** — `application/x-www-form-urlencoded`

| Field | Notes |
|---|---|
| `notes` | nullable\|string |
| `_token` | CSRF token |

**Responses**

| Status | Notes |
|---|---|
| 302 | Success: `"Cập nhật thành công"`. The failure branch (`"Cập nhật thất bại"`) is effectively unreachable. |
| 404 | Unknown order. |

**OrderWrite schema** (for `POST /orders` and `PUT /orders/{order}`)

| Field | Required | Notes |
|---|---|---|
| `items` | yes (create); conditional (update) | `required|array` on create. `required_without:create_now_mode` on update. |
| `items[].code` | yes | Product code. Unknown codes are silently skipped. |
| `items[].qty` | yes | `min:1`. |
| `items[].unit_id` | no | integer \| null |
| `customer.phone` | no | Resolves the buyer via `Customer::code(phone)`. |
| `notes` | no | |
| `discount_amount` | no | `min:0`. Stored as `round(…, 1)`. |
| `debt_amount` | no | `min:0`. Requires a customer. Must not exceed order total. |
| `status` | no | `draft` (default) or `done`. |
| `create_now_mode` | no | (PUT only) Rebuild items from stored pivots and finalise. Re-prices at current catalog prices. |
| `_token` | yes | |

---

### Settings — Reference Data

All settings endpoints use Encore\Admin ModelForm scaffolding. The `GET` endpoints render a combined grid-and-inline-form editor. `POST` creates a new row. Update and delete come from the ModelForm trait (not individually documented here as they follow framework conventions).

---

#### `GET /settings/brand` · `POST /settings/brand`

Brand editor. Brands are undeletable (referenced by required FK `products.brand_id`). Brand id 1 is edit-locked as the default.

**POST request body** — `application/x-www-form-urlencoded`

| Field | Required | Notes |
|---|---|---|
| `name` | yes | |
| `description` | no | |
| `_token` | yes | |

**Responses:** `GET 200 text/html` · `POST 302 redirect`

---

#### `GET /settings/categories` · `POST /settings/categories`

Category editor. Two-level `parent_id` taxonomy. Products may only be assigned to child (leaf) categories. Delete is disabled. `parent_id` is never settable through the UI — UI-created categories cannot be assigned to products. Category ids 1 (milk) and 8 (medicine) are special-cased in customer statistics.

**POST request body** — same as brands (`RefDataWrite`).

**Responses:** `GET 200 text/html` · `POST 302 redirect`

---

#### `GET /settings/units` · `POST /settings/units`

Measurement-unit editor. Unit id 1 is edit-locked as the default. All rows are delete-disabled. Units supply the base-unit name dropdown, conversion-unit FK, and receipt line labels.

**POST request body** — same as brands (`RefDataWrite`).

**Responses:** `GET 200 text/html` · `POST 302 redirect`

---

#### `GET /settings/gifts` · `POST /settings/gifts`

Loyalty-reward gift catalogue editor. Full CRUD — all rows editable and deletable. Lifecycle hooks zero-default numeric fields, force `used = 0` on create, and resize uploaded images to 300 × 300 px.

**POST request body** — `multipart/form-data`

| Field | Required | Notes |
|---|---|---|
| `name` | yes | |
| `image` | no | `max:1024 KB|mimes:jpeg,png,jpg,gif,svg,webp`. Resized to 300 × 300 on upload. |
| `points` | no | `nullable|numeric|min:0` — `decimal(8,1)` storage. |
| `limit` | no | `nullable|numeric|min:0`. `0` = unlimited. |
| `quantity` | no | `nullable|numeric`. On edit, `min:{used}` (cannot drop below redeemed count). |
| `active` | no | `0` or `1`. Defaults to `1`. |
| `_token` | yes | |

**Responses:** `GET 200 text/html` · `POST 302 redirect`

---

#### `GET /settings/gifts/scan`

Gift autocomplete. Searches `name LIKE %q%`, returning up to 10 results wrapped in a `{data: […]}` envelope. Unlike the other scan endpoints, this returns an envelope object, not a bare array. Does not filter by stock (`checkGiftAvailable` is the real gate). An empty or absent `q` returns the first 10 live gifts.

**Query parameters**

| Parameter | Type | Notes |
|---|---|---|
| `q` | string | Name fragment. |
| `active` | integer (0 or 1) | Filter applied only when the parameter is present. |

**Responses**

| Status | Body | Notes |
|---|---|---|
| 200 | `application/json` — GiftScanEnvelope | `{data: Gift[]}` — up to 10 items. |
| 302 | — | Unauthenticated → `/auth/login`. |

---

## Known Gaps and Risks

| Endpoint | Risk |
|---|---|
| `GET /artisan` | Side-effecting GET, no CSRF, exit code discarded, no audit trail. Any authenticated session (including browser prefetch) can trigger production migrations. Accepted as a known risk 2026-09-21. |
| `GET /products/get-price` | All error types collapse to a bare `400` JSON string; the caller cannot distinguish a missing product from a database error. |
| `GET /customers/scan` | An absent `q` returns `LIKE '%%'` — the first 10 live customers with no filter. |
| `GET /settings/gifts/scan` | Does not filter by stock; callers must use `check-gift` for the real availability gate. |
| `POST /settings/categories` | `parent_id` is not settable through the UI; UI-created categories can never be assigned to products. |
| `POST /orders`, `PUT /orders/{order}` | Neither create nor update is wrapped in a database transaction — partial writes are possible on failure. |
