Discount-code lifecycle (create / list / update / archive / apply at cart)
This file documentsCouponsController, 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
- Overview
- Base URL and Headers
- Authorization (Policies & Permissions)
- Coupon vs Voucher
- Data Model:
Coupon - Lifecycle Diagram
- 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
- Business Rules & Edge Cases
- History Tracking Actions
- 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):
CouponsControllerprovides CRUD + archive/unarchive/force-delete. - Consumption (student cart):
OrdersController@apply_couponand@unapply_couponattach/detach a coupon to the student’s active cart and adjust the coupon’s remainingmax_usage.
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 requestedcompany_idmust 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-levelapply_coupon/unapply_couponendpoints 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 (fromroutes/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:
7.2 Create Coupon
POST /api/coupon
Creates a new coupon. Policy: create.
Request Fields:
Exactly one ofRequest Body (percentage coupon):discount_percentage/discount_valuemust be provided — they are mutually exclusive.
7.3 Show Coupon
GET /api/coupon/{id}
Returns a single coupon. Policy: view. 404 if not found.
Example Request:
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).
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 (Success Response (200):orders_count > 0→401).
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.
BecauseSuccess Response (200):Coupondoes not useSoftDeletes, this is a true hard delete — there is no restore.
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:
- Validates the code exists and is uppercase-only.
- Loads the student’s
cart; 400 if there is no cart yet. - Rejects if the same coupon is already applied (
403), already in the user’sapplied_coupons(403), or already used on a completed order (403). - Validates the coupon is active (
403), within itsstarts_at/expires_atwindow (403), the cart total meetsapplicable_if_total_is_above(403), andmax_usage > 0(403). - Sets
order.coupon_id, decrementsmax_usage, pushes the coupon id into the user’sapplied_coupons. - History:
applied_coupon.
7.9 Unapply Coupon from Cart
max_usage++). Lives in OrdersController.
Path Parameter: student_id.
Request Body: none.
Behavior:
- Loads the student’s cart (creates an empty cart if none exists).
- 400 if no coupon is currently applied.
- Clears
order.coupon_id, removes the id from the user’sapplied_coupons, and increments the coupon’smax_usageby 1. - History:
unapplied_coupon.
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 own422message. - Discount type is exclusive — supply
discount_percentageordiscount_value, never both, never neither. Enforced viaprohibited_unless+required_without. max_usageis 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_couponsarray 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 toinsert_in_history_table($action, $resource_id, $table):
§7.1 and §7.3 are read-only — no history is written.
10. Related Files
app/Http/Controllers/CouponsController.php— coupon CRUDapp/Http/Controllers/OrdersController.php—apply_coupon/unapply_couponapp/Models/Coupon.phpapp/Policies/CouponPolicy.php—COUPONS_*permission gatesapp/Http/Controllers/LoyaltyController.php—convertPointsToCoupon(points → coupon)routes/api.php—CouponsControllergroup + cart coupon routes- Vouchers — free-access voucher grants
- Orders & payment — cart → order → Konnect payment flow