Free-access grants for trainings & exams (create / apply / email / bulk-send)
This file documentsVouchersController, 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
- Overview
- Base URL and Headers
- Authorization (Policies & Permissions)
- Voucher vs Coupon
- Data Models:
Voucher&VoucherAttempt - Lifecycle Diagram
- 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
- Redemption Rate-Limiting
- Business Rules & Edge Cases
- History Tracking Actions
- 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’sowned_trainings / owned_exams.
Key characteristics:
- Company-scoped; bundles ≥ 1 training or ≥ 1 exam (validated
afterhook). - Code is a system-generated 13-char random string (not user-chosen).
- Single-use: once redeemed,
is_voucher_appliedflips totrueand the voucher is auto-archived (archived_atset), 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
VoucherAttemptmodel.
2. Base URL and Headers
All voucher endpoints sit behind['auth:sanctum', 'check_if_user_is_blocked_middleware', 'company_policy_middleware']. The requestedcompany_idmust 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.bulkCreateAndSenddoes not callauthorize()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()— belongsToorders()— 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 useY-m-d H:i:s.
🖥️ Frontend always sendsexpires_at, defaulted to today + 1 year — so the backend’snow + 24hfallback 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 (fromroutes/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:Success Response (200): paginator whose
- Search is server-side via the
searchparam, debounced 300 ms (no client filtering).- The UI only exposes these filters as chips;
title,code, andexpires_afterare 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_pageis forced to 10 (not the API’s 20 default); paged grid, no infinite scroll.- Each header stat card fires its own
per_page=1count request on load.
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 oftrainings/quizzesmust be non-empty (enforced by anaftervalidation hook). Thecodeis generated server-side (str()->random(13)) — do not send it.
🖥️ Frontend notes:Request Body:
- UI defaults:
activeON, expiry today + 1 year, “lock to recipient” ON.- The form has one “Recipient” email field: it’s sent as
user_emailwhen filled, and also aslocked_mailwhen the lock toggle is on (so usually the same address goes to both).- Client-side it adds:
titlemax 30 chars, and the “≥1 training or quiz” check runs in-app (snackbar) before submit.codeis never entered (system-generated notice shown).- The “email after create” toggle makes a separate §7.6 send_email call after this one.
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:
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:
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.Request Body:codeis validated withregex:/^\S+$/, so any code containing spaces (leading, trailing, or internal) fails validation with422before the lookup runs — it does not count toward the rate-limit (§8). Note the globalTrimStringsmiddleware already strips leading/trailing spaces fromcode(it is not in the trim exception list), so in practice a422here is triggered by internal spaces; a whitespace-onlycodeis trimmed to empty and fails therequiredrule instead.
- Resolves/creates a
VoucherAttemptfor the student; if currently blocked →429. - Looks up the voucher by
code+company_id. If not found → increments attempts (block after 3) and returns400. On success → resets the attempt counter. - Checks: active (
400), not expired (400),locked_mailmatches the student’s email if set (403with masked email), not already applied (400). - Computes which bundled items the student already paid for (Paid-Offline or Paid-Online orders). Splits into new vs already owned.
- If nothing new →
202withalready_ownedlists. - Otherwise creates a
Order(type = ORDER, status Paid-Offline,voucher_idset, with a descriptiveorder_system_log), zero-prices the new items, lifts any revocation flags, and pushes the new slugs into the student’sowned_trainings/owned_exams. - Marks the voucher
is_voucher_applied = trueand archives it (archived_atset via theArchivabletrait) so the spent voucher leaves the default active listing. History:created(the order).
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:
🖥️ Frontend notes: the dialog pre-fills the recipient fromError Responses:user_emailand makes it read-only oncemail_sent == true(resend allowed, retarget not). The send button is disabled whenis_voucher_applied == true. This is also the call the Create form’s “email after create” toggle makes (§7.2).
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:
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 capsemailsat 10 (backend rule is onlymin 1). It sends noexpires_athere, 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 successfulRequest Body:apply_voucher(redeem) now archives the voucher automatically (setsarchived_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.
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
VoucherAttemptrow is created/loaded forstudent_idon each attempt. - On an invalid code,
attemptsis 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 = 0andblocked_until = null. - Validation failures don’t count. A request rejected at validation (e.g.
codecontaining whitespace →422, see §7.5) never reaches the lookup, so it does not incrementattempts. Only a syntactically valid code that isn’t found increments the counter.
9. Business Rules & Edge Cases
- Bundle requirement — every voucher must reference ≥ 1 training or ≥ 1 quiz (create & update
afterhook). - Code is system-generated — 13 random chars (
store/applyuse mixed case;bulkCreateAndSenduppercases). Never sendcodeon create. - Single-use —
is_voucher_appliedflips totrueon successful redemption; re-redeeming returns400. - Auto-archive on redeem — a successful redemption also sets
archived_at, removing the spent voucher from the default active listing (still retrievable withonly_archived=true, and reversible viaunarchive). - Email lock — if
locked_mailis 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_idand a descriptiveorder_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
ArchivableandSoftDeletes. - 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 toinsert_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).
11. Related Files
app/Http/Controllers/VouchersController.phpapp/Models/Voucher.php,app/Models/VoucherAttempt.phpapp/Policies/VoucherPolicy.phpapp/Models/Order.php,app/Models/OrderStatus.php— Paid-Offline order created on redemptionresources/views/voucher.blade.php— the email templateroutes/api.php—VouchersControllergroup- Coupons — cart discount codes
- Orders & payment — orders, ownership grants, free assignment
- Archive, delete & restore flow — archive vs soft-delete semantics