Traducción. Autoritativo: fs-loy-0002-points-ledger.md.
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 comopending 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) yledger_transaction_states(pending,available,consumed,cancelled). loyalty.point_lotsconexpires_at, y consumo FIFO entre lotes.- La transición
pending → availableguiada 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_componentse reserva en canjes (FS-LOY-0006), no en las transacciones del libro.
Comportamiento (normativo)
- Append-only, siempre. Una fila del libro nunca se actualiza salvo por la transición
pending → availabley el tombstone de supresión. Nunca se borra. Una corrección es una transacción compensatoriaadjustorevokeque referencia a la original. - Una columna de saldo mutable está PROHIBIDA en cualquier parte del módulo.
- 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_idy una referencia a lo que la causó. - Los puntos no son dinero (DEC-B5):
points_amountes INTEGER contra una moneda de puntos, nunca un monto monetario y nunca en la misma columna que uno. - Un
earncrea la transacción y unpoint_lotconexpires_atcalculado desde la política del programa, o desde el override del nivel del member cuando aplica. - Un
earnentra comopendingcuando el tenant tiene una ventana de devolución distinta de cero, y comoavailablesi no. La transición aavailablees idempotente: ejecutarla dos veces debe producir un solo cambio de estado y ningún segundo evento. - Un
redeemconsume lotes porexpires_atmás antiguo primero, desempatando porcreated_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. - Los puntos
pendingno son canjeables y no cuentan como disponibles. - La expiración corre contra lotes, no contra transacciones: un lote expirado escribe una
transacción
expirepor 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 eventotier.grace_startedde FS-LOY-0010 dejan de ser opcionales — si no, un member pierde nivel por vencimiento sin haber dejado de comprar. - Una transacción, siempre (ADR-017): la escritura del libro, sus efectos sobre lotes, el
evento del outbox y la fila de
core.audit_logconfirman juntos o no confirman. - La supresión deja
contact_iden 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. - Agregar una fila a
ledger_transaction_typesno agrega comportamiento. El catálogo no es extensible por tenant (DEC-B4) y la API rechaza códigos no soportados conUNSUPPORTED_CODE.
Datos (normativo)
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)
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)
- Un
earncon ventana de devolución configurada crea una transacciónpendingy un lote; el job de transición lo mueve aavailableexactamente una vez, y ejecutarlo dos veces no produce un segundo cambio de estado ni un segundo evento. - 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_remainingcorrecto en el lote parcialmente consumido. - 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.
points_remainingnunca puede quedar bajo cero ni sobrepoints_total— probado por un test que espera la violación del check constraint.- 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.
- 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.
- Supresión: anonimizar un contacto deja la referencia en el tombstone mientras el total de puntos pendientes del programa permanece sin cambios.
- Test de denegación RLS por tabla.
- Negativo: no existe ninguna columna llamada
balanceo 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ónpending → 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, v1.1) y se
incorporó a las secciones normativas de arriba.