Spec · approved · companion to spec.md

Cuenta

The blueprint: one app, one database, one email service, and one invoice layout used three ways.

How it works, in plain language

Cuenta is one Next.js app on Vercel. Everything Eileen records lives in one Postgres database on Neon. When she taps Save, Create invoice or Mark paid, the page calls one of the app's own API routes, which checks her PIN session, writes to the database, and logs one line. Every invoice is a snapshot, so nothing that changes later can rewrite it.

One invoice layout does three jobs: the screen, Print, and the PDF. For the PDF, the server opens a hidden print page in a headless Chrome with a 60-second pass for that one invoice, and saves it. Resend emails the PDF. All dates are worked out in Pacific time by one helper.

Architecture

Step through the judge path to see which pieces each step touches.

BROWSER (PHONE / LAPTOP) VERCEL · NEXT.JS SERVICES PIN Count Deliver / Edit Invoice + Print Ledger PIN check · proxy.ts API routes /api/session /api/counts /api/invoices · [id] …/void · …/payment …/pdf · …/email with-route → log.ts PDF rendererpuppeteer-core + Chromium /print/invoice/[id]?t= 60s token Neon Postgres6 tables · snapshots Resendinvoices@lacondesa… Vercel logs /health loads The print route skips the PIN check · Eileen's Print button prints the Invoice screen directly

Stack

PieceChoiceWhose call
Framework · hostingNext.js (App Router, TS) on VercelLearner (brief)
DatabaseNeon Postgres via @neondatabase/serverlessLearner (brief)
Data accessDrizzle ORM + drizzle-kitAccepted
PDFpuppeteer-core + serverless Chromium · fallback: a hosted renderer of the same URLLearner
EmailResend Node SDKLearner (brief)
Fontsnext/font/google: Bodoni Moda, EB Garamond, Space Grotesk, LimelightDerived from the PRD
StylingCSS Modules + tokens.css + print.cssAccepted
TestsVitest (helpers) · Playwright (judge path)Accepted

Not looked up, verify early: current package versions, Vercel Hobby size and duration limits (Spike 1), and Hobby commercial-use terms (Block 8).

Where it runs

Local: Node (the current LTS Vercel supports) and a Neon database. Local dev uses the Neon dev branch; production uses main (set up in 5-build, after test invoices landed in production). Schema changes are migrated to both. cp .env.example .env.local · npm install · npm run db:migrate · npm run db:seed · npm run dev.

Deployed: production on Vercel, connected to the public GitHub repo, is the judge URL. Headless Chrome loads APP_URL, which must be the production domain, since Deployment Protection on preview URLs would block it.

Demo video: the judge path on the production URL: Count for Corazon Cocina → Deliver → Invoice: Print, then Email (To is the mock cafe's delivered@resend.dev) → Ledger. Then the send shows as Delivered in the Resend dashboard with Invoice-[number].pdf attached, and the matching message ID in Vercel's function logs. No personal email address appears on screen.

Submission: a short demo video and a public GitHub repository are both required. The deployed URL is an addition, not a substitute.

Environment variables (in .env.example without values; never committed)
NamePurpose
DATABASE_URLNeon connection string
APP_PINthe six-digit PIN
SESSION_SECRETsigns the 30-day PIN cookie
PRINT_TOKEN_SECRETsigns the 60-second print tokens
RESEND_API_KEYResend
EMAIL_FROMonboarding@resend.dev (Resend's onboarding sender, which only delivers to the account owner) until the domain verifies; then invoices@lacondesabakingcompany.com. Never hardcoded
EMAIL_REPLY_TOEileen's Gmail
EMAIL_TEST_TOtest mode (added Oct 18). When set, every send goes to this address, whatever To says; empty or unset sends to the cafe. Set to Eileen's Gmail in Production at Handoff; turning it off is removing it and redeploying. Server-only
APP_URLthe app's own public URL, which headless Chrome loads
BUSINESS_CHEF_LINEfooter signature line ("Chef …"). Placeholder "Chef Example Name" except in production
BUSINESS_ADDRESS_LINESinvoice footer address. Placeholder in the repo and the mock deploy; the real kitchen address only in production env
BUSINESS_PHONEfooter phone. Placeholder 555 number except in production
BUSINESS_EMAILfooter email. hello@example.com except in production

Components

PIN Gate

prd.md › PIN Gate

  • POST /api/session compares to APP_PIN in constant time and sets the cuenta_session cookie (HMAC-signed exp, httpOnly, Secure, Lax, 30 days).
  • proxy.ts guards everything except /pin, /api/session, /print/*, /health, /help (the How it works page, which holds no data) and static assets. Pages redirect to the PIN; the API returns 401.
  • Attempt limiting (lib/pin-limit.ts, table pin_attempts): every wrong PIN adds a fail row with the client IP. 5 fails from one IP within 15 minutes add a lock row for that IP; 20 fails from anywhere add a global lock (IP null). Any lock younger than 15 minutes for the caller's IP, or a global one, returns 429 "Too many tries. Wait 15 minutes." before the PIN is compared. Attempts during a lock aren't counted, so they don't extend it. The IP comes from x-forwarded-for.
  • Each failure logs {route, outcome, ip, ts}, never the digits.

app/pin/page.tsx · app/api/session/route.ts · lib/session.ts · lib/pin-limit.ts · proxy.ts

Data model

Reference (seeded)
  • customers: code (unique), name, phone, address_lines, billing_email?, logo_path? (mock: a generated placeholder), sort
  • products: slug (unique, stable), name, price_cents, active, sort (menu order, added in 5-build). Open-ended count; Block 1 sixth-product check.
  • One menu for every cafe (corrected in 5-build): every active product, in sort order, is on every cafe's Count and Deliver at the one price, via listProducts(). The old per-cafe table standing_items was removed by migration 0002_one_menu.
Records
  • counts: rows only added: customer, product, qty, counted_on (date)
  • invoices: number (unique text), invoice_date, cafe snapshot, voided_at, paid_on, paid_reference, email_sent_at, email_message_id, email_last_sent_test (added Oct 18, migration 0003), email_last_failed_at
  • invoice_lines: product_id? (null = special order), description, unit_price_cents, qty ≥ 1, position
  • pin_attempts: kind (fail or lock), ip? (null on a global lock), at. Added in 5-build

Due date and totals are calculated, never stored. Blank menu rows are never saved, so an invoice holds only what was delivered.

File structure

Show the annotated tree (every file starts with // Job:)
app/
  pin/page.tsx · (app)/layout.tsx · (app)/page.tsx → /count
  (app)/count · count/past · deliver · invoices/[id] · invoices/[id]/edit · ledger · help · customers
  print/invoice/[id]/page.tsx       token-gated, no chrome
  health/route.ts
  api/session · counts · invoices · invoices/[id]/{route (PUT), void, payment, pdf, email}
proxy.ts                            PIN check
components/
  nav/{BottomNav, unsaved} · count/{CountForm, Stepper}
  deliver/{DeliveryForm, LineRow, SpecialOrderRow, useDeliveryLines}
  invoice/{InvoiceDocument(+.module.css), InvoiceActions, EmailSheet}
  ledger/{LedgerRow, StatRow, PaymentSheet} · ui/{Sheet, Confirm, CafePicker, Wordmark, HelpLink}
lib/
  dates · money · invoice-number · invoice-capacity · email-status · business · session
  pin-limit · client-ip · last-count-cafe · invoice-input · print-token · log · ledger-stats
  api/{with-route, client, duplicate-number} · pdf/{browser, render-invoice-pdf} · email/{invoice-email, send}
  queries/{customers, products, counts, invoices, ledger}
db/
  client.ts · schema/{customers, products, counts, invoices, invoice-lines, pin-attempts} · migrations/
seed/{mock-data, seed}.ts           mock cafes, real products and prices, fake contacts
styles/{tokens, globals, print}.css
public/{invoice/ (Canva PNGs), logos/placeholder.png}
tests/{unit/, e2e/judge-path.spec.ts}
scripts/audit-mock-data.mjs         fail if a tracked file carries real customer or business data
design/ (Stitch references, gitignored PNG) · devpost/
.env.example · drizzle.config.ts · next.config.ts · playwright.config.ts · package.json · README.md

Failure modes

BreaksShe sees
Chromium too big or slow on VercelSpike 1 decides → hosted renderer. PDF/Email show a one-line failure; Print always works.
Resend failsThe invoice is already saved · "Couldn't send…" + Try again · "Not sent", or "Last sent … · last try failed" after an earlier success · never "Last sent" for a send that failed.
Neon cold start or outageA one-line error, never blank · /health tells them apart · warm it before the demo.
Duplicate number raceThe unique index → "Already used on…".
Late-evening datePrevented by todayPacific(); Block 1 check.

Where the 20% leaked, this time

Caught in the PRD (the what)
  • Blank vs pre-filled steppers
  • Delivered-only invoice lines
  • The cafe logo on the form
Caught in the spec (the how)
  • The UTC clock shifting dates and numbers
  • The PDF renderer blocked by the PIN
  • Linked prices rewriting past invoices

In 5-build, every PASS/FAIL check cites a PRD heading and a spec heading, so "checking against the plan" has two exact addresses.

Public-repo decisions

Never in git
  • The real Corazon logo (production seed at Handoff)
  • Eileen's real footer details (production env only)
  • design/canva-invoice-reference.png (gitignored, local only)
  • The Stitch mockups, HTML and PNG (gitignored, local only: found in 5-build to contain real addresses, phones and emails). Only design/DESIGN.md is committed.
Checked, not assumed
  • The Block 6 mock-data audit verifies no real logo, no real footer details, the reference PNG untracked, and mock-only customer data
  • Mock cafes bill to Resend's test inbox delivered@resend.dev, so no personal address is in the seed or on screen

Carried from the PRD: cafe switch on Edit is locked; void and recreate instead. Decided in slice 7.