# Customers Loyalty Specification

## Purpose

The Customers Loyalty capability exposes three admin-only HTTP routes that let an admin verify whether a customer may claim a loyalty gift, atomically redeem a customer's points for an active gift, and browse the paginated history of gifts a customer has already received. Redemption is the only mechanism through which a customer's points balance is spent.

## Requirements

### Requirement: Admin Authentication Required

The system SHALL require an active admin session for all three loyalty routes. An unauthenticated request to any loyalty route SHALL respond with `302` redirecting to `auth/login`. (Implemented in `routes/web.php:23-28,55,56,59`)

#### Scenario: Unauthenticated pre-flight check
- **GIVEN** a visitor without an admin session
- **WHEN** the visitor sends `GET /customers/{customer}/check-gift`
- **THEN** the system responds with `302` redirecting to `auth/login`

#### Scenario: Unauthenticated redemption
- **GIVEN** a visitor without an admin session
- **WHEN** the visitor sends `POST /customers/{id}/redeem-points`
- **THEN** the system responds with `302` redirecting to `auth/login`

#### Scenario: Unauthenticated gift history
- **GIVEN** a visitor without an admin session
- **WHEN** the visitor sends `GET /customers/{customer}/gift-received`
- **THEN** the system responds with `302` redirecting to `auth/login`

### Requirement: Unknown Customer Rejected

The system SHALL respond with `404` when the customer identified by the route parameter does not exist or has been soft-deleted, on any of the three loyalty routes. (Implemented in `app/Http/Controllers/CustomerController.php:303,348,363`)

#### Scenario: Unknown customer on pre-flight
- **GIVEN** an authenticated admin
- **WHEN** the admin sends `GET /customers/99999/check-gift?gift_id=1`
- **THEN** the system responds with `404`

#### Scenario: Unknown customer on redemption
- **GIVEN** an authenticated admin
- **WHEN** the admin sends `POST /customers/99999/redeem-points` with `gift_id=1`
- **THEN** the system responds with `404`

#### Scenario: Unknown customer on gift history
- **GIVEN** an authenticated admin
- **WHEN** the admin sends `GET /customers/99999/gift-received`
- **THEN** the system responds with `404`

### Requirement: Gift Availability Pre-flight Returns JSON Verdict

`GET /customers/{customer}/check-gift` (`customers.check-gift`) SHALL respond with `200 application/json`. When the customer may redeem the gift the body SHALL be `{"status": true}`. When the customer is blocked the body SHALL be `{"status": false, "message": "<reason>"}` where reason is one of `"Quà tặng không khả dụng"`, `"Đã vượt quá số lần đổi quà tối đa"`, or `"Không đủ điều kiện để nhận quà"`. (Implemented in `app/Http/Controllers/CustomerController.php:361-377`)

#### Scenario: Gift is available
- **GIVEN** an authenticated admin, customer C with sufficient points, and active gift G that is in stock and C has not reached G's redemption limit
- **WHEN** the admin sends `GET /customers/{C}/check-gift?gift_id={G}`
- **THEN** the system responds with `200 application/json` body `{"status": true}`

#### Scenario: Gift blocked — out of stock
- **GIVEN** an authenticated admin and active gift G with `quantity_available` equal to `0`
- **WHEN** the admin sends `GET /customers/{C}/check-gift?gift_id={G}`
- **THEN** the system responds with `200 application/json` body `{"status": false, "message": "Quà tặng không khả dụng"}`

#### Scenario: Gift blocked — limit reached
- **GIVEN** an authenticated admin and customer C who has redeemed active gift G exactly G.limit times, where G.limit is greater than `0`
- **WHEN** the admin sends `GET /customers/{C}/check-gift?gift_id={G}`
- **THEN** the system responds with `200 application/json` body `{"status": false, "message": "Đã vượt quá số lần đổi quà tối đa"}`

#### Scenario: Gift blocked — insufficient points
- **GIVEN** an authenticated admin and customer C whose `points` balance is less than active gift G's cost
- **WHEN** the admin sends `GET /customers/{C}/check-gift?gift_id={G}`
- **THEN** the system responds with `200 application/json` body `{"status": false, "message": "Không đủ điều kiện để nhận quà"}`

### Requirement: Pre-flight Handles Missing or Inactive Gift Cleanly

`GET /customers/{customer}/check-gift` SHALL return `{"status": false, "message": "Quà tặng không khả dụng"}` — not a server error — when `gift_id` is absent, references an unknown gift, or references an inactive gift. (Implemented in `app/Http/Controllers/CustomerController.php:361-370`)

#### Scenario: gift_id omitted
- **GIVEN** an authenticated admin and a valid customer
- **WHEN** the admin sends `GET /customers/{customer}/check-gift` without a `gift_id` parameter
- **THEN** the system responds with `200 application/json` body `{"status": false, "message": "Quà tặng không khả dụng"}`

#### Scenario: gift_id references an inactive gift
- **GIVEN** an authenticated admin, a valid customer, and gift G with `active = 0`
- **WHEN** the admin sends `GET /customers/{customer}/check-gift?gift_id={G}`
- **THEN** the system responds with `200 application/json` body `{"status": false, "message": "Quà tặng không khả dụng"}`

### Requirement: Pre-flight Is Advisory Only

`GET /customers/{customer}/check-gift` SHALL perform no writes and SHALL NOT reserve stock. A `{"status": true}` response does not guarantee a subsequent redemption will succeed if conditions change before the `POST` is submitted. (Implemented in `app/Http/Controllers/CustomerController.php:361-377`)

#### Scenario: Stock consumed between pre-flight and redemption
- **GIVEN** an admin who received `{"status": true}` for gift G with `quantity_available` equal to `1`, and another admin who redeems the last unit of G immediately afterward
- **WHEN** the first admin then sends `POST /customers/{C}/redeem-points` with `gift_id={G}`
- **THEN** the system rejects the redemption with an availability error, no redemption record is created, and neither the customer's balance nor the gift's consumed count changes

### Requirement: Redemption Validates gift_id as Required

`POST /customers/{id}/redeem-points` (`customers.redeem-points`) SHALL require `gift_id` in the request body. When `gift_id` is absent the system SHALL respond with `302` redirecting back with a validation error. The `note` field SHALL be accepted as an optional free-text value and persisted on the redemption record when provided. (Implemented in `app/Http/Controllers/CustomerController.php:298-301`)

#### Scenario: Missing gift_id
- **GIVEN** an authenticated admin and a valid customer C
- **WHEN** the admin sends `POST /customers/{C}/redeem-points` without a `gift_id` field
- **THEN** the system responds with `302` redirecting back with a validation error, and no redemption record is created

#### Scenario: Optional note persisted
- **GIVEN** an authenticated admin, customer C with sufficient points, and available active gift G
- **WHEN** the admin sends `POST /customers/{C}/redeem-points` with `gift_id={G}` and `note="birthday gift"`
- **THEN** the redemption succeeds and the value `"birthday gift"` is stored on the redemption record and visible in the gift history

### Requirement: Redemption Requires an Active Gift

`POST /customers/{id}/redeem-points` SHALL reject a `gift_id` that does not resolve to an active gift with `302` redirecting back and the error `"Quà tặng không khả dụng"`. No redemption record SHALL be created and no balances SHALL change. (Implemented in `app/Http/Controllers/CustomerController.php:304-307`)

#### Scenario: Inactive gift rejected
- **GIVEN** an authenticated admin, a valid customer, and gift G with `active = 0`
- **WHEN** the admin sends `POST /customers/{C}/redeem-points` with `gift_id={G}`
- **THEN** the system responds with `302` redirecting back with error `"Quà tặng không khả dụng"` and no redemption record is created

#### Scenario: Unknown gift rejected
- **GIVEN** an authenticated admin and a valid customer
- **WHEN** the admin sends `POST /customers/{C}/redeem-points` with `gift_id=99999`
- **THEN** the system responds with `302` redirecting back with error `"Quà tặng không khả dụng"` and no redemption record is created

### Requirement: Redemption Gate Enforces Stock, Limit, and Points

`POST /customers/{id}/redeem-points` SHALL enforce three conditions before writing: the gift must have `quantity_available` greater than `0`; the customer's prior redemption count for that gift must be below the gift's `limit` (when `limit` is not `0`); and the customer's `points` balance must be at least equal to the gift's cost. Any failing condition SHALL produce a `302` redirect back with the corresponding message and no change to any balance or count. (Implemented in `app/Models/Customer.php:64-89`, `app/Http/Controllers/CustomerController.php:309-311`)

#### Scenario: Redemption blocked — out of stock
- **GIVEN** an authenticated admin and active gift G with `quantity_available` equal to `0`
- **WHEN** the admin sends `POST /customers/{C}/redeem-points` with `gift_id={G}`
- **THEN** the system responds with `302` redirecting back with error `"Quà tặng không khả dụng"` and no redemption record is created

#### Scenario: Redemption blocked — limit reached
- **GIVEN** an authenticated admin and customer C who has already redeemed active in-stock gift G exactly G.limit times, where G.limit is greater than `0`
- **WHEN** the admin sends `POST /customers/{C}/redeem-points` with `gift_id={G}`
- **THEN** the system responds with `302` redirecting back with error `"Đã vượt quá số lần đổi quà tối đa"` and no redemption record is created

#### Scenario: Redemption blocked — insufficient points
- **GIVEN** an authenticated admin and customer C whose `points` balance is less than active in-stock gift G's cost
- **WHEN** the admin sends `POST /customers/{C}/redeem-points` with `gift_id={G}`
- **THEN** the system responds with `302` redirecting back with error `"Không đủ điều kiện để nhận quà"` and no redemption record is created

### Requirement: Zero Limit Means Unlimited Redemptions

When a gift's `limit` field equals `0` the system SHALL place no upper bound on the number of times a single customer may redeem that gift; the per-customer count check SHALL be skipped entirely. (Implemented in `app/Models/Customer.php:74`)

#### Scenario: Unlimited gift redeemed multiple times
- **GIVEN** an authenticated admin, customer C with sufficient points each time, and active in-stock gift G with `limit = 0`
- **WHEN** the admin redeems G for C on two separate requests
- **THEN** both redemptions succeed; neither is blocked by a per-customer limit error

### Requirement: Successful Redemption Applies All Three Changes

On a successful redemption `POST /customers/{id}/redeem-points` SHALL respond with `302` redirecting back with the success notification `"Đổi quà thành công"` and SHALL have created one redemption record linking the customer and the gift, incremented the gift's consumed count by exactly `1`, and decremented the customer's `points` balance by exactly the gift's point cost. (Implemented in `app/Http/Controllers/CustomerController.php:313-337`)

#### Scenario: All three changes applied on success
- **GIVEN** an authenticated admin, customer C with `points` equal to `50`, and active in-stock gift G with a cost of `30` and a consumed count of `2`, where C has not reached G's limit
- **WHEN** the admin sends `POST /customers/{C}/redeem-points` with `gift_id={G}`
- **THEN** a redemption record exists for the (C, G) pair, G's consumed count is `3`, C's `points` balance is `20`, and the response is `302` redirecting back with notification `"Đổi quà thành công"`

### Requirement: Concurrent Redemptions Are Serialised

When two redemptions for the same gift arrive simultaneously the system SHALL guarantee that at most one succeeds when the gift has only one unit of stock remaining or only one redemption slot remaining within a customer's limit. The losing request SHALL receive a `302` redirect back with an availability error. Neither overselling nor overspending of points SHALL occur. (Implemented in `app/Http/Controllers/CustomerController.php:315-326`)

#### Scenario: Two concurrent redemptions of the last unit
- **GIVEN** active gift G with `quantity_available` equal to `1` and two simultaneous `POST /customers/{C}/redeem-points` requests with `gift_id={G}`
- **WHEN** both requests are processed concurrently
- **THEN** exactly one creates a redemption record and G's consumed count increases by `1`; the other responds with `302` redirecting back with an availability error and makes no changes

### Requirement: Redemption Snapshots the Point Cost

The system SHALL store the gift's point cost on the redemption record at the time of redemption. The stored cost SHALL remain unchanged even if the gift's cost is subsequently modified. (Implemented in `app/Http/Controllers/CustomerController.php:328`)

#### Scenario: Gift repriced after redemption
- **GIVEN** customer C who redeemed gift G when G's cost was `30`, and G whose cost is later updated to `50`
- **WHEN** the admin views C's gift history
- **THEN** the redemption record for G shows the cost as `30`

### Requirement: Gift History Page Renders Paginated Results

`GET /customers/{customer}/gift-received` (`customers.gift-received`) SHALL respond with `200 text/html` listing the customer's redeemed gifts ordered newest-first, paginated at `30` items per page. Each row SHALL display the gift image, name, point cost at redemption time, note, and the gift's creation date formatted as `d-m-Y H:i`. When the customer has no redemptions the page SHALL show `"Không có dữ liệu"`. (Implemented in `app/Http/Controllers/CustomerController.php:340-359`, `resources/views/pages/customer-gift-received.blade.php`)

#### Scenario: History with redeemed gifts
- **GIVEN** an authenticated admin and customer C who has redeemed two gifts
- **WHEN** the admin sends `GET /customers/{C}/gift-received`
- **THEN** the system responds with `200 text/html` listing both gifts newest-first, each showing the gift image, name, redemption point cost, note, and creation date

#### Scenario: Empty history
- **GIVEN** an authenticated admin and customer C who has never redeemed a gift
- **WHEN** the admin sends `GET /customers/{C}/gift-received`
- **THEN** the system responds with `200 text/html` showing the message `"Không có dữ liệu"`

#### Scenario: Pagination beyond first page
- **GIVEN** an authenticated admin and customer C who has redeemed `35` gifts
- **WHEN** the admin sends `GET /customers/{C}/gift-received?page=2`
- **THEN** the system responds with the `5` oldest gifts on the second page

### Requirement: Gift History Search Filters by Name

`GET /customers/{customer}/gift-received` SHALL support an optional `q` query parameter that narrows the list to gifts whose name contains the search term. The filter SHALL apply only to the current customer's own redemption records and SHALL NOT surface redemptions belonging to other customers. (Implemented in `app/Http/Controllers/CustomerController.php:351-355`)

#### Scenario: Search matches by name
- **GIVEN** an authenticated admin and customer C who has redeemed gifts named `"Apple"` and `"Banana"`
- **WHEN** the admin sends `GET /customers/{C}/gift-received?q=App`
- **THEN** the response lists only `"Apple"` and excludes `"Banana"`

#### Scenario: Search is scoped to the current customer
- **GIVEN** an authenticated admin, customer C1 who redeemed a gift named `"Apple"`, and customer C2 who also redeemed a gift named `"Apple"`
- **WHEN** the admin sends `GET /customers/{C1}/gift-received?q=Apple`
- **THEN** the response contains only C1's redemption of `"Apple"` and does not include C2's redemption
