# 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 }[] } DeclarationDto = { id, formCode: FormCode, period: string, status: "draft"|"ready"|"approved", summary: Record, // 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.