Discount Module — Admin
HTTP surface for managing platform-wide discount coupons: percentage or fixed-amount discounts, with rich targeting (variants / categories / brands / tags / ingredients / vendors…
HTTP surface for managing platform-wide discount coupons: percentage or fixed-amount discounts, with rich targeting (variants / categories / brands / tags / ingredients / vendors via include/exclude filter entries), customer-scope rules, usage limits, time windows, sale-item exclusion, and a free-shipping flag.
Source:
api-modules/discount/src/controllers/admin-discount.controller.ts.Discounts are evaluated by the cart pricing pipeline at apply-coupon time. The applied coupon is snapshotted into
cart.appliedCoupons[]and again intoorder.appliedCoupons[]at place-order — mutations after place-order do not retroactively change a finalised order.
Conventions
Authentication
All endpoints require a Better-Auth admin session and a role granting the matching discount:* permission.
| Endpoint group | Permission |
|---|---|
GET /admin/discounts, GET /admin/discounts/:id | discount: read |
POST /admin/discounts | discount: create |
PATCH /admin/discounts/:id, POST /admin/discounts/:id/restore | discount: update |
PATCH /admin/discounts/:id/archive, PATCH /admin/discounts/:id/unarchive | discount: archive |
DELETE /admin/discounts/:id | discount: delete |
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 409 | UNIQUE_VIOLATION (code already taken) |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Lifecycle
Discounts have three states:
| State | How | Visibility |
|---|---|---|
active | Default after create | Listed and applicable at checkout |
archived | PATCH /:id/archive | Listed (in admin) but not applicable |
deleted | DELETE /:id (soft) | Hidden from default lists; reversible via POST /:id/restore |
code is immutable post-create — changing a live coupon's code confuses customers and breaks usage tracking.
Currency
value (when discountType=FIXED), minOrderAmount, and maxOrderAmount are integer subunits (paise / cents).
Domain types
DiscountResponse
type DiscountType = "FIXED" | "PERCENTAGE";
type Platform = "APP" | "WEB" | "BOTH";
type CustomerScope = "ALL" | "INCLUDE" | "EXCLUDE";
type PurchaseHistoryMode = "DISABLED" | "FIRST_ORDER" | "MIN_ORDERS";
type DiscountFilterMode = "INCLUDE" | "EXCLUDE";
type DiscountFilterEntry = {
id: string;
mode: DiscountFilterMode;
};
type DiscountResponse = {
id: string;
name: string; // human-readable label
code: string; // uppercase alnum + `-_`, 2..50
isActive: boolean;
archivedAt: string | null; // ISO
platform: Platform;
discountType: DiscountType;
value: number; // subunits (FIXED) or whole-percent 1..100 (PERCENTAGE)
minOrderAmount: number | null; // subunits; null = no floor
maxOrderAmount: number | null; // subunits; null = no cap
freeShipping: boolean;
requireCustomerLogin: boolean;
showOnCart: boolean; // suggest on cart page
totalUsageLimit: number | null; // platform-wide redemption cap
usageLimitPerCustomer: number | null;
startsAt: string | null; // ISO
endsAt: string | null; // ISO
individualUsageOnly: boolean; // cannot stack with other coupons
excludeSaleItems: boolean;
excludeSaleItemsOverPercent: number | null; // 1..100
purchaseHistoryMode: PurchaseHistoryMode;
minOrderCount: number | null; // required when MIN_ORDERS
customerScope: CustomerScope;
customerUserIds: string[]; // applicable user ids when scope != ALL
variants: DiscountFilterEntry[];
categories: DiscountFilterEntry[];
brands: DiscountFilterEntry[];
tags: DiscountFilterEntry[];
ingredients: DiscountFilterEntry[];
vendors: DiscountFilterEntry[];
createdAt: string;
updatedAt: string;
deletedAt: string | null;
};Endpoints
Base path: /admin/discounts.
GET /admin/discounts — List discounts
Required permission: discount: read.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
q | string? | — | Free-text search (trimmed, min 1) |
status | "active" | "archived" | "deleted" | "all" | "active" | Lifecycle filter |
platform | Platform? | — | APP / WEB / BOTH |
isActive | boolean? | — | Coerced |
sortBy | "createdAt" | "updatedAt" | "name" | "code" | "endsAt" | "createdAt" | — |
sortDirection | "asc" | "desc" | "desc" | — |
limit | int | 100 | 1..500 |
offset | int | 0 | >= 0 |
Response 200 — paginated envelope of DiscountResponse[].
GET /admin/discounts/:id — Get a discount
Required permission: discount: read.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
POST /admin/discounts — Create a discount
Required permission: discount: create.
Body
{
"name": "Welcome 10%",
"code": "WELCOME10",
"isActive": true,
"platform": "BOTH",
"discountType": "PERCENTAGE",
"value": 10, // 1..100 for PERCENTAGE; >= 1 subunits for FIXED
"minOrderAmount": 50000, // subunits; null/optional
"maxOrderAmount": null,
"freeShipping": false,
"requireCustomerLogin": false,
"showOnCart": true,
"totalUsageLimit": null,
"usageLimitPerCustomer": 1,
"startsAt": "2026-05-01T00:00:00.000Z",
"endsAt": "2026-06-01T00:00:00.000Z",
"individualUsageOnly": false,
"excludeSaleItems": false,
"excludeSaleItemsOverPercent": null,
"purchaseHistoryMode": "FIRST_ORDER",
"minOrderCount": null,
"customerScope": "ALL",
"customerUserIds": [],
"variants": [{ "id": "01J9...", "mode": "EXCLUDE" }],
"categories": [],
"brands": [{ "id": "01J9...", "mode": "INCLUDE" }],
"tags": [],
"ingredients": [],
"vendors": []
}| Field | Type | Constraints |
|---|---|---|
name | string | 1..255 |
code | string | 2..50; ^[A-Z0-9_-]+$. Immutable on update |
platform | enum | Default BOTH |
discountType | enum | FIXED or PERCENTAGE |
value | int | FIXED: >= 1 subunits. PERCENTAGE: 1..100 whole percent |
minOrderAmount / maxOrderAmount | int? | null | >= 0. If both set, min <= max |
totalUsageLimit / usageLimitPerCustomer | int? | null | >= 1 |
startsAt / endsAt | ISO datetime? | null | If both, startsAt < endsAt |
excludeSaleItemsOverPercent | int? | null | 1..100 |
purchaseHistoryMode | enum | DISABLED / FIRST_ORDER / MIN_ORDERS. MIN_ORDERS requires minOrderCount >= 1 |
customerScope | enum | ALL / INCLUDE / EXCLUDE. Non-ALL requires non-empty customerUserIds |
customerUserIds | string[] | Better-Auth user ids |
variants / categories / brands / tags / ingredients / vendors | DiscountFilterEntry[] | { id, mode: "INCLUDE" | "EXCLUDE" }; default [] |
Response 201 — DiscountResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod or cross-field refinements |
| 409 | UNIQUE_VIOLATION | code already exists |
PATCH /admin/discounts/:id — Update a discount
Required permission: discount: update. Partial — every field optional except code (omitted entirely). Cross-field refinements re-run on the merged result.
Response 200 — updated DiscountResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
| 400 | VALIDATION_ERROR | Body fails zod / cross-field rules |
PATCH /admin/discounts/:id/archive — Archive
Required permission: discount: archive. Stamps archivedAt; the discount becomes inapplicable at checkout but remains visible in admin.
Response 200 — archived DiscountResponse.
PATCH /admin/discounts/:id/unarchive — Unarchive
Required permission: discount: archive. Clears archivedAt.
Response 200 — DiscountResponse.
DELETE /admin/discounts/:id — Soft-delete
Required permission: discount: delete. Stamps deletedAt; hidden from default lists. Reversible via POST /:id/restore.
Response 200 — deleted DiscountResponse.
POST /admin/discounts/:id/restore — Restore
Required permission: discount: update. Clears deletedAt.
Response 200 — restored DiscountResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
Related modules
admin-rbac— gates every endpoint viadiscount:*. Seeadmin-rbac.md.cart— applies discounts at coupon-apply time and snapshots them intocart.appliedCoupons[]. Seecart.md.order— re-snapshots applied coupons at place-order; the discount module's mutations do not retroactively affect placed orders.catalog—variants,categories,brands,tags,ingredientsfilter entries reference catalog ids.vendor—vendors[]filter entries reference vendor ids.customer—customerUserIds[]references Better-Auth user ids.
Customer Module — Admin
HTTP surface for the admin-only customer picker. The customer's storefront address book lives in the same module but is out of scope here (see store/customer.md). The picker is…
Dynamic Link Module — Admin
HTTP surface for managing dynamic link groups (CMS-style tile collections — e.g. homepage banner slots) and the dynamic links inside each group. Groups are addressable by slug and…