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:
@@ -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.
|
||||
Reference in New Issue
Block a user