# Teletón Rifa — Local End-to-End Testing Playbook

This is the runnable, offline-first playbook for walking the WHOLE Teletón charity-raffle workflow locally — as both a **buyer** (landing → register → pay → confirmation) and an **operator** (admin → provably-fair draw → verify → CSV exports → reconcile). Every external service is bypassed, stubbed, or run in sandbox: Cloudflare Turnstile and Upstash Redis fail open in development, Culqi is exercised via a self-signed test webhook (no real card traffic), Resend is previewed (no real mail), and the only true external dependency is the public drand beacon over HTTPS (no key, pinned chain hash).

Stack: Next.js 16 App Router · Drizzle ORM + Postgres (postgres.js) · Upstash Redis ratelimit · Culqi (primary) + Mercado Pago (fallback) · Resend + react-email · Cloudflare Turnstile · drand · signed-cookie admin auth.

**Default path in this doc = local Docker Postgres + a crafted, HMAC-signed Culqi webhook.** That combination runs fully offline. The cloud Postgres + real Culqi sandbox + cloudflared tunnel path is documented as the higher-fidelity alternative.

> All paths are absolute. Working directory throughout: `/Users/sebasbimbi/sebastian-bimbi/projects/teleton`.

---

## TL;DR — fastest path to a full run

Copy-paste these in order. End state: a `paid` purchase with a minted ticket, a confirmation screen that flipped to "Listo. Ya estás participando.", and a verifiable mock draw.

```bash
# 1. Local Postgres (offline, repeatable)
docker run -d --name teleton-pg -e POSTGRES_PASSWORD=postgres -e POSTGRES_USER=postgres -e POSTGRES_DB=teleton -p 5432:5432 postgres:16
docker exec teleton-pg pg_isready -U postgres   # wait until "accepting connections"

# 2. Env file — DATABASE_URL is the ONLY required key; CULQI_WEBHOOK_SECRET is the local-mint door
cat > /Users/sebasbimbi/sebastian-bimbi/projects/teleton/.env.local <<'EOF'
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/teleton"
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/teleton"
PAYMENT_PROVIDER="culqi"
CULQI_WEBHOOK_SECRET="local-test-secret"
ADMIN_SESSION_SECRET="local-dev-admin-session-secret-32chars-min"
CRON_SECRET="local-dev-cron-secret"
NEXT_PUBLIC_APP_URL="http://localhost:3000"
EOF

# 3. Schema + seed (creates two raffles, both status=draft)
npm --prefix /Users/sebasbimbi/sebastian-bimbi/projects/teleton install   # skip if node_modules present
npm --prefix /Users/sebasbimbi/sebastian-bimbi/projects/teleton run db:push
npm --prefix /Users/sebasbimbi/sebastian-bimbi/projects/teleton run db:seed

# 4. Open the warm-up raffle (seed leaves both raffles DRAFT; /api/register + /api/orders 409 unless OPEN)
docker exec teleton-pg psql -U postgres -d teleton -c "update raffles set status='open' where slug='warmup-ago-2026';"

# 5. Boot dev (NODE_ENV=development unlocks Turnstile + ratelimit fail-open)
npm --prefix /Users/sebasbimbi/sebastian-bimbi/projects/teleton run dev
# leave running; new terminal for the rest

# 6. Register a participant (Turnstile dev-bypass: any non-empty token works)
curl -sS -X POST http://localhost:3000/api/register -H 'content-type: application/json' \
  -d '{"raffleSlug":"warmup-ago-2026","fullName":"Test Buyer","phone":"987654321","email":"buyer@example.com","dataConsent":true,"marketingConsent":false,"termsVersion":"v1","turnstileToken":"dev-bypass-token"}'
# → {"participantId":"<uuid>","basePriceCents":500,"raffleId":"<uuid>"}  — copy participantId + raffleId

# 7. Create a pending order WITHOUT a payment token (token-first path; no Culqi network call)
curl -sS -X POST http://localhost:3000/api/orders -H 'content-type: application/json' \
  -d '{"raffleId":"<RAFFLE_ID>","participantId":"<PARTICIPANT_ID>","tierId":null,"feeOptIn":false}'
# → {"purchaseId":"<uuid>","chances":1,"amountCents":500,"currency":"PEN"}  — copy purchaseId

# 8. MINT IT — craft a signed Culqi webhook (THE verified primary local-mint method)
SECRET='local-test-secret'; PID='<PURCHASE_ID>'
BODY=$(printf '{"id":"evt_local_001","type":"charge.succeeded","data":{"id":"chrg_local_001","object":"charge","amount":500,"currency_code":"PEN","outcome":{"type":"venta_exitosa","code":"AUT0000"},"metadata":{"purchaseId":"%s"}}}' "$PID")
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
curl -sS -i -X POST http://localhost:3000/api/webhooks/culqi -H 'content-type: application/json' -H "x-culqi-signature: $SIG" --data-raw "$BODY"
# → HTTP 200 {"received":true}

# 9. Confirm the mint + see the screen flip
curl -sS http://localhost:3000/api/orders/<PURCHASE_ID>/status
# → {"status":"paid","chances":1,"amountCents":500,"ticketNumbers":[1]}
open "http://localhost:3000/participar/gracias?purchase=<PURCHASE_ID>"   # flips to "Listo. Ya estás participando." within ~3s

# 10. Operator side — mint an scrypt admin hash, then assemble ADMIN_ALLOWLIST (see Operator section for the full draw)
npx --prefix /Users/sebasbimbi/sebastian-bimbi/projects/teleton tsx -e 'import{hashPassword}from"./lib/auth";console.log(hashPassword("test1234"))'
# paste as: ADMIN_ALLOWLIST="ops@teleton.pe:admin:scrypt$<salt>$<hash>"  — then RESTART npm run dev (env is cached)
```

After step 9 you have proven the buyer path end to end offline. Steps for the full provably-fair draw are in **The operator journey** below.

---

## Prerequisites

### Tooling

- **Node v22.12.0 + npm 11.2.0** (the repo's baseline). `node_modules` is already present in this checkout; on a fresh clone run `npm install`.
- **Docker** (daemon running) for the local Postgres option.
- **psql is NOT installed on the host.** Use the container's bundled psql via `docker exec teleton-pg psql ...`, or the Supabase SQL editor for the cloud option.
- `openssl` (ships with macOS) for signing the webhook.

### The one ready-to-paste `.env.local`

`lib/env.ts` validates lazily and **`DATABASE_URL` is the only strictly-required key** (everything else is `.optional()`). This block has everything for the full buyer + operator run. The Turnstile keys below are Cloudflare's documented **always-pass test keys** — optional (dev fail-open covers you without them), but they give a higher-fidelity widget render if you want it.

```bash
cat > /Users/sebasbimbi/sebastian-bimbi/projects/teleton/.env.local <<'EOF'
# ── Database: local Docker Postgres (offline). Both URLs point at the same local DB. ──
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/teleton"
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/teleton"

# ── Payment provider ──
PAYMENT_PROVIDER="culqi"
# THE local-mint door: any known value. You sign the test webhook with this.
CULQI_WEBHOOK_SECRET="local-test-secret"

# ── Cloudflare Turnstile (OPTIONAL — always-pass test keys for higher fidelity) ──
# Leave both BLANK to use the dev fail-open path instead. If set, use the always-pass pair:
NEXT_PUBLIC_TURNSTILE_SITE_KEY="1x00000000000000000000AA"
TURNSTILE_SECRET_KEY="1x0000000000000000000000000000000AA"

# ── Admin auth (operator leg). ADMIN_ALLOWLIST gets its scrypt hash appended later. ──
ADMIN_SESSION_SECRET="local-dev-admin-session-secret-32chars-min"
# Placeholder until you mint a hash (see operator section). The .env.example value CANNOT log in.
ADMIN_ALLOWLIST="ops@teleton.pe:admin"

# ── Cron protection ──
CRON_SECRET="local-dev-cron-secret"

# ── Public app config (local share links) ──
NEXT_PUBLIC_APP_URL="http://localhost:3000"
EOF
```

**Leave blank (they all fail open / no-op in development):** `CULQI_PUBLIC_KEY`, `CULQI_SECRET_KEY`, `MERCADOPAGO_*`, `RESEND_API_KEY`, `UPSTASH_REDIS_REST_URL`, `UPSTASH_REDIS_REST_TOKEN`, `DRAND_*`, `DRAW_SERVER_SEED`.

> `DRAW_SERVER_SEED` is declared in `lib/env.ts` and `.env.example` but **no code reads it** — `lib/draw/commit.ts` generates its own per-draw `server_seed` via `randomBytes(32)`. Leave it empty; setting it does nothing.

> **Env is cached.** `lib/env.ts` memoizes the parsed env in a module-level `cached` on first access. After editing `.env.local` (e.g. adding `ADMIN_ALLOWLIST`), you **must restart `npm run dev`** or the running server keeps the old values.

> Prefer creating `.env.local` with `cat > ... <<'EOF'` (overwrite) over appending with `>>`. Appending risks a duplicate `ADMIN_ALLOWLIST` line or a missing trailing newline colliding two keys.

### Database bring-up

**Option A — Local Docker Postgres (default, fully offline).** A plain local Postgres is not behind a transaction pooler, so `prepare:false` in `lib/db/index.ts` is simply a no-op (postgres.js skips prepared statements). No caveat to work around.

```bash
docker run -d --name teleton-pg -e POSTGRES_PASSWORD=postgres -e POSTGRES_USER=postgres -e POSTGRES_DB=teleton -p 5432:5432 postgres:16
docker exec teleton-pg pg_isready -U postgres   # → "accepting connections"
```

postgres:16 has `gen_random_uuid()` built in (needed by Drizzle `defaultRandom()` and the hand-mint SQL). On an older Postgres image, run `CREATE EXTENSION IF NOT EXISTS pgcrypto;` before `db:push`.

**Option B — Free cloud Postgres (Supabase / Neon, higher fidelity).** Create a project, then copy the **Transaction pooler** URI (port 6543) into `DATABASE_URL` and the **Direct connection** URI (port 5432) into `DIRECT_URL`. `prepare:false` is REQUIRED for the pooler and is already hardcoded in `lib/db/index.ts` — no action. For Neon, use the `-pooler` host for `DATABASE_URL` and the plain host for `DIRECT_URL`, both with `?sslmode=require`. Run the schema/seed SQL via the Supabase SQL editor where this doc uses `docker exec ... psql`.

**Apply schema + seed (both options):**

```bash
npm --prefix /Users/sebasbimbi/sebastian-bimbi/projects/teleton run db:push   # diffs lib/db/schema.ts → creates all tables/enums/indexes
npm --prefix /Users/sebasbimbi/sebastian-bimbi/projects/teleton run db:seed   # inserts the two raffles
```

> There is **no `db:migrate` script** — only `db:generate` (emits SQL, never touches the DB), `db:push`, and `db:seed`. Use `db:push`: it reads `lib/db/schema.ts` directly (which already includes the `0001_draw_hardening` columns `snapshot_prize_count`, `snapshot_prize_ids`, `drand_signature`), so a fresh push creates the complete current schema. Do NOT attempt a migration-file replay — `drizzle/meta` only has a `0000` snapshot, so replay would miss `0001`.

> If `db:push` / `db:seed` errors that the connection URL is empty, prefix the var inline:
> `DATABASE_URL='postgresql://postgres:postgres@localhost:5432/teleton' DIRECT_URL='postgresql://postgres:postgres@localhost:5432/teleton' npm --prefix /Users/sebasbimbi/sebastian-bimbi/projects/teleton run db:push`

### What the seed creates

`npm run db:seed` runs `tsx lib/db/seed.ts` and prints:

```
Seeding Teleton Rifa database...
  seeded "warmup-ago-2026": 5 prizes, 3 pricing tiers, base 500 cents (S/5).
  seeded "principal-12-sep-2026": 10 prizes, 3 pricing tiers, base 500 cents (S/5).
Seed complete. Two raffles configured (draft).
```

| Item | Value |
|---|---|
| Raffles | `warmup-ago-2026` (5 prizes), `principal-12-sep-2026` (10 prizes) — **both status `draft`** |
| Base price | `base_price_cents = 500` (S/5) |
| Max chances/purchase | `max_chances_per_purchase = 4` |
| Fee opt-in | `fee_opt_in_enabled = true` (adds S/1 = 100 cents) |
| Pricing tiers (each raffle) | `1 chance mas por S/5` (+1, 500), `2 chances mas por S/10` (+2, 1000), `3 chances mas por S/15` (+3, 1500) |
| Confirmed prizes (warmup) | pos 1 `Smart TV 50 pulgadas` (159900), pos 2 `Vale de consumo S/500` (50000); pos 3–5 `Premio por confirmar`, `is_confirmed=false` |
| Confirmed prizes (principal) | pos 1 `Automovil 0 kilometros` (8000000), pos 2 `Moto lineal` (1200000); pos 3–10 `Premio por confirmar` |
| Tickets / purchases | **none** — you mint those (buyer flow or operator hand-mint) |
| Admin user | **none** — admin identity comes only from `ADMIN_ALLOWLIST` in env, never the DB |

The seed is idempotent (upsert by `slug`) and re-replaces prizes/tiers **only while the raffle is still `draft` with no committed draw** — so flip an opened raffle back to draft (or truncate) before re-seeding.

---

## The buyer journey, step by step

Run `npm run dev`, flip a raffle to `open`, then walk it in the browser at `http://localhost:3000`. Each step lists the action, the expected result, and the DB verification.

### 0. Open a raffle (one-time per run)

The buyer flow is dead-on-arrival straight after seeding. `/participar` (`loadActiveRaffle`, `app/(public)/participar/page.tsx`) only matches `status='open'` and has **no draft fallback**; `/api/register` and `/api/orders` return `RAFFLE_CLOSED` (409) for anything not `open`.

```bash
docker exec teleton-pg psql -U postgres -d teleton -c "update raffles set status='open' where slug='principal-12-sep-2026';"
```

(The landing page does NOT need this — `components/landing/data.ts` falls back to a draft raffle so the landing renders either way.)

### 1. Landing — `http://localhost:3000/`

- **Action:** load and scroll the full column.
- **Expected:** sticky top bar with the Teletón wordmark, a "Rifa oficial Teletón" trust header, Hero ("Tu ayuda también te ayuda a ti" + "Quiero participar" CTA + "Desde S/5,00 por una chance" since `base_price_cents=500`), HowItWorks, PrizeGrid (Smart TV / Auto / "Premio por confirmar" placeholders), CauseImpact, BoletosCounter (0 of goal on a fresh DB), FAQ, footer, fixed bottom "Quiero participar" bar.
- **Zero-data degrade (optional):** point `DATABASE_URL` at an unreachable host, restart, reload. The page still renders fully (force-dynamic + `getLandingData` try/catch): Hero shows "Participa desde una chance." (no price), countdown shows "La fecha del sorteo se anuncia pronto.", empty PrizeGrid, counter at 0. Re-point and re-seed before continuing.
- **Verify:** `curl -s http://localhost:3000/ | grep -i 'por una chance'` returns a match with data present.

### 2. Enter the flow — click "Quiero participar"

- **Action:** click the Hero or fixed bottom-bar "Quiero participar".
- **Expected:** navigates to `http://localhost:3000/participar`. Because a raffle is `open`, `PurchaseFlow` renders "Paso 1 de 3 — Inscríbete" with the RegisterStep form. If you instead see "La rifa no está abierta en este momento", the raffle is not `open` (redo step 0).
- **Verify:** `curl -s http://localhost:3000/participar | grep -i 'Inscríbete en la rifa'` matches; absence of "La rifa no está abierta" confirms an open raffle was found.

### 3. Step 1 — RegisterStep

- **Action:** fill Nombre completo ("Test Buyer"), Celular (9 digits, "987654321"), Correo ("test@correo.com"). Tick the two mandatory checkboxes — `dataConsent` ("Autorizo el tratamiento de mis datos") and `termsAccepted` ("He leído y acepto las bases"). Leave marketing optional. Click "Continuar".
- **Expected:** with `NEXT_PUBLIC_TURNSTILE_SITE_KEY` blank in dev, the Turnstile widget reads "Verificación completa." immediately (auto `dev-bypass-token`). The button enables (requires valid fields + `dataConsent` + `termsAccepted` + a token), then shows "Inscribiendo..." and POSTs `/api/register`. On success the flow advances to "Paso 2 de 3 — Tus chances".
- **Verify:** `docker exec teleton-pg psql -U postgres -d teleton -c "select full_name, phone_e164, email_normalized, terms_version from participants order by created_at desc limit 1;"` — phone normalized to `+51987654321`, email lowercased.

### 4. Step 2 — UpsellStep

- **Action:** leave "No, gracias. Sigo con 1 chance." selected, OR pick a tier card. Click "Continuar".
- **Expected:** up to 3 tier radios plus the equal-weight decline option (default-selected). A pinned "Total: N chances por S/X" preview updates live. Advances to "review".

### 5. Step 3 — ReviewStep → "Pagar S/X"

- **Action:** review the summary (Evento, Chances, Nombre, Correo, Celular, Total a pagar), optionally toggle the OFF-by-default "Suma S/1 para cubrir la comisión", select a payment method (Yape pre-selected; Tarjeta / PagoEfectivo available). Click "Pagar S/X".
- **Expected:** "Abriendo el pago..." and a POST `/api/orders` (`createOrder`). The returned **server-computed** amount becomes the authoritative total on the button. Then either a Mercado Pago redirect, or the Culqi hosted popup tries to open.
- **Important local caveat:** with no `CULQI_PUBLIC_KEY`, the in-page Culqi popup **cannot** open — `components/flow/culqi.ts openCulqiCheckout` throws "El sistema de pago no está disponible." So **do not rely on the in-browser popup to reach `/gracias`.** Instead, capture the `purchaseId` from the `/api/orders` response (DevTools → Network, or the `/gracias?purchase=` URL if the flow routed there), then mint via the signed webhook below and open `/gracias` directly. The clean, deterministic local proof of the screen advancing is: **curl the order (or read it from the network tab) → curl the signed webhook → open `/participar/gracias?purchase=<id>`**.
- **Verify:** `docker exec teleton-pg psql -U postgres -d teleton -c "select id, status, chances, amount_cents, fee_cover_cents, idempotency_key, provider_name, provider_status, expires_at from purchases order by created_at desc limit 1;"` — `status='pending'`, `amount_cents` matches server math, `idempotency_key` is a UUID, `provider_name='culqi'`, `provider_status='awaiting_token'`, `expires_at` ~30 min out.

### 6. Confirmation — `/participar/gracias?purchase=<PURCHASE_ID>`

- **Action:** open the gracias URL with the real `purchaseId`.
- **Expected (before mint):** "Estamos confirmando tu pago" — `Confirmation.tsx` polls `GET /api/orders/<id>/status` every 3s (`POLL_INTERVAL_MS=3000`, give-up at 90s).
- **Expected (after you fire the signed webhook):** within ~3s the screen flips to "Listo. Ya estás participando." showing the chance count and "Aporte: S/X" plus the share block.
- **Known UI gap:** the gracias screen does **not** render ticket numbers (`Confirmation.tsx` reads only `status.chances` and `status.amountCents`, never `status.ticketNumbers`). Ticket numbers appear only in the **email** and the status API. This is by design in current code — flag it if on-screen numbers were expected; don't read it as a failed mint.

The actual transition from "confirmando" to "Listo" is driven by the mint, which is the next section.

---

## The critical step: producing a confirmed entry locally

A confirmed, minted entry exists **only after a paid webhook lands** — the webhook mint TX (`mintPaidPurchase` in `app/api/webhooks/[provider]/route.ts`) is the single source of truth. There is **no NODE_ENV bypass** on the signature path: if `CULQI_WEBHOOK_SECRET` is unset, `checkSignature` returns false and the route 401s. **The secret IS the local-test door.**

### Method 1 — Crafted signed Culqi webhook (VERIFIED — the primary recommendation)

This was independently verified byte-for-byte against the code. It exercises the exact same mint path a real production webhook hits, with **zero Culqi traffic** and no keys beyond a self-chosen `CULQI_WEBHOOK_SECRET`.

**Why this works (verified):**
- `route.ts` reads `rawBody = await req.text()` (exact bytes, no re-parse), then `checkSignature` computes `HMAC-SHA256(rawBody, CULQI_WEBHOOK_SECRET)` as lowercase hex and constant-time-compares it to the `x-culqi-signature` header.
- The event NAME alone never mints. `verifyWebhook` downgrades `charge.succeeded` to `other` **unless the charge body proves capture**: `data.outcome.type === 'venta_exitosa'` (`isCulqiPaid`). With a valid outcome in the payload, no `getCharge` network call is made — so no `CULQI_SECRET_KEY` is needed.
- The locator is `metadata.purchaseId` (the token-first order has `provider_charge_id = NULL`, so `extractPurchaseId` reads `metadata.purchaseId`).
- The **amount-integrity guard** refuses to mint if `data.amount !== purchase.amount_cents`. Set `data.amount` to exactly the locked amount (500 for a base-only order; 1000 for +1; 1500 for +2; 2000 for +3; add 100 if `feeOptIn:true`).

**Worked example (base-only S/5 order):**

```bash
SECRET='local-test-secret'           # must equal CULQI_WEBHOOK_SECRET in .env.local
PID='<PURCHASE_ID>'                  # from the /api/orders response

# Build the EXACT bytes once. printf '%s' (no trailing newline) is essential —
# the signed bytes MUST equal the sent bytes. Do NOT use echo (adds \n).
BODY=$(printf '{"id":"evt_local_001","type":"charge.succeeded","data":{"id":"chrg_local_001","object":"charge","amount":500,"currency_code":"PEN","outcome":{"type":"venta_exitosa","code":"AUT0000"},"metadata":{"purchaseId":"%s"}}}' "$PID")

# Sign those exact bytes. openssl -r prints "<hexdigest> *stdin"; cut isolates the lowercase hex.
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)

# Portable fallback if your openssl lacks -r (older / LibreSSL builds):
#   SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')

# Send the IDENTICAL bytes with --data-raw so they match what was signed.
curl -sS -i -X POST http://localhost:3000/api/webhooks/culqi \
  -H 'content-type: application/json' \
  -H "x-culqi-signature: $SIG" \
  --data-raw "$BODY"
```

- **Expected:** `HTTP 200` with body `{"received":true}`. A wrong/absent signature returns `401 INVALID_SIGNATURE` (proves the gate is real, not a bypass).
- **Amount escape hatch:** omit `data.amount` entirely → `confirmedAmountCents` is null and the typeof-number guard is skipped, so the mint proceeds unchecked. Providing the exact amount is the realistic test.

### Method 2 — Admin manual entry (VERIFIED fallback — no webhook at all)

Mints a `paid` purchase + N tickets with `provider_name='manual'`, no webhook, no charge. This path has its **own inline mint** (it does NOT call `mintPaidPurchase`).

First mint an scrypt password hash. The `.env.example` value `ops@teleton.pe:admin` has **no hash** and cannot log in (`verifyPassword` returns false for a null hash). Use the exported helper:

```bash
# Quoting-proof: emit just the scrypt$salt$hash, then hand-assemble ADMIN_ALLOWLIST in your editor.
npx --prefix /Users/sebasbimbi/sebastian-bimbi/projects/teleton tsx -e 'import{hashPassword}from"./lib/auth";console.log(hashPassword("test1234"))'
```

> **Do NOT** use a `node -e "...console.log('ADMIN_ALLOWLIST=...scrypt$'+...')"` one-liner with double quotes — zsh/bash will try to expand `$'...'`, `$s`, `$h` before node sees them and corrupt the line. The `npx tsx` form above sidesteps all of it.

Set `ADMIN_ALLOWLIST` to the full triple (format `email:role:scrypt$salt$hash`) and **restart `npm run dev`** (env is cached):

```bash
# In your editor, set:
# ADMIN_ALLOWLIST="ops@teleton.pe:admin:scrypt$<salt-from-above>$<hash-from-above>"
```

Then:

1. `open http://localhost:3000/admin/login`
2. Log in with `ops@teleton.pe` / `test1234` → redirects to `/admin` (signed `teleton_admin_session` cookie, non-secure on local http).
3. Navigate to `/admin/manual`, pick the raffle, fill Nombre/Teléfono/Correo/Oportunidades=2/Monto=10/Comprobante='YAPE-123' (`proofReference` is required), submit.
4. **Expected:** banner "Entrada manual registrada (2 oportunidades)." A `paid` purchase (`provider_name='manual'`, `provider_status='manual'`) + 2 tickets + an `audit_log` row `action='manual_entry'`, `actor='admin:ops@teleton.pe'`, `detail->>'rail'='manual'`.

`manualEntryAction` requires `requireRole('operator')`; an `admin` account satisfies it (`admin=3 >= operator=1`).

### Higher-fidelity alternative — real Culqi sandbox + cloudflared tunnel

Only needed to exercise the genuine Culqi popup + a real signed webhook end to end. Slowest path.

1. Get Culqi sandbox keys from your CulqiPanel test environment: `CULQI_PUBLIC_KEY=pk_test_...`, `CULQI_SECRET_KEY=sk_test_...`.
2. Expose the dev server: `cloudflared tunnel --url http://localhost:3000` (or `ngrok http 3000`). Culqi cannot POST to `http://localhost:3000`.
3. In the Culqi panel (test environment → Webhooks), register `https://<tunnel-host>/api/webhooks/culqi`, then copy that endpoint's signing secret into `CULQI_WEBHOOK_SECRET`.
4. Buy with Culqi's documented sandbox test card `4111 1111 1111 1111`, any future expiry, CVV `123` (Yape test via the panel). A real sandbox charge then delivers a real signed webhook that mints via the exact same path.

> There are **no test cards baked into this codebase.** Sandbox cards come from Culqi's panel. Verify the signature header NAME Culqi actually sends matches one of the candidates in `extractSignatureHeader` (`x-culqi-signature`, `culqi-signature`, `x-culqi-hmac-sha256`, `x-signature`) — this is panel-config-dependent and only a real delivery confirms it.

---

## Verifying the mint (SQL)

All via `docker exec teleton-pg psql -U postgres -d teleton -c "<SQL>"` (or the Supabase SQL editor for cloud).

**Purchase flipped to paid:**
```sql
select id, status, provider_name, provider_charge_id, paid_at, chances, amount_cents
from purchases where id='<PURCHASE_ID>';
-- status=paid, provider_charge_id='chrg_local_001', paid_at NOT NULL
```

**Exactly N gapless tickets for the purchase:**
```sql
select count(*) as n, min(ticket_no) as lo, max(ticket_no) as hi
from tickets where purchase_id='<PURCHASE_ID>';
-- n=chances, and hi-lo = n-1 (contiguous block), voided=false
```

**Gapless across the whole raffle:**
```sql
select count(*) as total, max(ticket_no)-min(ticket_no)+1 as span
from tickets t join purchases p on p.id=t.purchase_id
where p.raffle_id=(select raffle_id from purchases where id='<PURCHASE_ID>');
-- total should equal span (no gaps)
```

**Webhook recorded + processed:**
```sql
select provider, provider_event_id, event_type, signature_valid, processed_at, received_at
from webhook_events order by received_at desc limit 5;
-- one row, signature_valid=true, processed_at NOT NULL
```

**Audit trail (both rows present, inside the mint TX):**
```sql
select action, actor, detail from audit_log
where entity='purchase' and entity_id='<PURCHASE_ID>' order by id;
-- 'paid' (actor='webhook:culqi') and 'ticket_minted' (detail count=chances)
```

**Status endpoint (what /gracias polls):**
```bash
curl -sS http://localhost:3000/api/orders/<PURCHASE_ID>/status
# {"purchaseId":"...","status":"paid","chances":1,"amountCents":500,"ticketNumbers":[1]}  — Cache-Control: no-store
```

### Idempotency re-fire check

Re-POST the **identical** signed body (same `evt id`):

```bash
curl -sS -i -X POST http://localhost:3000/api/webhooks/culqi -H 'content-type: application/json' -H "x-culqi-signature: $SIG" --data-raw "$BODY"
docker exec teleton-pg psql -U postgres -d teleton -c "select count(*) from tickets where purchase_id='<PURCHASE_ID>';"
```

- **Expected:** still `HTTP 200 {"received":true}`; ticket count **unchanged**. The duplicate hits the `(provider, provider_event_id)` unique constraint (`duplicateEvent=true`) but falls through to the mint, which locks the row `FOR UPDATE` and no-ops because `status='paid'` (`already_paid`). No double-mint.
- **Negative control:** re-send with a tampered/empty `x-culqi-signature` → `HTTP 401 {error.code:'INVALID_SIGNATURE'}` and a `[webhook] invalid signature` server log.
- **No duplicate audit rows:**
  ```sql
  select action, count(*) from audit_log where entity_id='<PURCHASE_ID>' group by action;
  -- 'paid' and 'ticket_minted' each appear exactly once
  ```

**Clean pending rows between runs (optional):**
```bash
docker exec teleton-pg psql -U postgres -d teleton -c "delete from purchases where status='pending';"
```

---

## The operator journey: admin + mock draw

Proves: admin login → provably-fair draw (commit → drand beacon → reveal → winners) on hand-minted manual tickets → independent certificate re-verify → CSV exports → nightly reconcile. The only true external dependency is outbound HTTPS to the public drand relays (no key; quicknet chain hash `52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971` is pinned).

### 1. Admin login

Mint the hash and wire `ADMIN_ALLOWLIST` exactly as in **Method 2** above (role `admin` clears every gate: `admin >= draw_officer >= operator >= viewer`). Ensure `ADMIN_SESSION_SECRET >= 16 chars` or `sessionSecret()` throws and login returns "La sesión no está configurada en el servidor." **Restart `npm run dev`** after editing.

```bash
open http://localhost:3000/admin/login
# log in ops@teleton.pe / test1234 → /admin shows nav + "ops@teleton.pe (administrador)" + "Salir"
```

For curl-based admin calls, log in once in the browser, copy `teleton_admin_session` from DevTools → Application → Cookies, and export it (valid 8h):

```bash
export ADMIN_COOKIE='teleton_admin_session=PASTE_COOKIE_VALUE_HERE'
```

### 2. Hand-mint test tickets on the MANUAL rail

Keep tickets on the manual rail (`provider_name='manual'`, `provider_charge_id=NULL`) so `reconcileNow()` skips the provider entirely and the draw's reconcile gate goes GREEN with zero Culqi config.

```bash
RID=$(docker exec -i teleton-pg psql -U postgres -d teleton -tAc "select id from raffles where slug='warmup-ago-2026'"); echo $RID

docker exec -i teleton-pg psql -U postgres -d teleton <<SQL
WITH p AS (
  INSERT INTO participants (full_name, phone_e164, email_normalized, identity_hash, data_consent_at, terms_version)
  VALUES ('Tester Uno', '+51999000111', 'tester1@example.com', 'hash-tester-1', now(), 'manual-test')
  RETURNING id
), pu AS (
  INSERT INTO purchases (raffle_id, participant_id, status, chances, amount_cents, currency, method, idempotency_key, provider_name, provider_status, paid_at)
  SELECT '$RID', p.id, 'paid', 5, 2500, 'PEN', 'other', gen_random_uuid(), 'manual', 'manual', now() FROM p
  RETURNING id, participant_id
)
INSERT INTO tickets (raffle_id, purchase_id, participant_id, ticket_no)
SELECT '$RID', pu.id, pu.participant_id, g FROM pu, generate_series(1,5) AS g;
SQL
# → INSERT 0 5
```

### 3. Set raffle CLOSED + a near-future drawAt (critical for a local draw)

`commit.ts` refuses unless the raffle is `closed`/`drawn`. And the unriggability guard refuses any drand round that publishes within 60s — the seeded `drawAt` (Aug/Sep 2026) picks a round whose randomness won't exist for months, so reveal could never fetch it locally. Set `draw_at` ~3 minutes out so commit picks a round that publishes shortly.

```bash
docker exec teleton-pg psql -U postgres -d teleton -c "update raffles set status='closed' where slug='warmup-ago-2026';"
docker exec teleton-pg psql -U postgres -d teleton -c "update raffles set draw_at = now() + interval '3 minutes' where slug='warmup-ago-2026';"
```

### 4. Commit — `/admin/draw`

- Click the warm-up raffle → DrawStepper "Paso 1 (pre-vuelo)".
- Fill "Referencia de la resolución MININTER" with any text (e.g. `RES-TEST-001`), tick "El representante del gobierno (veedor DGIN) está presente", click "Verificar reconciliación" → badge "Reconciliación en verde" (manual tickets, no provider calls).
- Click "Paso 2. Comprometer el sorteo". POST `/api/admin/raffles/<id>/draw/commit` returns 201 with `serverSeedHash`, `drandRound`, `snapshotHash`, `snapshotTicketCount=5`. The "Valores comprometidos (públicos)" card shows the 4 values + the expected drand publish time.

### 5. Reveal — wait for the beacon, then sort

- Wait until the displayed drand publish time passes (~3 min).
- Click "Revelar y sortear (drand automático)". POSTs `{source:'relay'}` to `/api/admin/raffles/<id>/draw/reveal`. The route re-runs the GREEN reconcile gate, `fetchVerifiedRound()` pulls the committed round from a pinned relay and verifies the BLS sig, `reveal.ts` derives `final_seed=sha256(server_seed||randomness)` and runs `selectWinners` over the 5 frozen tickets (one winner per prize).
- **Expected:** 200; "Ganadores" table lists one winning ticket per prize (5 prizes, 5 tickets → all 5 win one prize each in selection order). `draws.status` flips `committed → verified`.
- **Air-gapped fallback:** if outbound HTTPS to drand is blocked, reveal returns `503 DRAND_UNAVAILABLE`. Note the `drandRound` shown, open the public drand explorer for quicknet (chain `52db9ba7...`), copy that round's randomness (64-hex) and signature, click "Mostrar la ronda manual", paste both, click "Revelar con la ronda manual". `manualVerifiedRound()` enforces `randomness == sha256(signature)`, so you can't fake it.

### 6. Verify the certificate

- Click "Paso 4. Verificar el sorteo". `GET /api/admin/raffles/<id>/verify` runs the same pure `verifyDraw()` a third party would run.
- **Expected:** "Verificación independiente" card shows "El sorteo es reproducible. Los ganadores recalculados coinciden." with all checks OK.
- **Independent re-verify via curl:**
  ```bash
  curl -sS -H "Cookie: $ADMIN_COOKIE" "http://localhost:3000/api/admin/raffles/$RID/verify" | python3 -m json.tool
  # bundle + verification.matches=true, every check ok:true. The bundle (serverSeed, drandRandomness,
  # drandSignature, snapshotHash, orderedTicketNos, snapshotPrizeCount) reproduces winners offline.
  ```

### 7. CSV exports

```bash
# winners.csv (operator role; masks phone unless ?unmask=1)
curl -sS -H "Cookie: $ADMIN_COOKIE" "http://localhost:3000/api/admin/raffles/$RID/winners.csv" -o /tmp/ganadores.csv; head /tmp/ganadores.csv
# header: posicion_premio,premio,boleto,ganador,contacto,orden_seleccion  — 5 rows, masked phone "+51 9** **11"

# entries.csv (operator role; ?unmask=1 for full PII)
curl -sS -H "Cookie: $ADMIN_COOKIE" "http://localhost:3000/api/admin/raffles/$RID/entries.csv?unmask=1" -o /tmp/entradas.csv; head /tmp/entradas.csv
# header: ticket_no,participante,telefono,email,oportunidades,...  — proveedor='manual', cargo_proveedor empty

# revenue.csv (viewer role; fees ESTIMATED)
curl -sS -H "Cookie: $ADMIN_COOKIE" "http://localhost:3000/api/admin/raffles/$RID/revenue.csv" -o /tmp/recaudacion.csv; cat /tmp/recaudacion.csv
# header: dia_lima,metodo,compras_pagadas,oportunidades,bruto_soles,comision_estimada_soles,...  + TOTAL row (one 'other' rail at 25.00 soles)
```
Each export writes an audit row (`winners_csv_masked` / `entries_csv_unmasked` / `revenue_csv`).

### 8. Nightly reconcile cron

```bash
curl -sS -H "Authorization: Bearer local-dev-cron-secret" "http://localhost:3000/api/cron/reconcile" | python3 -m json.tool
# 200 + {"scanned":<n>,"reconciledPaid":0,"expired":0,"errors":0}  — writes audit action='reconcile_run', actor='system:cron'

# Negative auth checks
curl -sS -o /dev/null -w '%{http_code}\n' "http://localhost:3000/api/cron/reconcile"                  # → 401 (no Bearer)
curl -sS -o /dev/null -w '%{http_code}\n' "http://localhost:3000/api/admin/raffles/$RID/winners.csv"  # → 401 UNAUTHENTICATED (no cookie)
```

### Operator verification

```sql
-- Draw verified
select status, server_seed_hash, drand_round, snapshot_ticket_count, final_seed is not null as revealed from draws;
-- one row, status='verified', revealed=t

-- Winners: one ticket per prize, in selection order
select w.selection_index, t.ticket_no, p.name
from winners w join tickets t on t.id=w.ticket_id join prizes p on p.id=w.prize_id
order by w.selection_index;

-- Audit attribution: every privileged action carries actor='admin:ops@teleton.pe' / system:cron
select entity, action, actor from audit_log order by id;
```

---

## Blockers and bypasses

| Blocker | Severity | Resolution |
|---|---|---|
| Seed leaves both raffles `status='draft'`; `/api/register` + `/api/orders` 409 `RAFFLE_CLOSED`, `/participar` shows "La rifa no está abierta", `commit.ts` refuses non-`closed`/`drawn` | Hard | One SQL flip: `update raffles set status='open' where slug='...'` (buyer) or `='closed'` (draw). Bypasses the admin publish gate (MININTER ref + priced prizes + bases URL) which is heavy locally. |
| Webhook signature is verified with **no NODE_ENV bypass**; unset `CULQI_WEBHOOK_SECRET` → 401 | Hard | Set `CULQI_WEBHOOK_SECRET` to a known value and sign the body yourself: `printf '%s' "$BODY" \| openssl dgst -sha256 -hmac "$SECRET" -r \| cut -d' ' -f1` (fallback `... \| sed 's/^.*= //'`). Send identical bytes with `--data-raw`. |
| Event name alone never mints — `verifyWebhook` downgrades to `other` without proof of capture, falling to a `getCharge` call that needs a real `CULQI_SECRET_KEY` | Hard | Put `data.outcome.type='venta_exitosa'` in the payload so `isCulqiPaid` is true and no network call happens. |
| Amount-integrity guard refuses mint if `data.amount != purchase.amount_cents` | Hard | Set `data.amount` to the exact locked amount (base 500; +1=1000; +2=1500; +3=2000; +100 if fee). Or omit `data.amount` to skip the guard. |
| Culqi hosted popup can't open locally (no `CULQI_PUBLIC_KEY`) — `openCulqiCheckout` throws | Soft | Don't use the popup. Create the order without `paymentToken` (returns `awaiting_token`, no network), mint via the signed webhook, open `/gracias?purchase=<id>` directly. |
| Cloudflare Turnstile bot gate on `/api/register` | Soft | Dev fail-open: leave `TURNSTILE_SECRET_KEY` unset + `NODE_ENV!=production` → `verifyTurnstile` returns true; client auto-emits `dev-bypass-token` when `NEXT_PUBLIC_TURNSTILE_SITE_KEY` is unset. Any non-empty token passes. Higher fidelity: always-pass keys `1x00000000000000000000AA` / `1x0000000000000000000000000000000AA`. (Empty string is still rejected by zod → 400.) |
| Upstash Redis rate limiting (register 20/10m IP, order 5/10m IP, etc.) | Soft | Leave `UPSTASH_REDIS_REST_URL/TOKEN` unset → `checkRateLimit` fails open `{ok:true}`. Re-run tests infinitely. |
| Confirmation email needs `RESEND_API_KEY` | Soft | Not needed — `sendConfirmationEmail` is fired `void` and wrapped in try/catch; mint succeeds regardless (you'll see `console.error('[email] ...RESEND_API_KEY is not set')`). Preview offline: `npx --prefix /Users/sebasbimbi/sebastian-bimbi/projects/teleton email dev -d lib/email -p 3001`. To send in sandbox: `RESEND_API_KEY=<test key>` + `RESEND_FROM_EMAIL='onboarding@resend.dev'` to your own verified address. |
| drand beacon required for reveal (outbound HTTPS to `api.drand.sh` / `drand.cloudflare.com`) | Soft | Allow outbound HTTPS (no key; chain hash pinned). Or use the manual-round paste fallback in the stepper (`randomness == sha256(signature)` enforced). |
| `.env.example` `ADMIN_ALLOWLIST` (`ops@teleton.pe:admin`) cannot log in (no scrypt hash) | Soft / Hard for admin leg | Mint a hash: `npx --prefix .../teleton tsx -e 'import{hashPassword}from"./lib/auth";console.log(hashPassword("test1234"))'`, set `ADMIN_ALLOWLIST="email:role:scrypt$salt$hash"`. A single 3-segment line satisfies both `parseAdminCredentials` (login) and `parseAdminAllowlist` (session role re-check, which only reads the first two segments). |
| `ADMIN_SESSION_SECRET` < 16 chars → `sessionSecret()` throws, login fails | Hard for admin leg | Set `ADMIN_SESSION_SECRET` to 16+ chars. Cookie is non-secure on local http (`secure` only in production), so the browser stores it. |
| Env is cached after first access — edits to `.env.local` don't reach a running server | Soft | **Restart `npm run dev`** after editing `ADMIN_ALLOWLIST` / `ADMIN_SESSION_SECRET` / any key. |
| `db:push` is the only apply path (`drizzle/meta` has only a `0000` snapshot; no `db:migrate`) | Soft | Always use `npm run db:push` (reads `lib/db/schema.ts` directly, includes the `0001` columns). Never replay migration files. Don't run `db:generate` for a fresh DB. |
| psql not installed on host | Soft | `docker exec teleton-pg psql -U postgres -d teleton -c "<SQL>"`, or Supabase SQL editor for cloud. |
| `CRON_SECRET` gate fails closed | Soft (by design) | Pass `Authorization: Bearer <CRON_SECRET>`. No header → 401. |
| `middleware.ts` sets CSP/security headers on all routes | Soft (no action) | Does no auth/rate-limit/blocking. `/api/*` gets a minimal CSP; document CSP drops `upgrade-insecure-requests` in dev so `http://localhost` works. Does not affect curl or JSON. |

---

## Adding automated tests

No test framework is wired today (`package.json` scripts are dev/build/lint/typecheck/db:*). Add exactly **two layers** — Vitest for the pure money/draw invariants and ONE Playwright buyer happy-path. Do NOT build a full pyramid against the hard Sept 12, 2026 deadline (no component tests, no visual regression, no MP-fallback E2E, no multi-browser unit CI).

### Install

```bash
npm --prefix /Users/sebasbimbi/sebastian-bimbi/projects/teleton i -D vitest@^3 @vitest/coverage-v8@^3 vite-tsconfig-paths
npm --prefix /Users/sebasbimbi/sebastian-bimbi/projects/teleton i -D @playwright/test@^1
npx --prefix /Users/sebasbimbi/sebastian-bimbi/projects/teleton playwright install --with-deps chromium
```

### Scripts (add to `package.json`)

```json
"scripts": {
  "test": "vitest run",
  "test:watch": "vitest",
  "test:cov": "vitest run --coverage",
  "test:e2e": "playwright test",
  "test:e2e:ui": "playwright test --ui"
}
```

### `vitest.config.ts`

```ts
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
  plugins: [tsconfigPaths()],
  test: { environment: 'node', include: ['tests/unit/**/*.test.ts', 'lib/**/*.test.ts'] },
});
```

### `playwright.config.ts`

```ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
  testDir: 'tests/e2e',
  use: { baseURL: 'http://localhost:3000', trace: 'on-first-retry' },
  projects: [{ name: 'mobile-chromium', use: { ...devices['Pixel 7'] } }],
  webServer: { command: 'npm run dev', url: 'http://localhost:3000', reuseExistingServer: true, timeout: 120000 },
});
```
(Pixel 7 viewport — this is a mobile-first social-traffic app.)

### First unit test to write (highest value): draw verifiability + money + the mint DECISIONS

These are pure (`node:crypto` only, no DB, no network) and cover the catastrophic-but-invisible failure modes.

- **`tests/unit/draw-verify.test.ts`** (`@/lib/draw/select`, `@/lib/draw/verify`):
  - **Determinism:** `selectWinners(pool, finalSeed, k)` twice → byte-identical.
  - **Reproducibility:** `finalSeed=computeFinalSeed(serverSeed, drandRandomness)`, run `selectWinners`, feed the recorded winners + `serverSeedHash=sha256(serverSeed)` + correct `snapshotHash` + a `drandSignature` whose `sha256 == drandRandomness` into `verifyDraw` → `matches===true`, all `checks ok:true`.
  - **Tamper detection:** flip one ticket in `orderedTicketNos` → snapshot-hash check fails; wrong `serverSeed` → seed-hash check fails; omit `drandSignature` → beacon-binding check `ok:false`.
  - **Without-replacement:** `prizeCount===pool.length` → winners distinct + a permutation; `prizeCount>pool.length` → throws.
- **`tests/unit/money.test.ts`** (`@/lib/money`): `formatPEN(500)==='S/5'`, `formatPEN(1250)==='S/12.50'`, `formatPEN(1200)==='S/12'`, `formatPEN(0)==='S/0'`, `formatPEN(-500)==='-S/5'`; `solesToCents(12.5)===1250`, `solesToCents(12.555)===1256`; `sumCents`/`multiplyCents`/`isValidCents` reject floats/negatives/non-integers.
- **Amount-integrity + idempotency DECISIONS** (the pure branches extracted from `mintPaidPurchase`): confirmed amount `600` vs locked `500` → no mint, `reason==='amount_mismatch'`; `500===500` → mint proceeds; `amountCents` null/undefined → mint proceeds (MP backstop); `status==='paid'` → `{minted:false, reason:'already_paid'}`, zero tickets; `'refunded'/'expired'` → `not_pending`; ticket numbers `[startNo+1 .. startNo+chances]` contiguous.

The lock/concurrency proof (`FOR UPDATE` no-op-if-paid) needs a real Postgres TX — add ONE DB-backed integration test under `tests/integration/` (run against the local Docker PG after `db:push`): insert a pending purchase, call `mintPaidPurchase` twice concurrently with `Promise.all`, assert exactly `chances` tickets, contiguous `ticket_no`, second call `{minted:false, reason:'already_paid'}`.

### Starter Playwright happy-path (`tests/e2e/buyer-journey.spec.ts`)

Walk landing → participar → register → upsell → review, then **inject the payment-confirmed step via the verified non-UI mint** (the spec cannot click the cross-origin Culqi popup). Outline:

1. `page.goto('/')` → assert hero + a CTA link to `/participar`.
2. Click `getByRole('link',{name:/particip/i})` → URL contains `/participar`; assert h1 "Inscríbete en la rifa".
3. Fill `getByLabel('Nombre completo')`, `getByLabel('Celular')`='987654321', `getByLabel('Correo')`=unique; check `dataConsent` + `termsAccepted` (Turnstile auto-passes in dev); wait for `getByRole('button',{name:'Continuar'})` enabled; click.
4. Upsell: select a tier or continue with base → Continuar.
5. Review: `const orders = page.waitForResponse('**/api/orders')` **before** clicking pay; click pay; do NOT touch the popup. `const { purchaseId, amountCents } = await (await orders).json();`
6. In-spec, build + sign the Culqi webhook with a Node `crypto` helper (`createHmac('sha256', process.env.CULQI_WEBHOOK_SECRET).update(rawBody).digest('hex')`), payload `{ id:'evt_test_1', type:'charge.succeeded', data:{ id:'chr_test_1', amount:amountCents, currency_code:'PEN', outcome:{type:'venta_exitosa'}, metadata:{purchaseId} } }`; `request.post('/api/webhooks/culqi', { headers:{'x-culqi-signature':sig}, data: rawBody })` → assert 200 `{received:true}`.
7. `page.goto('/participar/gracias?purchase='+purchaseId)` → assert it transitions to the confirmed state showing the chances count + "Aporte: S/X". (Ticket numbers are intentionally absent from this screen — assert them via the status API / DB instead.)
8. Optional DB assert: `purchase.status==='paid'` and ticket count === chances.

### CI

Two jobs. **`unit`** on every push: `npm ci && npm test` (no services, <30s; gate merge). **`e2e`** on PRs to main: Postgres service container + `npm ci` + `db:push` + `db:seed` + the blank-secret env (only a throwaway `CULQI_WEBHOOK_SECRET` + `ADMIN_SESSION_SECRET`) + `npm run test:e2e` (advisory until selectors stabilize, then gate).

---

## Manual-only checklist

Automation provably cannot cover these. Per `PLAN.md` they are launch gates.

- **[A] IG + TikTok in-app webview round trip** (Phase 2 exit gate + R6 — top conversion threat). Deploy to a real first-party HTTPS origin (cloudflared tunnel or Vercel preview — NOT localhost; the Culqi popup + cookies need HTTPS + a real domain). Post the link in an Instagram DM and a TikTok message, open it inside each app's in-app browser on a real phone, complete a REAL Culqi **sandbox** charge, confirm the buyer returns to `/gracias` AND the entry mints (admin + email). Exercise the `OpenInBrowserHint` escape hatch.
- **[B] Mobile screen-reader run** (Phase 4 exit + R11). VoiceOver (iOS Safari) + TalkBack (Android Chrome): walk register → upsell → review → pay → gracias. Confirm each step's heading is announced on focus move, consent checkboxes are labeled and unticked, the disabled-CTA reason (`RegisterStep disabledReason`) is read as TEXT, and the Turnstile `aria-live` status announces success/expiry.
- **[C] Real Culqi sandbox card/Yape end to end.** Test keys (`sk_test_`/`pk_test_`): `createCharge` → real webhook delivery from Culqi → mint → confirmation email via Resend. Verify the signature header NAME Culqi actually sends matches one of `extractSignatureHeader`'s candidates (panel-config-dependent; only a real delivery confirms it). Refund the test charge and confirm `voidRefundedPurchase` voids the tickets.
- **[D] Load test to the influencer peak** (Phase 4 + R9). k6 or autocannon against deployed staging `/api/register` + `/api/orders` at estimated peak RPS through the transaction pooler; watch for connection exhaustion and 429 behavior. The instrumented August warm-up raffle is the real rehearsal.
