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

# Programas y monedas de puntos

> Cuando esto se entregue, un tenant podrá crear y nombrar programas de fidelización con sus propias monedas de puntos, y toda otra tabla de loyalty tendrá un programa al que pertenecer.

> Traducción. Autoritativo: [`fs-loy-0001-programs-and-currencies.md`](/modules/loyalty/features/fs-loy-0001-programs-and-currencies).

## Contexto

Un programa es el contenedor de todo lo demás en el módulo: reglas, monedas, niveles y catálogo de
recompensas cuelgan de él. Nada más en `loyalty` se puede construir antes de que exista.

La decisión que da forma a esta feature es **ADR-021: multi-programa es una capacidad de primera
clase en F1**, superando la postura anterior de dejarlo solo en el esquema. Un holding con dos
marcas, una empresa con un programa B2C junto a uno B2B para distribuidores, o una campaña
estacional que no debe contaminar el principal son situaciones corrientes, y un cliente que
pregunta en la primera reunión comercial no debería escuchar "después". La cantidad de programas es
además una dimensión honesta de plan: el plan base incluye uno, y necesitar un segundo es una razón
concreta para subir de tier.

El costo de esquema ya estaba pagado — `program_id` iba a estar en todas las tablas desde la
primera migración de cualquier forma. Lo que ADR-021 recupera es superficie de consola y de API,
que solo se encarece a medida que se acumulan pantallas.

La segunda decisión es la moneda dual de ADR-011: los puntos canjeables y los de estatus son
monedas separadas y jamás se mezclan. Es el modelo aerolínea —millas que gastas versus millas que
te califican para un nivel— y confundirlas es la forma más común de que un programa se vuelva
imposible de razonar.

## Alcance *(normativo)*

* `loyalty.programs`: una fila por programa, con `tenant_id`, nombre puesto por el tenant e
  `is_default`.
* CRUD completo: crear, renombrar, activar y desactivar, sujeto al entitlement del tenant.
* Creación automática del programa por defecto al aprovisionar un tenant.
* `loyalty.point_currencies`: al menos dos por programa, una de cada tipo.
* `loyalty.point_currency_kinds`: catálogo paramétrico, semillas `is_system` `redeemable` y `status`.
* `program_id` presente y NOT NULL en toda tabla de loyalty creada por este o cualquier FS posterior.
* Helper de resolución: dado un contexto de tenant sin programa explícito, resolver el por defecto.
* Verificación de entitlement sobre la cantidad de programas, bloqueando en vez de facturar overage.
* Endpoints de gestión para programas y monedas.

## Fuera de alcance *(normativo)*

* **Eliminar un programa.** Los programas se desactivan, nunca se borran: sus filas del libro,
  niveles y canjes deben seguir resolviendo para siempre.
* **Agregación entre programas en reportería.** Cada programa reporta sobre sí mismo (ADR-021).
  Agregar abre preguntas —de quién son los puntos, en qué moneda, con qué escalera de niveles— que
  merecen su propia decisión.
* Conversión de monedas entre programas o entre monedas de puntos. No está planificado.
* Acreditar o gastar cualquier cosa — eso es FS-LOY-0002 en adelante.
* Definiciones de niveles, que referencian monedas pero pertenecen a FS-LOY-0010.

## Comportamiento *(normativo)*

1. El aprovisionamiento de un tenant crea exactamente un programa con `is_default = true` y sus dos
   monedas `is_system`. Ocurre en la **misma transacción** que la creación del tenant: un tenant sin
   programa es un estado inválido, no un estado a reparar después.
2. Un tenant tiene exactamente un programa con `is_default = true`, garantizado por un índice único
   parcial. Esto se mantiene incluso cuando se habilite multi-programa.
3. Toda escritura en cualquier tabla de loyalty resuelve `program_id` antes de tocar la base. No hay
   fallback de "null significa por defecto" en la capa de datos: la resolución ocurre una vez, en el
   borde. Un request que omite el programa resuelve el por defecto del tenant, de modo que una
   integración de un solo programa nunca tiene que saber que el concepto existe.
4. `point_currency_kinds` **no es extensible por tenant** (DEC-B4): el tipo gobierna lógica del
   sistema, así que un tipo nuevo es un cambio de código y de spec, nunca un insert. La API rechaza
   tipos desconocidos con `UNSUPPORTED_CODE`.
5. Las monedas de un programa no se pueden eliminar una vez que alguna transacción del libro las
   referencia. Se desactivan (`is_active = false`), nunca se borran.
6. Crear un programa más allá del entitlement del tenant se **bloquea con un error tipado que
   nombra el límite**, nunca se factura como overage. Una capacidad que el tenant elige adoptar
   lleva tope duro; el precio por overage es para el consumo que genera el comportamiento de sus
   clientes (ADR-021).
7. Las políticas de expiración y de ventana de devolución son propias de cada programa. Dos
   programas de un mismo tenant pueden correr reglas completamente distintas — para eso son dos.
8. PROHIBIDO: una tabla de loyalty sin `program_id` · una sola moneda sirviendo a la vez como
   canjeable y de estatus · puntos, niveles o recompensas cruzando la frontera de un programa.

## Datos *(normativo)*

| Tabla                          | Invariantes clave                                                                                                                                                                     |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `loyalty.programs`             | scoped por tenant (`tenant_id`, `cell_id`, RLS); `is_default` único por tenant vía índice parcial; `name` puesto por el tenant, único por tenant; `is_active`; nunca se borra en duro |
| `loyalty.point_currencies`     | FK `program_id`; FK `kind_code` → `point_currency_kinds`; `code` único por programa; `label`; `is_active`; al menos una moneda de cada tipo por programa                              |
| `loyalty.point_currency_kinds` | paramétrica según `standards/data.md`: `code` TEXT PK, `label`, `is_system`, `is_active`, `sort_order`, `metadata`. Semillas: `redeemable`, `status`. No extensible por tenant.       |

Ninguna de las dos se particiona — son pequeñas y regulares (`standards/data.md` §4).
`point_currency_kinds` se cachea y nunca debe unirse en un camino caliente; sus filas `is_system`
generan el union type TS/Zod que consume `packages/core`.

## API *(normativo)*

| Endpoint                           | Clase      | Permiso                   | Presupuesto |
| ---------------------------------- | ---------- | ------------------------- | ----------- |
| `GET /v1/loyalty/programs`         | Management | `loyalty.programs.read`   | p95 \<1 s   |
| `POST /v1/loyalty/programs`        | Management | `loyalty.programs.create` | p95 \<1 s   |
| `PATCH /v1/loyalty/programs/{id}`  | Management | `loyalty.programs.update` | p95 \<1 s   |
| `GET /v1/loyalty/programs/current` | Management | `loyalty.programs.read`   | p95 \<1 s   |
| `GET /v1/loyalty/currencies`       | Management | `loyalty.currencies.read` | p95 \<1 s   |

`programs/current` se mantiene como atajo al por defecto, para que una integración con un solo
programa nunca maneje un id. `program_id` es un parámetro normal y documentado en todo lo demás.

## Eventos *(normativo)*

`loyalty.program.created` cuando un tenant crea uno más allá del por defecto. La creación del
programa por defecto no emite nada — es parte del aprovisionamiento del tenant y queda cubierta por
la auditoría de ese flujo.

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

1. Aprovisionar un tenant nuevo crea exactamente un programa y exactamente dos monedas, en la misma
   transacción. Revertir la creación del tenant no deja programas huérfanos.
2. Intentar insertar un segundo programa con `is_default = true` para el mismo tenant falla contra
   el índice único parcial — probado por un test que espera la violación de constraint.
3. Test de denegación RLS: una consulta bajo el contexto del tenant A devuelve cero filas del
   programa del tenant B.
4. Las semillas `is_system` generan un union TS que contiene exactamente `'redeemable' | 'status'`,
   y `packages/core` compila contra él.
5. Enviar un tipo de moneda desconocido a cualquier endpoint devuelve `UNSUPPORTED_CODE`, no un 500
   ni un insert silencioso.
6. Crear un programa más allá del entitlement se rechaza con un error tipado que nombra el límite,
   y no se escribe ninguna fila.
7. Un request que omite `program_id` resuelve el por defecto; el mismo request con id explícito
   direcciona ese programa. Ambos probados sobre el conjunto de endpoints del módulo.
8. Dos programas de un mismo tenant mantienen saldos completamente independientes: acumular en uno
   deja el saldo del otro sin cambios.
9. **Negativo:** ninguna operación mueve puntos, un nivel ni una recompensa entre programas.

## Ejecución

Un solo slice, arquetipo comando síncrono. Se entrega como parte de TS-001 (migración fundacional):
definiciones Drizzle en `database/postgres/src/schema/loyalty/`, semillas en
`database/postgres/seeds/`, el hook de aprovisionamiento junto a la creación del tenant, y los dos
endpoints de lectura en `backend/api`. Sin worker, sin cola, sin cron.

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

Abierta por la misma decisión:

| # | Pregunta                                             | Decide | Para                                                |
| - | ---------------------------------------------------- | ------ | --------------------------------------------------- |
| 1 | ¿Cuántos programas incluye el plan base — uno o dos? | Daniel | antes del lanzamiento comercial; no bloquea este FS |

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