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

# Catálogo de recompensas

> Cuando esto se entregue, un tenant podrá definir qué compran realmente los puntos, y un member podrá verlo.

> Traducción. Autoritativo: [`fs-loy-0005-rewards-catalog.md`](/modules/loyalty/features/fs-loy-0005-rewards-catalog).

## Contexto

Puntos sin nada en qué gastarse son un pasivo sin valor de marketing. El catálogo es lo que
convierte el libro en un programa.

La decisión de diseño que importa es DEC-B4: `reward_types` **sí** es extensible por tenant, y es
uno de solo dos catálogos en toda la plataforma que lo son. La razón es la regla enunciada en el
estándar de datos: un catálogo es extensible por tenant cuando sus valores son la taxonomía de
negocio del tenant, y nunca cuando gobiernan una máquina de estados. "Envío gratis", "un café
gratis", "10% en la próxima compra" son el vocabulario de un gimnasio o una panadería, no el
nuestro. Qué significa cada tipo *operacionalmente* sigue siendo el negocio del tenant, y por eso el
fulfillment queda deliberadamente fuera de alcance.

## Alcance *(normativo)*

* `loyalty.rewards`: catálogo por programa, con `cost_points` contra una moneda de puntos específica.
* `loyalty.reward_types`: paramétrico y **extensible por tenant**, con semillas `is_system`.
* Ventanas de disponibilidad, límites de stock y límites por contacto.
* Overrides de costo por nivel, reservados y sin usar hasta FS-LOY-0010.
* CRUD de gestión y una lectura del catálogo disponible orientada al member.

## Fuera de alcance *(normativo)*

* Canjear una recompensa — FS-LOY-0006. Esta feature define qué existe; gastarlo es un acto aparte
  con modos de falla completamente distintos.
* **Fulfillment.** Registramos que una recompensa fue canjeada; entregar el café es el proceso de
  negocio del tenant. Softcrum no es un sistema de fulfillment y esto no es una brecha de fase uno.
* Recompensas con precio en dinero. `cost_points` es solo puntos; el canje mixto puntos + dinero es
  la costura del canje (DEC-H3), no un asunto del catálogo.

## Comportamiento *(normativo)*

1. Una recompensa pertenece a exactamente un programa y se cotiza en exactamente una moneda de
   puntos. Cotizar una recompensa en puntos de estatus está PROHIBIDO: los puntos de estatus
   califican, no compran.
2. `cost_points` es INTEGER, nunca un monto monetario, y nunca en una columna compartida con uno
   (DEC-B5).
3. Una recompensa con stock lo decrementa al canjearse, dentro de la transacción del canje. El stock
   nunca es indicativo.
4. Una recompensa **nunca se borra en duro**: se desactiva. Los canjes históricos deben seguir
   resolviendo a lo que el member efectivamente recibió.
5. Cambiar `cost_points` no altera canjes pasados — el canje registra el costo que aplicó en su
   momento.
6. Las filas de `reward_types` creadas por un tenant tienen `is_system = false` y quedan acotadas a
   ese tenant. Un tenant nunca puede modificar ni desactivar una fila `is_system`.
7. La lectura orientada al member devuelve solo recompensas activas, en ventana, con stock y dentro
   del límite por contacto de ese member.

## Datos *(normativo)*

| Tabla                  | Invariantes clave                                                                                                                                                                                                                                                             |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `loyalty.rewards`      | `program_id`; `type_code` FK; `cost_points` INTEGER > 0; `point_currency_id` debe ser de tipo `redeemable`; `available_from`/`available_until` nullable; `stock_total`/`stock_remaining` nullable = ilimitado; `per_contact_limit` nullable; `is_active`; solo borrado lógico |
| `loyalty.reward_types` | paramétrica; **extensible por tenant** (DEC-B4); semillas `is_system` `discount`, `free_item`, `free_shipping`, `experience`, `donation`, `external_voucher`                                                                                                                  |

## API *(normativo)*

| Endpoint                             | Clase      | Permiso                                  | Presupuesto  |
| ------------------------------------ | ---------- | ---------------------------------------- | ------------ |
| `GET/POST/PATCH /v1/loyalty/rewards` | Management | `loyalty.rewards.{read\|create\|update}` | p95 \<1 s    |
| `GET /v1/loyalty/members/me/rewards` | Runtime    | token de member                          | p95 \<150 ms |

## Eventos *(normativo)*

Ninguno. Un cambio de catálogo es un acto administrativo cubierto por el registro de auditoría;
ningún consumidor reacciona a él.

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

1. Una recompensa cotizada en una moneda `status` se rechaza al crearse con un error tipado.
2. Un tipo de recompensa creado por un tenant es visible solo para ese tenant — probado por una
   lectura entre tenants.
3. Un tenant que intenta desactivar un tipo `is_system` es rechazado.
4. Desactivar una recompensa mantiene todo canje histórico resoluble a su nombre y costo.
5. Cambiar `cost_points` de 2000 a 2500 deja un canje hecho a 2000 reportando 2000.
6. `members/me/rewards` p95 \<150 ms sobre un catálogo de 500 recompensas y excluye las sin stock,
   fuera de ventana y con límite agotado.
7. **Negativo:** ningún endpoint permite el borrado duro de una recompensa.

## Ejecución

Un solo slice, comando síncrono. Schema en TS-001, endpoints en el primer slice de API del módulo.

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