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 viaFreeGiftModule.forRoot()inapps/api/src/app.module.ts).The module is pluggable: removing the
FreeGiftModule.forRoot()line fromapp.module.tsdisables the feature entirely without breaking the rest of the app. Future cart/order modules consume theFREE_GIFT_PORTinjection token (FreeGiftPortinterface) to evaluate gift rules; today the evaluator stub throwsNotImplementedException.
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 group | Required permission |
|---|---|
GET /admin/free-gifts, GET /admin/free-gifts/:id | freeGift: read |
POST /admin/free-gifts | freeGift: create |
PATCH /admin/free-gifts/:id, POST /admin/free-gifts/:id/restore | freeGift: update |
PATCH /admin/free-gifts/:id/archive, PATCH /admin/free-gifts/:id/unarchive | freeGift: archive |
DELETE /admin/free-gifts/:id | freeGift: 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 free-gift rule has two independent off-states (mirrors discount):
| State | Column | Visibility | Editable | Reversible | Used by |
|---|---|---|---|---|---|
| active | archivedAt IS NULL AND deletedAt IS NULL | default list | yes | n/a | cart evaluation |
| 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 |
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
idin thevariantsarray is aproductVariant.id(SKU), not a parentproduct.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; // ≥ 1 — units the customer must purchase per group
getQuantity: number; // ≥ 1 — free units per group
giftProductMode: FreeGiftProductMode;
giftVariantIds: string[]; // ≥ 1 when giftProductMode = DIFFERENT; empty when SAME
repeatGift: boolean; // false → at most one group regardless of basket size
repeatLimit: number | null; // null when repeatGift=true ⇒ unlimited groups
};Repeat semantics — groups = floor(buyTotal / buyQuantity). When repeatGift=true and repeatLimit=N, groups cap at N. Free-item count = groups × getQuantity.
| Example | buyQty | getQty | repeat | limit | Customer buys | Free items |
|---|---|---|---|---|---|---|
| Buy 2 get 1 | 2 | 1 | true | null | 4 of A | 2 |
| Buy 2 get 1 (cap 3) | 2 | 1 | true | 3 | 8 of A | 3 |
| Buy 2 get 1 (no repeat) | 2 | 1 | false | — | 8 of A | 1 |
Same vs. different gift — SAME 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
| Field | Meaning |
|---|---|
criteriaScope + criteriaScopeIds | What 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 / maxAmount | Lower/upper bound on the criteriaScope total in rupees. Either side nullable. |
minQuantity / maxQuantity | Lower/upper bound on the customer's total cart units. |
minProductCount / maxProductCount | Lower/upper bound on the customer's distinct variant count. |
requireCustomerLogin | Rule applies only to logged-in customers (guests are skipped). |
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. |
individualUsageOnly | Rule cannot fire alongside any other free-gift rule or discount on the same order. Cart-layer enforcement is deferred until cart/order ships. |
customerScope + customerUserIds | ALL = any customer. ONLY_LISTED = only the user IDs in customerUserIds. EXCEPT_LISTED = anyone except those user IDs. |
showOnCart | Storefront 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
| Name | Type | Default | Notes |
|---|---|---|---|
q | string? | — | Substring match on name or couponCode (case-insensitive) |
status | "active" | "archived" | "deleted" | "all" | "active" | Lifecycle filter |
platform | Platform? | — | Exact-match filter |
type | FreeGiftRuleType? | — | Exact-match filter |
isActive | boolean? | — | Filter on isActive independently of lifecycle |
criteriaScope | FreeGiftCriteriaScope? | — | Exact-match filter |
sortBy | "createdAt" | "updatedAt" | "name" | "endsAt" | "createdAt" | |
sortDirection | "asc" | "desc" | "desc" | |
limit | int | 100 | 1..500 |
offset | int | 0 | ≥ 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
| Name | Type |
|---|---|
id | string (UUID) |
Response 200 — FreeGiftResponse (envelope above).
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Rule 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
| Rule | Error path |
|---|---|
type=AUTOMATIC ⇒ automaticConfig present, buyXGetYConfig/couponConfig absent | type-specific path |
type=BUYXGETY ⇒ buyXGetYConfig present | buyXGetYConfig |
type=BUYXGETY, giftProductMode=DIFFERENT ⇒ giftVariantIds.length >= 1 | buyXGetYConfig.giftVariantIds |
type=BUYXGETY, giftProductMode=SAME ⇒ giftVariantIds.length == 0 | buyXGetYConfig.giftVariantIds |
type=BUYXGETY, repeatGift=false ⇒ repeatLimit absent | buyXGetYConfig.repeatLimit |
type=COUPON_BASED ⇒ couponConfig present | couponConfig |
criteriaScope ∈ {CART_SUBTOTAL, ORDER_TOTAL} ⇒ criteriaScopeIds empty | criteriaScopeIds |
criteriaScope ∈ per-entity totals ⇒ criteriaScopeIds.length >= 1 | criteriaScopeIds |
purchaseHistoryMode=MIN_ORDERS ⇒ minOrderCount >= 1 | minOrderCount |
customerScope != "ALL" ⇒ customerUserIds.length >= 1 | customerUserIds |
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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod validation (single-field or cross-field) |
| 403 | FORBIDDEN | Caller lacks freeGift: create permission |
| 409 | CONFLICT | A 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
| Name | Type |
|---|---|
id | string (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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod validation, or type was provided |
| 404 | NOT_FOUND | Rule does not exist |
| 409 | CONFLICT | Rule 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
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Rule does not exist |
| 409 | CONFLICT | Rule 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
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Rule does not exist |
| 409 | CONFLICT | Rule 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
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Rule does not exist |
| 409 | CONFLICT | Rule 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
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Rule does not exist |
| 409 | CONFLICT | Rule 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: FreeGiftPortFreeGiftPort.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:
| Event | Constant | Class | Fired when |
|---|---|---|---|
free-gift.created | FREE_GIFT_CREATED | FreeGiftCreatedEvent | A rule is created |
free-gift.updated | FREE_GIFT_UPDATED | FreeGiftUpdatedEvent | A rule's fields, configs, or filters change |
free-gift.archived | FREE_GIFT_ARCHIVED | FreeGiftArchivedEvent | A rule is archived |
free-gift.unarchived | FREE_GIFT_UNARCHIVED | FreeGiftUnarchivedEvent | A rule is unarchived |
free-gift.deleted | FREE_GIFT_DELETED | FreeGiftDeletedEvent | A rule is soft-deleted |
free-gift.restored | FREE_GIFT_RESTORED | FreeGiftRestoredEvent | A 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:
- Remove the line
FreeGiftModule.forRoot()from theimportsarray inapps/api/src/app.module.ts. - Remove the
import { FreeGiftModule } from "@sc/free-gift";line. - Restart the API. All
/admin/free-gifts/*endpoints return 404 and nofreeGiftpermission 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.
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…
Inventory Module
Vendor-facing HTTP endpoints for managing product-variant stock levels, policies, manual adjustments, audit trails, and bulk CSV imports.