Discount Module
Admin-facing HTTP endpoints for managing platform-wide coupon/discount rules — creation, lifecycle (active → archived → soft-deleted), include/exclude filters across the catalog,…
Admin-facing HTTP endpoints for managing platform-wide coupon/discount rules — creation, lifecycle (active → archived → soft-deleted), include/exclude filters across the catalog, customer scoping, and usage limits.
Source:
api-modules/discount(registered viaDiscountModule.forRoot()inapps/api/src/app.module.ts).Cart application logic is not in this module. Future cart/order modules consume the
DISCOUNT_PORTinjection token (DiscountPortinterface) to validate and apply coupons; today the validate stub throwsNotImplementedException.
Conventions
Authentication
All endpoints require a Better-Auth bearer session and a role with the relevant discount permission (granted to superAdmin and admin by default).
Authorization: Bearer <session-token>| Endpoint group | Required 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 |
A caller missing the required permission gets 403 Forbidden (FORBIDDEN).
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Error envelope
Failures are normalized by HttpExceptionFilter:
{
"data": null,
"message": "Human-readable summary",
"statusCode": 400,
"errorCode": "BAD_REQUEST",
"errors": [ /* zod issues, when validation fails */ ],
"debug": { /* dev-only: error + stack */ }
}statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 409 | CONFLICT, UNIQUE_VIOLATION |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
| 501 | NOT_IMPLEMENTED (returned by the port stub until cart/order ships) |
Lifecycle
A discount has two independent off-states:
| State | Column | Visibility | Editable | Reversible | Used by |
|---|---|---|---|---|---|
| active | archivedAt IS NULL AND deletedAt IS NULL | default list | yes | n/a | new orders |
| archived | archivedAt IS NOT NULL AND deletedAt IS NULL | ?status=archived | no | unarchive | retired — not applicable |
| deleted | deletedAt IS NOT NULL | ?status=deleted | no | restore | reports only |
Codes are unique among non-deleted rows — deleting a coupon frees its code for re-use; restoring a deleted coupon will conflict if its code has been taken meanwhile.
Domain types
Used in payloads and responses below.
Enums
type Platform = "APP" | "WEB" | "BOTH";
type DiscountType = "PERCENTAGE" | "FIXED";
type CustomerScope = "ALL" | "ONLY_LISTED" | "EXCEPT_LISTED";
type PurchaseHistoryMode = "DISABLED" | "ZERO_ORDERS" | "MIN_ORDERS";
type DiscountFilterMode = "INCLUDE" | "EXCLUDE";DiscountFilterEntry
Used in every filter array (variants, categories, brands, tags, ingredients, vendors).
type DiscountFilterEntry = {
id: string; // UUID of the referenced entity
mode: DiscountFilterMode;
};INCLUDE entries narrow the discount to only matching items; EXCLUDE entries remove matching items. Both can coexist within the same array.
Note: each
idin thevariantsarray is aproductVariant.id(SKU), not a parentproduct.id. Pick the SKU(s) the discount should match — multiple variants of the same product can be targeted independently.
DiscountResponse
Returned by every endpoint that returns a single discount.
type DiscountResponse = {
id: string;
name: string;
code: string; // uppercase
isActive: boolean;
archivedAt: string | null; // ISO
platform: Platform;
discountType: DiscountType;
value: number; // see "Value semantics"
minOrderAmount: number | null; // rupees
maxOrderAmount: number | null; // rupees
freeShipping: boolean;
requireCustomerLogin: boolean;
showOnCart: boolean;
totalUsageLimit: number | null;
usageLimitPerCustomer: number | null;
startsAt: string | null; // ISO
endsAt: string | null; // ISO
individualUsageOnly: boolean;
excludeSaleItems: boolean;
excludeSaleItemsOverPercent: number | null; // 1..100
purchaseHistoryMode: PurchaseHistoryMode;
minOrderCount: number | null;
customerScope: CustomerScope;
customerUserIds: string[]; // populated only when scope != ALL
variants: DiscountFilterEntry[];
categories: DiscountFilterEntry[];
brands: DiscountFilterEntry[];
tags: DiscountFilterEntry[];
ingredients: DiscountFilterEntry[];
vendors: DiscountFilterEntry[];
createdAt: string; // ISO
updatedAt: string; // ISO
deletedAt: string | null; // ISO
};Currency
All monetary fields (value, minOrderAmount, maxOrderAmount) are stored and exchanged as whole rupees — no paise. Clients should send and receive integers in INR.
Value semantics
value is interpreted by discountType:
discountType | value semantics | Example |
|---|---|---|
FIXED | Discount amount in rupees (≥ 1) | 250 = ₹250 off |
PERCENTAGE | Whole-number percent, 1..100 | 10 = 10% off |
Server-side validation rejects out-of-range values per type.
Field semantics
| Field | Meaning |
|---|---|
freeShipping | If true, applying this coupon waives shipping in addition to (or instead of) the value discount. |
requireCustomerLogin | Coupon can only be applied by a logged-in customer. |
showOnCart | Storefront should auto-suggest this coupon in the cart UI rather than requiring manual code entry. |
individualUsageOnly | Coupon cannot be combined with any other coupon on the same order. |
excludeSaleItems | Variants currently on special-price are skipped when computing the discount. |
excludeSaleItemsOverPercent | When excludeSaleItems is true, only skip variants whose effective specialPrice discount is ≥ this percentage. |
purchaseHistoryMode | Restrict eligibility by the customer's order history. DISABLED = no restriction; ZERO_ORDERS = first-time customers only; MIN_ORDERS = customers with >= minOrderCount past orders. Persisted today; evaluated only after the Orders module ships. |
customerScope + customerUserIds | ALL = any customer. ONLY_LISTED = only the user IDs in customerUserIds. EXCEPT_LISTED = anyone except those user IDs. |
Endpoints
Base path: /admin/discounts
GET / — List discounts
Returns a paginated list of discounts filtered by lifecycle status.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
q | string? | — | Substring match on name or code (case-insensitive) |
status | "active" | "archived" | "deleted" | "all" | "active" | Lifecycle filter |
platform | Platform? | — | Exact-match filter |
isActive | boolean? | — | Filter on the isActive column independently of lifecycle |
sortBy | "createdAt" | "updatedAt" | "name" | "code" | "endsAt" | "createdAt" | |
sortDirection | "asc" | "desc" | "desc" | |
limit | int | 100 | 1..500 |
offset | int | 0 | ≥ 0 |
Response 200 — paginated list of discount rows (parent fields only; filter arrays are not hydrated in list view — use the get-by-id endpoint for those).
{
"data": [
{
"id": "01J9...",
"name": "Welcome 10",
"code": "WELCOME10",
"isActive": true,
"archivedAt": null,
"platform": "BOTH",
"discountType": "PERCENTAGE",
"value": 10,
// ...all parent columns
"createdAt": "2026-04-29T10:00:00.000Z",
"updatedAt": "2026-04-29T10:00:00.000Z",
"deletedAt": null
}
],
"message": "Success",
"statusCode": 200,
"metadata": { "total": 42, "limit": 100, "offset": 0, "hasMore": false }
}GET /:id — Get discount by ID
Returns the full DiscountResponse including all 6 filter arrays and the customer ID list. By default returns archived discounts but 404s for soft-deleted ones — to inspect a deleted discount, fetch via GET /?status=deleted and refer to its row.
Path params
| Name | Type |
|---|---|
id | string (UUID) |
Response 200 — DiscountResponse (envelope above).
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Discount does not exist or is soft-deleted |
POST / — Create a discount
Creates a discount, its filter relations, and customer scoping in a single transaction.
Body
{
"name": "Q4 Festive",
"code": "FESTIVE25", // uppercase, /^[A-Z0-9_-]+$/, length 2..50
"isActive": true, // default true
"platform": "BOTH", // default "BOTH"
"discountType": "FIXED", // PERCENTAGE | FIXED
"value": 250, // rupees (FIXED) or percent 1..100 (PERCENTAGE)
"minOrderAmount": 1000, // rupees, nullable
"maxOrderAmount": null, // rupees, nullable
"freeShipping": true,
"requireCustomerLogin": false,
"showOnCart": true,
"totalUsageLimit": 1000, // nullable
"usageLimitPerCustomer": 1, // nullable
"startsAt": "2026-04-29T00:00:00.000Z", // ISO; nullable
"endsAt": "2026-12-31T23:59:59.000Z", // ISO; nullable
"individualUsageOnly": false,
"excludeSaleItems": true,
"excludeSaleItemsOverPercent": 30, // 1..100, nullable
"purchaseHistoryMode": "MIN_ORDERS", // DISABLED | ZERO_ORDERS | MIN_ORDERS
"minOrderCount": 3, // required when MIN_ORDERS
"customerScope": "ONLY_LISTED", // ALL | ONLY_LISTED | EXCEPT_LISTED
"customerUserIds": ["01J9...", "01J9..."], // required when scope != ALL
"variants": [{ "id": "01J9...", "mode": "INCLUDE" }],
"categories": [{ "id": "01J9...", "mode": "EXCLUDE" }],
"brands": [{ "id": "01J9...", "mode": "INCLUDE" }],
"tags": [{ "id": "01J9...", "mode": "INCLUDE" }],
"ingredients": [{ "id": "01J9...", "mode": "EXCLUDE" }],
"vendors": [{ "id": "01J9...", "mode": "INCLUDE" }]
}Cross-field validation
| Rule | Error path |
|---|---|
discountType=PERCENTAGE ⇒ value <= 100 | value |
purchaseHistoryMode=MIN_ORDERS ⇒ minOrderCount >= 1 | minOrderCount |
customerScope != "ALL" ⇒ customerUserIds.length >= 1 | customerUserIds |
minOrderAmount <= maxOrderAmount (when both set) | minOrderAmount |
endsAt > startsAt (when both set) | endsAt |
Response 201 — full DiscountResponse for the newly created discount.
Side effects — emits discount.created (DiscountCreatedEvent) on the EventEmitter bus after the transaction commits.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod validation (single-field or cross-field) |
| 403 | FORBIDDEN | Caller lacks discount: create permission |
| 409 | CONFLICT | A non-deleted discount with the same code already exists |
PATCH /:id — Update a discount
Updates any subset of fields. code is immutable — to change the code, delete and create a new discount.
When a filter array is omitted from the body, that filter table is left untouched. When a filter array is present (even as an empty []), it fully replaces the existing rows for that table.
customerScope and customerUserIds follow the same replace-when-present rule. If either is provided, the join table is rewritten using the union of the new values (with customerScope falling back to the existing value when only customerUserIds is sent).
Path params
| Name | Type |
|---|---|
id | string (UUID) |
Body — same shape as create, all fields optional, code rejected if present. Cross-field validation runs against whatever fields are provided.
Response 200 — full DiscountResponse post-update.
Side effects — emits discount.updated (DiscountUpdatedEvent).
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod validation |
| 404 | NOT_FOUND | Discount does not exist |
| 409 | CONFLICT | Discount is archived or soft-deleted (must be active to edit) |
PATCH /:id/archive — Archive a discount
Sets archivedAt = now() and isActive = false. Archived discounts are visible via GET /?status=archived but are not editable and will not be applied at checkout.
Body — empty.
Response 200 — the updated DiscountResponse.
Side effects — emits discount.archived (DiscountArchivedEvent).
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Discount does not exist |
| 409 | CONFLICT | Discount is already archived, or is soft-deleted |
PATCH /:id/unarchive — Unarchive a discount
Sets archivedAt = null. Restores the discount to active status (isActive is not automatically toggled back to true — flip it via PATCH /:id if needed).
Body — empty.
Response 200 — the updated DiscountResponse.
Side effects — emits discount.unarchived (DiscountUnarchivedEvent).
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Discount does not exist |
| 409 | CONFLICT | Discount is not archived, or is soft-deleted |
DELETE /:id — Soft-delete a discount
Sets deletedAt = now(). The row is hidden from default lists but its usage history (in discount_usage) remains queryable for reports.
The discount's code becomes available for new discounts immediately after deletion.
Body — empty.
Response 200 — the deleted discount row (parent fields only).
Side effects — emits discount.deleted (DiscountDeletedEvent).
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Discount does not exist |
| 409 | CONFLICT | Discount is already soft-deleted |
POST /:id/restore — Restore a soft-deleted discount
Sets deletedAt = null. Before restoring, the server re-checks code uniqueness against non-deleted rows; if another discount has taken the code in the meantime, the restore is rejected with 409 Conflict — delete the conflicting discount or pick a new code (by deleting+recreating) before retrying.
Body — empty.
Response 200 — the restored DiscountResponse.
Side effects — emits discount.restored (DiscountRestoredEvent).
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Discount does not exist |
| 409 | CONFLICT | Discount is not soft-deleted, or its code is now in use by another active discount |
Cart application (internal API)
Coupon validation and application against a cart is not exposed over HTTP from this module. Future cart/order modules consume it in-process via the DISCOUNT_PORT injection token:
import { DISCOUNT_PORT, type DiscountPort } from "@sc/discount";
@Inject(DISCOUNT_PORT) private readonly discountPort: DiscountPortDiscountPort.validateCoupon(code, ctx) is the contract; today the implementation throws NotImplementedException and is filled in when cart/order ships.
Domain events emitted on lifecycle transitions:
| Event | Constant | Class | Fired when |
|---|---|---|---|
discount.created | DISCOUNT_CREATED | DiscountCreatedEvent | A discount is created |
discount.updated | DISCOUNT_UPDATED | DiscountUpdatedEvent | A discount's fields or filters change |
discount.archived | DISCOUNT_ARCHIVED | DiscountArchivedEvent | A discount is archived |
discount.unarchived | DISCOUNT_UNARCHIVED | DiscountUnarchivedEvent | A discount is unarchived |
discount.deleted | DISCOUNT_DELETED | DiscountDeletedEvent | A discount is soft-deleted |
discount.restored | DISCOUNT_RESTORED | DiscountRestoredEvent | A soft-deleted discount is restored |
All event classes carry (discountId: string, actorId: string).
Configuration
This module has no environment variables of its own. It depends on DATABASE_URL (consumed by @sc/db) and the Better-Auth session set up by @sc/auth.
Customer Module
HTTP surface for the customer-side address book (storefront) and an admin customer picker. The customer identity itself lives in Better-Auth's user table; this module owns the…
Dynamic Link Module
Admin-curated link groupings rendered on the storefront landing page. A DynamicLinkGroup has a slug used as the public lookup key; each DynamicLink belongs to exactly one group…