# Openapi

## tnx-pos.yaml

```yaml
# Produced by the Reversa **Writer** (phase: generation) · doc_level: `complete`
# Generated on 2026-09-21
#
# Confidence scale: 🟢 CONFIRMED (in code) · 🟡 INFERRED · 🔴 GAP
#
# This is a machine-readable synthesis of every HTTP surface exposed by the
# TinyPOS (`tnx-pos`) Laravel 5.6 monolith, aggregated from the 23 per-unit
# `contracts.md` files and `routes/web.php`. Reversa's absolute rule applies:
# this file documents the legacy contract, it does not change it.
#
# IMPORTANT — this app is a session-cookie back-office, NOT a JSON API. Most
# endpoints render server-side HTML (Blade) or issue redirects; only the
# `*/scan`, `get-price`, `check-gift` and JSON `destroy` actions return JSON.
# The `text/html` and `3xx` responses are described here for completeness so a
# reimplementer knows the true legacy behaviour, per doc_level `complete`.

openapi: 3.0.3

info:
  title: TinyPOS (tnx-pos) — Legacy HTTP Contract
  version: "1.0.0"
  description: >
    Reverse-engineered OpenAPI description of the TinyPOS point-of-sale system
    (Laravel 5.6 + Encore\Admin). All application routes live under the admin
    group with an **empty** route prefix (`config('admin.route.prefix') = ''`),
    so admin paths sit at the site root. Access control is authentication-only:
    the `['web','admin']` middleware authenticates but does not authorise, so any
    logged-in admin can reach every endpoint (see ADR-0009 / `permissions.md`).
    Grounded in `routes/web.php` and the per-unit `contracts.md`.
  x-reversa:
    project: tnx-pos
    phase: generation
    doc_level: complete
    granularity: endpoint
    generated_on: "2026-09-21"
    source_of_truth: routes/web.php + _reversa_sdd/<unit>/contracts.md

servers:
  - url: /
    description: >
      Site root. The admin route prefix is empty, so admin endpoints are served
      directly off the root (e.g. `/pos`, `/orders`). HTTPS is expected in
      production (`config/admin.php` secure => env('ADMIN_SECURE', true)). 🟢

tags:
  - name: auth
    description: Encore\Admin authentication (login/logout/own-account). Unit `auth`.
  - name: dashboard
    description: Analytics dashboard. Unit `dashboard`.
  - name: pos
    description: POS terminal page and product scan. Units `pos-terminal`, `pos-scan`.
  - name: products
    description: Product master data and pricing. Units `products-catalog`, `products-pricing`.
  - name: customers
    description: Customer CRM, autocomplete, history, statistics, debt, loyalty.
  - name: orders
    description: Order lifecycle, draft picker, receipt print, note edit.
  - name: debts
    description: Accounts-receivable overview. Unit `debts`.
  - name: settings
    description: Reference data (brands, categories, units, gifts). Encore\Admin ModelForm scaffolds.
  - name: maintenance
    description: Operational maintenance endpoints. Unit `artisan-migrate`.

security:
  - sessionCookie: []

paths:

  # ────────────────────────────────────────────────────────────────────────
  # Root / landing
  # ────────────────────────────────────────────────────────────────────────
  /:
    get:
      tags: [pos]
      summary: Landing redirect → /pos 🟢
      description: Permanent redirect to the POS terminal (`routes/web.php:22`). Unit `pos-terminal`.
      security: []
      responses:
        "301":
          description: Moved Permanently to `/pos`.
          headers:
            Location:
              schema: { type: string, example: /pos }

  # ────────────────────────────────────────────────────────────────────────
  # Auth (Encore\Admin AuthController) — unit `auth`
  # ────────────────────────────────────────────────────────────────────────
  /auth/login:
    get:
      tags: [auth]
      summary: Show login page 🟢
      description: >
        Renders the `admin::login` view. If the `admin` guard is already
        authenticated, redirects to `redirectPath()` (root → 301 → `/pos`).
      security: []
      responses:
        "200":
          description: HTML login form (`username`, `password`, CSRF `_token`).
          content:
            text/html: { schema: { type: string } }
        "302":
          description: Already authenticated — redirect to landing.
    post:
      tags: [auth]
      summary: Authenticate 🟢
      description: >
        Validates `username`/`password` (both required). On success regenerates
        the session and redirects to the intended URL (default root → `/pos`).
        On bad credentials redirects back with a generic, non-enumerating error.
        No rate-limiting or failed-login logging. 🔴
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [username, password, _token]
              properties:
                username: { type: string, description: Login identity (not email). }
                password: { type: string, format: password }
                _token: { type: string, description: Laravel CSRF token. }
      responses:
        "302":
          description: >
            Success → redirect to intended (root → `/pos`); or back to
            `auth/login` with `errors[username]="These credentials do not match
            our records."` on failure (same message for unknown user / wrong password).
  /auth/logout:
    get:
      tags: [auth]
      summary: End session 🟢
      description: >
        `guard()->logout()` → `session()->invalidate()` → redirect to admin root.
        Logout is a GET (CSRF-unprotected by package design). 🟡
      responses:
        "302":
          description: Redirect to admin root (`/`).
  /auth/setting:
    get:
      tags: [auth]
      summary: Own-account form 🟢
      responses:
        "200":
          description: HTML form bound to the current user (`name`, `avatar`, `password`).
          content:
            text/html: { schema: { type: string } }
        "302":
          description: Unauthenticated → `auth/login`.
    put:
      tags: [auth]
      summary: Update own account 🟢
      description: >
        Validates `name` (required) and `password` (`confirmed|required`). The
        `saving` hook re-bcrypts the password only when it differs from the
        stored hash; `password_confirmation` is dropped before save.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [name, password, password_confirmation, _token]
              properties:
                name: { type: string }
                avatar: { type: string, format: binary, description: Optional profile image. }
                password: { type: string, format: password }
                password_confirmation: { type: string, format: password }
                _token: { type: string }
      responses:
        "302":
          description: Back to `auth/setting` with a success toast, or back with a validation error bag.

  # ────────────────────────────────────────────────────────────────────────
  # Maintenance — unit `artisan-migrate`
  # ────────────────────────────────────────────────────────────────────────
  /artisan:
    get:
      tags: [maintenance]
      summary: Run pending DB migrations over HTTP 🔴
      description: >
        Inline closure route that runs `Artisan::call('migrate', ['--force'=>true])`
        and always echoes the static string `"Migrating completed<br>"`. The exit
        code is captured but DISCARDED (the only `dd($exitCode)` is commented out),
        so a non-zero exit is indistinguishable from success; a thrown migration
        surfaces as a 500. This is a SIDE-EFFECTING GET behind authentication only —
        a prefetch/crawler with a live admin session can run production migrations.
        No CSRF, no confirmation, no audit. Reviewed with the project owner
        2026-09-21 — accepted as a known risk, kept as legacy behaviour (no
        change scheduled). 🔴 (`routes/web.php:31-37`)
      responses:
        "200":
          description: Always `"Migrating completed<br>"` (text/html), regardless of the real outcome.
          content:
            text/html:
              schema: { type: string, example: "Migrating completed<br>" }
        "500":
          description: Uncaught migration exception (not handled by the closure). 🟡

  # ────────────────────────────────────────────────────────────────────────
  # Dashboard — unit `dashboard`
  # ────────────────────────────────────────────────────────────────────────
  /dashboard:
    get:
      tags: [dashboard]
      summary: Analytics dashboard 🟢
      description: >
        Renders `pages.dashboard` with 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. Read-only.
        Registered via `resource('/dashboard')` but only `index` (GET) is
        meaningful — the other resource verbs are unused framework stubs. 🟢
      parameters:
        - name: range
          in: query
          required: false
          description: >
            Chart bucketing. `day` = three store-open 6h windows (Sáng/Trưa/Chiều,
            06:00–24:00); `week` = per calendar day of the current week; `month`
            = 5-day windows. Absent/falsy/unknown → `month`. 🟢
          schema: { type: string, enum: [day, week, month], default: month }
      responses:
        "200":
          description: HTML dashboard page.
          content:
            text/html: { schema: { type: string } }
        "302":
          description: Unauthenticated → `auth/login`.

  # ────────────────────────────────────────────────────────────────────────
  # Products pricing — unit `products-pricing`
  # (declared BEFORE resource('/products') so it isn't shadowed by `show`)
  # ────────────────────────────────────────────────────────────────────────
  /products/get-price:
    get:
      tags: [products]
      summary: Effective unit price for a product / customer / unit 🟢
      description: >
        The single JSON endpoint the POS terminal calls synchronously per cart
        line. base price = wholesale_prices[type] when a tier is configured, else
        sale_price; then divided by the selling unit's `conversion_qty` when a
        matching `ProductUnit` (conversion_qty>0) is given. Success returns a BARE
        JSON number; ALL failures collapse to a `400` with a bare JSON string
        message — a coarse error contract. 🔴 (`ProductController::getPriceByCustomerType`)
      parameters:
        - name: product_id
          in: query
          required: true
          description: Falsy/absent → `ErrorException('Product not found')` → 400. 🟢
          schema: { type: integer }
        - name: id
          in: query
          required: false
          description: Customer id via `findOrFail` — an unknown id THROWS → 400. 🟢
          schema: { type: integer }
        - name: phone
          in: query
          required: false
          description: >
            Customer phone via `->first()` (unknown phone → no error, retail).
            OVERRIDES `id` when both present. The live POS client always sends
            `phone`, never `id`. 🟢
          schema: { type: string }
        - name: unit_id
          in: query
          required: false
          description: Selling unit; absent/falsy → base unit (no division). 🟢
          schema: { type: integer, nullable: true }
      responses:
        "200":
          description: Bare numeric effective unit price (may be non-integer, e.g. `1666.6666666667`).
          content:
            application/json:
              schema: { type: number }
              examples:
                retail: { value: 20000 }
                wholesale_tier: { value: 18000 }
                converted_subunit: { value: 1500 }
        "400":
          description: Bare JSON string carrying the raw exception message.
          content:
            application/json:
              schema: { type: string }
              examples:
                missing_product: { value: "Product not found" }
                unknown_product: { value: "No query results for model [App\\Models\\Product] 999999" }
        "302":
          description: Unauthenticated → `auth/login`.

  # ────────────────────────────────────────────────────────────────────────
  # Products catalog resource — unit `products-catalog`
  # ────────────────────────────────────────────────────────────────────────
  /products:
    get:
      tags: [products]
      summary: Product listing (paginated, filterable) 🟢
      parameters:
        - name: expiring
          in: query
          required: false
          description: >
            `near` (within `near_expiry_days`, asc) | `expired` (past, desc) |
            absent/other (id desc). 🟢
          schema: { type: string, enum: [near, expired] }
        - name: q
          in: query
          required: false
          description: Substring match on `code` OR `name` (grouped closure, leading-wildcard LIKE). 🟢
          schema: { type: string }
        - name: page
          in: query
          required: false
          schema: { type: integer }
      responses:
        "200":
          description: HTML `pages.products` (≤30 products/page + `nearExpiryDays`, `expiring`).
          content:
            text/html: { schema: { type: string } }
    post:
      tags: [products]
      summary: Create product (+ conversion units) 🟢
      description: >
        Auto-generates `code = P{category_id}{6-digit max(id)+1}` when blank;
        defaults `sale_price = price`. `code` unique among LIVE rows only. Persists
        the product then full-replaces `product_units` via `syncProductUnits`.
        Not wrapped in a transaction. 🟡
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: '#/components/schemas/ProductWrite' }
      responses:
        "302": { description: Redirect back with success/failure toast. }
        "422": { description: Validation errors. }
    # create/show/edit/update/destroy documented under their own path items below.

  /products/create:
    get:
      tags: [products]
      summary: Create form 🟢
      responses:
        "200":
          description: HTML `pages.products-add` (child categories, brands, units, wholesale tiers).
          content:
            text/html: { schema: { type: string } }

  /products/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [products]
      summary: Show — EMPTY STUB 🟢
      description: The `show` method has no body; returns an empty response. Do not rely on it. 🟢
      responses:
        "200": { description: Empty response (stub). }
    put:
      tags: [products]
      summary: Update product 🟢
      description: Same body as create EXCEPT `code` is not validated/updated (barcode effectively immutable).
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: '#/components/schemas/ProductWrite' }
      responses:
        "302": { description: Redirect back with success/failure toast. }
        "422": { description: Validation errors. }
        "404": { description: Unknown product. }
    delete:
      tags: [products]
      summary: Soft-delete product 🟢
      description: >
        `Product::destroy` soft-deletes; the `deleted` model event prefixes the
        name with `(DELETED) `. `code` becomes reusable (live-only unique index).
      responses:
        "200":
          description: JSON status result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeleteResult' }

  /products/{id}/edit:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [products]
      summary: Edit form 🟢
      responses:
        "200":
          description: HTML `pages.products-edit` (eager-loaded product + dropdown data + productUnits).
          content:
            text/html: { schema: { type: string } }
        "404": { description: Unknown product. }

  # ────────────────────────────────────────────────────────────────────────
  # Customers — autocomplete + per-customer sub-screens
  # (all declared BEFORE resource('/customers') so they aren't shadowed)
  # ────────────────────────────────────────────────────────────────────────
  /customers/scan:
    get:
      tags: [customers]
      summary: Customer autocomplete (JSON) 🟢
      description: >
        `phone LIKE %q%` OR `fullname LIKE %q%`, `take(10)`. ALWAYS returns a
        `200` JSON array (empty → `[]`); the `204` branch is dead code. Empty/missing
        `q` becomes `LIKE '%%'` and returns the first 10 live rows. 🔴 Unit `customers-scan`.
      parameters:
        - name: q
          in: query
          required: false
          schema: { type: string }
      responses:
        "200":
          description: Up to 10 full Customer records.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Customer' }
        "302":
          description: Unauthenticated → `auth/login`.

  /customers/{customer}/orders:
    parameters:
      - name: customer
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [customers]
      summary: Customer purchase history (HTML) 🟢
      description: >
        Lists a customer's orders (both draft and done, newest first, paginate 20)
        with an optional category filter (`whereHas('products', category_id=?)`,
        order-level EXISTS) and a lifetime spend total computed unfiltered.
        Unit `customers-purchase-history`.
      parameters:
        - name: category
          in: query
          required: false
          description: >
            Category id filter (truthy only). Dropdown lists only CHILD categories
            (`whereNotNull('parent_id')`). ✅ Fixed 2026-09-21 (was every category
            including parents, a dead-end selection).
          schema: { type: integer }
        - name: page
          in: query
          required: false
          schema: { type: integer }
      responses:
        "200":
          description: HTML `pages.customer-orders` (item, amountTotal, orders, categories).
          content:
            text/html: { schema: { type: string } }
        "404": { description: Unknown/soft-deleted customer. }

  /customers/{customer}/statistic:
    parameters:
      - name: customer
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [customers]
      summary: Customer quick-statistics (HTML, precomputed read model) 🟢
      description: >
        Pure reader of the nightly `customer_order_summary` read model (stale
        ≤~24h) plus LIVE `points`/`debt_total` off the customer. Breaks category
        stats into milk (id 1), medicine (id 8) and other goods. Also renders an
        annotated-orders panel (`whereNotNull('notes')`, paginate 10). A null
        summary row is indistinguishable from a genuinely all-zero customer. 🔴
        Unit `customers-statistics`.
      responses:
        "200":
          description: HTML `pages.customer-statis`.
          content:
            text/html: { schema: { type: string } }
        "404": { description: Unknown/soft-deleted customer. }

  /customers/{customer}/debt:
    parameters:
      - name: customer
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [customers]
      summary: Customer debt ledger page (HTML) 🟢
      description: >
        Renders `pages.customer-debt` with LIVE `debt_total` and a paginated
        `CustomerDebt` ledger (id desc, 20/page, `with('order')`). Unit
        `customers-debt-actions`.
      parameters:
        - name: page
          in: query
          required: false
          schema: { type: integer }
      responses:
        "200":
          description: HTML ledger page.
          content:
            text/html: { schema: { type: string } }
        "404": { description: Unknown/soft-deleted customer. }

  /customers/{customer}/debts:
    parameters:
      - name: customer
        in: path
        required: true
        schema: { type: integer }
    post:
      tags: [customers]
      summary: Record a manual debt 🟢
      description: >
        `CustomerDebt::record(customer,'manual_debt',amount,…)` — INCREASES
        `debt_total`, appends one ledger row with `balance_after`, in a locked
        transaction. Manual debt never throws (no upper bound / no confirmation). 🟡
        Web form (redirect-back), not JSON. Unit `customers-debt-actions`.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: '#/components/schemas/DebtWrite' }
      responses:
        "302":
          description: Redirect back — success toast, or validation errors.
        "404": { description: Unknown/soft-deleted customer. }

  /customers/{customer}/repayments:
    parameters:
      - name: customer
        in: path
        required: true
        schema: { type: integer }
    post:
      tags: [customers]
      summary: Record a repayment 🟢
      description: >
        `CustomerDebt::record(customer,'repayment',amount,…)` inside a try/catch —
        under a row lock, DECREASES `debt_total`; a repayment above the current
        balance is REJECTED (throws → redirect back with error, no ledger row).
        Web form, not JSON. Unit `customers-debt-actions`.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: '#/components/schemas/DebtWrite' }
      responses:
        "302":
          description: >
            Redirect back — 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": { description: Unknown/soft-deleted customer. }

  /customers/{customer}/check-gift:
    parameters:
      - name: customer
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [customers]
      summary: Gift-availability pre-flight (JSON, advisory) 🟢
      description: >
        Advisory only — no writes, reserves no stock. Resolves the gift via
        `Gift::active()->find` and runs `checkGiftAvailable` (stock, per-customer
        limit, points balance). A `true` here can still be rejected by the
        under-lock re-check in redeem-points. Unit `customers-loyalty`.
      parameters:
        - name: gift_id
          in: query
          required: true
          schema: { type: integer }
      responses:
        "200":
          description: Availability verdict.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/CheckGiftResult' }
        "404": { description: Unknown/soft-deleted customer. }

  /customers/{id}/redeem-points:
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: integer }
    post:
      tags: [customers]
      summary: Redeem points for a gift 🟢
      description: >
        The ONLY place `customer.points` is spent. In a `DB::transaction` with
        `lockForUpdate` on both customer and gift plus an under-lock re-check:
        attaches a `customer_gift` pivot row (snapshotting `gift.points`),
        increments `gifts.used`, and debits `customer.points`. Rolled back on any
        throw. Web form (redirect-back), not idempotent. Unit `customers-loyalty`.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [gift_id]
              properties:
                gift_id: { type: integer }
                note: { type: string, nullable: true }
                _token: { type: string }
      responses:
        "302":
          description: >
            Redirect back — success toast `"Đổi quà thành công"`, or errors
            (validation, no active gift `"Quà tặng không khả dụng"`, gate failure,
            or under-lock re-check throw).
        "404": { description: Unknown/soft-deleted customer. }

  /customers/{customer}/gift-received:
    parameters:
      - name: customer
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [customers]
      summary: Redeemed-gifts history page (HTML) 🟢
      description: >
        Renders `pages.customer-gift-received` — the customer's redeemed gifts
        (id desc, 30/page, each carrying its pivot `points`/`note`). Optional `q`
        matches gift `name`. Unit `customers-loyalty`.
      parameters:
        - name: q
          in: query
          required: false
          schema: { type: string }
        - name: page
          in: query
          required: false
          schema: { type: integer }
      responses:
        "200":
          description: HTML history page.
          content:
            text/html: { schema: { type: string } }
        "404": { description: Unknown/soft-deleted customer. }

  # ────────────────────────────────────────────────────────────────────────
  # Customers CRUD resource — unit `customers-crud`
  # ────────────────────────────────────────────────────────────────────────
  /customers:
    get:
      tags: [customers]
      summary: Customer listing (HTML) 🟢
      parameters:
        - name: q
          in: query
          required: false
          description: '`phone` EXACT OR `fullname LIKE %q%` (grouped, AND-scoped to `month`). 🟢'
          schema: { type: string }
        - name: month
          in: query
          required: false
          description: Filters `MONTH(birthday2)=month`; sorts by day-of-month asc. 🟢
          schema: { type: integer, minimum: 1, maximum: 12 }
        - name: page
          in: query
          required: false
          schema: { type: integer }
      responses:
        "200":
          description: HTML `pages.customers` (≤30/page).
          content:
            text/html: { schema: { type: string } }
    post:
      tags: [customers]
      summary: Create customer (web form AND POS quick-add) 🟢
      description: >
        Content-negotiates on `request()->expectsJson()`: JSON path (POS quick-add)
        returns the created Customer as JSON (or `400` null); web path redirects
        with a toast. Does NOT accept `type` — new customers default to `khach_le`.
        Parses `birthday` (d/m/Y) into `birthday2`; a parse failure silently nulls
        `birthday`. 🟡 Unit `customers-crud`.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: '#/components/schemas/CustomerCreate' }
      responses:
        "200":
          description: (JSON-expecting request) created Customer.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Customer' }
        "400":
          description: (JSON path) `null` body when `Customer::create` returns falsy.
          content:
            application/json: { schema: { type: object, nullable: true } }
        "302":
          description: (Web path) redirect with success/failure toast.
        "422":
          description: Validation errors.

  /customers/create:
    get:
      tags: [customers]
      summary: Create form (no tier selector) 🟢
      responses:
        "200":
          description: HTML `pages.customer-add`.
          content:
            text/html: { schema: { type: string } }

  /customers/{customer}:
    parameters:
      - name: customer
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [customers]
      summary: Show — EMPTY STUB 🟢
      description: The `show` method has no body; returns an empty response. 🟢
      responses:
        "200": { description: Empty response (stub). }
    put:
      tags: [customers]
      summary: Update customer (only action that sets the tier) 🟢
      description: >
        Validates `phone` as STRING (vs numeric on create), `type` nullable
        in:khach_le,si_1,si_2, `gender` WITHOUT nullable. Does NOT re-derive
        `birthday2`. Unit `customers-crud`.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: '#/components/schemas/CustomerUpdate' }
      responses:
        "302": { description: Redirect back with success/failure toast. }
        "422": { description: Validation errors. }
        "404": { description: Unknown customer. }
    delete:
      tags: [customers]
      summary: Soft-delete customer 🟢
      description: '`Customer::destroy` soft-deletes (no rename side effect). 🟢'
      responses:
        "200":
          description: JSON status result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeleteResult' }

  /customers/{customer}/edit:
    parameters:
      - name: customer
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [customers]
      summary: Edit form (with tier selector) 🟢
      responses:
        "200":
          description: HTML `pages.customer-edit` (item + `Customer::$types`).
          content:
            text/html: { schema: { type: string } }
        "404": { description: Unknown/soft-deleted customer. }

  # ────────────────────────────────────────────────────────────────────────
  # Debts overview — unit `debts`
  # ────────────────────────────────────────────────────────────────────────
  /debts:
    get:
      tags: [debts]
      summary: Accounts-receivable overview (HTML) 🟢
      description: >
        Read-only list of customers with `debt_total > 0` (desc), optional grouped
        `q` filter (phone/fullname LIKE), paginate 30. Balances read LIVE off
        `customers.debt_total`. All mutations delegate to `customers-debt-actions`.
        Unit `debts`.
      parameters:
        - name: q
          in: query
          required: false
          schema: { type: string }
        - name: page
          in: query
          required: false
          schema: { type: integer }
      responses:
        "200":
          description: HTML `pages.debts`.
          content:
            text/html: { schema: { type: string } }
        "302":
          description: Unauthenticated → `auth/login`.

  # ────────────────────────────────────────────────────────────────────────
  # Orders — scan / print / note (declared BEFORE resource('/orders'))
  # ────────────────────────────────────────────────────────────────────────
  /orders/scan:
    get:
      tags: [orders]
      summary: Order/draft picker (JSON) 🟢
      description: >
        Lists up to 10 orders (id desc) with eager-loaded `customer` + `products`
        (pivot). Optional `q` matches the related customer's phone/fullname
        (grouped inside `whereHas`, so customerless orders never match a `q`).
        Optional `status` filter (`?status=draft` is the POS resume case). Always
        returns a `200` JSON array (the `204` branch is dead code). Unit `orders-scan`.
      parameters:
        - name: q
          in: query
          required: false
          schema: { type: string }
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [draft, done] }
      responses:
        "200":
          description: Up to 10 Order objects (empty → `[]`).
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Order' }
        "302":
          description: Unauthenticated → `auth/login`.

  /orders/{order}/print:
    parameters:
      - name: order
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [orders]
      summary: Printable receipt (HTML, 80mm) 🟢
      description: >
        Renders `pages.pos-print` for any order (no draft/done guard). Uses
        `Order::findOrFail` — unknown id → clean `404`. ✅ Fixed 2026-09-21 (was
        `Order::find`, which returned `null` and made the view fatal). Shows LIVE
        current customer points, not sale-time. Unit `orders-print`.
      parameters:
        - name: ref
          in: query
          required: false
          description: When `orders`, the back control uses `history.back()`. 🟢
          schema: { type: string }
      responses:
        "200":
          description: HTML receipt.
          content:
            text/html: { schema: { type: string } }
        "404":
          description: Unknown order id (`findOrFail`).

  /orders/{order}/note:
    parameters:
      - name: order
        in: path
        required: true
        schema: { type: integer }
    put:
      tags: [orders]
      summary: Edit an order's free-text note 🟢
      description: >
        Validates `notes` nullable|string, `findOrFail` (404), fill (bounded to
        `notes` by `$fillable`), save. Mutates EXACTLY one column — no totals,
        points, debt, pivots or status touched. No status/editability guard.
        Web form (redirect-back). Unit `orders-note`.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                notes: { type: string, nullable: true }
                _token: { type: string }
      responses:
        "302":
          description: >
            Redirect back — `"Cập nhật thành công"` on success, or back with errors
            `"Cập nhật thất bại"` on failure (effectively unreachable).
        "404": { description: Unknown order. }

  # ────────────────────────────────────────────────────────────────────────
  # Orders CRUD resource — unit `orders-crud` (architectural spine)
  # ────────────────────────────────────────────────────────────────────────
  /orders:
    get:
      tags: [orders]
      summary: Order list (HTML) 🟢
      parameters:
        - name: q
          in: query
          required: false
          description: '`id = q` OR `#QT78-{id} = q` OR customer `phone LIKE %q%` (grouped). 🟢'
          schema: { type: string }
        - name: status
          in: query
          required: false
          schema: { type: string, enum: [draft, done] }
        - name: page
          in: query
          required: false
          schema: { type: integer }
      responses:
        "200":
          description: HTML `pages.orders` (paginate 30, `updated_at` desc).
          content:
            text/html: { schema: { type: string } }
    post:
      tags: [orders]
      summary: Create order 🟢
      description: >
        Prices each line via `getPriceByCustomerType` (unknown codes silently
        skipped), accumulates subtotal/earned points, applies discount. Debt guard:
        `debt_amount>0` requires a customer and must not exceed total. On `done`
        with a customer, awards points once (`points_awarded_at`) and posts a
        `pos_debt` once (`debt_locked`). NOT wrapped in a transaction. 🟡 A `draft`
        redirects to the POS; a `done` renders the printable receipt. Unit `orders-crud`.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: '#/components/schemas/OrderWrite' }
      responses:
        "200":
          description: (done) HTML `pages.pos-print` receipt.
          content:
            text/html: { schema: { type: string } }
        "302":
          description: >
            (draft) redirect to `pos.index` with toast; or debt-rule violation /
            save failure redirect with error.
        "422":
          description: Validation errors (e.g. empty items).

  /orders/create:
    get:
      tags: [orders]
      summary: Create — EMPTY STUB 🟢
      description: No-op; order creation is driven by the POS terminal. 🟢
      responses:
        "200": { description: Empty response (stub). }

  /orders/{order}:
    parameters:
      - name: order
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [orders]
      summary: Order detail (HTML) 🟢
      responses:
        "200":
          description: HTML `pages.orders-detail`.
          content:
            text/html: { schema: { type: string } }
        "404": { description: Unknown order (findOrFail). }
    put:
      tags: [orders]
      summary: Edit / finalise order 🟢
      description: >
        Same body as create, plus `create_now_mode` (rebuild items from stored
        pivots to finalise a draft, re-pricing at CURRENT catalog prices). Guards:
        `is_editable` (draft, or done ≤24h of `updated_at`) else rejected; customer
        immutable unless draft; debt frozen when `debt_locked`; points awarded only
        if not already. Unit `orders-crud`.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: '#/components/schemas/OrderWrite' }
      responses:
        "200":
          description: (done) HTML `pages.pos-print` receipt.
          content:
            text/html: { schema: { type: string } }
        "302":
          description: (draft) redirect with toast; or guard/validation failure redirect back.
        "422": { description: Validation errors. }
        "404": { description: Unknown order. }
    delete:
      tags: [orders]
      summary: Delete order with full reversal 🟢
      description: >
        Transactional: reverse awarded points (`reversePointsForOrder`, clamped
        ≥0), void `pos_debt` (`voidForOrder` → `debt_void` entry), then HARD-delete
        the order (`order_product` cascades, `customer_debts.order_id` → null).
        Uses `find` (not findOrFail) — unknown id → `{status:false}`, NOT 404. Unit `orders-crud`.
      responses:
        "200":
          description: JSON status result.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DeleteResult' }

  /orders/{order}/edit:
    parameters:
      - name: order
        in: path
        required: true
        schema: { type: integer }
    get:
      tags: [orders]
      summary: Edit — EMPTY STUB 🟢
      description: No-op; order editing is driven by the POS terminal. 🟢
      responses:
        "200": { description: Empty response (stub). }

  # ────────────────────────────────────────────────────────────────────────
  # POS — scan (JSON) + terminal page
  # ────────────────────────────────────────────────────────────────────────
  /pos/scan:
    get:
      tags: [pos]
      summary: Product lookup for the terminal (JSON) 🟢
      description: >
        Barcode-first: exact `products.code` match → `{is_barcode:true, data:<one
        product>}`; otherwise `name LIKE %q%` (≤10) → `{is_barcode:false, data:[…]}`.
        DISCRIMINATED UNION on `is_barcode` — `data` is an object vs an array.
        Soft-deleted products excluded. Unit `pos-scan`.
      parameters:
        - name: q
          in: query
          required: false
          description: Barcode or name fragment (used verbatim, no trim/escape). 🟢
          schema: { type: string }
      responses:
        "200":
          description: Discriminated union keyed on `is_barcode`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProductScanResult' }
        "302":
          description: Unauthenticated → `auth/login`.

  /pos:
    get:
      tags: [pos]
      summary: POS terminal page (landing) 🟢
      description: >
        Renders `pages.pos` with an injected ~630-line client cart engine. The
        server action is thin and performs no writes. Optional `?id=` pre-loads an
        order as a resumable draft. Registered via `resource('/pos')` but only
        `index` (GET) is meaningful. Unit `pos-terminal`.
      parameters:
        - name: id
          in: query
          required: false
          description: When present, pre-loads that order as a draft to resume/edit. 🟢
          schema: { type: integer }
      responses:
        "200":
          description: HTML POS terminal page.
          content:
            text/html: { schema: { type: string } }
        "302":
          description: Unauthenticated → `auth/login`.

  # ────────────────────────────────────────────────────────────────────────
  # Settings — reference data (Encore\Admin ModelForm resources)
  # ────────────────────────────────────────────────────────────────────────
  /settings/brand:
    get:
      tags: [settings]
      summary: Brands editor grid + inline form (HTML) 🟢
      description: >
        Two-column reference-data editor (name + optional description). Brands are
        undeletable by design (products.brand_id is a required FK); id 1 is the
        edit-locked default. Encore\Admin ModelForm — store/update/destroy come
        from the trait. Unit `brands`.
      responses:
        "200":
          description: HTML brands editor.
          content:
            text/html: { schema: { type: string } }
    post:
      tags: [settings]
      summary: Create brand 🟢
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: '#/components/schemas/RefDataWrite' }
      responses:
        "302": { description: Redirect back. }

  /settings/categories:
    get:
      tags: [settings]
      summary: Categories editor grid + inline form (HTML) 🟢
      description: >
        Two-level `parent_id` taxonomy (products may only use CHILD categories).
        Every row editable (the id-1 edit lock is commented out); no create button
        for the grid; delete disabled. `parent_id` is NEVER settable through the UI
        so UI-created categories can never be assigned to products. 🔴 ids 1 (milk)
        / 8 (medicine) are special-cased in statistics. Unit `categories`.
      responses:
        "200":
          description: HTML categories editor.
          content:
            text/html: { schema: { type: string } }
    post:
      tags: [settings]
      summary: Create category 🟢
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: '#/components/schemas/RefDataWrite' }
      responses:
        "302": { description: Redirect back. }

  /settings/units:
    get:
      tags: [settings]
      summary: Measurement-units editor grid + inline form (HTML) 🟢
      description: >
        Thin reference-data editor (name + optional description). id 1 is the
        edit-locked default unit; all rows delete-disabled. Consumed as the base-unit
        name dropdown, the conversion-unit FK, and receipt line labels. Unit `units`.
      responses:
        "200":
          description: HTML units editor.
          content:
            text/html: { schema: { type: string } }
    post:
      tags: [settings]
      summary: Create unit 🟢
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema: { $ref: '#/components/schemas/RefDataWrite' }
      responses:
        "302": { description: Redirect back. }

  /settings/gifts/scan:
    get:
      tags: [settings]
      summary: Gift autocomplete (JSON) 🟢
      description: >
        `name LIKE %q%` (≤10), optional `active` filter applied only when the param
        is present. Returns a `{data:[…]}` ENVELOPE (distinct from the other scan
        endpoints). Does NOT filter by stock — the real gate is `checkGiftAvailable`.
        Empty/missing `q` returns the first 10 live gifts. 🔴 Unit `gifts-scan`.
      parameters:
        - name: q
          in: query
          required: false
          schema: { type: string }
        - name: active
          in: query
          required: false
          schema: { type: integer, enum: [0, 1] }
      responses:
        "200":
          description: '`{data:[…Gift]}` envelope (≤10 items).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GiftScanEnvelope' }
        "302":
          description: Unauthenticated → `auth/login`.

  /settings/gifts:
    get:
      tags: [settings]
      summary: Gifts (loyalty rewards) editor grid + inline form (HTML) 🟢
      description: >
        Full-featured reward catalogue editor (points cost, per-customer limit,
        quantity stock, used counter, active flag, image). SoftDeletes; every row
        editable AND deletable (no locks). Lifecycle hooks zero-default numerics,
        force `used=0` on create, and resize uploaded images to 300x300. Unit `gifts-crud`.
      responses:
        "200":
          description: HTML gifts editor.
          content:
            text/html: { schema: { type: string } }
    post:
      tags: [settings]
      summary: Create gift 🟢
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: { $ref: '#/components/schemas/GiftWrite' }
      responses:
        "302": { description: Redirect back. }

components:

  securitySchemes:
    sessionCookie:
      type: apiKey
      in: cookie
      name: laravel_session
      description: >
        Cookie-based session issued by the `web` middleware group and required by
        the `admin` guard. Unauthenticated requests to guarded routes get
        `302 → auth/login`. Authentication only — NO authorization/RBAC is enforced
        at the route layer (ADR-0009). CSRF tokens (`_token`) are additionally
        required on POST/PUT/PATCH/DELETE web forms.

  schemas:

    DeleteResult:
      type: object
      description: JSON body returned by the `destroy` actions and the order delete. 🟢
      properties:
        status:
          type: boolean
          description: '`true` on success, `false` otherwise (incl. unknown id for orders — no 404).'
        message:
          type: string
          description: '`trans(''admin.delete_succeeded'')` / `trans(''admin.delete_failed'')`.'

    CheckGiftResult:
      type: object
      description: Advisory gift-availability verdict. 🟢
      properties:
        status: { type: boolean }
        message:
          type: string
          description: >
            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:
      type: object
      description: Discriminated union — clients MUST branch on `is_barcode`. 🟢
      required: [is_barcode, data]
      properties:
        is_barcode:
          type: boolean
          description: '`true` = barcode hit (`data` is one product); `false` = name search (`data` is an array).'
        data:
          description: A single Product (barcode hit) or an array of ≤10 Products (name search, empty `[]` on no match).
          oneOf:
            - $ref: '#/components/schemas/Product'
            - type: array
              items: { $ref: '#/components/schemas/Product' }

    GiftScanEnvelope:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Gift' }

    ScanUnitEntry:
      type: object
      description: >
        One selling-unit option injected into a scanned product's `units[]`. The
        base unit (unit_id null, conversion_qty 1) is always first; exactly one
        entry is `is_default`. 🟢
      properties:
        unit_id: { type: integer, nullable: true, description: null for the base unit. }
        label: { type: string, description: Unit display name (products.unit for base, else Unit.name). }
        conversion_qty: { type: number, description: Base-units per this unit (base = 1); price divisor. }
        is_default: { type: boolean }

    Product:
      type: object
      description: >
        A `products` row serialized via `toArray()` with appended accessors and an
        injected `units[]` array (as returned by the scan endpoints). 🟢/🟡
      properties:
        id: { type: integer }
        code: { type: string, description: Barcode / SKU; unique among live rows; auto-generated when blank. }
        name: { type: string }
        description: { type: string, nullable: true }
        category_id: { type: integer, description: Child category (products use leaf categories only). }
        brand_id: { type: integer }
        price: { type: number, description: Cost / base price. }
        sale_price: { type: number, description: Retail price (defaults to `price`). }
        wholesale_prices:
          type: object
          nullable: true
          description: 'JSON-decoded tier map, e.g. `{si_1: 6500, si_2: 6000}`.'
          additionalProperties: { type: number }
        qty: { type: number, nullable: true, description: Stock quantity. }
        unit: { type: string, description: Base-unit name (free string). }
        attr_weight: { type: string, nullable: true }
        reward_point: { type: number, nullable: true, description: Points earned per unit sold. }
        expiry_date: { type: string, nullable: true }
        is_expired: { type: boolean, description: Appended accessor. }
        promotion_note: { type: string, nullable: true }
        units:
          type: array
          items: { $ref: '#/components/schemas/ScanUnitEntry' }
        created_at: { type: string, nullable: true }
        updated_at: { type: string, nullable: true }
        deleted_at: { type: string, nullable: true }

    ProductWrite:
      type: object
      description: Create/update body for `/products` (multipart). On update, `code` is ignored. 🟢
      required: [name, price, unit, category_id, brand_id]
      properties:
        code: { type: string, description: 'Blank → auto-generated `P{category_id}{6-digit}`.' }
        name: { type: string }
        description: { type: string, nullable: true }
        price: { type: number }
        sale_price: { type: number, description: Blank → `price`. }
        qty: { type: number, description: Validated numeric (not required/nullable). }
        unit: { type: string, description: Base-unit name. }
        category_id: { type: integer, description: 'exists:categories,id (child).' }
        brand_id: { type: integer, description: 'exists:brands,id.' }
        pictures: { type: string, format: binary, description: 'mimes:jpeg,jpg,png,webp.' }
        attr_weight: { type: string, nullable: true }
        reward_point: { type: number }
        wholesale_prices:
          type: object
          description: Map `si_1`/`si_2` → price; JSON-encoded on save.
          additionalProperties: { type: number }
        expiry_date: { type: string, description: 'date_format:Y-m-d.' }
        promotion_note: { type: string, nullable: true }
        product_units:
          type: array
          description: Full-replace conversion units; rows with falsy unit_id or conversion_qty≤0 are dropped; first is_default wins.
          items:
            type: object
            properties:
              unit_id: { type: integer }
              conversion_qty: { type: number }
              is_default: { type: boolean }
        _token: { type: string }

    Customer:
      type: object
      description: A `customers` row (SoftDeletes) with appended `type`/`type_label`. 🟢
      properties:
        id: { type: integer }
        fullname: { type: string }
        phone: { type: string, description: Business identity key (search token, POS select2 value, scopeCode). }
        email: { type: string, nullable: true }
        address: { type: string, nullable: true }
        gender: { type: string, nullable: true, enum: [male, female, other] }
        birthday: { type: string, nullable: true, description: Display string (d/m/yyyy). }
        birthday2: { type: string, nullable: true, description: Parsed date (Y-m-d), used by the month filter. }
        dependant: { type: string, nullable: true }
        points: { type: number, description: Current spendable loyalty points (live). }
        debt_total: { type: number, description: Denormalised A/R balance (live, maintained by CustomerDebt::record). }
        type: { type: string, enum: [khach_le, si_1, si_2], description: Pricing tier; accessor defaults to khach_le. }
        type_label: { type: string, description: Appended human label. }
        created_at: { type: string, nullable: true }
        updated_at: { type: string, nullable: true }
        deleted_at: { type: string, nullable: true }

    CustomerCreate:
      type: object
      description: Create body — does NOT accept `type` (defaults khach_le); parses `birthday` into `birthday2`. 🟢
      required: [fullname, phone]
      properties:
        fullname: { type: string }
        phone: { type: string, description: 'required|numeric|unique (live-only).' }
        email: { type: string, nullable: true }
        address: { type: string, nullable: true }
        gender: { type: string, nullable: true, enum: [male, female, other] }
        birthday: { type: string, nullable: true, description: 'regex d/m/yyyy; unparseable → birthday nulled.' }
        dependant: { type: string, nullable: true }
        _token: { type: string }

    CustomerUpdate:
      type: object
      description: Update body — the only action that sets `type`; `phone` validated as string; `birthday2` not recomputed. 🟢
      required: [fullname, phone]
      properties:
        fullname: { type: string }
        phone: { type: string, description: 'required|string|unique (live-only, excludes self).' }
        email: { type: string, nullable: true }
        address: { type: string, nullable: true }
        gender: { type: string, enum: [male, female, other], description: No `nullable` — an empty string fails. }
        birthday: { type: string, nullable: true, description: Not re-parsed into birthday2. }
        dependant: { type: string, nullable: true }
        type: { type: string, nullable: true, enum: [khach_le, si_1, si_2] }
        _token: { type: string }

    DebtWrite:
      type: object
      description: Body for manual-debt and repayment writes. 🟢
      required: [amount]
      properties:
        amount:
          type: number
          description: '`required|numeric|min:0.01`; cast (float). decimal(15,1) storage rounds sub-0.1 values. 🟡'
        note: { type: string, nullable: true }
        _token: { type: string }

    Order:
      type: object
      description: An `orders` row with appended `code`/`is_editable`/`debt_locked` and (in scan) nested customer + products. 🟢
      properties:
        id: { type: integer }
        customer_id: { type: integer, nullable: true, description: null for a walk-in order. }
        subtotal: { type: number, description: Σ round(price,1)·qty over lines. }
        discount_amount: { type: number, nullable: true }
        total: { type: number, description: subtotal − discount_amount. }
        paid: { type: number }
        debt_amount: { type: number, nullable: true, description: POS credit (≤ total; requires a customer). }
        earned_point: { type: number, description: Σ round(reward_point,1)·qty. }
        points_awarded_at: { type: string, nullable: true, description: Idempotency guard — points awarded once. }
        count: { type: integer, description: Number of LINES (not summed qty). }
        status: { type: string, enum: [draft, done] }
        notes: { type: string, nullable: true }
        code: { type: string, description: 'Appended `#QT78-{id}`.' }
        is_editable: { type: boolean, description: Appended — draft, or done within 24h of updated_at. }
        debt_locked: { type: boolean, description: Appended — a pos_debt ledger row exists. }
        customer:
          allOf: [{ $ref: '#/components/schemas/Customer' }]
          nullable: true
        products:
          type: array
          items: { $ref: '#/components/schemas/OrderLine' }
        created_at: { type: string, nullable: true }
        updated_at: { type: string, nullable: true }

    OrderLine:
      type: object
      description: A Product plus its `order_product` pivot (sale-time snapshot). 🟢
      allOf:
        - $ref: '#/components/schemas/Product'
        - type: object
          properties:
            pivot:
              type: object
              properties:
                qty: { type: number }
                price: { type: number, description: Sale-time unit price. }
                unit_id: { type: integer, nullable: true }
                conversion_qty: { type: number, nullable: true }

    OrderWrite:
      type: object
      description: Create/finalise body from the POS cart. On PUT, `create_now_mode` rebuilds items from stored pivots. 🟢
      required: [items]
      properties:
        items:
          type: array
          description: '`required|array` on create; `required_without:create_now_mode` on update.'
          items:
            type: object
            required: [code, qty]
            properties:
              code: { type: string, description: Product code (unknown codes silently skipped). }
              qty: { type: number, description: '`min:1`.' }
              unit_id: { type: integer, nullable: true }
        customer:
          type: object
          nullable: true
          properties:
            phone: { type: string, description: Resolves the buyer via Customer::code(phone). }
        notes: { type: string, nullable: true }
        discount_amount: { type: number, description: '`min:0`; round(…,1).' }
        debt_amount: { type: number, description: '`min:0`; POS credit — requires a customer, must not exceed total.' }
        status: { type: string, enum: [draft, done], default: draft }
        create_now_mode:
          type: boolean
          description: (PUT only) rebuild items from the order's existing pivots and finalise; re-prices at CURRENT catalog prices.
        _token: { type: string }

    CustomerDebt:
      type: object
      description: An append-only `customer_debts` ledger entry. 🟢
      properties:
        id: { type: integer }
        customer_id: { type: integer }
        order_id: { type: integer, nullable: true, description: null once the source order is hard-deleted. }
        related_debt_id: { type: integer, nullable: true, description: A debt_void → its pos_debt. }
        type: { type: string, enum: [pos_debt, manual_debt, repayment, debt_void] }
        amount: { type: number, description: decimal(15,1). }
        balance_after: { type: number, nullable: true, description: Balance snapshot after this entry (audit trail). }
        note: { type: string, nullable: true }
        created_by: { type: integer, description: Admin user id (unconstrained unsignedInteger, no FK). }
        created_at: { type: string, nullable: true }
        updated_at: { type: string, nullable: true }

    Gift:
      type: object
      description: A `gifts` row (SoftDeletes) with appended `quantity_available` and `image` URL. 🟢
      properties:
        id: { type: integer }
        name: { type: string }
        image: { type: string, nullable: true, description: Accessor URL (or noimage.png placeholder). }
        points: { type: number, description: 'decimal(8,1) cost to redeem.' }
        limit: { type: integer, description: Per-customer redemption cap (0 = unlimited; counts pivot rows). }
        quantity: { type: integer, description: Total stock. }
        used: { type: integer, description: Redeemed count. }
        active: { type: integer, enum: [0, 1] }
        quantity_available: { type: integer, description: Appended `quantity − used`. }
        created_at: { type: string, nullable: true }
        updated_at: { type: string, nullable: true }
        deleted_at: { type: string, nullable: true }

    GiftWrite:
      type: object
      description: Create/update body for `settings/gifts` (multipart). 🟢
      required: [name]
      properties:
        name: { type: string }
        image: { type: string, format: binary, description: 'max:1024|mimes:jpeg,png,jpg,gif,svg,webp; resized to 300x300.' }
        points: { type: number, description: 'nullable|numeric|min:0.' }
        limit: { type: integer, description: 'nullable|numeric|min:0.' }
        quantity:
          type: integer
          description: 'nullable|numeric; on edit, `min:{used}` (can''t drop below redeemed).'
        active: { type: integer, enum: [0, 1], default: 1 }
        _token: { type: string }

    RefDataWrite:
      type: object
      description: Create/update body for the thin reference-data editors (brands, categories, units). 🟢
      required: [name]
      properties:
        name: { type: string }
        description: { type: string, nullable: true }
        _token: { type: string }

```
