# Teleton Rifa: Next.js Solution Architecture and Data Model

## Summary
This is the technical architecture for the Teleton Peru paid charity raffle, grounded in the requirements, payments, and legal research already completed. Recommended stack: Next.js App Router on Vercel, Supabase Postgres accessed through the Supavisor transaction-mode pooler (port 6543) to survive serverless-to-Postgres connection limits during influencer-driven spikes, Drizzle ORM, Resend for email, and Upstash rate limiting plus Cloudflare Turnstile for abuse control. Payments go through Culqi as primary (native Yape, same-day BCP settlement, S/0 monthly) with Mercado Pago behind a thin provider adapter as fallback, and the webhook is the single source of truth for issuing tickets, made idempotent on the provider event id and the charge id so a duplicate webhook or a retried request can never double-issue tickets or double-charge. The draw is made auditable with a commit-reveal scheme bound to a future drand randomness beacon round, producing a deterministic, reproducible, third-party-verifiable winner selection with a full append-only audit log and CSV export. The data model supports both the returning-buyer recognition fork (participant keyed by a normalized phone or email hash, entries linked) and the flat-price fallback in the same schema, so the warm-up ships Fork B and September can turn on Fork A with no migration.

## Body
## 0. How this document is grounded

This is the build-side architecture. It consumes three research briefs already produced (requirements, Peru payments, Peru legal) and does not re-derive them. Where I assert a technical fact not stated in those briefs or the transcripts, I mark it `[inferred]`. Two facts I confirmed live this session and treat as established:

- Teleton's public site (teleton.pe) already collects money through **Yape, BCP transfer, Izipay, and PayPal**, is audited by PwC, and runs on a flat custom HTML structure (no visible CMS/framework). RUC **20523760689** is public on the site. This directly informs the payment-provider and embedding recommendations below.
- Supabase's **Supavisor transaction-mode pooler on port 6543** is the documented fix for serverless connection exhaustion (it does not support prepared statements, which dictates an ORM/driver flag). This is the spike-survival keystone.

Everything below is constrained by the hard rules: real money, real personal data (Ley 29733), a national charity's reputation, an auditable draw, WCAG 2.1 AA, Peruvian Spanish copy, soles as `S/`.

A non-negotiable framing from the legal brief that shapes the build sequence: the launch long-pole is **not** the software. It is the MININTER/DGIN authorization chain (classify, lock prizes, file Formulario 6 under silencio negativo, secure the DGIN veedor, publish matching bases, freeze pricing). The architecture is designed so the app is ready and frozen well before that chain clears, and so the draw mechanism satisfies the DGIN-veedor and INDECOPI-defensibility requirements.

---

## 1. Stack recommendation

| Layer | Choice | Why this one |
|---|---|---|
| Framework | **Next.js 15 App Router** | Mandated. App Router gives static-by-default RSC for the read path (landing, prizes) and Route Handlers for the thin write path (register, create-order, webhook). Server Actions are deliberately NOT used for money-moving steps (see 1.2). |
| Hosting | **Vercel** | Zero-config Next.js, global CDN/edge cache for the static landing, automatic HTTPS on the webhook route, instant rollback, preview deploys for the comms-team review gate. A standalone deploy that can later be embedded (Section 5). |
| Database | **Supabase Postgres** | Picked over Neon and Vercel Postgres. Justification in 1.1. |
| Connection mgmt | **Supavisor transaction pooler, port 6543** | The single most important infra decision for viral spikes. Justification in Section 7. |
| ORM | **Drizzle** | Picked over Prisma. Justification in 1.3. |
| Email | **Resend** | React Email templates, simple API, good deliverability, must send from a Teleton-controlled domain with SPF/DKIM/DMARC (open decision 8). |
| Rate limit / abuse | **Upstash Redis ratelimit + Cloudflare Turnstile** | Serverless-native sliding-window limiter (no connection-pool cost) plus a privacy-friendly human-challenge on registration. Section 6. |
| Background work | **Vercel Cron** (nightly reconciliation) + **QStash** `[inferred]` (optional, only if email or webhook fan-out needs a durable queue) | The webhook itself is synchronous and idempotent; a queue is a nice-to-have, not a requirement, for this volume. |
| Payments | **Culqi primary, Mercado Pago fallback, behind an adapter** | Locked by the payments brief. Section 3. |

### 1.1 Why Supabase Postgres (over Neon and Vercel Postgres)

All three are good Postgres. The deciding factors for a small team running a fair, auditable, spiky charity raffle:

- **Supavisor pooler is built in and battle-tested for serverless.** The serverless-to-Postgres connection-limit problem (Section 7) is solved by a managed pooler you do not operate. Neon solves the same with its own pooler endpoint; Vercel Postgres is Neon underneath. Supabase wins on the surrounding package.
- **Operational surface for a non-engineer client.** Supabase ships a SQL editor, a table viewer, row-level security, point-in-time recovery, and a one-click CSV export. Teleton's people can be given read-only access to *see* the entries table and run "el filtro" support queries without a developer in the loop. That maps directly to Rodrigo's "que el sorteo se vea de forma sencilla."
- **Auth and storage are there if needed later** (Fork A's OTP, prize-image hosting) without adding a vendor.
- **Free tier covers the warm-up; the Pro tier ($25/mo) covers September** with daily backups and the connection ceiling we need.

Neon's branching is elegant but unneeded here. Vercel Postgres adds a billing layer on top of Neon with fewer admin tools. Supabase is the best fit for *this* team and *this* trust requirement. `[inferred]` on the exact tier; size against the load test (Section 7).

### 1.2 Why Route Handlers, not Server Actions, for money

Server Actions are great for forms, but for the create-order and webhook steps I want explicit, individually-addressable HTTP endpoints: they are easier to rate-limit per route, easier to point a provider webhook at, easier to replay in tests, and easier to reason about for idempotency and signature verification. The registration form can use a Server Action or a Route Handler; the **payment-init and webhook MUST be Route Handlers** with their own runtime config (Node runtime, not Edge, because the Culqi/Mercado Pago Node SDKs and signature crypto want Node).

### 1.3 Why Drizzle (over Prisma)

- **Cold-start and bundle size.** Drizzle is a thin SQL builder with no separate query engine binary. On Vercel serverless under a traffic spike, Prisma's engine adds cold-start weight and historically more friction with poolers. Drizzle is leaner where it counts.
- **Transaction-pooler friendliness.** The pooler does not support prepared statements. With the `postgres` (postgres.js) driver you set `prepare: false`; Drizzle works cleanly over that. Prisma needs `?pgbouncer=true` and a separate `directUrl` for migrations, which is workable but more moving parts. `[inferred]` that Drizzle is meaningfully simpler here, based on the pooler's prepared-statement limitation.
- **Migrations as plain SQL.** `drizzle-kit` emits readable SQL migration files. For an auditable money system, a reviewable SQL migration history is a feature: Teleton's auditors can read exactly what the schema is.
- **Honest tradeoff:** Prisma's DX and ecosystem are larger and a solo builder may move faster on Prisma muscle memory. If Sebastian is faster in Prisma, Prisma is acceptable provided `directUrl` (direct 5432) is used for migrations and `pgbouncer=true` for the pooled runtime URL. The schema in Section 2 is given as both Drizzle and portable SQL DDL so the choice does not block anything.

### 1.4 Provider abstraction (locked from the payments brief)

A thin internal `PaymentProvider` interface (`createCharge`, `verifyWebhook`, `refund`, `getCharge`) with a `CulqiProvider` implementation and a `MercadoPagoProvider` implementation. Swapping rails if Culqi onboarding stalls or has an outage mid-campaign is a config-and-adapter change, not a rewrite. This is the redundancy the payments brief calls for.

---

## 2. Data model

Design goals encoded in the schema:

1. **One charge, one entry-group, N tickets, idempotent.** A confirmed payment is the only thing that mints tickets. Tickets are the draw unit.
2. **Both returning-buyer forks in one schema.** A `participant` row is keyed by a **normalized contact hash** (phone or email). Every purchase links to it. Fork B simply never queries it for the UI; Fork A turns on a lookup. No migration between forks.
3. **Auditability is structural, not bolted on.** Append-only `audit_log` and `webhook_event` tables; confirmed entries are never silently edited; refunds void tickets.
4. **Events are configuration.** Two raffles today, more later, pricing and windows per event.
5. **The draw is reproducible from stored data.** A frozen entry snapshot, a committed seed, a drand round, and a deterministic selection are all persisted.

### 2.1 Entity overview

```text
raffle (event: warm-up Aug, main Sep 12)
  └─< prize           (per-event prize list, values for the bases)
  └─< pricing_tier     (per-event upsell config: base + tiers; NOT hardcoded)
  └─< draw             (one per event; commit-reveal + drand)
        └─< winner     (one per prize, links a ticket -> prize)

participant (keyed by normalized contact hash; dedupes a person)
  └─< purchase         (one per checkout attempt; carries idempotency key, amount, provider refs)
        └─< ticket     (one row per chance; the draw unit; only exists when purchase is paid)

webhook_event          (raw provider events, dedup on provider event id)
audit_log              (append-only, every state transition)
```

### 2.2 Key modeling decisions

- **`participant` identity key.** Store `phone_e164` and `email_normalized` (lowercased, trimmed) AND a derived `identity_hash` = SHA-256 of the normalized phone (preferred) or email. Unique index on `identity_hash`. This dedupes "same person came back another day" for the draw aggregation in BOTH forks, while letting Fork A do a lookup. I store a hash, not just raw, so the lookup path (Fork A) can match without exposing raw PII in logs, and so a future "find my entries" never needs a table scan on raw contact. Raw phone/email are still stored (needed for the confirmation email and winner contact) under the data-protection controls in Section 6.
- **`ticket` rows are minted only on payment confirmation.** A `purchase` in `pending` state has zero ticket rows. This makes "unpaid entries never enter the draw" (NFR-5.3.1) a structural guarantee: the draw reads `ticket`, and tickets cannot exist without a paid purchase.
- **Ticket numbering is sequential per raffle.** A per-raffle monotonic `ticket_no` (gapless within confirmed tickets) gives the deterministic, range-selectable space the draw algorithm needs (Section 4) and reads cleanly on a live ("ganador: boleto 04417").
- **Money is integer cents (`amount_cents`), currency fixed `PEN`.** Never float. `S/5` is `500`.
- **`purchase.idempotency_key`** is a server-generated UUID created before the provider is ever called; unique. **`purchase.provider_charge_id`** is unique once known. Two unique guards: one stops duplicate submits, one stops duplicate webhook minting.
- **No DNI at registration.** Per the legal/requirements briefs, DNI is collected only from winners at handoff. `winner.dni` is nullable and filled post-draw.

### 2.3 Drizzle schema

```ts
// db/schema.ts
import {
  pgTable, uuid, text, integer, timestamp, boolean,
  jsonb, pgEnum, uniqueIndex, index, bigint,
} from "drizzle-orm/pg-core";

export const raffleStatus = pgEnum("raffle_status", [
  "draft", "open", "closed", "drawn",
]);
export const purchaseStatus = pgEnum("purchase_status", [
  "pending", "paid", "failed", "expired", "refunded",
]);
export const paymentMethod = pgEnum("payment_method", [
  "yape", "card", "pago_efectivo", "other",
]);
export const drawStatus = pgEnum("draw_status", [
  "committed", "revealed", "verified",
]);

// ── Events as configuration ────────────────────────────────
export const raffles = pgTable("raffles", {
  id: uuid("id").primaryKey().defaultRandom(),
  slug: text("slug").notNull().unique(),          // "warmup-ago-2026", "principal-12-sep-2026"
  name: text("name").notNull(),
  status: raffleStatus("status").notNull().default("draft"),
  opensAt: timestamp("opens_at", { withTimezone: true }).notNull(),
  closesAt: timestamp("closes_at", { withTimezone: true }).notNull(),
  drawAt: timestamp("draw_at", { withTimezone: true }).notNull(),
  basePriceCents: integer("base_price_cents").notNull(),   // S/5 -> 500 (frozen in bases)
  maxChancesPerPurchase: integer("max_chances_per_purchase").notNull().default(4),
  feeOptInEnabled: boolean("fee_opt_in_enabled").notNull().default(true), // "cubre la comision"
  basesUrl: text("bases_url"),                    // public PDF of the official bases
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});

export const prizes = pgTable("prizes", {
  id: uuid("id").primaryKey().defaultRandom(),
  raffleId: uuid("raffle_id").notNull().references(() => raffles.id),
  position: integer("position").notNull(),        // display + draw order
  name: text("name").notNull(),
  description: text("description"),
  marketValueCents: integer("market_value_cents"),// valor de mercado for the bases / tax base
  imageUrl: text("image_url"),
  sponsorName: text("sponsor_name"),              // "sujeto a lo que las marcas den"
  isConfirmed: boolean("is_confirmed").notNull().default(false), // brand deal closed?
}, (t) => ({
  byRaffle: index("prizes_raffle_idx").on(t.raffleId, t.position),
}));

// pricing/upsell tiers as DATA (the audio gave two conflicting models)
export const pricingTiers = pgTable("pricing_tiers", {
  id: uuid("id").primaryKey().defaultRandom(),
  raffleId: uuid("raffle_id").notNull().references(() => raffles.id),
  label: text("label").notNull(),                 // "1 mas por S/5"
  extraChances: integer("extra_chances").notNull(),
  extraPriceCents: integer("extra_price_cents").notNull(),
  sortOrder: integer("sort_order").notNull(),
});

// ── Person identity (powers BOTH returning-buyer forks) ────
export const participants = pgTable("participants", {
  id: uuid("id").primaryKey().defaultRandom(),
  fullName: text("full_name").notNull(),
  phoneE164: text("phone_e164").notNull(),        // normalized +51...
  emailNormalized: text("email_normalized").notNull(),
  identityHash: text("identity_hash").notNull(),  // sha256(normalized phone) primary key for dedupe
  dataConsentAt: timestamp("data_consent_at", { withTimezone: true }).notNull(),
  termsVersion: text("terms_version").notNull(),
  marketingConsent: boolean("marketing_consent").notNull().default(false),
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
  byIdentity: uniqueIndex("participants_identity_uq").on(t.identityHash),
  byPhone: index("participants_phone_idx").on(t.phoneE164),
  byEmail: index("participants_email_idx").on(t.emailNormalized),
}));

// ── Purchase = one checkout attempt (idempotent) ───────────
export const purchases = pgTable("purchases", {
  id: uuid("id").primaryKey().defaultRandom(),
  raffleId: uuid("raffle_id").notNull().references(() => raffles.id),
  participantId: uuid("participant_id").notNull().references(() => participants.id),
  status: purchaseStatus("status").notNull().default("pending"),
  chances: integer("chances").notNull(),          // base + chosen upsell, computed SERVER-side
  amountCents: integer("amount_cents").notNull(), // authoritative total, server-computed
  feeCoverCents: integer("fee_cover_cents").notNull().default(0), // optional donor-absorbed fee
  currency: text("currency").notNull().default("PEN"),
  method: paymentMethod("method"),
  // idempotency + reconciliation
  idempotencyKey: uuid("idempotency_key").notNull(),
  providerName: text("provider_name").notNull(),  // "culqi" | "mercadopago"
  providerChargeId: text("provider_charge_id"),   // set when known
  providerStatus: text("provider_status"),        // raw provider status string
  utmSource: text("utm_source"),                  // channel attribution
  utmCampaign: text("utm_campaign"),
  influencerCode: text("influencer_code"),         // per-influencer attribution
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
  paidAt: timestamp("paid_at", { withTimezone: true }),
  expiresAt: timestamp("expires_at", { withTimezone: true }), // pending timeout (async methods)
}, (t) => ({
  idemUq: uniqueIndex("purchases_idem_uq").on(t.idempotencyKey),
  chargeUq: uniqueIndex("purchases_charge_uq").on(t.providerChargeId), // null allowed, unique when set
  byRaffleStatus: index("purchases_raffle_status_idx").on(t.raffleId, t.status),
  byParticipant: index("purchases_participant_idx").on(t.participantId),
}));

// ── Ticket = one chance = the DRAW UNIT (only exists if paid)
export const tickets = pgTable("tickets", {
  id: uuid("id").primaryKey().defaultRandom(),
  raffleId: uuid("raffle_id").notNull().references(() => raffles.id),
  purchaseId: uuid("purchase_id").notNull().references(() => purchases.id),
  participantId: uuid("participant_id").notNull().references(() => participants.id),
  ticketNo: bigint("ticket_no", { mode: "number" }).notNull(), // sequential per raffle
  voided: boolean("voided").notNull().default(false),          // refund sets true -> excluded
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
  noUq: uniqueIndex("tickets_raffle_no_uq").on(t.raffleId, t.ticketNo),
  byRaffleActive: index("tickets_raffle_active_idx").on(t.raffleId, t.voided),
  byPurchase: index("tickets_purchase_idx").on(t.purchaseId),
}));

// ── Draw: commit-reveal + drand, fully reproducible ────────
export const draws = pgTable("draws", {
  id: uuid("id").primaryKey().defaultRandom(),
  raffleId: uuid("raffle_id").notNull().references(() => raffles.id).unique(),
  status: drawStatus("status").notNull().default("committed"),
  // COMMIT phase (published before the draw)
  serverSeedHash: text("server_seed_hash").notNull(),   // sha256(server_seed), published in advance
  drandRound: bigint("drand_round", { mode: "number" }).notNull(), // future round chosen at commit
  snapshotTicketCount: integer("snapshot_ticket_count").notNull(), // frozen pool size at commit
  snapshotHash: text("snapshot_hash").notNull(),        // hash of the ordered active ticket_no list
  committedAt: timestamp("committed_at", { withTimezone: true }).notNull().defaultNow(),
  // REVEAL phase (after the drand round publishes)
  serverSeed: text("server_seed"),                      // revealed; must hash to serverSeedHash
  drandRandomness: text("drand_randomness"),            // the beacon value for drandRound
  finalSeed: text("final_seed"),                        // sha256(server_seed || drand_randomness)
  revealedAt: timestamp("revealed_at", { withTimezone: true }),
});

export const winners = pgTable("winners", {
  id: uuid("id").primaryKey().defaultRandom(),
  drawId: uuid("draw_id").notNull().references(() => draws.id),
  prizeId: uuid("prize_id").notNull().references(() => prizes.id),
  ticketId: uuid("ticket_id").notNull().references(() => tickets.id),
  participantId: uuid("participant_id").notNull().references(() => participants.id),
  selectionIndex: integer("selection_index").notNull(), // order drawn (prize position)
  // filled at prize handoff, NOT at registration:
  dni: text("dni"),
  prizeClaimedAt: timestamp("prize_claimed_at", { withTimezone: true }),
}, (t) => ({
  prizeUq: uniqueIndex("winners_prize_uq").on(t.prizeId),     // one winner per prize
  ticketUq: uniqueIndex("winners_ticket_uq").on(t.drawId, t.ticketId), // no double-win
}));

// ── Idempotency + audit (append-only) ──────────────────────
export const webhookEvents = pgTable("webhook_events", {
  id: uuid("id").primaryKey().defaultRandom(),
  provider: text("provider").notNull(),
  providerEventId: text("provider_event_id").notNull(), // dedup key
  eventType: text("event_type").notNull(),
  payload: jsonb("payload").notNull(),
  signatureValid: boolean("signature_valid").notNull(),
  processedAt: timestamp("processed_at", { withTimezone: true }),
  receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
  eventUq: uniqueIndex("webhook_provider_event_uq").on(t.provider, t.providerEventId),
}));

export const auditLog = pgTable("audit_log", {
  id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(),
  entity: text("entity").notNull(),     // "purchase" | "draw" | "winner" | "refund"
  entityId: text("entity_id").notNull(),
  action: text("action").notNull(),     // "created" | "paid" | "refunded" | "ticket_minted" ...
  actor: text("actor").notNull(),       // "webhook:culqi" | "system:cron" | "admin:<id>"
  detail: jsonb("detail"),
  at: timestamp("at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
  byEntity: index("audit_entity_idx").on(t.entity, t.entityId),
  byAt: index("audit_at_idx").on(t.at),
}));
```

### 2.4 Portable SQL DDL (the load-bearing pieces, ORM-agnostic)

```sql
-- the two guards that make double-issue / double-charge impossible
ALTER TABLE purchases ADD CONSTRAINT purchases_idem_uq UNIQUE (idempotency_key);
CREATE UNIQUE INDEX purchases_charge_uq
  ON purchases (provider_charge_id) WHERE provider_charge_id IS NOT NULL;

-- the dedup key that makes a retried webhook a no-op
ALTER TABLE webhook_events
  ADD CONSTRAINT webhook_provider_event_uq UNIQUE (provider, provider_event_id);

-- gapless, draw-ready ticket numbers, scoped per raffle
CREATE UNIQUE INDEX tickets_raffle_no_uq ON tickets (raffle_id, ticket_no);

-- one winner per prize; a ticket cannot win twice in a draw
CREATE UNIQUE INDEX winners_prize_uq  ON winners (prize_id);
CREATE UNIQUE INDEX winners_ticket_uq ON winners (draw_id, ticket_id);

-- person dedupe across "came back another day" (both forks)
CREATE UNIQUE INDEX participants_identity_uq ON participants (identity_hash);

-- the draw reads ONLY active paid tickets
CREATE INDEX tickets_raffle_active_idx ON tickets (raffle_id, voided);
```

### 2.5 How this serves the returning-buyer fork

- **Fork B (ship for warm-up):** registration upserts a `participant` by `identity_hash`; each checkout is a fresh `purchase`. The UI never surfaces prior tickets. The draw still correctly aggregates a person's odds because all their tickets share `participant_id`. Zero extra build.
- **Fork A (turn on for September if warranted):** add a "ya participe" lookup that takes phone/email, sends an OTP (Resend email or an SMS provider), and on verify shows `SELECT count(*) FROM tickets WHERE participant_id = ? AND raffle_id = ?`. No schema change. The OTP is required because showing stored PII to anyone who types a contact is a Ley 29733 problem (the legal brief's privacy-surface point).

---

## 3. End-to-end payment flow (Culqi primary)

Provider-agnostic at the boundary; Culqi-specific where it matters. **The webhook is the source of truth.** The browser return is treated as a hint, never as confirmation, because mobile users close tabs and Instagram/TikTok webviews mangle redirects.

```text
┌──────────┐   1 register (name, phone, email, consent, +Turnstile token)
│  Client  │ ─────────────────────────────────────────────────────────────▶ POST /api/register
│ (mobile) │                                                                    │ validate (zod) + Turnstile
│          │                                                                    │ rate-limit (phone/email/IP)
│          │                                                                    │ upsert participant by identity_hash
│          │ ◀──────────────────────── participantId, base price ───────────────┘
│          │
│          │   2 choose upsell tier (optional)  ── client shows tiers from pricing_tiers (DATA)
│          │
│          │   3 create order (raffleId, participantId, tierId|null, feeOptIn)
│          │ ─────────────────────────────────────────────────────────────▶ POST /api/orders
│          │                                                                    │ COMPUTE chances+amount SERVER-side
│          │                                                                    │ (ignore any client price)
│          │                                                                    │ generate idempotencyKey (UUID)
│          │                                                                    │ INSERT purchase (status=pending,
│          │                                                                    │   expiresAt=+30min)
│          │                                                                    │ provider.createCharge(amount,
│          │                                                                    │   idempotencyKey)  -> token/checkout
│          │ ◀────────── checkout token / redirect URL + purchaseId ─────────────┘
│          │
│          │   4 pay  ── Culqi Checkout popup (Yape primary button; card; PagoEfectivo secondary)
│          │            3DS/OTP step-up on cards stays ON
│          │
│          │   5 browser returns -> /gracias?purchase=...  (shows "estamos confirmando tu pago")
│          │            polls GET /api/orders/:id/status   (NOT trusted as truth, just UX)
└──────────┘
                                  6 ── Culqi -> POST /api/webhooks/culqi  (SOURCE OF TRUTH)
                                        │ verify signature  (reject if invalid -> log, 401)
                                        │ INSERT webhook_events (provider,event_id) -- UNIQUE
                                        │   on conflict: it's a dup -> 200 OK, do nothing
                                        │ if event = charge succeeded:
                                        │   BEGIN TX
                                        │     SELECT purchase FOR UPDATE by provider_charge_id|idem
                                        │     if already 'paid' -> COMMIT, 200 (idempotent)
                                        │     UPDATE purchase status='paid', paidAt, providerStatus
                                        │     SELECT max(ticket_no) FOR UPDATE on raffle
                                        │     INSERT N tickets (sequential ticket_no)
                                        │     INSERT audit_log (purchase paid, ticket_minted x N)
                                        │   COMMIT
                                        │   enqueue confirmation email (Resend)
                                        └ 200 OK
```

### 3.1 The four guarantees against double-issue / double-charge

1. **One pending purchase per attempt, keyed by a server idempotency UUID.** A retried "create order" with the same client intent reuses the pending purchase rather than creating a second charge. Culqi (and Mercado Pago) accept an idempotency key on charge creation; we also persist ours.
2. **Webhook dedup on provider event id.** `webhook_events (provider, provider_event_id)` is unique. Culqi identifies events by a unique id; Mercado Pago resends IPN by design. A repeat insert hits the unique constraint, we catch it and return 200 without minting anything.
3. **Charge-id uniqueness on the purchase.** Even if two distinct events both claim to confirm the same charge, `purchases_charge_uq` plus the `SELECT ... FOR UPDATE` + "if already paid, no-op" branch make ticket minting exactly-once.
4. **Tickets minted inside the same transaction that flips the purchase to paid.** No window where a purchase is paid but tickets are missing, or tickets exist for an unpaid purchase.

### 3.2 Amount integrity

The client never sends a price. `POST /api/orders` recomputes `chances` and `amount_cents` from `raffle.base_price_cents` + the selected `pricing_tier` row + optional `fee_cover_cents`, and that server total is what goes to the provider and into the email. This kills the "spike-time price manipulation" risk (NFR-5.2.3).

### 3.3 Fee-drag handling, wired into the flow

From the payments brief: a bare S/5 card sale loses ~83% to the S/3.50+IGV minimum. The flow therefore:

- Positions **Yape as the default, most prominent button** (economics ~3% vs ~83% on cards at S/5).
- Sets the practical **card/voucher floor at the upsell bundle (S/15 for 3 chances)** so card sales clear the fixed-fee cliff (~28% drag, falling fast as bundles grow).
- Offers the honest, opt-in **"Suma S/1 para cubrir la comision"** toggle (`fee_cover_cents`), never a dark pattern.

These are product levers expressed in `pricing_tiers` + the Yape-first UI, not new code paths.

### 3.4 Reconciliation (the audit backstop)

A **Vercel Cron nightly job** pulls the provider's charge list for the day and compares to `purchases`:

- A provider charge marked succeeded with no `paid` purchase locally -> a missed webhook; reconcile and mint (idempotent, so safe).
- A local `pending` past `expiresAt` with no provider success -> mark `expired` (a lead, not an entry).
- PagoEfectivo `pending` (async voucher) stays pending until its webhook or this job confirms it.

This gives auditors a clean "every confirmed entry maps to a real provider transaction, and totals reconcile to money received" trail (NFR-5.3.2).

### 3.5 Refunds

`provider.refund(chargeId)` plus a refund webhook handler that, in one transaction, sets `purchase.status='refunded'` and `tickets.voided=true` for that purchase, and writes `audit_log`. The draw reads only `voided=false` tickets, so a refund cannot leave a refunded chance in the pool (fairness). Default policy in the bases: no refunds except duplicate charge or proven error (the charity-defensible stance from the payments brief), but the duplicate-charge refund path always exists operationally.

---

## 4. The draw: fair, auditable, manipulation-resistant

The legal brief makes this load-bearing: a DGIN veedor must witness the draw, INDECOPI can review it after the fact, and Teleton's reputation rides on it being defensible. "Sencillo" (Rodrigo) and "publicly verifiable" are not in tension if we use a **commit-reveal scheme bound to a future public randomness beacon (drand / League of Entropy)**. This is the standard pattern for a draw that nobody, including Teleton's own staff, can rig.

### 4.1 Why not just `ORDER BY random()`

A naive in-app random selection is unverifiable: a skeptic (or a journalist, or a losing participant) cannot tell whether staff re-rolled until a favored ticket won. For a national charity that is an unacceptable trust posture. The fix is to make the randomness **come from outside Teleton** and the method **reproducible by anyone**.

### 4.2 The scheme (commit -> freeze -> reveal -> verify)

```text
PHASE 1  COMMIT  (published BEFORE the draw, e.g. when the raffle closes)
  server_seed        = 32 random bytes (kept secret for now)
  server_seed_hash   = sha256(server_seed)            ── PUBLISH this
  drand_round        = the drand round number whose time is just AFTER draw_at
                       (chosen now; its randomness does not yet exist)  ── PUBLISH this
  freeze the pool:
    ordered_tickets  = SELECT ticket_no FROM tickets
                       WHERE raffle_id=? AND voided=false ORDER BY ticket_no
    snapshot_hash    = sha256(join(ordered_tickets))   ── PUBLISH this
    snapshot_count   = length(ordered_tickets)          ── PUBLISH this
  store all of the above in `draws` (status=committed)

  -- After this point, NOTHING about the pool can change without detection:
  -- adding/removing a ticket changes snapshot_hash; the hash was already public.

PHASE 2  WAIT
  the drand beacon publishes drand_round at its scheduled time (after the draw moment).
  Teleton CANNOT know the randomness in advance, so cannot have picked a seed to favor anyone.

PHASE 3  REVEAL  (live, with the DGIN veedor present)
  fetch drand_randomness for drand_round from the public drand HTTP API
  final_seed = sha256(server_seed || drand_randomness)   ── reveal server_seed now
  -- verify server_seed hashes to the previously published server_seed_hash

PHASE 4  SELECT  (deterministic, from final_seed)
  selection is a seeded, reproducible shuffle / index draw over ordered_tickets:
    rng = HMAC-DRBG(final_seed)        -- deterministic stream from the seed
    pool = ordered_tickets             -- the frozen list
    for each prize in prize.position order:
        i = rng.next_uniform_int(len(pool))   -- Fisher-Yates style, without replacement
        winner_ticket = pool[i]
        record winner(prize, winner_ticket, selection_index)
        remove pool[i]                  -- draw WITHOUT replacement (no double-win)
  persist winners + final_seed + drand_randomness + revealedAt (status=verified)
```

### 4.3 Why this is manipulation-resistant and auditable

- **Staff cannot rig it.** The selection is fully determined by `final_seed = sha256(server_seed || drand_randomness)`. `server_seed` is committed (hash published) before the public randomness exists, and `drand_randomness` comes from a 15-org external beacon Teleton does not control. Neither side can grind the result.
- **The pool cannot be edited after commit.** `snapshot_hash` and `snapshot_count` are published at commit. Any post-commit add/remove/void is detectable by recomputing the hash.
- **Anyone can verify, after the fact.** Given the published `server_seed_hash`, `drand_round`, `snapshot_hash`, and after-the-fact `server_seed` + `drand_randomness`, a third party re-runs Phase 4 and gets the identical winners. This is the strongest possible answer to an INDECOPI inquiry or a public dispute.
- **It is still "sencillo" to run.** Operationally it is: close raffle -> click "Commit" (publishes the three values) -> on the live, fetch the drand value, click "Reveal & Draw" -> winners appear, exportable. The cryptography is invisible to the operator.
- **It maps onto the legal requirements.** The DGIN veedor witnesses the reveal-and-draw live (Art. 10 supervision). The frozen snapshot + recorded seed + exportable winners record is exactly the "reproducible, documented, frozen-snapshot" draw the legal/requirements briefs demand. A notary, if Teleton's counsel wants one, simply attests the same live moment.

`[inferred]` that drand specifically is acceptable to Teleton's counsel as the randomness source; if counsel prefers a purely offline witnessed physical draw, the same schema still applies (replace `drand_randomness` with a publicly-recorded physical input like a witnessed lottery number), so the architecture does not depend on drand being approved.

### 4.4 Audit log and export

- Every phase writes `audit_log` (`draw committed`, `draw revealed`, `winner selected`) with actor and timestamp.
- **Winner export:** `GET /api/admin/raffles/:id/winners.csv` returns `prize, ticket_no, participant_name, contact, selection_index`, plus a separate **verification bundle** (`server_seed_hash`, `server_seed`, `drand_round`, `drand_randomness`, `snapshot_hash`, ordered ticket list) so the draw can be independently re-verified. Admin routes are behind auth and rate-limited (Section 6).
- **Entries export:** `GET /api/admin/raffles/:id/entries.csv` of confirmed entries for Teleton's own "filtro" and reconciliation. Supabase's table viewer is the no-code fallback for the same data.

---

## 5. Embedding into teleton.pe

teleton.pe is a flat custom-HTML site that already runs payment widgets (Yape, Izipay, PayPal) at paths like `/donativo/donar-ahora`. Three options:

| Option | What | Verdict |
|---|---|---|
| **Subdomain `rifa.teleton.pe`** | The Next.js app on Vercel, DNS `CNAME` from a Teleton-controlled subdomain. Standalone, linked from teleton.pe. | **RECOMMENDED.** |
| Subpath `teleton.pe/rifa` | Reverse-proxy the Next app under the existing domain. | Heavier; needs Teleton's host to proxy; couples deploys. |
| Iframe embed inside an existing teleton.pe page | `<iframe src="rifa.teleton.pe">` on a Teleton page. | Use only as a *secondary* surface, not the core flow. |

### 5.1 Recommendation: `rifa.teleton.pe` (subdomain), shareable as the canonical link; iframe only as an optional inset

Reasons, each tied to a hard requirement:

- **Payment redirects and in-app webviews.** Instagram/TikTok webviews already break cookies and redirects (NFR-5.1.1/5.1.3). Running the *checkout inside an iframe on another origin* compounds this: third-party-cookie blocking and webview iframe quirks will break Culqi Checkout for a meaningful slice of mobile users. A first-party subdomain gives the payment popup a clean same-site context. This alone rules out iframe-as-primary.
- **"Un landing aparte" is literally what Rodrigo asked for.** A subdomain *is* the separate landing, and it is the most shareable artifact for IG/TikTok bios and link stickers (`rifa.teleton.pe` reads as official Teleton, which matters for trust and for not looking like a scam raffle).
- **Independent deploys and rollback.** The raffle ships, changes, and rolls back on Vercel without touching teleton.pe's host or risking the main donation site during a viral spike.
- **Analytics and attribution are clean** on a first-party origin (UTM + influencer code capture, Section 2.3), no cross-origin cookie loss.
- **Later iframe inset is still possible.** If comms wants a teaser card embedded on teleton.pe, iframe the *landing* (not the checkout) and have the CTA break out to `rifa.teleton.pe` for the actual purchase. Best of both, no payment breakage.

`[inferred]` on DNS control: Teleton (or its web vendor) must create the `CNAME`. This is open decision 7; the recommendation is subdomain. The cookie/redirect reasoning is the decisive technical input the brief asked me to surface.

---

## 6. Security

The threat model: a public, money-handling form under heavy paid promotion is a magnet for carding (testing stolen cards on cheap S/5 charges), spam registrations, and PII scraping. Plus Ley 29733 obligations.

### 6.1 Secrets and keys

- All provider keys, DB URL, Resend key, Turnstile secret, webhook secrets, and the **draw `server_seed`** live in **Vercel environment variables**, never in the repo. Separate Preview vs Production scopes; live payment keys only in Production.
- **Least-privilege DB.** App connects as a role without DDL rights; migrations run with a separate direct-connection admin role. Supabase RLS on tables; the participant table is readable only by the service role and (read-only) by named Teleton staff.
- **Encryption.** TLS in transit (Vercel + Supabase default). At rest, Supabase encrypts the volume; for extra-sensitive fields consider app-level encryption of raw phone/email `[inferred]` (the `identity_hash` already avoids logging raw PII).

### 6.2 Webhook hardening (the money endpoint)

- **Signature verification is mandatory.** Culqi: verify the configured endpoint signature; Mercado Pago: verify its signature header. Reject and log any unsigned/mismatched payload with 401. Node runtime (Edge cannot do the crypto cleanly).
- **HTTPS only** (Vercel enforces). **Idempotent** per Section 3.1. **No business logic trusts the browser**; only the signed webhook (and the reconciliation job) flips purchases to paid.
- Store every raw event in `webhook_events` for forensic replay.

### 6.3 Input validation

- **Zod schemas** on every Route Handler input; reject anything malformed before touching the DB. Phone normalized to E.164, email normalized, name length-bounded. Server recomputes all money (Section 3.2).
- Parameterized queries only (Drizzle/SQL); no string-built SQL.

### 6.4 Bot and abuse protection on registration

- **Cloudflare Turnstile** (privacy-friendly, no puzzle UX, accessible) token required on `/api/register` and validated server-side. Chosen over hCaptcha for lower friction and better a11y, both first-class on the mobile flow.
- **Upstash Redis sliding-window rate limits**, layered: per IP, per phone, per email, and a tighter limit on `/api/orders` (charge creation) to blunt carding. Velocity caps from the payments brief.
- **3DS/OTP step-up stays ON** for cards (issuer fraud tooling). Yape/PagoEfectivo are push payments and effectively non-reversible, which is one more reason to favor Yape for this audience.
- **Admin/draw/export routes** behind authenticated access (Supabase Auth or a small allowlist) and rate-limited; the winner/verification export is sensitive.

### 6.5 Ley 29733 controls baked into the build (from the legal brief)

- **Express, unbundled, unticked consent checkbox** for data processing, separate from terms, separate again from optional marketing consent. `data_consent_at` + `terms_version` stored.
- **48-hour breach-notification readiness:** structured logging + an escalation runbook + a notification template. The `audit_log` and `webhook_events` tables are the forensic substrate.
- **Retention/minimization:** a scheduled job to anonymize non-winning participant contact data a defined period after each draw (period set by counsel), keeping only what tax/audit rules require. **Portability/ARCO:** a data-export-by-subject path (the `identity_hash` lookup powers it).
- **Banco de datos registration** (free, automatic since 31 Mar 2025) is a Teleton-legal task, not a build blocker, but the schema is the thing being registered.

---

## 7. Scale plan for social-driven spikes

The load profile is sharp and bursty: one influencer post or a live can dump thousands of concurrent users onto the landing in seconds. The read path and the write path scale differently and are treated separately.

### 7.1 Read path: make the spike never reach Postgres

- Landing and prize pages are **statically generated (SSG/ISR) and served from Vercel's CDN.** Prize data is read at build/ISR-revalidate time, not per request. A live that sends 50k views costs ~zero database reads.
- **Live counters** (e.g., "ya van X boletos") use ISR with a short revalidate or a single cached aggregate endpoint hit at most once per few seconds, never a per-view query.
- Defer heavy assets; mobile-data budget per NFR-5.1.2.

### 7.2 Write path: the serverless-to-Postgres connection-limit problem and the fix

This is the classic failure mode and the brief calls it out explicitly. **Each Vercel serverless invocation is its own short-lived process.** Under a spike, hundreds of concurrent invocations each try to open a Postgres connection. Postgres has a hard `max_connections` (low hundreds). Without pooling, the spike **exhausts connections, new invocations get `too many clients`, and registrations fail at exactly the moment the influencer drove traffic.** This is the single biggest infra risk.

**The fix (decided):** route all serverless DB traffic through **Supabase Supavisor in transaction mode on port 6543.**

- The pooler keeps a small set of hot Postgres connections and lends them to invocations **per transaction**, then reclaims them. Thousands of serverless functions multiplex onto a handful of real connections. Connection count at Postgres stays flat under load.
- **Driver requirement:** transaction mode does not support prepared statements. With Drizzle + postgres.js set `prepare: false`; with Prisma append `?pgbouncer=true` to the pooled URL. **Migrations use the direct connection (5432), never the pooler.**

```text
        spike: 5,000 concurrent registrations
   ┌──────────────────────────────────────────────┐
   │ Vercel serverless (auto-scales to N instances)│  N can be hundreds
   └───────────────┬──────────────────────────────┘
                   │  many transient connections
                   ▼
        ┌───────────────────────────┐
        │ Supavisor (transaction)   │  port 6543  ── keeps ~handful of hot conns
        │ pools + multiplexes        │
        └───────────────┬───────────┘
                        │  small, stable connection count
                        ▼
                 ┌──────────────┐
                 │  Postgres    │  max_connections NOT exhausted
                 └──────────────┘
```

### 7.3 Keep the write transaction tiny and fast

- The register and order endpoints do the minimum: one upsert, one insert, return. No N+1, no fan-out inside the request.
- The webhook transaction (flip-to-paid + mint N tickets + audit) is short and `FOR UPDATE`-scoped to one purchase/one raffle counter; the ticket-number `max()` lock is per-raffle and brief.
- **Email is out-of-band:** the webhook returns 200 fast; Resend send is enqueued (QStash `[inferred]`, or a fire-and-forget with retry) so email latency never holds a DB connection.

### 7.4 Rate limiting at the edge, before the DB

Upstash ratelimit runs against Redis, not Postgres, so throttling abusive bursts costs zero database connections. The limiter sheds carding/spam load before it can consume a pooled connection.

### 7.5 The warm-up is the load test for September (NFR-5.2.4)

- Before the end-of-August warm-up, **synthetic load test** to the estimated influencer peak: drive `/api/register` + `/api/orders` against a staging project, watch Supavisor connection count and Postgres `max_connections` headroom, p95 latency, and Upstash throttle behavior.
- **Instrument the real warm-up** (Vercel Analytics + structured logs + the UTM/influencer attribution in `purchases`) to get true conversion-burst numbers, then size the Supabase tier and pooler limits for the 12 September main raffle. September is the high-stakes event; August is the rehearsal, and it is wired to produce the numbers that de-risk it.

### 7.6 Failure containment

- If Culqi has an outage mid-campaign, flip the provider adapter to **Mercado Pago** (config + adapter, no rewrite) so the raffle keeps selling.
- If the DB tier is undersized at peak, the pooler degrades to queueing (slower) rather than hard-failing, and the static read path stays fully up so the brand surface never goes dark during a live.

---

## 8. Build sequence (so the app is frozen before the legal long-pole clears)

1. Schema + provider adapter + register/order/webhook/reconciliation (the spine). Yape-first checkout. Fork B.
2. Static landing + prizes (data-driven; prizes can be `is_confirmed=false` placeholders until brand deals close) + accessibility pass as a launch gate (WCAG 2.1 AA, screen-reader-tested through the payment redirect).
3. Draw module (commit-reveal + drand) + admin exports + audit log.
4. Consent/privacy/bases wiring (text supplied by Teleton legal), Turnstile + rate limits, breach-notification logging.
5. Load test -> warm-up launch (instrumented) -> read September's numbers -> optionally add Fork A -> 12 September main raffle.

The software can be done and frozen weeks before the MININTER resolution lands. Pricing must be frozen (open decision 1) before the bases are filed, because the bases cannot be filed with the audio's two conflicting price models.

## Recommendations
- **Next.js App Router on Vercel, Supabase Postgres via the Supavisor transaction-mode pooler (port 6543), Drizzle ORM, Resend email, Upstash rate limiting, Cloudflare Turnstile.**: Supavisor transaction pooling is the documented fix for the serverless-to-Postgres connection-exhaustion failure that would otherwise drop registrations at the exact moment an influencer drives a spike. Supabase also gives Teleton a no-code table viewer and CSV export for the draw, which maps to Rodrigo's request that the sorteo be simple to run. Drizzle is leaner on serverless cold starts and friendlier to the pooler's no-prepared-statements constraint than Prisma.
- **Culqi as primary payment provider with Yape positioned as the default, most prominent button, Mercado Pago as fallback behind a thin PaymentProvider adapter.**: Locked by the payments research: Culqi is the only option combining native Yape, same-day BCP settlement, real webhooks, a Node SDK, and S/0 monthly cost. Yape-first is essential because a bare S/5 card sale loses about 83 percent to Culqi's S/3.50 plus IGV minimum fee, while the same S/5 over Yape loses about 3 percent. teleton.pe already uses Yape and Izipay, so the audience and the org are familiar with the rails. The adapter makes swapping to Mercado Pago a config change if Culqi onboarding or uptime fails mid-campaign.
- **Make the webhook the single source of truth for issuing tickets, idempotent on the provider event id and on the charge id, with tickets minted only inside the same transaction that flips a purchase to paid, plus a nightly reconciliation cron.**: Mobile users close tabs and Instagram/TikTok webviews break redirects, so the browser return cannot be trusted. Two unique guards (idempotency key on submit, charge id on the purchase) plus webhook-event dedup mean a retried request or a duplicate webhook can never double-issue tickets or double-charge. The reconciliation job catches any missed webhook and gives auditors a clean trail from every confirmed entry to a real provider transaction.
- **Run the draw with a commit-reveal scheme bound to a future drand (League of Entropy) randomness round, freezing a hash of the ordered ticket pool beforehand and publishing the seed hash in advance.**: A naive ORDER BY random() is unverifiable and indefensible for a national charity. Commit-reveal plus external public randomness means neither Teleton staff nor the builder can rig the result, the pool cannot be edited after commit without detection, and any third party (or INDECOPI, or a disputing participant) can re-run the deterministic selection and get the identical winners. It also satisfies the mandatory DGIN veedor witnessing of the live draw and stays simple to operate (close, commit, reveal-and-draw).
- **Deploy as the standalone subdomain rifa.teleton.pe, shareable as the canonical link, and use an iframe only as an optional teaser inset on teleton.pe (never for the checkout itself).**: Instagram and TikTok in-app webviews already break cookies and redirects; running Culqi Checkout inside a cross-origin iframe compounds third-party-cookie blocking and would break payments for a meaningful slice of mobile users. A first-party subdomain gives the payment popup a clean context, is literally the landing aparte Rodrigo asked for, reads as official Teleton (trust), gives clean analytics and attribution, and deploys and rolls back independently of teleton.pe during a spike.
- **Model participants by a normalized phone or email identity hash with all purchases linked, so both the returning-buyer recognition fork (Fork A) and the flat-price fallback (Fork B) live in one schema; ship Fork B for the warm-up and turn on Fork A for September only if repeat-buying shows up.**: This matches Rodrigo's own si es sencillo, sino forma mas simple framing and avoids committing to the harder build before there is evidence it is needed. Because every purchase links to a deduped participant, the draw correctly aggregates a person's odds in both forks, and switching Fork A on (a verified OTP lookup) requires no data migration. Storing a hash keeps raw PII out of logs and lookups, which lowers the Ley 29733 privacy surface.

## Open Questions
- Pricing model and exact prices must be frozen before the bases can be filed with MININTER. Confirm base price (S/5 vs S/10), whether extra chances are flat-priced or discounted, the exact upsell tiers, and the maximum chances per person. The schema stores tiers as data, but the bases (a legal document) cannot be filed with the audio's two conflicting models unresolved.
- Returning-buyer fork: confirm Fork B (flat-price repeat, no recognition) for the warm-up with Fork A (OTP-verified recognition) deferred to September pending evidence of repeat-buying. Recommendation is Fork B first.
- Payment provider sign-off: confirm Culqi primary plus Mercado Pago fallback, and have Teleton finance request a charity/campaign rate in writing, specifically asking whether Culqi's S/3.50 minimum fee can be waived or lowered for a registered nonprofit (this materially changes the viable single-chance price).
- DNS control for rifa.teleton.pe: who creates the CNAME (Teleton or its existing web vendor), and is the subdomain recommendation accepted over a subpath proxy.
- Is drand (League of Entropy) acceptable to Teleton's legal counsel as the public randomness source for the draw, or does counsel prefer a purely offline witnessed physical input. The commit-reveal architecture supports either, but the source needs sign-off.
- Email sender domain: which Teleton-controlled domain sends the Resend confirmations, and can SPF/DKIM/DMARC be configured on it for deliverability and trust.
- Winners per event and double-win rule: confirm one winner per prize (about 5 for warm-up, about 10 for main) and that a single person cannot win more than one prize in the same event (the schema enforces one winner per prize and no double-win by default).
- Whether a queue (QStash) is wanted for confirmation-email delivery, or a fire-and-forget-with-retry inside the webhook is acceptable at this volume.
- Data retention period for non-winning participant contact data after each draw, to be set with counsel so the anonymization job can be scheduled.
- Confirm whether Teleton already has a Supabase or Vercel organization and a Peruvian RUC plus bank account (BCP for Culqi same-day settlement) under which to onboard the payment provider.

## Risks
- [high] Serverless-to-Postgres connection exhaustion during an influencer-driven spike drops registrations at the worst possible moment.  -> FIX: Route all serverless DB traffic through Supabase Supavisor transaction-mode pooler (port 6543) with the no-prepared-statements driver flag; keep write transactions tiny; static-cache the read path so views never hit the DB; rate-limit at the edge (Upstash/Redis) before a connection is consumed; load-test to the estimated peak before the warm-up and use the warm-up as the instrumented rehearsal for September.
- [critical] A duplicate or retried payment webhook double-issues tickets or double-charges a buyer, compromising draw fairness and trust.  -> FIX: Webhook is the single source of truth; dedup on provider event id (unique constraint), charge-id uniqueness on the purchase, server-generated idempotency key per attempt, and tickets minted exactly-once inside the same transaction that flips the purchase to paid with a FOR UPDATE no-op-if-already-paid branch. Nightly reconciliation catches missed webhooks idempotently.
- [critical] The MININTER/DGIN authorization chain (classify, lock brand-dependent prizes, file Formulario 6 under silencio negativo, secure the veedor, publish matching bases, freeze pricing) slips and blocks the 12 September draw regardless of how fast the app is built.  -> FIX: Architecture lets the app be finished and frozen weeks early; prizes are data with an is_confirmed flag so placeholders do not block the build; pricing freeze (a Rodrigo decision) is flagged as the gate to filing the bases. This is a client-side critical path that the build cannot resolve; Sebastian should surface it in writing and start the MININTER track now.
- [high] Running the checkout inside a cross-origin iframe (if embedding into teleton.pe is forced) breaks Culqi Checkout for a meaningful share of Instagram/TikTok in-app-webview users due to third-party-cookie and redirect handling.  -> FIX: Recommend the standalone subdomain rifa.teleton.pe as the canonical, shareable surface; use iframe only as an optional teaser inset whose CTA breaks out to the subdomain for the actual purchase; test the full flow inside the Instagram and TikTok in-app browsers before launch.
- [high] Carding (testing stolen cards on cheap S/5 charges) and spam registrations surge precisely during paid promotion, harming the charity and triggering chargebacks.  -> FIX: Cloudflare Turnstile on registration, Upstash sliding-window rate limits per IP/phone/email and tighter on order creation, 3DS/OTP step-up kept on for cards, Yape and PagoEfectivo (push, non-reversible) favored over cards, hosted Checkout to keep PCI scope minimal, and an append-only audit log of every charge, webhook, refund, and ticket-mint.
- [high] An unverifiable draw method exposes Teleton to a fairness dispute or an INDECOPI inquiry, damaging a national charity's reputation more than any fine.  -> FIX: Commit-reveal scheme bound to a future drand round: publish the server-seed hash, drand round, and a hash of the frozen ordered ticket pool before the draw; reveal and run a deterministic selection live with the DGIN veedor present; persist a verification bundle so any third party can re-run the selection and reproduce the exact winners; full append-only audit log and exportable winners record.
- [medium] Fee drag eats most of a bare S/5 card ticket (about 83 percent), making the raffle economically pointless on the smallest tickets.  -> FIX: Position Yape as the default rail (about 3 percent on S/5), set the practical card/voucher floor at the S/15 three-chance bundle to clear the S/3.50 minimum-fee cliff, offer an honest opt-in cubre la comision toggle, and have Teleton negotiate a nonprofit/campaign rate and minimum-fee relief in writing before launch.
- [high] Ley 29733 non-compliance (no express consent, no breach-notification capability, over-collection of PII) triggers sanctions up to 100 UIT and reputational harm.  -> FIX: Express, unbundled, unticked data-consent checkbox separate from terms and marketing; collect only name, phone, email (DNI only from winners at handoff); store an identity hash to keep raw PII out of logs; 48-hour breach-notification logging plus runbook plus template; least-privilege DB access and encryption; retention/anonymization job and a subject-data export path; banco de datos registration (free, automatic) handled by Teleton legal.

## Sources
- https://teleton.pe
- https://supabase.com/docs/guides/database/connecting-to-postgres
- https://drand.love/docs/http-api-reference
- https://www.cloudflare.com/leagueofentropy/
