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>
This commit is contained in:
Michilis
2026-09-03 21:46:35 +00:00
co-authored by Claude Opus 5
commit ae2ea20b7e
106 changed files with 11541 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
# CONTRACTS.md: API Shapes, Error Envelope, Seed Data
Authoritative for `packages/contracts` (Zod schemas, types inferred) and `src/db/seed.ts`. Field names are final: the agent must not rename.
---
## 1. Conventions
- All endpoints return `Content-Type: application/json` except file/PDF streams.
- IDs: UUIDv7 strings. Dates: `YYYY-MM-DD`. Timestamps: ISO-8601 UTC. Money: integer guaranies.
- Success: the resource or `{ ok: true }`. Lists: `{ items: T[], total: number, cursor?: string }` (cursor pagination, page size 50).
- **Error envelope (every non-2xx):**
```ts
{ error: { code: string, message: string, field?: string, detail?: unknown } }
```
Codes: `validation_error`, `unauthorized`, `forbidden`, `not_found`, `conflict`, `rate_limited`, `ocr_unavailable`, `internal`. `message` is user-safe copy from the `packages/i18n` catalogs, localized to the requester (profile locale when authenticated, else `Accept-Language`, else `es`); technical detail only in `detail` and only in development.
- Auth: session cookie (Better Auth, served by apps/api through the web proxy). Role guard failures: 403 `forbidden`, never 404-masking in v1.
- Locale: `PUT /me/profile` accepts `locale: "es"|"en"` and it drives all server-generated content for that user (notifications, emails, digest).
## 2. Schemas (packages/contracts)
```ts
// enums
IrpCategory = "alimentacion"|"salud"|"educacion"|"vivienda"|"vestimenta"|"esparcimiento"|"vehiculo"|"familiares"
DocSource = "scan_qr"|"scan_ocr"|"manual"
DocStatus = "needs_review"|"confirmed"|"rejected"
DocKind = "factura"|"autofactura"|"nota_credito"|"nota_debito"|"boleta_resimple"|"otro"
Direction = "purchase"|"sale"
FormCode = "120"|"515"
DocumentDto = {
id, source: DocSource, status: DocStatus, cdc: string|null, docKind: DocKind,
direction: Direction, emitterRuc: string, emitterDv: string|null, emitterName: string,
receiverDoc: string|null, issueDate: string, currency: "PYG",
total: number, amountIva10: number, amountIva5: number, amountExenta: number,
iva10: number, iva5: number, supplierRegimeHint: "normal"|"resimple"|"unknown",
verifiedDnit: boolean, verificationStatus: "unverified"|"valid"|"invalid"|"error",
fileUrl: string|null, classification: ClassificationDto|null,
createdAt, confirmedAt: string|null, merged?: boolean
}
ClassificationDto = {
ivaCreditEligible: boolean, ivaCreditAmount: number,
irpCategory: IrpCategory|"none", irpDeductibleAmount: number,
dependentId: string|null, confidence: number,
decidedBy: "auto"|"user"|"staff", rulesVersion: string, reasons: string[]
}
ProfileDto = {
fullName: string, docType: "ruc"|"ci", ruc: string|null, rucDv: string|null, ci: string|null,
taxpayerKind: "individual"|"company", deadlineDigit: number,
obligations: { code: "iva_120"|"irp_515", active: boolean, since: string }[],
irpGrossEstimate: number|null, autoConfirmDays: number, locale: "es"|"en"
}
DashboardDto = {
iva: { period: string, aPagar: number, aFavor: number, debito: number, credito: number } | null,
irp: { year: string, projectedTax: number, monthDelta: number, belowThreshold: boolean } | null,
nextAction: { kind: "overdue"|"declaration_ready"|"deadline_soon"|"bandeja"|"all_clear",
dueDate?: string, form?: FormCode, count?: number, declarationId?: string },
insights: { id: string, kind: "deduction_gap"|"month_close"|"deadline_preview",
params: Record<string,string|number> }[]
}
DeclarationDto = {
id, formCode: FormCode, period: string, status: "draft"|"ready"|"approved",
summary: Record<string, number>, // human numbers, keys per form (below)
values: { casilla: string, label: string, amount: number }[],
rulesVersion: string, documentCount: number, createdAt, approvedAt: string|null,
filedMarkedAt: string|null
}
// summary keys F120: sales, purchases, debito, credito, saldoAnterior, aPagar, saldoAFavor
// summary keys F515: grossIncome, perCategory (nested), capExcess, totalDeductions, netIncome, tax, effectiveRate
DeadlineDto = { obligation: "iva_120"|"irp_515", period: string, dueDate: string,
status: "upcoming"|"due_soon"|"overdue"|"done", declarationId: string|null }
```
## 3. Endpoints (method, path, request → response)
**Public**
- `GET /lookup/ruc/:number` → `{ valid: boolean, docType: "ruc"|"ci", base: string, dv: number|null, deadlineDigit: number, deadlineDay: number, nextDeadlines: string[3] }`. Rate limit 10/min/IP. Invalid: 200 with `valid:false` (the landing handles it inline, not as an error).
**Me**
- `GET /me/profile` → ProfileDto (404 `not_found` until setup complete; client routes to setup)
- `PUT /me/profile` body: ProfileDto minus deadlineDigit (derived) → ProfileDto
- `GET/POST/DELETE /me/dependents` standard CRUD, `{ id, displayName, relationship, active }`
- `POST /me/consents` `{ kind, granted: boolean }` → `{ ok }` (revocation of data_processing triggers logout + account freeze flow)
- `GET /me/data-export` → JSON file download (profile, dependents, documents, classifications, declarations, consents, notification prefs)
- `DELETE /me/account` `{ confirmText: string }` must equal user email → `{ ok }`
- `GET/PATCH /me/notification-prefs`, `POST /push/subscribe { subscription }`
**Documents**
- `POST /documents/scan` multipart: `file` (required), `qrPayload` (optional string) → DocumentDto (with `merged` when deduped). If no QR and OCR unavailable → 200 `{ needsManual: true, fileId }` (client opens manual form bound to fileId).
- `POST /documents/manual` body: manual fields + optional `fileId` → DocumentDto
- `GET /documents?month&direction&category&status&q&cursor` → list
- `GET /documents/:id` → DocumentDto
- `PATCH /documents/:id` (field edits; recomputes classification, decidedBy stays "user" once touched) → DocumentDto
- `POST /documents/:id/confirm` → DocumentDto; `POST /documents/:id/reject { reason }` → DocumentDto
- `PATCH /documents/:id/classification` `{ irpCategory?, ivaCreditEligible?, dependentId? }` → DocumentDto
**Dashboard, declarations, deadlines**
- `GET /dashboard` → DashboardDto; `POST /dashboard/insights/:id/dismiss` → `{ ok }`
- `GET /declarations?year` → list
- `POST /declarations/generate { formCode, period }` → DeclarationDto (409 `conflict` if approved one exists for period)
- `GET /declarations/:id` → DeclarationDto
- `POST /declarations/:id/approve` → DeclarationDto (regenerates values first; 409 if underlying documents changed since ready, message tells user to review again)
- `POST /declarations/:id/mark-filed` → DeclarationDto
- `GET /declarations/:id/pdf` → application/pdf stream
- `GET /deadlines/upcoming?months=12` → DeadlineDto[]
**Admin (staff+; role checks server-side, every handler writes audit)**
- `GET /admin/users/search?q` → `{ items: { id, email, fullName, doc, createdAt }[] }`
- `GET /admin/users/:id/overview` → `{ profile: ProfileDto, counts: { documents, needsReview, declarations }, recentErrors: IngestErrorDto[] }`
- `GET /admin/errors?stage&status&cursor` → list of `IngestErrorDto = { id, userId, documentId, stage, message, status, createdAt }` plus dead jobs surfaced as stage `"job"`
- `POST /admin/errors/:id/resolve { note }` → `{ ok }`
- `POST /admin/jobs/:id/retry` → `{ ok }`
- `GET /admin/audit?actor&action&subject&from&to&cursor` → list; `GET /admin/audit/export.csv`
- Superadmin only: `POST /admin/users/:id/role { role }` → `{ ok }`
## 4. Seed data (src/db/seed.ts, deterministic, faker seeded with fixed value)
Accounts (dev passwords printed to console, all pre-verified):
1. `superadmin@demo.local` (superadmin)
2. `staff@demo.local` (staff)
3. `maria@demo.local` (user): **the showcase account.** Individual, CI-based, IRP + IVA. RUC base `4123456` with computed DV. Profile: fullName "Maria Gonzalez", irpGrossEstimate 180,000,000, one dependent ("Lucas Gonzalez", hijo). Documents: 34 purchases across the current year matching keyword categories (6 SUPERMERCADO REAL, 4 FARMACIA CATEDRAL, 3 COLEGIO SAN JOSE, 3 PETROBRAS ESTACION 12, 2 INMOBILIARIA DEL SOL alquiler, 2 BOUTIQUE ANDREA, 2 CINE ITAU, 2 from a RESIMPLE supplier "DESPENSA DON JUAN" with regime hint resimple, rest mixed), amounts realistic (groceries 180,000 to 950,000; rent 2,800,000 monthly; fuel 300,000 to 450,000), IVA split correct per category (rent at 5%, most goods at 10%). 8 sale facturas (services) of 4,000,000 to 9,000,000 each with 10% IVA. States: 26 confirmed, 5 needs_review (2 of them low-confidence OCR), 3 in prior months fully confirmed forming one complete declarable month (previous month) with a ready F120. One approved F120 two months back (creates saldoAnterior history = 0).
4. `carlos@demo.local` (user): company (SRL), IVA only, sparse data: 6 documents, 2 needs_review, no IRP. Exists to test the IVA-only view.
- Ingest errors: 2 open rows (one ocr stage low-quality image, one qr_parse malformed) linked to maria.
- Audit: seeded role assignment entries.
- Fixture images: `scripts/fixtures.ts` renders 3 PNG "KUDEs" (simple HTML to PNG or canvas): 2 with valid QR payloads (CDCs generated with RULES.md section 4 fields matching two seeded documents) and 1 without QR (manual/OCR path testing). Committed under `/fixtures`.
## 5. Non-negotiable behaviors to test against these contracts
1. Scanning fixture 1 twice → second response `merged: true`, document count unchanged.
2. Confirming all of maria's needs_review docs changes `GET /dashboard` numbers deterministically (assert exact guaranies using RULES.md math).
3. `POST /declarations/generate` for maria's previous month F120 → summary equals `computeF120` over her seeded docs (write the expected numbers into the test).
4. Editing a confirmed document that belongs to a `ready` declaration flips the declaration back to `draft` and the dashboard next-action reflects it.
5. Staff hitting `GET /admin/users/:id/overview` produces exactly one `admin.user_lookup` audit row visible via `GET /admin/audit`.
6. `data-export` for maria contains every document id she owns and zero of carlos's.
+254
View File
@@ -0,0 +1,254 @@
# COPY.md: UI Copy (es, Paraguayan voseo)
Authoritative copy source. The agent transfers these verbatim into the `es` catalog of `packages/i18n` (structure mirrors the sections below) and must not rewrite them. Strings not listed here follow the tone rules in section 0 and get added to the same catalog.
**Multi-language policy (day 1):** the `en` catalog ships COMPLETE alongside `es`, same keys (type-checked parity). The agent writes the English itself following section 0-EN: natural English, not literal translation. Both locales are first-class: language switcher in the header and profile, locale routing, and the API localizes its user-facing output (errors, notifications, emails) per the user's stored locale. Official form previews and PDFs stay Spanish in every locale (they mirror DNIT forms); the UI around them localizes. New locales are one catalog file.
## 0-EN. English tone rules
- Audience: expats and international users in Paraguay. Friendly, direct, second person.
- Keep Paraguayan tax terms in Spanish with a short gloss on first use per screen: "factura (invoice)", "vencimiento (due date)", RUC, IVA, IRP, Formulario 120/515 stay as-is.
- Same restraint rules as Spanish: no exclamation stacking, only ✓ and 🔥, no em dashes, money always `Gs. 1.234.567`.
- Example anchors (match this register): landing hero = "Your taxes, on autopilot." / subtitle = "Scan your facturas and we build your books, your deductions and your declarations. Never miss a vencimiento again." / bandeja empty = "Inbox clear ✓".
## 0. Tone rules
- Voseo always: "ingresá", "revisá", "tenés", "podés". Never "ingrese/usted", never "tú".
- Plain words, no tax jargon on primary surfaces. Jargon allowed inside "Ver detalle" layers and the form preview.
- Money always via formatGs: `Gs. 1.234.567`.
- Never blame the user. Errors say what happened and what to do next.
- No exclamation stacking, max one "!" per screen. Emojis: only ✓ and 🔥 (streaks), nowhere else.
- Never use em dashes. Use commas, colons or parentheses.
- Dates: "19 de septiembre", short form "19 sep".
## 1. Common
```
common.appName = Impuestos (placeholder, rename at brand time)
common.continue = Continuar
common.back = Volver
common.save = Guardar
common.cancel = Cancelar
common.confirm = Confirmar
common.edit = Editar
common.delete = Eliminar
common.retry = Reintentar
common.close = Cerrar
common.loading = Cargando...
common.search = Buscar
common.seeDetail = Ver detalle
common.optional = (opcional)
common.error.generic = Algo salio mal de nuestro lado. Probá de nuevo en un momento.
common.error.offline = Sin conexion. Tus cambios se guardan y se sincronizan al volver.
common.status.alDia = Al dia
common.status.porVencer = Por vencer
common.status.enRevision = En revision
common.status.atrasado = Atrasado
```
## 2. Landing
```
landing.hero.title = Tus impuestos, en piloto automatico
landing.hero.subtitle = Escaneá tus facturas y nosotros armamos tus libros, tus deducciones y tus declaraciones. Nunca mas un vencimiento olvidado.
landing.hero.inputLabel = Ingresá tu RUC o CI
landing.hero.cta = Ver mi situacion
landing.value1.title = Escaneá y listo
landing.value1.body = Sacale una foto a cualquier factura. La leemos, la verificamos y la clasificamos por vos.
landing.value2.title = Sabé cuanto vas a pagar, siempre
landing.value2.body = Tu IVA del mes y tu IRP del año, calculados en vivo con cada factura que cargás.
landing.value3.title = Declaraciones listas para presentar
landing.value3.body = Tu Formulario 120 y tu 515 se arman solos. Vos solo revisás y aprobás.
landing.preview.title = Esto es lo que la DNIT ya sabe de vos
landing.preview.deadline = Tus vencimientos caen el dia {day} de cada mes
landing.preview.next = Proximos: {d1}, {d2} y {d3}
landing.preview.cta = Crear mi cuenta gratis
landing.invalidDoc = Ese numero no parece valido. Revisá el digito verificador (el numero despues del guion).
```
## 3. Auth
```
auth.register.title = Creá tu cuenta
auth.register.email = Tu email
auth.register.password = Elegí una contraseña
auth.login.title = Entrá a tu cuenta
auth.otp.title = Revisá tu email
auth.otp.body = Te enviamos un codigo de 6 digitos a {email}.
auth.otp.resend = Reenviar codigo
auth.logout = Cerrar sesion
```
## 4. Consent
```
consent.title = Antes de empezar, lo importante
consent.intro = Guardamos tus facturas y tus datos fiscales para armar tus impuestos. Nada mas, nada menos.
consent.bullet1 = Tus datos son tuyos: los podes descargar o borrar cuando quieras.
consent.bullet2 = Nunca vendemos ni compartimos tu informacion.
consent.bullet3 = Todo acceso de nuestro equipo a tus datos queda registrado.
consent.dataProcessing = Acepto el tratamiento de mis datos para este servicio
consent.notifications = Quiero recibir avisos de vencimientos y novedades
consent.policyLink = Leer la politica completa
```
## 5. Profile setup
```
setup.step1.title = Confirmá tus datos
setup.fullName = Nombre completo
setup.taxpayerKind.q = ¿Sos persona o empresa?
setup.taxpayerKind.ind = Persona fisica
setup.taxpayerKind.com = Empresa
setup.step2.title = ¿Que obligaciones tenes?
setup.oblig.iva.title = IVA mensual
setup.oblig.iva.body = Tengo RUC activo y facturo con IVA
setup.oblig.irp.title = IRP
setup.oblig.irp.body = Gano mas de Gs. 80 millones al año
setup.oblig.unsure = No estoy seguro
setup.income.q = ¿Cuanto estimás que vas a ganar este año?
setup.income.help = Sirve para proyectar tu IRP. Lo podés cambiar cuando quieras.
setup.dependents.title = Tus familiares a cargo
setup.dependents.help = Los gastos de tus familiares a cargo tambien pueden ser deducibles.
setup.dependents.add = Agregar familiar
setup.dependents.skip = Lo hago despues
setup.step3.title = Avisos
setup.push.title = Activá las notificaciones
setup.push.body = Un aviso a tiempo vale mas que mil recargos. Te avisamos solo lo importante.
setup.push.cta = Activar
setup.push.later = Ahora no
```
## 6. Dashboard (Mi situacion)
```
home.title = Mi situacion
home.iva.title = IVA de {month}
home.iva.aPagar = A pagar
home.iva.aFavor = A tu favor
home.iva.breakdown = Debito Gs. {debito}, credito Gs. {credito}
home.irp.title = IRP proyectado {year}
home.irp.delta = Bajaste Gs. {amount} este mes gracias a tus deducciones
home.irp.belowThreshold = Por ahora estas debajo del minimo de Gs. 80 millones. Esto es solo informativo.
home.next.allClear = Todo al dia ✓
home.next.upcoming = Proximo vencimiento: {date}
home.next.dueSoon = Vence tu {form} en {days} dias
home.next.reviewCta = Revisar y aprobar
home.next.bandeja = Tenes {count} facturas por revisar
home.next.bandejaCta = Ir a la bandeja
home.insight.gapTitle = Te estas perdiendo deducciones
home.insight.gapBody = Casi no cargaste facturas de {category} este año, y son deducibles.
home.insight.monthClose = Cerraste {month} con Gs. {savings} en deducciones nuevas.
home.firstRun.title = Empecemos con tu primera factura
home.firstRun.body = Escaneá cualquier factura que tengas a mano y mirá lo que pasa.
home.firstRun.scan = Escanear mi primera factura
home.firstRun.manual = O cargala a mano
```
## 7. Scan and manual entry
```
scan.fab = Escanear
scan.hint = Enfocá el QR de la factura
scan.noQrHint = ¿Factura sin QR? Sacale una foto igual
scan.upload = Subir archivo
scan.processing = Leyendo tu factura...
scan.verified = Verificado ✓
scan.registered = Registrada
scan.ocrLowConfidence = Revisá los campos marcados, no los pudimos leer bien.
scan.duplicate.title = Ya tenias esta factura
scan.duplicate.body = La registramos el {date}. No se duplica nada.
scan.result.suggested = Sugerimos: {category}
scan.result.confirm = Confirmar
scan.result.changeCat = Cambiar categoria
scan.result.discard = Descartar
manual.title = Cargar factura a mano
manual.emitterRuc = RUC del que emitio
manual.emitterName = Nombre o razon social
manual.total = Total
manual.ivaSplit.q = ¿Todo al 10%?
manual.date = Fecha
manual.saved = Factura guardada ✓
```
## 8. Bandeja
```
bandeja.title = Bandeja
bandeja.empty = Bandeja limpia ✓
bandeja.emptyBody = Cuando escanees o recibamos facturas nuevas, aparecen aca para que las confirmes.
bandeja.confirm = Confirmar
bandeja.reject.title = ¿Por que la descartas?
bandeja.reject.notMine = No es mia
bandeja.reject.duplicate = Esta duplicada
bandeja.reject.other = Otro motivo
bandeja.autoConfirmNote = Las facturas con alta confianza se confirman solas en {days} dias.
bandeja.autoConfirmLink = Cambiar
categories.alimentacion = Alimentacion
categories.salud = Salud
categories.educacion = Educacion
categories.vivienda = Vivienda
categories.vestimenta = Vestimenta
categories.esparcimiento = Esparcimiento
categories.vehiculo = Vehiculo
categories.familiares = Familiares
categories.none = Sin categoria
```
## 9. Declarations
```
decl.title = Declaraciones
decl.generate = Preparar {form} de {period}
decl.status.draft = Borrador
decl.status.ready = Lista para revisar
decl.status.approved = Aprobada
decl.f120.summary = Vendiste Gs. {sales} y compraste Gs. {purchases}. {result}
decl.f120.toPay = Te corresponde pagar Gs. {amount}.
decl.f120.inFavor = Te queda un saldo a favor de Gs. {amount} para el mes que viene.
decl.f515.storyTitle = Tu año {year}
decl.f515.capNote = Gs. {amount} no se pudieron deducir por el tope del 1% en compras a RESIMPLE.
decl.preview.draftMark = BORRADOR
decl.approve = Aprobar
decl.approve.confirmTitle = ¿Aprobas esta declaracion?
decl.approve.confirmBody = Vas a aprobar tu {form} de {period} por Gs. {amount}.
decl.downloadPdf = Descargar PDF
decl.checklist.title = Presentala en Marangatu
decl.checklist.intro = Segui estos pasos con tu clave de Marangatu. Los valores ya estan listos para copiar.
decl.checklist.copyValue = Copiar valor
decl.checklist.done = Ya la presente
decl.filed.title = Declaracion presentada ✓
decl.filed.body = Te avisamos antes de la fecha de pago. Buen trabajo.
decl.streak = {count} periodos seguidos al dia 🔥
```
## 10. Documents, deadlines, profile
```
docs.title = Comprobantes
docs.filter.month = Mes
docs.filter.category = Categoria
docs.detail.trail = Historial de cambios
vto.title = Vencimientos
vto.explainer = Por tu RUC terminado en {digit}, tus vencimientos caen el dia {day} de cada mes.
profile.title = Perfil
profile.myData.title = Tus datos
profile.myData.body = Esto es todo lo que guardamos sobre vos.
profile.myData.export = Descargar mis datos
profile.myData.delete = Eliminar mi cuenta
profile.myData.deleteWarn = Se borra todo: tus facturas, tus declaraciones y tu cuenta. No hay vuelta atras.
profile.consent.revoke = Revocar consentimiento
notif.digestHour = Hora del resumen diario
```
## 11. Notifications (templates)
```
push.digest = {bandejaCount} facturas por revisar · proximo vencimiento {date}
push.deadline.t2 = Pasado mañana vence tu {form}: Gs. {amount}
push.deadline.t0 = Hoy vence tu {form}: Gs. {amount}
push.declReady = Tu {form} de {period} esta lista para revisar
push.savings = Encontramos Gs. {amount} de credito nuevo este mes
email.subject.deadline = Vence tu {form} el {date}
telegram.linked = Listo, te aviso por aca. Solo lo importante.
```
## 12. Admin
```
admin.users.title = Usuarios
admin.users.auditBanner = Todos los accesos a datos de usuarios quedan registrados.
admin.errors.title = Errores de ingesta
admin.errors.resolve = Marcar resuelto
admin.audit.title = Auditoria
admin.audit.export = Exportar CSV
```
## 13. Legal pages (placeholders, human-written before launch)
`/legal/privacidad`, `/legal/terminos`: ship with clearly marked placeholder content ("Documento en preparacion") and a TODO in DECISIONS.md. Never generate fake legal text.
+119
View File
@@ -0,0 +1,119 @@
# FLOWS.md: Screens, Flows and Design System
Companion to PROMPT.md and SPEC.md. This file is authoritative for UX. Apply the `ui-ux-pro-max-skill` within these constraints.
---
## 1. Design system
**Feel:** calm fintech. A clean banking app, not accounting software. Generous whitespace, one accent color, big confident numbers. Mobile-first (design at 390px, scale up), fully responsive, dark mode supported (system preference + toggle).
**Tokens (Tailwind theme):**
- Accent: deep teal family (pick one scale, use for primary actions and links only).
- Semantic status colors, used EVERYWHERE consistently:
- `positive` (green): a favor, al dia, savings found.
- `attention` (amber): action required, needs review, T-10 to T-2 deadlines.
- `overdue` (red): ONLY for missed deadlines and invalid documents. Never use red for "tax to pay". Owing tax on time is neutral.
- `neutral` (slate): everything else.
- Typography: Inter (self-hosted) for UI; tabular-nums for every money figure. Money display component `<Money value>` renders `Gs. 1.234.567`, large variant for headline numbers.
- Radius: rounded-2xl cards, rounded-full pills. Shadows: subtle, one elevation step.
- Spanish voseo in all copy ("Ingresá tu RUC", "Revisá tus facturas"). No tax jargon at surface level; technical terms live behind "Ver detalle".
- **Multi-language:** `es` default and `en` fully shipped (COPY.md policy). Language switcher: compact globe menu in the header on marketing/auth screens, and a row in `/perfil` when authenticated (persists to profile and drives notification language). Locale is in the URL (`/es/...`, `/en/...`); switching preserves the current page. Dates localize; money never does (always `Gs.`). Form previews and PDFs remain Spanish in both locales, with a small caption in the active locale: "Formato oficial DNIT (en español)".
**Loading:** boneyard skeletons on every data screen (`<Skeleton name>` per component: `dashboard-position`, `bandeja-card`, `doc-list-item`, `declaration-summary`, `admin-user-row`, etc.). Content loads never show spinners.
**Component inventory (build once in `/components`):** `Money`, `StatusChip` (al dia | por vencer | en revision | atrasado), `DeadlinePill` (countdown), `TraceableNumber` (tappable money that opens source drill-down sheet), `DocCard`, `CategoryPicker` (8 IRP categories as icon grid bottom sheet), `FormPreview` (renders form definition + values as an official-looking document), `EmptyState` (illustration + one action), `ConfettiMoment` (GSAP, used sparingly).
---
## 2. Flow A: Landing and onboarding
**A1. Landing `/`** hero: headline "Tus impuestos, en piloto automatico", subline about scanning facturas and never missing a vencimiento. Single input: "Ingresá tu RUC o CI" + button "Ver mi situacion". canvas-ui permitted here: ONE subtle full-hero effect (e.g. Liquid or Ripple) behind the content, disabled on `prefers-reduced-motion`, graceful static fallback. Below: three value cards, pricing placeholder, footer with legal pages.
**A2. Instant preview (no account yet):** on submit, call the public lookup. Show a personalized card: formatted document, taxpayer kind guess, detected deadline day ("Tus vencimientos caen el dia 19 de cada mes") with the next 3 dates. GSAP: card flips in, deadline dates stagger. CTA: "Crear mi cuenta gratis". Invalid RUC/CI: inline validation with the check-digit explanation, never a dead end.
**A3. Account creation:** email + password, then email OTP verification screen (6-digit input, auto-advance). Better Auth flows. Keep to one screen each, no marketing interruptions.
**A4. Consent:** one screen, plain language: what we store, why, retention, revocation. Two switches: data processing (required to continue), notifications (optional). Link to full policy. Grant writes `consents` + audit.
**A5. Profile setup (max 3 steps, progress dots):**
1. Confirm identity: full name, doc from A2 prefilled, taxpayer kind.
2. "¿Que obligaciones tenes?": two big toggle cards: "IVA mensual (tengo RUC activo)" and "IRP (gano mas de Gs. 80 millones al año)". Either, both, or "No estoy seguro" (picks IRP-only view, flag for review). If IRP: optional annual income estimate slider/input (for projections) + add dependents (name + relationship, skippable).
3. Notifications: enable push (browser prompt behind an explainer card), optional email/Telegram if configured.
**A6. First-run state:** land on `/inicio` with a guided empty state: "Escanea tu primera factura" big button + "o cargala a mano". After the first confirmed document, GSAP counter rolls the first savings number: this is the wow, protect it.
## 3. Flow B: Ingestion
**B1. Scan:** persistent FAB `[+ Escanear]` on all `(app)` screens (bottom right, above tab bar). Opens full-screen camera with frame guide and torch toggle; also "Subir archivo" (image/PDF). Client QR decode runs live; on QR hit: instant haptic + green frame flash, auto-capture, no shutter needed.
- QR path result sheet (target under 3 seconds): "Verificado ✓" badge (or "Registrado" when verification is off), emitter name/RUC, date, total, suggested classification with confidence chip. Buttons: "Confirmar" (primary), "Cambiar categoria", "Descartar".
- No QR detected after 4 seconds: hint chip "¿Factura sin QR? Sacale una foto igual" → shutter → OCR path: uploading state on the card (boneyard shimmer), then same result sheet with per-field confidence; low-confidence fields get amber underline and tap-to-edit.
- No OCR configured: straight to manual form (B3) with the photo attached.
- Duplicate: sheet says "Ya tenias esta factura" with the existing card and a merge note. Never an error tone.
**B2. Offline:** captures queue locally with a chip "Se sincroniza al conectarte"; sync silently, notify only on failure.
**B3. Manual entry:** one screen form, big numeric keypad for amounts, RUC field with live check-digit validation, date defaulting to today, IVA split auto-computed from total with editable override ("¿Todo al 10%?" quick toggle). Same result sheet after save.
**B4. Bandeja `/bandeja`:** card stack of `needs_review` documents, newest first, count badge in tab bar.
- Card shows: emitter, date, `<Money>` total, suggested category icon + label, confidence chip, thumbnail corner (tap to zoom).
- Gestures: swipe right = confirm (GSAP: card flies right with green check trail), swipe left = opens CategoryPicker sheet then confirms, swipe down = reject sheet ("No es mia" / "Duplicada" / "Otro"). Buttons mirror every gesture (accessibility + desktop).
- Desktop: keyboard J/K navigate, Enter confirm, 1-8 category, X reject.
- Auto-confirm notice: subtle footer "Las facturas con alta confianza se confirman solas en 7 dias" linking to the setting.
- Empty state: "Bandeja limpia ✓" with a small GSAP checkmark draw-on.
## 4. Flow C: Dashboard `/inicio` ("Mi situacion")
Three stacked zones:
1. **Position header (one card, swipeable between obligations):** IVA card: "IVA de agosto" + big `<Money>` a pagar / a favor (colored by sign, never red), sub-line "debito Gs. X, credito Gs. Y". IRP card: "IRP proyectado 2026" + big number + delta chip "bajaste Gs. 900.000 este mes". Numbers are `TraceableNumber`s: tap opens a bottom sheet listing contributing documents with amounts, each tappable through to detail. GSAP counter roll-up on first paint and on value change.
2. **Next action strip:** exactly ONE card. Priority: overdue > declaration ready > deadline within 10 days > bandeja count > all-clear ("Todo al dia ✓ Proximo vencimiento: 19 sep"). One primary button.
3. **Insight feed:** stack of dismissible cards, max 3 visible: deduction gap ("Casi no cargaste facturas de Educacion este año, son deducibles"), monthly close summary, deadline preview. Dismissals persist.
## 5. Flow D: Declarations
**D1. List `/declaraciones`:** grouped by year, rows: form badge (120/515), period, status chip, amount. Generate button for the current open period when enough data exists.
**D2. Review `/declaraciones/:id`:** two layers:
- **Human summary first:** "Vendiste Gs. 12.4M, compraste Gs. 8.1M. Te corresponde pagar **Gs. 430.000**." Plus 3-4 line breakdown with TraceableNumbers.
- **Form preview below:** `FormPreview` renders the official-style layout from the rules form definition, every casilla filled, monospace values, watermark "BORRADOR" until approved.
- Sticky footer: "Aprobar" (primary) + "Descargar PDF".
**D3. Approve:** confirmation sheet restating the number → on approve: ConfettiMoment (short, tasteful), status → approved, then the **guided filing checklist**: numbered steps to present it in Marangatu yourself, with copy buttons per casilla value and a final "Ya lo presente" check that records completion and schedules the payment reminder. canvas-ui permitted here (second and last spot): a brief celebratory effect on the success screen, reduced-motion safe.
**D4. F515 annual:** same pattern, preceded by a "Tu año" story screen: income, each deduction category with totals and small bars, the final tax, share-nothing (no social buttons, this is private).
## 6. Flow E: Documents and profile
**E1. `/comprobantes`:** filterable list (month, direction, category, status), monthly totals header, search by emitter. Row: DocCard compact. Detail: full data, image viewer, classification editor, audit trail of changes, delete (soft, confirm).
**E2. `/vencimientos`:** vertical timeline of upcoming deadlines (12 months), each with DeadlinePill, obligation, linked declaration state. Personal deadline day explained at top ("Por tu RUC terminado en 6, tus vencimientos caen el dia 19").
**E3. `/perfil`:** identity data, obligations toggles, dependents CRUD, income estimate, notification prefs (channel toggles + digest hour), auto-confirm setting, **"Tus datos"** section: what we store (plain list), consent status with revoke, "Descargar mis datos" (JSON export), "Eliminar mi cuenta" (danger, double confirm). This screen is a trust feature; give it real design attention.
## 7. Flow H: Admin `(admin)`
Plain, dense, desktop-first, shadcn tables. No playfulness here.
- **/usuarios:** search by email/RUC/name → results table → user overview: profile summary, document counts, recent activity, links to their error rows. Every search and view writes an audit row; a banner reminds staff "Todos los accesos quedan registrados".
- **/errores:** table of ingest_errors + dead jobs, filters by stage/status, row expand shows payload, actions: retry job, resolve with note.
- **/auditoria:** filterable audit table (actor, action, subject, date range), read-only, export CSV.
- Superadmin extra: role management on user overview (with confirm + audit).
## 8. Motion system (GSAP)
Principles: fast (150-300ms), purposeful, interruptible, `prefers-reduced-motion` disables all non-essential motion globally (CSS + GSAP context).
- Counter roll-ups on headline Money values (dashboard, declaration summary).
- Bandeja card physics: drag with rotation, fly-out on commit, next card scales up.
- Sheet/dialog transitions: spring-ish ease, no bounce overdose.
- Success moments only at: first document confirmed, declaration approved, "Ya lo presente". Nowhere else.
- Skeleton→content: crossfade 150ms (boneyard handles layout, GSAP the fade).
## 9. Notification doctrine
- Bundled daily digest (default 09:00 local) when there is anything: bandeja count, upcoming deadline, savings found.
- Immediate sends ONLY: deadline T-2 and T-0 ("Mañana vence tu IVA: Gs. 430.000 → Revisar"), declaration ready, filing confirmation.
- Every notification: one number + one action deep link. Never two notifications where one suffices.
- Channels per prefs; unconfigured channels hidden everywhere.
## 10. States checklist (every screen ships all four)
1. boneyard skeleton, 2. empty state with one clear action, 3. error state with retry (friendly copy, never a stack trace), 4. content. Playwright asserts empty and content states on the golden paths.
+241
View File
@@ -0,0 +1,241 @@
# RULES.md: Exact Tax Logic, Algorithms, Worked Examples and Test Vectors
Authoritative for `packages/rules`. Every algorithm here must be implemented exactly as written and covered by the tests listed. Anything marked `TODO-TAX-VERIFY` is implemented as written now, flagged in code, and listed in DECISIONS.md for human verification before production.
All money values are integer guaranies (branded type `Pyg`). No floats in any money path. Percentages are computed as integer math with explicit rounding rules (round half up to whole guarani unless stated).
---
## 1. Constants
```ts
export const RULES_VERSION = "1.0.0";
export const IVA_RATE_10 = 10;
export const IVA_RATE_5 = 5;
export const IRP_THRESHOLD_ANNUAL = 80_000_000; // Gs., registration threshold
export const IRP_BRACKET_1_LIMIT = 50_000_000; // 8% up to here
export const IRP_BRACKET_2_LIMIT = 150_000_000; // 9% for the tranche above 1 up to here, 10% above
export const IRP_RATE_1 = 8;
export const IRP_RATE_2 = 9;
export const IRP_RATE_3 = 10;
export const RESIMPLE_SUPPLIER_DEDUCTION_CAP_PCT = 1; // 1% of gross annual income
export const DEADLINE_DAY_BY_DIGIT: Record<number, number> = {
0: 7, 1: 9, 2: 11, 3: 13, 4: 15, 5: 17, 6: 19, 7: 21, 8: 23, 9: 25,
};
```
`TODO-TAX-VERIFY`: IRP tranche boundaries and the 80M threshold against the live DNIT tables for the current fiscal year.
---
## 2. RUC and check digit
Paraguayan RUC: base number (up to 8 digits) + hyphen + verification digit (DV). CI holders use their CI as RUC base.
**DV algorithm (modulo 11, basis 2):**
1. Take the base digits, process right to left.
2. Multiply each digit by factors 2, 3, 4, 5, 6, 7, 8, 9, then cycle back to 2.
3. Sum the products. `r = sum % 11`.
4. `dv = r > 1 ? 11 - r : 0`.
```ts
export function computeRucDv(base: string): number;
export function validateRuc(base: string, dv: number): boolean; // also rejects non-digits, length 1..8
export function deadlineDigit(base: string): number; // last digit of base (NOT the dv)
```
`TODO-TAX-VERIFY`: confirm the algorithm against at least 5 real published RUCs (DNIT publishes RUC lists; the seed uses synthetic RUCs generated WITH this algorithm so internal consistency holds regardless).
**Tests:** compute and validate round-trip for 20 generated bases; reject wrong dv; reject letters; digit extraction ignores dv.
---
## 3. Calendario perpetuo
```ts
export function deadlineDay(digit: number): number; // table above, throws on out of range
export function nextDeadline(opts: {
digit: number;
obligation: "iva_120" | "irp_515";
from: Date; // "now"
}): { period: string; dueDate: Date };
```
Rules:
- `iva_120`: declares month M, due in month M+1 on the digit day. Example: August 2026 IVA, digit 6 → due 2026-09-19 (before roll rules).
- `irp_515`: declares year Y, due in March of Y+1 on the digit day.
- **Roll-forward:** if the computed date is a Saturday, Sunday, or a holiday (section 3.1), advance day by day until a business day.
- Timezone: all deadline math in `America/Asuncion`, dates stored as `YYYY-MM-DD`.
### 3.1 Holidays (hardcoded table, extend yearly)
Fixed every year: `01-01, 03-01, 05-01, 05-14, 05-15, 06-12, 08-15, 09-29, 12-08, 12-25`.
Movable (Holy Thursday and Good Friday), by year:
- 2026: `2026-04-02`, `2026-04-03`
- 2027: `2027-03-25`, `2027-03-26`
- 2028: `2028-04-13`, `2028-04-14`
`TODO-TAX-VERIFY`: government-decreed one-off holidays and "dias no laborables trasladables" per year; the table is data (`holidays.ts`), updating it is a data change, not a code change.
**Tests:** digit 0 vs digit 9 spread; due date landing on Saturday rolls to Monday; due date landing on 2026-04-02 rolls past both holidays to 2026-04-06 (Monday); December IVA due in January of next year; year boundary for IRP.
---
## 4. CDC parsing
CDC = exactly 44 digits:
| Field | Length | Offset |
|---|---|---|
| tipoDocumento | 2 | 0 |
| rucEmisor | 8 | 2 |
| dvEmisor | 1 | 10 |
| establecimiento | 3 | 11 |
| puntoExpedicion | 3 | 14 |
| numeroDocumento | 7 | 17 |
| tipoContribuyente | 1 | 24 |
| fechaEmision (YYYYMMDD) | 8 | 25 |
| tipoEmision | 1 | 33 |
| codigoSeguridad | 9 | 34 |
| digitoVerificador | 1 | 43 |
```ts
export function parseCdc(cdc: string): CdcFields; // throws TypedError on length/charset/date validity
```
tipoDocumento map (store label): `01` factura electronica, `04` autofactura, `05` nota de credito, `06` nota de debito, `07` nota de remision. Unknown codes: keep code, label "otro".
**Canonical test vector (synthetic, used by fixtures too):**
`01 80069563 1 001 001 0001234 1 20260815 1 123456789 4` → concatenated: `"01800695631001001000123412026081511234567894"`. Wait: build programmatically in tests from the field table (concatenate parts) rather than hardcoding a string, then assert round-trip parse. The fixtures script (`scripts/fixtures.ts`) must generate CDCs the same way.
**QR payload:** the KUDE QR is a URL. Treat it as: parse as URL, read query param `Id` = CDC (44 digits). If present, also read `dTotGralOpe` (total) and `dTotIVA` (total IVA) as integers when parseable. All other params are stored opaque in `documents.qr_url`. Any URL whose host is not recognizable is still accepted if `Id` parses as a valid CDC (offline QRs from test fixtures). If no `Id` param, try: the raw string IS a 44-digit CDC.
**Tests:** round-trip; invalid length; invalid date (20261340); QR URL with and without `Id`; raw-CDC QR.
---
## 5. Classification
```ts
export interface ClassificationInput {
direction: "purchase" | "sale";
docKind: string;
emitterName: string; // uppercase-normalized
emitterRuc: string;
supplierRegimeHint: "normal" | "resimple" | "unknown";
taxpayer: { kind: "individual" | "company"; hasIva: boolean; hasIrp: boolean };
amounts: { total: Pyg; iva10: Pyg; iva5: Pyg };
}
export interface ClassificationSuggestion {
ivaCreditEligible: boolean;
ivaCreditAmount: Pyg; // iva10 + iva5 when eligible, else 0
irpCategory: IrpCategory | "none";
irpDeductibleAmount: Pyg; // total when deductible, else 0
confidence: number; // 0..1
reasons: string[]; // human-readable, for the UI detail sheet
}
```
**IVA credit logic (purchases only):**
- eligible = `taxpayer.hasIva && direction === "purchase" && supplierRegimeHint !== "resimple" && (iva10 + iva5) > 0 && docKind !== "boleta_resimple"`.
- v1 assumes business purpose = true for IVA taxpayers; the user can toggle it off per document in the UI (that toggle overrides, `decided_by='user'`).
**IRP category by emitter keyword map** (`categoryHints.ts`, data not code; match on normalized emitter name, first hit wins, order as listed):
| Category | Keywords (contains, case/diacritic-insensitive) |
|---|---|
| salud | FARMACIA, FARMA, CLINICA, SANATORIO, HOSPITAL, LABORATORIO, ODONTO, OPTICA |
| educacion | COLEGIO, ESCUELA, UNIVERSIDAD, INSTITUTO, ACADEMIA, LIBRERIA |
| alimentacion | SUPERMERCADO, SUPER, DESPENSA, ALMACEN, MINIMARKET, CARNICERIA, PANADERIA, RESTAURANT, RESTAURANTE, PIZZERIA, COMIDAS |
| vehiculo | PETROBRAS, SHELL, PUMA, ESTACION, COMBUSTIBLE, TALLER, GOMERIA, REPUESTOS, LUBRICANTES |
| vivienda | INMOBILIARIA, ALQUILER, CONDOMINIO, FERRETERIA, ELECTRICIDAD, SANITARIOS, ANDE, ESSAP |
| vestimenta | BOUTIQUE, TIENDA, CALZADOS, MODAS, CONFECCIONES |
| esparcimiento | CINE, TEATRO, CLUB, GIMNASIO, GYM, TURISMO, HOTEL |
| familiares | (never keyword-assigned; only user-assigned with a dependent) |
- Confidence: keyword hit = 0.85; no hit = 0.4 with `irpCategory` set to the amount-weighted default `"alimentacion"`? NO: no hit → `irpCategory: "none"`, confidence 0.4, reasons include "Sin categoria sugerida". Never guess a category without a keyword hit.
- Sales documents: `irpCategory: "none"`, they count as income, not deductions.
- `esparcimiento` Paraguay-only rule: v1 treats all ingested comprobantes as Paraguayan (they have RUCs); rule noted for future foreign-expense support.
**Tests:** every keyword row; resimple supplier blocks IVA credit but NOT IRP deduction; sale never deductible; no-hit yields none/0.4.
---
## 6. Formulario 120 computation (monthly IVA)
```ts
export function computeF120(input: {
period: string; // "2026-08"
saldoAnterior: Pyg; // credit carried from previous period, >= 0
documents: F120Doc[]; // confirmed docs whose issue_date is in period
}): F120Result;
```
- `debito = sum(iva10 + iva5)` over confirmed SALE documents in the period.
- `credito = sum(ivaCreditAmount)` over confirmed PURCHASE documents with `ivaCreditEligible` in the period.
- `creditoTotal = credito + saldoAnterior`.
- If `debito > creditoTotal`: `aPagar = debito - creditoTotal`, `saldoAFavor = 0`.
- Else: `aPagar = 0`, `saldoAFavor = creditoTotal - debito` (becomes next period's `saldoAnterior`).
**Worked example (used verbatim as a test):**
Sales: 3 facturas with iva10 = 400,000 / 500,000 / 227,273 → debito 1,127,273.
Purchases eligible: iva10 total 610,000, iva5 total 90,000 → credito 700,000. saldoAnterior 150,000 → creditoTotal 850,000.
Result: aPagar = 277,273, saldoAFavor = 0.
Flip test: same purchases, sales debito only 500,000 → aPagar 0, saldoAFavor 350,000.
Form definition `forms/f120.v1.ts` maps: ventas gravadas 10/5 (base amounts), debito fiscal, compras gravadas 10/5, credito fiscal, saldo anterior, monto a pagar, saldo a favor. Casilla numbers are placeholder strings `"c-ventas-10"` etc. with `TODO-TAX-VERIFY: replace with official casilla numbers from live Marangatu F120 v4`.
---
## 7. Formulario 515 computation (annual IRP-RSP)
```ts
export function computeF515(input: {
year: string;
grossIncome: Pyg; // from profile estimate in v1 (sales docs when present add to it, take max)
documents: F515Doc[]; // confirmed purchase docs of the year with irpCategory != "none"
hasResimpleFlag: (doc) => boolean; // supplierRegimeHint === "resimple"
}): F515Result;
```
Steps:
1. Sum deductions per category from `irpDeductibleAmount`.
2. **RESIMPLE cap:** sum deductible amounts whose supplier is resimple; cap that subtotal at `floor(grossIncome * 1 / 100)`; excess is reported as `capExcess` (UI shows "Gs. X no deducible por tope del 1%").
3. `totalDeductions = sum(categories) - capExcess`.
4. `netIncome = max(0, grossIncome - totalDeductions)`.
5. **Tax by tranches** (`TODO-TAX-VERIFY`: progressive-by-tranche interpretation):
- tranche1 = min(netIncome, 50,000,000) * 8%
- tranche2 = min(max(netIncome - 50,000,000, 0), 100,000,000) * 9%
- tranche3 = max(netIncome - 150,000,000, 0) * 10%
- `tax = round(t1 + t2 + t3)`.
6. `effectiveRate` (2 decimals, display only). If `grossIncome < IRP_THRESHOLD_ANNUAL`, result includes `belowThreshold: true` and the UI frames the output as informative.
**Worked example (verbatim test):**
grossIncome 200,000,000. Deductions: alimentacion 18,000,000; salud 9,500,000; educacion 12,000,000; vivienda 24,000,000; vehiculo 6,500,000; of which 3,000,000 came from RESIMPLE suppliers. Cap = 2,000,000 → capExcess 1,000,000. totalDeductions = 70,000,000 - 1,000,000 = 69,000,000. netIncome = 131,000,000. Tax = 50,000,000*8% + 81,000,000*9% = 4,000,000 + 7,290,000 = **11,290,000**. effectiveRate on gross = 5.65%.
**Bracket edge tests:** netIncome 49,999,999 / 50,000,000 / 50,000,001 / 150,000,000 / 150,000,001; zero income; deductions exceeding income floor at 0.
Form definition `forms/f515.v1.ts`: income, one line per category, cap adjustment line, net, tax, with placeholder casillas and the same TODO.
---
## 8. Projections (dashboard)
- IRP projection for year Y at date D: `computeF515` with year-to-date documents, grossIncome = profile estimate (fallback: annualized YTD sales when no estimate). Label clearly "proyeccion".
- "Savings this month" delta = tax with current deductions minus tax with deductions excluding the current month's confirmed docs.
- IVA month position = `computeF120` over the open month with live (confirmed) docs, `saldoAnterior` from the last approved F120 declaration (0 when none).
## 9. OCR extraction schema (documents module, not rules, listed here for completeness)
Zod schema the Anthropic call must return (strict JSON, temperature 0):
`{ emitter_ruc: string|null, emitter_dv: string|null, emitter_name: string|null, receiver_doc: string|null, doc_number: string|null, issue_date: "YYYY-MM-DD"|null, total: int|null, amount_iva10: int|null, amount_iva5: int|null, amount_exenta: int|null, iva10: int|null, iva5: int|null, confidence: { [field]: number } }`
Prompt requirements: instruct that Paraguayan facturas print IVA columns as "10%", "5%", "Exentas"; amounts use dots as thousand separators; return integers without separators; null when unreadable, never guess. Validate: if `total` present and components present, assert `|total - (base10+base5+exenta)| tolerance 1 Gs` where derivable; on mismatch lower confidence of money fields to 0.5.
## 10. Test coverage bar
`packages/rules`: 100% line coverage target, every worked example verbatim, every `TODO-TAX-VERIFY` has a test pinning current behavior (so verification later is a red/green diff, not archaeology).
+243
View File
@@ -0,0 +1,243 @@
# 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.