---
feature: Coupons
slug: coupons
surface: admin
consumer: react
version: 1.0.1
status: ready
brd_version: "1.3"
brd_ids: [ADMIN-015, ADMIN-016, CUST-011, CUST-014]
business_rules: [BR-29]
decisions: [DEC-09, DEC-14, TD-19, TD-27, TD-131, TD-148]
schema_tables: [coupons, coupon_redemptions, orders]
related_docs: [../client/booking.md, ../client/orders.md, orders.md]
last_updated: 2026-11-10
---

# Coupons — React Dashboard Integration Guide

> **Base URL:** `{HOST}/api/v1/admin` · Shared rules (headers, envelope, errors,
> pagination, axios setup, TS types): [API_CONVENTIONS.md](../API_CONVENTIONS.md)

> **Working assumption.** Scope, usage counting, rounding and the free-order rule are
> [TD-148](../DECISIONS.md), recorded as a working assumption **awaiting owner confirmation**.

## Version history

Newest first. Never edit an old row.

| Version | Date | Type | Summary | Dashboard action required |
|---------|------|------|---------|---------------------------|
| 1.0.1 | 2026-11-10 | PATCH | Validation messages for the coupon form use Arabic field names; no English labels or sentences in Arabic responses (QA-NEW-01). No contract change. | None. |
| 1.0.0 | 2026-11-01 | Initial | Coupon CRUD with scope, limits and dates, plus a paginated redemption history. | Build the Coupons page, the form and the redemptions drawer; gate by `payments.*`. |

## Changelog

### 1.0.1 — 2026-11-10

- **Fixed:** Validation messages for the coupon form use Arabic field names; no English labels or sentences in Arabic responses (QA-NEW-01). No contract change.
- **Breaking:** No. **App action required:** None.

### 1.0.0 — 2026-11-01
- **Added:** `GET/POST /coupons`, `GET/PUT/DELETE /coupons/{coupon}`, `GET /coupons/{coupon}/redemptions`; `coupon` block on [admin order details](orders.md).
- **Changed:** —
- **Fixed:** —
- **Removed / Deprecated:** —
- **Breaking:** No
- **Endpoints affected:** all of the above.
- **Dashboard action required:** Build the pages in §2. Never send `funded_by`; send `applies_to_id` as a uuid.
- **BRD / decisions:** ADMIN-015/016, CUST-011/014; TD-131, TD-148; DEC-09 (provider funding stays unused).

---

## 1. Business summary

An admin creates discount codes that customers type at checkout. A coupon takes money off the
**services amount only** — delivery is never discounted, VAT is not involved, and Joshen funds the
discount (every coupon is `funded_by: platform`; the form cannot change it), so the provider's
commission base is untouched. A coupon can cover everything, one category, one bookable service or
one provider; packages are never covered. A use counts when the order is created and is given back
if the order is rejected, expires or is cancelled. A coupon that was ever used cannot be deleted —
switch `is_active` off instead.

## 2. Pages and permissions

All routes belong to the **`payments`** section (`config/rbac.php`).

| Page / component | Purpose | Endpoint(s) | RBAC permission (route name) | Section · action |
|------------------|---------|-------------|------------------------------|------------------|
| CouponsListPage | Table with used count, search, filters | `GET /coupons` | `admin.coupons.index` | payments · view |
| CouponDetailsPage | One coupon | `GET /coupons/{coupon}` | `admin.coupons.show` | payments · view |
| CouponForm (create) | New coupon | `POST /coupons` | `admin.coupons.store` | payments · create |
| CouponForm (edit) / active switch | Partial update | `PUT /coupons/{coupon}` | `admin.coupons.update` | payments · update |
| DeleteCouponDialog | Remove an unused coupon | `DELETE /coupons/{coupon}` | `admin.coupons.destroy` | payments · delete |
| RedemptionsDrawer | Orders the coupon was used on | `GET /coupons/{coupon}/redemptions` | `admin.coupons.redemptions` | payments · view |

Sidebar entry: Payments → Coupons. Hide it when the admin lacks `admin.coupons.index`.

## 3. List / table

`GET /api/v1/admin/coupons` — list key in `data`: `coupons` (standard paginated shape, API conventions §2.2).

| Column | Field | Sortable (`sort[...]`) | Filter (`filters[...]`) | Notes |
|--------|-------|------------------------|-------------------------|-------|
| Code | `code` | yes | — | Upper-case |
| Discount | `discount_type.label`, `discount_value` | no | `filters[discount_type]=percent\|fixed` | Percent shows `%`, fixed shows SAR |
| Scope | `applies_to.label` | no | `filters[applies_to]=all\|category\|service\|provider` | |
| Used | `used_count` / `usage_limit` | no | — | Live count of redemptions; `usage_limit: null` = unlimited |
| Active | `is_active` | no | `filters[is_active]=0\|1` | |
| Created | `created_at` | yes | — | ISO 8601 |

`search` matches: `code`, `description`.

Postman: `admin.coupons.index` › "200 Coupon List".

## 4. Details view

`GET /api/v1/admin/coupons/{coupon}` (`{coupon}` = uuid; an integer id is a 404)

Postman: `admin.coupons.show` › "200 Coupon Details".

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "id": "01a196be-dcb0-703f-839e-8e9f8749b6e4",
    "code": "sample-fejc44660761",
    "description": null,
    "discount_type": { "value": "fixed", "label": "Fixed amount" },
    "discount_value": "10.00",
    "max_discount": null,
    "min_order_amount": "0.00",
    "applies_to": { "value": "category", "label": "Category" },
    "applies_to_id": "01a196be-dcad-73fa-9b89-4cbb354615b9",
    "funded_by": { "value": "platform", "label": "Joshen" },
    "usage_limit": null,
    "per_customer_limit": 1,
    "used_count": 0,
    "starts_at": null,
    "ends_at": null,
    "is_active": true,
    "created_at": "2026-11-01T10:54:13+00:00"
  }
}
```

| Field | Type | Nullable | Description |
|-------|------|----------|-------------|
| `id` | uuid | no | Public id |
| `code` | string | no | Stored upper-case |
| `discount_type` | `{value,label}` | no | `percent` or `fixed` |
| `discount_value` | money string | no | Percent (≤ 100) or SAR |
| `max_discount` | money string | yes | Cap on one order's discount |
| `min_order_amount` | money string | no | Compared with the services amount (before delivery) |
| `applies_to` | `{value,label}` | no | `all`, `category`, `service`, `provider` (a `package` row would be inert — packages are never covered) |
| `applies_to_id` | uuid | yes | Category / service / provider uuid; `null` for `all` |
| `funded_by` | `{value,label}` | no | Always `platform` |
| `usage_limit` | int | yes | Total uses; `null` = unlimited |
| `per_customer_limit` | int | no | Uses per customer (≥ 1) |
| `used_count` | int | no | Live redemptions |
| `starts_at`, `ends_at` | ISO 8601 | yes | Active window |
| `is_active` | bool | no | |

## 5. Forms (create / edit)

`POST /api/v1/admin/coupons` · `PUT /api/v1/admin/coupons/{coupon}`
(update accepts partial payloads — every field is `sometimes`; business checks run on the **merged** values)

| Field | Input type | Required on create | Validation (mirror in zod/yup) | Notes |
|-------|-----------|--------------------|-------------------------------|-------|
| `code` | text | yes | string, max 40, `^[A-Za-z0-9_-]+$`, unique (case-insensitive) | Saved upper-case |
| `description` | text | no | string, max 255 | Internal note |
| `discount_type` | select | yes | `percent` \| `fixed` | |
| `discount_value` | number | yes | numeric > 0, max 2 decimals; **≤ 100 when percent** | |
| `max_discount` | number | no | numeric ≥ 0, 2 decimals, nullable | |
| `min_order_amount` | number | no | numeric ≥ 0, 2 decimals (default 0) | |
| `applies_to` | select | no | `all` \| `category` \| `service` \| `provider` (default `all`) | |
| `applies_to_id` | select | when `applies_to` ≠ `all` | uuid of an existing category / bookable service / provider | Ignored for `all` |
| `usage_limit` | number | no | integer ≥ 1, nullable | `null` = unlimited |
| `per_customer_limit` | number | no | integer ≥ 1, **not null** (default 1) | |
| `starts_at` | datetime | no | date, nullable | |
| `ends_at` | datetime | no | date, nullable, after `starts_at` | |
| `is_active` | switch | no | boolean (default true) | |

`funded_by` is not accepted; sending it is ignored.

```json
{ "code": "welcome10", "discount_type": "percent", "discount_value": "10.00" }
```

Postman: `admin.coupons.store` › "201 Coupon Created With Defaults", "201 Coupon Created For Provider";
`admin.coupons.update` › "200 Coupon Updated Partially".

### Validation errors

Field-keyed 422 (`data.errors`), as in API conventions §3.1. The merged-value rules (percent ≤ 100,
end after start, scope target exists) are domain rules in `App\Domain\Coupon\Rules` and still answer
in that shape.

```json
{
  "status": "fail",
  "message": "The discount percentage cannot exceed 100.",
  "data": { "errors": { "discount_value": ["The discount percentage cannot exceed 100."] } }
}
```

Postman: `admin.coupons.store` › "422 Coupon Percent Above Hundred", "422 Coupon Duplicate Code",
"422 Coupon Ends Before Start", "422 Coupon Scope Target Not A Uuid", "422 Coupon Per Customer Limit Null";
`admin.coupons.update` › "422 Coupon Update Percent Above Hundred".

## 6. Actions (non-CRUD)

### 6.1 Delete

`DELETE /api/v1/admin/coupons/{coupon}` · Permission: `admin.coupons.destroy`

Postman: `admin.coupons.destroy` › "200 Coupon Deleted", "409 Coupon Used Cannot Be Deleted".

| HTTP | `error_code` | When | Dashboard behavior |
|------|--------------|------|--------------------|
| 403 | — | Missing permission | Hide/disable the action |
| 409 | `COUPON_IN_USE` | The coupon was used on an order (even if later released) | Toast `message`; offer "Deactivate" (`PUT { is_active: false }`) |

```json
{
  "status": "fail",
  "message": "A coupon that has been used cannot be deleted; deactivate it instead.",
  "data": { "error_code": "COUPON_IN_USE", "correlation_id": "08f07348-ae45-439e-9a71-f554a13adf30" }
}
```

### 6.2 Redemptions

`GET /api/v1/admin/coupons/{coupon}/redemptions?page=&per_page=` · Permission: `admin.coupons.redemptions`

| Field | Type | Required | Validation | Description |
|-------|------|----------|-----------|-------------|
| `page` | int | no | ≥ 1 | |
| `per_page` | int | no | 1–100 (default 15) | |

List key `coupon_redemptions`, plus `links` and `meta` (API conventions §2.2). Only **live** uses are
listed: a use released by a rejected / expired / cancelled order is gone from here (its order still
shows the coupon in [order details](orders.md)).

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "coupon_redemptions": [
      {
        "id": "01a196be-e114-72e9-9db4-de1143240f2f",
        "order_id": "01a196be-e114-72e9-9db4-de1143c1aa44",
        "order_code": "JSH-ixfa81445362",
        "customer_id": "01a196be-e122-7227-9300-4484814986f4",
        "customer_name": "Dr. Shany Skiles II",
        "discount_amount": "100.00",
        "created_at": "2026-11-01T10:54:14+00:00"
      }
    ],
    "links": { "first": "…?page=1", "last": "…?page=2", "prev": null, "next": "…?page=2" },
    "meta": { "current_page": 1, "from": 1, "last_page": 2, "per_page": 1, "total": 2 }
  }
}
```

Postman: `admin.coupons.redemptions` › "200 Coupon Redemptions Page", "422 Coupon Redemptions Per Page Too Large".

## 7. Statuses and enums

| Enum | `value` | Label (en) | Badge color | Actions allowed |
|------|---------|------------|-------------|-----------------|
| `discount_type` | `percent` | Percentage | — | — |
| `discount_type` | `fixed` | Fixed amount | — | — |
| `applies_to` | `all` / `category` / `service` / `provider` | All services / Category / Service / Provider | — | pick a target for all but `all` |
| `funded_by` | `platform` | Joshen | — | read-only |

## Data model

| Table (`db-schema.sql`) | Read / write | Notes |
|-------------------------|--------------|-------|
| `coupons` | read + write | Provider scope stores the provider's `users.id` in `applies_to_id`; the API speaks uuids |
| `coupon_redemptions` | read | One row per counted use; deleted when the order is rejected / expired / cancelled |
| `orders` | read | `coupon_id` and `discount_amount` stay on the order as history |

## 8. TypeScript types

```ts
export interface Coupon {
  id: string; // uuid (TD-19)
  code: string;
  description: string | null;
  discount_type: EnumValue<'percent' | 'fixed'>;
  discount_value: Money;
  max_discount: Money | null;
  min_order_amount: Money;
  applies_to: EnumValue<'all' | 'category' | 'service' | 'provider'>;
  applies_to_id: string | null; // uuid of the category / service / provider
  funded_by: EnumValue<'platform'>;
  usage_limit: number | null;
  per_customer_limit: number;
  used_count: number;
  starts_at: string | null; // ISO 8601
  ends_at: string | null;
  is_active: boolean;
  created_at: string;
}

export interface CouponRedemption {
  id: string;
  order_id: string | null;
  order_code: string | null;
  customer_id: string | null;
  customer_name: string | null;
  discount_amount: Money;
  created_at: string;
}

export type CouponList = Paginated<'coupons', Coupon>;
export type CouponRedemptionList = Paginated<'coupon_redemptions', CouponRedemption>;
```

## 9. Business rules enforced by the backend

| Rule | What the backend does | What the admin sees |
|------|-----------------------|---------------------|
| BR-29 | Route name is the permission (`payments` section) | `403` otherwise |
| TD-131 | Discount comes off the services amount only; Joshen funds it; used coupons are deactivated, never deleted | `COUPON_IN_USE` on delete |
| TD-148 | Scope `all`/`category`/`service`/`provider`; packages and package sessions never use coupons | Scope select without "package" |
| TD-148 | Percent rounds half-up to 2 dp, then `max_discount`, then capped at the services amount | Preview the cap in the form help text |
| TD-148 | `min_order_amount` compares the services amount, before delivery | |
| TD-148 | A coupon may not bring the total (services + delivery − discount) to 0 | Customer gets `COUPON_FREE_ORDER` |
| TD-148 | A use counts at order creation, released on rejected / expired / cancelled | `used_count` drops again |
| TD-148 | `code` upper-case, unique ignoring case | Duplicate → 422 on `code` |

## 10. Notifications triggered

N/A — coupons send no notification.

## 11. Integration checklist

- [ ] Types match the real responses above
- [ ] Menu items/buttons gated by the listed permissions
- [ ] Filters, search and pagination wired to the documented params
- [ ] Form validation mirrors §5 (`per_customer_limit` is never null)
- [ ] `applies_to_id` is a uuid picked from the category / service / provider lists
- [ ] `COUPON_IN_USE` offers "Deactivate"
- [ ] RTL (`ar`) and `en` verified

## 12. Acceptance scenarios

| AC ID | Scenario | Expected |
|-------|----------|----------|
| AC-ADMIN-015 | Create with defaults | `per_customer_limit` 1, scope `all`, active, `funded_by` platform even if `funded_by: provider` is sent, creator stored |
| AC-ADMIN-015 | Code `Ramadan-25` then `ramadan-25` | Saved `RAMADAN-25`; the second is a 422 on `code` |
| AC-ADMIN-015 | Percent value 100.01, or an update pushing a percent coupon past 100 | 422 on `discount_value` |
| AC-ADMIN-015 | `ends_at` not after `starts_at` (create, or update on merged values) | 422 on `ends_at` |
| AC-ADMIN-015 | Scope target for category / service / provider by uuid | Stored; show returns the same uuid |
| AC-ADMIN-015 | Non-uuid / wrong-kind / missing target; integer coupon id | 422 on `applies_to_id`; integer id → 404 |
| AC-ADMIN-015 | List | `used_count` live; `search` and `filters[is_active]` narrow it |
| AC-ADMIN-016 | Delete unused / used | 200 / 409 `COUPON_IN_USE` |
| AC-ADMIN-016 | Redemptions | Paginated with `links` + `meta`; `per_page` > 100 → 422 |

## 13. Open decisions and known limitations

| ID | Impact on this feature | Current behavior |
|----|------------------------|------------------|
| TD-148 | Scope, usage counting, rounding, free-order rule | Working assumption, awaiting owner confirmation |
| DEC-09 | Provider-funded coupons | Not offered; only `platform` coupons are usable |
| TD-131 | Refund wording for a part-refunded coupon order | Not decided; the discount stays on the order |
