diff --git a/.gitignore b/.gitignore index a52f7aa..56046e0 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,11 @@ coverage/ .env .env.local apps/*/data/ +# Copied from node_modules by apps/web/scripts/copy-zxing-wasm.mjs before dev and build. +apps/web/public/zxing/ data/ +# Copied from node_modules by apps/web/scripts/copy-zxing-wasm.mjs before dev and build. +apps/web/public/zxing/ test-results/ playwright-report/ .DS_Store diff --git a/DECISIONS.md b/DECISIONS.md index 193961b..9ac03fb 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -17,6 +17,9 @@ algorithms. Both are implemented in phase 1 and the seed can now be completed. **Resolved.** ### `boneyard` and `canvas-ui` are not the packages the prompt means +_Still open. The `` component is in place and used on every data screen, so +swapping in the real library is one file._ + Both names resolve on npm to unrelated projects: `boneyard@0.1.4` is a 2015 Backbone "architectural toolkit", `canvas-ui@0.2.3` is a Mesosphere Bootstrap theme. Neither does skeleton loading or canvas effects. Neither is needed before phase 7. @@ -273,3 +276,94 @@ bounds in both states, because this is invisible to every other kind of test. `/legal/privacidad` and `/legal/terminos` render "Documento en preparacion" and say a document is being drafted, per COPY.md section 13. No legal text was generated. **TODO: human written before launch.** + + +--- + +## Phase 3 + +### The pipeline has three doors, and the trustworthy one wins +`ingestScan` tries the QR first, then queued OCR, then the manual form. A QR that will not +parse is recorded as an ingest error and falls through rather than failing the scan, so a +smudged code never costs the user their photograph. + +**A QR carries the emitter's RUC but not its name.** Verifying a CDC against DNIT is out of +scope for v1 (SPEC.md section 10 makes `verify_cdc` a noop), so a QR-only document shows +`RUC 80011223` as its emitter and the keyword classifier cannot match on it: it lands in +"Sin categoria" and the user picks one in the bandeja. Worth revisiting when CDC +verification exists, since it is the one thing standing between a QR scan and a fully +automatic classification. + +### `dedupe_hash` is defined here, not in the specs +SPEC-GAP. SPEC.md section 8 requires the column and never says what goes in it. With a CDC +it is the CDC, which identifies a comprobante nationally. Without one it is +`emitter | date | total | kind`, the tuple a human would use. Two photos of the same +factura collapse; two genuinely different facturas from the same shop on the same day for +the same amount would collapse too, which is why a merge is shown to the user ("Ya tenias +esta factura") rather than applied silently. + +A merge prefers QR sourced data over OCR or typing, and never overwrites a classification +the user has already decided. + +### Job claiming is the only dialect divergence, as specified +`jobs/claim.ts` holds both: Postgres takes `FOR UPDATE SKIP LOCKED` inside a transaction so +N workers take different rows; SQLite relies on its single writer and a conditional +`UPDATE ... WHERE status = 'pending'`, where the changed-row count decides the winner. A +test claims five queued jobs five times and asserts no id comes back twice. + +Retries follow SPEC.md section 10 exactly (1m, 5m, 25m, 2h, 12h, then dead) and a job +abandoned by a killed process returns to the queue once its lock is older than +`JOBS_STALE_MINUTES`. That is the phase 3 acceptance case and it has three tests: recovery, +the negative case of a merely slow job, and a restarted poller finishing the recovered work. + +### OCR is structured output, not prose parsing +The Anthropic call uses `messages.parse` with a Zod schema, so the model cannot return +anything but the shape in RULES.md section 9 and there is no JSON to repair. Every field is +nullable because "unreadable" is a real answer. `temperature: 0` from RULES.md is not sent: +the current models reject sampling parameters, and constraining the output format achieves +what the setting was there for. + +A dead `ocr_extract` job records an ingest error; a retryable failure does not, so a +transient API blip never shows up in the user's error list. + +### The fixtures are decoder fixtures, not visual replicas +SPEC-GAP against CONTRACTS.md section 4, which suggests HTML or canvas. `scripts/render-fixtures.ts` +draws them with raw pixels and a real, decodable QR. Faithful KUDE artwork would mean +carrying a browser or an SVG rasteriser to produce three images that nothing but a decoder +ever reads; the emitter, date and totals live in `fixtures.ts`, which is where the tests and +the seed read them from. A test decodes all three and asserts the third has no QR. + +### The ZXing wasm is served from our own origin +`zxing-wasm` fetches it from a CDN by default, which the CSP forbids and which would break +the offline queue. A script copies it into `public/zxing` before dev and build. Headless +Chromium has no `BarcodeDetector`, so the e2e runs exercise the ZXing path end to end, +which is the fallback that matters most. + +### Two defects the screens surfaced + +**Seeded documents were dated in the future.** `seedDate` placed current-month documents on +a fixed day of the month, so a day-8 document seeded on the 4th landed four days ahead. A +comprobante dated in a period that has not happened corrupts every projection computed from +it. `seedDate` now clamps to today and has its own tests. + +**Category buttons announced their keyboard shortcut.** The 1-to-8 hints were part of each +button's accessible name, so a screen reader read "Alimentacion 1". They are `aria-hidden` +now with an explicit label on the button. + +### Test isolation against a shared demo account +The flows that confirm and reject run against Maria's bandeja, which is shared mutable +state. They are serial and desktop only, and `bandeja.spec.ts` covers the same screen on a +phone viewport without mutating anything. A test that creates a document varies the total +as well as the name, because the name is deliberately not part of the dedupe key. + +### Deferred, deliberately +- **The scan FAB.** FLOWS.md B1 puts a persistent `[+ Escanear]` on every `(app)` screen. + There is no tab bar to anchor it above until the dashboard lands, so scanning is reached + from `/inicio`, `/comprobantes` and the empty bandeja. The FAB arrives with the app shell + in phase 4. +- **The offline queue.** FLOWS.md B2. It needs the service worker, which is phase 7. +- **Auto-confirm.** The setting and its copy are live; the sweep that acts on it is a job + for phase 4. +- **Seeded declarations.** CONTRACTS.md section 4 asks for a ready F120 and an approved one. + The declarations module is phase 5 and seeds them then; phase 3 seeds the documents they + will be computed from. diff --git a/README.md b/README.md index 6141f41..32ac186 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ and ready to file yourself. Working name. See `docs/` for the specifications, `DECISIONS.md` for choices made along the way and the gaps that still need answers. -> **Status: phase 2 of 8.** Foundation, the tax rules, and identity: landing, sign up, -> onboarding and the profile screen all work end to end. Ingestion, the dashboard and -> declarations are still ahead. +> **Status: phase 3 of 8.** Foundation, tax rules, identity, and ingestion: scan a QR +> comprobante or type one in, and it is classified and waiting in the bandeja. The +> dashboard, declarations and the admin area are still ahead. --- @@ -85,7 +85,8 @@ driver, and user facing strings cannot be written inline in JSX. | `pnpm typecheck` | `tsc --noEmit` in every package | | `pnpm lint` | eslint, including the module boundary and inline copy rules | | `pnpm db:migrate` | Auth tables, then our migrations | -| `pnpm db:seed` | Demo accounts, idempotent | +| `pnpm db:seed` | Demo accounts and their comprobantes, idempotent | +| `pnpm --filter @impuestos/api fixtures` | Redraws the scan fixtures under `/fixtures` | ## Configuration diff --git a/apps/api/package.json b/apps/api/package.json index f658c09..0b779eb 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,9 +9,13 @@ "start": "node dist/index.js", "typecheck": "tsc --noEmit", "db:migrate": "tsx src/db/migrate.cli.ts", - "db:seed": "tsx src/db/seed.cli.ts" + "db:seed": "tsx src/db/seed.cli.ts", + "fixtures": "tsx scripts/render-fixtures.ts" }, "dependencies": { + "@anthropic-ai/sdk": "^0.123.0", + "@aws-sdk/client-s3": "^3.1126.0", + "@aws-sdk/s3-request-presigner": "^3.1126.0", "@hono/node-server": "^2.1.1", "@impuestos/contracts": "workspace:*", "@impuestos/i18n": "workspace:*", @@ -28,6 +32,10 @@ "@types/better-sqlite3": "^9.6.0", "@types/node": "^26.4.1", "@types/pg": "^8.23.1", + "@types/pngjs": "^6.0.5", + "@types/qrcode": "^1.5.6", + "pngjs": "^7.0.0", + "qrcode": "^1.5.4", "tsup": "^8.5.1", "tsx": "^4.23.13", "typescript": "^5.9.3" diff --git a/apps/api/scripts/kude-png.ts b/apps/api/scripts/kude-png.ts new file mode 100644 index 0000000..7892482 --- /dev/null +++ b/apps/api/scripts/kude-png.ts @@ -0,0 +1,85 @@ +import { PNG } from 'pngjs'; +import QRCode from 'qrcode'; +import type { Fixture } from '../src/db/fixtures'; + +const WIDTH = 620; +const HEIGHT = 840; +const QR_MODULE_PX = 6; +const QUIET_ZONE_MODULES = 4; + +/** + * Draws a fixture as a PNG "page" with a real, decodable QR where a KUDE prints one. + * + * SPEC-GAP: CONTRACTS.md section 4 describes rendering these through HTML or canvas. They + * are drawn with raw pixels instead, because the only thing any test reads from them is + * the QR, and a faithful visual replica would mean carrying a browser or an SVG + * rasteriser purely to produce three images nothing looks at. The emitter, date and + * totals live in fixtures.ts, which is where tests and the seed read them from. + */ +export function renderKude(fixture: Fixture): Buffer { + const png = new PNG({ width: WIDTH, height: HEIGHT }); + fill(png, 0xff, 0xff, 0xff); + + // A masthead bar and a rule, so the image reads as a document rather than a blank page. + rect(png, 0, 0, WIDTH, 72, 0x0f, 0x4c, 0x4c); + rect(png, 40, 120, WIDTH - 80, 2, 0xd0, 0xd0, 0xd0); + rect(png, 40, 220, WIDTH - 80, 2, 0xd0, 0xd0, 0xd0); + + if (fixture.qrUrl) drawQr(png, fixture.qrUrl); + + return PNG.sync.write(png); +} + +function drawQr(png: PNG, payload: string): void { + // Level M with an explicit quiet zone: a QR without margin is unreliable to decode. + const qr = QRCode.create(payload, { errorCorrectionLevel: 'M' }); + const size = qr.modules.size; + const data = qr.modules.data; + + const side = (size + QUIET_ZONE_MODULES * 2) * QR_MODULE_PX; + const originX = Math.floor((WIDTH - side) / 2); + const originY = HEIGHT - side - 60; + + rect(png, originX, originY, side, side, 0xff, 0xff, 0xff); + + for (let row = 0; row < size; row++) { + for (let column = 0; column < size; column++) { + if (!data[row * size + column]) continue; + rect( + png, + originX + (column + QUIET_ZONE_MODULES) * QR_MODULE_PX, + originY + (row + QUIET_ZONE_MODULES) * QR_MODULE_PX, + QR_MODULE_PX, + QR_MODULE_PX, + 0x00, + 0x00, + 0x00, + ); + } + } +} + +function fill(png: PNG, r: number, g: number, b: number): void { + rect(png, 0, 0, png.width, png.height, r, g, b); +} + +function rect( + png: PNG, + x: number, + y: number, + width: number, + height: number, + r: number, + g: number, + b: number, +): void { + for (let row = y; row < y + height && row < png.height; row++) { + for (let column = x; column < x + width && column < png.width; column++) { + const index = (png.width * row + column) << 2; + png.data[index] = r; + png.data[index + 1] = g; + png.data[index + 2] = b; + png.data[index + 3] = 0xff; + } + } +} diff --git a/apps/api/scripts/render-fixtures.ts b/apps/api/scripts/render-fixtures.ts new file mode 100644 index 0000000..90df473 --- /dev/null +++ b/apps/api/scripts/render-fixtures.ts @@ -0,0 +1,23 @@ +/** + * Renders the fixture comprobantes to PNG under /fixtures, so the e2e tests scan a real + * image rather than a hand built payload. Run with `pnpm fixtures`. + */ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { FIXTURES } from '../src/db/fixtures'; +import { renderKude } from './kude-png'; + +const here = dirname(fileURLToPath(import.meta.url)); +const outputDir = join(here, '..', '..', '..', 'fixtures'); + +mkdirSync(outputDir, { recursive: true }); + +for (const fixture of FIXTURES) { + const png = renderKude(fixture); + const target = join(outputDir, `${fixture.name}.png`); + writeFileSync(target, png); + console.info(`[fixtures] ${target} (${png.byteLength} bytes)${fixture.cdc ? ' with QR' : ' no QR'}`); +} + +console.info(`[fixtures] ${FIXTURES.length} rendered`); diff --git a/apps/api/src/db/fixtures.ts b/apps/api/src/db/fixtures.ts new file mode 100644 index 0000000..1deecb2 --- /dev/null +++ b/apps/api/src/db/fixtures.ts @@ -0,0 +1,114 @@ +import { buildCdc, computeRucDv } from '@impuestos/rules'; + +/** + * The synthetic comprobantes the fixtures script renders and the tests scan. + * + * Every CDC is assembled from the field table in RULES.md section 4 rather than written + * out as a string, so a change to that table breaks these loudly instead of silently + * producing a CDC that no longer parses. + */ +export interface Fixture { + name: string; + emitterName: string; + emitterRucBase: string; + issueDate: string; // YYYY-MM-DD + total: number; + iva10: number; + /** Absent for the fixture that deliberately carries no QR. */ + cdc?: string; + qrUrl?: string; +} + +const QR_HOST = 'https://ekuatia.set.gov.py/consultas/qr'; + +function fixture(args: { + name: string; + emitterName: string; + emitterRucBase: string; + issueDate: string; + total: number; + iva10: number; + numeroDocumento: string; + codigoSeguridad: string; + withQr: boolean; +}): Fixture { + const base = args.emitterRucBase.padStart(8, '0'); + const dv = String(computeRucDv(args.emitterRucBase)); + + if (!args.withQr) { + return { + name: args.name, + emitterName: args.emitterName, + emitterRucBase: args.emitterRucBase, + issueDate: args.issueDate, + total: args.total, + iva10: args.iva10, + }; + } + + const cdc = buildCdc({ + tipoDocumento: '01', + rucEmisor: base, + dvEmisor: dv, + establecimiento: '001', + puntoExpedicion: '001', + numeroDocumento: args.numeroDocumento, + tipoContribuyente: '1', + fechaEmision: args.issueDate.replaceAll('-', ''), + tipoEmision: '1', + codigoSeguridad: args.codigoSeguridad, + digitoVerificador: '4', + }); + + const qrUrl = + `${QR_HOST}?nVersion=150&Id=${cdc}&dFeEmiDE=${args.issueDate}` + + `&dTotGralOpe=${args.total}&dTotIVA=${args.iva10}&cItems=3`; + + return { + name: args.name, + emitterName: args.emitterName, + emitterRucBase: args.emitterRucBase, + issueDate: args.issueDate, + total: args.total, + iva10: args.iva10, + cdc, + qrUrl, + }; +} + +/** Two with a scannable QR, one without, per CONTRACTS.md section 4. */ +export const FIXTURES: readonly Fixture[] = [ + fixture({ + name: 'factura-qr-supermercado', + emitterName: 'SUPERMERCADO REAL', + emitterRucBase: '80011223', + issueDate: '2026-08-14', + total: 385_000, + iva10: 35_000, + numeroDocumento: '0004521', + codigoSeguridad: '481920573', + withQr: true, + }), + fixture({ + name: 'factura-qr-farmacia', + emitterName: 'FARMACIA CATEDRAL', + emitterRucBase: '80022114', + issueDate: '2026-08-19', + total: 176_000, + iva10: 16_000, + numeroDocumento: '0009087', + codigoSeguridad: '736451028', + withQr: true, + }), + fixture({ + name: 'factura-sin-qr-ferreteria', + emitterName: 'FERRETERIA SAN MIGUEL', + emitterRucBase: '80033005', + issueDate: '2026-08-22', + total: 540_000, + iva10: 49_091, + numeroDocumento: '0001777', + codigoSeguridad: '905172634', + withQr: false, + }), +]; diff --git a/apps/api/src/db/seed-documents.test.ts b/apps/api/src/db/seed-documents.test.ts new file mode 100644 index 0000000..eac79cb --- /dev/null +++ b/apps/api/src/db/seed-documents.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { seedDate } from './seed-documents'; + +describe('seedDate', () => { + it('anchors to the month it is asked for', () => { + const now = new Date('2026-09-04T12:00:00Z'); + expect(seedDate(now, 1, 14)).toBe('2026-08-14'); + expect(seedDate(now, 3, 2)).toBe('2026-06-02'); + }); + + // A comprobante dated after today belongs to a period that has not happened. + it('never produces a future date in the current month', () => { + const now = new Date('2026-09-04T12:00:00Z'); + expect(seedDate(now, 0, 8)).toBe('2026-09-04'); + expect(seedDate(now, 0, 3)).toBe('2026-09-03'); + }); + + it('clamps to the length of a short month', () => { + const now = new Date('2026-03-15T12:00:00Z'); + expect(seedDate(now, 1, 31)).toBe('2026-02-28'); + }); + + it('crosses the year boundary', () => { + const now = new Date('2026-01-20T12:00:00Z'); + expect(seedDate(now, 1, 10)).toBe('2025-12-10'); + }); + + it('never emits a day before the first', () => { + const now = new Date('2026-09-01T12:00:00Z'); + expect(seedDate(now, 0, 8)).toBe('2026-09-01'); + }); +}); diff --git a/apps/api/src/db/seed-documents.ts b/apps/api/src/db/seed-documents.ts new file mode 100644 index 0000000..75c0441 --- /dev/null +++ b/apps/api/src/db/seed-documents.ts @@ -0,0 +1,252 @@ +import { computeRucDv } from '@impuestos/rules'; +import { uuidv7 } from 'uuidv7'; +import { dedupeHash } from '../modules/documents/dedupe'; +import { classifyDocument } from '../modules/documents/service'; +import { recordIngestError } from '../modules/ingest/errors'; +import type { DbHandle } from './index'; +import type { DocumentsTable } from './schema'; + +/** + * Maria's year of comprobantes, per CONTRACTS.md section 4. Deterministic: the amounts, + * dates and emitters are written down rather than generated, so every assertion about + * her dashboard and her declarations is a fixed number somebody can check by hand. + * + * Dates are anchored to the seed's "today" so the previous month is always complete. + */ + +const SEEDED_AT = '2026-01-15T12:00:00.000Z'; + +interface SeedDoc { + emitter: string; + rucBase: string; + /** Day of the month. The month comes from the offset the caller passes. */ + day: number; + monthsAgo: number; + total: number; + rate: 10 | 5 | 0; + direction?: 'purchase' | 'sale'; + regime?: 'normal' | 'resimple' | 'unknown'; + status?: 'confirmed' | 'needs_review'; + source?: 'scan_qr' | 'scan_ocr' | 'manual'; +} + +/** + * 34 purchases and 8 sales for Maria, 29 of the purchases confirmed and 5 waiting in the + * bandeja, exactly as CONTRACTS.md section 4 specifies. + */ + +/** IVA is included in the printed total, so the base is the total less the tax. */ +function split(total: number, rate: 10 | 5 | 0): { + base10: number; + base5: number; + exenta: number; + iva10: number; + iva5: number; +} { + if (rate === 0) return { base10: 0, base5: 0, exenta: total, iva10: 0, iva5: 0 }; + if (rate === 5) { + const iva5 = Math.round(total / 21); + return { base10: 0, base5: total - iva5, exenta: 0, iva10: 0, iva5 }; + } + const iva10 = Math.round(total / 11); + return { base10: total - iva10, base5: 0, exenta: 0, iva10, iva5: 0 }; +} + +const PURCHASES: SeedDoc[] = [ + // Groceries, six of them across the year. + { emitter: 'SUPERMERCADO REAL', rucBase: '80011223', day: 4, monthsAgo: 1, total: 412_000, rate: 10 }, + { emitter: 'SUPERMERCADO REAL', rucBase: '80011223', day: 12, monthsAgo: 1, total: 268_000, rate: 10 }, + { emitter: 'SUPERMERCADO REAL', rucBase: '80011223', day: 21, monthsAgo: 1, total: 735_000, rate: 10 }, + { emitter: 'SUPERMERCADO REAL', rucBase: '80011223', day: 8, monthsAgo: 2, total: 189_000, rate: 10 }, + { emitter: 'SUPERMERCADO REAL', rucBase: '80011223', day: 19, monthsAgo: 3, total: 903_000, rate: 10 }, + { emitter: 'SUPERMERCADO REAL', rucBase: '80011223', day: 26, monthsAgo: 4, total: 544_000, rate: 10 }, + + { emitter: 'FARMACIA CATEDRAL', rucBase: '80022114', day: 6, monthsAgo: 1, total: 176_000, rate: 10 }, + { emitter: 'FARMACIA CATEDRAL', rucBase: '80022114', day: 15, monthsAgo: 2, total: 321_000, rate: 10 }, + { emitter: 'FARMACIA CATEDRAL', rucBase: '80022114', day: 3, monthsAgo: 3, total: 88_000, rate: 10 }, + { emitter: 'FARMACIA CATEDRAL', rucBase: '80022114', day: 22, monthsAgo: 5, total: 245_000, rate: 10 }, + + { emitter: 'COLEGIO SAN JOSE', rucBase: '80033441', day: 5, monthsAgo: 1, total: 1_650_000, rate: 0 }, + { emitter: 'COLEGIO SAN JOSE', rucBase: '80033441', day: 5, monthsAgo: 2, total: 1_650_000, rate: 0 }, + { emitter: 'COLEGIO SAN JOSE', rucBase: '80033441', day: 5, monthsAgo: 3, total: 1_650_000, rate: 0 }, + + { emitter: 'PETROBRAS ESTACION 12', rucBase: '80044552', day: 9, monthsAgo: 1, total: 350_000, rate: 10 }, + { emitter: 'PETROBRAS ESTACION 12', rucBase: '80044552', day: 24, monthsAgo: 2, total: 420_000, rate: 10 }, + { emitter: 'PETROBRAS ESTACION 12', rucBase: '80044552', day: 17, monthsAgo: 4, total: 305_000, rate: 10 }, + + // Rent, at the 5% rate. + { emitter: 'INMOBILIARIA DEL SOL', rucBase: '80055663', day: 2, monthsAgo: 1, total: 2_800_000, rate: 5 }, + { emitter: 'INMOBILIARIA DEL SOL', rucBase: '80055663', day: 2, monthsAgo: 2, total: 2_800_000, rate: 5 }, + + { emitter: 'BOUTIQUE ANDREA', rucBase: '80066774', day: 14, monthsAgo: 2, total: 480_000, rate: 10 }, + { emitter: 'BOUTIQUE ANDREA', rucBase: '80066774', day: 28, monthsAgo: 5, total: 615_000, rate: 10 }, + + { emitter: 'CINE ITAU', rucBase: '80077885', day: 16, monthsAgo: 1, total: 95_000, rate: 10 }, + { emitter: 'CINE ITAU', rucBase: '80077885', day: 23, monthsAgo: 3, total: 120_000, rate: 10 }, + + // RESIMPLE supplier: deductible for IRP but capped, and no IVA credit at all. + { emitter: 'DESPENSA DON JUAN', rucBase: '20033445', day: 11, monthsAgo: 1, total: 145_000, rate: 10, regime: 'resimple' }, + { emitter: 'DESPENSA DON JUAN', rucBase: '20033445', day: 27, monthsAgo: 2, total: 210_000, rate: 10, regime: 'resimple' }, + + // The rest: a mix, including some with no keyword match at all. + { emitter: 'FERRETERIA SAN MIGUEL', rucBase: '80033005', day: 22, monthsAgo: 1, total: 540_000, rate: 10 }, + { emitter: 'CLINICA SANTA RITA', rucBase: '80010118', day: 13, monthsAgo: 3, total: 890_000, rate: 10 }, + { emitter: 'SERVICIOS INTEGRALES SA', rucBase: '80011229', day: 20, monthsAgo: 4, total: 375_000, rate: 10 }, + { emitter: 'ANDE', rucBase: '80000001', day: 10, monthsAgo: 1, total: 318_000, rate: 10 }, + { emitter: 'ESSAP', rucBase: '80000002', day: 10, monthsAgo: 1, total: 96_000, rate: 10 }, + + // Waiting in the bandeja, two of them from a low confidence OCR read. + { emitter: 'PANADERIA LA UNION', rucBase: '80012330', day: 3, monthsAgo: 0, total: 62_000, rate: 10, status: 'needs_review' }, + { emitter: 'TALLER MECANICO RUIZ', rucBase: '80012331', day: 5, monthsAgo: 0, total: 780_000, rate: 10, status: 'needs_review' }, + { emitter: 'CONSULTORA NORTE', rucBase: '80012332', day: 6, monthsAgo: 0, total: 1_200_000, rate: 10, status: 'needs_review' }, + { emitter: 'ALMACEN CENTRAL', rucBase: '80012333', day: 7, monthsAgo: 0, total: 143_000, rate: 10, status: 'needs_review', source: 'scan_ocr' }, + { emitter: 'OPTICA VISION', rucBase: '80012334', day: 8, monthsAgo: 0, total: 455_000, rate: 10, status: 'needs_review', source: 'scan_ocr' }, +]; + +/** Services she invoices, all at 10%. */ +const SALES: SeedDoc[] = [ + { emitter: 'MARIA GONZALEZ', rucBase: '4123456', day: 3, monthsAgo: 1, total: 6_600_000, rate: 10, direction: 'sale' }, + { emitter: 'MARIA GONZALEZ', rucBase: '4123456', day: 17, monthsAgo: 1, total: 4_400_000, rate: 10, direction: 'sale' }, + { emitter: 'MARIA GONZALEZ', rucBase: '4123456', day: 5, monthsAgo: 2, total: 9_900_000, rate: 10, direction: 'sale' }, + { emitter: 'MARIA GONZALEZ', rucBase: '4123456', day: 20, monthsAgo: 2, total: 5_500_000, rate: 10, direction: 'sale' }, + { emitter: 'MARIA GONZALEZ', rucBase: '4123456', day: 9, monthsAgo: 3, total: 7_700_000, rate: 10, direction: 'sale' }, + { emitter: 'MARIA GONZALEZ', rucBase: '4123456', day: 22, monthsAgo: 3, total: 4_400_000, rate: 10, direction: 'sale' }, + { emitter: 'MARIA GONZALEZ', rucBase: '4123456', day: 11, monthsAgo: 4, total: 8_800_000, rate: 10, direction: 'sale' }, + { emitter: 'MARIA GONZALEZ', rucBase: '4123456', day: 25, monthsAgo: 5, total: 6_050_000, rate: 10, direction: 'sale' }, +]; + +const CARLOS_DOCS: SeedDoc[] = [ + { emitter: 'DISTRIBUIDORA DEL ESTE', rucBase: '80020001', day: 4, monthsAgo: 1, total: 4_400_000, rate: 10 }, + { emitter: 'IMPRENTA MODERNA', rucBase: '80020002', day: 12, monthsAgo: 1, total: 1_320_000, rate: 10 }, + { emitter: 'FERRETERIA SAN MIGUEL', rucBase: '80033005', day: 19, monthsAgo: 2, total: 880_000, rate: 10 }, + { emitter: 'BENITEZ Y ASOCIADOS SRL', rucBase: '80012345', day: 6, monthsAgo: 1, total: 12_100_000, rate: 10, direction: 'sale' }, + { emitter: 'CONSULTORA SUR', rucBase: '80020003', day: 8, monthsAgo: 0, total: 660_000, rate: 10, status: 'needs_review' }, + { emitter: 'PAPELERIA CENTRAL', rucBase: '80020004', day: 9, monthsAgo: 0, total: 231_000, rate: 10, status: 'needs_review' }, +]; + +/** + * Anchors every seeded date to the same "now", so the previous month is always complete. + * + * Never returns a future date: the current month is only partly elapsed, and a comprobante + * dated after today would land in a period that has not happened, quietly corrupting every + * projection and deadline computed from it. + */ +export function seedDate(now: Date, monthsAgo: number, day: number): string { + const anchor = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - monthsAgo, 1)); + const lastOfMonth = new Date( + Date.UTC(anchor.getUTCFullYear(), anchor.getUTCMonth() + 1, 0), + ).getUTCDate(); + + const latestAllowed = monthsAgo === 0 ? Math.min(lastOfMonth, now.getUTCDate()) : lastOfMonth; + const safeDay = Math.max(1, Math.min(day, latestAllowed)); + + return `${anchor.getUTCFullYear()}-${String(anchor.getUTCMonth() + 1).padStart(2, '0')}-${String(safeDay).padStart(2, '0')}`; +} + +export async function seedDocuments(handle: DbHandle, now = new Date()): Promise { + const db = handle.db; + + const already = await db.selectFrom('documents').select('id').limit(1).executeTakeFirst(); + if (already) return 0; + + const maria = await userIdFor(handle, 'maria@demo.local'); + const carlos = await userIdFor(handle, 'carlos@demo.local'); + + let inserted = 0; + for (const [userId, docs] of [ + [maria, [...PURCHASES, ...SALES]], + [carlos, CARLOS_DOCS], + ] as const) { + for (const doc of docs) { + await insertDoc(handle, userId, doc, now); + inserted += 1; + } + } + + // Two open ingest errors against Maria, per CONTRACTS.md section 4. + await recordIngestError(db, { + userId: maria, + stage: 'ocr', + message: 'la foto salio movida y no se pudieron leer los importes', + payload: { seeded: true }, + }); + await recordIngestError(db, { + userId: maria, + stage: 'qr_parse', + message: 'el QR de la factura no tenia un CDC valido', + payload: { seeded: true }, + }); + + return inserted; +} + +async function insertDoc( + handle: DbHandle, + userId: string, + doc: SeedDoc, + now: Date, +): Promise { + const issueDate = seedDate(now, doc.monthsAgo, doc.day); + const amounts = split(doc.total, doc.rate); + const direction = doc.direction ?? 'purchase'; + const status = doc.status ?? 'confirmed'; + const id = uuidv7(); + + const row: DocumentsTable = { + id, + user_id: userId, + source: doc.source ?? 'scan_qr', + status, + cdc: null, + qr_url: null, + doc_kind: 'factura', + direction, + emitter_ruc: doc.rucBase, + emitter_dv: String(computeRucDv(doc.rucBase)), + emitter_name: doc.emitter, + receiver_doc: null, + issue_date: issueDate, + currency: 'PYG', + total: doc.total, + amount_iva10: amounts.base10, + amount_iva5: amounts.base5, + amount_exenta: amounts.exenta, + iva10: amounts.iva10, + iva5: amounts.iva5, + supplier_regime_hint: doc.regime ?? 'normal', + verified_dnit: 0, + verification_status: 'unverified', + dedupe_hash: dedupeHash({ + emitterRuc: doc.rucBase, + issueDate, + total: doc.total, + docKind: 'factura', + }), + file_id: null, + raw_extraction: null, + created_at: SEEDED_AT, + confirmed_at: status === 'confirmed' ? SEEDED_AT : null, + }; + + await handle.db.insertInto('documents').values(row).execute(); + await classifyDocument({ db: handle.db, storage: nullStorage }, id); +} + +/** The seed never reads a file, so classification does not need a real driver. */ +const nullStorage = { + kind: 'local' as const, + put: async () => undefined, + getStream: async () => new ReadableStream(), + delete: async () => undefined, + url: async () => '', + check: async () => undefined, +}; + +async function userIdFor(handle: DbHandle, email: string): Promise { + const row = await handle.db + .selectFrom('user') + .select('id') + .where('email', '=', email) + .executeTakeFirstOrThrow(); + return row.id; +} diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts index 4983244..37a5a90 100644 --- a/apps/api/src/db/seed.ts +++ b/apps/api/src/db/seed.ts @@ -4,6 +4,7 @@ import type { Auth, Role } from '../auth/options'; import { writeAudit } from '../modules/audit'; import { CONSENT_TEXT_VERSION } from '../modules/pii'; import type { DbHandle } from './index'; +import { seedDocuments } from './seed-documents'; export interface SeedAccount { email: string; @@ -71,6 +72,7 @@ export async function seed(handle: DbHandle, auth: Auth): Promise { } await seedProfiles(handle); + await seedDocuments(handle); await seedAuditTrail(handle); return result; } diff --git a/apps/api/src/http/app.test.ts b/apps/api/src/http/app.test.ts index 528ba10..7668682 100644 --- a/apps/api/src/http/app.test.ts +++ b/apps/api/src/http/app.test.ts @@ -23,7 +23,7 @@ describe('health endpoints', () => { expect(response.status).toBe(200); expect(await response.json()).toEqual({ ok: true, - checks: { database: 'ok', migrations: 'ok' }, + checks: { database: 'ok', migrations: 'ok', storage: 'ok' }, }); }); diff --git a/apps/api/src/http/app.ts b/apps/api/src/http/app.ts index 96d23e7..2ce2352 100644 --- a/apps/api/src/http/app.ts +++ b/apps/api/src/http/app.ts @@ -3,6 +3,8 @@ import type { AppDeps, AppEnv } from './context'; import { HttpError, toEnvelope } from './errors'; import { liveness, readiness } from './health'; import { localeMiddleware, sessionMiddleware } from './middleware'; +import { documentRoutes } from './routes/documents'; +import { fileRoutes } from './routes/files'; import { lookupRoutes } from './routes/lookup'; import { meRoutes } from './routes/me'; @@ -48,6 +50,8 @@ export function createApp(deps: AppDeps): AppHandle { api.use('*', localeMiddleware(deps)); api.route('/lookup', lookupRoutes()); api.route('/me', meRoutes(deps)); + api.route('/documents', documentRoutes(deps)); + api.route('/files', fileRoutes(deps)); app.route('/api', api); diff --git a/apps/api/src/http/context.ts b/apps/api/src/http/context.ts index 87d27c8..8eb0165 100644 --- a/apps/api/src/http/context.ts +++ b/apps/api/src/http/context.ts @@ -2,6 +2,8 @@ import type { Locale } from '@impuestos/i18n'; import type { Auth } from '../auth/options'; import type { DbHandle } from '../db/index'; import type { Env } from '../lib/env'; +import type { IngestDeps } from '../modules/ingest/pipeline'; +import type { StorageDriver } from '../modules/storage'; export interface SessionUser { id: string; @@ -13,6 +15,9 @@ export interface AppDeps { env: Env; handle: DbHandle; auth: Auth; + storage: StorageDriver; + /** Bundles the db and storage the documents and ingest modules need. */ + ingest: IngestDeps; } /** Hono context typing shared by every route and middleware. */ diff --git a/apps/api/src/http/health.ts b/apps/api/src/http/health.ts index 6436df1..8e71048 100644 --- a/apps/api/src/http/health.ts +++ b/apps/api/src/http/health.ts @@ -42,5 +42,12 @@ export async function readiness( checks['migrations'] = 'error'; } + try { + await deps.storage.check(); + checks['storage'] = 'ok'; + } catch { + checks['storage'] = 'error'; + } + return { ok: Object.values(checks).every((state) => state === 'ok'), checks }; } diff --git a/apps/api/src/http/routes/documents.ts b/apps/api/src/http/routes/documents.ts new file mode 100644 index 0000000..f853b43 --- /dev/null +++ b/apps/api/src/http/routes/documents.ts @@ -0,0 +1,150 @@ +import { + ClassificationPatchInput, + DocumentListQuery, + DocumentPatchInput, + ManualDocumentInput, + RejectInput, +} from '@impuestos/contracts'; +import { Hono } from 'hono'; +import type { z } from 'zod'; +import { + confirmDocument, + getDocument, + listDocuments, + patchClassification, + patchDocument, + rejectDocument, +} from '../../modules/documents/service'; +import { listIngestErrorsForUser } from '../../modules/ingest/errors'; +import { ingestManual, ingestScan } from '../../modules/ingest/pipeline'; +import type { AppDeps, AppEnv } from '../context'; +import { HttpError } from '../errors'; +import { requireUser } from '../middleware'; + +/** Bigger than any phone photograph, small enough that a bad request cannot exhaust RAM. */ +const MAX_UPLOAD_BYTES = 12 * 1024 * 1024; + +const ALLOWED_MIME = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/heic', + 'image/heif', + 'application/pdf', +]); + +export function documentRoutes(deps: AppDeps): Hono { + const routes = new Hono(); + + routes.post('/scan', async (c) => { + const user = requireUser(c); + + const form = await c.req.formData().catch(() => null); + const file = form?.get('file'); + if (!form || !(file instanceof File)) throw new HttpError('validation_error', { field: 'file' }); + + if (file.size > MAX_UPLOAD_BYTES) { + throw new HttpError('validation_error', { field: 'file', detail: { maxBytes: MAX_UPLOAD_BYTES } }); + } + const mime = file.type || 'image/jpeg'; + if (!ALLOWED_MIME.has(mime)) { + throw new HttpError('validation_error', { field: 'file', detail: { mime } }); + } + + const qrValue = form.get('qrPayload'); + const outcome = await ingestScan(deps.ingest, user.id, { + file: { data: new Uint8Array(await file.arrayBuffer()), mime }, + qrPayload: typeof qrValue === 'string' ? qrValue : undefined, + }); + + if (outcome.kind === 'needs_manual') return c.json(outcome.result); + return c.json( + outcome.merged ? { ...outcome.document, merged: true } : outcome.document, + outcome.merged ? 200 : 201, + ); + }); + + routes.post('/manual', async (c) => { + const user = requireUser(c); + const input = parse(ManualDocumentInput, await body(c)); + const { document, merged } = await ingestManual(deps.ingest, user.id, input); + return c.json(merged ? { ...document, merged: true } : document, merged ? 200 : 201); + }); + + routes.get('/', async (c) => { + const user = requireUser(c); + const query = parse(DocumentListQuery, stripUndefined(c.req.query())); + return c.json(await listDocuments(deps.ingest, user.id, query)); + }); + + routes.get('/errors', async (c) => { + const user = requireUser(c); + return c.json(await listIngestErrorsForUser(deps.handle.db, user.id)); + }); + + routes.get('/:id', async (c) => { + const user = requireUser(c); + const document = await getDocument(deps.ingest, user.id, c.req.param('id')); + if (!document) throw new HttpError('not_found'); + return c.json(document); + }); + + routes.patch('/:id', async (c) => { + const user = requireUser(c); + const patch = parse(DocumentPatchInput, await body(c)); + const document = await patchDocument(deps.ingest, user.id, c.req.param('id'), patch); + if (!document) throw new HttpError('not_found'); + return c.json(document); + }); + + routes.post('/:id/confirm', async (c) => { + const user = requireUser(c); + const document = await confirmDocument(deps.ingest, user.id, c.req.param('id')); + if (!document) throw new HttpError('not_found'); + return c.json(document); + }); + + routes.post('/:id/reject', async (c) => { + const user = requireUser(c); + const { reason } = parse(RejectInput, await body(c)); + const document = await rejectDocument(deps.ingest, user.id, c.req.param('id'), reason); + if (!document) throw new HttpError('not_found'); + return c.json(document); + }); + + routes.patch('/:id/classification', async (c) => { + const user = requireUser(c); + const patch = parse(ClassificationPatchInput, await body(c)); + const document = await patchClassification(deps.ingest, user.id, c.req.param('id'), patch); + if (!document) throw new HttpError('not_found'); + return c.json(document); + }); + + return routes; +} + +async function body(c: { req: { json: () => Promise } }): Promise { + try { + return await c.req.json(); + } catch { + throw new HttpError('validation_error'); + } +} + +function stripUndefined(query: Record): Record { + return Object.fromEntries( + Object.entries(query).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); +} + +function parse(schema: z.ZodType, value: unknown): T { + const result = schema.safeParse(value); + if (!result.success) { + const issue = result.error.issues[0]; + throw new HttpError('validation_error', { + ...(issue?.path.length ? { field: issue.path.join('.') } : {}), + detail: result.error.issues, + }); + } + return result.data; +} diff --git a/apps/api/src/http/routes/files.ts b/apps/api/src/http/routes/files.ts new file mode 100644 index 0000000..95076f9 --- /dev/null +++ b/apps/api/src/http/routes/files.ts @@ -0,0 +1,62 @@ +import { Hono } from 'hono'; +import { ADMIN_ROLES } from '../../auth/options'; +import { writeAudit } from '../../modules/audit'; +import type { AppDeps, AppEnv } from '../context'; +import { HttpError } from '../errors'; +import { requireUser } from '../middleware'; + +/** + * Files are private. Ownership is proved by joining through the document that references + * the file, so a stolen file id is worthless without the session that owns it. Staff may + * read a file for support, and every one of those reads is audited (SPEC.md section 7). + */ +export function fileRoutes(deps: AppDeps): Hono { + const routes = new Hono(); + + routes.get('/:id', async (c) => { + const user = requireUser(c); + const id = c.req.param('id'); + + const file = await deps.handle.db + .selectFrom('document_files') + .selectAll() + .where('id', '=', id) + .executeTakeFirst(); + if (!file) throw new HttpError('not_found'); + + const owner = await deps.handle.db + .selectFrom('documents') + .select('user_id') + .where('file_id', '=', id) + .executeTakeFirst(); + + const isOwner = owner?.user_id === user.id; + const isStaff = ADMIN_ROLES.includes(user.role as (typeof ADMIN_ROLES)[number]); + + // A file with no document yet belongs to whoever just uploaded it, which the storage + // key records; anything else needs the ownership join or a staff role. + const isUploader = owner === undefined && file.path.startsWith(`${user.id}/`); + if (!isOwner && !isUploader && !isStaff) throw new HttpError('forbidden'); + + if (isStaff && !isOwner) { + await writeAudit(deps.handle.db, { + actorUserId: user.id, + actorRole: user.role, + subjectUserId: owner?.user_id ?? null, + action: 'admin.file_access', + resource: `document_files/${id}`, + }); + } + + const stream = await deps.storage.getStream(file.path); + return new Response(stream, { + headers: { + 'content-type': file.mime, + 'content-length': String(file.size), + 'cache-control': 'private, max-age=300', + }, + }); + }); + + return routes; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 0fe6a90..dbe1fe1 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -5,7 +5,10 @@ import { pendingMigrations } from './db/migrator'; import { createApp } from './http/app'; import type { AppDeps } from './http/context'; import { loadEnv } from './lib/env'; +import { createHandlers, createPoller } from './modules/jobs'; +import { createOcrProvider } from './modules/documents/ocr'; import { createOtpSender } from './modules/notifications/mailer'; +import { createStorage } from './modules/storage'; const DRAIN_TIMEOUT_MS = 25_000; @@ -18,7 +21,11 @@ const auth = createAuth({ sendOtp: createOtpSender(env), }); -const deps: AppDeps = { env, handle, auth }; +const storage = createStorage(env); +const ocr = createOcrProvider(env); +const ingest = { db: handle.db, storage, ocrEnabled: ocr !== null }; + +const deps: AppDeps = { env, handle, auth, storage, ingest }; const { app, startDraining, inFlight } = createApp(deps); const pending = await pendingMigrations(handle).catch(() => ['']); @@ -26,10 +33,25 @@ if (pending.length > 0) { console.warn(`[boot] pending migrations: ${pending.join(', ')}. Run pnpm db:migrate.`); } -if (env.ROLE === 'worker') { - // The worker shares this image and this bootstrap. It serves only the health - // endpoints; the job poller is wired in with the jobs module. - console.info('[boot] role=worker'); +/** + * One poller per process (SPEC.md section 10). A dedicated worker always runs one; a + * server runs one only when JOBS_INLINE is on, which env validation already refuses to + * turn off on SQLite. + */ +const wantsPoller = env.ROLE === 'worker' || env.JOBS_INLINE; +const poller = wantsPoller + ? createPoller({ + db: handle.db, + dialect: handle.dialect, + handlers: createHandlers({ db: handle.db, storage, ocr }), + intervalMs: env.JOBS_POLL_INTERVAL_MS, + staleMinutes: env.JOBS_STALE_MINUTES, + }) + : null; + +if (poller) { + poller.start(); + console.info(`[boot] job poller running (role=${env.ROLE}, ocr=${ocr ? 'on' : 'off'})`); } const server = serve({ fetch: app.fetch, port: env.PORT, hostname: '0.0.0.0' }, (info) => { @@ -56,6 +78,7 @@ async function shutdown(signal: string): Promise { if ('closeAllConnections' in server) server.closeAllConnections(); } + await poller?.stop(); await handle.close(); console.info('[shutdown] done'); process.exit(0); diff --git a/apps/api/src/modules/documents/dedupe.ts b/apps/api/src/modules/documents/dedupe.ts new file mode 100644 index 0000000..663b64c --- /dev/null +++ b/apps/api/src/modules/documents/dedupe.ts @@ -0,0 +1,30 @@ +import { createHash } from 'node:crypto'; + +/** + * SPEC-GAP: SPEC.md section 8 requires a `dedupe_hash` but never defines it, and RULES.md + * does not either, so this is an implementation choice rather than a tax rule. + * + * A CDC already identifies a comprobante uniquely and nationally, so when there is one it + * is the whole key. Without a CDC (manual entry, OCR of a paper factura) the key is the + * tuple that identifies the document to a human: who issued it, when, for how much, and + * of what kind. Two photographs of the same factura collapse; two genuinely different + * facturas from the same shop on the same day for the same amount would collapse too, + * which is why a merge is presented to the user rather than applied silently. + */ +export function dedupeHash(input: { + cdc?: string | null; + emitterRuc: string; + issueDate: string; + total: number; + docKind: string; +}): string { + const material = input.cdc + ? `cdc:${input.cdc}` + : ['manual', input.emitterRuc.trim(), input.issueDate, String(input.total), input.docKind].join('|'); + + return createHash('sha256').update(material).digest('hex'); +} + +export function sha256(data: Uint8Array): string { + return createHash('sha256').update(data).digest('hex'); +} diff --git a/apps/api/src/modules/documents/mapper.ts b/apps/api/src/modules/documents/mapper.ts new file mode 100644 index 0000000..327d79f --- /dev/null +++ b/apps/api/src/modules/documents/mapper.ts @@ -0,0 +1,67 @@ +import type { ClassificationDto, DocumentDto } from '@impuestos/contracts'; +import type { ClassificationsTable, DocumentsTable } from '../../db/schema'; + +export type DocumentRow = DocumentsTable; +export type ClassificationRow = ClassificationsTable; + +export function toClassificationDto(row: ClassificationRow): ClassificationDto { + return { + ivaCreditEligible: row.iva_credit_eligible === 1, + ivaCreditAmount: row.iva_credit_amount, + irpCategory: row.irp_category as ClassificationDto['irpCategory'], + irpDeductibleAmount: row.irp_deductible_amount, + dependentId: row.dependent_id, + confidence: row.confidence, + decidedBy: row.decided_by, + rulesVersion: row.rules_version, + reasons: [], + }; +} + +export function toDocumentDto( + row: DocumentRow, + classification: ClassificationRow | null, + fileUrl: string | null, +): DocumentDto { + const reasons = readReasons(row.raw_extraction); + return { + id: row.id, + source: row.source, + status: row.status, + cdc: row.cdc, + docKind: row.doc_kind, + direction: row.direction, + emitterRuc: row.emitter_ruc, + emitterDv: row.emitter_dv, + emitterName: row.emitter_name, + receiverDoc: row.receiver_doc, + issueDate: row.issue_date, + currency: 'PYG', + total: row.total, + amountIva10: row.amount_iva10, + amountIva5: row.amount_iva5, + amountExenta: row.amount_exenta, + iva10: row.iva10, + iva5: row.iva5, + supplierRegimeHint: row.supplier_regime_hint, + verifiedDnit: row.verified_dnit === 1, + verificationStatus: row.verification_status, + fileUrl, + classification: classification + ? { ...toClassificationDto(classification), reasons } + : null, + createdAt: row.created_at, + confirmedAt: row.confirmed_at, + }; +} + +/** The classifier's reason codes ride along in raw_extraction rather than in a column. */ +function readReasons(rawExtraction: string | null): string[] { + if (!rawExtraction) return []; + try { + const parsed = JSON.parse(rawExtraction) as { reasons?: unknown }; + return Array.isArray(parsed.reasons) ? parsed.reasons.filter((r): r is string => typeof r === 'string') : []; + } catch { + return []; + } +} diff --git a/apps/api/src/modules/documents/ocr.ts b/apps/api/src/modules/documents/ocr.ts new file mode 100644 index 0000000..7e2aac2 --- /dev/null +++ b/apps/api/src/modules/documents/ocr.ts @@ -0,0 +1,126 @@ +import Anthropic from '@anthropic-ai/sdk'; +import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod'; +import { z } from 'zod'; +import type { Env } from '../../lib/env'; + +/** + * RULES.md section 9. The model returns this and nothing else: structured outputs + * constrain the response to the schema, so there is no prose to strip and no JSON to + * repair. Every field is nullable, because "unreadable" is a real answer and guessing a + * number onto a tax document is the one thing this must never do. + */ +export const OcrExtraction = z.object({ + emitter_ruc: z.string().nullable(), + emitter_dv: z.string().nullable(), + emitter_name: z.string().nullable(), + receiver_doc: z.string().nullable(), + doc_number: z.string().nullable(), + issue_date: z.string().nullable(), + total: z.number().int().nullable(), + amount_iva10: z.number().int().nullable(), + amount_iva5: z.number().int().nullable(), + amount_exenta: z.number().int().nullable(), + iva10: z.number().int().nullable(), + iva5: z.number().int().nullable(), + confidence: z.record(z.string(), z.number()), +}); +export type OcrExtraction = z.infer; + +export interface OcrProvider { + extract(args: { data: Uint8Array; mime: string }): Promise; +} + +const SYSTEM_PROMPT = `Sos un extractor de datos de comprobantes fiscales paraguayos (facturas, autofacturas, notas de credito y debito). + +Leé la imagen y devolvé unicamente los campos del esquema. + +Reglas: +- Las facturas paraguayas imprimen las columnas de IVA como "10%", "5%" y "Exentas". + amount_iva10, amount_iva5 y amount_exenta son las bases gravadas de cada columna; + iva10 e iva5 son los impuestos liquidados de cada una. +- Los importes se imprimen con punto como separador de miles y no llevan decimales. + Devolvelos como enteros sin separadores: "1.234.567" es 1234567. +- issue_date en formato YYYY-MM-DD. +- emitter_ruc sin el digito verificador; emitter_dv es ese digito. +- Si un campo no se lee con claridad, devolvé null. Nunca adivines un numero. +- confidence lleva una entrada por campo que sí leiste, de 0 a 1.`; + +/** Guaranies have no cents, so a component sum may legitimately differ by rounding. */ +const TOTAL_TOLERANCE_GS = 1; +/** RULES.md section 9: money fields drop to this when the components do not add up. */ +const MISMATCH_CONFIDENCE = 0.5; + +/** + * Returns null when no key is configured. Every caller treats that as "OCR is off" and + * falls back to manual entry rather than failing the scan (SPEC.md section 8). + */ +export function createOcrProvider(env: Env): OcrProvider | null { + if (!env.ANTHROPIC_API_KEY) return null; + + const client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY }); + + return { + async extract({ data, mime }) { + const message = await client.messages.parse({ + model: env.OCR_MODEL, + max_tokens: 4096, + system: SYSTEM_PROMPT, + output_config: { format: zodOutputFormat(OcrExtraction) }, + messages: [ + { + role: 'user', + content: [ + { + type: 'image', + source: { + type: 'base64', + media_type: imageMediaType(mime), + data: Buffer.from(data).toString('base64'), + }, + }, + { type: 'text', text: 'Extraé los datos de este comprobante.' }, + ], + }, + ], + }); + + const parsed = message.parsed_output; + if (!parsed) throw new Error('OCR returned no parseable output'); + return lowerConfidenceOnMismatch(OcrExtraction.parse(parsed)); + }, + }; +} + +/** + * RULES.md section 9: when the total and the components are both present and disagree, + * the money fields are not trusted, whatever the model said about them. + */ +export function lowerConfidenceOnMismatch(extraction: OcrExtraction): OcrExtraction { + const { total, amount_iva10, amount_iva5, amount_exenta, iva10, iva5 } = extraction; + const components = [amount_iva10, amount_iva5, amount_exenta, iva10, iva5]; + if (total === null || components.some((value) => value === null)) return extraction; + + const derived = + (amount_iva10 ?? 0) + (amount_iva5 ?? 0) + (amount_exenta ?? 0) + (iva10 ?? 0) + (iva5 ?? 0); + if (Math.abs(total - derived) <= TOTAL_TOLERANCE_GS) return extraction; + + const confidence = { ...extraction.confidence }; + for (const field of ['total', 'amount_iva10', 'amount_iva5', 'amount_exenta', 'iva10', 'iva5']) { + if (field in confidence) confidence[field] = Math.min(confidence[field] ?? 1, MISMATCH_CONFIDENCE); + else confidence[field] = MISMATCH_CONFIDENCE; + } + return { ...extraction, confidence }; +} + +type ImageMediaType = 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'; + +function imageMediaType(mime: string): ImageMediaType { + switch (mime) { + case 'image/png': + case 'image/gif': + case 'image/webp': + return mime; + default: + return 'image/jpeg'; + } +} diff --git a/apps/api/src/modules/documents/service.ts b/apps/api/src/modules/documents/service.ts new file mode 100644 index 0000000..77552f4 --- /dev/null +++ b/apps/api/src/modules/documents/service.ts @@ -0,0 +1,470 @@ +import type { + ClassificationPatchInput, + DocumentDto, + DocumentListQuery, + DocumentPatchInput, + ManualDocumentInput, +} from '@impuestos/contracts'; +import { RULES_VERSION, classify, type ClassificationInput } from '@impuestos/rules'; +import type { Kysely } from 'kysely'; +import { uuidv7 } from 'uuidv7'; +import type { Database, DocumentsTable } from '../../db/schema'; +import { getProfile } from '../pii'; +import type { StorageDriver } from '../storage'; +import { dedupeHash } from './dedupe'; +import { toDocumentDto, type ClassificationRow, type DocumentRow } from './mapper'; + +const PAGE_SIZE = 50; + +export interface DocumentsDeps { + db: Kysely; + storage: StorageDriver; +} + +export interface NewDocument { + userId: string; + source: 'scan_qr' | 'scan_ocr' | 'manual'; + cdc?: string | null; + qrUrl?: string | null; + docKind: DocumentsTable['doc_kind']; + direction: 'purchase' | 'sale'; + emitterRuc: string; + emitterDv?: string | null; + emitterName: string; + receiverDoc?: string | null; + issueDate: string; + total: number; + amountIva10: number; + amountIva5: number; + amountExenta: number; + iva10: number; + iva5: number; + supplierRegimeHint: 'normal' | 'resimple' | 'unknown'; + fileId?: string | null; + rawExtraction?: Record | null; +} + +/** + * Creates a document, or merges into the one already holding the same comprobante. + * + * A merge prefers QR sourced data over anything read from a photograph or typed by hand: + * the QR carries the numbers the emitter actually reported to DNIT (SPEC.md section 8). + */ +export async function createOrMerge( + deps: DocumentsDeps, + input: NewDocument, +): Promise<{ document: DocumentDto; merged: boolean }> { + const hash = dedupeHash({ + cdc: input.cdc ?? null, + emitterRuc: input.emitterRuc, + issueDate: input.issueDate, + total: input.total, + docKind: input.docKind, + }); + + const existing = await deps.db + .selectFrom('documents') + .selectAll() + .where('user_id', '=', input.userId) + .where('dedupe_hash', '=', hash) + .executeTakeFirst(); + + if (existing) { + const merged = await mergeInto(deps, existing, input); + return { document: merged, merged: true }; + } + + const now = new Date().toISOString(); + const id = uuidv7(); + + await deps.db + .insertInto('documents') + .values({ + id, + user_id: input.userId, + source: input.source, + status: 'needs_review', + cdc: input.cdc ?? null, + qr_url: input.qrUrl ?? null, + doc_kind: input.docKind, + direction: input.direction, + emitter_ruc: input.emitterRuc, + emitter_dv: input.emitterDv ?? null, + emitter_name: input.emitterName, + receiver_doc: input.receiverDoc ?? null, + issue_date: input.issueDate, + currency: 'PYG', + total: input.total, + amount_iva10: input.amountIva10, + amount_iva5: input.amountIva5, + amount_exenta: input.amountExenta, + iva10: input.iva10, + iva5: input.iva5, + supplier_regime_hint: input.supplierRegimeHint, + verified_dnit: 0, + verification_status: 'unverified', + dedupe_hash: hash, + file_id: input.fileId ?? null, + raw_extraction: input.rawExtraction ? JSON.stringify(input.rawExtraction) : null, + created_at: now, + confirmed_at: null, + }) + .execute(); + + await classifyDocument(deps, id); + const document = await getDocument(deps, input.userId, id); + if (!document) throw new Error('document disappeared immediately after being written'); + return { document, merged: false }; +} + +async function mergeInto( + deps: DocumentsDeps, + existing: DocumentRow, + incoming: NewDocument, +): Promise { + const incomingIsQr = incoming.source === 'scan_qr'; + const existingIsQr = existing.source === 'scan_qr'; + + // Only a QR beats what is already there. Anything else just fills in the blanks. + const patch: Partial = incomingIsQr && !existingIsQr + ? { + source: 'scan_qr', + cdc: incoming.cdc ?? existing.cdc, + qr_url: incoming.qrUrl ?? existing.qr_url, + emitter_ruc: incoming.emitterRuc, + emitter_dv: incoming.emitterDv ?? existing.emitter_dv, + emitter_name: incoming.emitterName, + issue_date: incoming.issueDate, + total: incoming.total, + amount_iva10: incoming.amountIva10, + amount_iva5: incoming.amountIva5, + amount_exenta: incoming.amountExenta, + iva10: incoming.iva10, + iva5: incoming.iva5, + } + : { + cdc: existing.cdc ?? incoming.cdc ?? null, + file_id: existing.file_id ?? incoming.fileId ?? null, + }; + + await deps.db.updateTable('documents').set(patch).where('id', '=', existing.id).execute(); + + // A user who already judged this document keeps their decision. + const classification = await deps.db + .selectFrom('classifications') + .select('decided_by') + .where('document_id', '=', existing.id) + .executeTakeFirst(); + if (classification?.decided_by === 'auto') await classifyDocument(deps, existing.id); + + const document = await getDocument(deps, existing.user_id, existing.id); + if (!document) throw new Error('merged document vanished'); + return document; +} + +/** Runs packages/rules over a document and stores the suggestion. Never overrides a user. */ +export async function classifyDocument(deps: DocumentsDeps, documentId: string): Promise { + const row = await deps.db + .selectFrom('documents') + .selectAll() + .where('id', '=', documentId) + .executeTakeFirst(); + if (!row) return; + + const existing = await deps.db + .selectFrom('classifications') + .selectAll() + .where('document_id', '=', documentId) + .executeTakeFirst(); + if (existing && existing.decided_by !== 'auto') return; + + const profile = await getProfile(deps.db, row.user_id); + const input: ClassificationInput = { + direction: row.direction, + docKind: row.doc_kind, + emitterName: row.emitter_name, + emitterRuc: row.emitter_ruc, + supplierRegimeHint: row.supplier_regime_hint, + taxpayer: { + kind: profile?.taxpayerKind ?? 'individual', + hasIva: hasObligation(profile, 'iva_120'), + hasIrp: hasObligation(profile, 'irp_515'), + }, + amounts: { total: row.total, iva10: row.iva10, iva5: row.iva5 } as ClassificationInput['amounts'], + }; + + const suggestion = classify(input); + const now = new Date().toISOString(); + + const values = { + iva_credit_eligible: suggestion.ivaCreditEligible ? 1 : 0, + iva_credit_amount: suggestion.ivaCreditAmount, + irp_category: suggestion.irpCategory, + irp_deductible_amount: suggestion.irpDeductibleAmount, + dependent_id: null, + confidence: suggestion.confidence, + decided_by: 'auto' as const, + rules_version: RULES_VERSION, + updated_at: now, + }; + + if (existing) { + await deps.db + .updateTable('classifications') + .set(values) + .where('document_id', '=', documentId) + .execute(); + } else { + await deps.db.insertInto('classifications').values({ document_id: documentId, ...values }).execute(); + } + + // Reason codes ride with the document so the detail sheet can explain the decision. + const raw = row.raw_extraction ? (JSON.parse(row.raw_extraction) as Record) : {}; + await deps.db + .updateTable('documents') + .set({ raw_extraction: JSON.stringify({ ...raw, reasons: suggestion.reasons }) }) + .where('id', '=', documentId) + .execute(); +} + +function hasObligation( + profile: { obligations: { code: string; active: boolean }[] } | null, + code: string, +): boolean { + return profile?.obligations.some((o) => o.code === code && o.active) ?? false; +} + +export async function getDocument( + deps: DocumentsDeps, + userId: string, + id: string, +): Promise { + const row = await deps.db + .selectFrom('documents') + .selectAll() + .where('id', '=', id) + .where('user_id', '=', userId) + .executeTakeFirst(); + if (!row) return null; + + const classification = await deps.db + .selectFrom('classifications') + .selectAll() + .where('document_id', '=', id) + .executeTakeFirst(); + + return toDocumentDto(row, classification ?? null, await fileUrlFor(deps, row.file_id)); +} + +async function fileUrlFor(deps: DocumentsDeps, fileId: string | null): Promise { + if (!fileId) return null; + const file = await deps.db + .selectFrom('document_files') + .select(['id', 'path']) + .where('id', '=', fileId) + .executeTakeFirst(); + if (!file) return null; + return deps.storage.kind === 's3' ? deps.storage.url(file.path) : `/api/files/${file.id}`; +} + +export async function listDocuments( + deps: DocumentsDeps, + userId: string, + query: DocumentListQuery, +): Promise<{ items: DocumentDto[]; total: number; cursor?: string }> { + let base = deps.db.selectFrom('documents').where('user_id', '=', userId); + + if (query.month) { + base = base.where('issue_date', '>=', `${query.month}-01`).where('issue_date', '<=', `${query.month}-31`); + } + if (query.direction) base = base.where('direction', '=', query.direction); + if (query.status) base = base.where('status', '=', query.status); + if (query.q) base = base.where('emitter_name', 'like', `%${query.q.toUpperCase()}%`); + if (query.category) { + base = base.where(({ exists, selectFrom }) => + exists( + selectFrom('classifications') + .select('document_id') + .whereRef('classifications.document_id', '=', 'documents.id') + .where('irp_category', '=', query.category as string), + ), + ); + } + + const { total } = await base + .select((eb) => eb.fn.countAll().as('total')) + .executeTakeFirstOrThrow(); + + let page = base.selectAll().orderBy('issue_date', 'desc').orderBy('id', 'desc').limit(PAGE_SIZE + 1); + if (query.cursor) page = page.where('id', '<', query.cursor); + + const rows = await page.execute(); + const hasMore = rows.length > PAGE_SIZE; + const visible = hasMore ? rows.slice(0, PAGE_SIZE) : rows; + + const items = await Promise.all( + visible.map(async (row) => { + const classification = await deps.db + .selectFrom('classifications') + .selectAll() + .where('document_id', '=', row.id) + .executeTakeFirst(); + return toDocumentDto(row, classification ?? null, await fileUrlFor(deps, row.file_id)); + }), + ); + + const last = visible.at(-1); + return { items, total: Number(total), ...(hasMore && last ? { cursor: last.id } : {}) }; +} + +export async function confirmDocument( + deps: DocumentsDeps, + userId: string, + id: string, +): Promise { + await deps.db + .updateTable('documents') + .set({ status: 'confirmed', confirmed_at: new Date().toISOString() }) + .where('id', '=', id) + .where('user_id', '=', userId) + .execute(); + return getDocument(deps, userId, id); +} + +export async function rejectDocument( + deps: DocumentsDeps, + userId: string, + id: string, + reason: string, +): Promise { + const row = await deps.db + .selectFrom('documents') + .select(['id', 'raw_extraction']) + .where('id', '=', id) + .where('user_id', '=', userId) + .executeTakeFirst(); + if (!row) return null; + + const raw = row.raw_extraction ? (JSON.parse(row.raw_extraction) as Record) : {}; + await deps.db + .updateTable('documents') + .set({ status: 'rejected', raw_extraction: JSON.stringify({ ...raw, rejectReason: reason }) }) + .where('id', '=', id) + .execute(); + + return getDocument(deps, userId, id); +} + +/** Editing the numbers re-runs the rules, unless the user has already overridden them. */ +export async function patchDocument( + deps: DocumentsDeps, + userId: string, + id: string, + patch: DocumentPatchInput, +): Promise { + const row = await deps.db + .selectFrom('documents') + .selectAll() + .where('id', '=', id) + .where('user_id', '=', userId) + .executeTakeFirst(); + if (!row) return null; + + const next = { + ...(patch.direction === undefined ? {} : { direction: patch.direction }), + ...(patch.docKind === undefined ? {} : { doc_kind: patch.docKind }), + ...(patch.emitterRuc === undefined ? {} : { emitter_ruc: patch.emitterRuc }), + ...(patch.emitterDv === undefined ? {} : { emitter_dv: patch.emitterDv }), + ...(patch.emitterName === undefined ? {} : { emitter_name: patch.emitterName }), + ...(patch.issueDate === undefined ? {} : { issue_date: patch.issueDate }), + ...(patch.total === undefined ? {} : { total: patch.total }), + ...(patch.amountIva10 === undefined ? {} : { amount_iva10: patch.amountIva10 }), + ...(patch.amountIva5 === undefined ? {} : { amount_iva5: patch.amountIva5 }), + ...(patch.amountExenta === undefined ? {} : { amount_exenta: patch.amountExenta }), + ...(patch.iva10 === undefined ? {} : { iva10: patch.iva10 }), + ...(patch.iva5 === undefined ? {} : { iva5: patch.iva5 }), + ...(patch.supplierRegimeHint === undefined ? {} : { supplier_regime_hint: patch.supplierRegimeHint }), + }; + + if (Object.keys(next).length > 0) { + const hash = dedupeHash({ + cdc: row.cdc, + emitterRuc: next.emitter_ruc ?? row.emitter_ruc, + issueDate: next.issue_date ?? row.issue_date, + total: next.total ?? row.total, + docKind: next.doc_kind ?? row.doc_kind, + }); + await deps.db + .updateTable('documents') + .set({ ...next, dedupe_hash: hash }) + .where('id', '=', id) + .execute(); + } + + await classifyDocument(deps, id); + return getDocument(deps, userId, id); +} + +/** A user decision. `decided_by` flips to 'user' and the classifier stops overriding it. */ +export async function patchClassification( + deps: DocumentsDeps, + userId: string, + id: string, + patch: ClassificationPatchInput, +): Promise { + const row = await deps.db + .selectFrom('documents') + .selectAll() + .where('id', '=', id) + .where('user_id', '=', userId) + .executeTakeFirst(); + if (!row) return null; + + const existing = await deps.db + .selectFrom('classifications') + .selectAll() + .where('document_id', '=', id) + .executeTakeFirst(); + if (!existing) { + await classifyDocument(deps, id); + } + + const current = (existing ?? + (await deps.db + .selectFrom('classifications') + .selectAll() + .where('document_id', '=', id) + .executeTakeFirstOrThrow())) as ClassificationRow; + + const irpCategory = patch.irpCategory ?? current.irp_category; + const ivaCreditEligible = patch.ivaCreditEligible ?? current.iva_credit_eligible === 1; + + await deps.db + .updateTable('classifications') + .set({ + irp_category: irpCategory, + // A category the user chose means the document is deductible at its full total. + irp_deductible_amount: irpCategory === 'none' ? 0 : row.total, + iva_credit_eligible: ivaCreditEligible ? 1 : 0, + iva_credit_amount: ivaCreditEligible ? row.iva10 + row.iva5 : 0, + ...(patch.dependentId === undefined ? {} : { dependent_id: patch.dependentId }), + decided_by: 'user', + updated_at: new Date().toISOString(), + }) + .where('document_id', '=', id) + .execute(); + + return getDocument(deps, userId, id); +} + +export async function countNeedsReview(deps: DocumentsDeps, userId: string): Promise { + const { total } = await deps.db + .selectFrom('documents') + .select((eb) => eb.fn.countAll().as('total')) + .where('user_id', '=', userId) + .where('status', '=', 'needs_review') + .executeTakeFirstOrThrow(); + return Number(total); +} + +export type { ManualDocumentInput }; diff --git a/apps/api/src/modules/ingest/errors.ts b/apps/api/src/modules/ingest/errors.ts new file mode 100644 index 0000000..8c1edb6 --- /dev/null +++ b/apps/api/src/modules/ingest/errors.ts @@ -0,0 +1,63 @@ +import type { IngestErrorDto } from '@impuestos/contracts'; +import type { Kysely } from 'kysely'; +import { uuidv7 } from 'uuidv7'; +import type { Database } from '../../db/schema'; + +export type IngestStage = 'qr_parse' | 'ocr' | 'dedupe' | 'verify' | 'job' | 'other'; + +/** + * Anything that goes wrong on the way in is recorded rather than swallowed: the user gets + * a retry affordance and staff get a queue (SPEC.md section 8, FLOWS.md Flow H). + */ +export async function recordIngestError( + db: Kysely, + entry: { + userId?: string | null; + documentId?: string | null; + stage: IngestStage; + message: string; + payload?: Record; + }, +): Promise { + const id = uuidv7(); + await db + .insertInto('ingest_errors') + .values({ + id, + user_id: entry.userId ?? null, + document_id: entry.documentId ?? null, + stage: entry.stage, + message: entry.message.slice(0, 1000), + payload: entry.payload ? JSON.stringify(entry.payload) : null, + status: 'open', + resolved_by: null, + resolved_at: null, + created_at: new Date().toISOString(), + }) + .execute(); + return id; +} + +export async function listIngestErrorsForUser( + db: Kysely, + userId: string, +): Promise { + const rows = await db + .selectFrom('ingest_errors') + .selectAll() + .where('user_id', '=', userId) + .where('status', '=', 'open') + .orderBy('created_at', 'desc') + .limit(20) + .execute(); + + return rows.map((row) => ({ + id: row.id, + userId: row.user_id, + documentId: row.document_id, + stage: row.stage, + message: row.message, + status: row.status, + createdAt: row.created_at, + })); +} diff --git a/apps/api/src/modules/ingest/pipeline.test.ts b/apps/api/src/modules/ingest/pipeline.test.ts new file mode 100644 index 0000000..b1f953d --- /dev/null +++ b/apps/api/src/modules/ingest/pipeline.test.ts @@ -0,0 +1,238 @@ +import { readFileSync } from 'node:fs'; +import { DocumentDto, NeedsManualDto } from '@impuestos/contracts'; +import { afterEach, describe, expect, it } from 'vitest'; +import { FIXTURES } from '../../db/fixtures'; +import { createHarness, type Harness } from '../../test/harness'; +import type { OcrExtraction, OcrProvider } from '../documents/ocr'; + +const FIXTURE_1 = FIXTURES[0]!; +const fixtureBytes = (name: string) => + new Uint8Array(readFileSync(new URL(`../../../../../fixtures/${name}.png`, import.meta.url))); + +let harness: Harness | null = null; + +afterEach(async () => { + await harness?.close(); + harness = null; +}); + +async function scan( + h: Harness, + cookie: string, + args: { fixture: string; qrPayload?: string }, +): Promise { + const form = new FormData(); + form.set('file', new File([fixtureBytes(args.fixture)], `${args.fixture}.png`, { type: 'image/png' })); + if (args.qrPayload) form.set('qrPayload', args.qrPayload); + return h.app.request('/api/documents/scan', { method: 'POST', body: form, headers: { cookie } }); +} + +describe('scanning a QR comprobante', () => { + it('creates a document prefilled from the CDC', async () => { + const h = (harness = await createHarness()); + const cookie = await h.signIn('maria@demo.local', 'demo-maria-1'); + + const response = await scan(h, cookie, { + fixture: FIXTURE_1.name, + qrPayload: FIXTURE_1.qrUrl as string, + }); + expect(response.status).toBe(201); + + const document = DocumentDto.parse(await response.json()); + expect(document.source).toBe('scan_qr'); + expect(document.status).toBe('needs_review'); + expect(document.cdc).toBe(FIXTURE_1.cdc); + expect(document.issueDate).toBe(FIXTURE_1.issueDate); + expect(document.total).toBe(FIXTURE_1.total); + expect(document.emitterRuc).toBe(FIXTURE_1.emitterRucBase); + expect(document.fileUrl).not.toBeNull(); + expect(document.classification).not.toBeNull(); + }); + + // CONTRACTS.md 5.1: the same comprobante twice is one document, not two. + it('collapses a second scan of the same fixture into the first', async () => { + const h = (harness = await createHarness()); + const cookie = await h.signIn('maria@demo.local', 'demo-maria-1'); + + const before = await countDocuments(h, 'maria@demo.local'); + + const first = DocumentDto.parse( + await (await scan(h, cookie, { fixture: FIXTURE_1.name, qrPayload: FIXTURE_1.qrUrl as string })).json(), + ); + + const second = await scan(h, cookie, { + fixture: FIXTURE_1.name, + qrPayload: FIXTURE_1.qrUrl as string, + }); + expect(second.status).toBe(200); + + const merged = DocumentDto.parse(await second.json()); + expect(merged.merged).toBe(true); + expect(merged.id).toBe(first.id); + expect(await countDocuments(h, 'maria@demo.local')).toBe(before + 1); + }); + + it('keeps two different comprobantes apart', async () => { + const h = (harness = await createHarness()); + const cookie = await h.signIn('maria@demo.local', 'demo-maria-1'); + const second = FIXTURES[1]!; + + const a = DocumentDto.parse( + await (await scan(h, cookie, { fixture: FIXTURE_1.name, qrPayload: FIXTURE_1.qrUrl as string })).json(), + ); + const b = DocumentDto.parse( + await (await scan(h, cookie, { fixture: second.name, qrPayload: second.qrUrl as string })).json(), + ); + expect(b.id).not.toBe(a.id); + expect(b.merged).toBeUndefined(); + }); + + it('does not merge across users', async () => { + const h = (harness = await createHarness()); + const maria = await h.signIn('maria@demo.local', 'demo-maria-1'); + const carlos = await h.signIn('carlos@demo.local', 'demo-carlos-1'); + + const mine = DocumentDto.parse( + await (await scan(h, maria, { fixture: FIXTURE_1.name, qrPayload: FIXTURE_1.qrUrl as string })).json(), + ); + const theirs = DocumentDto.parse( + await (await scan(h, carlos, { fixture: FIXTURE_1.name, qrPayload: FIXTURE_1.qrUrl as string })).json(), + ); + expect(theirs.id).not.toBe(mine.id); + }); + + it('records an ingest error for an unreadable QR and still keeps the file', async () => { + const h = (harness = await createHarness()); + const cookie = await h.signIn('maria@demo.local', 'demo-maria-1'); + + const response = await scan(h, cookie, { fixture: FIXTURE_1.name, qrPayload: 'not-a-cdc' }); + expect(response.status).toBe(200); + NeedsManualDto.parse(await response.json()); + + const errors = await h.deps.handle.db + .selectFrom('ingest_errors') + .selectAll() + .where('stage', '=', 'qr_parse') + .execute(); + expect(errors.length).toBeGreaterThan(0); + }); +}); + +describe('scanning without a QR', () => { + it('asks for manual entry when OCR is not configured', async () => { + const h = (harness = await createHarness()); + const cookie = await h.signIn('maria@demo.local', 'demo-maria-1'); + + const response = await scan(h, cookie, { fixture: 'factura-sin-qr-ferreteria' }); + expect(response.status).toBe(200); + + const result = NeedsManualDto.parse(await response.json()); + expect(result.needsManual).toBe(true); + expect(result.fileId).toBeTruthy(); + + // Nothing was queued, because there is nothing that could read the image. + const jobs = await h.deps.handle.db.selectFrom('jobs').selectAll().execute(); + expect(jobs.filter((job) => job.type === 'ocr_extract')).toEqual([]); + }); + + it('queues extraction when OCR is configured, and the job creates the document', async () => { + const h = (harness = await createHarness({ ocr: stubOcr() })); + const cookie = await h.signIn('maria@demo.local', 'demo-maria-1'); + + await scan(h, cookie, { fixture: 'factura-sin-qr-ferreteria' }); + + const queued = await h.deps.handle.db + .selectFrom('jobs') + .selectAll() + .where('type', '=', 'ocr_extract') + .execute(); + expect(queued).toHaveLength(1); + expect(queued[0]?.status).toBe('pending'); + + expect(await h.runJobs()).toBe(1); + + const document = await h.deps.handle.db + .selectFrom('documents') + .selectAll() + .where('emitter_name', '=', 'FERRETERIA SAN MIGUEL') + .where('source', '=', 'scan_ocr') + .executeTakeFirst(); + expect(document?.total).toBe(539_000); + }); +}); + +describe('manual entry', () => { + it('creates a document and classifies it', async () => { + const h = (harness = await createHarness()); + const cookie = await h.signIn('maria@demo.local', 'demo-maria-1'); + + const response = await h.app.request('/api/documents/manual', { + method: 'POST', + headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ + emitterRuc: '80022114', + emitterName: 'Farmacia Catedral', + issueDate: '2026-08-19', + total: 176_000, + amountIva10: 160_000, + iva10: 16_000, + }), + }); + expect(response.status).toBe(201); + + const document = DocumentDto.parse(await response.json()); + expect(document.source).toBe('manual'); + expect(document.emitterName).toBe('FARMACIA CATEDRAL'); + expect(document.classification?.irpCategory).toBe('salud'); + expect(document.classification?.ivaCreditAmount).toBe(16_000); + }); + + it('rejects a malformed date and a missing emitter', async () => { + const h = (harness = await createHarness()); + const cookie = await h.signIn('maria@demo.local', 'demo-maria-1'); + + for (const body of [ + { emitterRuc: '80022114', emitterName: 'X', issueDate: '19/08/2026', total: 1 }, + { emitterRuc: '', emitterName: 'X', issueDate: '2026-08-19', total: 1 }, + ]) { + const response = await h.app.request('/api/documents/manual', { + method: 'POST', + headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + expect(response.status).toBe(400); + } + }); +}); + +function stubOcr(): OcrProvider { + return { + extract: async (): Promise => ({ + emitter_ruc: '80033005', + emitter_dv: '1', + emitter_name: 'FERRETERIA SAN MIGUEL', + receiver_doc: null, + doc_number: '0001777', + // Deliberately not the seeded FERRETERIA figures: those would dedupe into the + // existing document and this test is about the job creating a new one. + issue_date: '2026-08-23', + total: 539_000, + amount_iva10: 490_000, + amount_iva5: 0, + amount_exenta: 0, + iva10: 49_000, + iva5: 0, + confidence: { total: 0.95, emitter_name: 0.9 }, + }), + }; +} + +async function countDocuments(h: Harness, email: string): Promise { + const { total } = await h.deps.handle.db + .selectFrom('documents') + .innerJoin('user', 'user.id', 'documents.user_id') + .select((eb) => eb.fn.countAll().as('total')) + .where('user.email', '=', email) + .executeTakeFirstOrThrow(); + return Number(total); +} diff --git a/apps/api/src/modules/ingest/pipeline.ts b/apps/api/src/modules/ingest/pipeline.ts new file mode 100644 index 0000000..90bf095 --- /dev/null +++ b/apps/api/src/modules/ingest/pipeline.ts @@ -0,0 +1,143 @@ +import type { DocumentDto, ManualDocumentInput, NeedsManualDto } from '@impuestos/contracts'; +import { docKindFromCdc, isRuleError, parseQrPayload } from '@impuestos/rules'; +import type { Kysely } from 'kysely'; +import { uuidv7 } from 'uuidv7'; +import type { Database, DocumentsTable } from '../../db/schema'; +import { createOrMerge, type DocumentsDeps } from '../documents/service'; +import { sha256 } from '../documents/dedupe'; +import { enqueue } from '../jobs/queue'; +import type { StorageDriver } from '../storage'; +import { storageKey } from '../storage'; +import { recordIngestError } from './errors'; + +export interface IngestDeps extends DocumentsDeps { + db: Kysely; + storage: StorageDriver; + /** Null when ANTHROPIC_API_KEY is unset: scans without a QR go to the manual form. */ + ocrEnabled: boolean; +} + +export interface StoredFile { + id: string; + path: string; +} + +/** Stores the upload and records it, before anything is known about what it contains. */ +export async function storeUpload( + deps: IngestDeps, + userId: string, + file: { data: Uint8Array; mime: string }, +): Promise { + const id = uuidv7(); + const path = storageKey(userId, id); + + await deps.storage.put(path, file.data, file.mime); + await deps.db + .insertInto('document_files') + .values({ + id, + driver: deps.storage.kind, + path, + mime: file.mime, + size: file.data.byteLength, + sha256: sha256(file.data), + created_at: new Date().toISOString(), + }) + .execute(); + + return { id, path }; +} + +export type ScanOutcome = + | { kind: 'document'; document: DocumentDto; merged: boolean } + | { kind: 'needs_manual'; result: NeedsManualDto }; + +/** + * SPEC.md section 8. Three paths, in order of how much we can trust the result: + * QR present -> parse the CDC and prefill from it, which is authoritative. + * No QR, OCR on -> queue extraction; the client polls the document. + * No QR, OCR off -> tell the client to open the manual form against this file. + */ +export async function ingestScan( + deps: IngestDeps, + userId: string, + args: { file: { data: Uint8Array; mime: string }; qrPayload?: string | undefined }, +): Promise { + const stored = await storeUpload(deps, userId, args.file); + + if (args.qrPayload && args.qrPayload.trim().length > 0) { + try { + const qr = parseQrPayload(args.qrPayload); + const { document, merged } = await createOrMerge(deps, { + userId, + source: 'scan_qr', + cdc: qr.cdc.cdc, + qrUrl: qr.raw, + docKind: docKindFromCdc(qr.cdc.tipoDocumento) as DocumentsTable['doc_kind'], + // A comprobante someone hands you is a purchase; a sale of your own is entered + // deliberately, because getting this backwards moves money in the declaration. + direction: 'purchase', + emitterRuc: qr.cdc.rucEmisor.replace(/^0+/, ''), + emitterDv: qr.cdc.dvEmisor, + emitterName: `RUC ${qr.cdc.rucEmisor.replace(/^0+/, '')}`, + issueDate: qr.cdc.fechaEmision, + total: qr.total ?? 0, + amountIva10: 0, + amountIva5: 0, + amountExenta: 0, + iva10: qr.totalIva ?? 0, + iva5: 0, + supplierRegimeHint: 'unknown', + fileId: stored.id, + rawExtraction: { qr: qr.raw }, + }); + + await enqueue(deps.db, { type: 'verify_cdc', payload: { documentId: document.id } }); + return { kind: 'document', document, merged }; + } catch (error) { + // A QR that will not parse is not a dead end: fall through to OCR or manual. + await recordIngestError(deps.db, { + userId, + stage: 'qr_parse', + message: isRuleError(error) ? `${error.code}: ${error.message}` : String(error), + payload: { qrPayload: args.qrPayload.slice(0, 500) }, + }); + } + } + + if (!deps.ocrEnabled) { + return { kind: 'needs_manual', result: { needsManual: true, fileId: stored.id } }; + } + + await enqueue(deps.db, { + type: 'ocr_extract', + payload: { userId, fileId: stored.id }, + maxAttempts: 3, + }); + return { kind: 'needs_manual', result: { needsManual: true, fileId: stored.id } }; +} + +export async function ingestManual( + deps: IngestDeps, + userId: string, + input: ManualDocumentInput, +): Promise<{ document: DocumentDto; merged: boolean }> { + return createOrMerge(deps, { + userId, + source: 'manual', + docKind: input.docKind, + direction: input.direction, + emitterRuc: input.emitterRuc, + emitterDv: input.emitterDv ?? null, + emitterName: input.emitterName.toUpperCase(), + issueDate: input.issueDate, + total: input.total, + amountIva10: input.amountIva10, + amountIva5: input.amountIva5, + amountExenta: input.amountExenta, + iva10: input.iva10, + iva5: input.iva5, + supplierRegimeHint: input.supplierRegimeHint, + fileId: input.fileId ?? null, + }); +} diff --git a/apps/api/src/modules/jobs/claim.ts b/apps/api/src/modules/jobs/claim.ts new file mode 100644 index 0000000..2a51029 --- /dev/null +++ b/apps/api/src/modules/jobs/claim.ts @@ -0,0 +1,114 @@ +import { type Kysely, sql } from 'kysely'; +import type { Dialect } from '../../db/index'; +import type { Database, JobsTable } from '../../db/schema'; + +export type JobRow = JobsTable; + +/** + * The one place outside the two driver factories where dialect specific SQL is allowed + * (SPEC.md section 5), because claiming a job exactly once is the one thing the two + * engines genuinely do differently. + * + * Postgres: `FOR UPDATE SKIP LOCKED` lets N workers take different rows concurrently. + * SQLite: a single writer serialises everything, so a conditional UPDATE that only + * matches a still-pending row is already atomic. The `changes` count says who won. + */ +export async function claimJob( + db: Kysely, + dialect: Dialect, + args: { instanceId: string; now: string }, +): Promise { + return dialect === 'postgres' ? claimPostgres(db, args) : claimSqlite(db, args); +} + +async function claimPostgres( + db: Kysely, + args: { instanceId: string; now: string }, +): Promise { + return db.transaction().execute(async (trx) => { + const candidate = await trx + .selectFrom('jobs') + .selectAll() + .where('status', '=', 'pending') + .where('run_at', '<=', args.now) + .orderBy('run_at') + .limit(1) + .forUpdate() + .skipLocked() + .executeTakeFirst(); + + if (!candidate) return null; + + await trx + .updateTable('jobs') + .set({ + status: 'running', + locked_by: args.instanceId, + locked_at: args.now, + attempts: candidate.attempts + 1, + updated_at: args.now, + }) + .where('id', '=', candidate.id) + .execute(); + + return { ...candidate, status: 'running', locked_by: args.instanceId, locked_at: args.now, attempts: candidate.attempts + 1 }; + }); +} + +async function claimSqlite( + db: Kysely, + args: { instanceId: string; now: string }, +): Promise { + const candidate = await db + .selectFrom('jobs') + .select('id') + .where('status', '=', 'pending') + .where('run_at', '<=', args.now) + .orderBy('run_at') + .limit(1) + .executeTakeFirst(); + + if (!candidate) return null; + + const result = await db + .updateTable('jobs') + .set({ + status: 'running', + locked_by: args.instanceId, + locked_at: args.now, + attempts: sql`attempts + 1`, + updated_at: args.now, + }) + // Still pending is the whole guard: a racing claim already flipped it. + .where('id', '=', candidate.id) + .where('status', '=', 'pending') + .executeTakeFirst(); + + if (Number(result.numUpdatedRows) === 0) return null; + + return ( + (await db.selectFrom('jobs').selectAll().where('id', '=', candidate.id).executeTakeFirst()) ?? + null + ); +} + +/** + * Crash recovery: a worker that died mid job left the row `running` and nobody will ever + * finish it. Anything locked longer than the stale window goes back to pending. + */ +export async function recoverStaleJobs( + db: Kysely, + staleMinutes: number, + now: Date, +): Promise { + const cutoff = new Date(now.getTime() - staleMinutes * 60_000).toISOString(); + + const result = await db + .updateTable('jobs') + .set({ status: 'pending', locked_by: null, locked_at: null, updated_at: now.toISOString() }) + .where('status', '=', 'running') + .where('locked_at', '<', cutoff) + .executeTakeFirst(); + + return Number(result.numUpdatedRows); +} diff --git a/apps/api/src/modules/jobs/handlers.ts b/apps/api/src/modules/jobs/handlers.ts new file mode 100644 index 0000000..8c2145a --- /dev/null +++ b/apps/api/src/modules/jobs/handlers.ts @@ -0,0 +1,91 @@ +import type { Kysely } from 'kysely'; +import type { Database, DocumentsTable } from '../../db/schema'; +import { classifyDocument, createOrMerge, type DocumentsDeps } from '../documents/service'; +import type { OcrProvider } from '../documents/ocr'; +import { recordIngestError } from '../ingest/errors'; +import type { StorageDriver } from '../storage'; +import type { JobHandlers } from './poller'; + +export interface HandlerDeps extends DocumentsDeps { + db: Kysely; + storage: StorageDriver; + ocr: OcrProvider | null; +} + +export function createHandlers(deps: HandlerDeps): JobHandlers { + return { + /** + * Reads a photographed factura and creates the document from what it found. Throwing + * is deliberate: the poller retries with backoff, and only a dead job records an + * ingest error, so a transient API blip does not spam the user's error list. + */ + ocr_extract: async ({ payload }) => { + const userId = String(payload['userId'] ?? ''); + const fileId = String(payload['fileId'] ?? ''); + if (!userId || !fileId) throw new Error('ocr_extract needs userId and fileId'); + if (!deps.ocr) throw new Error('OCR is not configured'); + + const file = await deps.db + .selectFrom('document_files') + .selectAll() + .where('id', '=', fileId) + .executeTakeFirstOrThrow(); + + const stream = await deps.storage.getStream(file.path); + const data = new Uint8Array(await new Response(stream).arrayBuffer()); + const extraction = await deps.ocr.extract({ data, mime: file.mime }); + + if (!extraction.emitter_ruc || !extraction.issue_date || extraction.total === null) { + // Not an error to retry: the photograph genuinely does not carry the fields. + await recordIngestError(deps.db, { + userId, + stage: 'ocr', + message: 'the image did not yield an emitter, a date and a total', + payload: { fileId, extraction }, + }); + return; + } + + await createOrMerge(deps, { + userId, + source: 'scan_ocr', + docKind: 'factura', + direction: 'purchase', + emitterRuc: extraction.emitter_ruc, + emitterDv: extraction.emitter_dv, + emitterName: (extraction.emitter_name ?? extraction.emitter_ruc).toUpperCase(), + receiverDoc: extraction.receiver_doc, + issueDate: extraction.issue_date, + total: extraction.total, + amountIva10: extraction.amount_iva10 ?? 0, + amountIva5: extraction.amount_iva5 ?? 0, + amountExenta: extraction.amount_exenta ?? 0, + iva10: extraction.iva10 ?? 0, + iva5: extraction.iva5 ?? 0, + supplierRegimeHint: 'unknown', + fileId, + rawExtraction: { ocr: extraction }, + }); + }, + + classify_document: async ({ payload }) => { + const documentId = String(payload['documentId'] ?? ''); + if (!documentId) throw new Error('classify_document needs documentId'); + await classifyDocument(deps, documentId); + }, + + /** + * Checking a CDC against DNIT is out of scope for v1 (SPEC.md section 10), so this + * records that no verification was attempted rather than pretending one succeeded. + */ + verify_cdc: async ({ payload }) => { + const documentId = String(payload['documentId'] ?? ''); + if (!documentId) return; + await deps.db + .updateTable('documents') + .set({ verification_status: 'unverified', verified_dnit: 0 } satisfies Partial) + .where('id', '=', documentId) + .execute(); + }, + }; +} diff --git a/apps/api/src/modules/jobs/index.ts b/apps/api/src/modules/jobs/index.ts new file mode 100644 index 0000000..3e574a9 --- /dev/null +++ b/apps/api/src/modules/jobs/index.ts @@ -0,0 +1,4 @@ +export { enqueue, nextRunAt, BACKOFF_MS, type EnqueueOptions, type JobType } from './queue'; +export { createPoller, type JobHandlers, type JobContext, type Poller } from './poller'; +export { createHandlers, type HandlerDeps } from './handlers'; +export { claimJob, recoverStaleJobs, type JobRow } from './claim'; diff --git a/apps/api/src/modules/jobs/jobs.test.ts b/apps/api/src/modules/jobs/jobs.test.ts new file mode 100644 index 0000000..674ca65 --- /dev/null +++ b/apps/api/src/modules/jobs/jobs.test.ts @@ -0,0 +1,224 @@ +import { describe, expect, it } from 'vitest'; +import { createHarness } from '../../test/harness'; +import { recoverStaleJobs } from './claim'; +import { enqueue, nextRunAt } from './queue'; + +describe('enqueue', () => { + it('queues a job that is due now', async () => { + const h = await createHarness(); + const { id, deduped } = await enqueue(h.deps.handle.db, { + type: 'classify_document', + payload: { documentId: 'x' }, + }); + + expect(deduped).toBe(false); + const row = await h.deps.handle.db + .selectFrom('jobs') + .selectAll() + .where('id', '=', id) + .executeTakeFirstOrThrow(); + expect(row.status).toBe('pending'); + expect(row.attempts).toBe(0); + await h.close(); + }); + + // Sweeps run daily and a restart must not queue a second copy (SPEC.md section 10). + it('is idempotent for a given dedupe key', async () => { + const h = await createHarness(); + const first = await enqueue(h.deps.handle.db, { + type: 'deadline_sweep', + payload: {}, + dedupeKey: 'sweep:2026-09-04', + }); + const second = await enqueue(h.deps.handle.db, { + type: 'deadline_sweep', + payload: {}, + dedupeKey: 'sweep:2026-09-04', + }); + + expect(second.deduped).toBe(true); + expect(second.id).toBe(first.id); + await h.close(); + }); +}); + +describe('backoff', () => { + it('follows the schedule in SPEC.md section 10', () => { + const now = new Date('2026-09-04T00:00:00Z'); + const minutesLater = (attempts: number) => + (new Date(nextRunAt(attempts, now)).getTime() - now.getTime()) / 60_000; + + expect(minutesLater(1)).toBe(1); + expect(minutesLater(2)).toBe(5); + expect(minutesLater(3)).toBe(25); + expect(minutesLater(4)).toBe(120); + expect(minutesLater(5)).toBe(720); + // Past the schedule the job is dead, but the helper must still return something sane. + expect(minutesLater(6)).toBe(720); + }); +}); + +describe('failure handling', () => { + it('retries a failing job with backoff, then marks it dead', async () => { + const h = await createHarness(); + await enqueue(h.deps.handle.db, { + type: 'classify_document', + payload: {}, // no documentId: the handler throws + maxAttempts: 2, + }); + + await h.runJobs(); + let row = await h.deps.handle.db.selectFrom('jobs').selectAll().executeTakeFirstOrThrow(); + expect(row.status).toBe('pending'); + expect(row.attempts).toBe(1); + expect(row.last_error).toContain('documentId'); + // It is scheduled into the future, so the next tick does not pick it straight back up. + expect(new Date(row.run_at).getTime()).toBeGreaterThan(Date.now()); + + // Make it due again and let it exhaust its attempts. + await h.deps.handle.db + .updateTable('jobs') + .set({ run_at: new Date(Date.now() - 1000).toISOString() }) + .execute(); + await h.runJobs(); + + row = await h.deps.handle.db.selectFrom('jobs').selectAll().executeTakeFirstOrThrow(); + expect(row.status).toBe('dead'); + expect(row.attempts).toBe(2); + await h.close(); + }); +}); + +/** + * The phase 3 acceptance case: a process that dies mid job leaves the row locked and + * `running`, and nobody would ever finish it. The next poller to come up takes it back. + */ +describe('crash recovery', () => { + it('returns a job abandoned by a dead worker to the queue', async () => { + const h = await createHarness(); + const { id } = await enqueue(h.deps.handle.db, { + type: 'classify_document', + payload: { documentId: 'abc' }, + }); + + // Exactly the row a killed process leaves behind. + const abandonedAt = new Date(Date.now() - 60 * 60_000).toISOString(); + await h.deps.handle.db + .updateTable('jobs') + .set({ status: 'running', locked_by: 'worker-that-died', locked_at: abandonedAt, attempts: 1 }) + .where('id', '=', id) + .execute(); + + const recovered = await recoverStaleJobs(h.deps.handle.db, h.env.JOBS_STALE_MINUTES, new Date()); + expect(recovered).toBe(1); + + const row = await h.deps.handle.db + .selectFrom('jobs') + .selectAll() + .where('id', '=', id) + .executeTakeFirstOrThrow(); + expect(row.status).toBe('pending'); + expect(row.locked_by).toBeNull(); + await h.close(); + }); + + it('leaves a job that is merely slow alone', async () => { + const h = await createHarness(); + const { id } = await enqueue(h.deps.handle.db, { + type: 'classify_document', + payload: { documentId: 'abc' }, + }); + await h.deps.handle.db + .updateTable('jobs') + .set({ status: 'running', locked_by: 'busy', locked_at: new Date().toISOString() }) + .where('id', '=', id) + .execute(); + + expect(await recoverStaleJobs(h.deps.handle.db, 10, new Date())).toBe(0); + await h.close(); + }); + + it('a restarted poller picks the recovered job up and runs it', async () => { + const h = await createHarness(); + const document = await h.deps.handle.db + .selectFrom('documents') + .select('id') + .where('status', '=', 'needs_review') + .executeTakeFirstOrThrow(); + + const { id } = await enqueue(h.deps.handle.db, { + type: 'classify_document', + payload: { documentId: document.id }, + }); + await h.deps.handle.db + .updateTable('jobs') + .set({ + status: 'running', + locked_by: 'worker-that-died', + locked_at: new Date(Date.now() - 60 * 60_000).toISOString(), + attempts: 1, + }) + .where('id', '=', id) + .execute(); + + // runOnce recovers stale rows first, which is what a fresh poller does on boot. + expect(await h.runJobs()).toBe(1); + + const row = await h.deps.handle.db + .selectFrom('jobs') + .selectAll() + .where('id', '=', id) + .executeTakeFirstOrThrow(); + expect(row.status).toBe('done'); + expect(row.attempts).toBe(2); + await h.close(); + }); +}); + +describe('claiming', () => { + it('hands a job to exactly one caller', async () => { + const h = await createHarness(); + for (let i = 0; i < 5; i++) { + await enqueue(h.deps.handle.db, { type: 'classify_document', payload: { documentId: `d${i}` } }); + } + + const { claimJob } = await import('./claim'); + const claimed = new Set(); + for (let i = 0; i < 5; i++) { + const job = await claimJob(h.deps.handle.db, h.deps.handle.dialect, { + instanceId: `worker-${i}`, + now: new Date().toISOString(), + }); + expect(job).not.toBeNull(); + expect(claimed.has(job!.id)).toBe(false); + claimed.add(job!.id); + } + + // Nothing left to claim. + expect( + await claimJob(h.deps.handle.db, h.deps.handle.dialect, { + instanceId: 'worker-late', + now: new Date().toISOString(), + }), + ).toBeNull(); + await h.close(); + }); + + it('does not claim a job scheduled for the future', async () => { + const h = await createHarness(); + await enqueue(h.deps.handle.db, { + type: 'classify_document', + payload: {}, + runAt: new Date(Date.now() + 60_000), + }); + + const { claimJob } = await import('./claim'); + expect( + await claimJob(h.deps.handle.db, h.deps.handle.dialect, { + instanceId: 'w', + now: new Date().toISOString(), + }), + ).toBeNull(); + await h.close(); + }); +}); diff --git a/apps/api/src/modules/jobs/poller.ts b/apps/api/src/modules/jobs/poller.ts new file mode 100644 index 0000000..00faa6e --- /dev/null +++ b/apps/api/src/modules/jobs/poller.ts @@ -0,0 +1,135 @@ +import type { Kysely } from 'kysely'; +import { uuidv7 } from 'uuidv7'; +import type { Dialect } from '../../db/index'; +import type { Database } from '../../db/schema'; +import { claimJob, recoverStaleJobs, type JobRow } from './claim'; +import { nextRunAt, type JobType } from './queue'; + +export interface JobContext { + id: string; + type: JobType; + payload: Record; + attempts: number; +} + +export type JobHandler = (context: JobContext) => Promise; +export type JobHandlers = Partial>; + +export interface PollerOptions { + db: Kysely; + dialect: Dialect; + handlers: JobHandlers; + intervalMs: number; + staleMinutes: number; + /** Called for every terminal failure, so the admin error queue sees dead jobs. */ + onDead?: (job: JobRow, error: unknown) => Promise; +} + +export interface Poller { + start(): void; + stop(): Promise; + /** Drains everything currently due. Used by tests and by the first tick after boot. */ + runOnce(): Promise; + readonly instanceId: string; +} + +/** + * One poller per process, guarded by the caller (SPEC.md section 10). Claiming is what + * makes N of these safe against each other, not this loop. + */ +export function createPoller(options: PollerOptions): Poller { + const instanceId = uuidv7(); + let timer: NodeJS.Timeout | null = null; + let running = false; + let stopped = false; + let inFlight: Promise = Promise.resolve(); + + async function runOne(): Promise { + const now = new Date(); + const job = await claimJob(options.db, options.dialect, { + instanceId, + now: now.toISOString(), + }); + if (!job) return false; + + const handler = options.handlers[job.type as JobType]; + try { + if (!handler) throw new Error(`no handler registered for job type: ${job.type}`); + await handler({ + id: job.id, + type: job.type as JobType, + payload: JSON.parse(job.payload) as Record, + attempts: job.attempts, + }); + await options.db + .updateTable('jobs') + .set({ status: 'done', locked_by: null, locked_at: null, updated_at: new Date().toISOString() }) + .where('id', '=', job.id) + .execute(); + } catch (error) { + await fail(job, error); + } + return true; + } + + async function fail(job: JobRow, error: unknown): Promise { + const now = new Date(); + const message = error instanceof Error ? error.message : String(error); + const exhausted = job.attempts >= job.max_attempts; + + await options.db + .updateTable('jobs') + .set({ + status: exhausted ? 'dead' : 'pending', + run_at: exhausted ? job.run_at : nextRunAt(job.attempts, now), + locked_by: null, + locked_at: null, + last_error: message.slice(0, 2000), + updated_at: now.toISOString(), + }) + .where('id', '=', job.id) + .execute(); + + if (exhausted) await options.onDead?.(job, error); + } + + async function runOnce(): Promise { + await recoverStaleJobs(options.db, options.staleMinutes, new Date()); + + let processed = 0; + // Bounded so one tick cannot monopolise the process when the queue is deep. + while (processed < 50 && !stopped && (await runOne())) processed += 1; + return processed; + } + + async function tick(): Promise { + if (running || stopped) return; + running = true; + inFlight = runOnce().catch((error: unknown) => { + console.error('[jobs] poll failed', error); + }); + try { + await inFlight; + } finally { + running = false; + } + } + + return { + instanceId, + start() { + if (timer) return; + timer = setInterval(() => void tick(), options.intervalMs); + // Node should not stay alive purely to keep polling. + timer.unref?.(); + void tick(); + }, + async stop() { + stopped = true; + if (timer) clearInterval(timer); + timer = null; + await inFlight; + }, + runOnce, + }; +} diff --git a/apps/api/src/modules/jobs/queue.ts b/apps/api/src/modules/jobs/queue.ts new file mode 100644 index 0000000..593d15c --- /dev/null +++ b/apps/api/src/modules/jobs/queue.ts @@ -0,0 +1,77 @@ +import type { Kysely } from 'kysely'; +import { uuidv7 } from 'uuidv7'; +import type { Database } from '../../db/schema'; + +export type JobType = + | 'ocr_extract' + | 'classify_document' + | 'verify_cdc' + | 'generate_declaration_pdf' + | 'send_notification' + | 'deadline_sweep' + | 'auto_confirm_sweep' + | 'digest_sweep' + | 'purge_user'; + +/** Retry schedule from SPEC.md section 10: 1m, 5m, 25m, 2h, 12h, then dead. */ +export const BACKOFF_MS = [60_000, 300_000, 1_500_000, 7_200_000, 43_200_000] as const; + +export function nextRunAt(attempts: number, now: Date): string { + const delay = BACKOFF_MS[Math.min(attempts, BACKOFF_MS.length) - 1] ?? BACKOFF_MS[0]; + return new Date(now.getTime() + delay).toISOString(); +} + +export interface EnqueueOptions { + type: JobType; + payload: Record; + runAt?: Date; + maxAttempts?: number; + /** + * Makes the enqueue idempotent. A job with the same type and key that is already + * pending, running or done is not queued again, which is what keeps the daily sweeps + * from piling up when a poller restarts (SPEC.md section 10). + */ + dedupeKey?: string; +} + +export async function enqueue( + db: Kysely, + options: EnqueueOptions, +): Promise<{ id: string; deduped: boolean }> { + const now = new Date(); + const payload = options.dedupeKey + ? { ...options.payload, dedupeKey: options.dedupeKey } + : options.payload; + + if (options.dedupeKey) { + const existing = await db + .selectFrom('jobs') + .select('id') + .where('type', '=', options.type) + .where('status', 'in', ['pending', 'running', 'done']) + .where('payload', 'like', `%"dedupeKey":"${options.dedupeKey}"%`) + .executeTakeFirst(); + if (existing) return { id: existing.id, deduped: true }; + } + + const id = uuidv7(); + await db + .insertInto('jobs') + .values({ + id, + type: options.type, + payload: JSON.stringify(payload), + status: 'pending', + run_at: (options.runAt ?? now).toISOString(), + attempts: 0, + max_attempts: options.maxAttempts ?? 5, + locked_by: null, + locked_at: null, + last_error: null, + created_at: now.toISOString(), + updated_at: now.toISOString(), + }) + .execute(); + + return { id, deduped: false }; +} diff --git a/apps/api/src/modules/storage/driver.ts b/apps/api/src/modules/storage/driver.ts new file mode 100644 index 0000000..10ca64f --- /dev/null +++ b/apps/api/src/modules/storage/driver.ts @@ -0,0 +1,19 @@ +/** + * Everything that touches a stored file goes through this (SPEC.md section 7). Keys never + * contain a user supplied filename: a scan is `/`, so a hostile name cannot + * escape a prefix or collide with someone else's file. + */ +export interface StorageDriver { + readonly kind: 'local' | 's3'; + put(key: string, data: Uint8Array, mime: string): Promise; + getStream(key: string): Promise>; + delete(key: string): Promise; + /** A signed URL on S3, or the authenticated API route for local disk. */ + url(key: string): Promise; + /** Readiness probe: cheap, and it must fail when the backing store is unreachable. */ + check(): Promise; +} + +export function storageKey(userId: string, id: string): string { + return `${userId}/${id}`; +} diff --git a/apps/api/src/modules/storage/index.ts b/apps/api/src/modules/storage/index.ts new file mode 100644 index 0000000..92ffa77 --- /dev/null +++ b/apps/api/src/modules/storage/index.ts @@ -0,0 +1,11 @@ +import type { Env } from '../../lib/env'; +import type { StorageDriver } from './driver'; +import { createLocalDriver } from './local'; +import { createS3Driver } from './s3'; + +export { type StorageDriver, storageKey } from './driver'; + +/** One switch, driven by STORAGE_DRIVER. Nothing else in the codebase branches on it. */ +export function createStorage(env: Env): StorageDriver { + return env.STORAGE_DRIVER === 's3' ? createS3Driver(env) : createLocalDriver(env.STORAGE_LOCAL_PATH); +} diff --git a/apps/api/src/modules/storage/local.ts b/apps/api/src/modules/storage/local.ts new file mode 100644 index 0000000..fc99deb --- /dev/null +++ b/apps/api/src/modules/storage/local.ts @@ -0,0 +1,52 @@ +import { createReadStream } from 'node:fs'; +import { mkdir, rm, stat, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve, sep } from 'node:path'; +import { Readable } from 'node:stream'; +import type { StorageDriver } from './driver'; + +/** + * Local disk. Requires one shared volume across every replica, which the boot check in + * lib/env.ts says out loud. Fine for the compose stack, not for k8s. + */ +export function createLocalDriver(root: string): StorageDriver { + const base = resolve(root); + + /** Refuses any key that would resolve outside the storage root. */ + function pathFor(key: string): string { + const target = resolve(join(base, key)); + if (target !== base && !target.startsWith(base + sep)) { + throw new Error(`storage key escapes the storage root: ${key}`); + } + return target; + } + + return { + kind: 'local', + + async put(key, data, _mime) { + const target = pathFor(key); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, data); + }, + + async getStream(key) { + const target = pathFor(key); + await stat(target); + return Readable.toWeb(createReadStream(target)) as ReadableStream; + }, + + async delete(key) { + await rm(pathFor(key), { force: true }); + }, + + async url(key) { + // Local files are private, so this is the authenticated route rather than a path. + return `/api/files/${encodeURIComponent(key)}`; + }, + + async check() { + await mkdir(base, { recursive: true }); + await stat(base); + }, + }; +} diff --git a/apps/api/src/modules/storage/s3.ts b/apps/api/src/modules/storage/s3.ts new file mode 100644 index 0000000..68cf7a8 --- /dev/null +++ b/apps/api/src/modules/storage/s3.ts @@ -0,0 +1,62 @@ +import { + DeleteObjectCommand, + GetObjectCommand, + HeadBucketCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import type { Env } from '../../lib/env'; +import type { StorageDriver } from './driver'; + +const SIGNED_URL_TTL_SECONDS = 600; + +/** Any S3 compatible endpoint: AWS, MinIO, R2, Backblaze. Path style for the rest. */ +export function createS3Driver(env: Env): StorageDriver { + const bucket = env.S3_BUCKET; + if (!bucket) throw new Error('S3_BUCKET is required for the s3 storage driver'); + + const client = new S3Client({ + region: env.S3_REGION ?? 'us-east-1', + forcePathStyle: env.S3_FORCE_PATH_STYLE, + ...(env.S3_ENDPOINT ? { endpoint: env.S3_ENDPOINT } : {}), + ...(env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY + ? { + credentials: { + accessKeyId: env.S3_ACCESS_KEY_ID, + secretAccessKey: env.S3_SECRET_ACCESS_KEY, + }, + } + : {}), + }); + + return { + kind: 's3', + + async put(key, data, mime) { + await client.send( + new PutObjectCommand({ Bucket: bucket, Key: key, Body: data, ContentType: mime }), + ); + }, + + async getStream(key) { + const result = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key })); + if (!result.Body) throw new Error(`object has no body: ${key}`); + return result.Body.transformToWebStream(); + }, + + async delete(key) { + await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key })); + }, + + async url(key) { + return getSignedUrl(client, new GetObjectCommand({ Bucket: bucket, Key: key }), { + expiresIn: SIGNED_URL_TTL_SECONDS, + }); + }, + + async check() { + await client.send(new HeadBucketCommand({ Bucket: bucket })); + }, + }; +} diff --git a/apps/api/src/test/harness.ts b/apps/api/src/test/harness.ts index 35401bb..672442f 100644 --- a/apps/api/src/test/harness.ts +++ b/apps/api/src/test/harness.ts @@ -1,3 +1,6 @@ +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { createAuth } from '../auth/options'; import { createDb } from '../db/index'; import { migrateToLatest } from '../db/migrator'; @@ -5,6 +8,9 @@ import { seed } from '../db/seed'; import { createApp, type AppHandle } from '../http/app'; import type { AppDeps } from '../http/context'; import { type Env, parseEnv } from '../lib/env'; +import { createHandlers, createPoller, type Poller } from '../modules/jobs'; +import type { OcrProvider } from '../modules/documents/ocr'; +import { createStorage, type StorageDriver } from '../modules/storage'; export const TEST_ENV: Record = { NODE_ENV: 'test', @@ -14,16 +20,27 @@ export const TEST_ENV: Record = { APP_PUBLIC_URL: 'http://localhost:3000', }; +export interface HarnessOptions { + env?: Record; + /** Stand in for the Anthropic call, so OCR paths are testable without a key. */ + ocr?: OcrProvider | null; + storage?: StorageDriver; +} + export interface Harness extends AppHandle { deps: AppDeps; env: Env; + storage: StorageDriver; + /** Drains the job queue. Tests call this instead of waiting on the interval. */ + runJobs: () => Promise; + poller: Poller; close: () => Promise; /** Signs in a seeded account and returns the cookie header for later requests. */ signIn: (email: string, password: string) => Promise; } -export async function createHarness(overrides: Record = {}): Promise { - const parsed = parseEnv({ ...TEST_ENV, ...overrides }); +export async function createHarness(options: HarnessOptions = {}): Promise { + const parsed = parseEnv({ ...TEST_ENV, ...(options.env ?? {}) }); if (!parsed.ok || !parsed.env) throw new Error(parsed.message); const env = parsed.env; @@ -33,14 +50,34 @@ export async function createHarness(overrides: Record = {}): Pro const auth = createAuth({ db: handle.db, dialect: handle.dialect, env, sendOtp: async () => undefined }); await seed(handle, auth); - const deps: AppDeps = { env, handle, auth }; + const storage = + options.storage ?? + createStorage({ ...env, STORAGE_DRIVER: 'local', STORAGE_LOCAL_PATH: mkdtempSync(join(tmpdir(), 'impuestos-test-')) }); + const ocr = options.ocr ?? null; + const ingest = { db: handle.db, storage, ocrEnabled: ocr !== null }; + + const deps: AppDeps = { env, handle, auth, storage, ingest }; const appHandle = createApp(deps); + const poller = createPoller({ + db: handle.db, + dialect: handle.dialect, + handlers: createHandlers({ db: handle.db, storage, ocr }), + intervalMs: env.JOBS_POLL_INTERVAL_MS, + staleMinutes: env.JOBS_STALE_MINUTES, + }); + return { ...appHandle, deps, env, - close: () => handle.close(), + storage, + poller, + runJobs: () => poller.runOnce(), + close: async () => { + await poller.stop(); + await handle.close(); + }, signIn: async (email, password) => { const response = await appHandle.app.request('/api/auth/sign-in/email', { method: 'POST', diff --git a/apps/web/app/[locale]/(app)/bandeja/bandeja.tsx b/apps/web/app/[locale]/(app)/bandeja/bandeja.tsx new file mode 100644 index 0000000..7bf33bd --- /dev/null +++ b/apps/web/app/[locale]/(app)/bandeja/bandeja.tsx @@ -0,0 +1,266 @@ +'use client'; + +import type { IrpCategory } from '@impuestos/contracts'; +import { formatDateLong, formatGs, type Locale } from '@impuestos/i18n'; +import { IRP_CATEGORIES } from '@impuestos/rules'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Check, X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { CategoryPicker } from '@/components/category-picker'; +import { Button, buttonClasses } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { EmptyState } from '@/components/ui/empty-state'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Link } from '@/i18n/navigation'; +import { useT } from '@/i18n/t'; +import { api } from '@/lib/api'; +import { cn } from '@/lib/utils'; + +/** Past this many pixels, letting go commits the swipe. */ +const COMMIT_PX = 96; + +/** + * Flow B4. A stack of documents waiting for a yes. Every gesture has a button and a key, + * because a swipe is unusable with a keyboard and invisible to a screen reader. + */ +export function Bandeja({ locale }: { locale: string }) { + const t = useT(); + const queryClient = useQueryClient(); + const [index, setIndex] = useState(0); + const [picking, setPicking] = useState(false); + const [drag, setDrag] = useState(0); + const [dragging, setDragging] = useState(false); + const dragStart = useRef(null); + + const pending = useQuery({ + queryKey: ['documents', { status: 'needs_review' }], + queryFn: ({ signal }) => api.listDocuments({ status: 'needs_review' }, signal), + }); + + const documents = pending.data?.items ?? []; + // Confirming the last card shortens the list under us. Clamping here rather than in an + // effect keeps the render consistent and avoids a cascading update. + const safeIndex = Math.min(index, Math.max(0, documents.length - 1)); + const current = documents[safeIndex]; + + const invalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey: ['documents'] }), + [queryClient], + ); + + const confirm = useMutation({ + mutationFn: (id: string) => api.confirmDocument(id), + onSuccess: async () => { + setDrag(0); + await invalidate(); + }, + }); + + const reject = useMutation({ + mutationFn: (args: { id: string; reason: 'not_mine' | 'duplicate' | 'other' }) => + api.rejectDocument(args.id, { reason: args.reason }), + onSuccess: async () => { + setDrag(0); + await invalidate(); + }, + }); + + const reclassify = useMutation({ + mutationFn: (args: { id: string; irpCategory: IrpCategory | 'none' }) => + api.patchClassification(args.id, { irpCategory: args.irpCategory }), + onSuccess: async () => { + setPicking(false); + await invalidate(); + }, + }); + + // Desktop keyboard mirrors of every gesture (FLOWS.md B4). + useEffect(() => { + function onKey(event: KeyboardEvent) { + if (!current || picking) return; + if (event.key === 'j') setIndex(Math.min(safeIndex + 1, documents.length - 1)); + else if (event.key === 'k') setIndex(Math.max(safeIndex - 1, 0)); + else if (event.key === 'Enter') confirm.mutate(current.id); + else if (event.key === 'x' || event.key === 'X') reject.mutate({ id: current.id, reason: 'other' }); + else if (/^[1-8]$/.test(event.key)) { + const category = IRP_CATEGORIES[Number(event.key) - 1]; + if (category) reclassify.mutate({ id: current.id, irpCategory: category }); + } else return; + event.preventDefault(); + } + + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [current, documents.length, safeIndex, picking, confirm, reject, reclassify]); + + if (pending.isPending) { + return ( +
+ + + + + + +
+ ); + } + + if (pending.isError) { + return ( + +

+ {t('common.error.generic')} +

+ +
+ ); + } + + if (!current) { + return ( +
+ + } + title={t('bandeja.empty')} + body={t('bandeja.emptyBody')} + action={ + + {t('scan.fab')} + + } + /> + +
+ ); + } + + const category = current.classification?.irpCategory ?? 'none'; + const highConfidence = (current.classification?.confidence ?? 0) >= 0.8; + + return ( +
+
+

{t('bandeja.title')}

+ + {t('bandeja.count', { count: documents.length })} + +
+ + { + dragStart.current = event.clientX; + setDragging(true); + event.currentTarget.setPointerCapture(event.pointerId); + }} + onPointerMove={(event) => { + if (dragStart.current === null) return; + setDrag(event.clientX - dragStart.current); + }} + onPointerUp={() => { + const offset = drag; + dragStart.current = null; + setDragging(false); + if (offset > COMMIT_PX) confirm.mutate(current.id); + else if (offset < -COMMIT_PX) { + setPicking(true); + setDrag(0); + } else setDrag(0); + }} + > +
+
+

{current.emitterName}

+

+ {formatDateLong(locale as Locale, current.issueDate)} +

+
+ + {highConfidence ? t('bandeja.confidence.high') : t('bandeja.confidence.low')} + +
+ +

{formatGs(current.total)}

+ +

+ {t('scan.result.suggested', { category: t(`categories.${category}` as const) })} +

+ + {current.classification?.reasons.length ? ( +
    + {current.classification.reasons.map((reason) => ( +
  • {t(`classification.reason.${reason}` as 'classification.reason.irp_no_category')}
  • + ))} +
+ ) : null} +
+ + {picking ? ( + +

{t('scan.result.changeCat')}

+ reclassify.mutate({ id: current.id, irpCategory: next })} + /> + +
+ ) : ( + /* Confirm leads and takes the full width: it is the action on almost every card, + and three buttons across a 390px screen wraps the longest label. */ +
+ +
+ + +
+
+ )} + +
+

{t('bandeja.swipeHint')}

+

{t('bandeja.keyboardHint')}

+

+ {t('bandeja.autoConfirmNote', { days: 7 })}{' '} + + {t('bandeja.autoConfirmLink')} + +

+
+
+ ); +} diff --git a/apps/web/app/[locale]/(app)/bandeja/page.tsx b/apps/web/app/[locale]/(app)/bandeja/page.tsx new file mode 100644 index 0000000..87fb63e --- /dev/null +++ b/apps/web/app/[locale]/(app)/bandeja/page.tsx @@ -0,0 +1,8 @@ +import { setRequestLocale } from 'next-intl/server'; +import { Bandeja } from './bandeja'; + +export default async function BandejaPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + setRequestLocale(locale); + return ; +} diff --git a/apps/web/app/[locale]/(app)/comprobantes/[id]/document-detail.tsx b/apps/web/app/[locale]/(app)/comprobantes/[id]/document-detail.tsx new file mode 100644 index 0000000..f00c5bc --- /dev/null +++ b/apps/web/app/[locale]/(app)/comprobantes/[id]/document-detail.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { isApiError, type IrpCategory } from '@impuestos/contracts'; +import { formatDateLong, formatGs, type Locale } from '@impuestos/i18n'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { CategoryPicker } from '@/components/category-picker'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { StatusChip } from '@/components/ui/status-chip'; +import { Switch } from '@/components/ui/switch'; +import { useT } from '@/i18n/t'; +import { api } from '@/lib/api'; + +/** Flow E1 detail: the data, the image, and the classification the user can override. */ +export function DocumentDetail({ id, locale }: { id: string; locale: string }) { + const t = useT(); + const queryClient = useQueryClient(); + const [editing, setEditing] = useState(false); + + const document = useQuery({ + queryKey: ['documents', id], + queryFn: ({ signal }) => api.getDocument(id, signal), + retry: (count, error) => !isApiError(error) && count < 1, + }); + + const reclassify = useMutation({ + mutationFn: (patch: { irpCategory?: IrpCategory | 'none'; ivaCreditEligible?: boolean }) => + api.patchClassification(id, patch), + onSuccess: async () => { + setEditing(false); + await queryClient.invalidateQueries({ queryKey: ['documents'] }); + }, + }); + + if (document.isPending) { + return ( + + + + ); + } + + if (document.isError || !document.data) { + const missing = isApiError(document.error) && document.error.code === 'not_found'; + return ( + +

+ {missing ? t('error.not_found') : t('common.error.generic')} +

+ {!missing ? ( + + ) : null} +
+ ); + } + + const doc = document.data; + const classification = doc.classification; + const category = classification?.irpCategory ?? 'none'; + + return ( +
+
+
+

{doc.emitterName}

+

+ {formatDateLong(locale as Locale, doc.issueDate)} +

+
+ +
+ + +

{formatGs(doc.total)}

+
+ + + + + +
+
+ + +
+

{t('bandeja.category')}

+ +
+ + {editing ? ( + reclassify.mutate({ irpCategory: next })} + /> + ) : ( +

+ {t(`categories.${category}` as const)} +

+ )} + + {classification ? ( +
+ + 0 + ? formatGs(classification.irpDeductibleAmount) + : t('docs.detail.notDeductible') + } + /> + + + {classification.reasons.length > 0 ? ( +
    + {classification.reasons.map((reason) => ( +
  • + {t(`classification.reason.${reason}` as 'classification.reason.irp_no_category')} +
  • + ))} +
+ ) : null} +
+ ) : null} +
+ + {doc.fileUrl ? ( + + {/* The stored photograph. Served through the authenticated file route. */} + {doc.emitterName} + + ) : null} +
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/apps/web/app/[locale]/(app)/comprobantes/[id]/page.tsx b/apps/web/app/[locale]/(app)/comprobantes/[id]/page.tsx new file mode 100644 index 0000000..7910513 --- /dev/null +++ b/apps/web/app/[locale]/(app)/comprobantes/[id]/page.tsx @@ -0,0 +1,12 @@ +import { setRequestLocale } from 'next-intl/server'; +import { DocumentDetail } from './document-detail'; + +export default async function DocumentPage({ + params, +}: { + params: Promise<{ locale: string; id: string }>; +}) { + const { locale, id } = await params; + setRequestLocale(locale); + return ; +} diff --git a/apps/web/app/[locale]/(app)/comprobantes/documents-screen.tsx b/apps/web/app/[locale]/(app)/comprobantes/documents-screen.tsx new file mode 100644 index 0000000..521392e --- /dev/null +++ b/apps/web/app/[locale]/(app)/comprobantes/documents-screen.tsx @@ -0,0 +1,188 @@ +'use client'; + +import type { Direction, DocStatus } from '@impuestos/contracts'; +import { formatGs, type Locale } from '@impuestos/i18n'; +import { useQuery } from '@tanstack/react-query'; +import { FileText } from 'lucide-react'; +import { useState } from 'react'; +import { DocCard } from '@/components/doc-card'; +import { Button, buttonClasses } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { EmptyState } from '@/components/ui/empty-state'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Link } from '@/i18n/navigation'; +import { useT } from '@/i18n/t'; +import { api } from '@/lib/api'; +import { cn } from '@/lib/utils'; + +/** Flow E1: filterable list with a monthly total header. */ +export function DocumentsScreen({ locale }: { locale: string }) { + const t = useT(); + const [direction, setDirection] = useState(); + const [status, setStatus] = useState(); + const [search, setSearch] = useState(''); + + const query = { + ...(direction ? { direction } : {}), + ...(status ? { status } : {}), + ...(search.trim() ? { q: search.trim() } : {}), + }; + + const documents = useQuery({ + queryKey: ['documents', query], + queryFn: ({ signal }) => api.listDocuments(query, signal), + }); + + const items = documents.data?.items ?? []; + const total = items.reduce((sum, doc) => sum + (doc.direction === 'purchase' ? doc.total : 0), 0); + + const errors = useQuery({ + queryKey: ['documents', 'errors'], + queryFn: ({ signal }) => api.listIngestErrors(signal), + }); + + return ( +
+
+

{t('docs.title')}

+ + {t('scan.fab')} + +
+ + setSearch(event.target.value)} + className="h-11 w-full rounded-2xl border bg-[var(--surface-raised)] px-4" + /> + +
+ { + setDirection(undefined); + setStatus(undefined); + }} /> + setDirection(direction === 'purchase' ? undefined : 'purchase')} + /> + setDirection(direction === 'sale' ? undefined : 'sale')} + /> + setStatus(status === 'needs_review' ? undefined : 'needs_review')} + /> +
+ + {errors.data && errors.data.length > 0 ? ( + +

{t('docs.errors.title')}

+
    + {errors.data.map((error) => ( +
  • {error.message}
  • + ))} +
+ + {t('docs.errors.retry')} + +
+ ) : null} + + {documents.isPending ? ( + + + + ) : null} + + {documents.isError ? ( + +

+ {t('common.error.generic')} +

+ +
+ ) : null} + + {documents.data && items.length === 0 ? ( + + } + title={t('docs.empty')} + action={ + + {t('docs.emptyCta')} + + } + /> + + ) : null} + + {items.length > 0 ? ( + <> + +
+

{t('docs.monthTotal')}

+

{formatGs(total)}

+
+

+ {t('docs.count', { count: documents.data?.total ?? items.length })} +

+
+ + +
    + {items.map((document) => ( +
  • + + + +
  • + ))} +
+
+ + ) : null} +
+ ); +} + +function Filter({ + label, + active, + onClick, +}: { + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/apps/web/app/[locale]/(app)/comprobantes/nuevo/manual-form.tsx b/apps/web/app/[locale]/(app)/comprobantes/nuevo/manual-form.tsx new file mode 100644 index 0000000..86b21eb --- /dev/null +++ b/apps/web/app/[locale]/(app)/comprobantes/nuevo/manual-form.tsx @@ -0,0 +1,233 @@ +'use client'; + +import type { Direction, SupplierRegimeHint } from '@impuestos/contracts'; +import { formatGsAmount } from '@impuestos/i18n'; +import { computeRucDv, parseRuc, validateRuc } from '@impuestos/rules'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useSearchParams } from 'next/navigation'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { useRouter } from '@/i18n/navigation'; +import { useT } from '@/i18n/t'; +import { api } from '@/lib/api'; +import { cn } from '@/lib/utils'; + +const REGIMES: SupplierRegimeHint[] = ['normal', 'resimple', 'unknown']; + +/** + * Flow B3. One screen, numeric keypads for the money, and the IVA split worked out from + * the total with an override, because retyping a number the factura already prints is how + * people give up halfway. + */ +export function ManualForm() { + const t = useT(); + const router = useRouter(); + const queryClient = useQueryClient(); + const fileId = useSearchParams().get('fileId'); + + const [direction, setDirection] = useState('purchase'); + const [ruc, setRuc] = useState(''); + const [emitterName, setEmitterName] = useState(''); + const [issueDate, setIssueDate] = useState(() => new Date().toISOString().slice(0, 10)); + const [total, setTotal] = useState(''); + const [allTenPercent, setAllTenPercent] = useState(true); + const [regime, setRegime] = useState('unknown'); + const [override, setOverride] = useState<{ iva10: string; iva5: string; exenta: string } | null>(null); + + const parsedRuc = parseRuc(ruc.trim()); + const rucValid = parsedRuc ? validateRuc(parsedRuc.base, parsedRuc.dv) : /^\d{1,8}$/.test(ruc.trim()); + const totalValue = total === '' ? 0 : Number(total); + + // IVA is inside the printed total: at 10% the tax is a eleventh of it, at 5% a twenty first. + const derived = allTenPercent + ? { iva10: Math.round(totalValue / 11), iva5: 0, exenta: 0 } + : { iva10: 0, iva5: Math.round(totalValue / 21), exenta: 0 }; + const split = override + ? { + iva10: Number(override.iva10 || '0'), + iva5: Number(override.iva5 || '0'), + exenta: Number(override.exenta || '0'), + } + : derived; + + const save = useMutation({ + mutationFn: () => { + const base = parsedRuc?.base ?? ruc.trim(); + return api.createManualDocument({ + ...(fileId ? { fileId } : {}), + direction, + docKind: 'factura', + emitterRuc: base, + emitterDv: parsedRuc ? String(parsedRuc.dv) : String(computeRucDv(base)), + emitterName: emitterName.trim(), + issueDate, + total: totalValue, + amountIva10: split.iva10 > 0 ? totalValue - split.iva10 - split.exenta : 0, + amountIva5: split.iva5 > 0 ? totalValue - split.iva5 - split.exenta : 0, + amountExenta: split.exenta, + iva10: split.iva10, + iva5: split.iva5, + supplierRegimeHint: regime, + }); + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['documents'] }); + router.push('/bandeja'); + }, + }); + + const canSave = rucValid && emitterName.trim().length > 0 && totalValue > 0; + + return ( +
+

{t('manual.title')}

+ {fileId ?

{t('scan.needsManual')}

: null} + + +
+ + {t('manual.direction')} + +
+ {(['purchase', 'sale'] as const).map((value) => ( + + ))} +
+
+ +
+ + 0 && !rucValid} + onChange={(event) => setRuc(event.target.value)} + /> + {ruc.length > 0 && !rucValid ? ( +

+ {t('landing.invalidDoc')} +

+ ) : null} +
+ +
+ + setEmitterName(event.target.value)} + /> +
+ +
+ + setIssueDate(event.target.value)} + /> +
+ +
+ + setTotal(event.target.value.replace(/\D/g, ''))} + /> +
+ +
+ +

{t('manual.ivaSplit.help')}

+ +
+ {( + [ + ['iva10', t('manual.iva10')], + ['iva5', t('manual.iva5')], + ['exenta', t('manual.exenta')], + ] as const + ).map(([key, label]) => ( +
+ + { + const digits = event.target.value.replace(/\D/g, ''); + setOverride({ + iva10: String(split.iva10), + iva5: String(split.iva5), + exenta: String(split.exenta), + [key]: digits, + }); + }} + /> +
+ ))} +
+
+ +
+ + +
+ + {save.isError ? ( +

+ {t('common.error.generic')} +

+ ) : null} + + +
+
+ ); +} diff --git a/apps/web/app/[locale]/(app)/comprobantes/nuevo/page.tsx b/apps/web/app/[locale]/(app)/comprobantes/nuevo/page.tsx new file mode 100644 index 0000000..9dff2c5 --- /dev/null +++ b/apps/web/app/[locale]/(app)/comprobantes/nuevo/page.tsx @@ -0,0 +1,21 @@ +import { setRequestLocale } from 'next-intl/server'; +import { Suspense } from 'react'; +import { Card } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ManualForm } from './manual-form'; + +export default async function ManualPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + setRequestLocale(locale); + return ( + + + + } + > + + + ); +} diff --git a/apps/web/app/[locale]/(app)/comprobantes/page.tsx b/apps/web/app/[locale]/(app)/comprobantes/page.tsx new file mode 100644 index 0000000..5192d2c --- /dev/null +++ b/apps/web/app/[locale]/(app)/comprobantes/page.tsx @@ -0,0 +1,8 @@ +import { setRequestLocale } from 'next-intl/server'; +import { DocumentsScreen } from './documents-screen'; + +export default async function DocumentsPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + setRequestLocale(locale); + return ; +} diff --git a/apps/web/app/[locale]/(app)/escanear/page.tsx b/apps/web/app/[locale]/(app)/escanear/page.tsx new file mode 100644 index 0000000..72cfc88 --- /dev/null +++ b/apps/web/app/[locale]/(app)/escanear/page.tsx @@ -0,0 +1,8 @@ +import { setRequestLocale } from 'next-intl/server'; +import { ScanScreen } from './scan-screen'; + +export default async function ScanPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + setRequestLocale(locale); + return ; +} diff --git a/apps/web/app/[locale]/(app)/escanear/scan-screen.tsx b/apps/web/app/[locale]/(app)/escanear/scan-screen.tsx new file mode 100644 index 0000000..a19bcaf --- /dev/null +++ b/apps/web/app/[locale]/(app)/escanear/scan-screen.tsx @@ -0,0 +1,308 @@ +'use client'; + +import type { DocumentDto, ScanResultDto } from '@impuestos/contracts'; +import { formatDateLong, formatGs, type Locale } from '@impuestos/i18n'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Flashlight, Upload } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Button, buttonClasses } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Link, useRouter } from '@/i18n/navigation'; +import { useT } from '@/i18n/t'; +import { api } from '@/lib/api'; +import { captureFrame, decodeQr, decodeQrFromVideo } from '@/lib/qr'; +import { cn } from '@/lib/utils'; + +/** How long to look for a QR before offering the plain photo path (FLOWS.md B1). */ +const NO_QR_HINT_AFTER_MS = 4000; +const DECODE_INTERVAL_MS = 250; + +type Outcome = + | { kind: 'document'; document: DocumentDto } + | { kind: 'needs_manual'; fileId: string }; + +export function ScanScreen({ locale }: { locale: string }) { + const t = useT(); + const router = useRouter(); + const queryClient = useQueryClient(); + + const video = useRef(null); + const stream = useRef(null); + const busy = useRef(false); + + const [cameraFailed, setCameraFailed] = useState(false); + const [showNoQrHint, setShowNoQrHint] = useState(false); + const [torchOn, setTorchOn] = useState(false); + const [outcome, setOutcome] = useState(null); + + const upload = useMutation({ + mutationFn: async (input: { file: File; qrPayload: string | null }) => { + const result: ScanResultDto = await api.scanDocument(input.file, input.qrPayload); + return result; + }, + onSuccess: async (result) => { + await queryClient.invalidateQueries({ queryKey: ['documents'] }); + if ('needsManual' in result) { + setOutcome({ kind: 'needs_manual', fileId: result.fileId }); + return; + } + setOutcome({ kind: 'document', document: result }); + }, + }); + + const submit = useCallback( + (file: File, qrPayload: string | null) => { + if (busy.current) return; + busy.current = true; + upload.mutate({ file, qrPayload }, { onSettled: () => (busy.current = false) }); + }, + [upload], + ); + + // Live capture: the camera is only opened here, and always released on unmount. + useEffect(() => { + if (outcome) return; + let cancelled = false; + + async function open() { + try { + const media = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'environment' }, + audio: false, + }); + if (cancelled) { + media.getTracks().forEach((track) => track.stop()); + return; + } + stream.current = media; + if (video.current) { + video.current.srcObject = media; + await video.current.play().catch(() => undefined); + } + } catch { + if (!cancelled) setCameraFailed(true); + } + } + + void open(); + return () => { + cancelled = true; + stream.current?.getTracks().forEach((track) => track.stop()); + stream.current = null; + }; + }, [outcome]); + + // A QR hit auto-captures: there is nothing for the user to decide once we have it. + useEffect(() => { + if (cameraFailed || outcome) return; + const hintTimer = setTimeout(() => setShowNoQrHint(true), NO_QR_HINT_AFTER_MS); + + const timer = setInterval(() => { + void (async () => { + if (!video.current || busy.current) return; + const payload = await decodeQrFromVideo(video.current); + if (!payload || !video.current) return; + + const frame = await captureFrame(video.current); + if (frame) { + navigator.vibrate?.(40); + submit(frame, payload); + } + })(); + }, DECODE_INTERVAL_MS); + + return () => { + clearTimeout(hintTimer); + clearInterval(timer); + }; + }, [cameraFailed, outcome, submit]); + + async function toggleTorch() { + const track = stream.current?.getVideoTracks()[0]; + if (!track) return; + try { + const next = !torchOn; + // `torch` is a real constraint on mobile but is not in the DOM lib's type yet. + await track.applyConstraints({ advanced: [{ torch: next }] } as unknown as MediaTrackConstraints); + setTorchOn(next); + } catch { + // Most desktop cameras have no torch. Nothing to say about it. + } + } + + async function onPick(file: File) { + // Decoding before upload keeps the QR path working for a photo from the gallery. + submit(file, await decodeQr(file)); + } + + if (outcome?.kind === 'document') { + return ( + setOutcome(null)} + /> + ); + } + + if (outcome?.kind === 'needs_manual') { + router.push(`/comprobantes/nuevo?fileId=${encodeURIComponent(outcome.fileId)}`); + return ( + + + + ); + } + + return ( +
+

{t('scan.title')}

+ +
+
+ ); +} + +function ScanResult({ + document, + locale, + onAnother, +}: { + document: DocumentDto; + locale: Locale; + onAnother: () => void; +}) { + const t = useT(); + const merged = document.merged === true; + + return ( +
+ +
+

+ {merged ? t('scan.duplicate.title') : t('scan.result.title')} +

+ + {document.verifiedDnit ? t('scan.verified') : t('scan.registered')} + +
+ + {merged ? ( +

+ {t('scan.duplicate.body', { date: formatDateLong(locale, document.issueDate) })} +

+ ) : null} + +
+ + + + {document.classification ? ( + + ) : null} +
+ +
+ + {t('scan.goToBandeja')} + + +
+
+
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/apps/web/app/[locale]/(app)/inicio/page.tsx b/apps/web/app/[locale]/(app)/inicio/page.tsx index 9d8bb13..e4e9dfd 100644 --- a/apps/web/app/[locale]/(app)/inicio/page.tsx +++ b/apps/web/app/[locale]/(app)/inicio/page.tsx @@ -44,11 +44,10 @@ export default async function InicioPage({ params }: { params: Promise<{ locale: body={t('home.firstRun.body')} action={
- {/* Scanning and manual entry arrive with the ingestion pipeline in phase 3. */} - + {t('home.firstRun.scan')} - + {t('home.firstRun.manual')}
diff --git a/apps/web/package.json b/apps/web/package.json index d5062d2..ee3f5cb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,7 +23,8 @@ "react": "^19.2.8", "react-dom": "^19.2.8", "tailwind-merge": "^3.6.0", - "zod": "^4.5.4" + "zod": "^4.5.4", + "zxing-wasm": "^3.1.3" }, "devDependencies": { "@tailwindcss/postcss": "^4.3.3", diff --git a/apps/web/scripts/copy-zxing-wasm.mjs b/apps/web/scripts/copy-zxing-wasm.mjs new file mode 100644 index 0000000..93ba71c --- /dev/null +++ b/apps/web/scripts/copy-zxing-wasm.mjs @@ -0,0 +1,21 @@ +// Copies the ZXing reader wasm into public/ so the browser loads it from our own origin. +// +// zxing-wasm fetches it from a CDN by default, which the CSP forbids (SPEC.md section 14: +// no third party scripts) and which would break the offline scan queue. Runs before dev +// and build, so a fresh clone never has to remember. +import { copyFileSync, mkdirSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +// Walk up from whichever build the resolver picked to the package root, so the path does +// not depend on cjs vs es. The package does not export ./package.json. +const readerEntry = require.resolve('zxing-wasm/reader'); +const packageRoot = readerEntry.slice(0, readerEntry.lastIndexOf(`${sep}dist${sep}`)); +const source = join(packageRoot, 'dist', 'reader', 'zxing_reader.wasm'); +const targetDir = join(dirname(dirname(fileURLToPath(import.meta.url))), 'public', 'zxing'); + +mkdirSync(targetDir, { recursive: true }); +copyFileSync(source, join(targetDir, 'zxing_reader.wasm')); +console.info(`[zxing] wasm copied to ${targetDir}`); diff --git a/apps/web/src/components/category-picker.tsx b/apps/web/src/components/category-picker.tsx new file mode 100644 index 0000000..88bcfdc --- /dev/null +++ b/apps/web/src/components/category-picker.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { IRP_CATEGORIES, type IrpCategory } from '@impuestos/contracts'; +import { + Apple, + Car, + Clapperboard, + GraduationCap, + HeartPulse, + House, + Shirt, + Users, +} from 'lucide-react'; +import type { ComponentType } from 'react'; +import { useT } from '@/i18n/t'; +import { cn } from '@/lib/utils'; + +const ICONS: Record> = { + alimentacion: Apple, + salud: HeartPulse, + educacion: GraduationCap, + vivienda: House, + vestimenta: Shirt, + esparcimiento: Clapperboard, + vehiculo: Car, + familiares: Users, +}; + +/** + * The eight IRP categories as an icon grid. Ordered as IRP_CATEGORIES, because the + * keyboard shortcuts in the bandeja are 1 to 8 against that same order. + */ +export function CategoryPicker({ + value, + onSelect, +}: { + value: IrpCategory | 'none'; + onSelect: (category: IrpCategory | 'none') => void; +}) { + const t = useT(); + + return ( +
+ {IRP_CATEGORIES.map((category, index) => { + const Icon = ICONS[category]; + const selected = value === category; + return ( + + ); + })} + +
+ ); +} diff --git a/apps/web/src/components/doc-card.tsx b/apps/web/src/components/doc-card.tsx new file mode 100644 index 0000000..3f8b97c --- /dev/null +++ b/apps/web/src/components/doc-card.tsx @@ -0,0 +1,36 @@ +import type { DocumentDto } from '@impuestos/contracts'; +import { formatDateShort, type Locale } from '@impuestos/i18n'; +import { Money } from '@/components/ui/money'; +import { StatusChip } from '@/components/ui/status-chip'; +import { cn } from '@/lib/utils'; + +/** One comprobante, the same shape in the bandeja and in the list (FLOWS.md section 1). */ +export function DocCard({ + document, + locale, + categoryLabel, + statusLabel, + compact, +}: { + document: DocumentDto; + locale: Locale; + categoryLabel: string; + statusLabel: string; + compact?: boolean; +}) { + return ( +
+
+

{document.emitterName}

+

+ {formatDateShort(locale, document.issueDate)} +

+
+ + {categoryLabel} +
+
+ +
+ ); +} diff --git a/apps/web/src/components/ui/status-chip.tsx b/apps/web/src/components/ui/status-chip.tsx new file mode 100644 index 0000000..39d6cda --- /dev/null +++ b/apps/web/src/components/ui/status-chip.tsx @@ -0,0 +1,18 @@ +import type { DocStatus } from '@impuestos/contracts'; +import { cn } from '@/lib/utils'; + +/** The status vocabulary, coloured the same way on every screen (FLOWS.md section 1). */ +export function StatusChip({ status, label }: { status: DocStatus; label: string }) { + return ( + + {label} + + ); +} diff --git a/apps/web/src/lib/qr.ts b/apps/web/src/lib/qr.ts new file mode 100644 index 0000000..9cad580 --- /dev/null +++ b/apps/web/src/lib/qr.ts @@ -0,0 +1,105 @@ +'use client'; + +/** + * Client side QR decoding (SPEC.md section 3). The platform decoder is tried first, since + * it is hardware accelerated and costs nothing to load; ZXing is the fallback for browsers + * that do not ship one, loaded lazily so the ~1MB wasm is fetched only when it is needed. + * + * The wasm is served from our own origin (public/zxing), not a CDN: the CSP forbids third + * party scripts and the offline queue has to keep working with no network. + */ + +import type * as ZxingReaderModule from 'zxing-wasm/reader'; + +const WASM_PATH = '/zxing/zxing_reader.wasm'; + +type BarcodeDetectorLike = { + detect(source: ImageBitmapSource): Promise<{ rawValue: string }[]>; +}; + +type ZxingReader = typeof ZxingReaderModule; +let zxingReady: Promise | null = null; + +async function loadZxing() { + zxingReady ??= (async () => { + const module = await import('zxing-wasm/reader'); + module.prepareZXingModule({ overrides: { locateFile: () => WASM_PATH }, fireImmediately: false }); + return module; + })(); + return zxingReady; +} + +function nativeDetector(): BarcodeDetectorLike | null { + const candidate = (globalThis as { BarcodeDetector?: new (options: { formats: string[] }) => BarcodeDetectorLike }) + .BarcodeDetector; + if (!candidate) return null; + try { + return new candidate({ formats: ['qr_code'] }); + } catch { + return null; + } +} + +/** Returns the payload of the first QR found, or null. Never throws for a QR-less image. */ +export async function decodeQr(source: Blob): Promise { + const native = nativeDetector(); + if (native) { + try { + const bitmap = await createImageBitmap(source); + const results = await native.detect(bitmap); + bitmap.close(); + const value = results[0]?.rawValue; + if (value) return value; + } catch { + // Fall through: a detector that throws is a detector we do not have. + } + } + + try { + const { readBarcodes } = await loadZxing(); + const results = await readBarcodes(source, { tryHarder: true, formats: ['QRCode'] }); + return results[0]?.text ?? null; + } catch { + return null; + } +} + +/** Decodes a single video frame, for the live camera view. */ +export async function decodeQrFromVideo(video: HTMLVideoElement): Promise { + if (video.readyState < 2 || video.videoWidth === 0) return null; + + const native = nativeDetector(); + if (native) { + try { + const results = await native.detect(video); + const value = results[0]?.rawValue; + if (value) return value; + } catch { + // Fall through to the canvas path below. + } + } + + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const context = canvas.getContext('2d'); + if (!context) return null; + context.drawImage(video, 0, 0); + + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.9)); + return blob ? decodeQr(blob) : null; +} + +/** Grabs the current frame as a file, so a QR hit can auto-capture without a shutter. */ +export async function captureFrame(video: HTMLVideoElement): Promise { + if (video.videoWidth === 0) return null; + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const context = canvas.getContext('2d'); + if (!context) return null; + context.drawImage(video, 0, 0); + + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.92)); + return blob ? new File([blob], 'captura.jpg', { type: 'image/jpeg' }) : null; +} diff --git a/e2e/bandeja.spec.ts b/e2e/bandeja.spec.ts new file mode 100644 index 0000000..d59bbdb --- /dev/null +++ b/e2e/bandeja.spec.ts @@ -0,0 +1,62 @@ +import { es } from '@impuestos/i18n'; +import { expect, test } from '@playwright/test'; + +/** + * Read-only bandeja coverage, so the most phone-centric screen in the product is checked + * on a phone viewport too. Nothing here confirms or rejects: the mutating flows live in + * ingestion.spec.ts and run in a single project to avoid two workers racing for a card. + */ +test.describe('bandeja', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/es/login'); + await page.getByLabel(es['auth.register.email']).fill('maria@demo.local'); + await page.getByLabel(es['auth.login.password']).fill('demo-maria-1'); + await page.getByRole('button', { name: es['auth.login.submit'] }).click(); + await expect(page).toHaveURL(/\/es\/inicio$/); + await page.goto('/es/bandeja'); + }); + + test('shows a card with every gesture mirrored as a button', async ({ page }) => { + const card = page.getByTestId('bandeja-card'); + await expect(card).toBeVisible(); + + // Every swipe has a button, which is what makes the screen usable without gestures. + await expect(page.getByRole('button', { name: es['bandeja.confirm'] })).toBeEnabled(); + await expect(page.getByRole('button', { name: es['scan.result.changeCat'] })).toBeEnabled(); + await expect(page.getByRole('button', { name: es['scan.result.discard'] })).toBeEnabled(); + await expect(page.getByText(es['bandeja.swipeHint'])).toBeVisible(); + }); + + test('opens the category picker with all eight categories', async ({ page }) => { + await page.getByRole('button', { name: es['scan.result.changeCat'] }).click(); + + for (const key of [ + 'categories.alimentacion', + 'categories.salud', + 'categories.educacion', + 'categories.vivienda', + 'categories.vestimenta', + 'categories.esparcimiento', + 'categories.vehiculo', + 'categories.familiares', + ] as const) { + await expect(page.getByRole('button', { name: es[key], exact: true })).toBeVisible(); + } + + await page.getByRole('button', { name: es['common.cancel'] }).click(); + await expect(page.getByRole('button', { name: es['bandeja.confirm'] })).toBeVisible(); + }); + + test('J and K move through the stack without changing anything', async ({ page }) => { + const first = await page.getByTestId('bandeja-card').locator('p').first().textContent(); + + await page.keyboard.press('j'); + await expect(async () => { + const next = await page.getByTestId('bandeja-card').locator('p').first().textContent(); + expect(next).not.toBe(first); + }).toPass({ timeout: 5_000 }); + + await page.keyboard.press('k'); + await expect(page.getByTestId('bandeja-card').locator('p').first()).toHaveText(first ?? ''); + }); +}); diff --git a/e2e/ingestion.spec.ts b/e2e/ingestion.spec.ts new file mode 100644 index 0000000..f9e724c --- /dev/null +++ b/e2e/ingestion.spec.ts @@ -0,0 +1,143 @@ +import { fileURLToPath } from 'node:url'; +import { es } from '@impuestos/i18n'; +import { expect, test, type Page } from '@playwright/test'; + +/** + * Golden paths 2 and 3 (SPEC.md section 13). Both run through the real UI: the browser + * decodes the fixture's QR itself, uploads the image, and the document comes back through + * the same pipeline a phone would use. + */ + +// Serial: these tests confirm and reject out of one account's bandeja, so running two of +// them at once has each pulling cards out from under the other. +test.describe.configure({ mode: 'serial' }); + +const fixture = (name: string) => + fileURLToPath(new URL(`../fixtures/${name}.png`, import.meta.url)); + +async function signIn(page: Page): Promise { + await page.goto('/es/login'); + await page.getByLabel(es['auth.register.email']).fill('maria@demo.local'); + await page.getByLabel(es['auth.login.password']).fill('demo-maria-1'); + await page.getByRole('button', { name: es['auth.login.submit'] }).click(); + await expect(page).toHaveURL(/\/es\/inicio$/); +} + +/** + * These confirm and reject documents out of one shared demo account, so running them in + * two projects at once has the two workers racing for the same card. They run on desktop + * only; bandeja rendering on a phone viewport is covered by bandeja.spec.ts, which reads + * and never mutates. + */ +test.describe('golden path 2: scan a QR comprobante and confirm it', () => { + test.beforeEach(() => { + test.skip(test.info().project.name !== 'desktop', 'mutates shared seed data'); + }); + + test('reads the fixture, then confirms it out of the bandeja', async ({ page }) => { + await signIn(page); + await page.goto('/es/escanear'); + + // "Subir archivo" is the same code path as the camera: decode, then upload. + await page.locator('input[type="file"]').setInputFiles(fixture('factura-qr-supermercado')); + + // Whether this is the first scan of the fixture or a repeat depends on what the + // stack has seen before, and path 2 is about the data, not about which of the two + // headings appears. The duplicate case has its own test below. + await expect(page.getByText(es['scan.registered'])).toBeVisible({ timeout: 20_000 }); + + // A QR carries the emitter's RUC but not its name, so that is what is shown. + await expect(page.getByText('RUC 80011223')).toBeVisible(); + await expect(page.getByText('Gs. 385.000')).toBeVisible(); + // Exact: the duplicate note also names the date, in a sentence. + await expect(page.getByText('14 de agosto', { exact: true })).toBeVisible(); + + await page.getByRole('link', { name: es['scan.goToBandeja'] }).click(); + await expect(page).toHaveURL(/\/es\/bandeja$/); + + const card = page.getByTestId('bandeja-card'); + await expect(card).toBeVisible(); + const emitter = (await card.locator('p').first().textContent()) ?? ''; + await page.getByRole('button', { name: es['bandeja.confirm'] }).click(); + + // The confirmed card leaves the stack. + await expect(async () => { + const next = await page.getByTestId('bandeja-card').locator('p').first().textContent(); + expect(next).not.toBe(emitter); + }).toPass({ timeout: 10_000 }); + }); + + // CONTRACTS.md 5.1, through the UI this time. Scanning twice inside the test makes the + // second outcome deterministic whatever the stack has seen before. + test('scanning the same fixture twice says so instead of duplicating it', async ({ page }) => { + await signIn(page); + + await page.goto('/es/escanear'); + await page.locator('input[type="file"]').setInputFiles(fixture('factura-qr-farmacia')); + await expect(page.getByText(es['scan.registered'])).toBeVisible({ timeout: 20_000 }); + + await page.goto('/es/escanear'); + await page.locator('input[type="file"]').setInputFiles(fixture('factura-qr-farmacia')); + await expect(page.getByRole('heading', { name: es['scan.duplicate.title'] })).toBeVisible({ + timeout: 20_000, + }); + await expect(page.getByText(es['scan.duplicate.body'].split('{')[0] as string)).toBeVisible(); + }); + + test('a photo with no QR sends you to the manual form with the file attached', async ({ page }) => { + await signIn(page); + await page.goto('/es/escanear'); + await page.locator('input[type="file"]').setInputFiles(fixture('factura-sin-qr-ferreteria')); + + await expect(page).toHaveURL(/\/es\/comprobantes\/nuevo\?fileId=/, { timeout: 20_000 }); + await expect(page.getByText(es['scan.needsManual'])).toBeVisible(); + }); +}); + +test.describe('golden path 3: enter a factura by hand and change its category', () => { + test.beforeEach(() => { + test.skip(test.info().project.name !== 'desktop', 'mutates shared seed data'); + }); + + test('saves it, classifies it, and takes a correction', async ({ page }) => { + await signIn(page); + await page.goto('/es/comprobantes/nuevo'); + + // The dedupe key is the issuer, the date and the total, not the name, so the total + // has to differ per run or this correctly merges into a previous run's document. + const emitter = `PANADERIA PRUEBA ${Date.now()}`; + const total = 170_000 + (Date.now() % 9_000); + await page.getByLabel(es['manual.emitterRuc']).fill('80022114-1'); + await page.getByLabel(es['manual.emitterName']).fill(emitter); + await page.getByLabel(es['manual.total']).fill(String(total)); + await page.getByRole('button', { name: es['manual.submit'] }).click(); + + await expect(page).toHaveURL(/\/es\/bandeja$/, { timeout: 15_000 }); + + // PANADERIA is an alimentacion keyword, so that is what the rules suggested. + const card = page.getByTestId('bandeja-card'); + await expect(card).toContainText(emitter); + await expect(card).toContainText(es['categories.alimentacion']); + + // Correct it to Salud, which is the user overriding the classifier. + await page.getByRole('button', { name: es['scan.result.changeCat'] }).click(); + await page.getByRole('button', { name: es['categories.salud'] }).click(); + await expect(card).toContainText(es['categories.salud']); + + await page.getByRole('button', { name: es['bandeja.confirm'] }).click(); + + // The correction survives into the document detail. + await page.goto('/es/comprobantes'); + await page.getByRole('link').filter({ hasText: emitter }).first().click(); + await expect(page.getByTestId('document-category')).toHaveText(es['categories.salud']); + }); + + test('refuses a RUC whose check digit does not match', async ({ page }) => { + await signIn(page); + await page.goto('/es/comprobantes/nuevo'); + + await page.getByLabel(es['manual.emitterRuc']).fill('4123456-9'); + await expect(page.getByText(es['landing.invalidDoc'])).toBeVisible(); + await expect(page.getByRole('button', { name: es['manual.submit'] })).toBeDisabled(); + }); +}); diff --git a/fixtures/factura-qr-farmacia.png b/fixtures/factura-qr-farmacia.png new file mode 100644 index 0000000..4ed7d45 Binary files /dev/null and b/fixtures/factura-qr-farmacia.png differ diff --git a/fixtures/factura-qr-supermercado.png b/fixtures/factura-qr-supermercado.png new file mode 100644 index 0000000..3236770 Binary files /dev/null and b/fixtures/factura-qr-supermercado.png differ diff --git a/fixtures/factura-sin-qr-ferreteria.png b/fixtures/factura-sin-qr-ferreteria.png new file mode 100644 index 0000000..8e66a62 Binary files /dev/null and b/fixtures/factura-sin-qr-ferreteria.png differ diff --git a/package.json b/package.json index 9f7d8d5..4317166 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.12.0", + "jsqr": "^1.4.0", "typescript": "^5.9.3", "typescript-eslint": "^8.69.0", "vitest": "^5.0.0" diff --git a/packages/contracts/src/client.ts b/packages/contracts/src/client.ts index e8e7a46..620d37c 100644 --- a/packages/contracts/src/client.ts +++ b/packages/contracts/src/client.ts @@ -1,16 +1,25 @@ import type { z } from 'zod'; import { + type ClassificationPatchInput, type ConsentInput, DataExportDto, type DeleteAccountInput, DependentDto, type DependentInput, + DocumentDto, + DocumentListDto, + type DocumentListQuery, + type DocumentPatchInput, + IngestErrorDto, LookupDto, + type ManualDocumentInput, NotificationPrefsDto, type NotificationPrefsInput, OkDto, ProfileDto, type ProfileInput, + type RejectInput, + ScanResultDto, } from './dto'; import { ApiError, ErrorEnvelope } from './errors'; @@ -38,6 +47,22 @@ export function createApiClient(options: ApiClientOptions = {}) { const baseUrl = (options.baseUrl ?? '').replace(/\/$/, ''); const doFetch = options.fetch ?? globalThis.fetch; + /** Multipart, so the browser sets the boundary. Never set content-type by hand here. */ + async function requestForm(path: string, form: FormData, schema: z.ZodType): Promise { + const url = `${baseUrl}/api${path}`; + const response = await doFetch(baseUrl ? url : `/api${path}`, { + method: 'POST', + headers: { accept: 'application/json', ...options.headers }, + credentials: 'include', + body: form, + }); + + const text = await response.text(); + const json: unknown = text.length > 0 ? safeJson(text) : undefined; + if (!response.ok) throw toApiError(response.status, json); + return schema.parse(json); + } + async function request( method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string, @@ -106,6 +131,43 @@ export function createApiClient(options: ApiClientOptions = {}) { deleteAccount: (body: DeleteAccountInput) => request('DELETE', '/me/account', { schema: OkDto, body }), + // Documents + scanDocument: (file: File, qrPayload?: string | null) => { + const form = new FormData(); + form.set('file', file); + if (qrPayload) form.set('qrPayload', qrPayload); + return requestForm('/documents/scan', form, ScanResultDto); + }, + createManualDocument: (body: ManualDocumentInput) => + request('POST', '/documents/manual', { schema: DocumentDto, body }), + listDocuments: (query: DocumentListQuery, signal?: AbortSignal) => + request('GET', '/documents', { + schema: DocumentListDto, + query: query as Record, + ...(signal ? { signal } : {}), + }), + getDocument: (id: string, signal?: AbortSignal) => + request('GET', `/documents/${encodeURIComponent(id)}`, { + schema: DocumentDto, + ...(signal ? { signal } : {}), + }), + patchDocument: (id: string, body: DocumentPatchInput) => + request('PATCH', `/documents/${encodeURIComponent(id)}`, { schema: DocumentDto, body }), + confirmDocument: (id: string) => + request('POST', `/documents/${encodeURIComponent(id)}/confirm`, { schema: DocumentDto }), + rejectDocument: (id: string, body: RejectInput) => + request('POST', `/documents/${encodeURIComponent(id)}/reject`, { schema: DocumentDto, body }), + patchClassification: (id: string, body: ClassificationPatchInput) => + request('PATCH', `/documents/${encodeURIComponent(id)}/classification`, { + schema: DocumentDto, + body, + }), + listIngestErrors: (signal?: AbortSignal) => + request('GET', '/documents/errors', { + schema: IngestErrorDto.array(), + ...(signal ? { signal } : {}), + }), + // Notifications getNotificationPrefs: (signal?: AbortSignal) => request('GET', '/me/notification-prefs', { diff --git a/packages/contracts/src/dto.ts b/packages/contracts/src/dto.ts index 6d7f21c..067b6db 100644 --- a/packages/contracts/src/dto.ts +++ b/packages/contracts/src/dto.ts @@ -1,5 +1,17 @@ import { z } from 'zod'; -import { DocType, IrpCategory, LocaleCode, ObligationCode, TaxpayerKind } from './enums'; +import { + Direction, + DocKind, + DocSource, + DocStatus, + DocType, + IrpCategory, + LocaleCode, + ObligationCode, + SupplierRegimeHint, + TaxpayerKind, + VerificationStatus, +} from './enums'; /** * DTO schemas are added as their phase lands. Field names come from CONTRACTS.md @@ -112,6 +124,129 @@ export type DataExportDto = z.infer; export const DeleteAccountInput = z.object({ confirmText: z.string() }); export type DeleteAccountInput = z.infer; +export const ClassificationDto = z.object({ + ivaCreditEligible: z.boolean(), + ivaCreditAmount: z.number().int(), + irpCategory: z.union([IrpCategory, z.literal('none')]), + irpDeductibleAmount: z.number().int(), + dependentId: z.string().nullable(), + confidence: z.number(), + decidedBy: z.enum(['auto', 'user', 'staff']), + rulesVersion: z.string(), + /** Reason codes from packages/rules, localized by the UI. */ + reasons: z.array(z.string()), +}); +export type ClassificationDto = z.infer; + +export const DocumentDto = z.object({ + id: z.string(), + source: DocSource, + status: DocStatus, + cdc: z.string().nullable(), + docKind: DocKind, + direction: Direction, + emitterRuc: z.string(), + emitterDv: z.string().nullable(), + emitterName: z.string(), + receiverDoc: z.string().nullable(), + issueDate: z.string(), + currency: z.literal('PYG'), + total: z.number().int(), + amountIva10: z.number().int(), + amountIva5: z.number().int(), + amountExenta: z.number().int(), + iva10: z.number().int(), + iva5: z.number().int(), + supplierRegimeHint: SupplierRegimeHint, + verifiedDnit: z.boolean(), + verificationStatus: VerificationStatus, + fileUrl: z.string().nullable(), + classification: ClassificationDto.nullable(), + createdAt: z.string(), + confirmedAt: z.string().nullable(), + /** Present only on a scan that collapsed into an existing document. */ + merged: z.boolean().optional(), +}); +export type DocumentDto = z.infer; + +/** A scan with no QR and no OCR configured: the client opens the manual form. */ +export const NeedsManualDto = z.object({ + needsManual: z.literal(true), + fileId: z.string(), +}); +export type NeedsManualDto = z.infer; + +export const ScanResultDto = z.union([DocumentDto, NeedsManualDto]); +export type ScanResultDto = z.infer; + +export const ManualDocumentInput = z.object({ + fileId: z.string().nullable().optional(), + direction: Direction.default('purchase'), + docKind: DocKind.default('factura'), + emitterRuc: z.string().trim().min(1).max(20), + emitterDv: z.string().trim().max(2).nullable().optional(), + emitterName: z.string().trim().min(1).max(200), + issueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + total: z.number().int().min(0), + amountIva10: z.number().int().min(0).default(0), + amountIva5: z.number().int().min(0).default(0), + amountExenta: z.number().int().min(0).default(0), + iva10: z.number().int().min(0).default(0), + iva5: z.number().int().min(0).default(0), + supplierRegimeHint: SupplierRegimeHint.default('unknown'), +}); +export type ManualDocumentInput = z.infer; + +export const DocumentPatchInput = ManualDocumentInput.partial().omit({ fileId: true }); +export type DocumentPatchInput = z.infer; + +export const ClassificationPatchInput = z.object({ + irpCategory: z.union([IrpCategory, z.literal('none')]).optional(), + ivaCreditEligible: z.boolean().optional(), + dependentId: z.string().nullable().optional(), +}); +export type ClassificationPatchInput = z.infer; + +export const RejectInput = z.object({ + reason: z.enum(['not_mine', 'duplicate', 'other']), +}); +export type RejectInput = z.infer; + +export const DocumentListQuery = z.object({ + month: z.string().regex(/^\d{4}-\d{2}$/).optional(), + direction: Direction.optional(), + category: z.union([IrpCategory, z.literal('none')]).optional(), + status: DocStatus.optional(), + q: z.string().trim().max(200).optional(), + cursor: z.string().optional(), +}); +export type DocumentListQuery = z.infer; + +/** Lists use cursor pagination with a page size of 50 (CONTRACTS.md section 1). */ +export const PAGE_SIZE = 50; + +export function listDto(item: T) { + return z.object({ + items: z.array(item), + total: z.number().int(), + cursor: z.string().optional(), + }); +} + +export const DocumentListDto = listDto(DocumentDto); +export type DocumentListDto = z.infer; + +export const IngestErrorDto = z.object({ + id: z.string(), + userId: z.string().nullable(), + documentId: z.string().nullable(), + stage: z.enum(['qr_parse', 'ocr', 'dedupe', 'verify', 'job', 'other']), + message: z.string(), + status: z.enum(['open', 'resolved']), + createdAt: z.string(), +}); +export type IngestErrorDto = z.infer; + export const OkDto = z.object({ ok: z.literal(true) }); export type OkDto = z.infer; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 84018cd..b04c7d5 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -32,6 +32,19 @@ export { LookupDto, DataExportDto, DeleteAccountInput, + ClassificationDto, + DocumentDto, + NeedsManualDto, + ScanResultDto, + ManualDocumentInput, + DocumentPatchInput, + ClassificationPatchInput, + RejectInput, + DocumentListQuery, + DocumentListDto, + IngestErrorDto, + PAGE_SIZE, + listDto, OkDto, HealthDto, ReadyDto, diff --git a/packages/i18n/src/catalogs/en.ts b/packages/i18n/src/catalogs/en.ts index 902cecc..52b68a0 100644 --- a/packages/i18n/src/catalogs/en.ts +++ b/packages/i18n/src/catalogs/en.ts @@ -301,6 +301,58 @@ export const en: Record = { "common.saving": "Saving...", "common.skip": "Skip", + // Scan, bandeja and documents (phase 3) + "scan.title": "Scan", + "scan.torch": "Torch", + "scan.shutter": "Take the photo", + "scan.cameraDenied": "We could not open the camera. You can still upload a photo.", + "scan.uploading": "Uploading...", + "scan.result.title": "Got it, we read it", + "scan.result.emitter": "Issuer", + "scan.result.date": "Date", + "scan.result.total": "Total", + "scan.needsManual": "We could not find the QR. Fill in the details and we will attach your photo.", + "scan.another": "Scan another", + "scan.goToBandeja": "Go to the inbox", + "manual.direction": "Is this a purchase or a sale of yours?", + "manual.direction.purchase": "Purchase", + "manual.direction.sale": "Sale", + "manual.ivaSplit.help": "We work the IVA out from the total. If your factura splits it differently, correct it.", + "manual.iva10": "IVA 10%", + "manual.iva5": "IVA 5%", + "manual.exenta": "Exempt", + "manual.regime": "Supplier regime", + "manual.regime.normal": "Normal", + "manual.regime.resimple": "RESIMPLE", + "manual.regime.unknown": "Not sure", + "manual.submit": "Save factura", + "bandeja.count": "{count} to review", + "bandeja.swipeHint": "Swipe right to confirm, left to change the category.", + "bandeja.keyboardHint": "With a keyboard: J and K to move, Enter to confirm, 1 to 8 for the category, X to discard.", + "bandeja.category": "Category", + "bandeja.confidence.high": "High confidence", + "bandeja.confidence.low": "Worth a look", + "bandeja.whyThis": "Why this category?", + "docs.empty": "You have not added any documents yet.", + "docs.emptyCta": "Scan the first one", + "docs.filter.all": "All", + "docs.filter.direction": "Type", + "docs.filter.status": "Status", + "docs.monthTotal": "Month total", + "docs.count": "{count} documents", + "docs.detail.title": "Document", + "docs.detail.ivaCredit": "IVA credit", + "docs.detail.deductible": "Deductible for IRP", + "docs.detail.notDeductible": "Not deductible", + "docs.detail.source.scan_qr": "Scanned from a QR", + "docs.detail.source.scan_ocr": "Read from a photo", + "docs.detail.source.manual": "Entered by hand", + "docs.errors.title": "Some could not be processed", + "docs.errors.retry": "Enter it by hand", + "status.needs_review": "To review", + "status.confirmed": "Confirmed", + "status.rejected": "Discarded", + // Chrome (es.extra.ts) "common.language": "Language", "common.languageEs": "Español", diff --git a/packages/i18n/src/catalogs/es.extra.ts b/packages/i18n/src/catalogs/es.extra.ts index 5e9238e..013e9fc 100644 --- a/packages/i18n/src/catalogs/es.extra.ts +++ b/packages/i18n/src/catalogs/es.extra.ts @@ -102,6 +102,58 @@ export const esExtra = { "common.saving": "Guardando...", "common.skip": "Omitir", + // Scan, bandeja and documents (phase 3) + "scan.title": "Escanear", + "scan.torch": "Linterna", + "scan.shutter": "Sacar la foto", + "scan.cameraDenied": "No pudimos abrir la camara. Podés subir una foto igual.", + "scan.uploading": "Subiendo...", + "scan.result.title": "Listo, la leimos", + "scan.result.emitter": "Emisor", + "scan.result.date": "Fecha", + "scan.result.total": "Total", + "scan.needsManual": "No encontramos el QR. Completá los datos y adjuntamos tu foto.", + "scan.another": "Escanear otra", + "scan.goToBandeja": "Ir a la bandeja", + "manual.direction": "¿Es una compra o una venta tuya?", + "manual.direction.purchase": "Compra", + "manual.direction.sale": "Venta", + "manual.ivaSplit.help": "Calculamos el IVA sobre el total. Si tu factura tiene otro reparto, corregilo.", + "manual.iva10": "IVA 10%", + "manual.iva5": "IVA 5%", + "manual.exenta": "Exentas", + "manual.regime": "Regimen del proveedor", + "manual.regime.normal": "Normal", + "manual.regime.resimple": "RESIMPLE", + "manual.regime.unknown": "No se", + "manual.submit": "Guardar factura", + "bandeja.count": "{count} por revisar", + "bandeja.swipeHint": "Deslizá a la derecha para confirmar, a la izquierda para cambiar la categoria.", + "bandeja.keyboardHint": "Con teclado: J y K para moverte, Enter para confirmar, 1 a 8 para la categoria, X para descartar.", + "bandeja.category": "Categoria", + "bandeja.confidence.high": "Alta confianza", + "bandeja.confidence.low": "Revisar", + "bandeja.whyThis": "¿Por que esta categoria?", + "docs.empty": "Todavia no cargaste ningun comprobante.", + "docs.emptyCta": "Escanear el primero", + "docs.filter.all": "Todos", + "docs.filter.direction": "Tipo", + "docs.filter.status": "Estado", + "docs.monthTotal": "Total del mes", + "docs.count": "{count} comprobantes", + "docs.detail.title": "Comprobante", + "docs.detail.ivaCredit": "Credito de IVA", + "docs.detail.deductible": "Deducible de IRP", + "docs.detail.notDeductible": "No deducible", + "docs.detail.source.scan_qr": "Escaneada por QR", + "docs.detail.source.scan_ocr": "Leida de una foto", + "docs.detail.source.manual": "Cargada a mano", + "docs.errors.title": "No pudimos procesar algunas", + "docs.errors.retry": "Cargala a mano", + "status.needs_review": "Por revisar", + "status.confirmed": "Confirmada", + "status.rejected": "Descartada", + // Chrome "common.language": "Idioma", "common.languageEs": "Español", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3d0d7e..79af232 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: globals: specifier: ^17.12.0 version: 17.12.0 + jsqr: + specifier: ^1.4.0 + version: 1.4.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -50,6 +53,15 @@ importers: apps/api: dependencies: + '@anthropic-ai/sdk': + specifier: ^0.123.0 + version: 0.123.0(zod@4.5.4) + '@aws-sdk/client-s3': + specifier: ^3.1126.0 + version: 3.1126.0 + '@aws-sdk/s3-request-presigner': + specifier: ^3.1126.0 + version: 3.1126.0 '@hono/node-server': specifier: ^2.1.1 version: 2.1.1(hono@4.13.5) @@ -64,7 +76,7 @@ importers: version: link:../../packages/rules better-auth: specifier: ^1.7.2 - version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))) + version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0) better-sqlite3: specifier: ^13.0.3 version: 13.0.3 @@ -93,6 +105,18 @@ importers: '@types/pg': specifier: ^8.23.1 version: 8.23.1 + '@types/pngjs': + specifier: ^6.0.5 + version: 6.0.5 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 + pngjs: + specifier: ^7.0.0 + version: 7.0.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 tsup: specifier: ^8.5.1 version: 8.5.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.13)(typescript@5.9.3) @@ -119,7 +143,7 @@ importers: version: 5.102.8(react@19.2.8) better-auth: specifier: ^1.7.2 - version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))) + version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -147,6 +171,9 @@ importers: zod: specifier: ^4.5.4 version: 4.5.4 + zxing-wasm: + specifier: ^3.1.3 + version: 3.1.3(@types/emscripten@1.41.6) devDependencies: '@tailwindcss/postcss': specifier: ^4.3.3 @@ -186,6 +213,91 @@ packages: resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==} engines: {node: '>=10'} + '@anthropic-ai/sdk@0.123.0': + resolution: {integrity: sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@aws-sdk/checksums@3.1000.29': + resolution: {integrity: sha512-Dtu0gr4dnATZAPwEYbpCsG+MpLM7OAliy2gTepEFQwl1vZ6DL3QMH2FveMa3HLvPsOdhJsPRB3KtxVhph9T75A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-s3@3.1126.0': + resolution: {integrity: sha512-9v+D5TOnISyWDBkY5N+lSeQ3/pPNi1bltpd7wfY2nALO7GoGq9QbRQ8KyI4j/ctnnHq40otqV5KWKYARdYI0vg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.977.9': + resolution: {integrity: sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.70': + resolution: {integrity: sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.72': + resolution: {integrity: sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.15': + resolution: {integrity: sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.77': + resolution: {integrity: sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.82': + resolution: {integrity: sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.70': + resolution: {integrity: sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.14': + resolution: {integrity: sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.76': + resolution: {integrity: sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.75': + resolution: {integrity: sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.44': + resolution: {integrity: sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/s3-request-presigner@3.1126.0': + resolution: {integrity: sha512-QeLZT7ynBL1ZxyreCF8alGG5iOExGg62erNnfgo/woAaw6wsIrJ5ceLynCBKASViqzdoU+Zn73E6PY7FjCO5kA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.46': + resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1116.0': + resolution: {integrity: sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.5': + resolution: {integrity: sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.40': + resolution: {integrity: sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -241,6 +353,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} @@ -1322,6 +1438,33 @@ packages: '@schummar/icu-type-parser@1.21.5': resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + '@smithy/core@3.33.3': + resolution: {integrity: sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.5.2': + resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.8.0': + resolution: {integrity: sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.12.1': + resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.7.3': + resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.18.0': + resolution: {integrity: sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==} + engines: {node: '>=18.0.0'} + + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1530,6 +1673,9 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/emscripten@1.41.6': + resolution: {integrity: sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -1545,6 +1691,12 @@ packages: '@types/pg@8.23.1': resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} + '@types/pngjs@6.0.5': + resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} + + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.7': resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} peerDependencies: @@ -1656,6 +1808,14 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1788,6 +1948,9 @@ packages: resolution: {integrity: sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==} engines: {node: '>=22'} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@1.1.18: resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} @@ -1822,6 +1985,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001810: resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} @@ -1839,10 +2006,20 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1892,6 +2069,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1910,6 +2091,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -1921,6 +2105,9 @@ packages: electron-to-chromium@1.5.420: resolution: {integrity: sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + enhanced-resolve@5.24.5: resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} engines: {node: '>=10.13.0'} @@ -2052,6 +2239,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2065,6 +2255,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -2111,6 +2305,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2241,6 +2439,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -2332,6 +2534,10 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -2343,6 +2549,9 @@ packages: engines: {node: '>=6'} hasBin: true + jsqr@1.4.0: + resolution: {integrity: sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -2517,6 +2726,10 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2663,14 +2876,26 @@ packages: resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} engines: {node: '>= 0.4'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2743,6 +2968,14 @@ packages: engines: {node: '>=20'} hasBin: true + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + po-parser@2.2.0: resolution: {integrity: sha512-NdTrKgh0oO7+y+RFX8KKhOB438x/j94UGXEFzl/7pHH0JjWSn42iJ9tgmPksIO6n1JKDHmNDcejPJfKspljB9g==} @@ -2803,6 +3036,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + react-dom@19.2.8: resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} peerDependencies: @@ -2827,6 +3065,13 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -2873,6 +3118,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@3.1.2: resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} @@ -2939,6 +3187,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.1.1: + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} + std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} @@ -2946,6 +3197,10 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string.prototype.matchall@4.1.0: resolution: {integrity: sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==} engines: {node: '>= 0.4'} @@ -2965,6 +3220,10 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -2987,6 +3246,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} @@ -3027,6 +3290,9 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -3067,6 +3333,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@5.9.0: + resolution: {integrity: sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==} + engines: {node: '>=20'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -3219,6 +3489,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.22: resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} engines: {node: '>= 0.4'} @@ -3237,13 +3510,28 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -3257,10 +3545,196 @@ packages: zod@4.5.4: resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} + zxing-wasm@3.1.3: + resolution: {integrity: sha512-3lC9BJk4fR5ZJxcGjb0hVnDFOW7KpLXHIebiBmVd4FDRQZVeObztoTKxRUPxlWwSgxrMRONK0u50hBD1aTYEKg==} + peerDependencies: + '@types/emscripten': '>=1.39.6' + snapshots: '@alloc/quick-lru@5.3.0': {} + '@anthropic-ai/sdk@0.123.0(zod@4.5.4)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.1.1 + optionalDependencies: + zod: 4.5.4 + + '@aws-sdk/checksums@3.1000.29': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1126.0': + dependencies: + '@aws-sdk/checksums': 3.1000.29 + '@aws-sdk/core': 3.977.9 + '@aws-sdk/credential-provider-node': 3.972.82 + '@aws-sdk/middleware-sdk-s3': 3.972.75 + '@aws-sdk/signature-v4-multi-region': 3.996.46 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/core@3.977.9': + dependencies: + '@aws-sdk/types': 3.974.5 + '@aws-sdk/xml-builder': 3.972.40 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.33.3 + '@smithy/signature-v4': 5.7.3 + '@smithy/types': 4.18.0 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.70': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.72': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.15': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/credential-provider-env': 3.972.70 + '@aws-sdk/credential-provider-http': 3.972.72 + '@aws-sdk/credential-provider-login': 3.972.77 + '@aws-sdk/credential-provider-process': 3.972.70 + '@aws-sdk/credential-provider-sso': 3.973.14 + '@aws-sdk/credential-provider-web-identity': 3.972.76 + '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.77': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.82': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.70 + '@aws-sdk/credential-provider-http': 3.972.72 + '@aws-sdk/credential-provider-ini': 3.973.15 + '@aws-sdk/credential-provider-process': 3.972.70 + '@aws-sdk/credential-provider-sso': 3.973.14 + '@aws-sdk/credential-provider-web-identity': 3.972.76 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.70': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.14': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/token-providers': 3.1116.0 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.76': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.75': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/signature-v4-multi-region': 3.996.46 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.44': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/signature-v4-multi-region': 3.996.46 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/s3-request-presigner@3.1126.0': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/signature-v4-multi-region': 3.996.46 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.46': + dependencies: + '@aws-sdk/types': 3.974.5 + '@smithy/signature-v4': 5.7.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1116.0': + dependencies: + '@aws-sdk/core': 3.977.9 + '@aws-sdk/nested-clients': 3.997.44 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.5': + dependencies: + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.40': + dependencies: + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -3338,6 +3812,8 @@ snapshots: dependencies: '@babel/types': 7.29.8 + '@babel/runtime@7.29.7': {} + '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -4003,6 +4479,41 @@ snapshots: '@schummar/icu-type-parser@1.21.5': {} + '@smithy/core@3.33.3': + dependencies: + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.5.2': + dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.8.0': + dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.12.1': + dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.7.3': + dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@smithy/types@4.18.0': + dependencies: + tslib: 2.8.1 + + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@swc/core-darwin-arm64@1.16.1': @@ -4157,6 +4668,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/emscripten@1.41.6': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.9': {} @@ -4173,6 +4686,14 @@ snapshots: pg-protocol: 1.16.0 pg-types: 2.2.0 + '@types/pngjs@6.0.5': + dependencies: + '@types/node': 26.4.1 + + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 26.4.1 + '@types/react-dom@19.2.7(@types/react@19.2.18)': dependencies: '@types/react': 19.2.18 @@ -4314,6 +4835,12 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + any-promise@1.3.0: {} array-buffer-byte-length@1.0.2: @@ -4393,7 +4920,7 @@ snapshots: baseline-browser-mapping@2.11.21: {} - better-auth@1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))): + better-auth@1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0): dependencies: '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) @@ -4436,6 +4963,8 @@ snapshots: dependencies: node-addon-api: 8.9.2 + bowser@2.14.1: {} + brace-expansion@1.1.18: dependencies: balanced-match: 1.0.2 @@ -4477,6 +5006,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + camelcase@5.3.1: {} + caniuse-lite@1.0.30001810: {} chai@6.2.2: {} @@ -4491,8 +5022,20 @@ snapshots: client-only@0.0.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + clsx@2.1.1: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + commander@4.1.1: {} concat-map@0.0.1: {} @@ -4535,6 +5078,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + deep-is@0.1.4: {} define-data-property@1.1.4: @@ -4553,6 +5098,8 @@ snapshots: detect-libc@2.1.2: {} + dijkstrajs@1.0.3: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -4565,6 +5112,8 @@ snapshots: electron-to-chromium@1.5.420: {} + emoji-regex@8.0.0: {} + enhanced-resolve@5.24.5: dependencies: graceful-fs: 4.2.11 @@ -4856,6 +5405,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-sha256@1.3.0: {} + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -4864,6 +5415,11 @@ snapshots: dependencies: flat-cache: 4.0.1 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -4912,6 +5468,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5050,6 +5608,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -5137,12 +5697,19 @@ snapshots: json-buffer@3.0.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.7 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} json-stable-stringify-without-jsonify@1.0.1: {} json5@2.2.3: {} + jsqr@1.4.0: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -5265,6 +5832,10 @@ snapshots: load-tsconfig@0.2.5: {} + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -5442,14 +6013,24 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -5513,6 +6094,10 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pngjs@5.0.0: {} + + pngjs@7.0.0: {} + po-parser@2.2.0: {} possible-typed-array-names@1.1.0: {} @@ -5557,6 +6142,12 @@ snapshots: punycode@2.3.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + react-dom@19.2.8(react@19.2.8): dependencies: react: 19.2.8 @@ -5588,6 +6179,10 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + require-directory@2.1.1: {} + + require-main-filename@2.0.0: {} + resolve-from@5.0.0: {} resolve@2.0.0-next.7: @@ -5679,6 +6274,8 @@ snapshots: semver@7.8.5: {} + set-blocking@2.0.0: {} + set-cookie-parser@3.1.2: {} set-function-length@1.2.2: @@ -5781,6 +6378,11 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.1.1: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + std-env@4.2.0: {} stop-iteration-iterator@1.1.0: @@ -5788,6 +6390,12 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string.prototype.matchall@4.1.0: dependencies: call-bind: 1.0.9 @@ -5833,6 +6441,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.8): dependencies: client-only: 0.0.1 @@ -5852,6 +6464,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tagged-tag@1.0.0: {} + tailwind-merge@3.6.0: {} tailwindcss@4.3.3: {} @@ -5881,6 +6495,8 @@ snapshots: tree-kill@1.2.2: {} + ts-algebra@2.0.0: {} + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -5928,6 +6544,10 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@5.9.0: + dependencies: + tagged-tag: 1.0.0 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -6072,6 +6692,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.22: dependencies: available-typed-arrays: 1.0.7 @@ -6093,10 +6715,37 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + xtend@4.0.2: {} + y18n@4.0.3: {} + yallist@3.1.1: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yocto-queue@0.1.0: {} zod-validation-error@4.0.2(zod@4.5.4): @@ -6104,3 +6753,8 @@ snapshots: zod: 4.5.4 zod@4.5.4: {} + + zxing-wasm@3.1.3(@types/emscripten@1.41.6): + dependencies: + '@types/emscripten': 1.41.6 + type-fest: 5.9.0