Files
MichilisandClaude Opus 5 ae2ea20b7e phase-0: foundation, both apps boot end to end
Monorepo (pnpm workspaces) with two deployable apps and three pure packages.

apps/api (Hono on Node): Zod validated env that fails fast and names the problem,
Kysely factories for SQLite and Postgres chosen by DATABASE_URL scheme, portable
migrations covering the whole SPEC section 5 schema, Better Auth with the four
roles and seeded demo accounts, localized error envelope, /healthz and /readyz,
graceful SIGTERM drain. Dialect specific SQL is confined to the two factories.

apps/web (Next.js App Router): locale routed shell in es and en with a language
switcher, sign in screen, and a runtime /api proxy so the browser only ever sees
one origin and cookies stay first party.

packages/i18n ships both catalogs complete; es is generated from COPY.md and a
test re-derives it from the document on every run so it cannot drift.
packages/contracts holds the Zod schemas and the typed client the web app uses.

Verified: 43 vitest tests, 14 Playwright tests on mobile and desktop, typecheck
and lint clean, migrate and seed from a clean database, sign in through the proxy
with CSRF rejection of foreign origins.

Not verified here: docker compose. This user has no access to the docker socket.

RULES.md is absent from docs/, so packages/rules exports only RULES_VERSION and
no tax rule, check digit or deadline was invented. See DECISIONS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 21:46:35 +00:00

244 lines
17 KiB
Markdown

# SPEC.md: Paraguay Tax Platform v1, Technical Specification
Companion to PROMPT.md (constraints, phases), FLOWS.md (UX), RULES.md (tax logic), COPY.md (copy/i18n), CONTRACTS.md (API shapes, seed). This file defines architecture, data, modules, configuration and deployment.
---
## 1. Purpose and domain summary
Users are Paraguayan taxpayers identified by RUC (companies/independents) or CI (individuals). The platform ingests purchase/sale comprobantes, classifies them for IVA credit and IRP deductions, shows a live tax position, and produces pre-filled declarations:
- **Formulario 120:** monthly IVA declaration (debito from sales, credito from purchases).
- **Formulario 515:** annual IRP-RSP declaration (income minus deductible personal/family expenses).
Deadlines follow the **calendario perpetuo** (RULES.md section 3). Tax math, CDC parsing, classification and form computation live exclusively in `packages/rules` (RULES.md is authoritative). All money is integer guaranies, no floats anywhere in money paths.
---
## 2. Architecture overview
```
Browser ──► apps/web (Next.js, stateless)
│ Next rewrites: /api/* ──► API_INTERNAL_URL
▼
apps/api (Hono on Node, stateless)
├── Better Auth (sessions in DB)
├── Kysely ──► SQLite file OR Postgres
├── StorageDriver ──► local disk OR S3-compatible
├── Jobs (portable table + poller; inline or dedicated worker)
└── Notifications (push/email/telegram fan-out)
```
- `apps/api` is the ONLY process touching the database and storage. It serves everything under `/api`, including the Better Auth routes.
- `apps/web` renders UI, holds zero secrets beyond `API_INTERNAL_URL`, and consumes the typed client from `packages/contracts`. Server Components may call the API server-side (forwarding cookies); all mutations go through TanStack Query on the client.
- Single-origin model: the browser only ever sees the web origin; the web app proxies `/api/*`. No CORS in v1. Exposing the API directly (mobile apps) is a future concern; do not add CORS scaffolding now.
- The dedicated worker is the same `apps/api` image started with `ROLE=worker`: it runs migrations check, the job poller and sweeps, and serves only `/healthz`. With `ROLE=server` (default) the poller runs inline only when `JOBS_INLINE=true` (default true, set false when a dedicated worker exists).
## 3. Stack
| Layer | Choice |
|---|---|
| API | Hono on Node 22, `@hono/node-server` |
| Frontend | Next.js latest stable, App Router |
| Language | TypeScript strict everywhere |
| DB | Kysely; SQLite (better-sqlite3) default, Postgres (pg) supported |
| Auth | Better Auth in apps/api (email+password, email OTP, admin plugin) |
| i18n | packages/i18n catalogs; next-intl in web; same catalogs in api for errors/notifications |
| Server state | TanStack Query over the typed hono/client |
| UI | Tailwind + shadcn/ui + boneyard + GSAP + canvas-ui (2 spots) |
| Validation | Zod at every boundary |
| PDF | pdf-lib |
| QR | BarcodeDetector + zxing-wasm fallback (client-side) |
| OCR | Anthropic API behind OcrProvider; disabled gracefully without key |
| Jobs | Portable jobs table + poller (section 10) |
| Tests | Vitest, Playwright; CI matrix sqlite+postgres |
| Monorepo | pnpm workspaces: apps/api, apps/web, packages/rules, packages/contracts, packages/i18n |
Pure packages (`rules`, `contracts`, `i18n`) have zero runtime deps besides Zod, no I/O, importable everywhere.
## 4. Repository layout and module boundaries
```
/apps/api
/src
/modules
/pii profiles, dependents, consents (ONLY module touching pii tables)
/documents comprobantes, files, ingestion pipeline
/classification rule application, bandeja logic
/declarations F120/F515 assembly, PDF
/deadlines calendario perpetuo, upcoming obligations
/notifications push/email/telegram fan-out, localized templates
/audit append-only audit writes + queries
/jobs poller, claim, handlers, sweeps
/storage StorageDriver: local | s3
/admin admin services (compose pii+audit)
/auth better-auth config, role guards
/db kysely factories, migrations, seed
/http hono app, routes, error envelope, healthz/readyz
/lib env, cdc helpers, dates, formatGs (re-export from i18n)
Dockerfile
/apps/web
/app
/[locale]
/(marketing) landing, RUC hook, legal
/(auth) login, register, verify
/(app) inicio, bandeja, comprobantes, declaraciones, vencimientos, perfil
/(admin) usuarios, errores, auditoria
/src (components, query hooks, i18n wiring, pwa)
Dockerfile
/packages/rules tax math, form definitions, calendario (pure, RULES.md)
/packages/contracts zod schemas + typed client (CONTRACTS.md)
/packages/i18n catalogs es/en, typed t(), formatGs, date formatting per locale
/deploy
docker-compose.yml combined mode
/k8s api.yaml, web.yaml, worker.yaml, ingress.yaml, configmap-example.yaml
```
**Boundary rules (eslint no-restricted-imports):** only `modules/pii` imports pii table types; UI never imports Kysely (web cannot: no DB deps in its package.json); `packages/*` import nothing from apps; user-facing strings only via `packages/i18n` (lint rule bans string literals in JSX text positions outside catalogs, allowlist for punctuation).
## 5. Database
Dialect from `DATABASE_URL` scheme. One Kysely `Database` interface (`apps/api/src/db/schema.ts`); factories `createSqliteDb` / `createPostgresDb`. Portable migrations via Kysely migrator:
- ids text UUIDv7 (app-generated), timestamps text ISO-8601 UTC, money integer guaranies, json text + Zod parse helper, booleans integer 0/1.
- Dialect-specific SQL only in the two factories and `jobs/claim.ts`.
- SQLite pragmas at boot: `journal_mode=WAL`, `busy_timeout=5000`, `foreign_keys=ON`.
- CI runs the whole suite on both dialects.
**Tables** (Better Auth manages its own; locale note: `profiles.locale` drives all server-side localization):
`profiles` (pii): `user_id` PK/FK, `full_name`, `doc_type` ('ruc'|'ci'), `ruc`, `ruc_dv`, `ci`, `taxpayer_kind` ('individual'|'company'), `deadline_digit` 0-9, `obligations` json [{code:'iva_120'|'irp_515', active, since}], `irp_gross_estimate` int null, `auto_confirm_days` int default 7 (0=off), `locale` ('es'|'en') default 'es', timestamps.
`dependents` (pii): `id`, `user_id`, `display_name`, `relationship` ('conyuge'|'hijo'|'padre'|'otro'), `doc_number` null, `active`, timestamps.
`consents` (pii): `id`, `user_id`, `kind` ('data_processing'|'notifications'), `granted_at`, `revoked_at` null, `text_version`.
`document_files`: `id`, `driver` ('local'|'s3'), `path`, `mime`, `size`, `sha256`, `created_at`.
`documents`: `id`, `user_id`, `source` ('scan_qr'|'scan_ocr'|'manual'), `status` ('needs_review'|'confirmed'|'rejected'), `cdc` null, `qr_url` null, `doc_kind` ('factura'|'autofactura'|'nota_credito'|'nota_debito'|'boleta_resimple'|'otro'), `direction` ('purchase'|'sale'), `emitter_ruc`, `emitter_dv` null, `emitter_name`, `receiver_doc` null, `issue_date`, `currency` 'PYG', `total`, `amount_iva10`, `amount_iva5`, `amount_exenta`, `iva10`, `iva5`, `supplier_regime_hint` ('normal'|'resimple'|'unknown'), `verified_dnit` bool, `verification_status` ('unverified'|'valid'|'invalid'|'error'), `dedupe_hash`, `file_id` null, `raw_extraction` json null, `created_at`, `confirmed_at` null. Unique `(user_id, dedupe_hash)`.
`classifications`: `document_id` PK/FK, `iva_credit_eligible`, `iva_credit_amount`, `irp_category` (8 categories | 'none'), `irp_deductible_amount`, `dependent_id` null, `confidence` real, `decided_by` ('auto'|'user'|'staff'), `rules_version`, `updated_at`.
`declarations`: `id`, `user_id`, `form_code` ('120'|'515'), `period`, `status` ('draft'|'ready'|'approved'), `values` json, `summary` json, `pdf_file_id` null, `rules_version`, `document_ids` json, `created_at`, `approved_at` null, `filed_marked_at` null. Unique `(user_id, form_code, period)`.
`jobs`: `id`, `type`, `payload` json, `status` ('pending'|'running'|'done'|'failed'|'dead'), `run_at`, `attempts`, `max_attempts` default 5, `locked_by` null, `locked_at` null, `last_error` null, timestamps. Index `(status, run_at)`.
`ingest_errors`: `id`, `user_id` null, `document_id` null, `stage` ('qr_parse'|'ocr'|'dedupe'|'verify'|'job'|'other'), `message`, `payload` json, `status` ('open'|'resolved'), `resolved_by` null, `resolved_at` null, `created_at`.
`audit_log` (append-only): `id`, `actor_user_id`, `actor_role`, `action`, `subject_user_id` null, `resource`, `detail` json, `ip` null, `created_at`.
`notification_prefs`: `user_id` PK, `push_enabled`, `email_enabled`, `telegram_chat_id` null, `digest_hour` default 9.
`push_subscriptions`: `id`, `user_id`, `endpoint`, `keys` json, `created_at`.
## 6. Auth and roles
Better Auth in apps/api: email+password, email OTP verification. Roles: `user` (default), `accountant` (dormant), `staff`, `superadmin`. Sessions DB-backed (stateless replicas). Cookies: httpOnly, sameSite=lax, secure in production; issued on the web origin because auth routes are proxied like everything else. Role checks re-validated in every handler. Superadmin role changes audited. Rate limiting: token bucket keyed by IP, in-memory per replica in v1 behind a `RateLimiter` interface (per-replica limits are acceptable at this scale; note in README).
## 7. Storage
```ts
interface StorageDriver {
put(key, data, mime): Promise<void>;
getStream(key): Promise<ReadableStream>;
delete(key): Promise<void>;
url(key): Promise<string>; // signed URL (s3) or authenticated api route (local)
}
```
LocalDriver under `STORAGE_LOCAL_PATH/<userId>/<uuid>`, served via authenticated `/api/files/:id` (ownership or staff, audited for staff). S3Driver via AWS SDK v3, any S3-compatible endpoint, path-style option, signed GETs 10 min. Keys never contain user filenames. **Scaling rule:** local driver requires a single shared volume; multi-replica requires S3 or an RWX volume (boot check warns, section 15).
## 8. Ingestion pipeline
1. Client captures image, attempts QR decode locally, uploads file + optional `qrPayload`.
2. `POST /api/documents/scan`: store file; QR present → parse URL → CDC → prefill → create document `scan_qr`; enqueue `verify_cdc` (Noop v1) + `classify_document`.
3. No QR + `ANTHROPIC_API_KEY` set → enqueue `ocr_extract` (Zod-constrained extraction per RULES.md section 9; low confidence flags fields). No key → `{ needsManual: true, fileId }`.
4. Dedupe on `(user_id, dedupe_hash)`: merge, prefer QR-sourced data, return `merged: true`.
5. Classification via packages/rules; auto-confirm sweep respects `profiles.auto_confirm_days`.
6. Failures → `ingest_errors` + user-visible retry affordance.
Offline: client queues in IndexedDB, syncs when online.
## 9. packages/rules
Implemented exactly per RULES.md (constants, RUC DV, calendario with holidays, CDC parsing, classification hints, computeF120, computeF515, projections). Exports `RULES_VERSION`, stamped on classifications and declarations. Test bar: RULES.md section 10.
## 10. Jobs
Portable poller inside apps/api:
- Runs when `ROLE=worker`, or inline when `ROLE=server && JOBS_INLINE=true`. Guard against duplicate pollers per process.
- Claim (`jobs/claim.ts`, the ONE dialect divergence): Postgres `SELECT ... FOR UPDATE SKIP LOCKED` then mark running with `locked_by=<instanceId>`; SQLite atomic conditional `UPDATE ... WHERE status='pending'` relying on single-writer serialization.
- Stale recovery: `running` jobs with `locked_at` older than `JOBS_STALE_MINUTES` (default 10) return to `pending` (crash safety).
- Retry backoff 1m/5m/25m/2h/12h, then `dead` (surfaced in admin errors as stage 'job').
- Types: `ocr_extract`, `classify_document`, `verify_cdc` (noop), `generate_declaration_pdf`, `send_notification`, `deadline_sweep` (daily), `auto_confirm_sweep` (daily), `digest_sweep` (hourly, respects per-user digest_hour and locale).
- Sweeps are idempotent (dedupe key per user+period+type in payload; skip if an identical done/pending job exists).
## 11. i18n (packages/i18n)
- Catalogs: `es.ts` (verbatim from COPY.md), `en.ts` (agent-written per COPY.md 0-EN), identical key sets enforced by a type-level check and a test.
- Typed `t(locale, key, params)` used by the API for: error envelope messages, notification/email/telegram templates, PDF cover labels (form bodies stay Spanish).
- Web: next-intl with `[locale]` routing, default `es`, language switcher in header and profile; switcher updates `profiles.locale` when authenticated (drives server-side notifications).
- Locale-aware date formatting; currency ALWAYS `formatGs` regardless of locale.
- Adding a locale = one new catalog file + adding the code to a `SUPPORTED_LOCALES` array.
## 12. API surface
As specified in CONTRACTS.md (shapes, error envelope, endpoints). Additions for this architecture:
- `GET /healthz` (liveness: process up) and `GET /readyz` (readiness: DB reachable, migrations current, storage driver responds) on the API; web exposes `GET /healthz` too.
- Localized `error.message` per requester locale (profile locale, else `Accept-Language`, else es).
- TanStack Query conventions: queryKeys `['me']`, `['documents', filters]`, `['dashboard']`, `['declarations']`, `['deadlines']`, `['admin', ...]`; optimistic updates ONLY for bandeja confirm/reclassify.
## 13. Testing
- Vitest: rules exhaustive; api module services against SQLite in-memory; i18n key-parity test.
- Playwright golden paths (against compose stack): (1) onboarding, (2) scan QR fixture → bandeja confirm, (3) manual entry + classification edit, (4) F120 generate → approve → PDF, (5) admin lookup → audit row. Plus: language switch persists after reload; offline scan queued then synced.
- Behavior pins from CONTRACTS.md section 5. CI matrix: sqlite + postgres service.
- Scale tests (Phase 8): with Postgres and 2 worker processes, enqueue 100 jobs, assert each claimed exactly once; SIGTERM drains in-flight HTTP before exit.
## 14. Security and privacy
PII module boundary + audited staff access; audit append-only; files private by default; Zod on every input; no raw SQL interpolation; secrets only via env; CSP, self-hosted fonts, no third-party scripts; soft-delete users + purge job stub; data export ships in v1. `.env.example` completeness enforced by a script against the env schema.
## 15. Deployment and scaling
**Configuration, apps/api/.env:**
```
NODE_ENV=development
PORT=4000
APP_PUBLIC_URL=http://localhost:3000 # user-facing origin (links in emails)
ROLE=server # server | worker
JOBS_INLINE=true
JOBS_POLL_INTERVAL_MS=2000
JOBS_STALE_MINUTES=10
DATABASE_URL=sqlite:./data/app.db # or postgres://...
BETTER_AUTH_SECRET=change-me-32-chars-min
BETTER_AUTH_URL=http://localhost:3000 # public origin (cookies issued via proxy)
STORAGE_DRIVER=local # local | s3
STORAGE_LOCAL_PATH=./data/files
S3_ENDPOINT= S3_REGION= S3_BUCKET= S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= S3_FORCE_PATH_STYLE=true
ANTHROPIC_API_KEY= # optional
OCR_MODEL=claude-sonnet-4-6
PUSH_VAPID_PUBLIC_KEY= PUSH_VAPID_PRIVATE_KEY=
SMTP_HOST= SMTP_PORT=587 SMTP_USER= SMTP_PASS= SMTP_FROM=
TELEGRAM_BOT_TOKEN= # optional
DEFAULT_LOCALE=es
```
**apps/web/.env:**
```
API_INTERNAL_URL=http://localhost:4000 # server-side proxy target (cluster DNS in k8s)
NEXT_PUBLIC_DEFAULT_LOCALE=es
```
**Mode matrix (enforced by boot checks, hard fail or loud warn):**
| Mode | DB | Storage | api replicas | worker |
|---|---|---|---|---|
| Combined (compose, one VPS) | SQLite | local volume | 1 | inline |
| Split small | SQLite | local shared volume | 1 | inline or 1 dedicated |
| Scaled (k8s/k3s) | Postgres required | S3 required (or RWX volume) | N | 1+ dedicated, JOBS_INLINE=false |
Boot checks in apps/api: SQLite + `ROLE=worker` running alongside another poller is not detectable, so instead: if `DATABASE_URL` is SQLite, refuse `JOBS_INLINE=false` (forcing single-process mode) and log a scaling notice; if driver=local, log the shared-volume requirement.
**docker-compose.yml (combined):** two services (api, web), one named volume mounted to api for `./data` (SQLite + files), healthchecks wired to /healthz, web depends_on api healthy, ports 3000 exposed only.
**k8s manifests (deploy/k8s/):** Deployments api (readiness `/readyz`, liveness `/healthz`, resources requests/limits, `terminationGracePeriodSeconds: 30`), web, worker (`ROLE=worker`, replicas 1 default, safe at N on Postgres); Services api+web (ClusterIP); Ingress to web only; ConfigMap/Secret examples; migrations run as an initContainer on api (`pnpm db:migrate`, safe concurrent: Kysely migrator + Postgres advisory lock taken in the migrate script). HPA example for api (CPU 70%). SQLite is explicitly unsupported on k8s manifests (values assume Postgres+S3); README says so.
**Statelessness rules (both apps):** no local file writes outside StorageDriver, no in-memory caches that affect correctness (rate limiter exempt and documented), push/OCR/SMTP clients constructed per process, graceful SIGTERM: stop accepting, drain (max 25s), close DB pool, exit 0.