# Tessera, the `vouchers` module (plan TE)

Codes that grant something, issued once, spent once: an admission voucher bought online, cards printed for a bank to sell. Tessera
answers only whether a code is genuine, unused, in date and (for a bound code) its holder's; what it unlocks is the product's. It lives
in `app/Modules/Vouchers/`.

| Table | What a row says | Platform or scoped |
|---|---|---|
| `voucher_campaign` (`vcp_…`) | What a product's codes grant (`grants`, the product's words), for one organisation, its serial `prefix` (2 to 8 letters and digits, unique in the organisation), `bearer` or `bound`, an optional price to print, `how_to`, from `starts_at` until `ends_at`, `most_codes`, how many it has `issued`, and whether it was closed early. Owned by the server that opened it and its product. Audited | Platform, naming its organisation |
| `voucher` (`vch_…`, addressed by its serial) | One code: its `number` in the campaign (the serial is `PREFIX-000123`), `lookup` (the keyed hash it's found by, unique over every code ever issued), `last3`, `secret` (the code encrypted, only while it may be given again: `secret_until`), a bound code's `holder_hash`, the product's `issue_ref`, and its redemption (`redeemed_at`, the product's `redeemed_ref`, the server) or voiding (when, why from Input Fields `voucher_void_reason`, by whom). Redeemed and voided can't both be set. Tied to its campaign's organisation by a composite key | Platform, naming its organisation |
| `voucher_batch` (`vbt_…`) | Codes made together for printing: its name, `size`, `first_number` (its numbers reserved when asked), `made` so far, `state` (`making`, `ready`, `failed`), when it was ready, first downloaded and voided, and the server that asked. Tied to its campaign's organisation | Platform, naming its organisation |
| `voucher_line` | Every change, never edited (`log_append_only`), kept for good: `opened`, `issued`, `redeemed`, `voided`, `closed`, `batch_asked`, `batch_made`, `downloaded` (with the format), and `refused` when someone tried to spend a known code and was refused (used, voided, expired, wrong holder; a check writes no line), with the server or Service account that acted | Platform |
| `voucher_attempt` | Every check and redemption of a well-formed code: the server, the organisation it was for, the person's source as a keyed hash, the answer. Kept 30 days (`OldVoucherRecords`) | Platform |

## The code

`Codes::make()`: 12 characters from `Codes::ALPHABET` (31: digits 2 to 9 and letters without I, L or O), by `random_int`, about 59 bits,
and a 13th check character so that the characters weighted 1 to 13 sum to a multiple of 31. Since 31 is prime, any one character typed
wrong and any two swapped fail the check, before anything is looked up. Shown in threes, `K7M-2XQ-4PR-T9W-C`; `Codes::clean()` ignores
case, spaces and dashes. Codes are found by `Codes::lookup()`, an HMAC-SHA256 with `config('vouchers.key')` (`VOUCHER_KEY`, else the
app's key). **That key is set once and never changed:** a new one loses every code issued. Holders and sources are hashed the same way.

A code is shown once, in the answer that issues it. It's kept encrypted for 24 hours (`Vouchers::REISSUE_HOURS`) so the same `issue_ref`
gives it back, then the night's purge empties it (`OldVoucherRecords`). Issuing isn't behind `idempotent`, which would keep the answer,
code and all, in `idempotency_key`.

## The API (`vouchers` scope, `X-Organisation`)

| Call | What it does |
|---|---|
| `POST /v1/vouchers/campaign` | Opens a campaign: `name`, `grants`, `prefix`, `binding`, `ends_at` (in the future), `most_codes` (1 to 1,000,000), optional `starts_at`, `price_minor` with `currency`, `how_to`. 201 with the campaign and its counts |
| `GET /v1/vouchers/campaign/{campaign}` | The campaign, its `state` (`open`, `not_yet`, `ended`, `closed`, worked out, never written) and `counts` (issued, redeemed, voided, unused, left) |
| `POST /v1/vouchers/campaign/{campaign}/close` | Closed early: no more codes, and unspent ones answer `expired` at once |
| `POST /v1/vouchers/campaign/{campaign}/issue` | One code: 201 with `code` (the only time), `serial`, `last3`, `state`, `ends_at`. A bound campaign needs `holder_ref` (the product's own reference, never a name or address); a bearer one refuses it. With `issue_ref`, the same reference within 24 hours gives the same code (200, `Idempotent-Replayed: true`), later a 422. Refused (422 `campaign`) when the campaign has ended or is full, or the organisation is at its monthly limit (`usage_limit` `vouchers`/`code`). Each code is counted (`usage_event` `vouchers`/`code`) |
| `POST /v1/vouchers/check` | What redeeming would say, without spending: always 200, `answer` one of `valid`, `used`, `expired`, `not_yet`, `voided`, `wrong_holder`, `unknown`, `mistyped`; with the serial, campaign, `grants` and `ends_at` for a code that's the caller's |
| `POST /v1/vouchers/redeem` | Spends it: `code`, `source`, `reference` (the product's own, such as an application), and `holder_ref` for a bound code. 200 `redeemed`, and the same again for the same `reference`; otherwise problem+json with `answer`: 409 `used` (with `redeemed_at`, never who or which reference), `expired`, `not_yet`, `voided`, `wrong_holder`; 404 `unknown`; 422 `mistyped` |
| `GET /v1/vouchers/{serial}` | A code by its serial: state, issued, redeemed with the product's reference, voided. Never the code |
| `DELETE /v1/vouchers/{serial}` | Voids it for ever; a redeemed code can't be voided (422 `voucher`) |

| `POST /v1/vouchers/campaign/{campaign}/batch` | A batch for printing: `name`, `size` (1 to 50,000). Its numbers are reserved at once (422 `size` when they don't fit, or the campaign has ended, or the month's limit is reached); a bound campaign's codes are never batched (422 `campaign`). 202 with the batch; `MakeBatch` makes the codes on the `bulk` queue and `vouchers.batch_ready` tells the server that asked |
| `GET /v1/vouchers/batch/{batch}` | The batch: `state` (`making`, `ready`, `failed`, `voided`), `made`, `first_serial`, `last_serial`, `ready_at`, `downloaded_at`, `kept_until` |
| `GET /v1/vouchers/batch/{batch}/file` | `format=pdf` (the default) or `csv`, streamed. 409 while it's being made; 410 once voided or its time is up |
| `DELETE /v1/vouchers/batch/{batch}` | Voids every unspent code in it at once, leaving redeemed ones; one `voided` line for the batch |

A server acts on a campaign of its organisation and its own product (any of the product's servers; an outside client only its own).
Another organisation's or product's code answers `unknown`, exactly as a code that never was.

**Redeeming once** (TE-6): one `UPDATE … WHERE redeemed_at IS NULL AND voided_at IS NULL AND` the campaign is open, so of two redemptions
at one moment one changes the row and the other finds it spent and hears `used`.

**Guessing** (TE-8): the product passes `source`, the person's own address or session as it sees them. A `unknown` or `wrong_holder` is a
miss, counted across all the product's servers; five misses from one source within 15 minutes shut it out for 15 minutes from the last
(429 with `Retry-After`). Past `vouchers.misses_per_hour` (500) misses in an hour for one organisation on one product, a source that has
missed in the hour waits an hour from its last miss, while a source typing a right code still gets in; the first trip each hour is logged.
Each attempt runs under a transaction advisory lock on the product and the source, so misses sent at once are counted one by one. A
`mistyped` code is caught by its check character and kept nowhere. A bound code given the wrong holder says nothing more about the code.
In the instant a campaign ends, the database's clock decides: `expired`, or `not_yet`.

## Batches and printing (TE2)

`Batches::ask` reserves the numbers under the campaign's lock and counts them (`usage_event` `vouchers`/`code`, the batch's size), then
`MakeBatch` makes 500 codes a chunk, each chunk and the batch's `made` in one transaction, so a worker that dies carries on from the last
chunk and never makes a number twice. Each code is kept encrypted to be printed. `Batches::download` streams the cards (`SheetPdf`) or a
CSV (serial, code, campaign, grants, price, valid until; a cell starting with `=`, `+`, `-` or `@` is quoted) and never writes a file. The
product's first download fixes `kept_until` on the batch (`vouchers.batch_keep_days`, 7, a client able to differ); a batch nobody downloads
keeps its codes 30 days; the Service printing from the console starts nothing. Then the night's purge empties them, and only the printed
cards hold them: they still redeem by their hash. A batch with no codes left, voided, or of a campaign that's over answers 410. The month's
limit counts a batch's whole size when it's asked for. `vouchers:check` (every 10 minutes) picks up a batch whose worker died, and a
failed one an hour later, carrying on from its last chunk.

`SheetPdf` writes the PDF by hand, not through dompdf, which holds the whole document in memory: a 50,000-code batch is 5,000 pages. Ten
cards to an A4 sheet in two columns with dashed cut lines: the organisation (its known-as name), the campaign's `grants` and price, the code
in Courier, the serial, the end date, and `how_to` (or a default), in the PDF's built-in fonts, which read Windows-1252. That's why the
price is written with its currency's code (`GHS 150.00`): the fonts have no cedi sign, and any character outside Windows-1252 prints as `?`.

## The console (TE3)

**Vouchers** (`Console\VoucherController`, `operations.view`) lists every campaign as a bar from issued to redeemed; **Find a code** (posted, so a code
never sits in an address) takes a serial or the code itself (by its hash) and opens the code's page: its life from `voucher_line` (its own lines, and its batch's
`batch_made` and `voided`), never its secret. With `workspace.create` the Service voids a code or a batch with a reason (Input Fields
`voucher_void_reason`), closes a campaign early, and prints a batch (behind `fresh`); each change tells the product's server
(`vouchers.changed`: `what` is `code_voided`, `batch_voided` or `campaign_closed`, with the serial or batch and the reason). A product's own
changes through the API raise nothing. Manual: `s20-console` §1.29.

## Reviewed

TE4, 9 November 2026: `reji/docs/SECURITY_REVIEW_TESSERA.md` (redeeming 7, batches and the console 6.5 as found; every finding fixed
with its test in `ReviewTest`, one accepted).

## Faults in Campus's vouchers it doesn't copy

Read 9 November 2026 (plan TE §2): a code checked outside the transaction and updated unconditionally (two redemptions), no throttle and
answers that confirm a code exists, 31 bits behind a public prefix, lower-case codes always refused, codes in plain text in the table,
the link and the logs, nothing to void or expire, and no audit.
