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

# apply_discount Effect

> After this ships, a tenant's checkout can ask "what does this cart cost for this member?" and get an answer — turning the loyalty engine into a promotions engine.

## Context

Today the engine's effects credit points, issue coupons, fire webhooks and trigger campaigns. All
of them happen *after* the fact. `apply_discount` is different in kind: it modifies a price **in
flight**, which means it sits on the tenant's checkout critical path.

That is the territory Talon.One occupies, and the convergence of payments and incentives — Adyen
acquiring them in July 2026 — is a signal that this is where the category is going, not a niche.
DEC-H4 ratified it for F2. The cost in F1 was zero precisely because `rule_effect_types` was left
open: adding this is a row plus an implementation, never a migration.

The new problem this introduces is a **cart model**. Every prior effect operated on a contact; this
one operates on a basket of items that exists only for the duration of a checkout. That, plus real
budget control at request time, is why this is its own feature and its own phase.

## Scope *(normative)*

* A cart/session model: items, quantities, prices, currency — accepted, never persisted as an order.
* `POST /v1/loyalty/qualifications`: given a cart and a member, return applicable effects.
* The `apply_discount` effect type: amount or percentage, scoped to cart or line item.
* Budget checks at request time, with atomic reservation.
* Margin guardrails: a maximum discount per cart and per line.
* Stacking evaluation (FS-LOY-0008) applied to the candidate discounts.
* `loyalty.qualification_sessions` for audit and reconciliation against what was actually charged.

## Non-scope *(normative)*

* Executing a payment or persisting an order. The tenant's checkout owns both. We answer a
  question; we do not become the commerce system.
* Tax. Discounts are computed on the values the caller supplies, and how tax interacts with them is
  the tenant's jurisdiction and their problem.
* Inventory. A discount does not reserve stock.

## Behaviour *(normative)*

1. The endpoint is **read-mostly**: it returns effects and reserves budget, but never writes a
   ledger transaction. Points are only credited when the tenant confirms the sale through a
   separate call.
2. Budget reservation has a **TTL**. An abandoned checkout releases its reservation automatically;
   without that, one abandoned cart per second silently exhausts a campaign.
3. Discounts are computed in **minor units** against the cart currency (DEC-B5). Percentage
   discounts round half-down in the member's favour, and the rounding rule is documented in the
   public API reference rather than left to be discovered.
4. Margin guardrails are hard caps. A rule that would exceed one is **clamped and the clamp is
   reported**, never applied silently — the tenant must be able to see that their rule wanted more
   than their guardrail allowed.
5. Stacking is evaluated before the response. The response contains only the combination that will
   actually be honoured.
6. Sessions are retained for reconciliation and are subject to the standard retention policy. They
   are not orders and carry no payment data.
7. Budget: p95 \<300 ms. This is on a checkout path — exceeding it costs the tenant conversions.
8. FORBIDDEN: returning an effect the tenant cannot honour, or one whose budget was not reserved.

## Data *(normative)*

| Table                            | Key invariants                                                                                                                                       |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `loyalty.qualification_sessions` | `program_id`, `contact_id`; cart snapshot JSONB; effects returned JSONB; `reserved_until`; `confirmed_at` nullable; append-only; partitioned monthly |
| `loyalty.campaign_budgets`       | reservation ledger against `rule_campaigns`: `reserved`, `consumed`, `released`; reservations expire on TTL                                          |

## API *(normative)*

| Endpoint                                       | Class   | Permission                       | Budget                                   |
| ---------------------------------------------- | ------- | -------------------------------- | ---------------------------------------- |
| `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` required |

## Events *(normative)*

`loyalty.qualification.confirmed` on confirmation, which is what triggers point accrual through the
normal effect path. The qualification request itself emits nothing — a browsing customer is not a
domain event.

## Acceptance criteria *(normative)*

1. A cart of 3 items at 45 000 with a Gold 10% rule returns a 4 500 discount and reserves budget.
2. An unconfirmed session releases its reservation at TTL, and the campaign budget returns to its
   prior value.
3. Confirmation credits points once; replaying it with the same idempotency key credits nothing
   further.
4. A rule that would exceed the margin guardrail is clamped, and the response reports both the
   requested and the applied amount.
5. Stacking is honoured: a non-combinable pair returns only the higher-precedence discount.
6. p95 \<300 ms under the k6 checkout profile.
7. **Negative:** the qualification call writes no ledger transaction — proven by a row count before
   and after.

## Execution

Synchronous command. Endpoint in `backend/api`; budget reservation uses the same Upstash primitives
as rate limiting; TTL release is a scheduled sweep.

## Open questions

None outstanding. Every question this spec carried was answered in the consolidated register
([`../../../design/open-questions-v1.md`](/design/open-questions-v1), v1.1) and folded
into the normative sections above.

## Changelog

| Version | Date       | Change                                                                     | Why                                      | Author                 |
| ------- | ---------- | -------------------------------------------------------------------------- | ---------------------------------------- | ---------------------- |
| 0.2.0   | 2026-08-17 | Open questions resolved (OQ-LOY-\*) and folded into the normative sections | Owner answered the consolidated register | daniel + claude-opus-5 |
| 0.1.0   | 2026-08-17 | Initial draft                                                              | —                                        | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
