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>
17 KiB
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/apiis the ONLY process touching the database and storage. It serves everything under/api, including the Better Auth routes.apps/webrenders UI, holds zero secrets beyondAPI_INTERNAL_URL, and consumes the typed client frompackages/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/apiimage started withROLE=worker: it runs migrations check, the job poller and sweeps, and serves only/healthz. WithROLE=server(default) the poller runs inline only whenJOBS_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-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
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
- Client captures image, attempts QR decode locally, uploads file + optional
qrPayload. POST /api/documents/scan: store file; QR present → parse URL → CDC → prefill → create documentscan_qr; enqueueverify_cdc(Noop v1) +classify_document.- No QR +
ANTHROPIC_API_KEYset → enqueueocr_extract(Zod-constrained extraction per RULES.md section 9; low confidence flags fields). No key →{ needsManual: true, fileId }. - Dedupe on
(user_id, dedupe_hash): merge, prefer QR-sourced data, returnmerged: true. - Classification via packages/rules; auto-confirm sweep respects
profiles.auto_confirm_days. - 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 whenROLE=server && JOBS_INLINE=true. Guard against duplicate pollers per process. - Claim (
jobs/claim.ts, the ONE dialect divergence): PostgresSELECT ... FOR UPDATE SKIP LOCKEDthen mark running withlocked_by=<instanceId>; SQLite atomic conditionalUPDATE ... WHERE status='pending'relying on single-writer serialization. - Stale recovery:
runningjobs withlocked_atolder thanJOBS_STALE_MINUTES(default 10) return topending(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, defaultes, language switcher in header and profile; switcher updatesprofiles.localewhen authenticated (drives server-side notifications). - Locale-aware date formatting; currency ALWAYS
formatGsregardless of locale. - Adding a locale = one new catalog file + adding the code to a
SUPPORTED_LOCALESarray.
12. API surface
As specified in CONTRACTS.md (shapes, error envelope, endpoints). Additions for this architecture:
GET /healthz(liveness: process up) andGET /readyz(readiness: DB reachable, migrations current, storage driver responds) on the API; web exposesGET /healthztoo.- Localized
error.messageper requester locale (profile locale, elseAccept-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.