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>
9.9 KiB
9.9 KiB
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/jsonexcept 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):
{ 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/profileacceptslocale: "es"|"en"and it drives all server-generated content for that user (notifications, emails, digest).
2. Schemas (packages/contracts)
// 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 withvalid:false(the landing handles it inline, not as an error).
Me
GET /me/profile→ ProfileDto (404not_founduntil setup complete; client routes to setup)PUT /me/profilebody: ProfileDto minus deadlineDigit (derived) → ProfileDtoGET/POST/DELETE /me/dependentsstandard 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/scanmultipart:file(required),qrPayload(optional string) → DocumentDto (withmergedwhen deduped). If no QR and OCR unavailable → 200{ needsManual: true, fileId }(client opens manual form bound to fileId).POST /documents/manualbody: manual fields + optionalfileId→ DocumentDtoGET /documents?month&direction&category&status&q&cursor→ listGET /documents/:id→ DocumentDtoPATCH /documents/:id(field edits; recomputes classification, decidedBy stays "user" once touched) → DocumentDtoPOST /documents/:id/confirm→ DocumentDto;POST /documents/:id/reject { reason }→ DocumentDtoPATCH /documents/:id/classification{ irpCategory?, ivaCreditEligible?, dependentId? }→ DocumentDto
Dashboard, declarations, deadlines
GET /dashboard→ DashboardDto;POST /dashboard/insights/:id/dismiss→{ ok }GET /declarations?year→ listPOST /declarations/generate { formCode, period }→ DeclarationDto (409conflictif approved one exists for period)GET /declarations/:id→ DeclarationDtoPOST /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→ DeclarationDtoGET /declarations/:id/pdf→ application/pdf streamGET /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 ofIngestErrorDto = { 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):
superadmin@demo.local(superadmin)staff@demo.local(staff)maria@demo.local(user): the showcase account. Individual, CI-based, IRP + IVA. RUC base4123456with 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).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.tsrenders 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
- Scanning fixture 1 twice → second response
merged: true, document count unchanged. - Confirming all of maria's needs_review docs changes
GET /dashboardnumbers deterministically (assert exact guaranies using RULES.md math). POST /declarations/generatefor maria's previous month F120 → summary equalscomputeF120over her seeded docs (write the expected numbers into the test).- Editing a confirmed document that belongs to a
readydeclaration flips the declaration back todraftand the dashboard next-action reflects it. - Staff hitting
GET /admin/users/:id/overviewproduces exactly oneadmin.user_lookupaudit row visible viaGET /admin/audit. data-exportfor maria contains every document id she owns and zero of carlos's.