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

# Libro de puntos y lotes FIFO

> Cuando esto se entregue, se podrán acreditar, retener, expirar y revocar puntos con un historial completo y auditable — y el pasivo pendiente se podrá calcular desde cero en cualquier momento.

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

## Contexto

Este es el camino del dinero del módulo. Toda otra feature de fidelización escribe en este libro o
lee una proyección suya, así que los invariantes que se fijan aquí no se pueden revisar barato
después.

Tres fuerzas le dan forma. Primero, **una columna de saldo mutable es una trampa de integridad**:
en cuanto el número y su historia pueden discrepar, no hay manera de determinar cuál tiene razón.
El libro es append-only y el saldo es derivado — la misma razón por la que la contabilidad nunca
funcionó de otra forma. Segundo, **una acreditación no es definitiva cuando ocurre**: el cliente
puede devolver la compra, así que los puntos entran como `pending` y pasan a `available` cuando se
cierra la ventana de devolución del tenant. Open Loyalty implementa exactamente esto como "locked
points", lo que es una corroboración útil de que el modelo es estándar y no ingenioso. Tercero, **la
expiración tiene que ser justa y explicable**: consumir primero lo más antiguo hace que los puntos
de un member expiren en el orden que esperaría, y es la única política que se explica en una frase
a un cliente.

La restricción de supresión no es un detalle. Bajo DEC-J3, un titular que ejerce su derecho ve
borrados su perfil y sus eventos, pero sus filas del libro se **anonimizan a un tombstone, nunca se
destruyen**. Eso reconcilia el derecho de supresión con la integridad contable, y significa que
todo lo que se diseñe aquí debe sobrevivir a que la referencia al contacto quede en null.

## Alcance *(normativo)*

* `loyalty.ledger_transactions`, append-only, candidata a partición RANGE mensual.
* Catálogos paramétricos `ledger_transaction_types` (`earn`, `redeem`, `expire`, `revoke`,
  `adjust`) y `ledger_transaction_states` (`pending`, `available`, `consumed`, `cancelled`).
* `loyalty.point_lots` con `expires_at`, y consumo FIFO entre lotes.
* La transición `pending → available` guiada por la ventana de devolución configurada por el tenant.
* Política de expiración **por moneda de puntos**, sobreescribible por nivel: `none`,
  `rolling_days`, `end_of_month`, `end_of_year`. Las monedas de estatus pueden expirar o no — lo
  configura el tenant (OQ-LOY-02).
* Camino de anonimización: dejar en null la referencia al contacto hacia un tombstone sin perder la
  fila.
* El servicio de dominio que escribe una transacción, sus efectos sobre lotes, el evento del outbox
  y la fila de auditoría en una sola transacción.

## Fuera de alcance *(normativo)*

* **La proyección de saldo** — FS-LOY-0003. Este FS produce historia correcta; leerla rápido es una
  preocupación aparte con modos de falla distintos.
* **La orquestación del canje** — FS-LOY-0006. Aquí se puede escribir una transacción `redeem`;
  decidir *si* un canje procede, y la estructura padre/hijo, viven allá.
* **Qué causa una acreditación** — FS-LOY-0004. El motor de reglas llama a este servicio; no vive
  aquí.
* **Avisos de expiración** — FS-LOY-0011. Este FS hace que la expiración ocurra; avisarlo con
  antelación es un escaneo más una campaña.
* **Ventanas de calificación de puntos de estatus** — FS-LOY-0010. Los puntos de estatus se
  escriben aquí como cualquier otra moneda; para qué califican es asunto de niveles.
* Canje mixto puntos + dinero. La costura `money_component` se reserva en canjes (FS-LOY-0006), no
  en las transacciones del libro.

## Comportamiento *(normativo)*

1. **Append-only, siempre.** Una fila del libro nunca se actualiza salvo por la transición
   `pending → available` y el tombstone de supresión. Nunca se borra. Una corrección es una
   transacción compensatoria `adjust` o `revoke` que referencia a la original.
2. **Una columna de saldo mutable está PROHIBIDA** en cualquier parte del módulo.
3. Toda transacción lleva `program_id`, `contact_id`, `point_currency_id`, `points_amount`
   (INTEGER, con signo según la convención de su tipo), `type_code`, `state_code`, `occurred_at`,
   `correlation_id` y una referencia a lo que la causó.
4. **Los puntos no son dinero** (DEC-B5): `points_amount` es INTEGER contra una moneda de puntos,
   nunca un monto monetario y nunca en la misma columna que uno.
5. Un `earn` crea la transacción **y** un `point_lot` con `expires_at` calculado desde la política
   del programa, o desde el override del nivel del member cuando aplica.
6. Un `earn` entra como `pending` cuando el tenant tiene una ventana de devolución distinta de cero,
   y como `available` si no. La transición a `available` es idempotente: ejecutarla dos veces debe
   producir un solo cambio de estado y ningún segundo evento.
7. Un `redeem` consume lotes **por `expires_at` más antiguo primero**, desempatando por
   `created_at`. El consumo parcial de un lote es normal y queda registrado. Consumir más que el
   total disponible falla antes de cualquier escritura — nunca un lote negativo, nunca un saldo
   negativo.
8. Los puntos `pending` **no** son canjeables y **no** cuentan como disponibles.
9. La expiración corre contra lotes, no contra transacciones: un lote expirado escribe una
   transacción `expire` por su remanente no consumido.
   9b. **La expiración es propiedad de la moneda de puntos, no de su tipo.** Una moneda de estatus se
   puede configurar para expirar igual que una canjeable, o para no expirar nunca. No decidimos
   esto por el tenant; hacemos expresable toda combinación (OQ-LOY-02). Cuando una moneda de
   estatus **sí** expira, el período de gracia y el evento `tier.grace_started` de FS-LOY-0010
   dejan de ser opcionales — si no, un member pierde nivel por vencimiento sin haber dejado de
   comprar.
10. **Una transacción, siempre** (ADR-017): la escritura del libro, sus efectos sobre lotes, el
    evento del outbox y la fila de `core.audit_log` confirman juntos o no confirman.
11. La supresión deja `contact_id` en null hacia una referencia tombstone. La fila, sus montos y su
    vínculo con los lotes sobreviven intactos: el pasivo no cambia porque una persona ejerza sus
    derechos.
12. Agregar una fila a `ledger_transaction_types` **no** agrega comportamiento. El catálogo no es
    extensible por tenant (DEC-B4) y la API rechaza códigos no soportados con `UNSUPPORTED_CODE`.

## Datos *(normativo)*

| Tabla                               | Invariantes clave                                                                                                                                                                                                                                              |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `loyalty.ledger_transactions`       | append-only; FKs `program_id`, `contact_id` (nullable por tombstone), `point_currency_id`; `type_code` y `state_code` FK a sus catálogos; `correlation_id` obligatorio; candidata a partición (RANGE mensual sobre `occurred_at`) según ADR-018                |
| `loyalty.point_lots`                | FK a la transacción `earn` que lo originó; `points_total`, `points_remaining`, `expires_at`; `points_remaining` entre 0 y `points_total`, garantizado por check constraint; índice de selección FIFO `(program_id, contact_id, point_currency_id, expires_at)` |
| `loyalty.ledger_transaction_types`  | paramétrica, semillas `is_system` `earn`/`redeem`/`expire`/`revoke`/`adjust`; **no** extensible por tenant                                                                                                                                                     |
| `loyalty.ledger_transaction_states` | paramétrica, semillas `is_system` `pending`/`available`/`consumed`/`cancelled`; **no** extensible por tenant                                                                                                                                                   |

Decisión de particionamiento: `ledger_transactions` figura en `standards/data.md` §4 como candidata
pendiente de volumen real, no como partición designada. Este FS la crea **sin particionar** y deja
registrado el disparador para revisarlo: crecimiento sostenido más allá del punto en que el
mantenimiento de índices aparece en la latencia de escritura. Particionarla después es una
migración; hacerlo prematuramente cuesta eficiencia del planner en una tabla que además se lee en
el camino caliente.

Toda consulta contra ella debe llevar igualmente `(program_id, contact_id)` — el índice FIFO
depende de eso.

## API *(normativo)*

Ninguna. Este FS entrega un servicio de dominio y su persistencia, consumidos por FS-LOY-0004
(motor de reglas) y FS-LOY-0006 (canjes). Exponer un endpoint crudo de "escribe una transacción del
libro" permitiría a un llamador saltarse toda regla de negocio que gobierna cuándo se pueden crear
puntos.

Los ajustes manuales del personal del tenant llegan con la consola, por un endpoint con su propio
permiso y su propia semántica de auditoría — un FS posterior.

## Eventos *(normativo)*

| Evento                    | Cuándo                            | Webhook |
| ------------------------- | --------------------------------- | ------- |
| `loyalty.points.earned`   | confirma una transacción `earn`   | sí      |
| `loyalty.points.expired`  | confirma una transacción `expire` | sí      |
| `loyalty.points.revoked`  | confirma una transacción `revoke` | sí      |
| `loyalty.points.adjusted` | confirma una transacción `adjust` | sí      |

`loyalty.points.redeemed` lo emite FS-LOY-0006, que es dueño del canje como un todo — este FS
escribe la transacción pero no es dueño del evento de negocio.

Los payloads son delgados: ids, `points_amount`, `point_currency_id` y el `correlation_id` heredado
del evento de origen. Sin PII más allá de `contact_id`.

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

1. Un `earn` con ventana de devolución configurada crea una transacción `pending` y un lote; el job
   de transición lo mueve a `available` exactamente una vez, y ejecutarlo dos veces no produce un
   segundo cambio de estado ni un segundo evento.
2. Corrección FIFO: dados tres lotes con vencimientos distintos, un canje de una cantidad que abarca
   dos de ellos consume los dos más antiguos y deja el tercero intacto, con `points_remaining`
   correcto en el lote parcialmente consumido.
3. Canjear más que el total disponible falla **antes** de cualquier escritura. Ningún lote se
   modifica, no existe fila de transacción, y el error es tipado.
4. `points_remaining` nunca puede quedar bajo cero ni sobre `points_total` — probado por un test que
   espera la violación del check constraint.
5. Atomicidad: una falla inducida en la escritura del outbox revierte con ella la fila del libro y
   la de auditoría. No sobrevive nada parcial.
6. Pasivo desde cero: sumar los lotes disponibles equivale a sumar el libro por tipo sobre un
   dataset sembrado de al menos 10 000 transacciones que incluya expiraciones y revocaciones.
7. Supresión: anonimizar un contacto deja la referencia en el tombstone mientras el total de puntos
   pendientes del programa permanece **sin cambios**.
8. Test de denegación RLS por tabla.
9. **Negativo:** no existe ninguna columna llamada `balance` o equivalente en ninguna tabla de este
   FS, y ningún camino de código actualiza un total de puntos en sitio. Verificado por una aserción
   de schema en CI.

## Ejecución

Se entrega entre TS-001 (schema, catálogos, semillas, constraints, índices) y TS-004 (el servicio de
dominio, su forma transaccional y el cableado de outbox y auditoría). Arquetipo comando síncrono: el
servicio se invoca dentro de un command handler, nunca directamente desde una ruta.

La transición `pending → available` es un job programado en `backend/scheduler`, idempotente vía
`core.processed_jobs`.

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