Rewards Module — Storefront
HTTP surface for the customer-facing rewards/loyalty plugin. Customers earn points on three actions (account registration, product purchase, product review) and redeem them at the…
HTTP surface for the customer-facing rewards/loyalty plugin. Customers earn points on three actions (account registration, product purchase, product review) and redeem them at the cart as a pre-tax discount. The plugin is optional — removing RewardsModule.forRoot() from the API's app.module.ts collapses the redemption port to a no-op and silences the listeners; existing storefront callers see exactly the same shapes as before, minus the redemption fields on the cart response.
Source:
api-modules/rewards/src/controllers/store-rewards.controller.ts(balance + history), andapi-modules/cart/src/controllers/store-cart-redemption.controller.ts(cart-side set/clear) for the redemption surface.Redemption is documented here because the rewards plugin owns the semantics, but the routes live under
/store/cart/*so they fit the cart module's existing cartToken/x-platform header flow.
Conventions
Authentication
| Endpoint group | Auth |
|---|---|
GET /store/rewards/** | required (customer session) |
POST/DELETE /store/cart/redemption | optional auth + cartToken (anonymous carts can carry the intent, but redemption evaluation rejects with USER_REQUIRED when there's no customer) |
Every balance/history read is scoped to session.user.id. Setting redemption on an anonymous cart is allowed (the intent is persisted on cart.redemption_points) but the port returns ok: false, reason: "USER_REQUIRED" until the customer signs in.
Headers (cart redemption only)
The redemption routes follow the cart module's header contract:
| Header | Direction | Notes |
|---|---|---|
x-cart-token | request (optional) + response | Cart handle. Mint by omitting — response sets it. |
x-platform | request (optional) | WEB / APP (case-insensitive). Defaults to WEB. |
Response envelope
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* on paginated lists */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR |
| 401 | UNAUTHORIZED |
| 404 | NOT_FOUND (cart) |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Redemption never errors on cap violations — the port returns ok: false with a typed reason. Callers render the rejection but the request succeeds with 200.
Currency + points
| Field | Unit |
|---|---|
points (everywhere) | whole integers, no fractions |
discountAmount, discountAmountSubunits | integer subunits (paise / cents) |
expiresAt, createdAt | ISO 8601 |
The conversion rate (rewards.point_value_subunits) is an admin setting — e.g. 10 means 1 point = 10 paise (₹0.10). discountAmount on the cart arrives pre-multiplied, so rendering an applied redemption needs no arithmetic; the rate is only needed to preview or bound a redemption before it is applied — read it from GET /store/settings/rewards.
Configuration
Every rewards setting except the two cron overrides (expiry_cron, pending_promote_cron) is registry-public, so the storefront reads the whole operator configuration anonymously from GET /store/settings/rewards — one flat { key: value } map, no auth, cacheable alongside the rest of the bootstrap fetch. Fold it into GET /store/settings on app boot rather than fetching per cart render.
Keys that gate the cart redeem box
| Key | Type | Default | Storefront use |
|---|---|---|---|
enabled | bool | false | Master gate — hide every rewards surface when false |
redemption_enabled | bool | false | Hide the cart redeem box but keep balance/history (earn-only mode) |
point_value_subunits | int ≥ 1 | 10 | Conversion display and the redeemable ceiling |
max_redeem_points_per_order | int ≥ 0 | 0 = unbounded | Absolute input ceiling |
max_redeem_pct_of_subtotal | int 0–100 | 100 | Percentage ceiling + "up to N% of your order" copy |
min_cart_total_subunits | int ≥ 0 | 0 = no floor | "Spend ₹X more to use points" |
Render the box only when the customer is signed in and enabled and redemption_enabled and point_value_subunits > 0 and available > 0 and the cart clears min_cart_total_subunits.
Computing the redeemable ceiling
Mirror the port's clamp order client-side, or the input will accept amounts the server silently reduces:
const maxByBalance = balance.available;
const maxByOrder =
max_redeem_points_per_order > 0 ? max_redeem_points_per_order : Infinity;
const maxByPct = Math.floor(
Math.floor((subtotalAfterCoupons * max_redeem_pct_of_subtotal) / 100) /
point_value_subunits,
);
const maxRedeemable = Math.min(maxByBalance, maxByOrder, maxByPct);subtotalAfterCoupons is the sum of PRODUCT lines only, post-coupon and pre-tax/shipping. The cart response has no field for it — derive it as cartTotals.subtotal − Σ appliedCoupons[].discountAmount. Do not use cartTotals.discountTotal: it already includes the redemption, so feeding it back in shrinks the ceiling on every re-render.
Keys for the account + earn-promo surfaces
| Key | Default | Storefront use |
|---|---|---|
expiry_enabled / expiry_days | false / 365 | Show or hide expiry columns and the expiring-soon callout |
pending_max_days | 30 | Explain when pending points become spendable |
registration_enabled / registration_reward_points | false / 0 | Signup bonus banner |
purchase_enabled / purchase_reward_type / purchase_reward_amount | false / FIXED / 0 | PDP "earn N points" badge |
purchase_first_enabled / purchase_first_reward_type / purchase_first_reward_amount | false / FIXED / 0 | First-order bonus (additive on the regular grant) |
review_enabled / review_reward_points | false / 0 | Review-for-points prompt |
review_award_condition | ON_APPROVAL | Post-submit copy: credited now vs on approval |
review_one_per_product | true | Suppress the prompt on an already-reviewed product |
review_purchased_users_only | true | Only promise points to verified purchasers |
PERCENTAGE reward amounts are basis points (0–10000 = 0.00%–100.00%), not percent.
Domain types
BalanceResponse
type BalanceResponse = {
/** Spendable balance. May be negative immediately after a refund
* reversal exceeded the redeemed amount; the storefront should
* surface that explicitly to avoid confusion. */
available: number;
/** Earned-but-not-yet-promoted points (e.g. credited at vendor
* fulfillment, waiting for delivery to become spendable). */
pending: number;
/** Points across `available` lots whose `expires_at` falls within
* the next 30 days. 0 when no lot is expiring soon. */
expiringSoonPoints: number;
/** Earliest expiry across available lots inside the soon-window.
* null when no lot is expiring soon. */
expiringSoonAt: string | null; // ISO
};HistoryItem
type HistoryItem = {
id: string;
/** 'earn' / 'redeem' / 'reverse' / 'expire' / 'restore' /
* 'manual_credit' / 'manual_debit'. */
entryType: RewardLedgerEntryType;
/** Signed: positive for earn / restore / manual_credit;
* negative for redeem / reverse / expire / manual_debit. */
points: number;
/** Origin category — drives the row's icon + label on the
* storefront history list. */
sourceType:
| "order_vendor"
| "first_purchase"
| "customer_registration"
| "review"
| "redemption"
| "reversal"
| "restoration"
| "expiry"
| "manual";
/** Free-text note (set on manual admin actions + reversal events). */
reason: string | null;
/** Set on `earn` rows. null on every other entry type, and on
* earn rows created when `expiry_enabled` was off. */
expiresAt: string | null;
createdAt: string;
};CartAppliedRedemption (on CartResponse.appliedRedemption)
type CartAppliedRedemption = {
/** Customer's most recent requested amount (cart.redemption_points). */
requestedPoints: number;
/** What the port accepted after clamping against balance, the
* per-order absolute cap, and the % of cart cap. May be less
* than `requestedPoints` (the cart UI should re-display so the
* customer can see the clamp), never more. */
acceptedPoints: number;
/** acceptedPoints × point_value_subunits, in integer subunits. */
discountAmount: number;
/** Pro-rata split across vendor sub-bags; sum equals discountAmount. */
allocations: Array<{ vendorId: string; amount: number }>;
};appliedRedemption is null when:
- the rewards plugin is disabled (
rewards.enabled = false) or not wired in at all, - the cart has
redemption_points = 0(customer hasn't asked to redeem), - the port rejected the request —
CartResponse.redemptionRejectionthen carries the reason.
Because POST /store/cart/redemption refuses anything the port will not honour in full, requestedPoints equals acceptedPoints right after a successful set. They diverge on later reads when the cart moves under the customer — a line removed, a coupon applied — and the same points no longer all fit.
CartResponse.redemptionRejection
RewardRedemptionRejectReason | null on every cart read. Non-null when the cart holds points that bought nothing on this pass, so the storefront can say why a discount it was showing has gone: the cart dropped under min_cart_total_subunits, the operator tightened a cap, the balance moved. null when there is no intent or it applied in full.
RewardRedemptionRejectReason
MODULE_DISABLED — rewards.enabled or redemption_enabled is off
USER_REQUIRED — anonymous cart, customer hasn't signed in
INSUFFICIENT_BALANCE — available balance is 0
BALANCE_NEGATIVE — available balance is below 0 (post-reversal debt)
BELOW_MIN_CART — cart subtotal after coupons < min_cart_total_subunits
EXCEEDS_PER_ORDER_CAP — request > max_redeem_points_per_order
EXCEEDS_PCT_CAP — requested discount > max_redeem_pct_of_subtotal × subtotal
RATE_NOT_CONFIGURED — point_value_subunits is 0 (mis-configuration)These reach the storefront two ways: as errorCode on the 400 from POST /store/cart/redemption (alongside ABOVE_MAX_REDEEMABLE_POINTS, which the port itself never returns — the cart raises it when only part of a request could apply), and as redemptionRejection on any cart read whose stored intent bought nothing.
Endpoints
GET /store/rewards/balance — Read the customer's balance
Auth: customer session required.
Response 200 — BalanceResponse.
{
"data": {
"available": 1240,
"pending": 100,
"expiringSoonPoints": 50,
"expiringSoonAt": "2026-06-12T17:10:07.651Z"
},
"message": "Success",
"statusCode": 200
}A customer who has never earned/redeemed points gets { available: 0, pending: 0, expiringSoonPoints: 0, expiringSoonAt: null } — the state row is lazy-materialized on first earn, not on first read.
GET /store/rewards/history — Paginated ledger history (newest first)
Auth: customer session required.
Query
| Name | Type | Default | Constraints |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 20 | 1..50 |
Response 200 — paginated HistoryItem[] with metadata: { total, limit, offset, hasMore }.
{
"data": [
{
"id": "79721f68-ce09-461a-ba97-3f6fe501d834",
"entryType": "earn",
"points": 50,
"sourceType": "customer_registration",
"reason": null,
"expiresAt": "2027-05-14T17:10:07.651Z",
"createdAt": "2026-05-14T17:10:07.649Z"
},
{
"id": "5d2c63ee-1b1e-4f57-9c44-...",
"entryType": "redeem",
"points": -1000,
"sourceType": "redemption",
"reason": "order:07001304-6fa4-4c26-bfac-0cc6905e7c1e",
"expiresAt": null,
"createdAt": "2026-05-14T17:13:55.121Z"
}
],
"message": "Success",
"statusCode": 200,
"metadata": { "total": 2, "limit": 20, "offset": 0, "hasMore": false }
}Cart redemption surface
Lives in the cart module's controller for header consistency but is part of the rewards story. The intent is evaluated against the port before it is persisted, so the cart never holds a number the pricing pass would not honour.
POST /store/cart/redemption — Set redemption points on the cart
Auth: optional. Anonymous carts can carry the intent; redemption evaluation only succeeds once the customer signs in.
Body
{ "points": 500 }| Field | Type | Constraints |
|---|---|---|
points | int | 0..1_000_000. Pass 0 to clear. |
The request is evaluated against the caps, the customer's balance and the minimum cart total before it is stored. Anything the port will not honour in full is refused rather than quietly reduced:
- fully allowed — stored and applied as asked;
- partly allowed (e.g. 2000 points against a
max_redeem_points_per_orderof 1000) — refused withABOVE_MAX_REDEEMABLE_POINTS;details.maxPointsis the most this cart can spend right now (the strictest of balance, per-order cap and % cap), so the UI can show the limit and offer to redeem that instead; - worth nothing — refused with the port's reason as
errorCode.
A refused request never becomes the cart's intent. Whatever the cart held before is kept only while it still applies in full; an older intent the caps have since outgrown is dropped rather than left to price as a silently reduced discount, so a refusal can lower the cart's redemption to zero but never to a clamped amount.
Response 200 — the re-priced CartResponse, appliedRedemption reporting what the cart is actually priced against.
{
"data": {
"cartId": "cafe5664-...",
"bags": [ /* ... */ ],
"cartTotals": {
"subtotal": 125800,
"discountTotal": 5000,
"shippingTotal": 0,
"total": 120800
},
"appliedCoupons": [],
"appliedRedemption": {
"requestedPoints": 500,
"acceptedPoints": 500,
"discountAmount": 5000,
"allocations": [
{ "vendorId": "XzkY5vtg...", "amount": 5000 }
]
}
},
"message": "Success",
"statusCode": 200
}Bag discountAllocated includes the redemption share; if you also have an active coupon, the line's allocatedDiscount is the sum of the coupon + redemption allocations against that line.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | points outside [0, 1_000_000] |
| 400 | ABOVE_MAX_REDEEMABLE_POINTS | Only part of the request could apply — details.maxPoints is the ceiling |
| 400 | MODULE_DISABLED | Rewards or redemption is switched off, or the plugin is not mounted |
| 400 | USER_REQUIRED | Anonymous cart — the customer must sign in to redeem |
| 400 | INSUFFICIENT_BALANCE | No points available to spend |
| 400 | BALANCE_NEGATIVE | Balance is below 0 (post-reversal debt) |
| 400 | BELOW_MIN_CART | Cart subtotal after coupons is under min_cart_total_subunits |
| 400 | EXCEEDS_PER_ORDER_CAP | The caps leave no points at all for this order |
| 400 | EXCEEDS_PCT_CAP | max_redeem_pct_of_subtotal leaves no whole point to spend |
| 400 | RATE_NOT_CONFIGURED | point_value_subunits is 0 (mis-configuration) |
| 404 | NOT_FOUND | Cart not found (bad / expired token) |
Every 400 carries details.requestedPoints — what was asked for and refused; ABOVE_MAX_REDEEMABLE_POINTS adds details.maxPoints:
{
"data": null,
"message": "You can redeem at most 1000 points on this order",
"statusCode": 400,
"errorCode": "ABOVE_MAX_REDEEMABLE_POINTS",
"details": { "requestedPoints": 2000, "maxPoints": 1000 }
}DELETE /store/cart/redemption — Clear redemption from the cart
Equivalent to POST { points: 0 }. Response 200 — re-priced cart with appliedRedemption: null.
Lifecycle (what affects the balance, and when)
These are the only events that move a customer's balance. They fire automatically when the underlying actions happen elsewhere in the system — the storefront doesn't trigger them.
| Event | Customer-visible effect |
|---|---|
Signup (customer.registered) | Earn registration_reward_points to available, immediately. |
Sub-order vendor-fulfilled (order.vendor.fulfilled) | Earn purchase points (regular + first-purchase bonus when applicable) into pending. Customer sees them in pending, can't spend yet. |
Sub-order delivered (order.vendor.delivered) | Pending → available for that sub-order. |
| Stuck-pending sweep (cron, default 30d after fulfillment) | If a sub-order is fulfilled but never reaches delivered, the daily sweep auto-promotes its pending lot to available so the customer is never stranded. |
| Sub-order cancelled / RTO | Pending lot voided (no balance change); available portion of the sub-order's earned points reversed. Balance may go negative if the customer had already redeemed against the original earn. |
| Return refunded | Proportional reverse of the sub-order's points based on refundedAmount / sub-order subtotal. |
| Order cancelled / refunded | Redemption points are restored to the customer's balance as a new restore lot inheriting the original earn lot's expires_at (no expiry reset, no double-up). |
| Review approved (or submitted, depending on admin setting) | Earn review_reward_points, gated by purchased-users-only + one-review-per-product. |
| Daily expiry sweep (cron, default 03:30) | Available lots past expires_at are written off; balance drops. The history row's entryType will be expire. |
| Admin credit / debit | An ops user manually adjusts the balance with a reason. Shows in history as manual_credit (positive) or manual_debit (negative). Admin debit cannot push balance negative; refund-induced reversal can. |
Negative-balance UX
A customer can land in negative balance if they:
- earn 1000 pts on an order,
- redeem those 1000 pts on a later order,
- return the first order — its earn is reversed but the points have already been spent.
The storefront should surface negative balances explicitly (e.g. "You owe 200 points — they'll be repaid from your next earning"). Subsequent earns settle the debt first; redemption is blocked while the balance is below zero (port returns BALANCE_NEGATIVE).
Reviews Module — Storefront
HTTP surface for product reviews on the storefront — anonymous list and aggregate reads of approved reviews, plus authenticated submission. Status filters are hardcoded…
Search Module — Storefront
HTTP surface for the storefront product search — Typesense-backed faceted search and autocomplete suggestions. Mirrors the beautybarn response shape so the catalog/listing screens…