Skip to main content
Traducción. Autoritativo: fs-loy-0003-balance-projection.md.

Contexto

El libro (FS-LOY-0002) es correcto pero caro de leer: calcular un saldo significa sumar lotes. En el camino caliente —un member abriendo el portal, un punto de venta verificando elegibilidad— eso no es aceptable dentro del presupuesto p95. La proyección es donde la mayoría de los sistemas de fidelización se rompen en silencio. Si se actualiza de forma asíncrona, el member canjea, refresca y ve el número viejo; si se le permite driftear, nadie puede decir cuál de las dos cifras es real. ADR-011 zanja ambas: la proyección se escribe en la misma transacción que el libro, y un job programado demuestra que sigue coincidiendo. DEC-G5 traza una línea que importa comercialmente: el add-on de frescura de datos sube la frecuencia de la reconciliación, nunca la del saldo. Cobrarle a un tenant por que sus members vean un saldo correcto sería cobrar por corrección.

Alcance (normativo)

  • loyalty.contact_balances: una fila por (programa, contacto, moneda de puntos).
  • Proyección actualizada dentro de la transacción de escritura del libro.
  • Reconciliación nocturna que recalcula saldos desde los lotes y reporta divergencias.
  • Frecuencia de reconciliación según el entitlement de frescura de datos del tenant.
  • Un procedimiento de reconstrucción documentado y ejecutable para toda la proyección.
  • Camino de lectura con caché en Redis, invalidada por el evento del outbox del libro.

Fuera de alcance (normativo)

  • Leer saldos por la API pública — ese es el endpoint de member en FS-LOY-0006.
  • Progreso de nivel, que es otra proyección sobre puntos de estatus (FS-LOY-0010).
  • Agregados analíticos y reportería de pasivo — después, y leen el libro, no esto.

Comportamiento (normativo)

  1. La proyección se actualiza en la misma transacción que la escritura del libro. Nunca un job de cola, nunca eventual. PROHIBIDO: un camino de código que escriba una fila del libro sin actualizar el saldo.
  2. La fila es estado derivado. Se puede borrar y reconstruir en cualquier momento; nada puede contener datos que existan solo aquí.
  3. La reconciliación recalcula desde point_lots y compara. Una divergencia se reporta y alerta, nunca se corrige en silencio — una corrección silenciosa destruye la evidencia del bug que la causó. Corregirla es una operación deliberada con entrada de auditoría.
  4. Frecuencia de reconciliación: nocturna por defecto, hasta horaria con el add-on de frescura. La latencia de la propia proyección nunca cambia con el tier.
  5. Las lecturas cacheadas se invalidan por el consumidor de invalidación de caché del outbox que ya existe. Un miss de caché cae a la proyección, nunca al libro.
  6. Los puntos pending se excluyen del saldo disponible y se exponen como una cifra separada.

Datos (normativo)

Procedimiento de reconstrucción: truncar para el tenant, recalcular desde point_lots agrupando por contacto y moneda, y rederivar points_lifetime_earned desde las transacciones earn. Documentado en el spec del módulo y ejercitado por el runbook de restore de tenant.

API (normativo)

Ninguna. La proyección la leen otras features; exponerla directamente permitiría a un llamador saltarse las verificaciones de permiso y consentimiento que pertenecen al endpoint de member.

Eventos (normativo)

No emite ninguno. Consume loyalty.points.* solo para invalidar caché.

Criterios de aceptación (normativo)

  1. Una acreditación y su actualización de saldo son visibles en la misma transacción: un lector dentro de la transacción ve ambas o ninguna.
  2. Lectura de saldo p95 <150 ms con caché fría sobre un dataset sembrado de 100 000 contactos.
  3. Un drift sembrado (un lote mutado directamente por SQL) es detectado por la reconciliación, alertado y no corregido automáticamente.
  4. La reconstrucción completa sobre 100 000 contactos reproduce cada saldo exactamente, verificado contra un conjunto de control derivado del libro.
  5. points_available no puede quedar negativo — probado por el test de violación de check constraint.
  6. Negativo: ningún camino de código actualiza contact_balances fuera del servicio del libro. Garantizado por una regla de lint que restringe las escrituras a ese módulo.

Ejecución

Se entrega en TS-004 junto al servicio del libro. El job de reconciliación vive 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.