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.
Stack
| Piece | Choice | Whose call |
|---|---|---|
| Framework · hosting | Next.js (App Router, TS) on Vercel | Learner (brief) |
| Database | Neon Postgres via @neondatabase/serverless | Learner (brief) |
| Data access | Drizzle ORM + drizzle-kit | Accepted |
| puppeteer-core + serverless Chromium · fallback: a hosted renderer of the same URL | Learner | |
| Resend Node SDK | Learner (brief) | |
| Fonts | next/font/google: Bodoni Moda, EB Garamond, Space Grotesk, Limelight | Derived from the PRD |
| Styling | CSS Modules + tokens.css + print.css | Accepted |
| Tests | Vitest (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.
.env.example without values; never committed)| Name | Purpose |
|---|---|
DATABASE_URL | Neon connection string |
APP_PIN | the six-digit PIN |
SESSION_SECRET | signs the 30-day PIN cookie |
PRINT_TOKEN_SECRET | signs the 60-second print tokens |
RESEND_API_KEY | Resend |
EMAIL_FROM | onboarding@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_TO | Eileen's Gmail |
EMAIL_TEST_TO | test 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_URL | the app's own public URL, which headless Chrome loads |
BUSINESS_CHEF_LINE | footer signature line ("Chef …"). Placeholder "Chef Example Name" except in production |
BUSINESS_ADDRESS_LINES | invoice footer address. Placeholder in the repo and the mock deploy; the real kitchen address only in production env |
BUSINESS_PHONE | footer phone. Placeholder 555 number except in production |
BUSINESS_EMAIL | footer email. hello@example.com except in production |
Components
PIN Gate
prd.md › PIN Gate
POST /api/sessioncompares toAPP_PINin constant time and sets thecuenta_sessioncookie (HMAC-signed exp, httpOnly, Secure, Lax, 30 days).proxy.tsguards 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, tablepin_attempts): every wrong PIN adds afailrow with the client IP. 5 fails from one IP within 15 minutes add alockrow for that IP; 20 fails from anywhere add a globallock(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 fromx-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
Count Screen
prd.md › Count
- Shows the menu (every active product, the same for every cafe). Steppers start blank; Save is disabled while every row is blank.
POST /api/countssends only the entered rows, then the page refreshes, clears the steppers and shows "Saved" for 2 seconds.countsrows are only ever added, so untouched products keep their old count. - The last cafe is kept in the
cuenta_last_count_cafecookie. A shared dirty flag drives the prompt on cafe switch and on nav. - Past counts (added in
5-build, built in slice 8): a link under the picker openscount/past/page.tsx?cafe=, which reads every count row for that cafe, groups them bycounted_on(newest first, then by menu order), and renders them read-only.
count/page.tsx · count/past/page.tsx · CountForm.tsx · Stepper.tsx · nav/unsaved.ts · queries/counts.ts
Deliver (New and Edit)
prd.md › Deliver (New Delivery and Edit)
- One
DeliveryFormfor both. One line per menu product (the same for every cafe): name, editable unit price, blank qty, line total, grey count. Special order lines (description, unit price, qty) included. - Create sends
POST /api/invoices; Edit sendsPUT /api/invoices/[id]. Create invoice is disabled until a line has qty above 0. - A
numberTouchedflag stops the suggestion following the date or cafe. A duplicate returns409 {usedOn, cafeName}: “Already used on Oct 3 for Corazon SB”. - Edit loads the saved copy (prices and descriptions), never the current products.
- Cafe switch on Edit: locked (decided in slice 7). Switching would re-snapshot the cafe and reload its prices mid-edit;
PUTanswers 400 ifcustomerIddiffers. Void and recreate instead. - Split (slice 7):
useDeliveryLines.tsholds line state, prices, special orders and totals;SpecialOrderRow.tsxsits next toLineRow.tsx;DeliveryFormkeeps the header, the mode (new or edit) and submit.
deliver/page.tsx · invoices/[id]/edit/page.tsx · DeliveryForm.tsx · useDeliveryLines.ts · LineRow.tsx · SpecialOrderRow.tsx · api/invoices · lib/api/duplicate-number.ts
Invoice Document: the kernel
prd.md › Invoice · Look and Feel
- A pure component that renders a saved invoice as the Canva form. Used only by the Invoice screen and the print route.
- Its CSS module plus
print.cssare the only invoice-shaped styles in the codebase. Lines with qty above 0 render in their saved order; the logo renders when set, otherwise its space stays empty. - Type: Limelight INVOICE · body
"Times New Roman", Tinos, "TeX Gyre Termes", Times, serif. Tinos (self-hosted withnext/font) is metric-compatible with Times New Roman; serverless Chromium has no system fonts, so the PDF renders in Tinos and breaks lines exactly where Eileen's Windows print does · PNG wordmark, footer title and headers frompublic/invoice/, exported from Canva and trimmed to their ink. - Layout unit: every element sits at its PDF-point coordinates from her form (612 × 792 Letter), scaled from the page width (
--pt), so screen, print and PDF share one coordinate system. - Footer identity from
BUSINESS_*env vars: placeholders in the repo, real values only in production. - Pages (revised Oct 18, decided by Eileen):
MAX_LINES_ONE_PAGE, measured in Block 3 at 8: the form's 36pt spacing for up to 5 lines, tightening evenly to 22pt, applied to each page's own line count.paginateLinessplits the lines 8 per page andpageLabelgives “Page 1 of 2” (none for one page). - Every page repeats the wordmark, INVOICE, number, date, “Invoice for:”, cafe name, column headings and any VOID stamp. Page 1 adds the cafe's phone, address and logo; the last page adds the total, contact block, “Order received by” and Net 30 line.
print.cssbreaks after every page but the last, so Print and the PDF give one Letter page each. No line limit: the Invoice screen, PDF and email accept any number of lines.
Invoice Screen
prd.md › Invoice
- Shows
<InvoiceDocument>, the grey status line, and Print · Download PDF · Send email / Send again · Edit · Void (one confirm, thenPOST …/void). - Email draft: the page builds it on the server (
buildInvoiceEmail, the savedcafe_email,sendButtonLabel) and hands it to the Email pop-up, so To is filled the moment the pop-up opens. - Navigation: the bottom nav stays; a "← Back to Ledger" link sits at the top.
- Download PDF: the route answers with
Content-Disposition: attachment. The button fetches it in the background and saves the file, so the tab never navigates to the PDF, and a failure can still show the quiet one-line message. - Sheets (
components/ui/Sheet.tsx): an open sheet pushes one history entry (markedcuentaSheet), so the phone's back button closes it. Cancel, a tap outside, Discard or a successful send close it throughuseSheetClose(), which steps back off that entry before running the action, so a follow-up navigation is never undone.
invoices/[id]/page.tsx · InvoiceActions.tsx · EmailSheet.tsx · ui/Sheet.tsx · lib/email-status.ts
Print Route
prd.md › Email Invoice · Invoice
/print/invoice/[id]?t=: the token is an HMAC(id, exp) withPRINT_TOKEN_SECRET, valid 60 s and scoped to one invoice.- A bad, expired or wrong-invoice token returns 404. It never reads the PIN cookie, never logs the token, and sends noindex.
- Used only by the renderer. Eileen's Print button calls
window.print()on the Invoice screen.
PDF Renderer
Spike 1 · learner-written pass criteria
- Mint a token, launch Chromium, open the print URL, wait for
load,document.fonts.readyand every image to decode, thenpdf({format:'Letter', printBackground:true, preferCSSPageSize:true}). - Used by
…/pdf(Download, served as an attachment) and…/email.maxDurationis 60 s on both; Spike 1 measured a cold render at about 6 s server-side. - Bundling:
serverExternalPackagescovers@sparticuz/chromiumandpuppeteer-core;outputFileTracingIncludesadds the Chromiumbin/to the PDF and email routes. Route keys are globs:/api/invoices/*/pdf, not[id].
- Release within the last 6 months; fits Hobby's size limit.
- Under 10 s cold, under 3 s warm. Limelight and the PNGs render correctly.
- If it fails after 60 min: a hosted renderer on the same URL. Never a second layout.
Email Sender
prd.md › Email Invoice
- Email pop-up (
EmailSheet), given anEmailDraftby the Invoice page, with no separate preview request (the oldemail-previewroute was removed in5-build). To is the snapshotcafe_email(editable for this send only), so she never types the recipient: one tap on Send email. No email saved: autofocus in To, "No email saved for this invoice. Type one to send.", and Send email disabled until the address is valid. - Test mode (added Oct 18): a hairline-framed line above To, "Test mode: this sends to [EMAIL_TEST_TO], not the cafe." The cafe's saved email stays visible, read-only, labelled "Cafe's email (not used in test mode)", and Send is enabled without a valid To.
- Preview: the subject, the body collapsed to its first two non-empty lines with "Show full email", and the attachment line.
- Buttons: Cancel and Send email (black), in
Sheet.module.css's.actionsrow, which isposition: stickyat the sheet's bottom so they never scroll away. After a send: "Send again" with "Last sent …" above. POST …/email {to}: build the template (subjectLa Condesa Baking Company Invoice ${number}, attachmentInvoice-${number}.pdf), render the PDF, thenresend.emails.send({from, to, replyTo, subject, text, attachments}).- Test mode on the server:
lib/email/test-mode.tsreadsEMAIL_TEST_TO.sendEmail, the only caller of Resend, sends to that address whenever it's set, so no route or pop-up can reach a cafe in test mode. The route then skips the To check (a cafe with no email can still be tested), recordsemail_last_sent_test, and logstest: true, never the address. - Success:
email_sent_at+ message ID +email_last_sent_test, and the failure flag clears. Any failure (PDF, network or Resend):email_last_failed_at+ 502, never clearing an earlieremail_sent_at; the pop-up stays open with the failure line and Try again. Resend's reason goes to the log only, with addresses scrubbed. - Status line (
lib/email-status.ts): “Last sent Oct 18, 9:14am” · “Last sent Oct 18, 9:14am · test send” (that send went to the test inbox; the Ledger's label doesn't change) · “Last sent Oct 18, 9:14am · last try failed” · “Not sent” (only if no send ever succeeded) · nothing if never attempted. The stamp never lies. - The template lives in
lib/email/invoice-email.tsuntil Handoff. - Sender: domain verification isn't a gate (learner decision in
5-build). Untillacondesabakingcompany.comverifies,EMAIL_FROMis Resend's onboarding sender, which only delivers to the account owner's inbox. Then settingEMAIL_FROM/EMAIL_REPLY_TOin Vercel and redeploying makes the switch, with no code change (checklist slice 8 polish, before submission). DNS records go in at her registrar and are merged into any existing SPF record. - Onboarding sender: while
EMAIL_FROMisonboarding@resend.dev, Resend delivers only to the Resend account owner's address and to its test addresses, and refuses every other recipient with a 403. - Mock cafes bill to
delivered@resend.dev(Resend's test inbox), set inseed/mock-data.ts. Sends really go through and show “delivered” in Resend, and no personal address is ever on screen (learner decision before the demo video).
Ledger
prd.md › Ledger
queries/ledger.tsreturns totalCents (summed from the lines), dueOn (+30), overdueDays (vstodayPacific()) and the status.ledger-stats.ts: Open, Outstanding, This month, excluding void. Filtered with?cafe=.- Sorted by
invoice_date desc, thencreated_at desc. PaymentSheet: Mark paid sendsPOST …/payment {paidOn, reference}; Mark unpaid is one confirm, thenDELETE …/payment. Void isPOST …/void; void rows have no payment action.- Email label:
emailLabel()gives the envelope plus “Emailed Oct 17” in grey, with “Emailed Oct 17, 8:12pm” as thetitle(hover) andaria-label. If no send succeeded and one failed, it reads “Not sent”. No icon ever appears without words.
Customers Screen
prd.md › Customers (read-only)
- Shows the menu once at the top (every product and price, shared by every cafe), then each cafe's details and logo, read-only.
- No admin screens yet: products and cafes change only through the seed. Editing comes at Block 8.
customers/page.tsx · queries/customers.ts · queries/products.ts
Logging & Health
prd.md › Operational Requirements
with-route.tswraps every API handler and logs one JSON line: {ts, route, outcome, ms, invoiceNumber?, cafe?, messageId?, error?}.- Never logged: emails, addresses, phones, bodies, the PIN, tokens, env values.
- No indexing:
next.config.tssendsX-Robots-Tag: noindex, nofollowon every response, static files included (the proxy skips those), andpublic/robots.txtdisallows everything. /healthrunsselect 1and returns{ok, db: 'up'|'down', version}. The client fetch wrapper shows one-line errors and logs them to the console.
Dates & Money
TZ='America/Los_Angeles'.todayPacific()is the only source of "today". Nothing reads the browser's clock for a date.- Block 1 check: an invoice created at 5:30 pm Pacific gets today's date and today's number.
- Integer cents everywhere.
suggestNumber(1, '2026-10-17')returns110172026.
Data model
- 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, insortorder, is on every cafe's Count and Deliver at the one price, vialistProducts(). The old per-cafe tablestanding_itemswas removed by migration0002_one_menu.
- 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 (
failorlock), ip? (null on a global lock), at. Added in5-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
| Breaks | She sees |
|---|---|
| Chromium too big or slow on Vercel | Spike 1 decides → hosted renderer. PDF/Email show a one-line failure; Print always works. |
| Resend fails | The 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 outage | A one-line error, never blank · /health tells them apart · warm it before the demo. |
| Duplicate number race | The unique index → "Already used on…". |
| Late-evening date | Prevented by todayPacific(); Block 1 check. |
Where the 20% leaked, this time
- Blank vs pre-filled steppers
- Delivered-only invoice lines
- The cafe logo on the form
- 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
- 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-buildto contain real addresses, phones and emails). Onlydesign/DESIGN.mdis committed.
- 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.