# 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; getStream(key): Promise; delete(key): Promise; url(key): Promise; // signed URL (s3) or authenticated api route (local) } ``` LocalDriver under `STORAGE_LOCAL_PATH//`, 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=`; 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.