# Customers Loyalty — Requirements

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

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

## Overview

The `customers-loyalty` unit is the per-customer **points-for-gifts redemption** surface of TinyPOS. It covers the three `/customers/*` routes that let an admin check whether a customer can claim a loyalty gift, redeem points for a gift, and browse the gifts a customer has already received. Redemption is the only place a customer's `points` balance is *spent* (points are *earned* on `done` orders — see `orders-crud`). 🟢 (`CustomerController.php:296-377`, `routes/web.php:55,56,59`)

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

## Responsibilities

- **Pre-flight availability check** — `GET /customers/{customer}/check-gift?gift_id=` returns a JSON verdict (`{status:true}` or `{status:false,message}`) so the redemption UI can validate before submitting. 🟢 (`CustomerController.php:361-377`)
- **Redeem points for a gift** — `POST /customers/{id}/redeem-points` atomically attaches the gift to the customer, increments the gift's `used` count, and debits `customer.points` by the gift's cost. 🟢 (`:296-338`)
- **List gifts received** — `GET /customers/{customer}/gift-received` renders a paginated, searchable history of the gifts a customer has redeemed, showing the points spent and note per pivot row. 🟢 (`:340-359`)
- Enforce the three-part redemption gate through `Customer::checkGiftAvailable` (stock, per-customer limit, sufficient points). 🟢 (`Customer.php:64-89`)

## Business Rules

- **BR-L1 — Redemption is gated on three conditions.** The gift must exist and not be out of stock (`quantity_available !== 0`, where `quantity_available = quantity − used`); the customer's prior redemptions of that gift must be under `gift.limit` (`limit = 0` ⇒ unlimited); and `gift.points <= customer.points`. All three are checked by `checkGiftAvailable`. 🟢 (`Customer.php:64-89`, `Gift.php:25-27`; domain.md#12)
- **BR-L2 — Only *active* gifts are redeemable.** Both `check-gift` and `redeem-points` resolve the gift via `Gift::active()->find(...)` (`active = 1`); an inactive gift is treated as not found. 🟢 (`:304,364`, `Gift.php:30-32`)
- **BR-L3 — Redemption is atomic and serialised.** Attaching the pivot, incrementing `gift.used`, and decrementing `customer.points` run inside one `DB::transaction` with `lockForUpdate` on both the customer and the gift row, plus a re-check of `checkGiftAvailable` under the lock. A failure or concurrent redemption cannot desynchronise pivot / `used` / `points`. 🟢 (`:313-331`; domain.md#13, ADR-0007)
- **BR-L4 — The points cost is snapshotted onto the pivot.** `attach` stores `points => gift.points` (and the free-text `note`) on the `customer_gift` row, preserving the cost paid at redemption time even if the gift's price later changes. 🟢 (`:328`, `customer_gift` migration:20-21)
- **BR-L5 — Redemption debits the *live* `points` balance.** `customer.points -= gift.points` under the lock; there is no separate ledger for points (unlike debt). The balance can only be restored by point claw-back when an order is deleted (`reversePointsForOrder`, clamped ≥ 0). 🟢 (`:330`, `Customer.php:97-112`; domain.md#14)
- **BR-L6 — The limit counts *rows*, not quantity.** `checkGiftAvailable` counts `customer.gifts()->where('gift_id', id)->count()` prior redemptions; each redemption is one pivot row, so `limit` bounds the number of times this customer may claim this gift. 🟢 (`Customer.php:72-78`)
- **BR-L7 — `check-gift` is advisory only.** It performs no writes and does not reserve stock; the authoritative gate is the under-lock re-check inside `redeem-points`. A `check-gift` that says `true` can still be rejected at redemption if stock/points changed in between. 🟢 (`:361-377`, `:322-326`)
- **BR-L8 — Authentication only, no per-record scope.** All three routes sit behind the `['web','admin']` group; any authenticated admin may redeem for / inspect any customer. 🟡 (ADR-0009)
- ✅ **Fixed (2026-09-21): `check-gift` no longer crashes on a missing/invalid `gift_id`.** A `null`-guard was added right after `Gift::active()->find(...)`, returning `{status:false, message:'Quà tặng không khả dụng'}` before `checkGiftAvailable` (non-nullable `Gift` hint) can be called with `null`. (The original guard at `:363` still lacks its `return`, but is now dead/harmless — the new null-check downstream catches both the missing- and invalid-`gift_id` cases.) 🟢 (`:361-369`, `Customer.php:64`)
- ✅ **Fixed (2026-09-21): `gift-received` search no longer references the non-existent `code` column.** Now searches only `name`, wrapped in a grouped closure so the match stays scoped to this customer's own `customer_gift` rows (was also an ungrouped-OR leak risk). 🟢 (`:351-355`, gifts migration:16-26)

## Functional Requirements

| ID | Requirement | Priority | Acceptance Criterion |
|----|-------------|----------|----------------------|
| RF-01 | `GET /customers/{customer}/check-gift?gift_id=` returns a JSON availability verdict for the (customer, active gift) pair. | Must | With a valid `gift_id` for an available gift → `200 {"status":true}`; for a blocked gift → `200 {status:false,message}`. 🟢 (`:361-377`) |
| RF-02 | `POST /customers/{id}/redeem-points` redeems an active gift for a customer, atomically attaching the pivot, incrementing `gift.used`, and debiting `customer.points`. | Must | On success: one new `customer_gift` row, `gift.used+1`, `customer.points − gift.points`, redirect back with `admin_toastr('Đổi quà thành công')`. 🟢 (`:296-338`) |
| RF-03 | Redemption validates `gift_id` (required) and `note` (nullable string), then gates on `Gift::active` + `checkGiftAvailable`. | Must | Missing `gift_id` → 302 back with validation error; inactive/unknown gift → 302 back "Quà tặng không khả dụng"; failed gate → 302 back with the gate message. 🟢 (`:298-311`) |
| RF-04 | Redemption's write section runs in a transaction with `lockForUpdate` on customer + gift and re-checks availability under the lock. | Must | Concurrent redemptions of a limited/last-stock gift never overspend points or oversell stock; a thrown re-check rolls back with the message. 🟢 (`:313-331`) |
| RF-05 | `GET /customers/{customer}/gift-received` renders a paginated (30/page, `id` desc) list of the customer's redeemed gifts with per-pivot `points` and `note`. | Should | Page shows image, name, pivot points, note, gift `created_at`; empty state renders "Không có dữ liệu". 🟢 (`:340-359`, `customer-gift-received.blade.php`) |
| RF-06 | The redemption gate treats `gift.limit = 0` as unlimited and otherwise blocks once prior-redemption count reaches `limit`. | Must | A customer at `limit` redemptions → gate returns `false` "Đã vượt quá số lần đổi quà tối đa". 🟢 (`Customer.php:74-78`) |
| RF-07 | The pivot snapshots the gift's `points` cost at redemption time. | Should | `customer_gift.points` equals `gift.points` as it was at redemption, not the current gift price. 🟢 (`:328`) |

## Non-Functional Requirements

| Type | Inferred requirement | Evidence | Confidence |
|------|----------------------|----------|------------|
| Security | All three routes require an authenticated admin session (`['web','admin']`); anonymous → `302 auth/login`. | `routes/web.php:23-28,55-59` | 🟢 |
| Security | `redeem-points` is a CSRF-protected web `POST` (`Form::open` token); `check-gift`/`gift-received` are `GET`. | `customer-statis.blade.php`, `:296` | 🟡 |
| Consistency | Point/stock mutation is serialised per (customer, gift) via `lockForUpdate` inside a transaction with an under-lock re-check. | `:313-331` | 🟢 |
| Availability | On any redemption exception the transaction rolls back and the request redirects back with the error message — no partial redemption. | `:332-334` | 🟢 |
| Observability | No logging/metrics on any of the three routes; a rolled-back redemption or a rejected availability check leaves no trace beyond the flashed message. | `:296-377` (absence) | 🔴 |

> Inferred from code. Validate with the operations team.

## Acceptance Criteria

```gherkin
Feature: Loyalty gift redemption

Scenario: Successful redemption
  Given an authenticated admin and customer C with points >= gift G.points
  And gift G is active, in stock, and C is under G.limit redemptions
  When the admin POSTs /customers/{C}/redeem-points with gift_id=G and a note
  Then a customer_gift row is created snapshotting points=G.points and the note
  And G.used is incremented by 1
  And C.points is decreased by G.points
  And the response is a 302 redirect back flashing "Đổi quà thành công"

Scenario: Redemption blocked — not enough points
  Given customer C with points < gift G.points
  When the admin POSTs /customers/{C}/redeem-points with gift_id=G
  Then no pivot row is created, G.used and C.points are unchanged
  And the response is a 302 redirect back with error "Không đủ điều kiện để nhận quà"

Scenario: Redemption blocked — out of stock
  Given gift G with quantity_available === 0
  When the admin POSTs /customers/{C}/redeem-points with gift_id=G
  Then the response is a 302 redirect back with error "Quà tặng không khả dụng"

Scenario: Pre-flight availability check — available
  Given customer C who may redeem active gift G
  When the admin GETs /customers/{C}/check-gift?gift_id=G
  Then the response is 200 application/json {"status": true}

Scenario: Pre-flight availability check — blocked
  Given customer C who is over G.limit for active gift G
  When the admin GETs /customers/{C}/check-gift?gift_id=G
  Then the response is 200 application/json {"status": false, "message": "Đã vượt quá số lần đổi quà tối đa"}

Scenario: List gifts received
  Given customer C who has redeemed two gifts
  When the admin GETs /customers/{C}/gift-received
  Then the page lists both gifts newest-first with pivot points and notes

Scenario: Concurrent redemption of the last unit
  Given gift G with quantity_available === 1 and two simultaneous redemptions for the same customer
  When both POST /customers/{C}/redeem-points with gift_id=G
  Then exactly one succeeds; the other rolls back with an availability error (under-lock re-check)
```

## Priority (MoSCoW)

| Requirement | MoSCoW | Rationale |
|-------------|--------|-----------|
| Atomic, locked point/stock mutation on redeem (RF-02, RF-04) | Must | Money-adjacent invariant; the only place points are spent — no fallback. |
| Three-part redemption gate (RF-03, RF-06) | Must | Core loyalty business rule enforced in code. |
| JSON pre-flight `check-gift` (RF-01) | Should | UX convenience; the under-lock re-check is the real authority. |
| Gifts-received history page (RF-05) | Should | Read-only reporting; important but not on the redemption critical path. |
| Pivot cost snapshot (RF-07) | Could | Audit nicety; not required for the redemption to complete. |

> Priority inferred by call frequency and position in the dependency chain.

## Code Traceability

| File | Function / Class | Coverage |
|------|------------------|----------|
| `app/Http/Controllers/CustomerController.php:296-338` | `redeemRewardPoints` | 🟢 |
| `app/Http/Controllers/CustomerController.php:340-359` | `getListGiftReceived` | 🟢 |
| `app/Http/Controllers/CustomerController.php:361-377` | `checkGift` | 🟢 |
| `app/Models/Customer.php:44-46` | `gifts()` belongsToMany | 🟢 |
| `app/Models/Customer.php:64-89` | `checkGiftAvailable` | 🟢 |
| `app/Models/Customer.php:97-112` | `reversePointsForOrder` (claw-back, consumed) | 🟢 |
| `app/Models/Gift.php:25-32` | `quantity_available` accessor, `active` scope | 🟢 |
| `database/migrations/2022_05_20_151916_create_customer_gift_table.php` | `customer_gift` pivot schema | 🟢 |
| `resources/views/pages/customer-gift-received.blade.php` | gifts-received view | 🟢 |
| `routes/web.php:55,56,59` | route declarations | 🟢 |
