Expiry + order revocation

Reward Points

Expiry + order revocation

Two automatic write-offs reduce a customer's balance without them spending: expiry (age-based) and revocation (cancelled or refunded order).

Expiry

expiry_days controls how long earned and bonus points stay live. The default is 365 days. Set to 0 to disable expiry entirely — points then stay forever (or until redeemed / revoked).

The expiry job is the ExpireOldPoints(ctx) service method. It is NOT wired to a cron in this codebase yet — it is a function operators must trigger themselves. Plug it into your existing scheduled-tasks runner (most platforms call it once a day via the gocron worker, or via an external cron hitting an internal admin endpoint). Recommended cadence: once a day at off-peak hours.

When invoked, the job:

  1. Loads reward_config for the tenant — bails out immediately if expiry_days <= 0.
  2. Computes cutoff = now - expiry_days.
  3. Selects every user with earn or bonus rows older than cutoff, summing their per-user "expirable" pool.
  4. For each user, computes toExpire = min(expirable, currentAvailable) so the operation can never push the balance below zero (handles the case where a customer has redeemed points after they were earned).
  5. Writes one expire-type transaction per user with negative points and description Points expired after N days.
  6. Returns the cumulative totalExpired across all users so the caller can log it.
ExpireOldPoints
    ├── per-user pool of expirable (earn+bonus older than cutoff)
    ├── compare to current available balance
    └── INSERT reward_points (type='expire', points=−min(pool, available),
                              order_id=NULL, description='Points expired after N days')

Caveats to be aware of

  • No customer notification. The expire write happens silently. If you want a 30-day-warning email, build it as a separate cron that reads the same cutoff math against expiry_days - 30.
  • The "already-expired" filter is heuristic. The job filters out earn-rows whose id has been written into the order_id column of an expire row — this is a quirk of the original schema that re-uses order_id to point at the source-earn-row. If you ever rewrite the expiry job, preserve this contract or migrate first.
  • Coarse-grained. All expiry rolls up to a single expire transaction per user per run. The customer's history shows one consolidated debit, not one per source-earn. Most loyalty programs prefer this — it stops history from getting noisy after years of small earns.
  • Idempotent within a single run, not across runs. Calling ExpireOldPoints twice in quick succession will only write a second row if new earn rows have aged into the cutoff between the two calls. Calling it daily is safe.
  • Expiry is an operator-driven knob, not a customer one. Customers see the resulting expire rows on /account/rewards but cannot prevent expiry by transacting (e.g., earning new points does NOT reset the expiry clock on older points — each batch ages on its own birthday).

Order revocation (the deferred-bonus parallel)

When an order moves into a cancelled or refunded status, the OrderListener's revokePointsForOrder(ctx, orderID) runs:

  1. Loads order info (customer_id, order_number).
  2. Sums every earn-type row tagged with this order_id — that's how many points to claw back.
  3. Bails if zero (no points to revoke) or if a negative adjust row already exists for this order_id (idempotency).
  4. Writes one adjust-type transaction with points = −earnedPoints and description Order #N cancelled (or … refunded).

This is the rewards-module equivalent of a "deferred bonus reversal" — points that were earned now become a negative adjust, taking the balance back to where it was before the order. It uses adjust (not expire) because the policy-intent is admin/system-driven correction, not aging.

order.statusChanged: paid → cancelled
                ▼
   OrderListener.revokePointsForOrder
                ▼
   SUM(points) WHERE order_id=X AND type='earn'  → 800
                ▼
   already-revoked? (any adjust row with same order_id and points<0)
                ▼
   INSERT reward_points (type='adjust', points=−800, order_id=X,
                         description='Order #N cancelled')

Important: revocation runs even if the user has spent the earned points. The available balance is allowed to go transiently negative on paper, but the safety clamp in GetBalance floors Available at zero so customers never see a negative balance. This is by design — don't let a refund give the customer free money.

If you spot an "available 0 / total earned 800 / total redeemed 800 / negative-adjust 800" customer in the admin user-detail, that's a customer who earned, spent, and then got refunded. Their NEXT earn will rebuild a usable balance.

What is NOT in scope here

The "deferred bonus" subsystem in the promotions module (usergroup grants on completion, generated coupons on completion) is a separate code path with separate tables (promotion_post_orders). Reward Points does not interact with it. If a customer is supposed to "earn 100 points after completing 3 orders", that's a reward_rules event (not yet auto-fired) plus your own glue — not the deferred-bonus engine in promotions.