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). The storefront never sees this rate directly; it gets pre-multiplied discount amounts.
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 (any of the rejection reasons below).
RewardRedemptionRejectReason (informational — never returned directly to the storefront; reflected as appliedRedemption: null after caps clamp)
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)The cart UI does not see these strings — it sees the clamped result. Use them as guidance when explaining "why my points aren't applying" in support flows.
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 actual port evaluation runs inside cart-pricing.service.ts on the next priced-cart read — these endpoints only persist the customer's intent.
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. |
Response 200 — the re-priced CartResponse with appliedRedemption populated (or null if the port rejected the request — see CartAppliedRedemption above).
{
"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] |
| 404 | NOT_FOUND | Cart not found (bad / expired token) |
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…