# Customers Loyalty — Contracts

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

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

External HTTP contracts exposed by the `customers-loyalty` unit — three routes under the admin group (`['web','admin']`, empty admin prefix): the advisory JSON pre-flight `GET /customers/{customer}/check-gift` (`customers.check-gift`), the balance-mutating write `POST /customers/{id}/redeem-points` (`customers.redeem-points`), and the read-only history page `GET /customers/{customer}/gift-received` (`customers.gift-received`). All three require an authenticated admin session; an unauthenticated request gets `302 → auth/login`. 🟢 (`routes/web.php:55,56,59`, `CustomerController.php:296-377`)

> These routes are declared **before** `resource('/customers', 'CustomerController')` (`routes/web.php:62`), so none is captured by the resource routes. The resource CRUD belongs to `customers-crud`; the sibling `/customers/{customer}/*` screens (`orders`, `statistic`, `debt`, `debts`, `repayments`) belong to `customers-purchase-history`, `customers-statistics`, and `customers-debt-actions`. Gift master data (`settings/gifts`, `settings/gifts/scan`) belongs to `gifts-crud` / `gifts-scan`. 🟢 (`routes/web.php:53-62`)

---

## GET `/customers/{customer}/check-gift` — availability pre-flight 🟢 (`:361-377`)

- **Auth:** required; anonymous → `302 auth/login`. 🟢 (`routes/web.php:23-28`)
- **Route parameter:**

  | Field | Type | Required | Notes |
  |-------|------|----------|-------|
  | `customer` | integer | ✅ | Customer id. `Customer::findOrFail` → `404` if unknown or soft-deleted. 🟢 (`:363`) |

- **Request (query string):**

  | Field | Type | Required | Notes |
  |-------|------|----------|-------|
  | `gift_id` | integer | ✅ (de facto) | Gift to test. Resolved via `Gift::active()->find` (active gifts only). 🟢 (`:364`) |

- **Response — `200 application/json`:**
  - Available → `{"status": true}`. 🟢 (`:370`)
  - Blocked → the full gate result `{"status": false, "message": "<reason>"}` where reason ∈ {`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à`}. 🟢 (`:367-368`, `Customer.php:64-89`)
- **Status codes:** `200` (verdict) · `404` (unknown/soft-deleted customer) · `302 → auth/login` (unauthenticated). 🟢
- ✅ **Fixed (2026-09-21): missing/invalid `gift_id` no longer 500s.** A null-guard after `Gift::active()->find()` now returns `{"status":false,"message":"Quà tặng không khả dụng"}` before the non-nullable `checkGiftAvailable` hint can be called with `null`. (`:361-370`)
- **Advisory only:** performs no writes and reserves no stock — a `true` here can still be rejected by the under-lock re-check in `redeem-points`. 🟢 (`:322-326`)

---

## POST `/customers/{id}/redeem-points` — redeem points for a gift 🟢 (`:296-338`)

- **Auth:** required; anonymous → `302 auth/login`. **CSRF:** required (web `Form::open` token). 🟡 (`customer-statis.blade.php`)
- **Route parameter:** `id` (integer, ✅) — `Customer::findOrFail` → `404`. 🟢 (`:303`)
- **Request body (form-encoded):**

  | Field | Type | Required | Rule | Notes |
  |-------|------|----------|------|-------|
  | `gift_id` | integer | ✅ | `required` | The gift to redeem; must resolve via `Gift::active()->find`. 🟢 (`:299,304`) |
  | `note` | string | ❌ | `nullable\|string` | Free-text, snapshotted onto the pivot row. 🟢 (`:300,328`) |

- **Effect (on success):** within a `DB::transaction` + `lockForUpdate` on customer and gift, with an under-lock availability re-check —
  1. inserts one `customer_gift` row `{gift_id, points: gift.points, note}`; 🟢 (`:328`)
  2. `gifts.used += 1`; 🟢 (`:329`)
  3. `customers.points -= gift.points`. 🟢 (`:330`)
- **Response — `302` redirect back:** success flashes `admin_toastr('Đổi quà thành công')`. 🟢 (`:336-337`)
- **Failure modes (all `302` back):**
  - validation (missing `gift_id`) → framework errors. 🟢 (`:298-301`)
  - no active gift → `withErrors('Quà tặng không khả dụng')`. 🟢 (`:306`)
  - pre-lock gate fail → `withErrors($gateMessage)`. 🟢 (`:310-311`)
  - under-lock re-check throw / lock loss → `catch` → `withInput()->withErrors($e->getMessage())`, transaction rolled back (no partial redemption). 🟢 (`:322-334`)
- **Status codes:** `302` (redirect back — success or any error) · `404` (unknown/soft-deleted customer) · `302 → auth/login` (unauthenticated). Not JSON / not content-negotiated. 🟢
- **Idempotency:** **not** idempotent — each successful POST appends a pivot row and shifts `points`/`used`; no client dedup key. 🟡
- **Concurrency:** serialised per (customer, gift) via `lockForUpdate` + under-lock re-check; no oversell / no overspend. 🟢 (`:315-326`)

---

## GET `/customers/{customer}/gift-received` — redeemed-gifts history 🟢 (`:340-359`)

- **Auth:** required; anonymous → `302 auth/login`. 🟢 (`routes/web.php:23-28`)
- **Route parameter:** `customer` (integer, ✅) — `Customer::findOrFail` → `404`. 🟢 (`:348`)
- **Request (query string):**

  | Field | Type | Required | Notes |
  |-------|------|----------|-------|
  | `q` | string | ❌ | Search term, matched against `name` only (grouped closure). ✅ Fixed 2026-09-21 — previously referenced a non-existent `code` column. 🟢 (`:351-355`) |
  | `page` | integer | ❌ | Laravel paginator page (`paginate(30)`). 🟢 (`:355`) |

- **Response — `200 text/html`** rendering `pages.customer-gift-received` with:

  | View variable | Type | Meaning |
  |---------------|------|---------|
  | `gifts` | `LengthAwarePaginator<Gift>` | The customer's redeemed gifts, `id` desc, 30/page, each carrying its `pivot` (`points`, `note`). 🟢 (`:349-355`) |

- **Row shape rendered:** gift image, `name`, `pivot.points` (points spent), `pivot.note`, gift `created_at` (`d-m-Y H:i`). Empty → "Không có dữ liệu". 🟢 (`customer-gift-received.blade.php:39-53`)
- **Status codes:** `200` (page) · `404` (unknown/soft-deleted customer) · `302 → auth/login` (unauthenticated). 🟢
- ✅ **Fixed (2026-09-21): `?q=` search no longer targets the non-existent `gifts.code` column**, and is now grouped so a `name` match can't escape the pivot/`customer_id` scope. (`:351-355`, gifts migration:16-26)

---

## Consumed contracts (owned by other units)

`customers-loyalty` reads/writes the `customers`, `gifts`, and `customer_gift` tables through their models. It calls no other unit's HTTP endpoint and no external service. 🟢

| Reads / writes | Owner unit | Purpose |
|----------------|------------|---------|
| `customers` (`findOrFail`, `points` debit, row lock, `gifts()` relation) | `customers-crud` | resolve the customer and spend the loyalty balance |
| `gifts` (`active` scope, `points`/`limit`/`quantity`/`used`, `quantity_available`, `used` increment, row lock) | `gifts-crud` | resolve and consume the redeemable gift |
| `customer_gift` (insert redemption row; read history `with pivot`) | **this unit** | the redemption ledger |
| admin session / `['web','admin']` | `auth` | route guard |

---

## Producer/consumer relationships

| This unit is… | Counterparty | Contract |
|---------------|--------------|----------|
| **Producer** | `customer_gift` pivot | originates every redemption row (`points` snapshot + `note`). 🟢 |
| **Producer/consumer** | `gifts` | consumes stock (`quantity_available`) and produces the `used` increment. 🟢 |
| **Consumer** | `customers-crud` | reached from the customer detail tabs; depends on `Customer.points` + `gifts()`. 🟢 |
| **Peer producer** | `orders-crud` | credits `customers.points` on `done` orders and claws it back on delete (`reversePointsForOrder`); shares the same `points` field this unit debits. 🟢 (`Customer.php:97-112`) |
| **Consumer** | `gifts-scan` | the redemption UI's gift picker uses `settings/gifts/scan`; `gift_id` fed here comes from that lookup. 🟡 |
| **Consumer** | `auth` | admin session guard on all three routes. 🟢 |

---

## Cross-cutting contract notes

- **Method surface:** two `GET` (advisory JSON + HTML history) and one `POST` (redeem); no `PUT`/`DELETE` — the pivot is append-only through this unit. 🟢 (`routes/web.php:55,56,59`)
- **Content types:** `application/json` for `check-gift`; `302` redirect-back for `redeem-points` (web form, **not** JSON — contrast `check-gift`); `text/html` for `gift-received`. 🟢
- **Route-param naming inconsistency:** `redeem-points` uses `{id}` while the other two use `{customer}` — both resolve to the customer id; cosmetic only. 🟢 (`routes/web.php:55,56,59`)
- **Points model:** points are spent live off `customers.points` with **no dedicated ledger** (contrast the `customer_debts` ledger in `customers-debt-actions`); the only redemption trail is the `customer_gift` row. 🟢
- **Cost snapshot:** the pivot stores the gift's `points` cost at redemption time, so historical redemptions keep their original cost even after the gift is repriced. 🟢 (`:328`)
- **Authorization:** authentication only; any admin may redeem for / inspect any customer (no per-record scope). 🟡 (ADR-0009)
- **No observability:** none of the three routes emit telemetry; rolled-back redemptions and the two 🔴 failure modes are invisible to operations. 🔴 (`:296-377`, absence)
