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:
- Loads
reward_configfor the tenant — bails out immediately ifexpiry_days <= 0. - Computes
cutoff = now - expiry_days. - Selects every user with
earnorbonusrows older thancutoff, summing their per-user "expirable" pool. - 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). - Writes one
expire-type transaction per user with negative points and descriptionPoints expired after N days. - Returns the cumulative
totalExpiredacross 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
idhas been written into theorder_idcolumn of anexpirerow — this is a quirk of the original schema that re-usesorder_idto 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
expiretransaction 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
ExpireOldPointstwice 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
expirerows on/account/rewardsbut 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:
- Loads order info (
customer_id,order_number). - Sums every
earn-type row tagged with thisorder_id— that's how many points to claw back. - Bails if zero (no points to revoke) or if a negative
adjustrow already exists for thisorder_id(idempotency). - Writes one
adjust-type transaction withpoints = −earnedPointsand descriptionOrder #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.
