Skip to content

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 poutineai for 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.com with 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.