Skip to main content
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. The cart → order → payment flow that consumes coupons is documented in Orders & payment.

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

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

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


7. Endpoints

Routes (from routes/api.php):

7.1 List Coupons

GET /api/coupons Returns a paginated list of coupons (newest first). All filters are optional. Query Parameters: Example Requests:
Success Response (200): standard Laravel paginator.

7.2 Create Coupon

POST /api/coupon Creates a new coupon. Policy: create. Request Fields:
Exactly one of discount_percentage / discount_value must be provided — they are mutually exclusive.
Request Body (percentage coupon):
Request Body (fixed-value coupon):
Success Response (200):
Error Responses:

7.3 Show Coupon

GET /api/coupon/{id} Returns a single coupon. Policy: view. 404 if not found. Example Request:
Success Response (200):

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

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

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

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: Request Body:
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):
Error Responses:

7.9 Unapply Coupon from Cart

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

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): §7.1 and §7.3 are read-only — no history is written.
  • 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 — free-access voucher grants
  • Orders & payment — cart → order → Konnect payment flow