# User Stories — Loyalty & Gifts

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

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

**Actor:** Store Administrator (acting for the shopper).
**Owning units:** `gifts-crud` (`resource settings/gifts`), `gifts-scan` (`GET settings/gifts/scan`), `customers-loyalty` (check-gift / redeem-points / gift-received).

Points are **earned** on `done` orders ([point-of-sale.md](point-of-sale.md)) and **spent** only here, by redeeming them for gifts. There is **no points ledger** (contrast the debt ledger) — the only redemption trail is the `customer_gift` pivot plus the `used`/`points` deltas. Redemption is atomic and gated on stock, per-customer limit, and points balance. 🟢

---

### US-GIFT-1 — Maintain the gift catalogue

**As a** Store Administrator, **I want** to manage the gifts customers can redeem, **so that** the loyalty programme stays current.

- **Given** the gifts admin (`settings/gifts`)
- **When** I create/edit a gift (name required; image; `points` cost; per-customer `limit`, 0 = unlimited; stock `quantity`; `active` switch)
- **Then** it saves via the Encore\Admin ModelForm; a `saving` hook zero-defaults points/limit/quantity and forces `used=0` for a new gift; a `saved` hook resizes the uploaded image to 300×300 🟢
- **And** on edit, `quantity` cannot drop below `used` ("Số lượng không được bé hơn tổng đã dùng") 🟢
- **And** deleting a gift **soft-deletes** it (redemption history in `customer_gift` survives) 🟢

Notes / gaps:
- Gifts have **no `code` column** (unlike products). 🟢
- Out-of-stock uses a strict `=== 0` test, so a negative `quantity_available` slips through. 🟡

Traces to: `gifts-crud/` (`GiftController` form/hooks)

---

### US-GIFT-2 — Pick a gift by name (system-facing)

**As the** redemption picker, **I want** to search active gifts by name, **so that** the Administrator can choose one to redeem.

- **Given** a typed query
- **When** the client calls `GET settings/gifts/scan?q=<text>`
- **Then** it returns `{ data: [ ≤10 gifts ] }` (a `{data:[]}` envelope) matching name `LIKE %q%`, each with the appended `quantity_available` + image URL 🟢

Notes / gaps:
- Empty/missing `q` becomes `LIKE '%%'`, returning the first 10 live gifts rather than `[]` — contract unconfirmed. 🔴
- No availability filtering by default at the endpoint level, but mitigated in practice: the redeem-points picker always passes `?active=1` and greys out/disables (`pointer-events: none`) any out-of-stock option client-side (`customer-statis.blade.php:328,338`, verified 2026-09-23). The authoritative gate is still `checkGiftAvailable`. 🟢
- Envelope shape differs from the other scan endpoints (`customers-scan` bare array, `pos-scan` discriminated union). 🟡

Traces to: `gifts-scan/` (`GiftController::scan`)

---

### US-GIFT-3 — Check gift availability before redeeming

**As a** Store Administrator, **I want** an advisory availability check, **so that** I don't attempt a redemption that will fail.

- **Given** a customer and a gift
- **When** I call `GET /customers/{customer}/check-gift`
- **Then** it returns `{ status: true }` when redeemable, or `{ status:false, message }` when the gate blocks (missing/out-of-stock gift; prior redemptions ≥ `limit` when `limit ≠ 0`; `gift.points > customer.points`) 🟢

Notes / gaps:
- Stateless pre-flight — no writes, no stock reservation; authority lives in the redeem re-check, so its verdict can drift. 🟡
- An unresolved/inactive `gift_id` returns a clean `{status:false, message:'Quà tặng không khả dụng'}` instead of crashing. ✅ Fixed 2026-09-21 (a null-guard was added after `Gift::active()->find()`; previously fell through into `checkGiftAvailable(Gift $gift)` and threw a TypeError → 500). 🟢 (`customers-loyalty`)

Traces to: `customers-loyalty/` (`checkGift`, `Customer::checkGiftAvailable`)

---

### US-GIFT-4 — Redeem points for a gift

**As a** Store Administrator, **I want** to redeem a customer's points for a gift, **so that** the shopper receives their reward and the balance is debited.

- **Given** a customer with enough points and an available active gift
- **When** I submit `POST /customers/{id}/redeem-points`
- **Then** in a transaction with `lockForUpdate` on **both** customer and gift plus an under-lock re-check, the gift pivot is attached (snapshotting `points` cost + note), `gift.used` is incremented, and `customer.points` is decremented — all rolled back together on any throw 🟢

Notes / gaps:
- This is the **only** place `customer.points` is spent. 🟢
- Concurrency is correctly handled (atomic since 2026-09-17, ADR-0007) — no oversell/overspend. 🟢 (not a gap)
- The pivot snapshots the cost paid, so historical redemptions survive a gift reprice. 🟢
- No observability on the redemption path. 🔴

Traces to: `customers-loyalty/` (`redeemRewardPoints`)

---

### US-GIFT-5 — Review a customer's redeemed gifts

**As a** Store Administrator, **I want** to see the gifts a customer has redeemed, **so that** I can confirm past rewards.

- **Given** a customer
- **When** I open `GET /customers/{customer}/gift-received`
- **Then** the redeemed-gifts history page renders from the `customer_gift` pivot 🟢

Notes / gaps:
- The `?q=` search matches gift `name` via a grouped closure, scoped to the current customer. ✅ Fixed 2026-09-21 (was `where('code', ...)` — gifts have no `code` column, so any search raised an unknown-column SQL error — combined with an ungrouped `orWhere('name', ...)` that could surface other customers' gifts). 🟢 (`customers-loyalty`)
- The pivot has no `withTimestamps`, so the redemption date isn't reliably captured (the view shows the gift's `created_at`). 🟡

Traces to: `customers-loyalty/` (`getListGiftReceived`)
