> ## 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.

# Puntos por vencer y escaneos de retención

> Cuando esto se entregue, un member escuchará "tienes 1 200 puntos que vencen en dos semanas" antes de perderlos, en vez de enterarse después.

> Traducción. Autoritativo: [`fs-loy-0011-expiring-points.md`](/modules/loyalty/features/fs-loy-0011-expiring-points).

## Contexto

La expiración es el mecanismo que mantiene acotado el pasivo y le da urgencia a los puntos. La
expiración silenciosa es la versión que genera reclamos y atención regulatoria; la expiración avisada
es una de las campañas de retención de mejor rendimiento que tiene un programa.

La feature es aparte del libro (FS-LOY-0002) porque los modos de falla no tienen relación. El libro
debe expirar puntos correctamente; esta feature debe encontrar a los members que están *por* perderlos
y avisarles, a una escala donde un escaneo ingenuo sobre todo el libro cada noche deja de funcionar.

## Alcance *(normativo)*

* Escaneo programado de lotes que vencen dentro de un horizonte configurable.
* `loyalty.points.expiring_soon` emitido una vez por member por cohorte de lotes por horizonte.
* Configuración de horizontes por programa, con varios horizontes permitidos (30 y 7 días, digamos).
* `loyalty.expiry_scan_runs` registrando cada corrida, su cohorte y su resultado.
* Fan-out por tenant para que un tenant grande no deje sin recursos al resto.
* Idempotencia que garantiza que a un member se le avisa una vez por cohorte, pase lo que pase con el
  job.

## Fuera de alcance *(normativo)*

* Expirar los puntos. Eso lo hace el libro (FS-LOY-0002); esta feature solo avisa.
* La campaña y su contenido — `messaging` consume el evento.
* La visualización de vencimientos en el portal, que lee la proyección.

## Comportamiento *(normativo)*

1. El escaneo corre por tenant y por programa, con fan-out por cron. Nunca como una consulta global.
2. A un member se le avisa **una vez por (cohorte de lotes, horizonte)**. Reejecutar el escaneo el
   mismo día, o reprocesar el job, no produce un segundo evento.
3. Los horizontes son independientes: a un member se le puede avisar a 30 días y de nuevo a 7.
4. El evento lleva el total por vencer, la moneda, la fecha de vencimiento y el id de cohorte —
   suficiente para que `messaging` renderice sin volver a consultar.
5. Los avisos son de **categoría `marketing`** y por lo tanto obedecen consentimiento, supresiones,
   quiet hours y frequency caps (ADR-019). PROHIBIDO: enviar un aviso de vencimiento como
   transaccional para saltarse un opt-out. Es un mensaje de retención, y el member dijo que no.
6. La frecuencia del escaneo sigue el tier de frescura del tenant (DEC-G5); el default es nocturno.
7. Un member sin canal alcanzable igualmente queda registrado como escaneado. El escaneo reporta lo
   que encontró, con independencia de la entrega.
8. El escaneo es de solo lectura contra el libro. Nunca muta lotes.

## Datos *(normativo)*

| Tabla                      | Invariantes clave                                                                                                                                                                                       |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `loyalty.expiry_scan_runs` | `program_id`, `horizon_days`, `cohort_key`, `started_at`, `finished_at`, `members_found`, `events_emitted`; único por (programa, horizonte, cohort\_key) — esa es la valla de idempotencia; append-only |

## API *(normativo)*

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

## Eventos *(normativo)*

`loyalty.points.expiring_soon`, disponible como webhook saliente. Payload: `contact_id`,
`points_amount`, `point_currency_id`, `expires_at`, `cohort_key`, `correlation_id`.

## Criterios de aceptación *(normativo)*

1. Un member con un lote que vence en 30 días recibe aviso exactamente una vez cuando corre el
   horizonte de 30 días.
2. Reejecutar el mismo escaneo el mismo día emite cero eventos adicionales.
3. Dos horizontes producen dos eventos en sus días respectivos, y ninguno suprime al otro.
4. El escaneo sobre 500 000 lotes termina dentro del presupuesto documentado sin exceder el
   presupuesto de consultas por tenant.
5. Un member que optó por salir de marketing no recibe mensaje, mientras el evento igual se emite y el
   escaneo igual lo registra.
6. Reprocesar el job tras una caída a media corrida se reanuda sin duplicar avisos.
7. **Negativo:** el escaneo no realiza ninguna escritura contra `point_lots` ni
   `ledger_transactions` — probado con un rol de base de datos de solo lectura en el test.

## Ejecución

Pipeline asíncrono. Fan-out programado en `backend/scheduler`, el escaneo en `backend/workers`,
idempotente vía `core.processed_jobs` más la valla de `expiry_scan_runs`.

## Preguntas abiertas

Ninguna pendiente. Toda pregunta que llevaba este spec quedó respondida en el registro consolidado
([`../../../../design/open-questions-v1.md`](/design/open-questions-v1), v1.1) y se
incorporó a las secciones normativas de arriba.

## Changelog

| Versión | Fecha      | Cambio                                                                             | Por qué                                    | Autor                  |
| ------- | ---------- | ---------------------------------------------------------------------------------- | ------------------------------------------ | ---------------------- |
| 0.2.0   | 2026-08-17 | Preguntas abiertas resueltas (OQ-LOY-\*) e incorporadas a las secciones normativas | El owner respondió el registro consolidado | daniel + claude-opus-5 |
| 0.1.0   | 2026-08-17 | Borrador inicial                                                                   | —                                          | daniel + claude-opus-5 |

## Registro de entrega

*Aún no implementado.*
