Supercommerce API Docs
Full Module Docs

Free Gift Module

Admin-facing HTTP endpoints for managing platform-wide free-gift rules — three rule types (Automatic, BuyXGetY, Coupon-based), criteria-based eligibility (cart subtotal, order…

Admin-facing HTTP endpoints for managing platform-wide free-gift rules — three rule types (Automatic, BuyXGetY, Coupon-based), criteria-based eligibility (cart subtotal, order total, or per-entity totals), include/exclude filters across the catalog, customer scoping, and usage limits.

Source: api-modules/free-gift (registered via FreeGiftModule.forRoot() in apps/api/src/app.module.ts).

The module is pluggable: removing the FreeGiftModule.forRoot() line from app.module.ts disables the feature entirely without breaking the rest of the app. Future cart/order modules consume the FREE_GIFT_PORT injection token (FreeGiftPort interface) to evaluate gift rules; today the evaluator stub throws NotImplementedException.


Conventions

Authentication

All endpoints require a Better-Auth bearer session and a role with the relevant freeGift permission (granted to superAdmin and admin by default).

Authorization: Bearer <session-token>
Endpoint groupRequired permission
GET /admin/free-gifts, GET /admin/free-gifts/:idfreeGift: read
POST /admin/free-giftsfreeGift: create
PATCH /admin/free-gifts/:id, POST /admin/free-gifts/:id/restorefreeGift: update
PATCH /admin/free-gifts/:id/archive, PATCH /admin/free-gifts/:id/unarchivefreeGift: archive
DELETE /admin/free-gifts/:idfreeGift: 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 free-gift rule has two independent off-states (mirrors discount):

StateColumnVisibilityEditableReversibleUsed by
activearchivedAt IS NULL AND deletedAt IS NULLdefault listyesn/acart evaluation
archivedarchivedAt IS NOT NULL AND deletedAt IS NULL?status=archivednounarchiveretired — not applicable
deleteddeletedAt IS NOT NULL?status=deletednorestorereports only

Rule names are unique among non-deleted rows — deleting a rule frees its name for re-use; restoring a deleted rule will conflict if its name has been taken meanwhile.

Archiving sets isActive = false automatically. Unarchiving does not flip isActive back to true — toggle it explicitly via PATCH /:id if needed.


Domain types

Used in payloads and responses below.

Enums

type Platform = "APP" | "WEB" | "BOTH";
type FreeGiftRuleType = "AUTOMATIC" | "BUYXGETY" | "COUPON_BASED";
type FreeGiftBuyScope = "VARIANT" | "BRAND" | "CATEGORY" | "TAG" | "INGREDIENT" | "VENDOR";
type FreeGiftProductMode = "SAME" | "DIFFERENT";
type FreeGiftCriteriaScope =
  | "CART_SUBTOTAL"
  | "ORDER_TOTAL"
  | "CATEGORY_TOTAL"
  | "BRAND_TOTAL"
  | "TAG_TOTAL"
  | "INGREDIENT_TOTAL"
  | "VENDOR_TOTAL";
type CustomerScope = "ALL" | "ONLY_LISTED" | "EXCEPT_LISTED";
type PurchaseHistoryMode = "DISABLED" | "ZERO_ORDERS" | "MIN_ORDERS";
type FreeGiftFilterMode = "INCLUDE" | "EXCLUDE";

FreeGiftFilterEntry

Used in every rule-wide filter array (variants, categories, brands, tags, ingredients, vendors).

type FreeGiftFilterEntry = {
  id: string;            // UUID of the referenced entity
  mode: FreeGiftFilterMode;
};

INCLUDE entries narrow the rule to only matching items; EXCLUDE entries remove matching items. Both can coexist within the same array.

Note: variant-level filtering means each id in the variants array is a productVariant.id (SKU), not a parent product.id. Pick the SKU(s) the rule should match — multiple variants of the same product can be targeted independently.

Type-specific config sub-shapes

AutomaticConfig

type AutomaticConfig = {
  quantity: number;       // free units of EACH listed variant
  variantIds: string[];   // ≥ 1 variants to gift on every applicable order
};

BuyXGetYConfig

type BuyXGetYConfig = {
  buyScope: FreeGiftBuyScope;
  buyScopeIds: string[];          // ≥ 1; entity IDs of `buyScope` kind
  buyQuantity: number;            // ≥ 1units the customer must purchase per group
  getQuantity: number;            // ≥ 1free units per group
  giftProductMode: FreeGiftProductMode;
  giftVariantIds: string[];       // ≥ 1 when giftProductMode = DIFFERENT; empty when SAME
  repeatGift: boolean;            // falseat most one group regardless of basket size
  repeatLimit: number | null;     // null when repeatGift=true ⇒ unlimited groups
};

Repeat semanticsgroups = floor(buyTotal / buyQuantity). When repeatGift=true and repeatLimit=N, groups cap at N. Free-item count = groups × getQuantity.

ExamplebuyQtygetQtyrepeatlimitCustomer buysFree items
Buy 2 get 121truenull4 of A2
Buy 2 get 1 (cap 3)21true38 of A3
Buy 2 get 1 (no repeat)21false8 of A1

Same vs. different giftSAME gifts the exact SKU the customer purchased that triggered the group. DIFFERENT gifts the variants in giftVariantIds (one of each per group).

CouponConfig

type CouponConfig = {
  couponCode: string;     // /^[A-Z0-9_-]+$/, length 2..50
  couponQuantity: number; // ≥ 1
  variantIds: string[];   // ≥ 1 variants gifted, each at couponQuantity
};

couponCode references a discount module coupon by string only (no FK) — keeping the modules decoupled. The cart layer can optionally validate the code via DISCOUNT_PORT at evaluation time. If the underlying discount is archived/deleted/missing, the gift rule simply does not fire.

FreeGiftResponse

Returned by every endpoint that returns a single rule.

type FreeGiftResponse = {
  id: string;
  name: string;
  description: string | null;
  isActive: boolean;
  archivedAt: string | null;                       // ISO

  platform: Platform;
  type: FreeGiftRuleType;

  automaticConfig: AutomaticConfig | null;
  buyXGetYConfig: BuyXGetYConfig | null;
  couponConfig: CouponConfig | null;

  criteriaScope: FreeGiftCriteriaScope;
  criteriaScopeIds: string[];                      // empty for CART_SUBTOTAL / ORDER_TOTAL
  minAmount: number | null;                        // rupees
  maxAmount: number | null;                        // rupees
  minQuantity: number | null;
  maxQuantity: number | null;
  minProductCount: number | null;
  maxProductCount: number | null;

  startsAt: string | null;                         // ISO
  endsAt: string | null;                           // ISO
  totalUsageLimit: number | null;
  usageLimitPerCustomer: number | null;
  requireCustomerLogin: boolean;
  purchaseHistoryMode: PurchaseHistoryMode;
  minOrderCount: number | null;
  individualUsageOnly: boolean;

  customerScope: CustomerScope;
  customerUserIds: string[];                       // populated only when scope != ALL

  variants: FreeGiftFilterEntry[];
  categories: FreeGiftFilterEntry[];
  brands: FreeGiftFilterEntry[];
  tags: FreeGiftFilterEntry[];
  ingredients: FreeGiftFilterEntry[];
  vendors: FreeGiftFilterEntry[];

  showOnCart: boolean;

  createdAt: string;                               // ISO
  updatedAt: string;                               // ISO
  deletedAt: string | null;                        // ISO
};

Exactly one of automaticConfig / buyXGetYConfig / couponConfig is non-null on any given response — the one matching the rule's type.

Currency

All monetary fields (minAmount, maxAmount) are stored and exchanged as whole rupees — no paise. Clients should send and receive integers in INR.

Field semantics

FieldMeaning
criteriaScope + criteriaScopeIdsWhat total the criteria-range fields apply to. CART_SUBTOTAL / ORDER_TOTAL need no IDs. CATEGORY_TOTAL / BRAND_TOTAL / TAG_TOTAL / INGREDIENT_TOTAL / VENDOR_TOTAL sum line items matching the listed entity IDs.
minAmount / maxAmountLower/upper bound on the criteriaScope total in rupees. Either side nullable.
minQuantity / maxQuantityLower/upper bound on the customer's total cart units.
minProductCount / maxProductCountLower/upper bound on the customer's distinct variant count.
requireCustomerLoginRule applies only to logged-in customers (guests are skipped).
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.
individualUsageOnlyRule cannot fire alongside any other free-gift rule or discount on the same order. Cart-layer enforcement is deferred until cart/order ships.
customerScope + customerUserIdsALL = any customer. ONLY_LISTED = only the user IDs in customerUserIds. EXCEPT_LISTED = anyone except those user IDs.
showOnCartStorefront should auto-suggest this rule in the cart UI rather than requiring the customer to discover it.

Endpoints

Base path: /admin/free-gifts

GET / — List free gift rules

Returns a paginated list of rules filtered by lifecycle status and other facets.

Query

NameTypeDefaultNotes
qstring?Substring match on name or couponCode (case-insensitive)
status"active" | "archived" | "deleted" | "all""active"Lifecycle filter
platformPlatform?Exact-match filter
typeFreeGiftRuleType?Exact-match filter
isActiveboolean?Filter on isActive independently of lifecycle
criteriaScopeFreeGiftCriteriaScope?Exact-match filter
sortBy"createdAt" | "updatedAt" | "name" | "endsAt""createdAt"
sortDirection"asc" | "desc""desc"
limitint1001..500
offsetint0≥ 0

Response 200 — paginated list of rule rows (parent fields only; type-specific configs and filter arrays are not hydrated in list view — use the get-by-id endpoint for those).

{
  "data": [
    {
      "id": "01J9...",
      "name": "Buy 2 chocolates, get 1 free",
      "description": null,
      "isActive": true,
      "archivedAt": null,
      "platform": "BOTH",
      "type": "BUYXGETY",
      // ...all parent columns
      "createdAt": "2026-04-30T10:00:00.000Z",
      "updatedAt": "2026-04-30T10:00:00.000Z",
      "deletedAt": null
    }
  ],
  "message": "Success",
  "statusCode": 200,
  "metadata": { "total": 8, "limit": 100, "offset": 0, "hasMore": false }
}

GET /:id — Get a free gift rule by ID

Returns the full FreeGiftResponse including the type-specific config, all 6 filter arrays, criteria-scope IDs, and the customer ID list. By default returns archived rules but 404s for soft-deleted ones — fetch via GET /?status=deleted to inspect a deleted rule.

Path params

NameType
idstring (UUID)

Response 200FreeGiftResponse (envelope above).

Errors

StatusCodeWhen
404NOT_FOUNDRule does not exist or is soft-deleted

POST / — Create a free gift rule

Creates a rule, its type-specific join rows, criteria-scope rows, filter rows, and customer scoping in a single transaction. The body shape varies by type — see the three examples below.

Common fields

{
  "name": "Buy 2 Choco, get 1 free",
  "description": "Festive season promo",          // optional
  "isActive": true,                                // default true
  "platform": "BOTH",                              // default "BOTH"

  "type": "BUYXGETY",                              // AUTOMATIC | BUYXGETY | COUPON_BASED

  // Type-specific configs (exactly one populated, per `type`):
  "automaticConfig":  null | { /* see AutomaticConfig */ },
  "buyXGetYConfig":   null | { /* see BuyXGetYConfig  */ },
  "couponConfig":     null | { /* see CouponConfig    */ },

  // Criteria
  "criteriaScope": "CART_SUBTOTAL",
  "criteriaScopeIds": [],                          // required for per-entity totals
  "minAmount": 1000,                               // rupees, nullable
  "maxAmount": null,                               // rupees, nullable
  "minQuantity": null,
  "maxQuantity": null,
  "minProductCount": null,
  "maxProductCount": null,

  // Restrictions
  "startsAt": "2026-04-30T00:00:00.000Z",          // ISO; nullable
  "endsAt": "2026-12-31T23:59:59.000Z",            // ISO; nullable
  "totalUsageLimit": 1000,                         // nullable
  "usageLimitPerCustomer": 1,                      // nullable
  "requireCustomerLogin": false,
  "purchaseHistoryMode": "DISABLED",               // DISABLED | ZERO_ORDERS | MIN_ORDERS
  "minOrderCount": null,                           // required when MIN_ORDERS
  "individualUsageOnly": false,

  // Customer scope
  "customerScope": "ALL",
  "customerUserIds": [],                           // required when scope != ALL

  // Rule-wide INCLUDE/EXCLUDE filters (variant-level for the variants array)
  "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" }],

  "showOnCart": false
}

Type-specific examples

type = "AUTOMATIC" — gift quantity-of-each on every applicable order:

{
  "type": "AUTOMATIC",
  "automaticConfig": {
    "quantity": 1,
    "variantIds": ["01J9...sample"]
  },
  "buyXGetYConfig": null,
  "couponConfig": null
}

type = "BUYXGETY" — buy 2 of any item in brand X, get 1 of the same SKU free, repeat up to 3 groups:

{
  "type": "BUYXGETY",
  "automaticConfig": null,
  "buyXGetYConfig": {
    "buyScope": "BRAND",
    "buyScopeIds": ["01J9...brandA"],
    "buyQuantity": 2,
    "getQuantity": 1,
    "giftProductMode": "SAME",
    "giftVariantIds": [],
    "repeatGift": true,
    "repeatLimit": 3
  },
  "couponConfig": null
}

type = "COUPON_BASED" — applying coupon SUMMER25 gifts 1×A + 1×B:

{
  "type": "COUPON_BASED",
  "automaticConfig": null,
  "buyXGetYConfig": null,
  "couponConfig": {
    "couponCode": "SUMMER25",
    "couponQuantity": 1,
    "variantIds": ["01J9...A", "01J9...B"]
  }
}

Cross-field validation

RuleError path
type=AUTOMATICautomaticConfig present, buyXGetYConfig/couponConfig absenttype-specific path
type=BUYXGETYbuyXGetYConfig presentbuyXGetYConfig
type=BUYXGETY, giftProductMode=DIFFERENTgiftVariantIds.length >= 1buyXGetYConfig.giftVariantIds
type=BUYXGETY, giftProductMode=SAMEgiftVariantIds.length == 0buyXGetYConfig.giftVariantIds
type=BUYXGETY, repeatGift=falserepeatLimit absentbuyXGetYConfig.repeatLimit
type=COUPON_BASEDcouponConfig presentcouponConfig
criteriaScope ∈ {CART_SUBTOTAL, ORDER_TOTAL}criteriaScopeIds emptycriteriaScopeIds
criteriaScope ∈ per-entity totalscriteriaScopeIds.length >= 1criteriaScopeIds
purchaseHistoryMode=MIN_ORDERSminOrderCount >= 1minOrderCount
customerScope != "ALL"customerUserIds.length >= 1customerUserIds
minAmount <= maxAmount (when both set)minAmount
minQuantity <= maxQuantity (when both set)minQuantity
minProductCount <= maxProductCount (when both set)minProductCount
endsAt > startsAt (when both set)endsAt

Response 201 — full FreeGiftResponse for the newly created rule.

Side effects — emits free-gift.created (FreeGiftCreatedEvent) on the EventEmitter bus after the transaction commits.

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod validation (single-field or cross-field)
403FORBIDDENCaller lacks freeGift: create permission
409CONFLICTA non-deleted rule with the same name already exists

PATCH /:id — Update a free gift rule

Updates any subset of fields. type is immutable — switching a rule's type silently re-shapes every downstream join table; create a new rule instead.

When a config sub-object (automaticConfig / buyXGetYConfig / couponConfig) is present in the body, it fully replaces the rule's type-specific config rows (buy-scope, gift-variant). Sending null clears them.

When a filter array (variants, categories, etc.) is omitted, that filter table is left untouched. When present (even as an empty []), it fully replaces the existing rows.

criteriaScope, criteriaScopeIds, customerScope, customerUserIds follow the same replace-when-present rule.

Path params

NameType
idstring (UUID)

Body — same shape as create, all fields optional, type rejected if present. Cross-field validation runs against whatever fields are provided.

Response 200 — full FreeGiftResponse post-update.

Side effects — emits free-gift.updated (FreeGiftUpdatedEvent).

Errors

StatusCodeWhen
400VALIDATION_ERRORBody fails zod validation, or type was provided
404NOT_FOUNDRule does not exist
409CONFLICTRule is archived or soft-deleted (must be active to edit)

PATCH /:id/archive — Archive a free gift rule

Sets archivedAt = now() and isActive = false. Archived rules are visible via GET /?status=archived but are not editable and will not be applied at checkout.

Body — empty.

Response 200 — the updated FreeGiftResponse.

Side effects — emits free-gift.archived (FreeGiftArchivedEvent).

Errors

StatusCodeWhen
404NOT_FOUNDRule does not exist
409CONFLICTRule is already archived, or is soft-deleted

PATCH /:id/unarchive — Unarchive a free gift rule

Sets archivedAt = null. Restores the rule to active status (isActive is not automatically toggled back to true — flip it via PATCH /:id if needed).

Body — empty.

Response 200 — the updated FreeGiftResponse.

Side effects — emits free-gift.unarchived (FreeGiftUnarchivedEvent).

Errors

StatusCodeWhen
404NOT_FOUNDRule does not exist
409CONFLICTRule is not archived, or is soft-deleted

DELETE /:id — Soft-delete a free gift rule

Sets deletedAt = now(). The row is hidden from default lists but its usage history (in free_gift_usage) remains queryable for reports.

The rule's name becomes available for new rules immediately after deletion.

Body — empty.

Response 200 — the deleted rule row (parent fields only).

Side effects — emits free-gift.deleted (FreeGiftDeletedEvent).

Errors

StatusCodeWhen
404NOT_FOUNDRule does not exist
409CONFLICTRule is already soft-deleted

POST /:id/restore — Restore a soft-deleted free gift rule

Sets deletedAt = null. Before restoring, the server re-checks name uniqueness against non-deleted rows; if another rule has taken the name in the meantime, the restore is rejected with 409 Conflict — delete the conflicting rule or pick a new name (by deleting+recreating) before retrying.

Body — empty.

Response 200 — the restored FreeGiftResponse.

Side effects — emits free-gift.restored (FreeGiftRestoredEvent).

Errors

StatusCodeWhen
404NOT_FOUNDRule does not exist
409CONFLICTRule is not soft-deleted, or its name is now in use by another active rule

Cart application (internal API)

Rule evaluation against a cart is not exposed over HTTP from this module. Future cart/order modules consume it in-process via the FREE_GIFT_PORT injection token:

import { FREE_GIFT_PORT, type FreeGiftPort } from "@sc/free-gift";

@Inject(FREE_GIFT_PORT) private readonly freeGiftPort: FreeGiftPort

FreeGiftPort.evaluate(ctx) is the contract; today the implementation throws NotImplementedException and is filled in when cart/order ships.

type FreeGiftEvaluationContext = {
  userId: string | null;
  cartSubtotal: number;        // rupees
  cartItems: Array<{
    productId: string; variantId: string; quantity: number;
    unitPrice: number; specialPrice: number | null;
    categoryIds: string[]; brandId: string | null;
    tagIds: string[]; ingredientIds: string[]; vendorId: string;
  }>;
  platform: "APP" | "WEB";
  appliedCouponCodes: string[];
};

type FreeGiftEvaluationItem = {
  ruleId: string;
  productId: string;
  variantId: string;
  quantity: number;
  reason: string;              // "AUTOMATIC" | "BUYXGETY" | "COUPON_BASED:<code>"
};

type FreeGiftEvaluationResult = {
  rulesFired: string[];
  items: FreeGiftEvaluationItem[];
};

Domain events emitted on lifecycle transitions:

EventConstantClassFired when
free-gift.createdFREE_GIFT_CREATEDFreeGiftCreatedEventA rule is created
free-gift.updatedFREE_GIFT_UPDATEDFreeGiftUpdatedEventA rule's fields, configs, or filters change
free-gift.archivedFREE_GIFT_ARCHIVEDFreeGiftArchivedEventA rule is archived
free-gift.unarchivedFREE_GIFT_UNARCHIVEDFreeGiftUnarchivedEventA rule is unarchived
free-gift.deletedFREE_GIFT_DELETEDFreeGiftDeletedEventA rule is soft-deleted
free-gift.restoredFREE_GIFT_RESTOREDFreeGiftRestoredEventA soft-deleted rule is restored

All event classes carry (ruleId: string, actorId: string).


Pluggability

The module is opt-in. To disable it for a deployment:

  1. Remove the line FreeGiftModule.forRoot() from the imports array in apps/api/src/app.module.ts.
  2. Remove the import { FreeGiftModule } from "@sc/free-gift"; line.
  3. Restart the API. All /admin/free-gifts/* endpoints return 404 and no freeGift permission checks fire.

The schema tables and migration remain (dropping them is a separate destructive operation) but no code path reads them.

Coupon-based rules reference discount.code by string only — the discount module is not a hard dependency. If the discount module is also disabled, coupon-based rules can still be CRUD'd; their evaluation just always skips the cross-module lookup.


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