Skip to main content
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. The order/ownership mechanics a redeemed voucher triggers are documented in Orders & payment.
🖥️ 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

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:
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


5. Data Models: Voucher & VoucherAttempt

Voucher — app/Models/Voucher.php

Uses Archivable and SoftDeletes (so it has both archived_at and deleted_at).
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.

6. Lifecycle Diagram


7. Endpoints

Routes (from routes/api.php):

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: Example Requests:
🖥️ 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.

7.2 Create Voucher

POST /api/voucher Creates a single voucher. Policy: create. Must bundle at least one training or one quiz. Request Fields:
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:
Success Response (200):
Error Responses:

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:
Success Response (200):
Error Responses:

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:
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:
Success Response (200):
Error Responses:

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:
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:
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):
Nothing-new Response (202):
Error Responses:
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: Request Body:
Success Response (200):
🖥️ 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:

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: Request Body:
Success Response (201 — all sent):
Partial Success (207 — some failures):
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:
Success Response (200):
Error Responses:

7.9 Unarchive Voucher

POST /api/voucher/{id}/unarchive Restores an archived voucher. Policy: restore. Looked up via onlyArchived(). Success Response (200):

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.
Success Response (200):

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):

10. History Tracking Actions

Calls to insert_in_history_table($action, $resource_id, $table): §7.1, §7.3, §7.6, and §7.7 do not write to the history table (bulkCreateAndSend logs via the app log instead).
  • 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 — cart discount codes
  • Orders & payment — orders, ownership grants, free assignment
  • Archive, delete & restore flow — archive vs soft-delete semantics