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

# Proyección de saldo y reconciliación de drift

> Cuando esto se entregue, el saldo de un member se podrá leer en milisegundos sin tocar el libro, y podremos demostrar que el número rápido y el número verdadero coinciden.

> Traducción. Autoritativo: [`fs-loy-0003-balance-projection.md`](/modules/loyalty/features/fs-loy-0003-balance-projection).

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

| Tabla                      | Invariantes clave                                                                                                                                                                                                                    |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `loyalty.contact_balances` | PK (`program_id`, `contact_id`, `point_currency_id`); `points_available`, `points_pending`, `points_lifetime_earned`, `updated_at`; `points_available` nunca negativo (check constraint); scoped por tenant con RLS; sin particionar |

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`](/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.*
