> ## Documentation Index
> Fetch the complete documentation index at: https://theteam-13fc7f49.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Coupons Endpoints

###### ***Discount-code lifecycle (create / list / update / archive / apply at cart)***

This file documents `CouponsController`, which owns the **admin-facing CRUD lifecycle** for discount codes, plus the two cart-level endpoints in `OrdersController` (`apply_coupon` / `unapply_coupon`) that actually consume a coupon during checkout.

Vouchers (free-access grants, a different concept) are documented in [Vouchers](https://theteam-13fc7f49.mintlify.site/api-reference/vouchers-endpoints.md). The cart → order → payment flow that consumes coupons is documented in [Orders & payment](https://theteam-13fc7f49.mintlify.site/api-reference/orders-and-payment-endpoints.md).

***

## **Table of Contents**

1. **Overview**
2. **Base URL and Headers**
3. **Authorization (Policies & Permissions)**
4. **Coupon vs Voucher**
5. **Data Model:** `Coupon`
6. **Lifecycle Diagram**
7. **Endpoints**
   * **7.1 List Coupons**
   * **7.2 Create Coupon**
   * **7.3 Show Coupon**
   * **7.4 Update Coupon**
   * **7.5 Archive Coupon**
   * **7.6 Unarchive Coupon**
   * **7.7 Force Delete Coupon**
   * **7.8 Apply Coupon to Cart**
   * **7.9 Unapply Coupon from Cart**
8. **Business Rules & Edge Cases**
9. **History Tracking Actions**
10. **Related Files**

***

## **1. Overview**

A **coupon** is a reusable percentage- or fixed-value discount code applied to a shopping cart before it converts to a paid order. Coupons are company-scoped, have a usage budget (`max_usage`), an activation window (`starts_at` / `expires_at`), and a minimum-cart-total threshold (`applicable_if_total_is_above`).

* **Authoring** (admin / content manager): `CouponsController` provides CRUD + archive/unarchive/force-delete.
* **Consumption** (student cart): `OrdersController@apply_coupon` and `@unapply_coupon` attach/detach a coupon to the student's active cart and adjust the coupon's remaining `max_usage`.

Coupons can also be **generated from loyalty points** via `POST /api/user/convert-points` (`LoyaltyController@convertPointsToCoupon`) — that flow lives in the loyalty domain and is out of scope here.

***

## **2. Base URL and Headers**

```json theme={null}
{{base_url}}/api
```

```shellscript theme={null}
Accept: application/json
Content-Type: application/json
Authorization: Bearer {{token}}
```

> All coupon endpoints sit behind `['auth:sanctum', 'check_if_user_is_blocked_middleware', 'company_policy_middleware']`. The requested `company_id` must match the authenticated user's company (super-admins bypass).

***

## **3. Authorization (Policies & Permissions)**

Every CRUD endpoint calls `$this->authorize(...)` against `CouponPolicy`. Each ability resolves to:

```shellscript theme={null}
super-admin  ||  admin  ||  user has the matching COUPONS_* permission
```

| **Endpoint** | **Policy ability** | **Permission constant** |
| :- | :- | :- |
| List | `viewAny` | `COUPONS_VIEWANY` |
| Create | `create` | `COUPONS_CREATE` |
| Show | `view` | `COUPONS_VIEW` |
| Update | `update` | `COUPONS_UPDATE` |
| Archive | `archive` | `COUPONS_ARCHIVE` |
| Unarchive | `restore` | `COUPONS_UNARCHIVE` |
| Force delete | `delete` | `COUPONS_DELETE` |

> The cart-level `apply_coupon` / `unapply_coupon` endpoints are **not** policy-gated — any authenticated student may apply a coupon to their own cart (subject to the company middleware).

***

## **4. Coupon vs Voucher**

These are different concepts — do not confuse them:

| | **Coupon** | **Voucher** |
| :- | :- | :- |
| Purpose | A **discount** on a cart total | A **free-access grant** to specific trainings/exams |
| Effect | Reduces price | Creates a Paid-Offline order at price 0 |
| Code format | Uppercase `[A-Z0-9]`, min 5 chars, user-chosen | 13-char random string, system-generated |
| Reusable | Yes, up to `max_usage` | Single-use (`is_voucher_applied` flips to true) |
| Applied where | Cart (`apply_coupon`) | Directly (`apply_voucher`) |
| Doc | this page | [Vouchers](https://theteam-13fc7f49.mintlify.site/api-reference/vouchers-endpoints.md) |

***

## **5. Data Model:** `Coupon`

File: `app/Models/Coupon.php` — uses the `Archivable` trait (so it has `archived_at`; note it does **not** use `SoftDeletes`, so `force_delete` is a hard delete).

```php theme={null}
protected $fillable = [
    'company_id', 'code', 'title', 'description',
    'discount_percentage', 'discount_value',
    'applicable_if_total_is_above', 'max_usage',
    'active', 'starts_at', 'expires_at',
];

protected $casts = [
    'discount_percentage'          => 'float',
    'discount_value'               => 'float',
    'applicable_if_total_is_above' => 'float',
];
```

| **Column** | **Type** | **Meaning** |
| :- | :- | :- |
| `company_id` | integer | Tenant owner (FK to `companies`) |
| `code` | string | Discount code — **uppercase letters & digits only**, unique, min 5 chars |
| `title` | string | Human-readable name |
| `description` | string | Free text |
| `discount_percentage` | float | Percent discount (0–100). **Mutually exclusive** with `discount_value` |
| `discount_value` | float | Fixed-amount discount. **Mutually exclusive** with `discount_percentage` |
| `applicable_if_total_is_above` | float | Minimum cart total required to apply (≥ 1) |
| `max_usage` | integer | Remaining usage budget. Decremented on apply, incremented on unapply |
| `active` | boolean | On/off switch |
| `starts_at` | date | Activation start |
| `expires_at` | date | Activation end (≥ `starts_at`) |
| `archived_at` | datetime | Set by `archive()`; hidden from default queries |

**Appended accessors:** `is_expired` (`expires_at < now()`), `is_active` (cast of `active`), `is_archived` (`archived_at` not null).

**Relations:** `company()` (belongsTo), `orders()` (hasMany — used by `withCount('orders')` to block archiving used coupons).

Timestamps serialize as `Y-m-d H:i:s`.

***

## **6. Lifecycle Diagram**

```text theme={null}
   ┌──────────────────────────────────────────────┐
   │  POST /api/coupon            (create)          │
   │   → code must be UPPERCASE [A-Z0-9], unique    │
   │   → exactly one of percentage / value          │
   └───────────────────────┬────────────────────────┘
                           │
            ┌──────────────┴───────────────┐
            ▼                              ▼
 ┌─────────────────────┐      ┌──────────────────────────────┐
 │ STUDENT CART FLOW    │      │ ADMIN MANAGEMENT              │
 │ apply_coupon         │      │ update / archive / unarchive  │
 │  → max_usage--       │      │ force_delete (hard)           │
 │ unapply_coupon       │      │  ↳ archive blocked if used    │
 │  → max_usage++       │      │    in any order               │
 └─────────────────────┘      └──────────────────────────────┘
```

***

## **7. Endpoints**

Routes (from `routes/api.php`):

```php theme={null}
Route::controller(CouponsController::class)->group(function () {
    Route::get(   '/coupons',                 'index');
    Route::post(  '/coupon',                  'store');
    Route::get(   '/coupon/{id}',             'show');
    Route::post(  '/coupon/{id}',             'update');
    Route::post(  '/coupon/{id}/archive',     'archive');
    Route::post(  '/coupon/{id}/unarchive',   'unarchive');
    Route::delete('/coupon/{id}/force_delete','forceDelete');
});

// Cart-level (OrdersController):
Route::post('/cart/{student_id}/apply_coupon',   'apply_coupon');
Route::post('/cart/{student_id}/unapply_coupon', 'unapply_coupon');
```

***

### **7.1 List Coupons**

`GET /api/coupons`

Returns a paginated list of coupons (newest first). All filters are optional.

**Query Parameters:**

| **Param** | **Type** | **Effect** |
| :- | :- | :- |
| `title` | string | `LIKE %title%` |
| `code` | string | `LIKE %code%` |
| `active` | boolean | Exact match on `active` |
| `expired` | boolean | `true` → `expires_at < now()`; `false` → `expires_at >= now()` |
| `min_discount` | integer ≥ 0 | `discount_percentage >= n` OR `discount_value >= n` |
| `max_discount` | integer ≥ 0 | `discount_percentage <= n` OR `discount_value <= n` |
| `is_archived` | boolean | `true` → only archived; `false` → only non-archived |
| `only_archived` | boolean | Only archived (Archivable scope) |
| `search` | string | `LIKE %term%` across `title` OR `code` |
| `page` | integer ≥ 1 | Page number (default 1) |
| `per_page` | integer 1–100 | Items per page (default 20) |

**Example Requests:**

```shellscript theme={null}
GET {{base_url}}/api/coupons?company_id=1
GET {{base_url}}/api/coupons?company_id=1&active=true&expired=false
GET {{base_url}}/api/coupons?company_id=1&search=SUMMER&per_page=10
```

**Success Response (200):** standard Laravel paginator.

```json theme={null}
{
  "current_page": 1,
  "data": [
    {
      "id": 12,
      "company_id": 1,
      "code": "SUMMER25",
      "title": "Summer Sale 25%",
      "description": "25% off all courses this summer.",
      "discount_percentage": 25,
      "discount_value": null,
      "applicable_if_total_is_above": 50,
      "max_usage": 100,
      "active": 1,
      "starts_at": "2026-06-01 00:00:00",
      "expires_at": "2026-08-31 00:00:00",
      "archived_at": null,
      "created_at": "2026-05-20 09:00:00",
      "updated_at": "2026-05-20 09:00:00"
    }
  ],
  "per_page": 20,
  "total": 1,
  "last_page": 1
}
```

***

### **7.2 Create Coupon**

`POST /api/coupon`

Creates a new coupon. Policy: `create`.

**Request Fields:**

| **Field** | **Type** | **Required** | **Notes** |
| :- | :- | :- | :- |
| `company_id` | integer | **yes** | must exist in `companies` |
| `code` | string | **yes** | unique in `coupons`, min 5, **uppercase** `[A-Z0-9] `**only** |
| `title` | string | **yes** | |
| `description` | string | **yes** | |
| `discount_percentage` | numeric 0–100 | conditional | required unless `discount_value` is set; **prohibited if** `discount_value `**is non-null** |
| `discount_value` | numeric | conditional | required unless `discount_percentage` is set; **prohibited if** `discount_percentage `**is non-null** |
| `applicable_if_total_is_above` | numeric ≥ 1 | **yes** | minimum cart total to apply |
| `max_usage` | integer ≥ 1 | **yes** | usage budget |
| `active` | boolean | **yes** | |
| `starts_at` | date | **yes** | |
| `expires_at` | date | **yes** | must be `>= starts_at` |

> **Exactly one** of `discount_percentage` / `discount_value` must be provided — they are mutually exclusive.

**Request Body (percentage coupon):**

```json theme={null}
{
  "company_id": 1,
  "code": "SUMMER25",
  "title": "Summer Sale 25%",
  "description": "25% off all courses this summer.",
  "discount_percentage": 25,
  "applicable_if_total_is_above": 50,
  "max_usage": 100,
  "active": true,
  "starts_at": "2026-06-01",
  "expires_at": "2026-08-31"
}
```

**Request Body (fixed-value coupon):**

```json theme={null}
{
  "company_id": 1,
  "code": "FLAT10",
  "title": "10 TND Off",
  "description": "Flat 10 TND discount.",
  "discount_value": 10,
  "applicable_if_total_is_above": 30,
  "max_usage": 50,
  "active": true,
  "starts_at": "2026-06-01",
  "expires_at": "2026-12-31"
}
```

**Success Response (200):**

```json theme={null}
{
  "coupon_id": 12,
  "message": "Coupon created successfully."
}
```

**Error Responses:**

| **Status** | **Message** | **Reason** |
| :- | :- | :- |
| `422` | `"Coupon code must be uppercase letters and numbers only."` | Code not all-uppercase or contains symbols (checked **before** the validator). Translated from `Accept-Language` (`en`, `fr`, `ar`), like the same check in `POST /cart/{student_id}/apply_coupon` |
| `422` | Validation error object | Missing fields, non-unique code, both/neither discount fields set, `expires_at` before `starts_at` |
| `403` | Policy denial | Caller lacks `COUPONS_CREATE` |

***

### **7.3 Show Coupon**

`GET /api/coupon/{id}`

Returns a single coupon. Policy: `view`. 404 if not found.

**Example Request:**

```shellscript theme={null}
GET {{base_url}}/api/coupon/12?company_id=1
```

**Success Response (200):**

```json theme={null}
{
  "id": 12,
  "company_id": 1,
  "code": "SUMMER25",
  "title": "Summer Sale 25%",
  "description": "25% off all courses this summer.",
  "discount_percentage": 25,
  "discount_value": null,
  "applicable_if_total_is_above": 50,
  "max_usage": 100,
  "active": 1,
  "starts_at": "2026-06-01 00:00:00",
  "expires_at": "2026-08-31 00:00:00",
  "archived_at": null,
  "created_at": "2026-05-20 09:00:00",
  "updated_at": "2026-05-20 09:00:00"
}
```

***

### **7.4 Update Coupon**

`POST /api/coupon/{id}`

Full update of an existing coupon. Policy: `update`. Same rules as create, except the `code` uniqueness check **excludes the current id**.

**Request Body:** identical shape to §7.2 (all fields required).

```json theme={null}
{
  "company_id": 1,
  "code": "SUMMER30",
  "title": "Summer Sale 30%",
  "description": "Bumped to 30% off.",
  "discount_percentage": 30,
  "applicable_if_total_is_above": 50,
  "max_usage": 80,
  "active": true,
  "starts_at": "2026-06-01",
  "expires_at": "2026-08-31"
}
```

**Success Response (200):**

```json theme={null}
{ "message": "Coupon updated successfully." }
```

**Error Responses:** same matrix as §7.2 (`422` uppercase, `422` validation, `403` policy, `404` not found).

***

### **7.5 Archive Coupon**

`POST /api/coupon/{id}/archive`

Soft-hides the coupon via the Archivable trait. Policy: `archive`.

> **Guard:** a coupon that has been used in **any order** cannot be archived (`orders_count > 0` → `401`).

**Success Response (200):**

```json theme={null}
{ "message": "Coupon archived successfully." }
```

**Error Responses:**

| **Status** | **Message** | **Reason** |
| :- | :- | :- |
| `401` | `"Coupon cannot be deleted, used in N orders."` | Coupon is referenced by ≥ 1 order |
| `404` | — | Coupon not found |
| `403` | Policy denial | Caller lacks `COUPONS_ARCHIVE` |

***

### **7.6 Unarchive Coupon**

`POST /api/coupon/{id}/unarchive`

Restores an archived coupon. Policy: `restore`. Looks up via `onlyArchived()`, so a non-archived id returns 404.

**Success Response (200):**

```json theme={null}
{
  "message": "Coupon restored successfully.",
  "coupon": {
    "id": 12,
    "code": "SUMMER25",
    "archived_at": null,
    "...": "..."
  }
}
```

***

### **7.7 Force Delete Coupon**

`DELETE /api/coupon/{id}/force_delete`

Permanently removes the coupon row. Policy: `delete`. The coupon must be **archived first** (looked up via `onlyArchived()`), otherwise 404.

> Because `Coupon` does not use `SoftDeletes`, this is a true hard delete — there is no restore.

**Success Response (200):**

```json theme={null}
{ "message": "Coupon force deleted successfully." }
```

***

### **7.8 Apply Coupon to Cart**

`POST /api/cart/{student_id}/apply_coupon`

Attaches a coupon to the student's active cart, decrements the coupon's `max_usage`, and returns the recomputed cart. Lives in `OrdersController`.

**Path Parameter:** `student_id` — owner of the cart.

**Request Fields:**

| **Field** | **Type** | **Required** | **Notes** |
| :- | :- | :- | :- |
| `code` | string | **yes** | must exist in `coupons`, uppercase `[A-Z0-9]` only |

**Request Body:**

```json theme={null}
{ "code": "SUMMER25" }
```

**Behavior:**

1. Validates the code exists and is uppercase-only.
2. Loads the student's `cart`; **400** if there is no cart yet.
3. Rejects if the same coupon is already applied (`403`), already in the user's `applied_coupons` (`403`), or already used on a completed order (`403`).
4. Validates the coupon is active (`403`), within its `starts_at`/`expires_at` window (`403`), the cart total meets `applicable_if_total_is_above` (`403`), and `max_usage > 0` (`403`).
5. Sets `order.coupon_id`, decrements `max_usage`, pushes the coupon id into the user's `applied_coupons`.
6. History: `applied_coupon`.

**Success Response (200):**

```json theme={null}
{
  "message": "Coupon applied successfully.",
  "coupon_code": "SUMMER25",
  "cart": {
    "...": "recomputed cart payload (see Orders API doc)"
  }
}
```

**Error Responses:**

| **Status** | **Reason** |
| :- | :- |
| `400` | No cart — `"Add an item to the cart before applying a coupon."` |
| `403` | Coupon already applied / used before / used on completed order / inactive / not yet active / expired / total below threshold / max usage reached |
| `422` | Code missing, doesn't exist, or not uppercase-only |

***

### **7.9 Unapply Coupon from Cart**

```shellscript theme={null}
POST /api/cart/{student_id}/unapply_coupon
```

Detaches the coupon from the cart and **refunds** one usage (`max_usage++`). Lives in `OrdersController`.

**Path Parameter:** `student_id`.

**Request Body:** none.

**Behavior:**

1. Loads the student's cart (creates an empty cart if none exists).
2. **400** if no coupon is currently applied.
3. Clears `order.coupon_id`, removes the id from the user's `applied_coupons`, and increments the coupon's `max_usage` by 1.
4. History: `unapplied_coupon`.

**Success Response (200):**

```json theme={null}
{
  "message": "Coupon unapplied successfully.",
  "cart": {
    "...": "recomputed cart payload"
  }
}
```

**Error Responses:**

| **Status** | **Reason** |
| :- | :- |
| `400` | `"No coupon is applied to this cart."` |
| `404` | `"No active cart found for that student."` (student not found) |

***

## **8. Business Rules & Edge Cases**

* **Code is uppercase-only** — `[A-Z0-9]`, min 5 chars, unique. The uppercase check runs **before** the Laravel validator and returns its own `422` message.
* **Discount type is exclusive** — supply `discount_percentage` **or** `discount_value`, never both, never neither. Enforced via `prohibited_unless` + `required_without`.
* `max_usage `**is a live budget** — decremented on apply, incremented on unapply. When it reaches 0, applies are rejected (`403`).
* **A coupon used in any order can't be archived** (`orders_count > 0` → `401`).
* **Force delete requires the coupon to be archived first** and is a true hard delete (no SoftDeletes on this model).
* **Per-user single-use within carts** — the user's `applied_coupons` array and a completed-order check prevent a student from reusing the same coupon.
* **Window + threshold are enforced only at apply time**, not at create time — you can create a coupon whose window is already in the past.

***

## **9. History Tracking Actions**

Calls to `insert_in_history_table($action, $resource_id, $table)`:

| **Action** | **Endpoint** | **Resource id** |
| :- | :- | :- |
| `created` | §7.2 | `coupon.id` |
| `updated` | §7.4 | `coupon.id` |
| `archive` | §7.5 | `coupon.id` |
| `unarchived` | §7.6 | `coupon.id` |
| `forcedeleted` | §7.7 | `coupon.id` |
| `applied_coupon` | §7.8 | `order.id` |
| `unapplied_coupon` | §7.9 | `order.id` |

§7.1 and §7.3 are read-only — no history is written.

***

## **10. Related Files**

* `app/Http/Controllers/CouponsController.php` — coupon CRUD
* `app/Http/Controllers/OrdersController.php` — `apply_coupon` / `unapply_coupon`
* `app/Models/Coupon.php`
* `app/Policies/CouponPolicy.php` — `COUPONS_*` permission gates
* `app/Http/Controllers/LoyaltyController.php` — `convertPointsToCoupon` (points → coupon)
* `routes/api.php` — `CouponsController` group + cart coupon routes
* [Vouchers](https://theteam-13fc7f49.mintlify.site/api-reference/vouchers-endpoints.md) — free-access voucher grants
* [Orders & payment](https://theteam-13fc7f49.mintlify.site/api-reference/orders-and-payment-endpoints.md) — cart → order → Konnect payment flow


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.