Skip to main content
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 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 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)

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)

  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, v1.1) y se incorporó a las secciones normativas de arriba.

Changelog

Registro de entrega

Aún no implementado.