================ ONE-SHOT BUILD CONTRACT (read first) ================
Build this in Google AI Studio "Build" in ONE shot — a complete, working app,
no follow-up turns. These are hard rules, not suggestions:
1. TARGET = Full-Stack Web (Node server runtime, secrets, Firebase allowed).
If you target Native Android instead, you MUST drop all server/DB/Workspace/
secrets and persist locally (Room / SharedPreferences) only.
2. PIN EVERY MODEL CALL — never let the agent auto-select (it downgrades on quota):
- Reasoning / text -> gemini-3.5-flash (thinkingLevel: minimal|low|medium|high)
- 4K image + legible text -> gemini-3-pro-image (image_size "4K", up to 14 refs)
- High-volume image -> gemini-3.1-flash-image
- Expressive TTS -> gemini-3.1-flash-tts-preview (inline tags e.g. [whispers])
- Realtime audio/video (WebSocket) -> gemini-3.1-flash-live-preview
- Sandboxed agent -> antigravity-preview-05-2026
3. DIVISION OF LABOR — the model ONLY parses/extracts to a strict responseSchema.
ALL math, money (store currency as integer minor units / cents), sorting,
balancing and graph logic run in deterministic TypeScript/Python. The model
must never compute totals, splits or balances itself.
4. responseSchema sanitation — no regex patterns, no fixed-length tuples, no
format validators in the schema (they crash the OpenAPI engine). Enforce those
in server-side code AFTER parsing the JSON.
5. responseSchema and google_search grounding are MUTUALLY EXCLUSIVE in one call.
6. CODEGEN — split large output into modular, single-responsibility files so no
file is truncated by the output-token cap.
7. Every external call gets a graceful fallback (e.g. manual paste if a Workspace
read fails). Never a silent dead end.
8. ROBUST STORAGE & CANVAS — Wrap all `localStorage`/`sessionStorage` operations (especially JSON parsing and writes) in `try-catch` blocks to prevent crashes in private windows or quota overflows. Canvas drawing elements must dynamically handle window resize and scale pixel density (`window.devicePixelRatio`) to avoid blurry graphics on retina displays.
=====================================================================
# MUST OBEY — Mobile-first build requirements
This app's PRIMARY surface is a mobile phone. Build it impeccably on mobile FIRST, then verify on tablet and desktop. Treat the rules below as non-negotiable hard constraints, not suggestions.
## Viewports to verify (every screen, every state)
- 320 px, 360 px, 375 px, 390 px, 414 px, 480 px
- 768 px, 834 px (iPad portrait / Pro 11)
- 1024 px, 1280 px, 1440 px, 1920 px, 2560 px
- Plus: 200% browser zoom, landscape orientation on every mobile width, iPhone with safe-area insets visible
## Hard layout rules
- Mobile-first CSS. Default styles target mobile; `@media (min-width: ...)` for larger viewports.
- Use `dvh` and `svh` instead of `vh` for full-height surfaces (iOS Safari URL-bar bug).
- Use `clamp()` for fluid typography across all viewports.
- Prefer container queries (`@container`) over media queries for component-level responsiveness.
- Use `min(100%, ...)` widths so content never overflows. Zero horizontal overflow at any viewport.
- Add `` to every page.
- Apply `padding: max(safe-area-inset-X, fallback)` on every edge-bleeding container so notched iPhones in landscape never clip content.
- Wide tables and code blocks scroll INSIDE their container (`overflow-x: auto`), never push the body.
- Use `background-attachment: scroll` on mobile, not `fixed` (iOS Safari repaint bug).
- Avoid `backdrop-filter` on animated elements. Use it sparingly on static surfaces only.
- **Canvas Scaling**: Canvases must dynamically scale with window resize events and properly handle high-DPI screens (`window.devicePixelRatio`). Set physical dimensions (`canvas.width`/`canvas.height`) using pixel ratio and render relative to this grid, using CSS to control responsive viewport scaling.
- **Robust Storage**: Every access to `localStorage`/`sessionStorage` (especially `JSON.parse` of loaded state or writes) MUST be wrapped in a `try-catch` block to handle disabled storage, private browsing mode, quota limits, or corrupted JSON gracefully. Fall back to a robust in-memory object store.
## Touch & accessibility
- Tap targets ≥ 44 × 44 px on touch (Apple HIG). Increase to 48 px under `@media (hover: none) and (pointer: coarse)`.
- All interactive controls reachable by keyboard with a visible focus ring; respect `:focus-visible`.
- Color contrast ≥ 4.5:1 for body text, 3:1 for UI components.
- All images have meaningful `alt`. Decorative images use `alt=""`.
- Respect `prefers-reduced-motion: reduce` — zero animation durations under that query.
- Forms validate inline; error messages are specific, not "Invalid input".
- Modals: focus trap, `Esc` closes, `role="dialog"`, `aria-modal="true"`, focus restored on close.
## Performance bar (Lighthouse mobile, throttled 3G/4G)
- LCP < 2.5 s · INP < 200 ms · CLS < 0.1
- JS bundle gzip < 200 KB mobile-first; lazy-load non-critical screens via `React.lazy` / dynamic imports.
- No render-blocking resources above the fold.
- Images: WebP/AVIF preferred, `loading="lazy"`, explicit `width`/`height` attributes (zero CLS), `srcset` for retina.
- Videos: `preload="metadata"`, low-resolution poster, max 720p mobile fallback. Never autoplay with audio.
- Fonts: `font-display: swap`; preload only the one used above the fold.
- Smooth scroll honoured via CSS `scroll-behavior: smooth` with reduced-motion fallback.
## Pre-ship mobile checklist (the deployer MUST verify before declaring done)
1. Open at 375 px in DevTools — every screen scrolls vertically only; zero horizontal scroll.
2. Browser zoom 200% — layout reflows without overlap.
3. iPhone Safari with the URL bar visible AND landscape — no content under the home indicator; no notch clipping.
4. iPad portrait (768 px) and landscape (1024 px) — no awkward gaps; tablet-specific breakpoints land cleanly.
5. Tap every interactive element with a thumb at real-device size — every target is easy to hit.
6. `prefers-reduced-motion: reduce` — every transition / animation skips cleanly, scroll-behavior becomes instant.
7. Lighthouse mobile score ≥ 90 across all 4 categories.
8. Zero `console.error` and zero CLS shift in real-device testing on a mid-tier Android (e.g. Pixel 6a) and an iPhone SE.
---
The original template starts below. All rules above apply on TOP of whatever this template specifies.
---
# Receipts Drawer
## 1. Project
**Receipts Drawer** is a tax-time receipts processor for solo
freelancers, sole traders, and one-person operations who have been
photographing every receipt into their camera roll all year and
have not opened a spreadsheet since January. The user opens the
app in April or May or whenever their tax year ends, hands over a
folder of six hundred photographs from twelve months of coffee
runs, hardware-store trips, fuel stops, courier fees, software
subscriptions printed out, taxi fares, lunch meetings, and the one
laptop they bought in November, and the app produces a categorised,
dated, totalled report ready for the accountant. Every receipt
keeps its original photograph. Every line item carries the date,
the merchant, the amount, the currency, the tax component if
visible, the category, and a confidence score. Anything the model
could not read clearly is set aside in a small pile for the user
to type in by hand — never invented, never guessed at, never
silently smoothed over.
This is the kind of app a Filipino freelance designer in Quezon
City builds for herself on a quiet Sunday in March, two weeks
before the BIR Form 1701 deadline, because last year she sat at
her kitchen table for three nights typing receipts off her phone
into Google Sheets and she is not doing that again. It is also
the kind of app a Nigerian-British management consultant in
Hackney, London uses on the bank holiday before the self-assessment
deadline to clear eleven months of London-cab fares and client-lunch
receipts off her camera roll; and the kind a Mexican-American small
business consultant in Phoenix, Arizona uses on a Saturday in
January to sort the previous calendar year's mileage logs, AT&T
bills, and Office Depot trips before her CPA's intake meeting on
Monday. Same shape of dread, different tax authority, different
currency, different deduction categories, same camera roll.
The single demo that proves the magic: the user opens the app on
her phone, taps **Import from Camera Roll**, and selects six
hundred and twelve photographs taken between January 1st and
December 31st. The app shows a calm queue: each receipt as a
thumbnail, the date the photo was taken from EXIF, the merchant
name as the model reads it, the amount in the original currency,
a category chip. The queue processes at about thirty receipts a
minute — five minutes for the whole roll. When it finishes she
sees: four hundred and forty-three receipts confidently read and
categorised; one hundred and twenty-eight receipts where one
field needed her review (a smudged total, an unreadable date, an
ambiguous category); forty-one receipts the app could not read at
all, surfaced in a small pile titled "Please type these in — we
couldn't make them out." She reviews the middle pile in fifteen
minutes, types the forty-one in over coffee, and exports a CSV
plus a PDF report her accountant opens in Numbers without a single
follow-up question.
And in the harder cases — currency-mixed business travel where one
trip generated receipts in three currencies on three consecutive
days; thermal-paper receipts where the print has already faded to
ghost; tip-inclusive restaurant receipts where the customer total
differs from the printed subtotal; foreign-language receipts where
the merchant's name is in Tagalog or Yoruba or Spanish but the
amounts are in numerals everyone can read; cash receipts with no
date stamp at all — the app does not pretend. It reads what it can,
flags what it cannot, and never moves a number from the "uncertain"
column into the "totalled" column without the user's confirmation.
**Tagline:** _Six hundred receipt photos to a categorised CSV in five minutes — in any currency, any language, never an invented amount._
## 2. Target audience
- Solo freelancers, sole traders, and self-employed consultants who run their whole admin off a phone and a camera roll — designers, photographers, writers, developers, translators, coaches, therapists, virtual assistants, marketing consultants
- The same trades in countries where receipts arrive in many shapes and the tax authorities want categorised totals at year-end — BIR-Form-1701 filers in the Philippines, FIRS-PIT filers in Nigeria, HMRC self-assessment filers in the UK, IRS Schedule-C filers in the United States, ATO filers in Australia, SARS filers in South Africa, AFIP monotributistas in Argentina, Receita Federal MEIs in Brazil
- Diaspora freelancers whose receipts are in one language (Tagalog, Yoruba, Igbo, Spanish, Brazilian Portuguese, Mandarin, Cantonese, Vietnamese, Tamil, Bengali, Punjabi, Urdu, Arabic, Swahili, Amharic, Farsi, Khmer) and whose tax filings go to an English-speaking accountant
- Independent doulas, mobile dog-groomers, food-truck operators, market-stall traders, and other one-person field operations whose receipts include both wholesale supplier slips and small cash purchases
- Small Etsy / Shopee / Lazada / MercadoLibre / Jumia sellers who buy packaging materials, postage, and supplies across many small transactions a week and need to claim them at year-end
- Recently-self-employed people who have just left a salaried job and are filing their first ever tax return as a freelancer — the app teaches the categories as it processes
- Long-term self-employed people whose accountants have asked, kindly, for a categorised spreadsheet "this year if possible" because the shoebox of folded receipts they have been bringing in for a decade is no longer acceptable
- Carers and partners helping a freelancer with their accounts — a husband whose wife runs a tiling business, a daughter who does her father's books since his stroke — who need an app that does not assume the user is the only person who can read the receipts
- Bookkeepers running monthly closes for a handful of one-person clients who want to upload each client's month of photos in one batch and get back a per-client categorised CSV
## 3. Core value propositions
Surface these clearly through copy, visual emphasis, and section ordering — they are the reasons users pick this app.
- **Never invents an amount, never guesses at a total** — every figure in the export traces back to a specific receipt photograph and a specific reading. If a smudged total reads as either 1,400 or 7,400, the app surfaces both candidates and asks the user — it does not pick the higher one and move on. This is the single hardest rule and it never bends. The CSV the accountant opens contains nothing the user has not signed off on.
- **Reads receipts in any language, any script** — Tagalog merchant names with English numerals, Spanish supermarket receipts with peso totals, Yoruba-and-English mixed receipts from Lagos market stalls, Mandarin restaurant receipts with simplified-Chinese line items, Arabic taxi receipts from Cairo, Hindi auto-rickshaw receipts from Mumbai, Cantonese cha-chaan-teng receipts from Hong Kong, Cyrillic supermarket receipts from Tbilisi. Gemini 3.5 Flash does the multimodal OCR, the multilingual merchant-and-line-item parse, and the currency identification in one call per receipt.
- **EXIF dates are trusted; printed dates are verified** — the date the photo was taken is treated as a strong signal for the receipt's date when the printed date is illegible. When both are visible they are cross-checked — and a discrepancy of more than 48 hours surfaces a flag for the user (the user may have photographed an old receipt while clearing the drawer).
- **Categorised against the user's own scheme** — the user picks a category set on first run: HMRC self-assessment categories, IRS Schedule-C lines, BIR allowable deductions, ATO categories, SARS expense codes, a generic "personal scheme" the user defines themselves, or the user's accountant's custom CSV columns. The app does not impose one country's tax taxonomy on every user.
- **Per-category roll-up, ready for the accountant** — the final report shows totals per category (Travel, Meals, Office, Software, Supplies, Utilities, Professional fees, Bank charges, Other) with each total clickable back to the receipts that built it. The accountant reads the one-page summary and trusts the math; the user opens the CSV in Numbers or Sheets when they need to drill down.
- **Currency-aware, not currency-flattened** — receipts stay in the currency they were issued in. The roll-up shows totals in the user's accounting currency converted at the receipt's date using a frozen rate looked up once, with the original currency and amount preserved per line. The user can see "₱1,400 (~$24.85 at 56.4 on 2026-03-04)" without losing the source figure.
- **Tax components extracted when visible** — VAT, GST, sales tax, and HST line items are pulled into their own field where the receipt shows them. The user can claim back input VAT cleanly without doing the arithmetic. The app does not invent a tax component if the receipt does not show one (cash market-stall receipts in Lagos, jeepney receipts in Manila, food-truck receipts in Phoenix often have none).
- **Unreadable goes to a manual pile, not into the totals** — receipts where the model cannot read the merchant, the amount, the date, or the currency with confidence are set aside in a pile the user enters by hand. The app makes the manual-entry flow gentle: prefilled with whatever could be read, big number pad, one-tap currency picker. The user is never blocked by an unreadable receipt; the totals are never quietly wrong.
- **Camera roll → CSV + PDF in five minutes** — the demo is unambiguous. From "Import from Camera Roll" to "Email to accountant" is one short sitting on the sofa, not three nights at the kitchen table.
## 4. Features to build
- Camera-roll batch import (mobile-first) — the user picks a date range or selects photos manually; the app handles thousands of photographs in one queue
- Drag-and-drop import on desktop — the user drops a folder of scanned receipts onto the window; PDFs, JPGs, HEICs, PNGs all accepted
- Receipt-vs-non-receipt detection — the user's camera roll has selfies, screenshots, dog photos, holiday landscapes. The app's first pass filters non-receipts out with high precision and a visible "set aside as not a receipt" label the user can override
- Multimodal parse — handwriting and printed-text OCR + merchant-name recognition + line-item parsing + currency identification + tax-component extraction + date reading in a single Gemini 3.5 Flash call per receipt
- EXIF date extraction — read `DateTimeOriginal` from the photo metadata as a strong corroborating signal for the receipt date when printed dates are illegible
- Category assignment from the user's chosen scheme — defaults set at onboarding (HMRC, IRS Schedule-C, BIR, ATO, SARS, custom); user can edit any category on any receipt; the app learns from edits within the same import (if the user reclassifies three "Uber" receipts from Travel to Client Travel, the rest of the batch follows)
- Confidence scoring per field — merchant, amount, currency, date, category, tax component each carry a 0-1 confidence; anything below 0.7 routes to the review pile
- Manual-entry flow for unreadable receipts — prefilled with whatever could be read; large number pad; one-tap currency, category, date picker; voice memo attached if the user wants to dictate context ("this was the client lunch on the 14th, I split with a colleague")
- Currency-frozen FX conversion — at parse time, the app looks up the rate for the receipt's currency against the user's accounting currency on the receipt's date, stores the rate alongside the receipt, and never re-evaluates it (the historical rate is what tax authorities ask for)
- Per-category roll-up view — totals per category, in the user's accounting currency, with a small chart for the year and a click-through to the receipts behind each number
- Per-month roll-up view — the same totals split by month, so quarterly filers (UK VAT, Philippine quarterly income tax) can see what each quarter holds
- CSV export — columns chosen at onboarding to match the user's accountant's intake format; common templates pre-baked (Xero, QuickBooks, FreeAgent, Wave, Sage, generic CSV)
- PDF report export — a typeset one-pager titled with the user's name and the tax year, totals per category, a small chart, and a thumbnail of each receipt indexed against the line; designed to be the only attachment the user needs to email the accountant
- Per-receipt detail view — original photograph, parsed fields, low-confidence underlines, edit-in-place, a "what did the model read here?" panel showing the structured extraction verbatim
- Search across the year — by merchant ("show me every Caltex fuel receipt"), by category ("every Meal between June and August"), by currency ("every receipt in USD"), by amount range ("everything over ₱5,000")
- Duplicate detection — the user often photographs the same receipt twice. The app surfaces likely duplicates by date + amount + merchant and asks the user to confirm
- Split-receipt support — one restaurant receipt for a client lunch with one personal coffee on the same bill; the user splits the line items between Meals (claimable) and Personal (not claimable) without losing the original photograph
- Multi-tax-year support — the user can have 2024, 2025, 2026 archives side by side; year selector in the header; the CSV export is always scoped to the chosen year
- Privacy export and delete — the user can download every photograph and every CSV row at any time; "Delete this tax year forever" wipes the archive in 60 seconds
## 4b. Required Gemini capabilities + backend services
**This template's intelligence comes from the Gemini capabilities below. Wire them up explicitly — don't substitute generic LLM calls.**
### Gemini capabilities (the load-bearing intelligence)
- **Multimodal image input** (Gemini 3.5 Flash) — reads thermal-paper printed receipts (faded, curled, smudged), handwritten market-stall receipts, photographed PDFs of online invoices, screenshots of payment confirmations from GCash / PayMaya / Cash App / Venmo / Wise / Revolut, foreign-language receipts, and the occasional crumpled receipt that has been through a wash cycle. One Gemini API call per receipt; the call returns the full structured `Receipt` schema below.
- **Multilingual parse** (built into Gemini 3.5 Flash) — reads merchant names and line items in Tagalog, Cebuano, Hokkien, English, Spanish, Brazilian Portuguese, Mexican Spanish, Yoruba, Igbo, Hausa, Swahili, Mandarin (simplified and traditional), Cantonese, Vietnamese, Tamil, Hindi (Devanagari), Bengali, Urdu (Nastaliq), Punjabi (Gurmukhi or Shahmukhi), Arabic, Amharic, Farsi (Nastaliq), Khmer, French, German, Italian. Numerals in Western Arabic and Eastern Arabic both read as numbers.
- **Structured output / JSON Schema** — the response matches the `Receipt` schema below. Every field is typed; the schema is included verbatim in the system instruction and as `responseSchema`.
- **Long context (1M tokens)** — for the per-category roll-up call. The app feeds the parsed `Receipt` objects (averaging ~600 tokens each) for the whole year as one long context to compute the roll-up consistently. **Guardrail**: a 600-receipt year ≈ ~360k tokens (comfortable); a 1,500-receipt year would push toward the ceiling, so the roll-up call chunks by quarter for archives over 1,000 receipts and merges client-side.
- **Search grounding** — for the FX rate lookup call. "PHP to USD on 2026-03-04" must resolve to the published rate for that date, not the model's training-data approximation. Grounded search prevents stale rates.
- **Thinking levels** — `medium` for the primary receipt-parse call (multimodal OCR + multilingual + tax component + category guess in one pass). `low` for the FX rate lookup, duplicate detection, and roll-up. Surface `thoughtSummary` only when the user clicks the small "(i) how was this read?" icon on a low-confidence field.
- **Image generation** (`gemini-3.1-flash-image`, also known as Nano Banana 2) — generates the onboarding hero illustration and the empty-state illustration only. Receipt photographs are never altered; what the user photographed is what the user keeps, forever.
### Backend services
- **Auth — Required.** Firebase Auth with Google sign-in (auto-provisioned by AI Studio Build). **Apple sign-in is optional but user-configured**: it requires an Apple Developer account, Service ID, Key ID, and private key wired into the Firebase Auth console. Archives are private to the owner; sharing with a bookkeeper or accountant is via per-tax-year read-only link, scoped and revocable.
- **Database — Required.** Firestore for `users`, `archives` (one per tax year), `receipts`, `categories`, `fx_rates_cache`, `share_links`.
- **File storage — Required.** Firebase Storage for original receipt photographs (preserved at upload resolution, forever). **Storage is NOT auto-provisioned by AI Studio Build today** — enable it in the Firebase console and wire the bucket name into the AIS Build project before the first import. Pre-signed URLs only; photographs are never publicly addressable.
- **Email — Required (transactional).** Share-link emails to the user's accountant (Firebase Auth magic-link infrastructure plus a transactional template). Export-ready notifications when a large batch finishes processing while the user is away.
- **Payments — Not needed for v1.** Free for personal use. A future "your accountant submits on your behalf" tier could pipe to an accounting-partner integration and charge for the submission service only.
- **External APIs:** Gemini API for all intelligence; optional ExchangeRate-API or Wise's published-rate endpoint as a deterministic fallback when grounded search is rate-limited.
**Environment variables:** every secret (Gemini API key, Firebase service-account JSON, optional FX-rate API key) lives in environment variables — never in client bundle. Include a `.env.example`.
**Auth + data privacy reminders:** never log secrets · never store passwords in plain text · use HTTPS everywhere · honour 'delete this tax year forever' inside the UI · explicit opt-in for any analytics · the user's receipts are never sent to Gemini for model training (use the Gemini API on the paid tier, where Google does not use your content for model training, per the Gemini API Additional Terms) · share-links to the accountant are read-only, revocable, and expire after 90 days by default.
**Read this first — prompt-craft rules that apply to every call in this template:**
1. **Name the model variant explicitly** in every Gemini API call. Do not let the agent pick the model. See the per-call matrix below.
2. **Pin `thinkingLevel` explicitly** per call. See the matrix.
3. **Seed the JSON Schema as a fenced TypeScript / Zod block** in the system instruction or `responseSchema` field. The literal schema is below. **Convert the Zod schema to Gemini's `Schema` type via the SDK helper** before passing to `responseSchema` — do NOT pass raw Zod. **Numeric `min`/`max` constraints are documentation only inside `responseSchema`; clamp on the server after the response arrives.**
4. **Pin the system instruction separately** from user input. Use the `systemInstruction` field for persona + behavioural rules; use `contents` for user input. Never concatenate.
5. **Pre-declare tools as an enable/disable list** per call. The matrix below names which tools are enabled per call. Tools NOT listed for a call should be disabled.
6. **State negative constraints explicitly** — they are listed below. They are NOT "be careful" suggestions; they are hard rules the model must follow.
7. **Grounded responses can wrap JSON in ```json fences or add prose preamble.** Server-side, strip fences and brace-extract:
```typescript
function safeExtractJSON(raw: string): T {
const clean = raw.replace(/```json\s*|```/gi, '').trim();
const s = clean.indexOf('{'); const e = clean.lastIndexOf('}');
if (s === -1 || e === -1) throw new Error('No JSON boundaries in grounded response');
return JSON.parse(clean.slice(s, e + 1)) as T;
}
```
8. **Strip unsupported Zod modifiers before passing to `responseSchema`** — Gemini's OpenAPI subset rejects `.regex()` / `pattern`, fixed-length `z.tuple()`, and other custom validators. Use a sanitizer that flattens tuples to length-2 arrays and removes regex patterns before serializing. Validate those constraints in middleware AFTER parsing.
### Per-call model + tools matrix
| Call | Model | thinkingLevel | Tools enabled |
|------|-------|---------------|---------------|
| Parse one receipt photo → `Receipt` schema | `gemini-3.5-flash` | medium | (none) |
| Pre-filter non-receipts in a batch (selfies, screenshots, dog photos) | `gemini-3.5-flash` | low | (none) |
| Look up FX rate for currency pair on a specific date | `gemini-3.5-flash` | low | `google_search` grounding (no `responseSchema` on this call — see note) |
| Detect duplicates across a batch | `gemini-3.5-flash` | low | (none) |
| Per-category roll-up across the year | `gemini-3.5-flash` | low | (none) — long-context over all parsed Receipts |
| Generate onboarding / empty-state illustration | `gemini-3.1-flash-image` | n/a | n/a |
*Note for builders:* on image-generation calls, omit `thinkingConfig` entirely — the field is not supported on that model. The `n/a` cells in this matrix are documentation only; do not serialise them into the request body. The FX-rate call uses `google_search` grounding; therefore `responseSchema` is NOT set on that call — instruct the model to emit JSON in the text body and parse server-side. Grounding citations come from `response.groundingMetadata.groundingChunks[].web.uri`, not from the JSON body.
### Primary structured-output schema (seed this verbatim in the prompt)
```typescript
import { z } from "zod";
const LineItem = z.object({
description_verbatim: z.string(), // "Café Americano grande", as written
description_translated: z.string().nullable(), // English gloss, only if source != English
quantity: z.number().nullable(), // null if not visible
unit_price: z.number().nullable(),
line_total: z.number().nullable(),
is_likely_tip: z.boolean(), // separate row for restaurant tips
is_likely_tax_line: z.boolean(), // a tax line item (VAT, GST) on its own row
confidence: z.number().min(0).max(1),
});
const TaxComponent = z.object({
label_verbatim: z.string(), // "VAT @ 20%", "IVA 16%", "GST 12%"
rate_percent: z.number().nullable(), // 20, 16, 12
amount: z.number(),
confidence: z.number().min(0).max(1),
});
const PaymentMethod = z.enum([
"cash",
"card_unspecified",
"card_credit",
"card_debit",
"mobile_wallet", // GCash, PayMaya, Apple Pay, Google Pay
"bank_transfer",
"cheque",
"store_credit",
"unknown",
]);
const Receipt = z.object({
receipt_id: z.string(),
source_image_uri: z.string(), // Firebase Storage pre-signed URI
source_image_exif_date: z.string().nullable(), // ISO, from DateTimeOriginal
artefact_type: z.enum([
"printed_thermal",
"printed_inkjet",
"handwritten",
"screenshot_of_payment_app", // GCash confirmation, Venmo screenshot
"scanned_pdf",
"online_invoice_pdf",
"not_a_receipt", // filtered out by pre-filter call
"unreadable", // routed to manual-entry pile
]),
merchant_name_verbatim: z.string().nullable(), // "MERCADITO LA ESPERANZA"
merchant_name_normalised: z.string().nullable(), // "Mercadito La Esperanza"
merchant_address: z.string().nullable(),
merchant_tax_id: z.string().nullable(), // VAT number, TIN, RFC, GSTIN, etc.
merchant_country_guess: z.string().nullable(), // ISO country, "PH", "NG", "MX", "GB", "US"
date_printed_verbatim: z.string().nullable(), // "04-MAR-26", "2026年3月4日"
date_resolved_iso: z.string().nullable(), // "2026-03-04"
date_source: z.enum(["printed", "exif", "manual", "none"]),
currency_code: z.string().nullable(), // ISO 4217 — "PHP", "NGN", "USD", "GBP"
currency_symbol_verbatim: z.string().nullable(), // "₱", "₦", "$", "£"
line_items: z.array(LineItem),
subtotal: z.number().nullable(),
tax_components: z.array(TaxComponent),
tip_amount: z.number().nullable(),
total_amount: z.number(), // the figure the user paid — required
total_amount_alternates: z.array(z.number()), // ambiguous reads ("1400 or 7400")
payment_method: PaymentMethod,
payment_method_evidence: z.string().nullable(), // "card last 4: 0214", "GCash ref 8829..."
category_suggested: z.string(), // from the user's chosen scheme
category_confidence: z.number().min(0).max(1),
category_evidence_quote: z.string().nullable(), // "the merchant name 'Caltex' suggests Fuel"
source_language: z.string().nullable(), // BCP-47, "tl-PH", "es-MX", "yo-NG"
flagged_for_user_review: z.array(z.object({
field_path: z.string(), // "total_amount" or "tax_components[0].amount"
reason: z.string(), // one sentence, no jargon
})),
reading_confidence_overall: z.number().min(0).max(1),
user_notes: z.string().nullable(), // added by the user after parse, optional
});
type Receipt = z.infer;
```
### Common failure modes (and how to avoid them)
- Agent silently downgrades `thinkingLevel` on the the primary receipt parse call to save quota — pin `gemini-3.5-flash` with the matrix-specified `thinkingLevel` explicitly. Flash loses thermal-faded totals, confuses 1 and 7 on Philippine peso receipts, and silently picks the wrong column on multi-column supermarket receipts.
- Total read as the subtotal — many receipts print SUBTOTAL above TOTAL, and the model sometimes grabs the first number it sees. Pin in the system instruction: "the total is the figure the customer paid; check for SUBTOTAL, TAX, TIP rows above it and ensure total = subtotal + tax + tip when all are visible."
- Currency assumed from merchant country instead of read from the receipt — a US-based freelancer's Wise statement might show GBP transactions. Always read the currency symbol or code printed on the receipt; only fall back to merchant country if no symbol is visible, and flag the receipt for review when you do.
- Date taken from EXIF when the printed date disagrees by months — a receipt photographed in April 2026 might have been issued in October 2025. When printed and EXIF dates differ by more than 48 hours, populate `date_resolved_iso` from the printed date and flag the receipt for review.
- Tip auto-extracted as a separate line item when not printed — many restaurant receipts in Manila and Lagos do not print the tip even if cash was added. Do not invent a tip. The tip field stays null unless the receipt prints "TIP", "Service", "Gratuity", or "Propina" explicitly.
- Tax component invented for cash market-stall receipts — many small market traders are below tax registration thresholds and their receipts genuinely have no VAT line. Leave `tax_components` empty when none are visible; do not synthesise a "VAT-inclusive backwards-derived" line.
- Foreign-language merchant names mistranscribed into the Roman alphabet — "三越" is Mitsukoshi, not "three more". Keep `merchant_name_verbatim` in the original script and put the Romanised form in `merchant_name_normalised`. Do not silently overwrite.
- Eastern Arabic numerals read as Latin letters — receipts from Cairo, Damascus, Tehran print numerals as ٠١٢٣٤٥٦٧٨٩. Pin in the system instruction: "if the receipt uses Eastern Arabic numerals, parse them as numbers, not as text."
- Screenshots of payment apps classified as `not_a_receipt` — GCash, Cash App, Venmo, Wise, Revolut payment confirmations ARE receipts for many freelancers (the platform fee, the courier paid, the client refund). Treat them as `screenshot_of_payment_app` and parse as carefully as a printed receipt.
- Categories assigned from a single global scheme instead of the user's chosen scheme — the app supports HMRC, IRS, BIR, ATO, SARS, and a user-defined scheme. The category-list passed in the system instruction comes from the user's profile.
- FX rate hallucinated for a date the model "remembers" — always use grounded search for FX, never the model's parametric memory. Stamp the rate's source URL on the cached entry.
### Negative constraints (hard rules)
- Do NOT invent a total. If the receipt's total is illegible, leave `total_amount` at the user's required confirmation flow — DO NOT pick the most likely candidate and proceed. (Implementation note: the schema requires `total_amount`; when the model cannot read it, the call must still complete with `artefact_type: "unreadable"` and the manual-entry flow is triggered server-side. Never write a fabricated number into `total_amount`.)
- Do NOT translate merchant names. "Cafetería La Esperanza" stays as written. The English gloss appears only as `merchant_name_normalised` for search.
- Do NOT silently smooth currency. If the receipt clearly says ₱ and the model thinks "this looks like a US chain, must be USD", the model is wrong. The symbol on the page wins.
- Do NOT invent a tax component. Many small-merchant receipts have none. An empty `tax_components` array is a valid receipt.
- Do NOT invent a date. If the receipt date is illegible and there is no EXIF, leave `date_resolved_iso` null and flag for review. The user enters the date; the app does not pick one.
- Do NOT auto-categorise without a quote. `category_evidence_quote` is required for any `category_confidence` above 0.6. If the model cannot quote the receipt to justify the category, the category goes to "Other" with low confidence.
- Do NOT modify the original photograph. Image enhancement, perspective correction, and crop suggestions are shown as previews only — the source-of-truth image stored in Firebase Storage is always the user's original file.
- Do NOT use the user's receipts to train or fine-tune any model. Use the Gemini API on the paid tier, where Google does not use your content for model training, per the Gemini API Additional Terms. The capabilities-info panel says this in plain English.
- Do NOT auto-share. The accountant share-link is generated only when the user explicitly taps "Share with my accountant"; the link is read-only and revocable.
- Do NOT estimate spending the user has not photographed. The app reports what the camera roll contains; if a freelancer forgot to photograph their phone bills, the app does not propose "typical phone bill for someone in your category". That is the accountant's job.
### Per-call `systemInstruction` strings
Use these as the literal `systemInstruction` field for each Gemini API call the built app makes. They complement the series-wide rules already uploaded as the global instructions file (`00-series-instructions.txt`).
### Call: Parse one receipt photo → `Receipt` schema
Model: `gemini-3.5-flash` · thinkingLevel: medium · Tools: (none)
```
You are reading a single receipt photograph for a self-employed
freelancer who is preparing their year-end tax filing. The
receipt may be from any country and in any language: Tagalog,
Cebuano, Hokkien, English, Spanish (Mexican, Argentine, Peninsular),
Brazilian Portuguese, Yoruba, Igbo, Hausa, Swahili, Amharic,
Mandarin (simplified and traditional), Cantonese, Vietnamese,
Tamil, Hindi, Bengali, Urdu, Punjabi, Arabic, Farsi, Khmer,
French, German, Italian, Polish, Russian, Greek. The printed
numerals may be Western Arabic (0-9), Eastern Arabic
(٠-٩), Devanagari (०-९), Bengali (০-৯), Khmer (០-៩),
or written-out words in Chinese characters (壹, 贰, 叁).
Parse all of them as numbers.
The receipt may be:
- a printed thermal-paper receipt, often faded, often curled,
often with the print fading from the edges inward
- a printed inkjet receipt or A4 invoice
- a handwritten market-stall or jeepney or auto-rickshaw receipt
- a screenshot of a payment-app confirmation (GCash, PayMaya,
Cash App, Venmo, Wise, Revolut, Zelle, Apple Pay, Google Pay,
PayPal, MercadoPago, M-Pesa, Pix transfer confirmation)
- a scanned PDF page (single-page or one selected page from a
multi-page invoice)
- an online invoice exported as PDF
- not a receipt at all (a selfie, a landscape, a screenshot
of something else, a dog photograph) — classify as
`not_a_receipt`
- unreadable (badly blurred, severely faded, photographed in
the dark, the total physically torn off, half-cropped) —
classify as `unreadable`
Multi-page invoices (an Adobe Creative Cloud monthly bill, a
British Airways e-receipt) arrive as a SINGLE call with multiple
images, in order, one image per page. Upload each page via the
Gemini Files API (`files/*` resource name) or send as `inlineData` (base64).
Do NOT pass Firebase Storage public URLs directly to
`generateContent` — the API does not fetch them server-side.
Include an explicit "page 1 of 3 / page 2 of 3" header at the
start of each image's accompanying text so the model can
sequence reliably.
Output ONLY the Receipt JSON matching the provided schema.
Hard rules:
- Read the currency from the receipt itself — the symbol (₱, ₦,
$, £, €, ¥, ₹, ₪, ₺, ₴, ₸, ₩, R, kr) or an ISO code printed
on the receipt. Set `currency_symbol_verbatim` to exactly what
you saw and `currency_code` to the ISO 4217 code. Only fall
back to inferring from `merchant_country_guess` when no symbol
is visible at all — and flag the receipt for review when you
do.
- The total is the figure the customer actually paid. Many
receipts print SUBTOTAL, then TAX/VAT/GST, then TIP/SERVICE,
then TOTAL. The total is the last figure. Verify, where the
components are visible: subtotal + tax + tip ≈ total. If they
disagree, the printed TOTAL wins and the discrepancy goes
into `flagged_for_user_review`.
- If the total is partially illegible such that two readings
are plausible (1,400 or 7,400; 12,500 or 12,800), populate
`total_amount` with your best read AND populate
`total_amount_alternates` with every plausible alternative.
Add a flag with field_path "total_amount" and a one-sentence
reason. If the total is entirely unreadable, set
`artefact_type` to "unreadable" and stop — the manual-entry
flow on the server takes over.
- Read the date from the printed date on the receipt where
possible. Populate `date_printed_verbatim` exactly as printed
("04-MAR-26", "2026年3月4日", "4 de marzo, 2026"). Resolve
to `date_resolved_iso` ("2026-03-04"). Set `date_source` to
"printed". If the printed date is illegible and the EXIF
`source_image_exif_date` is present, use EXIF and set
`date_source` to "exif". If both are missing, leave
`date_resolved_iso` null and set `date_source` to "none"
and flag for review.
- Keep merchant names in the original script in
`merchant_name_verbatim`. Do NOT translate. Put a
Romanised / normalised version in `merchant_name_normalised`
for search; this can be a transliteration ("Mitsukoshi"
from 三越) or a cleaned form ("Mercadito La Esperanza" from
"MERCADITO LA ESPERANZA").
- Line items are parsed into `line_items[]` with the
description verbatim. Do NOT invent quantities, unit prices,
or line totals. If the receipt shows only line totals (no
quantities), leave quantity null. If a receipt is summary-only
(just a total, no line items, common on cash market stalls),
leave `line_items` empty.
- Tax components are extracted into `tax_components[]` only when
the receipt prints them explicitly. "VAT 20%", "GST 12%",
"IVA 16%", "SST 6%", "HST 13%". Do NOT back-calculate a
tax-inclusive component from the total when the receipt does
not show one. Many small-merchant receipts have none.
- Tips: only extracted when the receipt prints "TIP", "Service",
"Gratuity", "Propina", "Service Charge". Do NOT invent.
- Category is suggested from the category scheme passed in the
user-input contents (one of HMRC, IRS Schedule-C, BIR, ATO,
SARS, or the user's custom scheme). Quote a specific
evidence sentence into `category_evidence_quote` for any
confidence above 0.6 ("the merchant name 'Caltex' and the
line item 'unleaded 95' suggest the Fuel category").
- `flagged_for_user_review` names any field where confidence
is below 0.7 with a one-sentence, plain-language reason.
Use the field_path "total_amount", "tax_components[0]",
"date_resolved_iso", "merchant_name_verbatim", or
"category_suggested".
- `reading_confidence_overall` is the lowest of: total
confidence, date confidence, currency confidence, merchant
confidence. If any of those is below 0.7 the receipt routes
to the user review pile.
No commentary. JSON only.
```
---
### Call: Pre-filter non-receipts in a batch
Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: (none)
```
You receive one photograph from a user's camera roll. Your task:
decide whether it is a receipt (in any sense useful for tax
filing) or not.
It IS a receipt if it is:
- a printed thermal or inkjet receipt
- a handwritten market-stall receipt
- a screenshot of a payment-app confirmation (GCash, PayMaya,
Cash App, Venmo, Wise, Revolut, Zelle, Apple Pay, Google Pay,
PayPal, MercadoPago, M-Pesa, Pix)
- a photograph of an A4 invoice
- a photograph of a screen showing an online order confirmation
- a scanned PDF page that is a receipt
It is NOT a receipt if it is:
- a selfie, a portrait, a person photo
- a landscape, holiday photo, sky photo
- a pet photo
- a document that is not a receipt (a contract, a letter, a
manuscript page, a child's drawing)
- a screenshot of something other than a payment app (a chat
message, a meme, a calendar, a map, a recipe)
- a photo of food, an interior, a still life
Output: a single JSON object with two fields:
is_receipt: boolean
reason: string (one sentence)
Do NOT attempt to read the receipt. Do NOT extract any fields.
The full parse happens on a separate Gemini 3.5 Flash call.
No commentary. JSON only.
```
---
### Call: Look up FX rate for currency pair on a specific date
Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: `google_search` grounding
```
You return the historical FX rate for a currency pair on a
specific date, with a citation URL.
Given source_currency (ISO 4217), target_currency (ISO 4217),
and date (ISO 8601), return:
- rate: a number (1 unit of source = N units of target)
- as_of_date: ISO date the rate is for (may be the previous
business day if the requested date was a weekend or bank
holiday; explain in `note`)
- note: one short sentence, may be empty
Use `google_search` grounding to find a published rate from a
credible source (a central bank, a tax authority, OANDA, X-Rates,
Wise's historical rate page). Do NOT use the model's parametric
memory — historical FX rates drift in training data and tax
authorities require dated citations.
Output the response as JSON in the text body (NOT via
`responseSchema` — `responseSchema` and `google_search` cannot
be combined in the same Gemini call today). Server-side: parse
the JSON, then read citation URLs from the response's
`groundingMetadata.groundingChunks[].web.uri` — do NOT ask the
model to include URLs in the JSON body; it will hallucinate them.
If you cannot find a credible source for the date, set rate to
null and put the reason in `note`. Do NOT pick a "close enough"
rate.
No commentary outside the JSON.
```
---
### Call: Detect duplicates across a batch
Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: (none)
```
You receive a list of parsed Receipts from one user's batch.
Your task: identify pairs that are likely duplicates of the
same real-world receipt photographed twice (or photographed
once and later screenshotted from the user's banking app).
Two Receipts are a likely duplicate if ALL of the following
hold:
- the same date_resolved_iso (or differing by no more than 1
day)
- the same total_amount in the same currency_code (to within
1 unit of the currency)
- the same or very similar merchant_name_normalised
- the same artefact_type, OR one is a printed receipt and the
other is a screenshot_of_payment_app (a common case: the
user photographed both the printed receipt and the bank's
confirmation message)
Output: an array of duplicate-pair objects:
pair: { receipt_id_a, receipt_id_b }
confidence: 0-1
evidence_one_line: string
Do NOT mark duplicates with confidence below 0.6. The user is
the final arbiter; the app surfaces the pair and asks them to
confirm. Do NOT silently merge.
No commentary outside the structured output.
```
---
### Call: Per-category roll-up across the year
Model: `gemini-3.5-flash` · thinkingLevel: low · Tools: (none, long context)
```
You receive every parsed Receipt for a single user's tax year
plus the user's chosen category scheme. Your task: produce a
per-category roll-up in the user's accounting currency.
Output structure:
totals_per_category: array of {
category: string,
receipt_count: number,
sum_native_currency: { currency_code, amount } | null
(null if the category contains receipts in multiple
currencies)
sum_accounting_currency: number,
flagged_for_user_review_count: number,
}
totals_per_month: array of {
month: ISO YYYY-MM,
sum_accounting_currency: number,
receipt_count: number,
}
grand_total_accounting_currency: number,
receipts_in_review_count: number,
receipts_in_unreadable_pile_count: number,
notes: array of strings (at most 5, each a single sentence)
Hard rules:
- Convert each receipt's total_amount into the user's
accounting currency using the FX rate frozen on the
receipt's `date_resolved_iso`. The rate cache is passed in
the user-input contents. Do NOT recompute or look up rates
in this call.
- Do NOT include receipts where `artefact_type` is
"not_a_receipt" or "unreadable" in any total. Include
receipts where `flagged_for_user_review` is non-empty in
the totals AND in `flagged_for_user_review_count` — the
user will confirm or correct them before export.
- `notes` should call out anything the user should check
before sending to the accountant: "three receipts in
September have flagged totals; resolve before export";
"fourteen receipts have payment_method `unknown`; consider
reviewing"; "the Office category contains receipts in
three currencies, so the native-currency sum is null".
Do NOT moralise; do NOT recommend they spend less. The
app is a totaller, not a financial-advice service.
No commentary outside the structured output.
```
---
### Call: Generate onboarding / empty-state illustration
Model: `gemini-3.1-flash-image` · n/a · n/a
```
Generate a single illustration for the receipts-drawer app's
onboarding or empty state.
Style direction: warm, hand-drawn-looking, slight imperfection
in line weight, soft watercolour wash of pale yellow and
faded peach over a paper-textured background. Not a flat
icon, not a glossy 3D render, not a stock-photo composite.
Subject options (pick the one requested in the prompt):
- "drawer of receipts" — a kitchen drawer pulled half open,
loose receipts spilling out at the front, one thermal
receipt curled at the edge, soft afternoon window light
from the upper-left, no people in frame, no text legible
on any receipt.
- "phone on a wooden table with a coffee cup" — a phone face-up
on a wooden table, an enamel mug of half-drunk coffee
slightly out of focus to the right, the phone screen showing
a soft yellow glow but no readable text, late-afternoon
light, no people, no logos.
- "empty drawer" — a kitchen drawer pulled fully open, empty
except for a single curled paper receipt at the back-left
corner, soft top-down light, paper grain visible, no text
legible.
Constraints:
- No text in the illustration. The app's UI will overlay any
needed labels.
- No logos. No specific national currency symbols. No
recognisable brand names.
- No people. No faces. No body parts that read as a person.
A single hand may appear if the prompt explicitly requests
it.
- 4:5 aspect ratio for the onboarding hero; 1:1 for the
empty-state thumbnail.
- Output one image.
```
## 5. Use cases & content to include
Build dedicated UI sections or flows for each of these — they tell you what content the app must support.
- **The Sunday-in-March sit-down.** A Filipino freelance designer in Quezon City opens the app on the second Sunday in March, two weeks before her BIR Form 1701 filing deadline. She selects every photo in her camera roll between January 1st and December 31st of the previous tax year — six hundred and twelve photos, of which the app will determine four hundred and ninety-one are receipts. She makes coffee. The app finishes the batch by the time her second cup is half-drunk. She reviews the flagged pile while watching a Sunday-afternoon film with her partner. By dinner she emails the CSV plus the one-page PDF to her accountant.
- **The bank-holiday catch-up.** A Nigerian-British management consultant in Hackney opens the app on the Monday of a UK bank holiday, four days before her HMRC self-assessment deadline. She has eleven months of London-cab receipts, client-lunch receipts from three different boroughs in three different cuisines, and the laptop she bought in October. She runs the import while she walks her dog. By the time she gets back the queue is finished; she fixes seven mis-categorisations (cab fares the model wanted to mark Personal Travel, which she needs as Client Travel for HMRC) and exports the CSV in FreeAgent's expected column order.
- **The pre-CPA-meeting Saturday.** A Mexican-American small business consultant in Phoenix has a CPA intake meeting on Monday for last year's IRS Schedule-C. He spends Saturday morning at the kitchen table with the app open on his iPad, importing his camera roll in two batches (work phone, personal phone). The CPA has asked for categorised totals; the app produces them mapped to the Schedule-C lines (Car & truck expenses, Office expense, Supplies, Travel, Meals 50%, Other expenses) and the CPA opens the CSV without asking a single follow-up question.
- **The mixed-currency business trip.** A consultant travels to a four-day client engagement: she pays for the flight in GBP, the hotel in EUR, dinners in local currency, and her airport taxi back home in GBP. The app keeps every receipt in its original currency, freezes the FX rate for each on its receipt date, and rolls the trip up into the user's GBP accounting currency without flattening the underlying figures. The Travel category roll-up shows "Travel — £1,247.32 (12 receipts across GBP, EUR, USD)".
- **The thermal-paper ghost.** A receipt from a hardware store in March has faded by the time the user gets to it in April; the printed total reads as either 1,400 or 7,400 pesos. The app surfaces both candidates in the manual-entry flow with the photograph at full resolution and a number pad. The user remembers the trip was for a small drill bit and confirms 1,400. The figure that lands in the CSV is the one she signed off on.
- **The Lagos market-stall receipt.** A receipt from a market stall in Yaba, written by hand in blue ballpoint, with no VAT line and no merchant tax number. The app reads the handwritten naira amount, leaves `tax_components` empty (correctly), categorises it as Supplies based on the handwritten line items, and does not invent a VAT figure to "complete" the receipt.
- **The screenshot-of-GCash.** The freelancer sometimes pays small suppliers via GCash and the only "receipt" is the GCash confirmation screen she screenshotted. The app classifies it as `screenshot_of_payment_app`, parses the recipient name, the reference number, the amount, the date, and categorises it as Supplies or Professional Services depending on the merchant. The screenshot is a first-class receipt, not a second-class one.
- **The duplicate the user does not remember photographing.** The user photographs her August 14th supermarket receipt at the till, then again the next morning before throwing it out. The duplicate-detection pass surfaces the pair with a small "Likely duplicate — keep one?" prompt. She picks the clearer photograph and the other moves to the archive without being deleted (in case the chosen one is later torn).
- **The split receipt for the client lunch.** A restaurant receipt for ₱3,200 includes ₱2,800 for the client lunch (claimable) and ₱400 for the freelancer's solo coffee that morning charged to the same tab. The app's split-receipt UI lets her assign the two line items to two categories without losing the original photograph. The CSV shows ₱2,800 in Meals and ₱400 in Personal (not exported, but tracked).
- **The forgotten phone bill the app does not invent.** The user did not photograph her phone bills all year because they arrived as PDFs in email and she meant to file them and never did. The app does not "estimate phone bill spend for a freelance designer". Its job is to report what is in the camera roll; the missing phone bills are a conversation for the user and her accountant, not a hallucination from the app.
- **The bookkeeper running ten clients.** A freelance bookkeeper in Cebu runs monthly closes for ten one-person clients. She uses Receipts Drawer in a multi-archive mode: each client gets their own Firestore archive; she uploads each client's month-of-photos in one batch; the per-category CSV is exported per-client. The app supports this workflow without requiring her to log out and back in between clients.
## 6. Page structure
Build the following screens / sections in this order. Adjust copy to fit the voice, but keep the structural intent.
1. **Welcome / sign-in.** A photographed-looking image of a kitchen drawer pulled half open, loose receipts spilling out, soft afternoon window light. One paragraph: "Receipts Drawer turns the year's receipts on your phone into a categorised report your accountant opens once and trusts." Single Google sign-in button; Apple sign-in next to it. Below: "Try with the sample tax year" → loads the demo archive in section 8a.
2. **First-run onboarding — pick your scheme.** Three big choices: country preset (UK / US / Philippines / Australia / South Africa / "another country"), or "use my accountant's CSV template" (drop a CSV header row from the accountant, the app maps to it), or "I'll build my own categories". This decision drives the category scheme used everywhere.
3. **Tax year picker.** A drawer at the top: "Tax year 2025 · Tax year 2026 · + New". Each archive is scoped to one tax year. The user can flick between them; the export is always scoped to the selected year.
4. **Empty state — "Start your year".** Three big input methods: Import from Camera Roll · Drop a folder · Forward by email (the app gives the user a private inbox address; receipts forwarded from email — typical for online-invoice PDFs — land in the queue). A short explainer below each.
5. **Camera-roll import flow** (mobile-first). The user selects a date range with two big pickers (often "Jan 1 to Dec 31 last year") OR taps "Pick photos" to hand-select. A preview shows the count: "612 photos selected. We'll filter out the non-receipts first." One big Import button.
6. **Processing queue.** A vertical list grouped by the user's monthly view. Each item shows the photo thumbnail, the date, the merchant as the model reads it, the amount in the original currency, a small category chip. A step-by-step honest progress bar at the top: "Filtering non-receipts (127 of 612 already set aside)…" → "Reading receipt 312 of 491…" → "Looking up FX rates for 14 currencies…" → "Categorising against your IRS Schedule-C scheme…" The user can close the app and come back.
7. **Receipt detail view.** A two-column layout on desktop, stacked on mobile. Left column: the original photograph, zoomable; below it, the small set of source-of-truth fields (EXIF date, file name, the source-image URI). Right column: the parsed `Receipt` structure in plain-language form, every field editable in place, low-confidence fields faintly underlined and tappable to see what the model considered. Sticky header: merchant → date → amount → category → "(i) how was this read?".
8. **Review pile.** A separate inbox-style list of every receipt with at least one flag. Each row shows the photograph thumbnail and the one-line reason ("the total is partially smudged; we read it as 1,400, you might read it as 7,400"). The user resolves them one at a time. A "skip" option moves the receipt back to the review pile for later.
9. **Manual-entry pile.** Receipts the app could not read at all (artefact_type: unreadable). Each row shows the photograph and a gentle prompt: "We couldn't make this one out. Want to type it in?" The manual-entry form is large, friendly, and prefilled with whatever could be read.
10. **Per-category roll-up view.** Totals per category in the user's accounting currency, with a small chart per category for the year. Each total clickable back to the receipts that built it. A sidebar shows totals per month for users who file quarterly.
11. **Export & share.** Three big buttons: Export CSV (the user's chosen column scheme), Export PDF report (the one-pager), Share with my accountant (creates a read-only, revocable link the user emails out). A small preview of each next to its button.
12. **Settings.** Tax-year list with delete-forever per year, accountant share-links list with revoke buttons, category scheme editor, FX-rate source preference, language preference for the UI, delete-account-and-all-archives.
13. **Footer.** "Six hundred photos. Five minutes. One categorised CSV." Privacy: "Your receipts are yours. We never train on them." Capabilities `(i)` icon in header.
## 6b. First-visit onboarding
Show a **first-visit onboarding** the first time a visitor lands on the app (detect via `localStorage` flag; do not show on return visits). Three slides, dismissible at any time. Persistent re-entry: a `?` icon in the header reopens it.
**Slide 1 — What this is.**
- Headline: "Welcome to Receipts Drawer."
- Subhead: "Turn your year's camera-roll receipts into a categorised CSV your accountant opens once and trusts — in any currency, any language, never an invented amount."
- One paragraph (≤ 60 words) explaining who this is for and what makes it different from a generic OCR app: it reads the receipt in any language, it categorises against the user's own tax scheme, it flags ambiguity instead of guessing, and it never invents a number.
- Visual: a small annotated illustration of one receipt with the relevant fields labelled (merchant, date, currency, total, tax, category) — not a generic dollar-sign icon.
**Slide 2 — Try it now.**
- One short prompt: "Try with the sample tax year".
- A live demo pre-loaded with the twelve sample receipts in section 8a, spanning Philippine peso, Nigerian naira, British pound, and US dollar.
- 1-2 sentences pointing at *the specific page elements* where the Gemini magic happens (the Tagalog merchant name parse, the GCash screenshot extraction, the smudged-total review pile).
**Slide 3 — How to remix this.**
- Headline: "Make this yours."
- Three short bullets:
- "Swap the sample tax year in `/data/seed-tax-year/` for your own camera roll."
- "Adjust the category schemes in `/server/schemes/` to fit your country's tax filing."
- "Wire up your Gemini API key and Firebase project via the env-var list in the capabilities panel."
- Primary CTA: "Use this template" → links to AI Studio Build remix entry point.
- Secondary: "Just exploring — close" (sets localStorage flag, never auto-shows again).
**Accessibility:** focus trap, `Esc` closes, `role="dialog"`, `aria-modal="true"`, `aria-labelledby`, focus restored to trigger on close. Respect `prefers-reduced-motion`.
**Don't:**
- Don't gate content behind the modal. The page beneath must be fully usable.
- Don't auto-reshow on return visits. Use `localStorage['onboarding-seen-v1']`.
- Don't include unrelated CTAs (newsletter signup, social follow). Keep it about the template only.
## 6c. Capabilities info button (persistent in header)
Add a persistent `(i)` icon in the top-right of the header (next to the primary nav). Click → opens a modal/panel titled **"What powers this app"**.
**Panel contents (in this order):**
**Gemini capabilities used (the hero list):**
- **Gemini 3.5 Flash (multimodal)** — reads thermal-paper printed receipts, handwritten market-stall receipts, screenshots of payment-app confirmations, and scanned PDFs. One call per receipt; returns the full structured `Receipt` object including merchant, date, currency, line items, tax components, total, and a category guess against your chosen scheme.
- **Gemini 3.5 Flash (multilingual)** — parses receipts in Tagalog, Yoruba, Spanish, Brazilian Portuguese, Mandarin, Cantonese, Vietnamese, Tamil, Hindi, Bengali, Urdu, Arabic, Swahili, Amharic, Farsi, Khmer, French, German, and more, with merchant names preserved in their original script.
- **Gemini 3.5 Flash (long context)** — once your year is parsed, the per-category roll-up call sees every receipt at once so the totals are consistent.
- **Gemini 3.5 Flash + grounded search** — looks up historical FX rates for each receipt's date with a citation URL, so the rate the accountant sees is the rate the tax authority will recognise.
- **Gemini 3.5 Flash** — runs the cheap pre-filter pass that sets aside selfies, screenshots of memes, and dog photos before the expensive Gemini 3.5 Flash parse runs.
- **Nano Banana 2 (`gemini-3.1-flash-image`)** — generates the onboarding hero illustration and the empty-state illustration only. Your receipts are never altered; the photograph you took is the photograph we keep, forever.
- **Firebase Auth** — Google and Apple sign-in. Bookkeeper share-links via revocable magic links.
- **Firestore** — stores your tax-year archives, your category schemes, your FX-rate cache. Syncs across devices in real time.
- **Firebase Storage** — keeps the original receipt photographs at upload resolution, forever. Pre-signed URLs only.
- **Cost note** — see the detailed breakdown in 6d. A typical year of 600 receipts costs about $7 of Gemini API spend, total, processed once.
- **Privacy note** — your receipts are private to you and the accountant share-links you choose to create. This app uses the Gemini API on the paid tier, where Google does not use your content for model training, per the Gemini API Additional Terms. We never sell, share, or aggregate your spending data.
**Backend services this app depends on:**
- Auth: see section 4b
- Database: see section 4b
- Storage: see section 4b
- Email: see section 4b
- Payments: see section 4b (not used in v1)
- External APIs: see section 4b
**Environment variables you'll need to configure:**
- `GEMINI_API_KEY` — your Google AI Studio API key
- `FIREBASE_PROJECT_ID` — your Firebase project id
- `FIREBASE_SERVICE_ACCOUNT` — service-account JSON (server-side only)
- `FX_RATE_API_KEY` — optional fallback for the grounded-search FX lookup
**Cost + privacy notes:**
- One short paragraph per cost-sensitive capability: the multimodal parse is billed per image input and output token; a typical receipt costs about $0.011 to parse end-to-end. The pre-filter pass costs ~$0.0008 per photo and is what makes the camera-roll-of-mostly-non-receipts case affordable.
- One short paragraph on privacy: where the data lives (your Firebase project), how to delete it (Settings → "Delete this tax year forever" — gone in 60 seconds), what is never sent for training. The accountant share-links are read-only, revocable, and expire after 90 days by default.
**Documentation links:**
- AI Studio Build docs
- Gemini API multimodal, multilingual, long-context, grounding docs
- Firebase Auth, Firestore, Firebase Storage docs
- A short note on each supported tax-authority category scheme (HMRC, IRS Schedule-C, BIR, ATO, SARS) with a link to the relevant official page
**Accessibility:** same standards as the onboarding modal — focus trap, `Esc`, ARIA, restored focus.
**Behaviour:**
- Always available — single click from anywhere in the app.
- Tooltip on the `(i)` icon: "How this app is built".
- Mobile: opens as a full-screen sheet that slides up.
- Should be the most honest part of the app — never hand-wave service requirements; never say "AI" without naming the specific Gemini model and capability.
## 6d. Detailed cost breakdown (deployer reads this BEFORE shipping)
- **Pre-filter non-receipts (Gemini 3.5 Flash, low thinking)** — one image input, one-sentence output, ~$0.0008/photo. A 600-photo camera roll filters down to ~480 receipts for ~$0.50 in total.
- **Parse one receipt (Gemini 3.5 Flash, medium thinking)** — one image input, ~700 output tokens (the structured Receipt JSON), ~$0.011/receipt. A year of 480 receipts ≈ $5.30.
- **FX rate lookup (Gemini 3.5 Flash + grounded search)** — ~$0.001/lookup, cached per (currency, date). A year touching 6 currencies across 12 months might trigger ~60 lookups ≈ $0.06.
- **Duplicate detection (Gemini 3.5 Flash, low thinking)** — runs once at the end of the batch over the parsed Receipts (no images re-sent), ~$0.05 for a year of 480 receipts.
- **Per-category roll-up (Gemini 3.5 Flash, low thinking, long-context)** — runs at export time over all parsed Receipts; ~$0.30 per export for a 480-receipt year.
- **Onboarding / empty-state illustration (Nano Banana 2)** — generated once per app deployment; ~$0.03/image. Two images total.
- **Expected per-year cost on first ingest:** ~$6-7 for a year of 600 photographs (480 receipts). **Ongoing re-export** (categories tweaked, re-roll-up): ~$0.30 per export.
- **Image storage:** Firebase Storage standard tier, ~$0.026/GB/month. A high-quality receipt photo is ~1.5 MB; a 600-receipt year uses ~0.9 GB ≈ ~$0.02/month.
## 7. Design language
- **Mood:** A kitchen drawer at the end of the year. Not a fintech app. Not an accounting software. The freelancer at the kitchen table with the camera roll open on one phone, a coffee getting cold, and the relief of watching the year resolve itself into a CSV in five minutes instead of three nights.
- **Typography:** Display serif for the category headings and the PDF report (Source Serif Pro). Tabular figures (a monospaced number style) for every figure in the totals view — alignment matters when the user is reading a column of money. Clean grotesque for app chrome (Inter or Geist).
- **Palette:** Bone-paper background `#F4EFE6` for the receipt detail view, deep ink `#1B1714` for body text, soft amber `#C99846` for low-confidence underlines and review-pile flags, faded green `#5A7855` for confirmed totals, muted red `#A33A2C` only for the manual-entry pile prompt ("We couldn't make this out"). A muted blue `#3A5773` for the user's own edits so they cannot be mistaken for model output. Borrowed from a paper file folder, not from SaaS design systems.
- **Imagery:** The photographs of the receipts are the hero. Never replace them; never aggressively enhance them; the user trusts the app partly because the photograph next to the parsed figure is the photograph she took. Slight perspective correction is offered as a toggle, never applied silently. The onboarding hero is a single warm illustration of an open drawer, not a stock-photo of a person at a laptop.
- **Hand-feel touches:** Tabular figures align by decimal point in every total. Currency symbols sit ahead of the figure (₱ 1,400.00) or behind (R 320,00) according to the source locale. Hover on any total reveals the receipt photograph as a small thumbnail preview; click jumps to the detail view.
- **Spacing:** consistent 4-px base. Generous whitespace — the totals page must not feel cramped.
- **Radius:** consistent token set (e.g. 6 / 12 / 20 px). Receipt cards use 6; the category roll-up cards use 12; the welcome card uses 20.
- **Shadows:** subtle, layered, paper-toned. Avoid heavy drop-shadows.
- **Motion:** purposeful — entrance fades, hover lifts, page transitions. Respect `prefers-reduced-motion`. No bouncing splash animations. The roll-up view's per-category bar growing is the one place where motion carries meaning; respect reduced-motion by setting the bars instantly rather than animating.
- **States:** every interactive element has hover, focus, active, disabled. Loading uses skeletons not spinners where possible. Empty states have helpful next-action guidance ("Import your camera roll to start your tax year").
## 8. Content generation rules
- Write **realistic, specific copy**. NO Lorem Ipsum. NO generic placeholders like 'Your tagline here'.
- Invent plausible names, dates, merchants, amounts, line items that fit the domain (use the seed content in section 8a as a starting point). When inventing, lean on the user-anchor cultures of this template — Filipino freelance market in Quezon City, Nigerian-British consulting in London, Mexican-American consulting in Phoenix — and include a few European, African, South Asian, East Asian, and Latin American examples so the demo data does not centre any one country.
- Tone: warm, direct, free of corporate language. This template is for a person at the kitchen table, not a finance department.
- Headlines: punchy and concrete. No 'Empower your X' filler. No 'Revolutionize'. No 'Seamless'. No 'AI-powered receipt management'.
- Body copy: short paragraphs (2-4 sentences). Use lists where appropriate.
- Plain language. Avoid jargon — except where the user already speaks the jargon (the HMRC user wants to see "self-assessment categories" in the scheme picker; the IRS user wants to see "Schedule-C lines"; the BIR user wants to see "allowable deductions").
- Where the app outputs AI-generated content (the merchant name, the category guess), never label it as "AI says" — let it speak naturally. Use small uncertainty cues only where epistemic honesty requires them (a low-confidence total shows as a faintly underlined figure; tapping it reveals the alternates the model considered).
## 8a. Seed content (use these specific examples)
Anchor every generated copy + sample data point in the concrete content below. Use these names, numbers, dates, and snippets verbatim where helpful, or generate close variants that sit in the same world.
**Sample tax-year archives (sidebar):**
- "Maricel's 2025 (PH)" — 491 receipts, BIR Form 1701 scheme, primary currency PHP with occasional USD from Upwork-paid client lunches, demo user is a Filipino freelance designer in Quezon City.
- "Adaeze's FY 2025-26 (UK)" — 612 receipts, HMRC self-assessment scheme, primary currency GBP with EUR from a Berlin client engagement and USD from a New York conference, demo user is a Nigerian-British management consultant in Hackney.
- "Mateo's 2025 (US)" — 458 receipts, IRS Schedule-C scheme, primary currency USD with MXN from a four-day trip to Hermosillo and CAD from a Vancouver client visit, demo user is a Mexican-American small business consultant in Phoenix.
- "Cely's 2025 (PH)" — 287 receipts, bookkeeper-run on behalf of a one-person stationery seller in Cebu, BIR scheme, primary currency PHP only.
**Sample receipts in detail view (these are what the demo should show):**
**Receipt 1 — Tagalog merchant name, Philippine peso, printed thermal:**
- `merchant_name_verbatim`: "Mercury Drug — SM North EDSA"
- `merchant_name_normalised`: "Mercury Drug (SM North EDSA branch)"
- `merchant_country_guess`: "PH"
- `date_printed_verbatim`: "03/14/26 14:22"
- `date_resolved_iso`: "2026-03-14"
- `currency_code`: "PHP", `currency_symbol_verbatim`: "₱"
- `line_items`: "1× Biogesic 500mg tabs (10s) — ₱48.00", "1× Bioflu (10s) — ₱75.00", "1× Strepsils throat lozenges — ₱96.00"
- `subtotal`: 219.00, `tax_components`: [{ label_verbatim: "VAT (incl.)", rate_percent: 12, amount: 23.46 }]
- `total_amount`: 219.00, `payment_method`: "card_unspecified", `payment_method_evidence`: "VISA ****0214"
- `category_suggested`: "Health (personal, non-claimable on BIR)", `category_confidence`: 0.84
- `reading_confidence_overall`: 0.93
**Receipt 2 — handwritten Lagos market-stall receipt, naira, no tax line:**
- `merchant_name_verbatim`: "AISHA STORE — Computer Village, Ikeja"
- `merchant_country_guess`: "NG"
- `date_printed_verbatim`: "12-AUG-25" (handwritten in blue ballpoint)
- `date_resolved_iso`: "2025-08-12"
- `currency_code`: "NGN", `currency_symbol_verbatim`: "₦"
- `line_items`: "USB-C cable 1m — ₦4,500", "HDMI cable 3m — ₦7,500", "Plastic carrier — ₦200"
- `subtotal`: 12200, `tax_components`: [] (no VAT line printed; correct)
- `total_amount`: 12200, `payment_method`: "cash"
- `category_suggested`: "Supplies", `category_confidence`: 0.91
- `category_evidence_quote`: "the line items 'USB-C cable' and 'HDMI cable' suggest office supplies"
- `reading_confidence_overall`: 0.87 (handwriting confidence on the naira amount lowered the overall figure)
**Receipt 3 — GCash payment screenshot, Philippine peso:**
- `artefact_type`: "screenshot_of_payment_app"
- `merchant_name_verbatim`: "JR Printing Services — Maginhawa St"
- `merchant_country_guess`: "PH"
- `date_resolved_iso`: "2026-02-18", `date_source`: "printed" (the GCash screen shows the timestamp)
- `currency_code`: "PHP"
- `line_items`: [] (the payment app shows only the lump-sum transfer)
- `total_amount`: 1800.00, `payment_method`: "mobile_wallet", `payment_method_evidence`: "GCash Ref 8829-1773-0421"
- `user_notes`: "Brochure print run for the Pasig client pitch — should claim under Marketing"
- `category_suggested`: "Marketing", `category_confidence`: 0.78
- `reading_confidence_overall`: 0.96
**Receipt 4 — London cab fare, smudged total, GBP:**
- `merchant_name_verbatim`: "ADDISON LEE"
- `merchant_country_guess`: "GB"
- `date_printed_verbatim`: "14 NOV 2025 18:47"
- `date_resolved_iso`: "2025-11-14"
- `currency_code`: "GBP", `currency_symbol_verbatim`: "£"
- `line_items`: [] (cab receipt prints only total)
- `total_amount`: 47.50, `total_amount_alternates`: [42.50] (the bottom of the 4 is smudged into a 2)
- `payment_method`: "card_credit"
- `category_suggested`: "Client Travel", `category_confidence`: 0.82
- `flagged_for_user_review`: [{ field_path: "total_amount", reason: "the total reads as £47.50 but the second figure could be a 2 — please confirm" }]
- `reading_confidence_overall`: 0.66
**Receipt 5 — Phoenix restaurant client lunch, US dollar, tip and tax separately printed:**
- `merchant_name_verbatim`: "Postino WineCafe — Central Avenue"
- `merchant_country_guess`: "US"
- `date_printed_verbatim`: "10/22/2025 13:14"
- `date_resolved_iso`: "2025-10-22"
- `currency_code`: "USD"
- `line_items`: "Bruschetta board (4 pc) — $14.00", "Caesar salad (split) — $13.00", "2× sparkling water — $8.00", "Espresso — $4.00"
- `subtotal`: 39.00, `tax_components`: [{ label_verbatim: "AZ Sales Tax 8.6%", rate_percent: 8.6, amount: 3.35 }]
- `tip_amount`: 8.00, `total_amount`: 50.35, `payment_method`: "card_credit"
- `category_suggested`: "Meals (50% deductible on Schedule-C, line 24b)", `category_confidence`: 0.88
- `reading_confidence_overall`: 0.94
**Receipt 6 — Hermosillo taco truck, Mexican peso, no tax line, handwritten:**
- `merchant_name_verbatim`: "Tacos El Sahuaro"
- `merchant_country_guess`: "MX"
- `date_printed_verbatim`: "08-OCT-25" (handwritten)
- `date_resolved_iso`: "2025-10-08"
- `currency_code`: "MXN"
- `line_items`: "3× tacos de carne asada — MXN 75", "1× agua de jamaica — MXN 25"
- `total_amount`: 100, `tax_components`: [], `payment_method`: "cash"
- `category_suggested`: "Meals (travel — Schedule-C line 24a)", `category_confidence`: 0.83
- `reading_confidence_overall`: 0.85
**Sample input artefacts (for the build to demonstrate):**
- A faded thermal-paper grocery receipt from SM Supermalls in Quezon City with a Filipino-English merchant name, a 12% VAT line, and a partial smudge over the total.
- A handwritten ballpoint receipt from a market stall in Computer Village, Lagos, with the naira amount written in numerals and the date in DD-MMM-YY format.
- A GCash payment confirmation screenshot at 1170×2532 (iPhone screen dimensions) showing a transfer to a Manila printing service.
- An Addison Lee taxi receipt from a London cab, dot-matrix printed on a small ticket, with a partially smudged total.
- A Postino WineCafe receipt from Phoenix with a separate Arizona sales tax line and a tip line.
- A tacos-truck handwritten receipt from Hermosillo, Mexican-peso amounts, no tax line.
- A British Airways e-receipt PDF for a London-Berlin business flight in GBP with separate APD (Air Passenger Duty) and base-fare lines.
- A Wise transaction-confirmation screenshot for an EUR→GBP transfer to a Berlin web-developer subcontractor.
- A Hackney corner-shop receipt printed in English with sterling amounts and a VAT-registered merchant TIN at the bottom.
- A Cebu stationery wholesaler's printed receipt in Cebuano with line items in English and totals in PHP.
- A Hong Kong cha-chaan-teng receipt with traditional Chinese line items and HKD amounts.
- An Adobe Creative Cloud monthly invoice PDF in USD with a card-payment confirmation block at the bottom.
**Sample voice copy:**
- Onboarding: "Hand over the year's receipts. We'll read them — even the smudged ones."
- Processing — pre-filter: "Setting aside the selfies, the dog photos, and the screenshots that aren't receipts…"
- Processing — parse: "Reading receipt 312 of 491 — Mercury Drug, ₱219.00, 14 March 2026."
- Processing — FX: "Looking up the GBP→USD rate for 14 November 2025 — Bank of England published rate cached."
- Processing — roll-up: "Rolling up totals into your IRS Schedule-C categories…"
- Review pile prompt: "612 photos in. 491 receipts. 47 to review. 12 we couldn't make out."
- Smudged-total prompt: "We read this total as £47.50 but the second figure could be a 2. Want to check the photo?"
- Manual-entry prompt: "This one was hard to read. Type whatever's clearest — we'll keep the photograph next to it."
- Save confirmation: "Added — Mercury Drug, ₱219.00, 14 March 2026. Categorised as Health."
- Duplicate detected: "Looks like you photographed this one twice — once at the till, once at home. Keep one?"
- Empty archive: "This tax year is waiting for its first receipt. Import your camera roll to start."
- Export-ready: "491 receipts. £8,247.32 across 7 categories. CSV and PDF ready when you are."
- Error (couldn't read): "We couldn't make this one out — too blurry, or too faded. Want to retake the photo, or type what you can read?"
- Low-confidence note: "Some figures were hard to read. Tap any underlined number to see what the model considered."
**Sample accountant share email (subject + body):**
- Subject: "Adaeze's FY 2025-26 receipts — ready for the call on Monday"
- Body: "Hi Sam — I've categorised the year against the HMRC self-assessment scheme you sent me. 612 receipts in, 491 after non-receipt filtering, 47 I've reviewed and confirmed, 12 I typed in by hand. Totals are £14,820 across seven categories. Read-only link below — expires in 90 days. Adaeze." [Open Archive (read-only)]
**Sample PDF report cover line:**
- "Adaeze Okonkwo · Tax year 6 April 2025 – 5 April 2026 · HMRC self-assessment categories · 491 receipts · £14,820.47 total · prepared in Receipts Drawer on 14 January 2027."
## 9. Media & assets
- **Hero image (landing screen):** A photographed-looking shot of a kitchen drawer pulled half open at an Asian or Latin American or African kitchen counter, loose receipts spilling out, soft afternoon window light, no people in frame, no readable text on any receipt. Generate via Nano Banana 2 with a prompt emphasising "wooden drawer, warm afternoon light, real worn paper, soft shadow under the drawer, no people, no logos, no specific currency".
- **App icon / wordmark:** Set in the display serif. Slightly worn paper texture behind it. No icon — just type.
- **Empty-state illustration:** A simple line drawing of one curled receipt at the back of an otherwise empty drawer. Hand-drawn aesthetic, not a flat icon.
- **Demo receipt photographs:** Generated per the prompts in section 8a — Nano Banana 2 prompts that specifically request "thermal-paper receipt, slightly curled, slightly faded, no people, no logos, no recognisable brand", "handwritten ballpoint receipt on small notepad paper, blue ink, no people", "iPhone-screen-sized screenshot of a payment-app confirmation, no real bank logo, abstract app chrome". Each demo receipt should look photographed, not rendered.
- **Stock fallbacks:** If image generation fails, fall back to the photographed sample receipt from `/public/samples/sample-receipt.jpg`. Never to a "🧾" emoji.
- **Generated imagery:** prefer Nano Banana 2 over stock photography. Prompt for warmth, asymmetry, and slight imperfection — avoid the glossy 'AI render' look.
- **Optimisation:** WebP/AVIF, `loading="lazy"`, explicit `width`/`height` to prevent layout shift.
- **Icons:** `lucide-react` for UI. Use sparingly — never decorative-only.
### Build-time asset manifest (explicit specs)
Every image, illustration, and visual reference mentioned above must resolve to ONE of the three buckets below — runtime-generated, seed-shipped, or user-supplied. Do NOT ship `` tags whose `src` is not listed here. Do NOT depend on bare "section 8a prompts" without binding them to explicit paths and model IDs.
**Bucket 1 — Runtime-generated (Nano Banana Pro `gemini-3-pro-image` for hero/demo photographs; Nano Banana 2 `gemini-3.1-flash-image` for in-app illustrations and reference-conditioned variants).** Cached to Firebase Storage; served via signed URL. Every reference above to "Nano Banana 2" or "Nano Banana Pro" MUST be wired to one of these specific calls with an explicit model id:
- `/public/generated/hero.webp` (2400×1500, WebP) — model `gemini-3-pro-image` — uses the literal prompt described as "Hero image (landing screen)" above. Run once at build; commit a `/public/samples/hero-fallback.webp` (1600×1000) generated from the same prompt with `gemini-3.1-flash-image` so the page renders if quota is exhausted.
- `/public/generated/demo/{demo-slug}-{NN}.webp` (1600×1200, WebP) — model `gemini-3.1-flash-image` (reference-conditioned where the prior frame is passed as input) — one path per "Demo X" image referenced above. The slug derives from the seed example in section 8a; the NN index covers each frame in the demo sequence.
- `/public/generated/illustrations/{name}.webp` (1024×1024, WebP) — model `gemini-3.1-flash-image` — one path per named illustration above ("Empty-state illustration", "Recipe-card hero illustrations", "Curriculum picker imagery", "Period-style frames", etc.). Each illustration's prompt is the literal description above; ship a deterministic seed in the request so re-runs are reproducible.
**Bucket 2 — Seed assets shipped with the deliverable.** Every "Stock fallback" path referenced above (e.g. `/public/samples/sample-X.jpg`) is generated once via Nano Banana 2 (`gemini-3.1-flash-image`) at 1024×1024 WebP using the same prompt as its Bucket-1 counterpart, then committed to the repo so the page renders identically if Gemini quota is exhausted or the user is offline. Replace any `.jpg` extension above with `.webp` to match the optimisation rule. Also commit these empty-state seeds (1024×1024 WebP, single-stroke hand-drawn line, no colour fill):
- `/public/samples/empty-state-primary.webp` — line drawing of the app's primary empty surface (the named "Empty-state illustration" above), generated from that exact prompt.
- `/public/samples/empty-state-archive.webp` — line drawing of an empty saved/archive view, single-stroke outline.
- `/public/samples/empty-state-error.webp` — line drawing of a hand placing a single object aside with care, used when an AI call fails.
**Bucket 3 — User-supplied.** Uploads from the user's camera / file picker land at the Firebase Storage path conventional for this template (named in section 4b). The build ships with Bucket-1 + Bucket-2 only; no user-supplied images at first paint.
**Hard rules**
- Every `` tag MUST have a `src` that resolves to a path listed in Bucket 1, Bucket 2, or a Bucket 3 upload path. Anything else is a build error.
- No bare `image.jpg` / `hero.jpg` / `placeholder.png` references anywhere in the code.
- Model IDs: `gemini-3-pro-image` for hero-quality photographic generation; `gemini-3.1-flash-image` for in-app illustrations, reference-conditioned variants, empty-state seeds, and stock fallbacks. Never use a legacy model id (no `imagen-*`, no `gemini-1.5-*-image`).
- File format: WebP everywhere (AVIF acceptable where the target browsers support it). No `.jpg` / `.jpeg` / `.png` in `/public/samples/`.
## 10. Interactivity & states
- Every interactive element has hover, focus, active, and disabled states.
- Forms validate inline and show specific error messages (not "Invalid input"). The manual-entry total field validates against the currency picker — "Enter a positive amount in PHP" rather than "Invalid input".
- Loading states use skeletons that match the eventual layout, not spinners.
- Empty states explain the next action with a button whose label fits THIS app's domain: "Import your camera roll", "Drop your scanned PDFs", "Forward a receipt by email", "Type in your first receipt by hand" — never a generic "Add your first item".
- Smooth scroll for in-page anchors.
- All AI-generated content streams in token-by-token where supported, with a clear "thinking…" indicator before content starts arriving.
- If an AI call fails, show a calm, specific error ("We couldn't read this receipt — try a clearer photo, or type the merchant, date, and total in?") and offer retry.
- Low-confidence figures in the totals view are faintly underlined; tapping reveals the alternates the model considered.
- The per-category roll-up bars grow from zero to their final width over 600 ms with `prefers-reduced-motion` falling back to instant.
## 11. Tech & responsive requirements
- **Deterministic arithmetic — server-side TypeScript only.** Any money-split / settlement / total / VAT / unit-conversion math runs in TypeScript on the server using integer-cents arithmetic. The Gemini call only extracts entities (line items, payer, currency code, raw quantities) into structured JSON. Never let the model do the sum — Gemini will return plausible-looking arithmetic that does not actually balance.
- **File downloads on Safari / Firefox:** when offering local-disk save of any export (PDF, CSV, MP3, ZIP, JSON, image), fall back to `` with a blob URL — the File System Access API (`showSaveFilePicker()`) is Chromium-only. Detect with `'showSaveFilePicker' in window`; otherwise use the anchor-download path.
- **Stack:** React + TypeScript + Tailwind CSS. Functional components + hooks. Use Shadcn UI primitives where appropriate.
- **Build runtime:** AI Studio Build — full-stack with Cloud Run server-side functions. All Gemini API calls happen server-side; API key lives in Secrets Manager, never in client bundle.
- **Model selection:** explicitly pin `gemini-3.5-flash` for the primary receipt parse and the per-category roll-up; `gemini-3.5-flash` for the pre-filter, the FX rate lookup, and duplicate detection; `gemini-3.1-flash-image` for the onboarding and empty-state illustrations. Set `thinkingLevel` explicitly per call and omit `thinkingConfig` entirely on the image-generation call.
- **Database:** Firestore (auto-provisioned by AI Studio Build). Show the seed tax year on first launch.
- **Auth:** Firebase Auth — Google sign-in by default; Apple sign-in next to it; accountant share-links use revocable magic-link tokens.
- **Storage:** Firebase Storage for original receipt photographs. Pre-signed URLs only.
- **Mobile-first.** Verify layouts at 375 px (iPhone SE), 768 px (iPad), 1024 px, 1440 px+.
- Use `clamp()` for fluid typography. Prefer container queries over media queries for component-level responsiveness.
- Use `dvh` / `svh` instead of `vh`. Respect safe-area insets on iOS.
- Zero horizontal overflow at any width. Zero layout shift on load.
- Persist user data in Firestore. Use real-time listeners on the processing queue view so the user sees receipts complete in real time.
- Optimistic UI on writes; reconcile on response.
- Camera-roll import uses the Web Photos API where supported (iOS Safari, Android Chrome) and the file-picker as a fallback.
- Batches of more than 200 photos process in server-side queues with progress reported via Firestore listeners. The user can close the app and come back; the queue continues.
## 12. Accessibility (WCAG 2.2 AA)
- Semantic HTML — `header`, `nav`, `main`, `section`, `article`, `footer`.
- All interactive controls reachable by keyboard with a visible focus ring.
- Color contrast ≥ 4.5:1 for body, 3:1 for large text and UI components. The soft-amber low-confidence underline and the faded-green confirmed-total chip are both contrast-checked against the bone-paper background.
- All images have meaningful `alt` text. The original receipt photographs have `alt` describing the artefact ("photograph of a thermal-paper receipt from Mercury Drug dated 14 March 2026, total ₱219.00"). The model-generated alt-text comes from the Receipt's parsed fields, so it stays accurate.
- Form fields have associated `