# TE · Tessera, vouchers (build plan step 6)

**Status:** TE-1 to TE-14 decided under the Architect's autonomy grant, 9 November 2026. Mockups (`reji/ui/html/te/`: campaign, code,
voucher, enter) drawn and checked in light, dark and on a phone. TE1 to TE4 built and reviewed, 9 November 2026 (`SECURITY_REVIEW_TESSERA.md`).

---

## 0 · The one-paragraph version

Tessera is the ticket office: a product opens a campaign ("Jaeda admissions 2027: 500 codes, valid until 31 March, one application"),
asks for codes from it, one at a time for a parent who paid online or a batch printed for a bank, and later redeems one when a parent types
it in. Tessera guarantees the code is genuine, unused and in date, and that no two codes anywhere are the same; the product decides what
it unlocks. Codes are 12 characters a parent can read aloud, grouped in threes, with a check character that catches a typing slip before
it reaches the server; they're kept hashed, never in plain text, so a copy of the database spends nothing. Redemption is one atomic step,
so two people entering the same code can't both win, and a source that guesses is shut out after a few misses.

**What it is.** Codes that grant something, issued once, spent once.
**Scope.** In Atrium: campaigns, issuing codes (one, or a batch in the background), printing a batch, checking and redeeming a code once,
voiding, the audit of every change, guessing kept out, the console's view. Staying in the products: what a code unlocks, selling it
(Paystack stays in Campus), sending it (through Nuntius), and the portal where a parent types it.
Out of TE: moving Campus's own vouchers (its own plan, on the Architect's say-so), and GWCL's campaign (when GWCL is signed).
**Failsafe.** Atrium down: a code can't be redeemed (the spec's strictest uptime, with accounts), so the product's portal says to try
again shortly and keeps what the parent typed; nothing is lost, since a code is only spent when Tessera says so. A printed batch already
downloaded keeps selling. A code sent by mistake or a batch lost in transit: voided at once.

---

## 1 · What the apps already do

Read 9 November 2026 (`tutecampus`, nothing changed there). **Only Campus has vouchers**, one flow: an applicant buys an admission
voucher online, gets a code by e-mail and text, and redeems it once to open an application. There are no batches, campaigns, serials,
vendors, printed cards or PDFs, and no page where staff see or void a voucher. Desk and Loop have none; GWCL appears nowhere.

- **The table** `admission_vouchers`: the code (unique across every school), the applicant's name it's bound to, the price, the buyer,
  sold, redeemed and expiry times, a status (pending, issued, redeemed, expired, cancelled: nothing ever sets the last two). The price
  comes from the school's profile with a coded fallback; expiry is coded at six months (`PurchaseVoucher.php:79`).
- **The code** `{SCHOOL}-V-{YY}-{6 characters}` (`PurchaseVoucher.php:116-125`): the prefix and year are public, the six characters
  carry about 31 bits, with look-alikes (0/O, 1/I) and no check character.
- **Selling**: a public page, paid through the routed gateway; the settled payment issues the voucher (`SettlePayment.php:79-91`), sent
  through the `voucher_issued` template, and shown for 15 minutes on a signed return page.
- **Redeeming** (`PublicController.php:189-261`): signed in, the code looked up in the school, `isRedeemable`, the name compared, the
  intake still open, then a draft application and the voucher marked redeemed in one transaction.
- **Worth keeping, as ideas:** a voucher becomes real only when its payment settles; the buyer isn't always the holder; delivery through a
  configured template. What a code unlocks (the intake, the application) is Campus's, and stays there (TE-1).

## 2 · Faults found, not to copy

Each checked in Campus's code, 9 November 2026.

1. **A voucher can be issued without paying.** `stubSettle` (`PaymentController.php:92-96`) checks only that a payment is pending, not
   that it's the demo gateway's or the caller's, and its route is live for anyone signed in (`routes/web.php:2456-2460`); payment ids are
   sequential. The same reaches offers and invoices. *Not Tessera's to fix* (Campus's payments); reported to the Architect. Tessera
   never takes a payment: the product issues a code once it's been paid (TE-9).
2. **One voucher, two redemptions.** The check and the update are separate, with no lock and no condition (`PublicController.php:206`,
   `:250`), and `applications.voucher_id` isn't unique → TE-6, one conditional update.
3. **Guessing.** No throttle on redeeming, about 31 bits behind a public prefix, and distinct answers that confirm a code exists before
   the name is asked → TE-3 (59 bits, no prefix), TE-8.
4. **Lower-case codes always fail**: upper-cased only by the page's style, looked up exactly (`Redeem.vue:47`, `PublicController.php:199`)
   → TE-3, case and dashes ignored.
5. **The code in plain text** in the table, in the e-mailed link's query string, the text log and the e-mail log → TE-5; a product sends
   a code through Nuntius with the code as a template's secret value, so the log keeps it masked.
6. **No lifecycle:** nothing expires, cancels or voids a voucher; abandoned pending ones stay for ever → TE-11, TE-12.
7. **No audit**, only timestamps → TE-13.

## 3 · Decisions

**Decided under the Architect's autonomy grant, 9 November 2026.**

| # | Decision | Chosen | Why, and the alternative |
|---|---|---|---|
| **TE-1** | Who opens a campaign | A product's server with the `vouchers` scope, for the organisation it names (`POST /v1/vouchers/campaign`): a name, what it grants (the product's words, with an optional price to print), when it's valid, the most codes it may hold, and whether its codes are bearer or bound | The spec: the product decides what a code unlocks; Tessera only that it's genuine, unused and in date |
| **TE-2** | Bearer or bound | Per campaign. **Bearer**: whoever holds the code may spend it (printed cards sold at a bank). **Bound**: issued to a holder the product names (its own reference, kept hashed; never a name), and spent only with that reference (an applicant's own code bought online) | The spec's two scenarios differ in kind; one engine serves both |
| **TE-3** | The code | 12 characters from 31 (letters and digits with no 0, O, 1, I or L), random from the operating system, read in groups of three with a 13th check character at the end (`K7M-2XQ-4PR-T9W-C`): the characters weighted 1 to 13 sum to a multiple of 31, which, 31 being prime, catches any single slip and any swap of two before a request is made. Case and dashes don't matter when typed. About 59 bits: guessing one in a lifetime of tries is hopeless | The spec's readable codes. A shorter code needs the lock-out to carry all the weight |
| **TE-4** | One uniqueness | One unique index over every code ever issued, every product and organisation; a code is never reissued | The spec's reason for sharing Tessera at all |
| **TE-5** | Keeping codes | Never in plain text: a keyed hash (HMAC with the app's key) for lookup, the last three characters for people to recognise it, and a **serial** printed beside it (`JIS27-000123`) for support to find a code without its secret. A printed batch's codes are kept encrypted only until downloaded, then 7 days, then wiped | A database copy, a log or a support screen must never spend a code |
| **TE-6** | Redeeming | `POST /v1/vouchers/redeem`: one conditional update, so of two simultaneous attempts one wins and the other hears "already used". Answers: redeemed (with the campaign and what it grants), used (when, never by whom), expired, voided, not ours (another organisation's or product's code, said as "not found"), wrong holder, mistyped (the check character, refused before any lookup). Idempotent for the same caller | The spec's two people at one moment; Campus's isn't atomic |
| **TE-7** | Checking first | `POST /v1/vouchers/check` says what redeeming would, without spending, so a portal can say "valid, one application" before the form is sent | A portal shouldn't spend a code to show its worth |
| **TE-8** | Guessing | The product passes the parent's source (their address, or a session reference); a well-formed code that doesn't exist counts as a miss: 5 misses in 15 minutes from one source shut it out for 15 minutes (429), with a ceiling per server an hour; every attempt is kept 30 days with its source hashed. Mistypes (the check character) don't count | The spec's script trying thousands of codes. Atrium sees the product's server, not the parent, so the product names the source |
| **TE-9** | Issuing | One code at once (`POST …/campaign/{id}/issue`, in the answer, for a parent who just paid; the same `issue_ref` gives the same code back for a day), or a batch of up to 50,000 in the background (`POST …/batch`), the product told by webhook when ready and fetching the codes as a file, or Atrium's print-ready PDF | The spec: batches in the background, codes returned for printing |
| **TE-10** | Printing | Atrium makes the printable file: cards ten to an A4 sheet with the organisation's name, the campaign's wording, what it grants, the price if any, the serial, the code and how to use it, cut along dashed lines (a print house can add a scratch-off panel); and a CSV for a print house. Downloaded at most from the console or by the product's server | GWCL's and a school's wording differ: it's the campaign's, not code |
| **TE-11** | Voiding | A code or a whole batch, by the product (`DELETE`) or the Service on the console with a reason (Input Fields `voucher_void_reason`); a voided code is refused for ever | A batch lost in transit must die at once |
| **TE-12** | Expiry | A campaign's end date ends its unspent codes; a campaign can also be closed early. Expired is worked out, never written | Writing expiry needs a job that can fail |
| **TE-13** | The audit | Every state change of a code is a line, never edited: issued, redeemed, voided (by whom, why); attempts kept apart, 30 days | The spec: "every state change is audited" |
| **TE-14** | What the console shows | Campaigns (issued, redeemed, voided, left, redemptions a day), batches (generating, ready, downloaded), one code's history by serial or code, voiding with a reason | The spec's mockups |

## 4 · Mockups (`reji/ui/html/te/`)

- **`campaign.html`** in the console: a campaign in progress (a gauge of issued, redeemed, voided, left, and redemptions a day), its
  batches (generating, ready to print, downloaded), a campaign that has ended, and a new one as the product made it.
- **`code.html`** in the console: one code's history found by its serial, redeemed or voided, and voiding one with a reason.
- **`voucher.html`**: the printed card, ten to a sheet.
- **`enter.html`**: the words a product's portal shows from Tessera's answers (valid, used, expired, mistyped, blocked), on a phone.

## 5 · Slices

| Slice | What | Done when |
|---|---|---|
| **TE0** ☑ | Mockups | Drawn and checked in light, dark and on a phone |
| **TE1** ☑ | Campaigns, issuing one code, checking and redeeming once, guessing kept out, the audit | Two redemptions at one moment, one wins, by test; a bound code refused for the wrong holder; a source shut out after five misses |
| **TE2** ☑ | Batches in the background: the file, the printed PDF, the codes wiped after download | A 10,000-code batch made and printed by test |
| **TE3** ☑ | The console: campaigns, a code's history, voiding | Each by test and in the app |
| **TE4** ☑ | A fresh reader's review of the code paths | Findings fixed with tests |

## 6 · Open items for the Architect

- GWCL's wording and campaign, when GWCL is signed.
- Whether Campus moves its vouchers to Tessera, and when (its own plan).

### 6.1 · What building added

- **TE1 (9 November 2026).** Luhn mod N works only for an even alphabet; with 31 characters two values double alike and some slips
  pass. The check is a weighted sum instead (weights 1 to 13, mod 31, prime), which catches every single slip and every swap of two.
  Issuing isn't behind the shared `idempotent` middleware, which keeps the answer and so would keep the code in plain text: `issue_ref`
  gives the same code back for 24 hours from the encrypted copy instead. A bound code keeps no display name, only the product's reference,
  hashed. The campaign doesn't copy its server's product (normal form); a code is tied to its campaign's organisation by a composite key.
  Refusals of a known code are lines on its history (`refused`), so support sees "refused at the portal". `VOUCHER_KEY` is set once.
- **TE2 (9 November 2026).** The cards are a PDF written by hand (`SheetPdf`), not dompdf, which holds the whole document in memory: 5,000
  pages for the largest batch. Built-in fonts mean Windows-1252, so the price prints with its currency's code (`GHS 150.00`). Files are
  streamed on download and never stored, so the codes sit nowhere but the encrypted column until the week is up. A batch nobody downloads
  keeps its codes 30 days. A bound campaign's codes are never batched: they name a holder. The numbers are reserved when the batch is
  asked, so a single code issued meanwhile takes the next number after the batch.
- **TE3 (9 November 2026).** The campaign list wasn't drawn; it follows `tl/links.html`'s list, a row each with the campaign's bar. The
  Service's changes tell the product in one event, `vouchers.changed` (code voided, batch voided, campaign closed), so the product's records
  agree. Printing a batch from the console asks for a fresh proof, since the file is the codes themselves. A serial is unique within an
  organisation; typed on the console, where two organisations share a prefix, the oldest campaign's code opens.
- **TE4 (9 November 2026).** Two fresh reviewers: redeeming 7, batches and the console 6.5, as found. Serious: a batch counted against
  the month's limit only as its codes were made, and a code typed into the console's search went into the page address. Both fixed, with
  every lesser finding but one: a prefix stays unique across one organisation's products, so a serial names one code for its support. The
  hourly ceiling is now one organisation's on one product, and stops only sources that have missed. `kept_until` lives on the batch.

