North-Star Stack (decision lock)¶
Project identity: poutineAI (GCP project id: poutineai)
Frontend (SPA for dashboard & approvals only)¶
- React 18 + Vite (dev server + prod bundler; code-split via import() & React.lazy)
- TanStack Query for server-state (fetch, cache, background refetch, mutations, invalidation)
- React-Intl (FormatJS) for French-first i18n (dates, numbers, messages)
- React Router with route-based lazy loading for code-splitting
- Sentry JS from day one (errors + perf sampling)
Backend (FastAPI on GCP)¶
- FastAPI (REST), Pydantic v2 models; JWT access + refresh tokens; passwordless magic links (short-lived, single-use)
- PostgreSQL on Cloud SQL (private IP) behind Serverless VPC Access; connect from Cloud Run via Cloud SQL Python Connector (private IP mode)
- Google Cloud Storage (GCS) for invoice files, served via V4 signed URLs (time-limited)
- Inbound email: Mailgun Routes (EU region for routing/logs) with HMAC-SHA256 signature verification on webhooks; inbound domain
inbound.poutineai.com - WhatsApp ingestion: Twilio webhook for inbound media (photos/PDFs) with X-Twilio-Signature verification
- OCR: Google Document AI Invoice Parser (specialized processor; supports uptraining later)
- Pub/Sub for async OCR pipeline (at-least-once → make workers idempotent)
- Sentry Python for error monitoring in FastAPI
Ops (MVP-light)¶
- Cloud Run services: api (FastAPI) + worker (OCR/async)
- SPA built by Vite → static assets served either:
- a) from Cloud Run (api) under /app for one-box simplicity, or
- b) from Cloud Storage + Cloud CDN when you're ready (Vite emits static bundles)
- Secret Manager for API keys; Cloud Logging by default; Sentry for prod visibility
- CI/CD: GitHub Actions for build, test, and deploy to Cloud Run via OIDC (no long-lived keys)
1) System Overview & Data Flow¶
Core user loop¶
Invoice arrives:¶
Email → Mailgun Routes posts to /webhooks/email (multipart w/ attachments). Verify HMAC-SHA256 signature (timestamp, token, signature); reject if invalid/stale.
WhatsApp → Twilio posts to /webhooks/wa with media URLs and parameters like MediaUrl0, From (whatsapp:+E164) etc. Verify X-Twilio-Signature.
Webhook handler uploads files to GCS, stores invoice(status=processing) row, then publishes doc.ingested to Pub/Sub (payload: invoice_id, gcs_uri, mime, source). Idempotency: use provider's unique event ID + our hash; ignore duplicates. At-least-once means handlers are idempotent.
worker (Cloud Run, Pub/Sub subscription) pulls message → calls Document AI Invoice Parser (Montréal region) → writes extracted fields (supplier, invoice no, dates, totals, taxes, optional line items), sets status=awaiting_approval, logs Audit entry. (Uptraining later improves accuracy).
SPA dashboard fetches via TanStack Query (GET /api/invoices?status=pending), shows Awaiting Approval; user approves/rejects; mutations invalidate queries for instant UX.
Export: user runs Excel export → FastAPI streams XLSX built with openpyxl or XlsxWriter.
Payment stub: UI shows "Pay" (coming soon); user can Mark as paid; audit recorded.
Storage/access¶
Original PDFs/images in GCS, delivered to browser via time-boxed signed URLs (no public bucket).
2) Frontend Architecture (React + Vite + TanStack Query + React-Intl)¶
2.1 App layout & routing¶
React Router with route-level lazy:
- /(app)/dashboard (SPA) — lazy chunk
- /invoice/:id (detail) — lazy chunk
- /settings, /exports — small pages; can be SSR later
Code-split with React.lazy + Suspense to keep initial JS small; Vite auto-splits dynamic imports.
2.2 Data fetching & caching¶
TanStack Query provides declarative server-state: queries for lists/details; mutations for approve/reject; optimistic updates, background refetch on focus, stale-time tuning per endpoint.
2.3 i18n (French-first)¶
React-Intl (FormatJS) for strings, fr-CA number/currency/date formatting. Lazy-load message catalogs per route to keep bundles small.
2.4 Error & performance monitoring¶
@sentry/react initialized in main.tsx with DSN, release, environment, and sampling config. Capture route transitions and failed queries.
2.5 Build & deploy¶
Vite commands: vite dev, vite build (emits static site). Artifacts can be served by the API service (Nginx/Starlette static) initially; later move to Cloud Storage + Cloud CDN.
3) Backend Architecture (FastAPI on Cloud Run)¶
3.1 Services¶
api (FastAPI): REST, webhooks, auth, signed URL broker, exports. Deployed to Cloud Run.
worker (FastAPI or plain Python): Pub/Sub subscriber; OCR & extraction; uptraining tasks later. Also on Cloud Run.
3.2 Connectivity & security (GCP)¶
Cloud SQL (Postgres) over private IP + Serverless VPC Access so Cloud Run reaches DB securely; use Cloud SQL Python Connector with ip_type="private".
GCS signed URLs to view files; set short TTLs; only GET; server generates URL per request.
Secrets in Secret Manager; no secrets in images.
3.3 Auth & sessions¶
Passwordless magic links:
- POST /auth/magic-link (email) → generate single-use token (short TTL), email link.
- GET /auth/verify?token=… → validate (nonce + exp), set HTTP-only cookie with short-lived JWT access token + refresh token (consider rotation later to harden).
- Access: Bearer JWT for API (FastAPI security utilities), scopes optional.
Best practices: short-lived access tokens; protect refresh tokens; rotate on use (when you add rotation).
3.4 Ingestion webhooks (security-first)¶
Mailgun Routes: verify HMAC signature headers; handle multipart with attachments; reject link-only emails or auto-respond asking for PDF.
Twilio WhatsApp: verify X-Twilio-Signature; parse media parameters (NumMedia, MediaUrl0, etc.), support PDFs/photos from WhatsApp; save sender E.164 for account routing.
3.5 OCR pipeline (async, idempotent)¶
Publish doc.ingested (invoice_id, gcs_uri, sha256) to Pub/Sub; worker processes message; set idempotency key as (provider_event_id || sha256) to ignore duplicates (at-least-once delivery).
Document AI Invoice Parser (northamerica-northeast1): extract supplier, invoice no, dates, totals, taxes, and line items (for V2 "price-hike alerts"); consider uptraining to improve accuracy on your suppliers.
3.6 API surface (selected)¶
- GET /api/invoices?status=&q=&due_before= – list (pagination).
- GET /api/invoices/{id} – details + GCS signed URL for file.
- POST /api/invoices/{id}/approve – approve; audit trail; optional rule checks.
- POST /api/invoices/{id}/reject – reject with note; audit.
- POST /api/invoices/{id}/mark-paid – payment stub; audit.
- GET /api/exports?from=&to= – streams XLSX (openpyxl/XlsxWriter).
Use Pydantic for request/response models; mirror with Zod on the SPA for exact schema parity (generate types from OpenAPI or maintain manually).
4) Data Model (MVP slice)¶
tenant / user¶
- restaurant(id, name, slug_email, timezone, locale='fr-CA')
- user(id, email, phone_e164, is_owner)
- user_restaurant(user_id, restaurant_id, role) // Owner, Approver
invoices¶
- invoice(id, restaurant_id, supplier, invoice_no, issue_date, due_date, total, tax_total, currency, status ENUM[pending, awaiting_approval, approved, rejected, paid], source ENUM[email, whatsapp], gcs_uri, extract_confidence, created_at)
- invoice_line(id, invoice_id, description, qty, unit_price, line_total) (store now for V2 price alerts)
- invoice_approval(id, invoice_id, user_id, decision, note, decided_at)
- approval_rule(id, restaurant_id, supplier_match?, category?, min_amount?, approvals_required) (simple in MVP)
- audit_log(id, restaurant_id, invoice_id?, user_id?, event, metadata_json, ts)
Indexes: (restaurant_id, status, due_date), (restaurant_id, supplier), (invoice_no, restaurant_id) for duplicate detection later.
5) Webhook & Worker Idempotency¶
Email/WhatsApp webhooks: persist provider event id and body hash; return 2xx on duplicates.
Pub/Sub is at-least-once by default; handlers must be idempotent. Use message.messageId and our invoice_id as de-dupe keys in a small processed-messages table.
6) Security Controls¶
- Transport: HTTPS everywhere.
- AuthN: magic links (short TTL, single-use), JWT access + refresh; plan refresh-token rotation for hardening.
- AuthZ: tenant isolation by restaurant_id in every query; roles Owner/Approver enforced on endpoints.
- Secrets: GCP Secret Manager; principle of least privilege service accounts.
- Storage: no public GCS; only signed URLs with short expiry; force content-disposition: inline for PDFs as needed.
- Webhooks: verify signatures (Mailgun HMAC-SHA256, Twilio signature) before parsing; ignore if stale timestamp.
- DB: private IP, Serverless VPC Access to Cloud SQL; use Python Connector or direct private IP with IAM auth.
- Logs: redact PII; Sentry sampling in prod.
7) Observability¶
Sentry on SPA & API (errors + performance traces). React: @sentry/react with BrowserTracing; FastAPI: sentry_sdk.init(...).
Cloud Logging default on Cloud Run.
Session replay (optional): OpenReplay later if needed; privacy guardrails required.
8) Deploy & Environments (MVP-light)¶
Build¶
- FE: vite build → /dist
- BE: Docker image (multi-stage) → Artifact Registry
Run¶
- Cloud Run: deploy api and worker images; set env vars (DB conn, GCS bucket, DocAI processor, Mailgun/Twilio secrets).
- Cloud SQL: enable private IP, create Serverless VPC Access connector, allow api and worker to connect over private IP.
- GCS: create bucket; service account with minimal roles; enable signed URL code path.
- Pub/Sub: doc.ingested topic; worker subscription. Default at-least-once.
Serving SPA¶
- Phase 1 (simplest): serve /app/* from api (static mount of Vite /dist).
- Phase 2: upload /dist to GCS + Cloud CDN (still talk to api on Cloud Run).
9) API Schemas (Pydantic ↔ Zod parity)¶
Example (trimmed):
Pydantic (BE)¶
class InvoiceOut(BaseModel):
id: str
restaurant_id: str
supplier: str
invoice_no: str | None
issue_date: date | None
due_date: date | None
currency: str = "CAD"
total: Decimal
tax_total: Decimal | None
status: Literal["pending","awaiting_approval","approved","rejected","paid"]
file_url: HttpUrl | None # GCS signed URL
Pydantic guarantees parsed output matches defined types; reject/transform invalid inputs.
Zod (FE)¶
export const InvoiceOut = z.object({
id: z.string(),
restaurant_id: z.string(),
supplier: z.string(),
invoice_no: z.string().nullable(),
issue_date: z.string().nullable(),
due_date: z.string().nullable(),
currency: z.string().default("CAD"),
total: z.number(),
tax_total: z.number().nullable(),
status: z.enum(["pending","awaiting_approval","approved","rejected","paid"]),
file_url: z.string().url().nullable()
});
Zod validates at runtime and gives typed results to React code.
10) Frontend UX specifics (SPA area)¶
Dashboard: table w/ checkbox multi-select; instant running total (client-side state); filters and search update in place; details open in drawer with original PDF (signed URL) + extracted fields.
Approve/Reject: optimistic mutation; on success → invalidate list; on error → rollback toast. Powered by TanStack Query patterns.
Code-split heavy views (invoice detail, exports wizard) to reduce first paint; lazy via React.lazy() and Suspense.
i18n: default fr-CA; number & currency formats from Intl; lazy-load messages.
11) Testing & Quality Gates¶
- Contract tests: generate OpenAPI → check Zod schemas line up with Pydantic (small smoke tests).
- Webhook tests: verify signature paths for Mailgun/Twilio with sample payloads; enforce idempotency.
- E2E: Playwright on "capture → approve → export" happy path.
12) "Pay Later" Futures (designed in)¶
Price-Hike Alerts: extracting/storing line items now lets you add a background job to compare current vs historical price per item & supplier; alert via dashboard banner or email. (Document AI supports line items + uptraining).
Payments Module: add a payments service + real bank/card rails; keep stub UI; audit trail already modeled.
13) Risks & Mitigations¶
- OCR accuracy variance → "side-by-side verify", low-confidence highlights; consider uptraining as data accrues.
- Webhook spoofing → signature verification required; reject on invalid/expired timestamp.
- Dupes (retry storms) → idempotent webhook + worker design; Pub/Sub at-least-once noted.
- Bundle bloat → enforce code-splitting & lazy routes; monitor Sentry performance; Vite dynamic imports.
Appendix A — Minimal infra checklist (copy/paste)¶
- GCP Project: use project id
poutineaifor all resources and service accounts. - Cloud SQL (Postgres): enable private IP; create Serverless VPC Access connector; bind to both api & worker.
- Cloud Run: deploy api, worker with least-priv service accounts; set DB_CONN, BUCKET, DOC_AI_PROCESSOR, SENDGRID/TWILIO secrets.
- GCS: create bucket; block public; lifecycle; use V4 signed URLs.
- Pub/Sub: doc.ingested topic + subscription; dead-letter optional.
- Mailgun: inbound domain/subdomain → webhook URL; signature check. Outbound sending domain
mg.poutineai.comwith SPF/DKIM/DMARC. - Twilio WhatsApp: sandbox for dev; set inbound webhook URL; signature check.
- Sentry: DSNs for SPA + API; prod sampling 0.1–0.3.
Why this will feel fast & "boring-and-correct"¶
- Vite keeps DX snappy and outputs optimized static assets; React.lazy + Suspense means you ship only what a user needs now.
- TanStack Query removes 90% of boilerplate around server-state and keeps the dashboard feeling live.
- Document AI Invoice Parser is purpose-built for invoices and uptrainable with your data for accuracy.
- Cloud Run + Cloud SQL (private IP) is the cleanest serverless path on GCP for a secure, autoscaling Python API.
If you want, I can follow up with endpoint-by-endpoint OpenAPI stubs and a repo layout (monorepo with /frontend and /services/api,/services/worker) tuned to this spec.