# Customers Loyalty — Technical Design

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

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

## Interface

### HTTP endpoints

| Method | Path | Route name | Input | Output | Status codes |
|--------|------|------------|-------|--------|--------------|
| GET | `/customers/{customer}/check-gift` | `customers.check-gift` | `customer` (path), `gift_id` (query) | JSON `{status:bool[, message]}` | 200, (404) |
| POST | `/customers/{id}/redeem-points` | `customers.redeem-points` | `id` (path), `gift_id`+`note` (body) | 302 redirect back | 302, 404 |
| GET | `/customers/{customer}/gift-received` | `customers.gift-received` | `customer` (path), `q` (query) | `text/html` (`pages.customer-gift-received`) | 200, 404 |

All three require an authenticated admin session (`['web','admin']`); anonymous → `302 auth/login`. 🟢 (`routes/web.php:23-28`)

### Model surface consumed

| Symbol | Signature | Returns | Note |
|--------|-----------|---------|------|
| `Customer::gifts()` | `belongsToMany(Gift)->withPivot('points','note')` | relation | Pivot table `customer_gift`; no `withTimestamps()`. 🟢 (`Customer.php:44-46`) |
| `Customer::checkGiftAvailable` | `(Gift $gift)` | `['status'=>bool, 'message'?=>string]` | The redemption gate; **non-nullable** `Gift` type hint. 🟢 (`Customer.php:64-89`) |
| `Customer::reversePointsForOrder` | `(Order $order): void` | — | Point claw-back on order delete (owned/called by `orders-crud`). 🟢 (`Customer.php:97-112`) |
| `Gift::scopeActive` | `($query)` | query `where('active',1)` | 🟢 (`Gift.php:30-32`) |
| `Gift::getQuantityAvailableAttribute` | — | `quantity − used` | Appended attribute; stock test uses strict `=== 0`. 🟢 (`Gift.php:25-27`) |

## Main Flow — `redeem-points` (the write path)

1. Validate the request: `gift_id` required, `note` nullable string. 🟢 (`:298-301`)
2. `Customer::findOrFail($id)` → 404 if unknown/soft-deleted. 🟢 (`:303`)
3. `Gift::active()->find($gift_id)`; if falsy → `back()->withErrors('Quà tặng không khả dụng')`. 🟢 (`:304-307`)
4. Pre-lock gate: `$customer->checkGiftAvailable($gift)`; if `status===false` → `back()->withErrors($message)`. 🟢 (`:309-311`)
5. Open `DB::transaction`: 🟢 (`:313-331`)
   1. `lockForUpdate()->find` the customer and the gift rows (row locks). 🟢 (`:315-316`)
   2. If the locked gift vanished → `throw 'Quà tặng không khả dụng'`. 🟢 (`:318-320`)
   3. **Re-check** `checkGiftAvailable` under the lock; if blocked → `throw $message` (rolls back). 🟢 (`:322-326`)
   4. `gifts()->attach([gift.id => ['note'=>note, 'points'=>gift.points]])` — insert the `customer_gift` row snapshotting the cost. 🟢 (`:328`)
   5. `gift.update(['used' => used+1])`. 🟢 (`:329`)
   6. `customer.update(['points' => points − gift.points])`. 🟢 (`:330`)
6. On any thrown exception the whole transaction rolls back; `catch` → `back()->withInput()->withErrors($e->getMessage())`. 🟢 (`:332-334`)
7. Success → `admin_toastr('Đổi quà thành công')`, `redirect()->back()`. 🟢 (`:336-337`)

## Main Flow — `check-gift` (advisory JSON)

1. Guard: `if (!\request()->gift_id) response()->json(null,400);` — the `return` is still missing here (dead code), but no longer matters: 🟢 (`:363`)
2. `Customer::findOrFail($id)` → 404. 🟢 (`:365`)
3. `$gift = Gift::active()->find(request()->gift_id)`. 🟢 (`:366`)
4. ✅ **Fixed (2026-09-21):** `if (!$gift) return response()->json(['status'=>false,'message'=>'Quà tặng không khả dụng'])` — catches both a missing and an invalid/inactive `gift_id` before the non-nullable `checkGiftAvailable(Gift $gift)` can be called with `null`. 🟢 (`:368-370`, `Customer.php:64`)
5. `$customer->checkGiftAvailable($gift)` — if gate `status===false` → `response()->json($checkGiftResult)` (the full `{status:false,message}`). 🟢 (`:372-373`)
6. Otherwise → `response()->json(['status'=>true])`. 🟢 (`:376`)

## Main Flow — `gift-received` (read page)

1. `Customer::findOrFail($id)` → 404. 🟢 (`:348`)
2. Base query `customer.gifts()->orderBy('id','desc')`. 🟢 (`:349`)
3. ✅ **Fixed (2026-09-21):** optional `?q=` search now runs `where(fn: name LIKE %q%)` — grouped in a closure, targeting only the existing `name` column, so it stays scoped to this customer's own pivot rows. 🟢 (`:351-355`)
4. `paginate(30)`; render `pages.customer-gift-received` with `gifts`. 🟢 (`:357-358`)

## Alternative Flows

- **Missing `gift_id` on redeem:** framework validation fails → `302` back with validation errors (never reaches the gift lookup). 🟢 (`:298-301`)
- **Unknown/inactive gift on redeem:** step 3 returns early with "Quà tặng không khả dụng"; no transaction opened. 🟢 (`:304-307`)
- **Gate fails pre-lock on redeem:** step 4 returns early; no transaction opened. 🟢 (`:309-311`)
- **Race lost under lock:** the second concurrent redeemer's under-lock re-check throws; transaction rolls back; caller sees the availability error. 🟢 (`:322-326`)
- **`check-gift` with blocked gift:** returns `200` with the `{status:false,message}` object (the whole result, not just the flag). 🟢 (`:367-368`)
- **`gift-received` empty:** `@forelse … @empty` renders "Không có dữ liệu". 🟢 (`customer-gift-received.blade.php:49-53`)

## Dependencies

- **`customers-crud` / `Customer` model** — resolves the customer, owns `points` (debited here) and the `gifts()` relation. 🟢
- **`gifts-crud` / `Gift` model** — owns gift master data (`points`, `limit`, `quantity`, `used`, `active`); redemption increments `used` and reads `quantity_available`. 🟢
- **`customer_gift` pivot table** — the redemption ledger this unit writes (`points`, `note`). 🟢
- **`auth`** — the admin session guard on all three routes. 🟢
- **`orders-crud`** — the *counterparty* on the points balance: it credits `points` on `done` orders and claws them back via `reversePointsForOrder` on delete. Not called by this unit, but shares the `customers.points` field. 🟢 (`Customer.php:97-112`; domain.md#14)

## Design Decisions Identified

| Decision | Evidence | Confidence |
|----------|----------|------------|
| Redemption made transactional + row-locked with an under-lock re-check (fixed 2026-09-17). | `:313-331`; ADR-0007 | 🟢 |
| Points spent directly off `customers.points` — no dedicated points ledger (unlike `customer_debts`). | `:330`, `Customer.php` (no points-ledger table) | 🟢 |
| Cost snapshotted on the pivot (`points => gift.points`) rather than recomputed from the gift. | `:328` | 🟢 |
| `check-gift` is a stateless advisory pre-flight; authority lives in the redeem re-check. | `:361-377`, `:322-326` | 🟢 |
| `limit = 0` sentinel means "unlimited redemptions". | `Customer.php:74` | 🟢 |
| Out-of-stock test is strict `=== 0` on the computed `quantity_available` (a negative available slips through). | `Customer.php:66`, `Gift.php:25-27` | 🟡 |

## Internal State

This unit holds no in-process state. Durable state it mutates:

- `customer_gift` — one row per redemption (`customer_id`, `gift_id`, `points`, `note`, timestamps). Append-only via this unit; never edited or deleted here. 🟢
- `gifts.used` — running redeemed count, incremented per redemption; drives `quantity_available = quantity − used`. 🟢
- `customers.points` — live spendable balance, decremented per redemption. 🟢

## Observability

None. No log lines, metrics, or audit events are emitted by any of the three actions; a rolled-back redemption, a rejected availability check, and the `check-gift`/`gift-received` failure modes below are all invisible to operations. The only durable trace of a successful redemption is the pivot row + the `used`/`points` deltas. 🔴 (`:296-377`, absence)

## Risks and Gaps

- ✅ **Fixed (2026-09-21): `check-gift` no longer 500s on missing/invalid `gift_id`.** A null-guard now short-circuits before `checkGiftAvailable` runs.
- ✅ **Fixed (2026-09-21): `gift-received` search no longer hits the non-existent `code` column**, and the `orWhere` is now grouped so a name match can't surface gifts belonging to other customers (was the same bug family as `customers-crud`/`customers-scan`).
- 🟢 **Verified NOT a bug (2026-09-23): `gifts()->orderBy('id','desc')` does not raise an ambiguous-column error.** Both `gifts` and `customer_gift` do have an `id` column, and the Reviewer's 2026-09-22 static-analysis pass flagged this as a deterministic MySQL/MariaDB error 1052 on every row-fetch. Empirically retested against the project's real database (MariaDB 10.11.13, `docker exec` into the app container, `php artisan tinker`): `$customer->gifts()->orderBy('id','desc')->paginate(30)` runs the exact production SQL (`select * from gifts inner join customer_gift on gifts.id = customer_gift.gift_id where customer_gift.customer_id = ? and gifts.deleted_at is null order by id desc`) and returns the correct rows with no error. MariaDB resolves the unqualified `ORDER BY id` without ambiguity in practice on this engine/version. `questions.md#question-5` closed — no code change needed; the earlier 🔴 upgrade is retracted. (`CustomerController.php:349`)
- 🟡 **Out-of-stock uses strict `=== 0`** — a gift whose `used` exceeds `quantity` (negative `quantity_available`) is not caught by `checkGiftAvailable`; confirm `used` can never overrun `quantity`.
- 🟡 **`check-gift` gate can drift from the redeem outcome** — advisory only; a `true` verdict can still be rejected at redemption if stock/points change in between (by design, but the UI must handle the redeem-time error).
- 🟡 **No per-record authorization** — any admin redeems for / inspects any customer (ADR-0009).
- 🟡 **Pivot has no `withTimestamps()`** — `customer_gift.created_at/updated_at` are not maintained by `attach`; the received-list view shows the *gift's* `created_at`, not the redemption time, so redemption date is not reliably captured.
