# BUILD_CONTRACT.md — Teleton Rifa foundation

This is the contract the 8 feature agents (A through H) build against. The
foundation (scaffold, schema, lib core, UI primitives, route shells) is complete
and typechecks clean. Read this before writing any feature code.

Stack as scaffolded: Next.js 16.2.6 (App Router, Turbopack), React 19.2.4,
TypeScript 5, Tailwind v4 (`@import "tailwindcss"` + `@theme` in `app/globals.css`),
Drizzle ORM over postgres.js, Resend, Upstash, drand-client, date-fns, zod.

> Note: the brief named "Next.js 15"; `create-next-app@latest` installed Next 16.
> Per the instruction "match whatever version create-next-app produced; do not
> downgrade," the project is on Next 16. App Router APIs used here are stable across 15/16.

---

## 1. Directory tree (current foundation)

```
teleton/
├─ app/
│  ├─ (public)/
│  │  ├─ layout.tsx          # public shell: top-bar lockup + bottom-CTA slot
│  │  └─ page.tsx            # PLACEHOLDER landing — agent A overwrites
│  ├─ admin/
│  │  └─ layout.tsx          # admin shell: requireRole('viewer') + nav
│  ├─ api/                   # route handlers go here (agents C, D, F)
│  │  └─ .gitkeep
│  ├─ favicon.ico
│  ├─ globals.css            # design tokens (CSS vars + Tailwind @theme)
│  └─ layout.tsx             # root: Poppins/Montserrat/Asap fonts, es-PE
├─ components/
│  └─ ui/                    # token-styled accessible primitives (DONE)
│     ├─ Badge.tsx  Button.tsx  Card.tsx  Checkbox.tsx
│     ├─ Countdown.tsx  ImpactStat.tsx  Input.tsx  StripeProgressBar.tsx
│     └─ index.ts           # barrel — import { Button } from "@/components/ui"
├─ lib/
│  ├─ db/
│  │  ├─ index.ts            # Drizzle client (postgres.js, prepare:false singleton)
│  │  └─ schema.ts           # FULL schema (the spine) — DO NOT edit field names
│  ├─ payments/
│  │  └─ provider.ts         # PaymentProvider INTERFACE + getProvider() stub
│  ├─ audit.ts               # writeAudit()
│  ├─ auth.ts                # requireRole(), getSession(), AuthError (stub)
│  ├─ env.ts                 # zod env loader (lazy) + parseAdminAllowlist()
│  ├─ identity.ts            # normalizePhoneE164, normalizeEmail, identityHash
│  ├─ money.ts               # formatPEN + integer-cent helpers
│  └─ types.ts               # shared enums + API DTOs
├─ drizzle.config.ts         # drizzle-kit (DIRECT_URL for migrations)
├─ .env.example              # full env contract
├─ next.config.ts  postcss.config.mjs  eslint.config.mjs  tsconfig.json
├─ package.json              # all deps installed; agents must NOT add deps
└─ (docs/, DESIGN.md, PLAN.md, transcripts — pre-existing, untouched)
```

Directories agents create (do not exist yet): `app/(public)/participar/`,
`components/landing/`, `components/flow/`, `components/admin/`, `lib/email/`,
`lib/draw/`, `lib/db/seed.ts`, `lib/ratelimit.ts`, `lib/reconcile.ts`,
`components/Turnstile.tsx`, `middleware.ts`, `app/admin/draw/`, `app/admin/login/`,
and the API route files below.

---

## 2. PaymentProvider interface (`lib/payments/provider.ts`)

Routes depend ONLY on this interface. Agent C implements `lib/payments/culqi.ts`
and `lib/payments/mercadopago.ts` and wires `getProvider()`.

```ts
interface PaymentProvider {
  readonly name: ProviderName; // "culqi" | "mercadopago"
  createCharge(params: CreateChargeParams): Promise<CreateChargeResult>;
  verifyWebhook(rawBody: string, headers: VerifyWebhookParams["headers"]): Promise<VerifiedWebhook>;
  getCharge(id: string): Promise<ChargeInfo>;
  refund(chargeId: string, amountCents?: Cents): Promise<RefundResult>;
}
```

Key types (full definitions in the file):
- `CreateChargeParams`: `{ amountCents, currency, idempotencyKey, purchaseId, email, method?, description, returnUrl?, metadata? }`
- `CreateChargeResult`: `{ providerChargeId, providerStatus, checkoutToken?, redirectUrl?, publicKey? }`
- `VerifiedWebhook`: `{ signatureValid, providerEventId, eventType, providerChargeId, outcome: "charge_succeeded"|"charge_failed"|"refunded"|"other", amountCents?, payload }`
- `ChargeInfo`: `{ providerChargeId, providerStatus, paid, amountCents, currency, createdAt? }`
- `RefundResult`: `{ providerRefundId, providerChargeId, amountCents, status }`

`getProvider(name)` currently throws "not implemented". Agent C replaces the
body with a real registry. Until then any route calling it fails loudly.

---

## 3. API route contracts to implement

All inputs validated with zod. All money recomputed server-side. The webhook is
the single source of truth for minting tickets. Spanish error messages: no
em-dashes, no emojis, Peruvian register.

### Public (agents C, D)

| Method | Path | Owner | Request | Response |
|---|---|---|---|---|
| POST | `/api/register` | C | `RegisterRequest` | `RegisterResponse` |
| POST | `/api/orders` | C | `CreateOrderRequest` | `CreateOrderResponse` |
| GET | `/api/orders/[id]/status` | D | — (path id) | `OrderStatusResponse` |
| POST | `/api/webhooks/[provider]` | C | raw body (provider) | `WebhookAck` / 200 |
| GET | `/api/cron/reconcile` | D | Bearer `CRON_SECRET` | `ReconcileResponse` |

DTOs live in `lib/types.ts`. Contract notes:
- `POST /api/register`: validate + verify Turnstile token, rate-limit per
  IP/phone/email, upsert `participants` by `identityHash`, store `dataConsentAt`
  + `termsVersion`. Return `participantId` + `basePriceCents` (server-authoritative).
- `POST /api/orders`: COMPUTE `chances` + `amountCents` server-side from
  `raffles.basePriceCents` + selected `pricingTiers` row + optional
  `feeCoverCents`. Generate idempotency UUID, INSERT `purchases` (status=pending,
  `expiresAt`=+30min), call `provider.createCharge`. NEVER trust a client price.
- `GET /api/orders/[id]/status`: UX hint only, never treated as truth.
- `POST /api/webhooks/[provider]`: `provider.verifyWebhook` (reject invalid sig
  with 401 + log), INSERT `webhookEvents (provider, providerEventId)` UNIQUE (dup
  -> 200 no-op), on `charge_succeeded` run the mint TX: SELECT purchase FOR
  UPDATE, if already paid -> no-op, else flip to paid + mint N sequential
  `tickets` + `writeAudit`. Refund event voids tickets in one TX.
- `GET /api/cron/reconcile`: pull provider charges, mint missed (idempotent),
  expire stale pendings.

### Admin + draw (agents E, F) — all behind `requireRole`, rate-limited

| Method | Path | Role | Purpose |
|---|---|---|---|
| GET | `/api/admin/raffles/[id]/entries.csv` | operator | confirmed entries export (logged) |
| GET | `/api/admin/raffles/[id]/revenue.csv` | viewer | per-event/day gross/fees/net/rail-mix |
| GET | `/api/admin/raffles/[id]/winners.csv` | operator | winners export from `winners` |
| GET | `/api/admin/raffles/[id]/verify` | viewer | `DrawVerificationBundle` (re-runnable proof) |
| POST | `/api/admin/raffles/[id]/draw/commit` | draw_officer | publish seed hash + drand round + snapshot hash |
| POST | `/api/admin/raffles/[id]/draw/reveal` | draw_officer | reveal seed, fetch drand, select winners, lock |
| (CRUD) | `/api/admin/raffles/[id]/prizes...` | operator | prize CRUD (name/value/displayStatus/sort) |
| POST | `/api/admin/purchases/[id]/refund` | operator | refund + void tickets (logged) |

Commit-reveal mechanics (architecture.md §4, admin.md §4.1): commit publishes
`serverSeedHash`, `drandRound`, `snapshotHash`, `snapshotTicketCount`; reveal
sets `serverSeed`, `drandRandomness`, `finalSeed = sha256(serverSeed || drandRandomness)`,
then deterministic Fisher-Yates select over the frozen ordered ticket list,
without replacement, one winner per prize. Every phase calls `writeAudit`.

---

## 4. Design tokens (`app/globals.css`)

Tailwind v4 `@theme` exposes these as utilities. Use utilities, never raw hex.

### Colors (DESIGN.md frontmatter)
| Token | Utility | Hex |
|---|---|---|
| primary | `bg-primary` `text-primary` | #dc0d15 |
| primary-soft | `bg-primary-soft` | #e94b52 |
| primary-tint | `bg-primary-tint` | #f28b8e (stripes only) |
| steelblue | `text-steelblue` | #0066b1 |
| powderblue | `text-powderblue` | #4da6d4 |
| ink | `text-ink` | #000000 |
| ink-strong | `text-ink-strong` | #111111 |
| muted-label | `text-muted-label` | #555555 |
| muted | `text-muted` | #666666 |
| on-primary | `text-on-primary` | #ffffff |
| canvas | `bg-canvas` | #ffffff |
| track | `bg-track` | #e0e0e0 |
| hairline | `border-hairline` | #eeeeee |

### Fonts (next/font in `app/layout.tsx`, vars in CSS)
- `font-body` -> Poppins (body/nav/UI). `var(--font-poppins)`
- `font-display` -> Asap 700 (mid display headings). `var(--font-asap)`
- `font-impact` -> Montserrat 700 (big figures, impact counters). `var(--font-montserrat)`

### Type scale (font-size utilities)
`text-display-xl` (72/1.0) · `text-display-lg` (60) · `text-display-md` (47) ·
`text-display-sm` (36) · `text-stat-figure` (40) · `text-heading-lg` (34) ·
`text-heading-md` (24) · `text-heading-sm` (20) · `text-body-lg` (18) ·
`text-body-md` (14) · `text-caption` (13) · `text-micro` (10) · `text-button` (18).

### Radius / spacing / shadow / breakpoints
- Radius: `rounded-sm|md|lg|xl|full` (6/10/12/20/9999). `rounded-xl` is the signature corner.
- Spacing extras: `p-section` (60), `p-xxl` (40), `gap-md` (24), etc. (xs10 sm20 md24 lg30 xl36 xxl40 section60).
- Shadows: `shadow-drop`, `shadow-drop-tight`, `shadow-modal`, `shadow-video-modal`.
- Breakpoints: `xs:417 sm:480 modal:520 md:768 tablet:820 lg:1024 xl:1440`.

### Motion (with `prefers-reduced-motion` guard)
`@keyframes moveStripes` (progress stripes) and `modalIn` (modal scale-in) are
defined. The global `@media (prefers-reduced-motion: reduce)` disables all
animation/transition. Client components ALSO guard in JS (see StripeProgressBar).

---

## 5. UI primitives (`@/components/ui`)

All accessible (labeled, text-not-color errors, 44px targets, focus-visible ring).

| Component | Props (key) | Notes |
|---|---|---|
| `Button` | `variant: "primary"\|"secondary"\|"ghost"`, `size`, `block`, ...button | 44px min, server-safe |
| `Input` | `label` (req), `error`, `hint`, `inputMode`, ...input | label+aria wired |
| `Checkbox` | `label`, `error`, ...input | unticked default (consent) |
| `Card` | `elevation: "flat"\|"drop"\|"tight"`, `as` | white surface |
| `Badge` | `tone: "primary"\|"neutral"\|"blue"` | status by text |
| `ImpactStat` | `figure`, `label`, `align` | SIGNATURE, red Montserrat |
| `Countdown` | `to: Date\|string\|number`, `label`, `onComplete` | "use client", reduced-motion + live region |
| `StripeProgressBar` | `value`, `max`, `label` (req), `caption` | SIGNATURE, "use client", reduced-motion guard, role=progressbar |

---

## 6. Lib helpers (signatures)

**`@/lib/db`** — `db` (Drizzle client, `prepare:false`, singleton), `sql` (raw),
`schema` (namespace). Use `db.transaction(async (tx) => {...})` for the mint TX.

**`@/lib/db/schema`** — all tables + enums + inferred row/insert types
(`Purchase`, `NewPurchase`, ...). Tables: `raffles`, `prizes`, `pricingTiers`,
`participants`, `purchases`, `tickets`, `draws`, `winners`, `webhookEvents`,
`auditLog`. Enums: `raffleStatus`, `purchaseStatus`, `paymentMethod`, `drawStatus`.

**`@/lib/money`** — `formatPEN(cents): string` ("S/5", "S/12.50"),
`solesToCents`, `centsToSoles`, `sumCents`, `multiplyCents`, `clampCents`,
`clampInt`, `isValidCents`. Type `Cents`, const `CURRENCY="PEN"`.

**`@/lib/identity`** — `normalizePhoneE164(raw, defaultCountry="PE"): string`,
`normalizeEmail(raw): string`, `identityHash({phoneE164?, emailNormalized?}, {normalized?}): string`
(sha256, namespaced phone-preferred).

**`@/lib/auth`** — `requireRole(need: AdminRole): Promise<AdminSession>` (throws
`AuthError` 401/403), `getSession(): Promise<AdminSession|null>`,
`roleSatisfies(have, need)`, `ADMIN_SESSION_COOKIE`. Roles:
`viewer<operator<draw_officer<admin`. STUB backing (env allowlist + cookie) —
agent E replaces with real signed-session auth.

**`@/lib/audit`** — `writeAudit({entity, entityId, action, actor, detail?}, tx?): Promise<void>`.
Pass `tx` to participate in the money transaction.

**`@/lib/env`** — `env` (lazy zod-validated proxy; build-safe), `assertEnv()`,
`parseAdminAllowlist()`. Required at runtime: `DATABASE_URL`. All others optional
until their feature uses them.

**`@/lib/payments/provider`** — the `PaymentProvider` interface + `getProvider()`
stub (see §2).

**`@/lib/types`** — enum unions + all API DTOs (see §3).

---

## 7. File-ownership map (disjoint — no two agents write the same file)

| Agent | Owns (writes only here) |
|---|---|
| **A landing** | `app/(public)/page.tsx`, `components/landing/*` |
| **B purchase-flow** | `app/(public)/participar/*`, `components/flow/*` |
| **C payments-backend** | `lib/payments/culqi.ts`, `lib/payments/mercadopago.ts`, `app/api/orders/*`, `app/api/register/*`, `app/api/webhooks/*` (and the `getProvider` registry in `lib/payments/provider.ts`) |
| **D reconcile-email** | `app/api/cron/*`, `app/api/orders/[id]/status/*`, `lib/email/*`, `lib/reconcile.ts` |
| **E admin** | `app/admin/**` EXCEPT `app/admin/draw/**`; `components/admin/*` (incl. `app/admin/login/`) |
| **F draw** | `lib/draw/*`, `app/admin/draw/*`, `app/api/admin/raffles/*` (winners.csv, verify bundle, commit/reveal) |
| **G security** | `lib/ratelimit.ts`, `components/Turnstile.tsx`, `middleware.ts`, security headers in `next.config.ts` |
| **H seed-data** | `lib/db/seed.ts`, drizzle migration generation (`drizzle/`), `scripts/*` |

Shared files agents READ but do not edit: everything in §6 plus `app/globals.css`,
`app/layout.tsx`, `components/ui/*`, `lib/db/schema.ts`, `package.json`. If a
shared file truly must change, coordinate; do not silently fork it.

Edge cases: agent E owns `app/admin/layout.tsx`'s eventual real-auth wiring
(currently a stub); agent C is the only one allowed to edit the `getProvider`
factory in the otherwise-read-only `provider.ts`.

---

## 8. Coding conventions (non-negotiable)

1. **Money is integer cents, currency fixed "PEN".** Never float on money. Use
   `lib/money` helpers. S/5 = 500. Render with `formatPEN` ("S/5", "S/12.50").
2. **The client never sends a price.** `/api/orders` recomputes `chances` +
   `amountCents` from the raffle config + tier row. Ignore any client amount.
3. **Zod-validate every route input** before touching the DB. Normalize phone
   (E.164) and email. Reject malformed input with a Spanish error envelope.
4. **The webhook is the single source of truth** for minting tickets. Browser
   return + status poll are UX hints only. Mint inside the same TX that flips the
   purchase to paid; dedup on `(provider, providerEventId)` and `providerChargeId`.
5. **Append-only audit** on every state transition via `writeAudit` (pass `tx`
   inside money transactions).
6. **Parameterized queries only** (Drizzle). No string-built SQL.
7. **Spanish copy: NO em-dashes** (use periods, commas, colons, parentheses,
   line breaks), **NO emojis**. Peruvian register (tu/eres/quieres), never voseo.
   Normal hyphens in compound words are fine. Applies to all user-facing copy and
   error messages.
8. **Currency renders as "S/" prefix** (e.g. "S/5").
9. **Accessibility is a launch gate (WCAG 2.1 AA):** semantic HTML, labeled
   inputs, text-not-color errors, 44px tap targets, `prefers-reduced-motion`
   guards on ALL motion. Reuse `@/components/ui` rather than reinventing.
10. **No new dependencies.** All required packages are installed. If you think you
    need one, that is a contract change — flag it, do not `npm install`.
11. **Secrets via `env` only** (lib/env.ts). Never read `process.env` directly in
    feature code; add the key to the zod schema if it is missing.
12. **Node runtime for money + crypto routes.** Webhook and draw routes need the
    Node runtime (`export const runtime = "nodejs"`), not Edge.

---

## 9. npm scripts

`dev` · `build` · `start` · `lint` · `typecheck` (tsc --noEmit) ·
`db:generate` (drizzle-kit generate) · `db:push` · `db:seed` (tsx lib/db/seed.ts).

Run `npm run typecheck` before handing off. The foundation passes clean.
