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. The cart module consumes the FREE_GIFT_PORT injection token (FreeGiftPort interface) to evaluate gift rules.


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;            // ≥ 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.

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

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

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 200 — FreeGiftResponse (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=AUTOMATIC ⇒ automaticConfig present, buyXGetYConfig/couponConfig absenttype-specific path
type=BUYXGETY ⇒ buyXGetYConfig presentbuyXGetYConfig
type=BUYXGETY, giftProductMode=DIFFERENT ⇒ giftVariantIds.length >= 1buyXGetYConfig.giftVariantIds
type=BUYXGETY, giftProductMode=SAME ⇒ giftVariantIds.length == 0buyXGetYConfig.giftVariantIds
type=BUYXGETY, repeatGift=false ⇒ repeatLimit absentbuyXGetYConfig.repeatLimit
type=COUPON_BASED ⇒ couponConfig presentcouponConfig
criteriaScope ∈ {CART_SUBTOTAL, ORDER_TOTAL} ⇒ criteriaScopeIds emptycriteriaScopeIds
criteriaScope ∈ per-entity totals ⇒ criteriaScopeIds.length >= 1criteriaScopeIds
purchaseHistoryMode=MIN_ORDERS ⇒ minOrderCount >= 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. The cart module consumes 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

The port has two methods. evaluate(ctx) decides which rules fire and which gift variants to attach — it drives GIFT cart lines and cart.pendingGifts[]. describeSpendTiers(ctx) is a read-only projection for the cart's progress bar: it reports the showable spend thresholds including ones the cart has not reached, which evaluate deliberately omits. See GET /store/cart/gifts/progress.

All money in this contract is integer subunits (paise / cents), never rupees or floats.

Gift pools are filtered by stock

Both port methods consider only gift variants that are currently orderable — untracked, backorder-enabled, or with quantity_on_hand − reserved − safety_stock > 0. A variant with no inventory_stock row at all counts as unavailable, matching what a reservation attempt would conclude.

This filtering happens where the pool is loaded, so it shapes everything downstream:

  • Slot count is computed against the filtered pool, so a sold-out variant no longer inflates how many gifts an AUTOMATIC or COUPON_BASED rule hands out (both default to the pool size).
  • A rule whose entire pool is unavailable does not fire, and its spend tier reports GIFTS_OUT_OF_STOCK rather than the misconfiguration code NO_GIFT_VARIANTS.
  • Picker options exclude unavailable variants, so a customer is never offered a gift that cannot be given.
  • A rule can flip from picker to auto-attach as its pool shrinks: picker mode is entered only when the pool is larger than the slot count, so filtering can take a rule below that line and start attaching the remaining gift automatically.

Gift lines are reserved and committed like any other line, so a gift consumes inventory and a returned gift restocks correctly. If a gift's stock cannot be reserved at checkout it is dropped from the cart rather than failing it — see POST /store/cart/prepare-checkout.

type FreeGiftEvaluationContext = {
  userId: string | null;
  cartSubtotal: number;        // integer subunits
  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[];
  // The cart's stored picker choices (cart_gift_selection), threaded in on
  // every call so the result reflects them.
  selections?: Array<{ ruleId: string; selectedVariantIds: string[] }>;
};

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

// Surfaced when a rule's gift pool is larger than its slot count — the
// customer must choose which variants fill the slots.
type FreeGiftPickerCandidate = {
  ruleId: string;
  slotCount: number;
  alreadySelectedVariantIds: string[];
  optionVariantIds: string[];
};

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

type FreeGiftTierLockedReason =
  | "BELOW_MIN_AMOUNT"
  | "ABOVE_MAX_AMOUNT"
  | "REQUIRES_LOGIN"
  | "NO_GIFT_VARIANTS"
  | "GIFTS_OUT_OF_STOCK";

// One rung of the cart spend ladder. Ordered by threshold ascending.
type FreeGiftSpendTier = {
  ruleId: string;
  name: string;
  description: string | null;
  threshold: number;           // rule.minAmount, integer subunits
  maxAmount: number | null;
  currentAmount: number;       // the criteria-scope total for this rule
  achieved: boolean;
  amountAway: number;
  giftQuantity: number;
  giftVariantIds: string[];
  lockedReason: FreeGiftTierLockedReason | null;
};

describeSpendTiers considers only rules with showOnCart: true, type: AUTOMATIC, criteriaScope in {CART_SUBTOTAL, ORDER_TOTAL} and a non-null minAmount. Rules whose customerScope excludes the caller are dropped rather than returned locked; requireCustomerLogin rules are returned locked with REQUIRES_LOGIN.

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 remove its admin surface 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.

This removes rule authoring, not rule evaluation. CartModule imports FreeGiftCoreModule directly for DI, so FREE_GIFT_PORT stays bound and the cart keeps evaluating any rules already in the database — gifts continue to attach and the spend ladder keeps rendering. Cart is a hard dependant of the free-gift core, not an optional consumer of it.

To stop gifts entirely, deactivate or archive the rules (or clear show_on_cart for the ladder alone, or switch off the product_cart.gift_progress_bar_enabled store setting for the bar alone). The schema tables and migration remain regardless — dropping them is a separate destructive operation.

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