Rewards Module — Admin
HTTP surface for platform staff managing the rewards/loyalty plugin: per-customer search, balance + history inspection, and manual credit/debit adjustments. All endpoints sit…
HTTP surface for platform staff managing the rewards/loyalty plugin: per-customer search, balance + history inspection, and manual credit/debit adjustments. All endpoints sit under a single new RBAC resource (rewards) with one action (manage) — granted by default to the superAdmin and admin roles.
Source:
api-modules/rewards/src/controllers/admin-rewards.controller.ts.Plugin configuration (rates, caps, gates, cron schedules) is not managed here — those are platform settings under group
rewards, served by the generic/admin/settings/adminsurface (settings.md). This controller is the per-customer ops surface only.
Conventions
Authentication
All endpoints require a Better-Auth admin session and the rewards: manage permission.
| Endpoint group | Permission |
|---|---|
GET /admin/rewards/** | rewards: manage |
POST /admin/rewards/customers/:id/credit | rewards: manage |
POST /admin/rewards/customers/:id/debit | rewards: manage |
Single-permission gate is deliberate — the standalone admin Rewards section is meant for customer-support/ops users; finer-grained view vs manage splits aren't worth the configuration overhead for this feature.
Response envelope
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* pagination */ }
}Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR (incl. EXCEEDS_AVAILABLE for over-debit — see debit endpoint below) |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN (missing rewards:manage) |
| 404 | NOT_FOUND (customer or ledger row) |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Currency + points
All money is integer subunits. Points are whole integers. The conversion rate (rewards.point_value_subunits) lives in admin settings under group rewards; this surface never returns the rate, only point amounts. Use the settings endpoint to read it for display purposes.
Domain types
CustomerSearchItem
type CustomerSearchItem = {
customerId: string;
email: string | null;
name: string | null;
/** Spendable balance. May be negative after a reversal exceeded
* the redeemed amount. */
availableBalance: number;
/** Earned-but-not-yet-promoted points (waiting on delivery /
* stuck-pending sweep). */
pendingBalance: number;
/** Set once when the customer crosses the first-purchase gate.
* null = first-purchase bonus is still unclaimed. */
firstPurchaseAwardedAt: string | null;
};Customers who have never had any rewards activity still appear in the search results with availableBalance: 0, pendingBalance: 0, firstPurchaseAwardedAt: null. The state row is lazy-materialized on first ledger insert, not on listing.
CustomerSummaryResponse
type CustomerSummaryResponse = {
customerId: string;
email: string | null;
name: string | null;
availableBalance: number;
pendingBalance: number;
firstPurchaseAwardedAt: string | null;
/** Most recent reward_ledger.created_at for this customer.
* null when the customer has had zero rewards activity. */
lastActivityAt: string | null;
};LedgerListItem
type LedgerListItem = {
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;
/** Lot state — only set on `earn` rows; null elsewhere.
* Values: pending / available / consumed / expired / reversed / void. */
state: RewardLotState | null;
/** Set on `earn` rows; null elsewhere. */
earnedAt: string | null;
/** Set on `earn` rows when the lot was created with expiry enabled. */
expiresAt: string | null;
/** Origin category. */
sourceType:
| "order_vendor"
| "first_purchase"
| "customer_registration"
| "review"
| "redemption"
| "reversal"
| "restoration"
| "expiry"
| "manual";
/** Interpretation depends on sourceType:
* order_vendor / first_purchase → orderVendor.id
* customer_registration → user.id (the customer themselves)
* review → reviewId (or `${productId}:${customerId}` when
* one_per_product is on — the dedupe key)
* redemption → orderId (or cartId if no order yet)
* reversal / restoration → parent_ledger_id is also set
* expiry → parent_ledger_id of the expired lot
* manual → admin user.id who performed the credit/debit */
sourceId: string | null;
/** For reverse/restore/expire rows, points back at the original
* earn/redeem ledger row this entry compensates. */
parentLedgerId: string | null;
/** Human / system note. Required on manual_credit + manual_debit. */
reason: string | null;
createdAt: string;
};ManualAdjustResponse
type ManualAdjustResponse = {
ledgerId: string;
/** Customer's new balance after the adjustment. */
availableBalance: number;
pendingBalance: number;
};Endpoints
Base path: /admin/rewards. Permission on every endpoint: rewards: manage.
GET /admin/rewards/customers — Paginated customer search
Substring ILIKE across user.email + user.name. Customers with no rewards state row default to zero balances. Use this to populate the standalone Rewards admin section's customer-search UI.
Query
| Name | Type | Default | Constraints |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 20 | 1..100 |
search | string | — | 1..200 chars, optional |
Response 200 — paginated CustomerSearchItem[] with metadata: { total, limit, offset, hasMore }.
{
"data": [
{
"customerId": "lWpZoPgXt8Oux2kWd1fntGQzKfO6MTx8",
"email": "alice@test.com",
"name": "Alice",
"availableBalance": 19250,
"pendingBalance": 0,
"firstPurchaseAwardedAt": null
}
],
"message": "Success",
"statusCode": 200,
"metadata": { "total": 1, "limit": 20, "offset": 0, "hasMore": false }
}GET /admin/rewards/customers/:customerId/summary — Single-customer detail
Powers the customer-detail page in the admin Rewards section: balance card, first-purchase status, last-activity timestamp.
Path params
| Name | Notes |
|---|---|
customerId | user.id of the target customer |
Response 200 — CustomerSummaryResponse.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | No user row with this id |
GET /admin/rewards/customers/:customerId/ledger — Paginated ledger history
Newest first. Full audit view including non-earn rows (redeem / reverse / expire / restore / manual_credit / manual_debit) and parent-pointer links so support can trace "why did this reversal happen — what was the parent earn row?".
Path params
| Name | Notes |
|---|---|
customerId | user.id of the target customer |
Query
| Name | Type | Default | Constraints |
|---|---|---|---|
page | int | 1 | >= 1 |
limit | int | 20 | 1..100 |
Response 200 — paginated LedgerListItem[] with metadata: { total, limit, offset, hasMore }.
{
"data": [
{
"id": "fb6b014e-b1f3-4f2b-a2dd-c8d6d28d8c8b",
"entryType": "earn",
"points": 500,
"state": "available",
"earnedAt": "2026-05-14T17:12:50.000Z",
"expiresAt": null,
"sourceType": "manual",
"sourceId": "<admin user id>",
"parentLedgerId": null,
"reason": "Verification credit",
"createdAt": "2026-05-14T17:12:50.000Z"
},
{
"id": "5d2c63ee-...",
"entryType": "restore",
"points": 1000,
"state": null,
"earnedAt": null,
"expiresAt": null,
"sourceType": "restoration",
"sourceId": "07001304-...",
"parentLedgerId": "9a48...redemption-row-id",
"reason": "order.cancelled: Changed my mind",
"createdAt": "2026-05-14T17:15:21.000Z"
}
],
"message": "Success",
"statusCode": 200,
"metadata": { "total": 2, "limit": 20, "offset": 0, "hasMore": false }
}POST /admin/rewards/customers/:customerId/credit — Manual credit
Grant points to a customer with a required reason (audit). Creates an immediately-available earn lot — no pending hold for admin grants. Expiry defaults to the global rewards.expiry_days setting; pass neverExpire: true to grant a permanent lot.
Path params
| Name | Notes |
|---|---|
customerId | user.id of the target customer |
Body
{
"points": 500,
"reason": "Loyalty appreciation for being a long-time customer",
"neverExpire": true
}| Field | Type | Constraints |
|---|---|---|
points | int | 1..1_000_000 |
reason | string | trimmed 1..500 chars, required |
neverExpire | bool | optional, defaults to false |
Response 201 — ManualAdjustResponse.
{
"data": {
"ledgerId": "fb6b014e-b1f3-4f2b-a2dd-c8d6d28d8c8b",
"availableBalance": 19550,
"pendingBalance": 0
},
"message": "Success",
"statusCode": 201
}Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | points outside [1, 1_000_000] or reason empty |
| 404 | NOT_FOUND | No user row with this id |
POST /admin/rewards/customers/:customerId/debit — Manual debit (capped at zero)
Remove points from a customer with a required reason. Consumes available lots FIFO (oldest expiry first, NULL-expiry lots last). Cannot push balance below zero — if the requested magnitude exceeds the customer's current available balance, returns 400 BAD_REQUEST with details.maxAllowed so the admin UI can re-render the modal with the right ceiling.
This is asymmetric with refund-induced reversal (which can push balance negative). The asymmetry is deliberate: admin manual actions need a typo guard; system-driven reversals are accurate by construction.
Path params
| Name | Notes |
|---|---|
customerId | user.id of the target customer |
Body
{
"points": 300,
"reason": "Correction — customer accidentally got 2× registration bonus"
}| Field | Type | Constraints |
|---|---|---|
points | int | 1..1_000_000 — absolute magnitude (the ledger row stores -points) |
reason | string | trimmed 1..500 chars, required |
Response 201 — ManualAdjustResponse.
{
"data": {
"ledgerId": "699e6c81-f38c-43d1-9119-0a9f294d1979",
"availableBalance": 19250,
"pendingBalance": 0
},
"message": "Success",
"statusCode": 201
}Errors
| Status | Code | When |
|---|---|---|
| 400 | EXCEEDS_AVAILABLE | points > current available_balance. Body carries details.maxAllowed (the largest amount the admin can debit right now). |
| 400 | VALIDATION_ERROR | points outside [1, 1_000_000] or reason empty |
| 404 | NOT_FOUND | No user row with this id |
EXCEEDS_AVAILABLE example:
{
"data": null,
"message": "Cannot debit 100000 pts; max allowed is 19250.",
"statusCode": 400,
"errorCode": "BAD_REQUEST",
"details": { "maxAllowed": 19250 }
}Settings + crons (read-only here; configured elsewhere)
Plugin behavior is controlled by the rewards settings group, edited via /admin/settings/admin (settings.md). 24 keys, all admin-scope:
| Section | Keys |
|---|---|
| Master | enabled, point_value_subunits |
| Redemption | redemption_enabled, max_redeem_points_per_order, max_redeem_pct_of_subtotal, min_cart_total_subunits |
| Product purchase | purchase_enabled, purchase_reward_type, purchase_reward_amount, purchase_first_enabled, purchase_first_reward_type, purchase_first_reward_amount |
| User registration | registration_enabled, registration_reward_points |
| Product review | review_enabled, review_reward_points, review_award_condition, review_one_per_product, review_purchased_users_only |
| Expiry | expiry_enabled, expiry_days |
| Pending hold | pending_max_days |
| Cron schedules | expiry_cron, pending_promote_cron |
Two BullMQ crons run inside the API process. They register on worker boot from the settings above and re-read the schedule values at startup — change-then-restart workers.
| Job | Default schedule | Behavior |
|---|---|---|
rewards-expiry | 30 3 * * * (03:30 daily) | Scans earn lots in state='available' with expires_at < now() and writes negative expire ledger rows. |
rewards-pending-promote | 0 3 * * * (03:00 daily) | Promotes earn lots stuck in pending past pending_max_days (default 30) to available. Catches the edge case where a sub-order reached fulfilled but never delivered — the customer's points aren't stranded indefinitely. |
Optionality
The whole module is opt-in. Removing RewardsModule.forRoot() from apps/api/src/app.module.ts makes the plugin disappear:
- All
/admin/rewards/**+/store/rewards/**routes drop off the router. - The cart-side
@Optional() @Inject(REWARD_REDEMPTION_PORT)falls through to a no-op —POST /store/cart/redemptionstill accepts and persists the customer's intent oncart.redemption_points, but pricing returnsappliedRedemption: nulland there's no discount. - The two BullMQ crons stop registering.
- No event listeners are wired, so order/customer/review events fire normally and the rewards machinery is silent.
cart.redemption_points (column added in migration 0034) and order.redemption_points_consumed (column added in 0035) stay in the schema either way — they're cheap zero-default integers and removing them would require a destructive migration. Treat them as harmless residue when the plugin is off.
Reviews Module — Admin
HTTP surface for platform-admin moderation of product reviews: cross-vendor list with filters, full-field edit, lifecycle transitions (approve / reject / mark-spam), admin-create…
Search Module — Admin
HTTP surface for the platform-admin operational control of the product search index. Currently a single endpoint: kick off a (chained) bulk reindex of the search index, optionally…