Optiqo

System architecture

The stack, the data flow, and the security model — the parts of Optiqo that exist below the URL surface.

Stack overview

  • Frontend: Next.js 15 App Router, React 19, TypeScript strict, Tailwind CSS v3
  • Engine: Pure-TypeScript package (@swiss-tax/engine) with Decimal.js for money math, no I/O — embeddable in any runtime
  • Database: PostgreSQL 16 via Drizzle ORM (idempotent migrations + seed)
  • Auth: NextAuth v5 (Auth.js) with Resend magic links
  • Payments: Stripe Checkout (Card + TWINT)
  • AI extraction: Anthropic Claude Sonnet (PDF → structured fields, AHV-redacted)
  • Encryption: AES-256-GCM with per-file 12-byte nonce
  • Internationalisation: next-intl (EN / DE / FR)
  • Build: pnpm workspaces + Turborepo
  • Hosting: Self-hosted Docker on Hetzner Cloud (Germany), Tailscale-bound during dev

Repository layout

swiss-tax/
├── apps/
│   └── web/                       — Next.js app (UI + API routes)
│       ├── src/
│       │   ├── app/               — App-router pages (60 SSG/SSR routes)
│       │   ├── components/        — shared React
│       │   ├── lib/               — atlas helpers, fonts
│       │   └── server/            — db, auth, gate, debug
│       └── drizzle/               — DB migrations
├── packages/
│   └── tax-engine/                — Pure-TS engine
│       ├── src/
│       │   ├── cantonal/          — 26 canton scales (verified)
│       │   ├── communal/          — bundled commune JSON
│       │   ├── core/              — compute, scale, levers
│       │   ├── federal/           — federal scale
│       │   ├── levers/            — LPP, 3a, combo, multi-year, move
│       │   ├── news/              — front-page ticker items
│       │   └── social/            — social-insurance constants
│       └── test/                  — 57 Vitest tests
├── tooling/scripts/
│   ├── fetch-communes.ts          — ingestion pipeline (CSV/PDF/XLS/JSON)
│   ├── build-canton-map.ts        — TopoJSON → SVG paths
│   └── lib/pdf.ts                 — pdftotext wrapper
└── deploy/
    ├── entrypoint.sh              — migrations + seed on container boot
    ├── sync.sh                    — rsync + docker compose up
    └── cron-refresh.sh            — yearly data refresh

Data flow — request lifecycle

  1. Browser: user lands on a route (e.g. /commune/355).
  2. Next.js standalone server (Docker container, port 3000): receives the request.
  3. Authentication (only for paid routes): the gate (requirePaidUser) checks the session token and looks up the user in Postgres.
  4. Computation: imports @swiss-tax/engine, calls pure functions to compute tax / lever results. No external I/O during compute.
  5. Data lookup: commune data comes from the bundled communes.generated.json (loaded at module init); canton scales from per-canton TS files. Postgres is not on the compute path — it holds user profile, uploads, and a mirror of the same commune data for admin/SEO.
  6. Rendering: server-side React renders the page; client-side hydration for interactive elements (commune search, canton-list filter, news ticker, calculator form).
  7. Response: HTML (for SSG/SSR routes), JSON (for API routes), or PNG (for OG images via next/og).

Deployment topology

                    ┌──────────────────────────────┐
   Internet ──HTTPS─┤  optiqo.ch (DNS)           │
                    │   ↓                          │
                    │  Cloudflare (optional CDN)   │
                    │   ↓                          │
                    │  Hetzner Cloud Server (DE)   │
                    │   ┌────────────────────────┐ │
                    │   │ Docker network         │ │
                    │   │  ┌──────────────┐      │ │
                    │   │  │ swisstax-web │←─────┼─┼── Tailscale (dev)
                    │   │  │ (Next.js)    │      │ │
                    │   │  └──────┬───────┘      │ │
                    │   │         │ TCP 5432     │ │
                    │   │  ┌──────▼───────┐      │ │
                    │   │  │ swisstax-db  │      │ │
                    │   │  │ (Postgres16) │      │ │
                    │   │  └──────────────┘      │ │
                    │   └────────────────────────┘ │
                    │  ./data/uploads  (bind mount)│
                    └──────────────────────────────┘

Both containers are private. swisstax-web exposes port 3000 (mapped to host port 3010 bound to Tailscale IP100.86.112.23 during development; public 80/443 via Cloudflare in production).

Boot sequence

  1. entrypoint.sh checks STORAGE_KEY is set (fatal if not — at-rest encryption requires it).
  2. Creates upload root with mode 700.
  3. Runs Drizzle migrations against Postgres (idempotent — tracks applied via __drizzle_migrations).
  4. Runs the seed script — commune_rate table is wiped + repopulated from the bundled JSON snapshot inside a single transaction.
  5. tax_news table is upserted from the bundled news JSON.
  6. Next.js server starts on port 3000.
  7. Hourly purge worker schedules — sweeps encrypted blobs older than 30 days.

Security model

The threat model assumes:

  • Adversary with cold storage access: an attacker who steals a backup or a hard drive cannot decrypt uploaded documents — the master key is in process memory only.
  • Adversary with database read access: structured extracted fields are stored separately from encrypted blobs. Theextracted_fields JSON contains only numbers (gross salary, BVG contribution); AHV/NAVS13 numbers, IBANs, addresses are explicitly redacted before persist.
  • Adversary with session-cookie theft: cookies are HTTP-only, secure, same-site Lax. Sessions are revocable from the dashboard.
  • Insider with server access: log lines do not capture form bodies; secrets come from environment variables (Docker compose) and are not echoed.

Caching

  • SSG: 60 routes pre-rendered at build time (home, atlas, 26 canton pages, ~2000 commune pages with generateStaticParams).
  • OG images: generated on first request, cached by Next.js's opengraph-image route until next deploy.
  • CSS/JS: hashed asset names with 1-year cache headers from Next.js.
  • Engine JSON snapshot: bundled at build time (12 KB canton paths, ~250 KB commune data).

Observability

Container logs go to Docker's stdout/stderr (collected by standard Docker logging). Migrations, seed runs, and the hourly purge worker write to the same stream with prefixes.

No external APM / logging service is wired today. For production we'd add Sentry for errors and a simple Prometheus exporter for uptime — both are on the “Sprint 4” backlog.

Performance targets

  • p50 SSG hit: under 100 ms TTFB (no DB; just file read + render)
  • p50 calculator: under 200 ms (engine compute + JSON serialise)
  • p50 OG image: under 500 ms (font load + render + PNG encode)
  • Total bundle (first load): 105 KB shared + 0-15 KB per route