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

# Efecto apply_discount

> Cuando esto se entregue, el checkout de un tenant podrá preguntar "¿cuánto cuesta este carro para este member?" y obtener una respuesta — convirtiendo el motor de fidelización en un motor de promociones.

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

## Contexto

Hoy los efectos del motor acreditan puntos, emiten cupones, disparan webhooks y gatillan campañas.
Todos ocurren *después* del hecho. `apply_discount` es distinto en naturaleza: modifica un precio **en
caliente**, lo que lo sitúa en el camino crítico del checkout del tenant.

Ese es el territorio que ocupa Talon.One, y la convergencia entre pagos e incentivos —Adyen
adquiriéndolos en julio de 2026— es una señal de hacia dónde va la categoría, no un nicho. DEC-H4 lo
ratificó para F2. El costo en F1 fue cero precisamente porque `rule_effect_types` quedó abierto:
agregarlo es una fila más una implementación, nunca una migración.

El problema nuevo que introduce es un **modelo de carro**. Todo efecto anterior operaba sobre un
contacto; este opera sobre una canasta de ítems que existe solo mientras dura un checkout. Eso, más
el control real de presupuesto en tiempo de request, es por qué es su propia feature y su propia fase.

## Alcance *(normativo)*

* Un modelo de carro/sesión: ítems, cantidades, precios, moneda — aceptado, nunca persistido como
  orden.
* `POST /v1/loyalty/qualifications`: dado un carro y un member, devolver los efectos aplicables.
* El tipo de efecto `apply_discount`: monto o porcentaje, con alcance de carro o de línea.
* Verificación de presupuesto en tiempo de request, con reserva atómica.
* Guardarraíles de margen: descuento máximo por carro y por línea.
* Evaluación de acumulación (FS-LOY-0008) aplicada a los descuentos candidatos.
* `loyalty.qualification_sessions` para auditoría y reconciliación contra lo que realmente se cobró.

## Fuera de alcance *(normativo)*

* Ejecutar un pago o persistir una orden. El checkout del tenant es dueño de ambos. Respondemos una
  pregunta; no nos convertimos en el sistema de comercio.
* Impuestos. Los descuentos se calculan sobre los valores que el llamador entrega, y cómo interactúa
  el impuesto con ellos es la jurisdicción del tenant y su problema.
* Inventario. Un descuento no reserva stock.

## Comportamiento *(normativo)*

1. El endpoint es **mayormente de lectura**: devuelve efectos y reserva presupuesto, pero nunca escribe
   una transacción del libro. Los puntos solo se acreditan cuando el tenant confirma la venta con una
   llamada aparte.
2. La reserva de presupuesto tiene **TTL**. Un checkout abandonado libera su reserva automáticamente;
   sin eso, un carro abandonado por segundo agota una campaña en silencio.
3. Los descuentos se calculan en **unidades menores** contra la moneda del carro (DEC-B5). Los
   descuentos porcentuales redondean a favor del member, y la regla de redondeo se documenta en la
   referencia pública de la API en vez de dejarse para que alguien la descubra.
4. Los guardarraíles de margen son topes duros. Una regla que los excedería se **acota y el recorte se
   reporta**, nunca se aplica en silencio: el tenant debe poder ver que su regla quería más de lo que
   su guardarraíl permite.
5. La acumulación se evalúa antes de responder. La respuesta contiene solo la combinación que
   efectivamente se honrará.
6. Las sesiones se conservan para reconciliación y quedan sujetas a la política estándar de retención.
   No son órdenes y no llevan datos de pago.
7. Presupuesto: p95 \<300 ms. Esto está en un camino de checkout — excederlo le cuesta conversiones al
   tenant.
8. PROHIBIDO: devolver un efecto que el tenant no pueda honrar, o uno cuyo presupuesto no se reservó.

## Datos *(normativo)*

| Tabla                            | Invariantes clave                                                                                                                                                |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `loyalty.qualification_sessions` | `program_id`, `contact_id`; snapshot del carro JSONB; efectos devueltos JSONB; `reserved_until`; `confirmed_at` nullable; append-only; particionada mensualmente |
| `loyalty.campaign_budgets`       | libro de reservas contra `rule_campaigns`: `reserved`, `consumed`, `released`; las reservas vencen por TTL                                                       |

## API *(normativo)*

| Endpoint                                       | Clase   | Permiso                          | Presupuesto                                 |
| ---------------------------------------------- | ------- | -------------------------------- | ------------------------------------------- |
| `POST /v1/loyalty/qualifications`              | Runtime | `loyalty.qualifications.read`    | p95 \<300 ms                                |
| `POST /v1/loyalty/qualifications/{id}/confirm` | Runtime | `loyalty.qualifications.confirm` | p95 \<300 ms, `Idempotency-Key` obligatorio |

## Eventos *(normativo)*

`loyalty.qualification.confirmed` al confirmar, que es lo que dispara la acreditación de puntos por el
camino normal de efectos. El request de calificación en sí no emite nada — un cliente navegando no es
un evento de dominio.

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

1. Un carro de 3 ítems a 45 000 con una regla Gold del 10% devuelve un descuento de 4 500 y reserva
   presupuesto.
2. Una sesión no confirmada libera su reserva al vencer el TTL, y el presupuesto de la campaña vuelve
   a su valor previo.
3. La confirmación acredita puntos una vez; reprocesarla con la misma clave de idempotencia no
   acredita nada más.
4. Una regla que excedería el guardarraíl de margen se acota, y la respuesta reporta tanto el monto
   solicitado como el aplicado.
5. La acumulación se respeta: un par no combinable devuelve solo el descuento de mayor precedencia.
6. p95 \<300 ms bajo el perfil k6 de checkout.
7. **Negativo:** la llamada de calificación no escribe ninguna transacción del libro — probado por el
   conteo de filas antes y después.

## Ejecución

Comando síncrono. Endpoint en `backend/api`; la reserva de presupuesto usa las mismas primitivas de
Upstash que el rate limiting; la liberación por TTL es un barrido programado.

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