Free Gift Module — Admin
HTTP surface for managing free-gift rules — one row per promotion. The cart engine evaluates active rules and produces cart.pendingGifts[], which the customer resolves into an…
HTTP surface for managing free-gift rules — one row per promotion. The cart engine evaluates active rules and produces cart.pendingGifts[], which the customer resolves into an attached gift selection at checkout.
Source:
api-modules/free-gift/src/controllers/admin-free-gift.controller.ts.Three rule types:
AUTOMATIC(apply a fixed quantity of pre-set variants when criteria pass),BUYXGETY(buy N matching variants → get M gift variants), andCOUPON_BASED(customer applies aFREE_GIFTcoupon code → receives pre-set gift variants). Each type has its own config sub-block; only one config field is allowed per rule.
Building a cart spend ladder
A rule with showOnCart: true, type: AUTOMATIC, criteriaScope: CART_SUBTOTAL and a minAmount becomes a visible rung on the storefront's cart progress bar via GET /store/cart/gifts/progress — "Shop for ₹999 / 1 Free gift". One rule per rung; there is no separate tier entity.
name | type | criteriaScope | minAmount | showOnCart |
|---|---|---|---|---|
| Gift 999 | AUTOMATIC | CART_SUBTOTAL | 99900 | true |
| Gift 2499 | AUTOMATIC | CART_SUBTOTAL | 249900 | true |
| Gift 3999 | AUTOMATIC | CART_SUBTOTAL | 399900 | true |
Points to be deliberate about:
- Rungs stack. A ₹2,499 cart clears rungs 1 and 2 and receives both gifts. There is no "best tier wins" precedence. For mutually exclusive rungs, set
maxAmounton the lower ones (e.g.99900–249899) — note this makes the lower rung read asABOVE_MAX_AMOUNTonce the cart grows past it. - Rules whose
customerScopeexcludes the shopper are omitted from the ladder rather than shown locked, so a rung nobody can reach is never rendered. requireCustomerLoginrules do appear, locked withREQUIRES_LOGIN, so the storefront can prompt a guest to sign in.BUYXGETYandCOUPON_BASEDrules never appear on the ladder even withshowOnCart: true— a linear bar cannot position them. They surface throughcart.pendingGifts[].- The whole bar can be switched off without touching rules via the
product_cart.gift_progress_bar_enabledstore setting; gifts keep attaching either way.
How usage limits are counted
totalUsageLimit and usageLimitPerCustomer are counted against free_gift_usage, one row per (rule, order) that actually carried the gift.
- A slot is consumed when the order reaches
confirmed— placement time for COD and hosted-checkout orders, gateway confirmation for prepaid ones. An order abandoned inpending_paymentnever holds a slot. - Cancelling an order releases its slots back to the rule.
- Guest orders are not counted (there is no customer to count against). Pair
usageLimitPerCustomerwithrequireCustomerLoginif the cap must hold. - The caps are advisory, not reservations. Usage is recorded after the order commits, so concurrent checkouts can all clear a near-exhausted cap and over-redeem. Do not rely on
totalUsageLimitalone to ration genuinely scarce stock — cap the gift variant's inventory as well. - Inventory is the hard limit, and it now has teeth. Gift variants consume stock exactly like purchased items: a gift is reserved at checkout and decremented when payment commits, and a returned gift is restocked. Once a gift variant runs out, the rule stops offering it — sold-out variants disappear from the gift picker, stop counting toward how many gifts the rule gives, and a rule whose whole pool is out of stock simply does not fire (its cart progress bar reports
GIFTS_OUT_OF_STOCK). A gift that sells out mid-checkout is dropped from the cart rather than blocking the order.
Conventions
Authentication
All endpoints require a Better-Auth admin session and a role granting the matching freeGift:* permission.
| Endpoint group | 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 |
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* optional, e.g. pagination */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Lifecycle
Same active / archived / deleted model as discounts.
| State | How | Visibility |
|---|---|---|
active | Default after create | Listed and evaluated by the cart engine |
archived | PATCH /:id/archive | Listed (admin) but not evaluated |
deleted | DELETE /:id (soft) | Hidden from default lists; reversible via POST /:id/restore |
Currency
minAmount, maxAmount are integer subunits. Quantity/count fields are plain integers.
Domain types
Enums
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 FreeGiftFilterMode = "INCLUDE" | "EXCLUDE";
type Platform = "APP" | "WEB" | "BOTH";
type CustomerScope = "ALL" | "INCLUDE" | "EXCLUDE";
type PurchaseHistoryMode = "DISABLED" | "FIRST_ORDER" | "MIN_ORDERS";FreeGiftResponse
type FreeGiftFilterEntry = { id: string; mode: FreeGiftFilterMode };
type FreeGiftResponse = {
id: string;
name: string;
description: string | null;
isActive: boolean;
archivedAt: string | null;
platform: Platform;
type: FreeGiftRuleType;
automaticConfig: {
quantity: number;
variantIds: string[];
} | null;
buyXGetYConfig: {
buyScope: FreeGiftBuyScope;
buyScopeIds: string[];
buyQuantity: number;
getQuantity: number;
giftProductMode: FreeGiftProductMode;
giftVariantIds: string[];
repeatGift: boolean;
repeatLimit: number | null;
} | null;
couponConfig: {
couponCode: string;
couponQuantity: number;
variantIds: string[];
} | null;
criteriaScope: FreeGiftCriteriaScope;
criteriaScopeIds: string[];
minAmount: number | null; // subunits
maxAmount: number | null;
minQuantity: number | null;
maxQuantity: number | null;
minProductCount: number | null;
maxProductCount: number | null;
startsAt: string | null;
endsAt: string | null;
totalUsageLimit: number | null;
usageLimitPerCustomer: number | null;
requireCustomerLogin: boolean;
purchaseHistoryMode: PurchaseHistoryMode;
minOrderCount: number | null;
individualUsageOnly: boolean;
customerScope: CustomerScope;
customerUserIds: string[];
variants: FreeGiftFilterEntry[];
categories: FreeGiftFilterEntry[];
brands: FreeGiftFilterEntry[];
tags: FreeGiftFilterEntry[];
ingredients: FreeGiftFilterEntry[];
vendors: FreeGiftFilterEntry[];
showOnCart: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
};Endpoints
Base path: /admin/free-gifts.
GET /admin/free-gifts — List rules
Required permission: freeGift: read.
Query
| Name | Type | Default | Notes |
|---|---|---|---|
q | string? | — | Free-text search (trimmed, min 1) |
status | "active" | "archived" | "deleted" | "all" | "active" | Lifecycle filter |
platform | Platform? | — | — |
type | FreeGiftRuleType? | — | — |
isActive | boolean? | — | Coerced |
criteriaScope | FreeGiftCriteriaScope? | — | — |
sortBy | "createdAt" | "updatedAt" | "name" | "endsAt" | "createdAt" | — |
sortDirection | "asc" | "desc" | "desc" | — |
limit | int | 100 | 1..500 |
offset | int | 0 | >= 0 |
Response 200 — paginated envelope of FreeGiftResponse[].
GET /admin/free-gifts/:id — Get a rule
Required permission: freeGift: read.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
POST /admin/free-gifts — Create a rule
Required permission: freeGift: create. Cross-field rules enforced by zod:
type=AUTOMATIC→automaticConfigis required;buyXGetYConfigandcouponConfigmust be absent.type=BUYXGETY→buyXGetYConfigis required;automaticConfigandcouponConfigmust be absent.- If
giftProductMode=DIFFERENT,giftVariantIds[]must be non-empty; ifSAME, must be empty. - If
repeatGift=false,repeatLimitmust be absent (ornull).
- If
type=COUPON_BASED→couponConfigis required;automaticConfigandbuyXGetYConfigmust be absent.couponConfig.couponCodemust name an existing discount whosediscountTypeisFREE_GIFT, else 400. Checked on create and on any update that sendscouponConfig. Skipped when thediscountmodule is unmounted.
criteriaScopein{CART_SUBTOTAL, ORDER_TOTAL}→criteriaScopeIdsmust be empty.criteriaScopein{CATEGORY_TOTAL, BRAND_TOTAL, TAG_TOTAL, INGREDIENT_TOTAL, VENDOR_TOTAL}→criteriaScopeIdsmust be non-empty.purchaseHistoryMode=MIN_ORDERSrequiresminOrderCount >= 1.customerScope != ALLrequires non-emptycustomerUserIds.- If both set,
minAmount <= maxAmount,minQuantity <= maxQuantity,minProductCount <= maxProductCount. - If both set,
startsAt < endsAt.
Body
{
"name": "Buy 2 lipsticks, get 1 free",
"description": "Mix and match across the lipstick category.",
"isActive": true,
"platform": "BOTH",
"type": "BUYXGETY",
"buyXGetYConfig": {
"buyScope": "CATEGORY",
"buyScopeIds": ["01J9..."],
"buyQuantity": 2,
"getQuantity": 1,
"giftProductMode": "SAME",
"giftVariantIds": [],
"repeatGift": true,
"repeatLimit": 3
},
"criteriaScope": "CART_SUBTOTAL",
"criteriaScopeIds": [],
"minAmount": 100000,
"maxAmount": null,
"startsAt": null,
"endsAt": null,
"totalUsageLimit": null,
"usageLimitPerCustomer": 1,
"requireCustomerLogin": false,
"purchaseHistoryMode": "DISABLED",
"minOrderCount": null,
"individualUsageOnly": false,
"customerScope": "ALL",
"customerUserIds": [],
"variants": [],
"categories": [],
"brands": [],
"tags": [],
"ingredients": [],
"vendors": [],
"showOnCart": true
}Field shapes are documented in the type definitions above. Validation summary:
| Field | Type | Constraints |
|---|---|---|
name | string | 1..255 |
description | string | null? | max 2000 |
couponConfig.couponCode | string | 2..50, uppercase alnum + -_. Must match a FREE_GIFT discount |
buyXGetYConfig.buyQuantity / getQuantity | int | >= 1 |
automaticConfig.quantity | int | >= 1 |
*.variantIds / buyScopeIds | string[] | Non-empty arrays where required |
criteriaScopeIds | string[] | Required non-empty for per-entity criteria scopes |
Response 201 — FreeGiftResponse.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Body fails zod or cross-field refinements |
PATCH /admin/free-gifts/:id — Update a rule
Required permission: freeGift: update. Partial. All cross-field rules re-run on the merged state.
Response 200 — updated FreeGiftResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
| 400 | VALIDATION_ERROR | Body fails zod |
PATCH /admin/free-gifts/:id/archive — Archive
Required permission: freeGift: archive. Stamps archivedAt; rule stops being evaluated by the cart engine but stays visible in admin.
Response 200 — FreeGiftResponse.
PATCH /admin/free-gifts/:id/unarchive — Unarchive
Required permission: freeGift: archive.
Response 200 — FreeGiftResponse.
DELETE /admin/free-gifts/:id — Soft-delete
Required permission: freeGift: delete.
Response 200 — deleted FreeGiftResponse.
POST /admin/free-gifts/:id/restore — Restore
Required permission: freeGift: update.
Response 200 — restored FreeGiftResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id |
Related modules
admin-rbac— gates every endpoint viafreeGift:*. Seeadmin-rbac.md.cart— evaluates active free-gift rules and producescart.pendingGifts[]. Seecart.md.discount—couponConfig.couponCodereferences adiscount.code(by string, no FK, so either module stays removable). The code must belong to aFREE_GIFTdiscount, validated on save viaDISCOUNT_LOOKUP_PORT.GET /admin/discounts/free-gift-couponsbacks the admin picker. Seediscount.md.catalog—variants,categories,brands,tags,ingredients,buyScopeIds,criteriaScopeIds,*Config.variantIdsall reference catalog ids.vendor—vendors[]references vendor ids.
Frequently Bought Together Module — Admin
HTTP surface for operating the Frequently-Bought-Together (FBT) recommendation pipeline — an offline batch that mines product co-purchase pairs from confirmed orders and stores a…
Global Scripts Module — Admin
HTTP surface for managing global scripts — raw HTML/JS snippets (analytics tags, marketing pixels, inline <style> blocks, chat widgets) that the storefront injects into one of…