Files
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

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/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):
{ 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)

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