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

# Canjes — padre/hijo, idempotentes, rollback

> Cuando esto se entregue, un punto de venta podrá canjear los puntos de un member sobre una conexión inestable con la certeza de que un reintento nunca le cobra dos veces.

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

## Contexto

Este es el endpoint que llama el terminal de un cajero con un cliente parado frente al mostrador. Es
el camino más hostil operacionalmente de todo el módulo: redes poco fiables, requests reintentados,
ventas anuladas y una persona esperando.

Tres propiedades no son negociables. **Idempotencia**, porque un POS va a reintentar y un canje
duplicado es un incidente de atención al cliente. **Atomicidad entre varias recompensas**, porque un
carro puede canjear más de una cosa y medio canje es peor que ninguno. Y **rollback**, porque las
ventas se anulan y los puntos tienen que volver con una decisión sobre los lotes que consumieron.

DEC-H3 confirmó el canje mixto puntos + dinero como requisito real — es una feature que gana
contratos. F1 no lo construye, pero deja la costura: un `money_component` nullable y un evento de
dominio con la forma necesaria para que agregar el pago después sea una adición y no una cirugía.

## Alcance *(normativo)*

* `loyalty.redemptions`, filas padre e hijo desde la primera migración.
* Manejo de `Idempotency-Key` con resultado almacenado por 24 horas.
* Validación contra saldo, disponibilidad de la recompensa, stock y límites por contacto.
* Transacciones `redeem` del libro consumiendo lotes FIFO, a través del servicio de FS-LOY-0002.
* Rollback con dos modos explícitos: `revert` (devolver los puntos) y `keep` (no devolverlos).
* `money_component`, nullable, sin uso en F1.
* La lectura `members/me` orientada al member: saldo, nivel, cupones activos.

## Fuera de alcance *(normativo)*

* La política de acumulación entre varios descuentos — FS-LOY-0008. Aquí un canje aplica lo que se
  le indica; decidir qué se puede combinar es una capa de política por encima.
* El canje de cupones — FS-LOY-0007. Cupones y puntos son instrumentos distintos.
* Cualquier ejecución de pago. La costura está reservada, no cableada.

## Comportamiento *(normativo)*

1. `Idempotency-Key` es **obligatorio**. La misma clave reproduce el resultado almacenado durante 24
   horas, byte por byte, sin re-ejecutar nada. Un payload distinto bajo la misma clave es un error
   tipado de conflicto, nunca una sobreescritura silenciosa.
2. Un canje es una transacción: validación, filas `redeem` del libro, consumo de lotes, decremento de
   stock, actualización de la proyección, evento del outbox y fila de auditoría confirman juntos.
3. Los requests con varias recompensas son padre/hijo. **El padre es todo o nada**: si cualquier hijo
   falla, no se aplica nada.
4. La validación ocurre antes de cualquier escritura. Saldo insuficiente, recompensa inactiva o sin
   stock, o límite por contacto agotado fallan limpiamente con un error tipado y cero efectos
   secundarios.
5. El rollback es a nivel de padre con modo explícito. `revert` escribe transacciones compensatorias
   que restauran los puntos; los lotes consumidos se restauran a sus lotes originales siempre que no
   hayan expirado, y a un lote nuevo que hereda el vencimiento más próximo cuando sí. `keep` deja los
   puntos gastados y registra la decisión. No hay valor por defecto: el llamador declara el modo.
6. Un canje **nunca se borra**. El rollback es una transición de estado más transacciones
   compensatorias.
7. `money_component` es nullable y DEBE ser null en F1. Un valor no nulo se rechaza hasta que la
   costura de pagos esté implementada.
8. Presupuesto: p95 \<300 ms para la decisión síncrona (DEC-D7).

## Datos *(normativo)*

| Tabla                       | Invariantes clave                                                                                                                                                                                                                                       |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `loyalty.redemptions`       | `parent_id` autorreferente (null = padre); `program_id`, `contact_id`, `reward_id`; `cost_points` congelado al canjear; `state_code` FK; `idempotency_key` único por tenant; `money_component` BIGINT + `currency_code`, ambos nullable; nunca se borra |
| `loyalty.redemption_states` | paramétrica; semillas `is_system` `requested`, `validated`, `applied`, `rolled_back`, `failed`; no extensible por tenant                                                                                                                                |

## API *(normativo)*

| Endpoint                                     | Clase   | Permiso                        | Presupuesto  | Idempotente     |
| -------------------------------------------- | ------- | ------------------------------ | ------------ | --------------- |
| `POST /v1/loyalty/redemptions`               | Runtime | `loyalty.redemptions.create`   | p95 \<300 ms | **obligatorio** |
| `POST /v1/loyalty/redemptions/{id}/rollback` | Runtime | `loyalty.redemptions.rollback` | p95 \<300 ms | obligatorio     |
| `GET /v1/loyalty/members/me`                 | Runtime | token de member                | p95 \<150 ms | —               |

## Eventos *(normativo)*

`loyalty.points.redeemed` sobre un padre exitoso, con el id del canje, las recompensas, el costo
total y el `correlation_id`. Un rollback lo emite de nuevo con una marca `reverted` en vez de
inventar un segundo nombre de evento — un consumidor que entiende un canje entiende su reversa.

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

1. La misma `Idempotency-Key` enviada 100 veces en paralelo produce exactamente un canje y un solo
   conjunto de filas del libro.
2. Un payload distinto bajo una clave ya usada devuelve un conflicto tipado, y el canje original
   queda sin cambios.
3. Un padre de tres recompensas donde la tercera excede el stock no aplica nada: sin filas del libro,
   sin decremento de stock, sin padre parcial.
4. `revert` restaura el total exacto de puntos; la suma sobre el libro del member vuelve a su valor
   previo al canje. Los puntos restaurados que caen en un lote nuevo heredan el vencimiento más
   próximo de los lotes consumidos.
5. `keep` deja el saldo sin cambios y registra el modo en la fila del canje.
6. p95 \<300 ms bajo el perfil k6 con un dataset de 10 000 contactos.
7. **Negativo:** un request con `money_component` no nulo se rechaza en F1 con un error tipado, no se
   ignora en silencio.

## Ejecución

Arquetipo comando síncrono. Se entrega como slice propio después de TS-004: el endpoint en
`backend/api`, el servicio de dominio junto al servicio del libro, sin worker — este camino es
síncrono por diseño porque el cajero está esperando.

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