Files
impuestospy/docs/SPEC.md
T
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

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/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

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.