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

# Coupons and Mass Code Generation

> After this ships, a rule can issue a member a unique coupon code, and a point of sale can validate and burn it in one call.

## Context

Coupons are the second instrument in the module, and they are not points. A point is a balance a
member accumulates; a coupon is a bearer token with its own lifecycle, its own fraud surface and
its own redemption path. Voucherify built its business on the difference, and the operational
lessons are well established: unique codes must be generated in bulk without collisions, and
validation must be a single fast call because it happens at a counter.

The fraud surface is what makes this its own feature rather than a field on a reward. A code that
can be guessed, shared, or redeemed twice is a direct revenue loss, and each of those is a
different control.

## Scope *(normative)*

* `loyalty.coupon_campaigns`: generic (one shared code) or unique (one code per member).
* Bulk generation of unique codes with a collision-free alphabet and a documented entropy budget.
* `loyalty.coupon_codes` with states `issued → redeemed | expired | void`.
* Validation endpoint: is this code valid, for this member, right now.
* Redemption, burning the code atomically.
* `issue_coupon` as a rule effect, wiring into FS-LOY-0004's executor.

## Non-scope *(normative)*

* Whether a coupon may be combined with another discount — FS-LOY-0008.
* Discounting a cart price. A coupon here records that it was redeemed; computing a new price is
  `apply_discount`, F2 (DEC-H4).
* Distribution. Sending the code to the member is `messaging`, reached through the issued event.

## Behaviour *(normative)*

1. A code is **12 characters over Crockford Base32** (no I, L, O, U) by default, unique per
   tenant, case-insensitive on validation. A campaign may drop to 10 where the code has to be
   dictated by phone. Twelve characters give \~1.15×10¹⁸ combinations, so bulk generation never
   fights itself even at hundreds of millions of codes across all tenants (OQ-LOY-11).
2. Entropy is a stated budget, not an accident: enough that guessing is infeasible against the
   campaign's rate limits. Sequential or predictable codes are FORBIDDEN.
3. Bulk generation is idempotent per request and never produces a collision. A retry of a
   generation request does not create a second batch.
4. Validation is read-only and never mutates. It answers valid or invalid **with a typed reason** —
   expired, already redeemed, wrong member, campaign inactive — because a cashier needs to know
   which.
5. Redemption burns the code in one transaction: state transition, outbox event, audit row. A
   second redemption of the same code fails with a typed error, always.
6. A code is never deleted. Cancelling it means the `void` state.
7. A unique-code campaign binds each code to one contact. A generic campaign does not, and its
   per-contact usage limit is enforced through redemption history.

## Data *(normative)*

| Table                      | Key invariants                                                                                                                                                               |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `loyalty.coupon_campaigns` | `program_id`; `kind` generic or unique; validity window; total and per-contact limits; reward or discount payload                                                            |
| `loyalty.coupon_codes`     | `code` unique per tenant; FK campaign; `contact_id` nullable (generic); `state_code`; `issued_at`, `redeemed_at`, `expires_at`; never deleted; partition candidate at volume |
| `loyalty.coupon_states`    | parametric; `is_system` seeds `issued`, `redeemed`, `expired`, `void`; not tenant-extensible                                                                                 |

## API *(normative)*

| Endpoint                                          | Class      | Permission                                | Budget                                   |
| ------------------------------------------------- | ---------- | ----------------------------------------- | ---------------------------------------- |
| `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` required |
| `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`       | async for large batches                  |

## Events *(normative)*

`loyalty.coupon.issued`, `loyalty.coupon.redeemed`, `loyalty.coupon.expired` — all available as
outgoing webhooks. `issued` is what `messaging` listens to in order to deliver the code.

## Acceptance criteria *(normative)*

1. Generating 100 000 unique codes produces zero collisions and completes within the documented
   budget.
2. Retrying a generation request with the same idempotency key does not create a second batch.
3. Validation returns a distinct typed reason for each invalid case and writes nothing.
4. Redeeming the same code twice concurrently (50 parallel requests) succeeds exactly once.
5. A code bound to member A validated for member B is rejected with `WRONG_MEMBER`.
6. Expiry moves a code to `expired` and emits the event exactly once.
7. **Negative:** no endpoint deletes a coupon code.

## Execution

Synchronous command for validation and redemption; bulk generation is a queued job for batches
above a documented threshold, idempotent via `core.processed_jobs`.

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