Redemption rules

Reward Points

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

  1. min_redeem_points — the customer must have at least this many points available to redeem ANY points. Default 100.
  2. 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.
  3. 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

GateWhere enforcedWhat happens
min_redeem_pointsRedeemPoints service call400 with minimum N points required for redemption
available balanceRedeemPoints service call400 with insufficient points
max_redeem_percentagevalidateRewardsRedemption in orders handler (orders.go:2128+)Request body's redeem_points × redemption_rate is checked against (orderTotal × max%) / 100
Module disabledService.moduleEnabled(ctx)RedeemPoints returns (nil, nil) — silent; the order handler treats this as a 400 with rewards module disabled
Service unwiredOrderHandlers.rewardsSvc == nil400 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:

  1. Adds items to cart.
  2. Goes to checkout.
  3. Sees a "Use my points" widget with their available balance and the order's max-allowed redemption (computed via GET /storefront/rewards/calculate?points=N or the synchronous order helper).
  4. Picks a points amount (slider or input) and confirms.
  5. 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:write scope)
  • 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 (promotions module).
  • ✅ Stacks with auto-coupons (auto-coupons module).
  • ✅ Stacks with gift certificates (gift-certificates) — gift certs reduce remaining-due AFTER points redemption.
  • ✅ Stacks with wallet credit (wallet).
  • ⚠️ The max_redeem_percentage is 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.