Redemption rules
Redemption is the customer-facing half of the loyalty program. Three settings constrain it, the checkout server enforces them, and the storefront UI surfaces the resulting cap.
The three guardrails
min_redeem_points— the customer must have at least this many points available to redeem ANY points. Default 100.max_redeem_percentage— points can pay for at most this percentage of the order total. Default 30%. Stops a customer with a huge balance from buying $0 orders.redemption_rate— how many cents (minor units) each point is worth. Default 1 (one point = $0.01). Set to 0 to disable redemption while keeping earning on.
The two formulas
redemptionValue (minor units) = points × redemption_rate
maxPoints = min(
floor((orderTotal × max_redeem_percentage / 100) / redemption_rate),
user.available
)
Worked example with max_redeem_percentage = 50:
- User has 5 000 available points, places a $60 order (6 000 cents)
- Maximum discount allowed by percentage = 6 000 × 50 / 100 = 3 000 cents ($30)
- That equals 3 000 points (since 1 point = 1 cent)
- User has 5 000 available, so the cap is the percentage one → 3 000 points = $30 off
The service's CalculateMaxRedemptionForOrder(userID, orderTotalMinor) returns this triple (maxPoints, actualMinorUnits, error) and is what the checkout summary calls.
Where the gates fire
| Gate | Where enforced | What happens |
|---|---|---|
min_redeem_points | RedeemPoints service call | 400 with minimum N points required for redemption |
available balance | RedeemPoints service call | 400 with insufficient points |
max_redeem_percentage | validateRewardsRedemption in orders handler (orders.go:2128+) | Request body's redeem_points × redemption_rate is checked against (orderTotal × max%) / 100 |
| Module disabled | Service.moduleEnabled(ctx) | RedeemPoints returns (nil, nil) — silent; the order handler treats this as a 400 with rewards module disabled |
| Service unwired | OrderHandlers.rewardsSvc == nil | 400 rewards service unavailable |
The order-side validation is fail-closed — if any check fails, the entire checkout is rejected. The customer-facing error message is exposed verbatim. This is the correct behaviour: an unredeemable amount means the displayed price was wrong, and silently swallowing it would charge the wrong total.
The checkout flow
Storefront cart submits POST /api/v1/storefront/orders {…, redeem_points: 500, customer_id: …}
│
▼
orders.go validateRewardsRedemption(input)
├── module disabled? → 400 rewards module disabled
├── balance < points? → 400 insufficient points
├── points × rate > order × max%?
│ → 400 redemption exceeds cap
└── ok → continue
│
▼
Order INSERT (one transaction):
├── orders row written
├── order_items rows written
└── reward_points row INSERT (type='redeem', points=−500, order_id=…)
↑ commitRewardsRedemption
The redemption write is intentionally inside the order transaction so it cannot orphan: if the order rolls back, so does the points debit. The reverse fail mode (order commits, points debit fails) is logged-but-not-unwound — the discount has already been applied to the order's saved total, so the customer paid the discounted price. A trailing manual Adjust is the operator's recovery if this ever happens (see logs: rewards redemption commit failed).
Storefront UX expectations
The /account/rewards page is read-only. Redemption is not initiated from the rewards page — it's a checkout widget. The customer:
- Adds items to cart.
- Goes to checkout.
- Sees a "Use my points" widget with their available balance and the order's max-allowed redemption (computed via
GET /storefront/rewards/calculate?points=Nor the synchronous order helper). - Picks a points amount (slider or input) and confirms.
- Order summary updates with the discount line.
The widget is gated on:
- module enabled (
reward-points) - user authenticated (guest checkouts cannot redeem; the storefront route requires
customer:writescope) - user balance ≥
min_redeem_points - order subtotal ×
max_redeem_percentage/ 100 ≥ at least one point's worth of cents
What stacks with redemption
Reward redemption is a discount, not a payment. It sits in the same conceptual slot as a coupon: it reduces the amount the customer pays at checkout. Stacking rules:
- ✅ Stacks with promotion-driven discounts (
promotionsmodule). - ✅ Stacks with auto-coupons (
auto-couponsmodule). - ✅ Stacks with gift certificates (
gift-certificates) — gift certs reduce remaining-due AFTER points redemption. - ✅ Stacks with wallet credit (
wallet). - ⚠️ The
max_redeem_percentageis calculated against the order total the storefront sends. Verify your checkout aggregator computes total correctly when other discounts are present.
Disabling redemption while keeping earning
Set redemption_rate = 0. CalculateRedemption returns 0 for any input, the storefront widget computes a max of zero allowed points, and the redemption route rejects with points must be positive. Earning continues normally. Use this to wind a program down — keep crediting until existing balances expire, then turn the module off.
