Skip to main content

Context

Expiration is the mechanism that keeps the liability bounded and gives points urgency. Silent expiration is the version that generates complaints and regulatory attention; warned expiration is one of the highest-performing retention campaigns a loyalty program has. The feature is separate from the ledger (FS-LOY-0002) because the failure modes are unrelated. The ledger must expire points correctly; this feature must find members who are about to lose them and tell them, at a scale where a naive scan over the whole ledger every night stops working.

Scope (normative)

  • Scheduled scan for lots expiring inside a configurable horizon.
  • loyalty.points.expiring_soon emitted once per member per lot cohort per horizon.
  • Per-program horizon configuration, with multiple horizons allowed (30 and 7 days, say).
  • loyalty.expiry_scan_runs recording each run, its cohort and its outcome.
  • Per-tenant fan-out so one large tenant cannot starve the rest.
  • Idempotency guaranteeing a member is warned once per cohort, whatever happens to the job.

Non-scope (normative)

  • Expiring the points. The ledger does that (FS-LOY-0002); this feature only warns.
  • The campaign and its content — messaging consumes the event.
  • Member-facing expiry display, which reads the projection.

Behaviour (normative)

  1. The scan runs per tenant per program, on cron fan-out. It never runs as one global query.
  2. A member is warned once per (lot cohort, horizon). Re-running the scan on the same day, or replaying the job, produces no second event.
  3. Multiple horizons are independent: a member can be warned at 30 days and again at 7.
  4. The event carries the total expiring, the currency, the expiry date and the cohort id — enough for messaging to render without querying back.
  5. Warnings are category marketing and therefore obey consent, suppressions, quiet hours and frequency caps (ADR-019). FORBIDDEN: sending an expiry warning as transactional to bypass an opt-out. It is a retention message, and the member said no.
  6. Scan frequency follows the tenant’s freshness tier (DEC-G5); the default is nightly.
  7. A member with no reachable channel is still recorded as scanned. The scan reports what it found, independent of delivery.
  8. The scan is read-only against the ledger. It never mutates lots.

Data (normative)

API (normative)

Events (normative)

loyalty.points.expiring_soon, available as an outgoing webhook. Payload: contact_id, points_amount, point_currency_id, expires_at, cohort_key, correlation_id.

Acceptance criteria (normative)

  1. A member with a lot expiring in 30 days is warned exactly once when the 30-day horizon runs.
  2. Re-running the same scan the same day emits zero additional events.
  3. Two horizons produce two events on their respective days, and neither suppresses the other.
  4. The scan over 500 000 lots completes within the documented budget without exceeding the per-tenant query budget.
  5. A member who opted out of marketing receives no message, while the event is still emitted and the scan still records them.
  6. Job replay after a mid-run crash resumes without duplicating warnings.
  7. Negative: the scan performs no write against point_lots or ledger_transactions — proven by a read-only database role in the test.

Execution

Asynchronous pipeline. Scheduled fan-out in backend/scheduler, the scan itself in backend/workers, idempotent via core.processed_jobs plus the expiry_scan_runs fence.

Open questions

None outstanding. Every question this spec carried was answered in the consolidated register (../../../design/open-questions-v1.md, v1.1) and folded into the normative sections above.

Changelog

Delivery record

Not implemented yet.