> ## 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.

# Vouchers Endpoints

###### ***Free-access grants for trainings & exams (create / apply / email / bulk-send)***

This file documents `VouchersController`, which owns the **voucher lifecycle**: creating a voucher that bundles one or more trainings/exams, emailing it to a recipient, redeeming it (which grants free access by creating a Paid-Offline order), and bulk-creating + sending vouchers to many emails at once.

Coupons (cart discounts, a different concept) are documented in [Coupons](https://theteam-13fc7f49.mintlify.site/api-reference/coupons-endpoints.md). The order/ownership mechanics a redeemed voucher triggers are documented in [Orders & payment](https://theteam-13fc7f49.mintlify.site/api-reference/orders-and-payment-endpoints.md).

> 🖥️ **Frontend callouts.** Blocks marked 🖥️ note what the **Strived Flutter frontend** sends in practice — only where it differs from or adds to the API contract below (which is unchanged).

***

## **Table of Contents**

1. **Overview**
2. **Base URL and Headers**
3. **Authorization (Policies & Permissions)**
4. **Voucher vs Coupon**
5. **Data Models: `Voucher` & `VoucherAttempt`**
6. **Lifecycle Diagram**
7. **Endpoints**
   * **7.1 List Vouchers**
   * **7.2 Create Voucher**
   * **7.3 Show Voucher**
   * **7.4 Update Voucher**
   * **7.5 Apply Voucher (redeem)**
   * **7.6 Send Voucher Email**
   * **7.7 Bulk Create & Send Vouchers**
   * **7.8 Archive Voucher**
   * **7.9 Unarchive Voucher**
   * **7.10 Delete Voucher**
8. **Redemption Rate-Limiting**
9. **Business Rules & Edge Cases**
10. **History Tracking Actions**
11. **Related Files**

***

## **1. Overview**

A **voucher** grants **free access** to a bundle of trainings and/or exams. Unlike a coupon (which discounts a cart), redeeming a voucher creates a brand-new **Paid-Offline order** (price 0) for the items the student doesn't already own, and pushes the item slugs into the student's `owned_trainings` / `owned_exams`.

Key characteristics:

* Company-scoped; bundles ≥ 1 training **or** ≥ 1 exam (validated `after` hook).
* Code is a **system-generated** 13-char random string (not user-chosen).
* **Single-use**: once redeemed, `is_voucher_applied` flips to `true` **and the voucher is auto-archived** (`archived_at` set), so spent vouchers drop out of the default active listing.
* Can be **locked to a specific email** (`locked_mail`) so only that user can redeem it.
* Can be emailed individually (`send_email`) or created + emailed in bulk (`bulk-send`).
* Redemption is **rate-limited per user** via the `VoucherAttempt` model.

***

## **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 voucher 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)**

CRUD endpoints call `$this->authorize(...)` against `VoucherPolicy`. Each ability resolves to:

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

| Endpoint | Policy ability |
| - | - |
| List | `viewAny` |
| Create | `create` |
| Show | `view` |
| Update | `update` |
| Send email | `update` |
| Archive | `archive` |
| Unarchive | `restore` |
| Delete | `delete` |

> **`apply_voucher` (redeem) is not policy-gated** — any authenticated user (subject to the company middleware) may redeem a voucher for a student, with per-user rate-limiting as the safeguard. `bulkCreateAndSend` does not call `authorize()` either; protect it via the route middleware / permissions layer.

***

## **4. Voucher vs Coupon**

| | **Voucher** | **Coupon** |
| - | - | - |
| Purpose | **Free-access grant** to specific trainings/exams | A **discount** on a cart total |
| Effect | Creates a Paid-Offline order at price 0, grants ownership | Reduces price at checkout |
| Code | 13-char random, system-generated | Uppercase `[A-Z0-9]`, user-chosen |
| Reusable | Single-use (`is_voucher_applied`) | Reusable up to `max_usage` |
| Email built-in | Yes (`send_email`, `bulk-send`) | No |
| Doc | this page | [Coupons](https://theteam-13fc7f49.mintlify.site/api-reference/coupons-endpoints.md) |

***

## **5. Data Models: `Voucher` & `VoucherAttempt`**

### `Voucher` — `app/Models/Voucher.php`

Uses `Archivable` **and** `SoftDeletes` (so it has both `archived_at` and `deleted_at`).

```php theme={null}
protected $fillable = [
    'company_id', 'code', 'title', 'description',
    'user_email', 'mail_sent', 'active',
    'locked_mail', 'expires_at', 'is_voucher_applied',
];

protected $casts = [
    'expires_at'         => 'date',
    'mail_sent'          => 'boolean',
    'is_voucher_applied' => 'boolean',
];
```

| Column | Type | Meaning |
| - | - | - |
| `company_id` | integer | Tenant owner (FK to `companies`) |
| `code` | string | 13-char random redemption code |
| `title` / `description` | string | Display fields |
| `user_email` | string\|null | Intended recipient (set when emailed) |
| `locked_mail` | string\|null | If set, **only this email** may redeem |
| `mail_sent` | boolean | Whether the voucher email was sent |
| `active` | boolean | On/off switch |
| `expires_at` | date | Expiry; defaults to **now + 24h** if omitted on create |
| `is_voucher_applied` | boolean | Single-use flag, flips on redemption |
| `archived_at` / `deleted_at` | datetime | Archive / soft-delete states |

**Relations:**

* `trainings()` — `belongsToMany(Training, 'voucher_training')`
* `quizzes()` — `belongsToMany(Quiz, 'voucher_quiz')` (exams are quizzes with no training/chapter)
* `company()` — belongsTo
* `orders()` — hasMany (the Paid-Offline orders a redeemed voucher created)

> Voucher timestamps serialize as ISO-8601 with offset (`Y-m-d\TH:i:s.uP`), **unlike** most models which use `Y-m-d H:i:s`.

> 🖥️ **Frontend always sends `expires_at`, defaulted to today + 1 year** — so the backend's `now + 24h` fallback never fires for app-created vouchers. (`admin_create_voucher_page.dart:43-45`)

### `VoucherAttempt` — `app/Models/VoucherAttempt.php`

Tracks failed redemption attempts per user for rate-limiting.

```php theme={null}
protected $fillable = ['user_id', 'attempts', 'blocked_until'];
```

| Column | Meaning |
| - | - |
| `user_id` | The student attempting redemption |
| `attempts` | Consecutive failed attempts |
| `blocked_until` | Timestamp until which the user is blocked |

***

## **6. Lifecycle Diagram**

```text theme={null}
   ┌─────────────────────────────────────────────────────────┐
   │  POST /api/voucher           (create, single)            │
   │  POST /api/vouchers/bulk-send (create+email, many)       │
   │   → code = random(13)                                    │
   │   → must bundle ≥1 training OR ≥1 quiz                   │
   └───────────────────────┬──────────────────────────────────┘
                           │
            ┌──────────────┴───────────────┐
            ▼                              ▼
 ┌──────────────────────┐     ┌──────────────────────────────┐
 │ POST /voucher/{id}/   │     │ POST /voucher/apply_voucher   │
 │      send_email       │     │  → rate-limited per user      │
 │  → mail_sent=true     │     │  → checks active/expiry/lock  │
 └──────────────────────┘     │  → creates Paid-Offline order │
                              │  → grants owned_* slugs        │
                              │  → is_voucher_applied=true     │
                              │  → auto-archived (archived_at) │
                              └──────────────────────────────┘
            (admin) [auto-archived on redeem] → unarchive → delete (soft)
```

***

## **7. Endpoints**

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

```php theme={null}
Route::controller(VouchersController::class)->group(function () {
    Route::get(   '/vouchers',                  'index');
    Route::post(  '/voucher',                   'store');
    Route::post(  '/voucher/apply_voucher',     'apply_voucher');
    Route::get(   '/voucher/{id}',              'show');
    Route::post(  '/voucher/{id}',              'update');
    Route::post(  '/voucher/{id}/archive',      'archive');
    Route::post(  '/voucher/{id}/unarchive',    'unarchive');
    Route::delete('/voucher/{id}/delete',       'delete');
    Route::post(  '/voucher/{id}/send_email',   'sendVoucherEmail');
    Route::post(  '/vouchers/bulk-send',        'bulkCreateAndSend');
});
```

***

### **7.1 List Vouchers**

`GET /api/vouchers`

Returns a paginated list of vouchers (newest first), each eager-loaded with its trainings (incl. image) and quizzes, plus an `orders_count`. All filters optional.

**Query Parameters:**

| Param | Type | Effect |
| - | - | - |
| `title` | string | `LIKE %title%` |
| `code` | string | `LIKE %code%` |
| `active` | boolean | Exact match |
| `expires_after` | date | `expires_at >= date` |
| `expires_before` | date | `expires_at <= date` |
| `is_voucher_applied` | boolean | Filter by redeemed flag |
| `company_id` | integer | Restrict to a company |
| `only_archived` | boolean | Only archived vouchers |
| `search` | string | `LIKE %term%` across `title`, `code`, `user_email`, `locked_mail` |
| `page` | integer ≥ 1 | Page (default 1) |
| `per_page` | integer 1–100 | Per page (default 20) |

**Example Requests:**

```text theme={null}
GET {{base_url}}/api/vouchers?company_id=1
GET {{base_url}}/api/vouchers?company_id=1&is_voucher_applied=false&active=true
GET {{base_url}}/api/vouchers?company_id=1&search=student@example.com
```

> 🖥️ **Frontend notes:**
>
> * **Search** is server-side via the `search` param, **debounced 300 ms** (no client filtering).
> * The UI only exposes these filters as chips; `title`, `code`, and `expires_after` are not surfaced:
>   * Active tab → **Active** (`active=1`), **Draft** (`active=0`), **Unconsumed** (`is_voucher_applied=0`), **Expiring** (`expires_before=today+7d`).
>   * Archived tab → **Consumed** / **Unconsumed**, all with `only_archived=true`.
> * **`per_page` is forced to 10** (not the API's 20 default); paged grid, no infinite scroll.
> * Each header stat card fires its own `per_page=1` count request on load.

**Success Response (200):** paginator whose `data[].trainings` and `data[].quizzes` are transformed to compact shapes.

```json theme={null}
{
  "current_page": 1,
  "data": [
    {
      "id": 7,
      "company_id": 1,
      "code": "aZ3kP9xL2mQ7r",
      "title": "Welcome Bundle",
      "description": "Free access to the onboarding bundle.",
      "user_email": "student@example.com",
      "locked_mail": "student@example.com",
      "mail_sent": true,
      "active": true,
      "expires_at": "2026-07-01T00:00:00.000000+01:00",
      "is_voucher_applied": false,
      "orders_count": 0,
      "trainings": [
        { "id": 12, "label": "PMP Prep", "image": null, "revenue_percentage": 0, "type": "training" }
      ],
      "quizzes": [
        { "id": 8, "label": "Final Exam", "revenue_percentage": 30, "type": "quiz" }
      ]
    }
  ],
  "per_page": 20,
  "total": 1,
  "last_page": 1
}
```

***

### **7.2 Create Voucher**

`POST /api/voucher`

Creates a single voucher. Policy: `create`. Must bundle at least one training **or** one quiz.

**Request Fields:**

| Field | Type | Required | Notes |
| - | - | - | - |
| `company_id` | integer | **yes** | must exist in `companies` |
| `title` | string | **yes** | |
| `description` | string | **yes** | |
| `active` | boolean | **yes** | |
| `expires_at` | date (`Y-m-d`) | no | `>= today`; **defaults to now + 24h** if omitted |
| `locked_mail` | email | no | restricts redemption to this email |
| `user_email` | email | no | intended recipient |
| `trainings` | array of int | conditional | each must exist in `trainings` |
| `quizzes` | array of int | conditional | each must exist in `quizzes` |

> **At least one** of `trainings` / `quizzes` must be non-empty (enforced by an `after` validation hook). The `code` is generated server-side (`str()->random(13)`) — do not send it.

> 🖥️ **Frontend notes:**
>
> * UI defaults: `active` **ON**, expiry **today + 1 year**, "lock to recipient" **ON**.
> * The form has **one "Recipient" email field**: it's sent as `user_email` when filled, and *also* as `locked_mail` when the lock toggle is on (so usually the same address goes to both).
> * Client-side it adds: `title` **max 30 chars**, and the "≥1 training or quiz" check runs in-app (snackbar) before submit. `code` is never entered (system-generated notice shown).
> * The "email after create" toggle makes a **separate §7.6 send\_email** call after this one.

**Request Body:**

```json theme={null}
{
  "company_id": 1,
  "title": "Welcome Bundle",
  "description": "Free access to the onboarding bundle.",
  "active": true,
  "expires_at": "2026-07-01",
  "locked_mail": "student@example.com",
  "user_email": "student@example.com",
  "trainings": [12],
  "quizzes": [8]
}
```

**Success Response (200):**

```json theme={null}
{
  "voucher_id": 7,
  "message": "Voucher created successfully."
}
```

**Error Responses:**

| Status | Message | Reason |
| - | - | - |
| `422` | `"Validation Error - At least one training or one quiz must be provided."` | Both `trainings` and `quizzes` empty |
| `422` | `"Validation Error - <first error>"` | Any other validation failure |
| `403` | Policy denial | Caller lacks create permission |
| `500` | `"Error creating voucher - <message>"` | Unexpected error |

***

### **7.3 Show Voucher**

`GET /api/voucher/{id}`

Returns a single voucher with trainings, quizzes, and `orders_count`. Policy: `view`. **`company_id` is required** and the voucher must belong to it.

**Example Request:**

```text theme={null}
GET {{base_url}}/api/voucher/7?company_id=1
```

**Success Response (200):**

```json theme={null}
{
  "id": 7,
  "company_id": 1,
  "code": "aZ3kP9xL2mQ7r",
  "title": "Welcome Bundle",
  "description": "Free access to the onboarding bundle.",
  "user_email": "student@example.com",
  "locked_mail": "student@example.com",
  "mail_sent": true,
  "active": true,
  "expires_at": "2026-07-01T00:00:00.000000+01:00",
  "is_voucher_applied": false,
  "orders_count": 0,
  "trainings": [ { "id": 12, "label": "PMP Prep" } ],
  "quizzes": [ { "id": 8, "label": "Final Exam" } ]
}
```

**Error Responses:**

| Status | Message |
| - | - |
| `422` | `"Validation Error - ..."` (missing/invalid `company_id`) |
| `404` | `"Voucher not found for the specified company."` |

***

### **7.4 Update Voucher**

`POST /api/voucher/{id}`

Updates an existing voucher. Policy: `update`. Accepts multiple date formats for `expires_at` (e.g. `Y-m-d`, `d/m/Y`, `F j, Y`).

**Request Fields:**

| Field | Type | Required | Notes |
| - | - | - | - |
| `company_id` | integer | **yes** | must exist in `companies` |
| `title` | string | **yes** | |
| `description` | string | **yes** | |
| `active` | boolean | **yes** | |
| `expires_at` | date | **yes** | `>= today`; multi-format parse, time set to 00:00 |
| `locked_mail` | email | no | only updated if the key is present |
| `trainings` | array of int | sometimes | re-synced if present |
| `quizzes` | array of int | sometimes | re-synced if present |

> As with create, the `after` hook requires at least one training or quiz across the request.

> 🖥️ **Frontend notes:** once a voucher is **emailed or redeemed**, the Edit UI disables the trainings/quizzes selectors (so the client won't re-sync them), and an existing `locked_mail` can't be unlocked from the UI.

**Request Body:**

```json theme={null}
{
  "company_id": 1,
  "title": "Welcome Bundle (extended)",
  "description": "Now valid an extra month.",
  "active": true,
  "expires_at": "2026-08-01",
  "locked_mail": "student@example.com",
  "trainings": [12, 15],
  "quizzes": [8]
}
```

**Success Response (200):**

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

**Error Responses:**

| Status | Message | Reason |
| - | - | - |
| `422` | `"Validation Error - ..."` | Failed validation / no items |
| `422` | `"Invalid date format for expires_at."` | None of the supported formats matched |
| `404` | `"Voucher not found."` | Unknown id |

***

### **7.5 Apply Voucher (redeem)**

`POST /api/voucher/apply_voucher`

Redeems a voucher for a student: grants free access to the bundled items the student doesn't already own. Rate-limited per user (see §8). **Not policy-gated.**

**Request Fields:**

| Field | Type | Required | Notes |
| - | - | - | - |
| `company_id` | integer | **yes** | must exist in `companies` |
| `code` | string | **yes** | the voucher code — **must not contain whitespace** (validated by `regex:/^\S+$/`) |
| `student_id` | integer | **yes** | must exist in `users` |

> **Whitespace is rejected up front.** `code` is validated with `regex:/^\S+$/`, so any code containing spaces (leading, trailing, or internal) fails validation with `422` **before** the lookup runs — it does **not** count toward the rate-limit (§8). Note the global `TrimStrings` middleware already strips leading/trailing spaces from `code` (it is *not* in the trim exception list), so in practice a `422` here is triggered by **internal** spaces; a whitespace-only `code` is trimmed to empty and fails the `required` rule instead.

**Request Body:**

```json theme={null}
{
  "company_id": 1,
  "code": "aZ3kP9xL2mQ7r",
  "student_id": 7
}
```

**Behavior:**

1. Resolves/creates a `VoucherAttempt` for the student; if currently blocked → `429`.
2. Looks up the voucher by `code` + `company_id`. If not found → increments attempts (block after 3) and returns `400`. On success → resets the attempt counter.
3. Checks: active (`400`), not expired (`400`), `locked_mail` matches the student's email if set (`403` with masked email), not already applied (`400`).
4. Computes which bundled items the student already **paid for** (Paid-Offline or Paid-Online orders). Splits into *new* vs *already owned*.
5. If nothing new → `202` with `already_owned` lists.
6. Otherwise creates a `Order` (`type = ORDER`, status Paid-Offline, `voucher_id` set, with a descriptive `order_system_log`), zero-prices the new items, lifts any revocation flags, and pushes the new slugs into the student's `owned_trainings` / `owned_exams`.
7. Marks the voucher `is_voucher_applied = true` **and archives it** (`archived_at` set via the `Archivable` trait) so the spent voucher leaves the default active listing. History: `created` (the order).

**Success Response (202):**

```json theme={null}
{
  "message": "Voucher applied successfully.",
  "assigned": {
    "trainings": ["pmp-prep"],
    "exams": ["final-exam"]
  },
  "already_owned": {
    "trainings": [],
    "exams": []
  }
}
```

**Nothing-new Response (202):**

```json theme={null}
{
  "message": "No new items to assign.",
  "already_owned": {
    "trainings": ["pmp-prep"],
    "exams": ["final-exam"]
  }
}
```

**Error Responses:**

| Status | Message | Reason |
| - | - | - |
| `429` | `"Too many failed attempts. Retry in N seconds."` | User currently blocked |
| `400` | `"Invalid voucher code."` | Code not found for the company |
| `400` | `"Voucher not active."` | `active = false` |
| `400` | `"Voucher expired."` | Past `expires_at` |
| `400` | `"Voucher already used."` | `is_voucher_applied = true` |
| `403` | `"Voucher locked to s****t@e****e.com."` | Student email ≠ `locked_mail` (masked) |
| `422` | Validation error (`validation.regex` on `code`) | `code` contains whitespace |
| `422` | Validation error | Missing/invalid fields |

> Note: both the success and nothing-new responses return HTTP **202 Accepted** (not 200).

> 🖥️ **Frontend notes:**
>
> * The redeem field enforces **exactly 13 characters** client-side, so wrong-length codes never reach the API. On success the app re-fetches the student's trainings/exams/progress.
> * ⚠️ The error handler maps **401**→"already applied" and **500**→"invalid code", but this endpoint returns those cases as **400/403/429** — so the 401/500 branches are effectively dead. Worth a fix if those localized messages were meant to show.

***

### **7.6 Send Voucher Email**

`POST /api/voucher/{id}/send_email`

Emails the voucher (using the `voucher` Blade view) to a given address, then records `user_email` and sets `mail_sent = true`. Policy: `update`.

**Request Fields:**

| Field | Type | Required | Notes |
| - | - | - | - |
| `company_id` | integer | **yes** | must match the voucher's company |
| `user_email` | email | **yes** | recipient |

**Request Body:**

```json theme={null}
{
  "company_id": 1,
  "user_email": "student@example.com"
}
```

**Success Response (200):**

```json theme={null}
{ "message": "Voucher email sent successfully." }
```

> 🖥️ **Frontend notes:** the dialog pre-fills the recipient from `user_email` and makes it read-only once `mail_sent == true` (resend allowed, retarget not). The send button is disabled when `is_voucher_applied == true`. This is also the call the Create form's "email after create" toggle makes (§7.2).

**Error Responses:**

| Status | Message |
| - | - |
| `422` | `"Validation Error - ..."` |
| `404` | `"Voucher not found for the specified company."` |
| `500` | `"Error sending voucher email: ..."` (mail transport failure) |

***

### **7.7 Bulk Create & Send Vouchers**

`POST /api/vouchers/bulk-send`

Creates **one voucher per email** in the list, syncs the same trainings/quizzes onto each, and emails each recipient. Each voucher is `locked_mail`-bound to its recipient, expires in **1 month**, and uses an **uppercase** 13-char random code.

**Request Fields:**

| Field | Type | Required | Notes |
| - | - | - | - |
| `company_id` | integer | **yes** | must exist in `companies` |
| `title` | string | **yes** | max 255 |
| `description` | string | **yes** | |
| `active` | boolean | **yes** | |
| `emails` | array | **yes** | min 1; each a distinct valid email |
| `trainings` | array of int | no | each must exist in `trainings` |
| `quizzes` | array of int | no | each must exist in `quizzes` |

**Request Body:**

```json theme={null}
{
  "company_id": 1,
  "title": "Cohort 2026 Access",
  "description": "Free access for the new cohort.",
  "active": true,
  "emails": ["a@example.com", "b@example.com"],
  "trainings": [12],
  "quizzes": [8]
}
```

**Success Response (201 — all sent):**

```json theme={null}
{
  "vouchers": [
    { "id": 31, "code": "AZ3KP9XL2MQ7R", "locked_mail": "a@example.com", "...": "..." },
    { "id": 32, "code": "BK8MN2QW5TY1Z", "locked_mail": "b@example.com", "...": "..." }
  ],
  "sent": ["a@example.com", "b@example.com"],
  "failures": {}
}
```

**Partial Success (207 — some failures):**

```json theme={null}
{
  "vouchers": [ { "id": 31, "code": "AZ3KP9XL2MQ7R", "...": "..." } ],
  "sent": ["a@example.com"],
  "failures": {
    "b@example.com": "SMTP error: connection timed out"
  }
}
```

> Status is **201** when every email succeeds, **207 Multi-Status** when at least one failed. Vouchers are still created even if their email fails (the failure is recorded per-email, not rolled back). Validation failure returns **422** (`"Validation Error: <first error>"`).

> 🖥️ **Frontend notes:** the UI caps `emails` at **10** (backend rule is only `min 1`). It sends no `expires_at` here, relying on the backend's 1-month rule — the one place the app *doesn't* use its own 1-year default. On a **207** it lists the failed addresses in a "partially sent" dialog.

***

### **7.8 Archive Voucher**

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

Archives the voucher (Archivable trait). Policy: `archive`. Requires `company_id`.

> **Consumed vouchers are auto-archived.** A successful `apply_voucher` (redeem) now archives the voucher automatically (sets `archived_at`), so you normally won't need to call this endpoint for spent vouchers. It remains available for manually archiving **unused** vouchers. There is **no** redemption guard — a voucher that has been used (`orders_count > 0`, `is_voucher_applied = true`) can also be archived manually just like an unused one. Archiving simply hides the voucher from the active list.

**Request Body:**

```json theme={null}
{ "company_id": 1 }
```

**Success Response (200):**

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

**Error Responses:**

| Status | Message |
| - | - |
| `404` | `"Voucher not found for the specified company."` |
| `422` | `"Validation Error - ..."` |

***

### **7.9 Unarchive Voucher**

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

Restores an archived voucher. Policy: `restore`. Looked up via `onlyArchived()`.

**Success Response (200):**

```json theme={null}
{
  "message": "Voucher restored successfully.",
  "voucher": { "id": 7, "archived_at": null, "...": "..." }
}
```

***

### **7.10 Delete Voucher**

`DELETE /api/voucher/{id}/delete`

**Soft-deletes** the voucher (sets `deleted_at` via `SoftDeletes`). Policy: `delete`. The voucher must be **archived first** (looked up via `onlyArchived()`), otherwise 404.

> Despite the success message saying "force deleted", this is a **soft delete** — see [Archive, delete & restore flow](https://theteam-13fc7f49.mintlify.site/api-reference/archive-delete-and-restore-flow.md).

**Success Response (200):**

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

***

## **8. Redemption Rate-Limiting**

`apply_voucher` rate-limits **per user** via the `VoucherAttempt` model:

* A `VoucherAttempt` row is created/loaded for `student_id` on each attempt.
* On an **invalid code**, `attempts` is incremented. After **3** failures, the user is blocked for **60 seconds** (`blocked_until = now + 60s`) and the counter resets.
* While blocked, redemption returns **429** with the seconds remaining.
* A **successful** lookup resets `attempts = 0` and `blocked_until = null`.
* **Validation failures don't count.** A request rejected at validation (e.g. `code` containing whitespace → `422`, see §7.5) never reaches the lookup, so it does **not** increment `attempts`. Only a syntactically valid code that isn't found increments the counter.

This protects against brute-forcing voucher codes.

***

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

* **Bundle requirement** — every voucher must reference ≥ 1 training or ≥ 1 quiz (create & update `after` hook).
* **Code is system-generated** — 13 random chars (`store`/`apply` use mixed case; `bulkCreateAndSend` uppercases). Never send `code` on create.
* **Single-use** — `is_voucher_applied` flips to `true` on successful redemption; re-redeeming returns `400`.
* **Auto-archive on redeem** — a successful redemption also sets `archived_at`, removing the spent voucher from the default active listing (still retrievable with `only_archived=true`, and reversible via `unarchive`).
* **Email lock** — if `locked_mail` is set, only the student whose email matches may redeem (the error masks the locked email).
* **Only-paid ownership check** — "already owned" is computed from **Paid-Offline / Paid-Online** orders only; pending/cart items don't count.
* **Redemption creates an order** — a Paid-Offline order (price 0) with `voucher_id` and a descriptive `order_system_log`; ownership is granted by pushing slugs, consistent with the rest of the platform.
* **202, not 200** — both apply outcomes return `202 Accepted`.
* **Used vouchers are auto-archived** — redemption archives the voucher automatically; the manual archive endpoint stays available (no redemption guard) and is mainly useful for archiving *unused* vouchers.
* **Delete is a soft delete** despite the "force deleted" wording; voucher uses both `Archivable` and `SoftDeletes`.
* **ISO-8601 timestamps** — voucher dates serialize with timezone offset, unlike most models.

> 🖥️ **Frontend deviations at a glance** (Strived app — details in the per-endpoint callouts):
>
> | Concern | Backend | Frontend |
> | - | - | - |
> | `expires_at` (single create) | `now + 24h` if omitted | Always sent, **today + 1 year** |
> | `expires_at` (bulk) | server sets 1 month | Not sent (uses backend's rule) |
> | `locked_mail` / `user_email` | separate fields | One field → both when "lock" is on |
> | `title` | no cap | Capped at **30 chars** |
> | `code` (redeem) | any non-whitespace | Enforces **exactly 13 chars** |
> | Bulk `emails` | `min 1` | Capped at **10** |
> | `per_page` | default 20 (1–100) | Forced to **10** |
> | Filters | many params | Only chips (no `title`/`code`/`expires_after`) |

***

## **10. History Tracking Actions**

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

| Action | Endpoint | Resource id |
| - | - | - |
| `created` | §7.2 | `voucher.id` |
| `updated` | §7.4 | `voucher.id` |
| `created` | §7.5 (apply) | `order.id` (the new Paid-Offline order) |
| `archive` | §7.8 | `voucher.id` |
| `unarchived` | §7.9 | `voucher.id` |
| `deleted` | §7.10 | `voucher.id` |

§7.1, §7.3, §7.6, and §7.7 do not write to the history table (`bulkCreateAndSend` logs via the app log instead).

***

## **11. Related Files**

* `app/Http/Controllers/VouchersController.php`
* `app/Models/Voucher.php`, `app/Models/VoucherAttempt.php`
* `app/Policies/VoucherPolicy.php`
* `app/Models/Order.php`, `app/Models/OrderStatus.php` — Paid-Offline order created on redemption
* `resources/views/voucher.blade.php` — the email template
* `routes/api.php` — `VouchersController` group
* [Coupons](https://theteam-13fc7f49.mintlify.site/api-reference/coupons-endpoints.md) — cart discount codes
* [Orders & payment](https://theteam-13fc7f49.mintlify.site/api-reference/orders-and-payment-endpoints.md) — orders, ownership grants, free assignment
* [Archive, delete & restore flow](https://theteam-13fc7f49.mintlify.site/api-reference/archive-delete-and-restore-flow.md) — archive vs soft-delete semantics


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