Supercommerce API Docs
Full Module Docs

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 via DiscountModule.forRoot() in apps/api/src/app.module.ts).

Cart application logic is not in this module. Future cart/order modules consume the DISCOUNT_PORT injection token (DiscountPort interface) to validate and apply coupons; today the validate stub throws NotImplementedException.


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 groupRequired permission
GET /admin/discounts, GET /admin/discounts/:iddiscount: read
POST /admin/discountsdiscount: create
PATCH /admin/discounts/:id, POST /admin/discounts/:id/restorediscount: update
PATCH /admin/discounts/:id/archive, PATCH /admin/discounts/:id/unarchivediscount: archive
DELETE /admin/discounts/:iddiscount: 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 */ }
}
statusCodeerrorCode examples
400BAD_REQUEST, VALIDATION_ERROR
401UNAUTHORIZED
403FORBIDDEN
404NOT_FOUND
409CONFLICT, UNIQUE_VIOLATION
500INTERNAL_SERVER_ERROR, DATABASE_ERROR
501NOT_IMPLEMENTED (returned by the port stub until cart/order ships)

Lifecycle

A discount has two independent off-states:

StateColumnVisibilityEditableReversibleUsed by
activearchivedAt IS NULL AND deletedAt IS NULLdefault listyesn/anew orders
archivedarchivedAt IS NOT NULL AND deletedAt IS NULL?status=archivednounarchiveretired — not applicable
deleteddeletedAt IS NOT NULL?status=deletednorestorereports 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 id in the variants array is a productVariant.id (SKU), not a parent product.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:

discountTypevalue semanticsExample
FIXEDDiscount amount in rupees (≥ 1)250 = ₹250 off
PERCENTAGEWhole-number percent, 1..10010 = 10% off

Server-side validation rejects out-of-range values per type.

Field semantics

FieldMeaning
freeShippingIf true, applying this coupon waives shipping in addition to (or instead of) the value discount.
requireCustomerLoginCoupon can only be applied by a logged-in customer.
showOnCartStorefront should auto-suggest this coupon in the cart UI rather than requiring manual code entry.
individualUsageOnlyCoupon cannot be combined with any other coupon on the same order.
excludeSaleItemsVariants currently on special-price are skipped when computing the discount.
excludeSaleItemsOverPercentWhen excludeSaleItems is true, only skip variants whose effective specialPrice discount is ≥ this percentage.
purchaseHistoryModeRestrict 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 + customerUserIdsALL = 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

NameTypeDefaultNotes
qstring?Substring match on name or code (case-insensitive)
status"active" | "archived" | "deleted" | "all""active"Lifecycle filter
platformPlatform?Exact-match filter
isActiveboolean?Filter on the isActive column independently of lifecycle
sortBy"createdAt" | "updatedAt" | "name" | "code" | "endsAt""createdAt"
sortDirection"asc" | "desc""desc"
limitint1001..500
offsetint0≥ 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

NameType
idstring (UUID)

Response 200DiscountResponse (envelope above).

Errors

StatusCodeWhen
404NOT_FOUNDDiscount 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

RuleError path
discountType=PERCENTAGEvalue <= 100value
purchaseHistoryMode=MIN_ORDERSminOrderCount >= 1minOrderCount
customerScope != "ALL"customerUserIds.length >= 1customerUserIds
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

StatusCodeWhen
400VALIDATION_ERRORBody fails zod validation (single-field or cross-field)
403FORBIDDENCaller lacks discount: create permission
409CONFLICTA 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

NameType
idstring (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

StatusCodeWhen
400VALIDATION_ERRORBody fails zod validation
404NOT_FOUNDDiscount does not exist
409CONFLICTDiscount 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

StatusCodeWhen
404NOT_FOUNDDiscount does not exist
409CONFLICTDiscount 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

StatusCodeWhen
404NOT_FOUNDDiscount does not exist
409CONFLICTDiscount 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

StatusCodeWhen
404NOT_FOUNDDiscount does not exist
409CONFLICTDiscount 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

StatusCodeWhen
404NOT_FOUNDDiscount does not exist
409CONFLICTDiscount 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: DiscountPort

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

EventConstantClassFired when
discount.createdDISCOUNT_CREATEDDiscountCreatedEventA discount is created
discount.updatedDISCOUNT_UPDATEDDiscountUpdatedEventA discount's fields or filters change
discount.archivedDISCOUNT_ARCHIVEDDiscountArchivedEventA discount is archived
discount.unarchivedDISCOUNT_UNARCHIVEDDiscountUnarchivedEventA discount is unarchived
discount.deletedDISCOUNT_DELETEDDiscountDeletedEventA discount is soft-deleted
discount.restoredDISCOUNT_RESTOREDDiscountRestoredEventA 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.

On this page