> ## Documentation Index
> Fetch the complete documentation index at: https://internal.softcrum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Expiring Points and Retention Scans

> After this ships, a member hears "you have 1 200 points expiring in two weeks" before they lose them, instead of discovering it afterwards.

## 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)*

| Table                      | Key invariants                                                                                                                                                                                      |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `loyalty.expiry_scan_runs` | `program_id`, `horizon_days`, `cohort_key`, `started_at`, `finished_at`, `members_found`, `events_emitted`; unique per (program, horizon, cohort\_key) — this is the idempotency fence; append-only |

## API *(normative)*

| Endpoint                                               | Class      | Permission                        | Budget    |
| ------------------------------------------------------ | ---------- | --------------------------------- | --------- |
| `GET/PUT /v1/loyalty/programs/current/expiry-horizons` | Management | `loyalty.programs.{read\|update}` | p95 \<1 s |

## 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`](/design/open-questions-v1), v1.1) and folded
into the normative sections above.

## Changelog

| Version | Date       | Change                                                                     | Why                                      | Author                 |
| ------- | ---------- | -------------------------------------------------------------------------- | ---------------------------------------- | ---------------------- |
| 0.2.0   | 2026-08-17 | Open questions resolved (OQ-LOY-\*) and folded into the normative sections | Owner answered the consolidated register | daniel + claude-opus-5 |
| 0.1.0   | 2026-08-17 | Initial draft                                                              | —                                        | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
