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

# Cupones y generación masiva de códigos

> Cuando esto se entregue, una regla podrá emitir a un member un código de cupón único, y un punto de venta podrá validarlo y quemarlo en una sola llamada.

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

## Contexto

Los cupones son el segundo instrumento del módulo, y no son puntos. Un punto es un saldo que un
member acumula; un cupón es un token al portador con su propio ciclo de vida, su propia superficie de
fraude y su propio camino de canje. Voucherify construyó su negocio sobre esa diferencia, y las
lecciones operativas están bien establecidas: los códigos únicos deben generarse en volumen sin
colisiones, y la validación debe ser una sola llamada rápida porque ocurre en un mostrador.

La superficie de fraude es lo que hace de esto una feature propia y no un campo de una recompensa.
Un código que se puede adivinar, compartir o canjear dos veces es pérdida directa de ingresos, y cada
uno de esos casos es un control distinto.

## Alcance *(normativo)*

* `loyalty.coupon_campaigns`: genéricas (un código compartido) o únicas (un código por member).
* Generación masiva de códigos únicos con un alfabeto libre de colisiones y un presupuesto de
  entropía documentado.
* `loyalty.coupon_codes` con estados `issued → redeemed | expired | void`.
* Endpoint de validación: ¿este código es válido, para este member, ahora?
* Canje, quemando el código atómicamente.
* `issue_coupon` como efecto de regla, cableado al ejecutor de FS-LOY-0004.

## Fuera de alcance *(normativo)*

* Si un cupón se puede combinar con otro descuento — FS-LOY-0008.
* Descontar el precio de un carro. Un cupón aquí registra que se canjeó; calcular un precio nuevo es
  `apply_discount`, F2 (DEC-H4).
* La distribución. Enviar el código al member es `messaging`, alcanzado por el evento de emisión.

## Comportamiento *(normativo)*

1. Un código es de **12 caracteres sobre Crockford Base32** (sin I, L, O, U) por defecto, único
   por tenant, insensible a mayúsculas al validar. Una campaña puede bajar a 10 cuando el código se
   dicta por teléfono. Doce caracteres dan \~1,15×10¹⁸ combinaciones, así que la generación masiva
   nunca pelea consigo misma ni con cientos de millones de códigos entre todos los tenants
   (OQ-LOY-11).
2. La entropía es un presupuesto declarado, no un accidente: suficiente para que adivinar sea
   inviable frente a los rate limits de la campaña. Códigos secuenciales o predecibles están
   PROHIBIDOS.
3. La generación masiva es idempotente por request y nunca produce una colisión. Reintentar un
   request de generación no crea un segundo lote.
4. La validación es de solo lectura y nunca muta. Responde válido o inválido **con una razón
   tipada** —expirado, ya canjeado, member incorrecto, campaña inactiva— porque un cajero necesita
   saber cuál.
5. El canje quema el código en una transacción: transición de estado, evento del outbox, fila de
   auditoría. Un segundo canje del mismo código falla con un error tipado, siempre.
6. Un código nunca se borra. Cancelarlo significa el estado `void`.
7. Una campaña de código único vincula cada código a un contacto. Una campaña genérica no lo hace, y
   su límite de uso por contacto se aplica mediante el historial de canjes.

## Datos *(normativo)*

| Tabla                      | Invariantes clave                                                                                                                                                                |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `loyalty.coupon_campaigns` | `program_id`; `kind` genérica o única; ventana de validez; límites totales y por contacto; payload de recompensa o descuento                                                     |
| `loyalty.coupon_codes`     | `code` único por tenant; FK campaña; `contact_id` nullable (genérica); `state_code`; `issued_at`, `redeemed_at`, `expires_at`; nunca se borra; candidata a partición con volumen |
| `loyalty.coupon_states`    | paramétrica; semillas `is_system` `issued`, `redeemed`, `expired`, `void`; no extensible por tenant                                                                              |

## API *(normativo)*

| Endpoint                                          | Clase      | Permiso                                   | Presupuesto                                 |
| ------------------------------------------------- | ---------- | ----------------------------------------- | ------------------------------------------- |
| `POST /v1/loyalty/coupons/validate`               | Runtime    | `loyalty.coupons.validate`                | p95 \<300 ms                                |
| `POST /v1/loyalty/coupons/redeem`                 | Runtime    | `loyalty.coupons.redeem`                  | p95 \<300 ms, `Idempotency-Key` obligatorio |
| `GET/POST /v1/loyalty/coupon-campaigns`           | Management | `loyalty.coupon_campaigns.{read\|create}` | p95 \<1 s                                   |
| `POST /v1/loyalty/coupon-campaigns/{id}/generate` | Management | `loyalty.coupon_campaigns.generate`       | asíncrono para lotes grandes                |

## Eventos *(normativo)*

`loyalty.coupon.issued`, `loyalty.coupon.redeemed`, `loyalty.coupon.expired` — todos disponibles como
webhooks salientes. `issued` es lo que escucha `messaging` para entregar el código.

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

1. Generar 100 000 códigos únicos produce cero colisiones y termina dentro del presupuesto
   documentado.
2. Reintentar un request de generación con la misma clave de idempotencia no crea un segundo lote.
3. La validación devuelve una razón tipada distinta para cada caso inválido y no escribe nada.
4. Canjear el mismo código dos veces en paralelo (50 requests) tiene éxito exactamente una vez.
5. Un código vinculado al member A validado para el member B se rechaza con `WRONG_MEMBER`.
6. El vencimiento mueve un código a `expired` y emite el evento exactamente una vez.
7. **Negativo:** ningún endpoint borra un código de cupón.

## Ejecución

Comando síncrono para validación y canje; la generación masiva es un job encolado para lotes sobre un
umbral documentado, 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.*
