From 6620650e9eefe920b9ad8f3fead2795a5567bfdd Mon Sep 17 00:00:00 2001 From: Michilis Date: Fri, 4 Sep 2026 05:50:29 +0000 Subject: [PATCH] phase-5: declarations, from generated numbers to a PDF you file yourself Formulario 120 and 515 assembly, the review screen, approval, the PDF, and the guided checklist that walks the user through presenting it in Marangatu. Approval is a promise about what the user actually saw: the numbers are recomputed on the way in, and if the documents moved since the declaration was generated the answer is a 409 with the fresh figures stored as a draft, not a silent approval of numbers nobody read. An approved declaration is never regenerated or invalidated. Editing, confirming, rejecting or reclassifying a document flips any ready declaration covering its period back to draft, which is what the dashboard reads to stop offering it for review. The PDF is rendered once and cached against the declaration, so two downloads are byte for byte the same document; every write that changes the numbers clears the cache. Approval pre-renders it, and the route renders on demand, so a failed job costs a wait rather than a missing file. It stays Spanish in both locales, like the form it mirrors, and carries a BORRADOR watermark until it is approved. The seeded declarations are computed through the same code path the product uses, so their figures agree with the documents behind them. Three defects the screenshots caught: the floating scan button swallowed the tap meant for Aprobar on a phone, the summary breakdown reused labels meant for other screens, and five tab labels collided at 390px. Tests that can avoid depending on a fresh seed now do; the ones that cannot say so in the assertion rather than timing out. db:reset warns that the API has to be stopped first, having learned that the hard way. 293 vitest tests, 59 Playwright tests, rules coverage still 100%, typecheck and lint clean. Co-Authored-By: Claude Opus 5 --- DECISIONS.md | 85 +++- README.md | 7 +- apps/api/package.json | 1 + apps/api/src/db/reset.cli.ts | 5 + apps/api/src/db/seed-declarations.ts | 82 ++++ apps/api/src/db/seed.ts | 2 + apps/api/src/http/app.ts | 2 + apps/api/src/http/routes/declarations.ts | 107 ++++ .../src/modules/dashboard/dashboard.test.ts | 16 +- apps/api/src/modules/declarations/assemble.ts | 203 ++++++++ .../modules/declarations/declarations.test.ts | 457 ++++++++++++++++++ apps/api/src/modules/declarations/index.ts | 14 + apps/api/src/modules/declarations/pdf.ts | 140 ++++++ apps/api/src/modules/declarations/service.ts | 234 +++++++++ apps/api/src/modules/declarations/store.ts | 79 +++ apps/api/src/modules/documents/service.ts | 27 ++ apps/api/src/modules/jobs/handlers.ts | 16 + .../declaraciones/[id]/declaration-detail.tsx | 248 ++++++++++ .../declaraciones/[id]/filing-checklist.tsx | 140 ++++++ .../(app)/declaraciones/[id]/page.tsx | 12 + .../declaraciones/declarations-screen.tsx | 160 ++++++ .../app/[locale]/(app)/declaraciones/page.tsx | 12 + apps/web/src/components/app-shell.tsx | 53 +- apps/web/src/components/form-preview.tsx | 83 ++++ e2e/db.ts | 30 ++ e2e/declarations.spec.ts | 149 ++++++ e2e/ingestion.spec.ts | 7 +- packages/contracts/src/client.ts | 26 + packages/contracts/src/dto.ts | 44 ++ packages/contracts/src/index.ts | 5 + packages/i18n/src/catalogs/en.ts | 31 ++ packages/i18n/src/catalogs/es.extra.ts | 31 ++ pnpm-lock.yaml | 37 ++ 33 files changed, 2522 insertions(+), 23 deletions(-) create mode 100644 apps/api/src/db/seed-declarations.ts create mode 100644 apps/api/src/http/routes/declarations.ts create mode 100644 apps/api/src/modules/declarations/assemble.ts create mode 100644 apps/api/src/modules/declarations/declarations.test.ts create mode 100644 apps/api/src/modules/declarations/index.ts create mode 100644 apps/api/src/modules/declarations/pdf.ts create mode 100644 apps/api/src/modules/declarations/service.ts create mode 100644 apps/api/src/modules/declarations/store.ts create mode 100644 apps/web/app/[locale]/(app)/declaraciones/[id]/declaration-detail.tsx create mode 100644 apps/web/app/[locale]/(app)/declaraciones/[id]/filing-checklist.tsx create mode 100644 apps/web/app/[locale]/(app)/declaraciones/[id]/page.tsx create mode 100644 apps/web/app/[locale]/(app)/declaraciones/declarations-screen.tsx create mode 100644 apps/web/app/[locale]/(app)/declaraciones/page.tsx create mode 100644 apps/web/src/components/form-preview.tsx create mode 100644 e2e/declarations.spec.ts diff --git a/DECISIONS.md b/DECISIONS.md index 18a5b0d..5f9ecbf 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -441,7 +441,14 @@ The suite also erodes the seed it runs against: after a run, documents that star bandeja are confirmed and insights are dismissed, so the next run is not the same run. `pnpm db:reset` rebuilds the local database from the migrations and the seed, and the README says to use it before an e2e run. It refuses to touch anything but a local SQLite -file, because it is destructive by definition. +file, because it is destructive by definition, and it warns that the API has to be stopped +first: deleting a file the running process holds open leaves it writing to an inode nothing +else can see, which looks like half the suite breaking at once. + +Where a test can avoid depending on a fresh seed, it does. Golden path 4 generates its own +declaration for a month that has documents and no declaration yet, because approving is a +one way door. The ones that genuinely cannot, like scanning into an empty bandeja, say so +in the assertion message rather than timing out on a locator. The dashboard's insight test self-skips once every insight for that account has been dismissed, since dismissals persist by design; the persistence guarantee itself is asserted against the API from a fresh database. @@ -453,3 +460,79 @@ against the API from a fresh database. obtains one is not in v1 scope. - **Declarations.** `declaration_ready` is in the next-action priority and the sweep reads from it, but nothing sets a declaration to `ready` until phase 5. + + +--- + +## Phase 5 + +### A generated declaration is `ready`, and approval is a promise about what was seen +Generating assembles the numbers and marks them `ready`: settled, waiting for a person to +agree. Approving recomputes them first, and if the documents moved in between it returns a +409, stores the new figures as a `draft`, and asks the user to look again. Approving +figures nobody saw is the one outcome that must be impossible, and it is the reason the +recompute happens on the way in rather than at render time. + +An approved declaration is never regenerated or invalidated. Once a person has put their +name to a set of numbers, rewriting them behind their back is worse than any staleness. + +### A document that moves invalidates the declaration that counted it +CONTRACTS.md 5.4. Confirming, rejecting, editing or reclassifying a document flips any +`ready` declaration covering its period back to `draft`, which is what the dashboard reads +to stop offering it for review. The month decides for a 120 and the year for a 515. + +### The PDF is cached against the declaration, not regenerated per download +Two downloads of the same declaration are byte for byte the same document, which matters +when someone is transcribing from a printout. Every write that changes the numbers clears +`pdf_file_id`, which is what makes the cache safe. Approval enqueues a render so the +download on the success screen is instant; the route renders on demand too, so a failed +job costs a wait rather than a missing document. + +### The form preview and the PDF stay Spanish in both locales +FLOWS.md section 1 and the COPY.md policy. The casilla labels mirror the DNIT form, and a +form whose labels have been translated is harder to transcribe from, not easier. The +caption around the preview says so in the reader's language. Two strings are hoisted out +of JSX with the reason written down, because the inline copy lint rule is right to flag +them and the answer is a comment, not an exception. + +### The platform never files anything +Flow D3 is a checklist, not a submission: numbered steps, every casilla one tap from the +clipboard, and "Ya la presente" as the user telling us rather than us finding out. Marking +filed is only possible after approval, because the checklist is what follows approval. + +### The seeded declarations are computed, not written down +CONTRACTS.md section 4 asks for a ready F120 for the previous month and an approved one two +months back. Both are assembled through the same code path the product uses, so their +figures agree with the documents behind them. A fixture whose numbers disagreed with its +own documents would be worse than no fixture. + +### The declarations tab joined the shell +Five tabs now. Vencimientos stays reachable from the dashboard's deadline card and its +overdue action, so nothing lost a route. + +### A test that had to grow +The next-action priority test now walks two rungs of the ladder rather than one: with the +seeded `ready` declaration in place, `declaration_ready` correctly outranks `bandeja`, and +the test approves it to check the fall through. The 5.4 test clears the outstanding +periods first, because `overdue` outranks everything and Maria has never filed a 515. + +### Three defects the screenshots caught +**The scan button swallowed the tap meant for "Aprobar".** The floating button and the +sticky approve footer overlap on a 390px screen, and the desktop e2e never saw it because +the viewport is wider. The button now appears on the screens you arrive at, not on a detail +screen that has its own primary action, and a test asserts it. + +**The summary breakdown was mislabelled.** It called the debito "A pagar" and the saldo +anterior "Casilla", because it reused keys meant for other screens. Three labels of its own +now. + +**Five tab labels collided at 390px.** Smaller, truncating labels rather than dropping a +tab, since every one of them is a place the user needs to reach. + +### Deferred, deliberately +- **Motion.** The confetti on approval and the canvas moment on the filed screen are + FLOWS.md D3; the motion pass and the canvas touches are phase 7. The screens are built + with the right structure and states. +- **Payment reminders after filing.** FLOWS.md D3 mentions scheduling one on "Ya lo + presente". The deadline sweep already covers T-2 and T-0 for the period; a separate + payment date is not in RULES.md and was not invented. diff --git a/README.md b/README.md index 6863a1a..3d4b394 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,10 @@ 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 4 of 8.** Everything up to a live tax position: scan a comprobante, -> confirm it, and watch the IVA and IRP figures move, with deadlines tracked and reminders -> queued. Declarations and the admin area are still ahead. +> **Status: phase 5 of 8.** The whole taxpayer path works: scan a comprobante, confirm it, +> watch the position move, and take the resulting Formulario 120 or 515 from review to +> approved to a PDF you file yourself in Marangatu. The admin area, the PWA and the +> scale-out work are still ahead. --- diff --git a/apps/api/package.json b/apps/api/package.json index 3a0598c..9e7bc26 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -26,6 +26,7 @@ "hono": "^4.13.5", "kysely": "^0.29.5", "nodemailer": "^9.1.1", + "pdf-lib": "^1.17.1", "pg": "^8.23.0", "uuidv7": "^1.2.1", "web-push": "^3.6.7", diff --git a/apps/api/src/db/reset.cli.ts b/apps/api/src/db/reset.cli.ts index 6fddb2f..6dfea0d 100644 --- a/apps/api/src/db/reset.cli.ts +++ b/apps/api/src/db/reset.cli.ts @@ -22,6 +22,11 @@ if (!isSqliteUrl(env.DATABASE_URL)) { process.exit(1); } +console.warn( + '[reset] stop the API before running this: deleting the file it has open leaves the ' + + 'running process writing to an inode nothing else can see.', +); + const file = sqliteFile(env.DATABASE_URL); if (file !== ':memory:') { for (const suffix of ['', '-wal', '-shm']) rmSync(`${file}${suffix}`, { force: true }); diff --git a/apps/api/src/db/seed-declarations.ts b/apps/api/src/db/seed-declarations.ts new file mode 100644 index 0000000..ef8b8f0 --- /dev/null +++ b/apps/api/src/db/seed-declarations.ts @@ -0,0 +1,82 @@ +import type { ProfileDto } from '@impuestos/contracts'; +import { addMonths, formatPeriod, todayInAsuncion } from '@impuestos/rules'; +import { assemble } from '../modules/declarations/assemble'; +import { getProfile } from '../modules/pii'; +import { RULES_VERSION } from '../modules/declarations/assemble'; +import { uuidv7 } from 'uuidv7'; +import type { DbHandle } from './index'; + +/** + * CONTRACTS.md section 4: Maria has a ready F120 for the previous month and an approved + * one two months back, which is what gives the saldo anterior chain something to carry. + * + * The numbers are computed rather than written down: a seeded declaration whose figures + * disagree with the documents behind it would be a worse fixture than none. + */ +export async function seedDeclarations(handle: DbHandle, now = new Date()): Promise { + const existing = await handle.db.selectFrom('declarations').select('id').limit(1).executeTakeFirst(); + if (existing) return 0; + + const maria = await handle.db + .selectFrom('user') + .select('id') + .where('email', '=', 'maria@demo.local') + .executeTakeFirstOrThrow(); + + const profile = await getProfile(handle.db, maria.id); + if (!profile) return 0; + + const today = todayInAsuncion(now); + const thisMonth = formatPeriod(today.year, today.month); + + let seeded = 0; + // Two months back is approved and filed; last month is ready and waiting for her. + for (const [monthsBack, status] of [ + [2, 'approved'], + [1, 'ready'], + ] as const) { + const period = addMonths(thisMonth, -monthsBack); + await insert(handle, { userId: maria.id, profile, period, status, now }); + seeded += 1; + } + + return seeded; +} + +async function insert( + handle: DbHandle, + args: { + userId: string; + profile: ProfileDto; + period: string; + status: 'ready' | 'approved'; + now: Date; + }, +): Promise { + const assembled = await assemble(handle.db, { + userId: args.userId, + profile: args.profile, + formCode: '120', + period: args.period, + }); + + const timestamp = args.now.toISOString(); + await handle.db + .insertInto('declarations') + .values({ + id: uuidv7(), + user_id: args.userId, + form_code: '120', + period: args.period, + status: args.status, + values: JSON.stringify(assembled.values), + summary: JSON.stringify(assembled.summary), + pdf_file_id: null, + rules_version: RULES_VERSION, + document_ids: JSON.stringify(assembled.documentIds), + created_at: timestamp, + approved_at: args.status === 'approved' ? timestamp : null, + filed_marked_at: args.status === 'approved' ? timestamp : null, + }) + .execute(); +} diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts index 8e4abee..a43e3f8 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 { seedDeclarations } from './seed-declarations'; import { seedDocuments } from './seed-documents'; export interface SeedAccount { @@ -77,6 +78,7 @@ export async function seed( await seedProfiles(handle); await seedDocuments(handle, options.now ?? new Date()); + await seedDeclarations(handle, options.now ?? new Date()); await seedAuditTrail(handle); return result; } diff --git a/apps/api/src/http/app.ts b/apps/api/src/http/app.ts index 41203c8..ed6173d 100644 --- a/apps/api/src/http/app.ts +++ b/apps/api/src/http/app.ts @@ -4,6 +4,7 @@ import { HttpError, toEnvelope } from './errors'; import { liveness, readiness } from './health'; import { localeMiddleware, sessionMiddleware } from './middleware'; import { dashboardRoutes, deadlineRoutes } from './routes/dashboard'; +import { declarationRoutes } from './routes/declarations'; import { documentRoutes } from './routes/documents'; import { fileRoutes } from './routes/files'; import { lookupRoutes } from './routes/lookup'; @@ -55,6 +56,7 @@ export function createApp(deps: AppDeps): AppHandle { api.route('/files', fileRoutes(deps)); api.route('/dashboard', dashboardRoutes(deps)); api.route('/deadlines', deadlineRoutes(deps)); + api.route('/declarations', declarationRoutes(deps)); app.route('/api', api); diff --git a/apps/api/src/http/routes/declarations.ts b/apps/api/src/http/routes/declarations.ts new file mode 100644 index 0000000..32861e1 --- /dev/null +++ b/apps/api/src/http/routes/declarations.ts @@ -0,0 +1,107 @@ +import { GenerateDeclarationInput } from '@impuestos/contracts'; +import { Hono } from 'hono'; +import { + approveDeclaration, + ensurePdf, + generateDeclaration, + getDeclaration, + listDeclarations, + markFiled, +} from '../../modules/declarations'; +import { enqueue } from '../../modules/jobs'; +import { getProfile } from '../../modules/pii'; +import type { AppDeps, AppEnv } from '../context'; +import { HttpError } from '../errors'; +import { requireUser } from '../middleware'; + +export function declarationRoutes(deps: AppDeps): Hono { + const routes = new Hono(); + const db = deps.handle.db; + + routes.get('/', async (c) => { + const user = requireUser(c); + return c.json(await listDeclarations({ db }, user.id, c.req.query('year'))); + }); + + routes.post('/generate', async (c) => { + const user = requireUser(c); + const body = await c.req.json().catch(() => null); + const input = GenerateDeclarationInput.safeParse(body); + if (!input.success) throw new HttpError('validation_error', { field: 'period' }); + + // A 120 declares a month and a 515 declares a year; the period has to say which. + const expectsMonth = input.data.formCode === '120'; + if (expectsMonth !== input.data.period.includes('-')) { + throw new HttpError('validation_error', { field: 'period' }); + } + + const profile = await getProfile(db, user.id); + if (!profile) throw new HttpError('not_found'); + + const outcome = await generateDeclaration( + { db }, + { userId: user.id, profile, formCode: input.data.formCode, period: input.data.period }, + ); + if (!outcome.ok) throw new HttpError('conflict'); + + return c.json(outcome.declaration, 201); + }); + + routes.get('/:id', async (c) => { + const user = requireUser(c); + const declaration = await getDeclaration({ db }, user.id, c.req.param('id')); + if (!declaration) throw new HttpError('not_found'); + return c.json(declaration); + }); + + routes.post('/:id/approve', async (c) => { + const user = requireUser(c); + const profile = await getProfile(db, user.id); + if (!profile) throw new HttpError('not_found'); + + const outcome = await approveDeclaration({ db }, { userId: user.id, profile, id: c.req.param('id') }); + if (!outcome.ok) { + if (outcome.reason === 'not_found') throw new HttpError('not_found'); + // Stale numbers and a second approval are both "look again", not a server error. + throw new HttpError('conflict', { messageKey: 'decl.stale' }); + } + + // Pre-render the PDF so the download is instant on the success screen. + await enqueue(db, { + type: 'generate_declaration_pdf', + payload: { userId: user.id, declarationId: outcome.declaration.id }, + dedupeKey: `pdf:${outcome.declaration.id}:approved`, + }); + + return c.json(outcome.declaration); + }); + + routes.post('/:id/mark-filed', async (c) => { + const user = requireUser(c); + const declaration = await markFiled({ db }, { userId: user.id, id: c.req.param('id') }); + if (!declaration) throw new HttpError('not_found'); + return c.json(declaration); + }); + + routes.get('/:id/pdf', async (c) => { + const user = requireUser(c); + const declaration = await getDeclaration({ db }, user.id, c.req.param('id')); + if (!declaration) throw new HttpError('not_found'); + + const { data, filename } = await ensurePdf( + { db, storage: deps.storage }, + { userId: user.id, declaration }, + ); + + return new Response(new Uint8Array(data), { + headers: { + 'content-type': 'application/pdf', + 'content-length': String(data.byteLength), + 'content-disposition': `attachment; filename="${filename}"`, + 'cache-control': 'private, no-store', + }, + }); + }); + + return routes; +} diff --git a/apps/api/src/modules/dashboard/dashboard.test.ts b/apps/api/src/modules/dashboard/dashboard.test.ts index 5230141..c8b1ea4 100644 --- a/apps/api/src/modules/dashboard/dashboard.test.ts +++ b/apps/api/src/modules/dashboard/dashboard.test.ts @@ -186,7 +186,7 @@ describe('GET /dashboard', () => { expect(dashboard.nextAction.dueDate).toBeDefined(); }); - it('falls through to the bandeja once nothing is overdue', async () => { + it('walks down the priority ladder as each thing above is dealt with', async () => { const h = (harness = await createHarness({ now: NOW })); const cookie = await h.signIn('maria@demo.local', 'demo-maria-1'); @@ -221,6 +221,20 @@ describe('GET /dashboard', () => { .execute(); } + // Overdue gone, the ready declaration is next in line. + const withoutOverdue = DashboardDto.parse( + await (await h.app.request('/api/dashboard', { headers: { cookie } })).json(), + ); + expect(withoutOverdue.nextAction.kind).toBe('declaration_ready'); + expect(withoutOverdue.nextAction.declarationId).toBeDefined(); + + // Approve it, and the bandeja is what is left. + await h.deps.handle.db + .updateTable('declarations') + .set({ status: 'approved', approved_at: NOW.toISOString() }) + .where('status', '=', 'ready') + .execute(); + const after = DashboardDto.parse( await (await h.app.request('/api/dashboard', { headers: { cookie } })).json(), ); diff --git a/apps/api/src/modules/declarations/assemble.ts b/apps/api/src/modules/declarations/assemble.ts new file mode 100644 index 0000000..1552dfd --- /dev/null +++ b/apps/api/src/modules/declarations/assemble.ts @@ -0,0 +1,203 @@ +import type { DeclarationDto, ProfileDto } from '@impuestos/contracts'; +import { + RULES_VERSION, + computeF120, + computeF515, + f120V1, + f515V1, + projectedGrossIncome, + pyg, + type F120Doc, + type F515Doc, + type FormValue, + type IrpCategory, +} from '@impuestos/rules'; +import type { Kysely } from 'kysely'; +import type { Database } from '../../db/schema'; + +export interface Assembled { + values: FormValue[]; + summary: Record; + documentIds: string[]; +} + +/** + * Turns a period into the numbers a declaration states. Every figure comes from + * packages/rules over the user's confirmed documents; this file only gathers inputs and + * names the outputs the way CONTRACTS.md section 2 does. + */ +export async function assemble( + db: Kysely, + args: { userId: string; profile: ProfileDto; formCode: '120' | '515'; period: string }, +): Promise { + return args.formCode === '120' ? assembleF120(db, args) : assembleF515(db, args); +} + +async function confirmedDocuments(db: Kysely, userId: string) { + return db + .selectFrom('documents') + .leftJoin('classifications', 'classifications.document_id', 'documents.id') + .selectAll('documents') + .select([ + 'classifications.iva_credit_eligible as ivaCreditEligible', + 'classifications.iva_credit_amount as ivaCreditAmount', + 'classifications.irp_category as irpCategory', + 'classifications.irp_deductible_amount as irpDeductibleAmount', + ]) + .where('documents.user_id', '=', userId) + .where('documents.status', '=', 'confirmed') + .execute(); +} + +async function assembleF120( + db: Kysely, + args: { userId: string; period: string }, +): Promise { + const rows = await confirmedDocuments(db, args.userId); + + const documents: F120Doc[] = rows.map((row) => ({ + id: row.id, + direction: row.direction, + issueDate: row.issue_date, + total: pyg(row.total), + amountIva10: pyg(row.amount_iva10), + amountIva5: pyg(row.amount_iva5), + amountExenta: pyg(row.amount_exenta), + iva10: pyg(row.iva10), + iva5: pyg(row.iva5), + ivaCreditEligible: row.ivaCreditEligible === 1, + ivaCreditAmount: pyg(row.ivaCreditAmount ?? 0), + })); + + const result = computeF120({ + period: args.period, + saldoAnterior: pyg(await carriedForward(db, args.userId, args.period)), + documents, + }); + + return { + values: f120V1.toValues(result), + summary: { + sales: result.sales, + purchases: result.purchases, + debito: result.debito, + credito: result.credito, + saldoAnterior: result.saldoAnterior, + aPagar: result.aPagar, + saldoAFavor: result.saldoAFavor, + }, + documentIds: result.documentIds, + }; +} + +/** The saldo a favor the previous approved period left behind, or nothing. */ +async function carriedForward( + db: Kysely, + userId: string, + period: string, +): Promise { + const previous = await db + .selectFrom('declarations') + .select('summary') + .where('user_id', '=', userId) + .where('form_code', '=', '120') + .where('status', '=', 'approved') + .where('period', '<', period) + .orderBy('period', 'desc') + .executeTakeFirst(); + + if (!previous) return 0; + try { + const parsed = JSON.parse(previous.summary) as { saldoAFavor?: unknown }; + return typeof parsed.saldoAFavor === 'number' ? parsed.saldoAFavor : 0; + } catch { + return 0; + } +} + +async function assembleF515( + db: Kysely, + args: { userId: string; profile: ProfileDto; period: string }, +): Promise { + const rows = await confirmedDocuments(db, args.userId); + const year = args.period; + + const documents: F515Doc[] = rows + .filter( + (row) => + row.direction === 'purchase' && + (row.irpCategory ?? 'none') !== 'none' && + row.issue_date.startsWith(`${year}-`), + ) + .map((row) => ({ + id: row.id, + irpCategory: row.irpCategory as IrpCategory, + irpDeductibleAmount: pyg(row.irpDeductibleAmount ?? 0), + supplierRegimeHint: row.supplier_regime_hint, + })); + + const salesYearToDate = rows + .filter((row) => row.direction === 'sale' && row.issue_date.startsWith(`${year}-`)) + .reduce((sum, row) => sum + row.total, 0); + + // A filed declaration states the whole year, so the income is what was actually + // invoiced when that exceeds the estimate the user gave us. + const { grossIncome } = projectedGrossIncome({ + profileEstimate: + args.profile.irpGrossEstimate === null ? null : pyg(args.profile.irpGrossEstimate), + salesYearToDate: pyg(salesYearToDate), + monthsElapsed: 12, + }); + + const result = computeF515({ + year, + grossIncome, + documents, + hasResimpleFlag: (doc) => doc.supplierRegimeHint === 'resimple', + }); + + return { + values: f515V1.toValues(result), + summary: { + grossIncome: result.grossIncome, + perCategory: result.perCategory, + capExcess: result.capExcess, + totalDeductions: result.totalDeductions, + netIncome: result.netIncome, + tax: result.tax, + effectiveRate: result.effectiveRate, + }, + documentIds: result.documentIds, + }; +} + +export function toDeclarationDto(row: { + id: string; + form_code: '120' | '515'; + period: string; + status: 'draft' | 'ready' | 'approved'; + values: string; + summary: string; + rules_version: string; + document_ids: string; + created_at: string; + approved_at: string | null; + filed_marked_at: string | null; +}): DeclarationDto { + const documentIds = JSON.parse(row.document_ids) as string[]; + return { + id: row.id, + formCode: row.form_code, + period: row.period, + status: row.status, + summary: JSON.parse(row.summary) as Record, + values: JSON.parse(row.values) as DeclarationDto['values'], + rulesVersion: row.rules_version, + documentCount: documentIds.length, + createdAt: row.created_at, + approvedAt: row.approved_at, + filedMarkedAt: row.filed_marked_at, + }; +} + +export { RULES_VERSION }; diff --git a/apps/api/src/modules/declarations/declarations.test.ts b/apps/api/src/modules/declarations/declarations.test.ts new file mode 100644 index 0000000..2e2cb39 --- /dev/null +++ b/apps/api/src/modules/declarations/declarations.test.ts @@ -0,0 +1,457 @@ +import { DeclarationDto, DeclarationListDto, ErrorEnvelope } from '@impuestos/contracts'; +import { addMonths, computeF120, formatPeriod, pyg, todayInAsuncion } from '@impuestos/rules'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createHarness, type Harness } from '../../test/harness'; + +const NOW = new Date('2026-09-04T12:00:00Z'); +const THIS_MONTH = formatPeriod(todayInAsuncion(NOW).year, todayInAsuncion(NOW).month); +const LAST_MONTH = addMonths(THIS_MONTH, -1); + +let harness: Harness | null = null; + +afterEach(async () => { + await harness?.close(); + harness = null; +}); + +async function signedIn(): Promise<{ h: Harness; cookie: string }> { + const h = (harness = await createHarness({ now: NOW })); + return { h, cookie: await h.signIn('maria@demo.local', 'demo-maria-1') }; +} + +const json = (h: Harness, cookie: string, path: string, init: RequestInit = {}) => + h.app.request(path, { + ...init, + headers: { cookie, 'content-type': 'application/json', ...(init.headers ?? {}) }, + }); + +/** + * Marks every outstanding period filed, so `overdue` stops outranking everything else in + * the next-action ladder. Maria has never filed a 515, which is genuinely overdue. + */ +async function clearOverdue(h: Harness, cookie: string): Promise { + const user = await h.deps.handle.db + .selectFrom('user') + .select('id') + .where('email', '=', 'maria@demo.local') + .executeTakeFirstOrThrow(); + + const deadlines = (await ( + await json(h, cookie, '/api/deadlines/upcoming?months=12') + ).json()) as { obligation: string; period: string; status: string }[]; + + for (const deadline of deadlines.filter((d) => d.status === 'overdue')) { + const formCode = deadline.obligation === 'iva_120' ? '120' : '515'; + const existing = await h.deps.handle.db + .selectFrom('declarations') + .select('id') + .where('user_id', '=', user.id) + .where('form_code', '=', formCode) + .where('period', '=', deadline.period) + .executeTakeFirst(); + + if (existing) { + await h.deps.handle.db + .updateTable('declarations') + .set({ status: 'approved', approved_at: NOW.toISOString(), filed_marked_at: NOW.toISOString() }) + .where('id', '=', existing.id) + .execute(); + continue; + } + + await h.deps.handle.db + .insertInto('declarations') + .values({ + id: `filed-${formCode}-${deadline.period}`, + user_id: user.id, + form_code: formCode, + period: deadline.period, + status: 'approved', + values: '[]', + summary: '{}', + pdf_file_id: null, + rules_version: '1.0.0', + document_ids: '[]', + created_at: NOW.toISOString(), + approved_at: NOW.toISOString(), + filed_marked_at: NOW.toISOString(), + }) + .execute(); + } +} + +/** The same computation the declaration should have made, read from the database. */ +async function expectedF120(h: Harness, period: string) { + const user = await h.deps.handle.db + .selectFrom('user') + .select('id') + .where('email', '=', 'maria@demo.local') + .executeTakeFirstOrThrow(); + + const rows = await h.deps.handle.db + .selectFrom('documents') + .leftJoin('classifications', 'classifications.document_id', 'documents.id') + .selectAll('documents') + .select([ + 'classifications.iva_credit_eligible as ivaCreditEligible', + 'classifications.iva_credit_amount as ivaCreditAmount', + ]) + .where('documents.user_id', '=', user.id) + .where('documents.status', '=', 'confirmed') + .execute(); + + const previous = await h.deps.handle.db + .selectFrom('declarations') + .select('summary') + .where('user_id', '=', user.id) + .where('form_code', '=', '120') + .where('status', '=', 'approved') + .where('period', '<', period) + .orderBy('period', 'desc') + .executeTakeFirst(); + + const saldoAnterior = previous + ? ((JSON.parse(previous.summary) as { saldoAFavor?: number }).saldoAFavor ?? 0) + : 0; + + return computeF120({ + period, + saldoAnterior: pyg(saldoAnterior), + documents: rows.map((row) => ({ + id: row.id, + direction: row.direction, + issueDate: row.issue_date, + total: pyg(row.total), + amountIva10: pyg(row.amount_iva10), + amountIva5: pyg(row.amount_iva5), + amountExenta: pyg(row.amount_exenta), + iva10: pyg(row.iva10), + iva5: pyg(row.iva5), + ivaCreditEligible: row.ivaCreditEligible === 1, + ivaCreditAmount: pyg(row.ivaCreditAmount ?? 0), + })), + }); +} + +/** CONTRACTS.md 5.3. */ +describe('POST /declarations/generate', () => { + it('produces exactly what computeF120 produces over the seeded documents', async () => { + const { h, cookie } = await signedIn(); + + // The seed already made last month ready, so regenerating is the interesting case. + const response = await json(h, cookie, '/api/declarations/generate', { + method: 'POST', + body: JSON.stringify({ formCode: '120', period: LAST_MONTH }), + }); + expect(response.status).toBe(201); + + const declaration = DeclarationDto.parse(await response.json()); + const expected = await expectedF120(h, LAST_MONTH); + + expect(declaration.formCode).toBe('120'); + expect(declaration.period).toBe(LAST_MONTH); + expect(declaration.status).toBe('ready'); + expect(declaration.summary['sales']).toBe(expected.sales); + expect(declaration.summary['purchases']).toBe(expected.purchases); + expect(declaration.summary['debito']).toBe(expected.debito); + expect(declaration.summary['credito']).toBe(expected.credito); + expect(declaration.summary['saldoAnterior']).toBe(expected.saldoAnterior); + expect(declaration.summary['aPagar']).toBe(expected.aPagar); + expect(declaration.summary['saldoAFavor']).toBe(expected.saldoAFavor); + expect(declaration.documentCount).toBe(expected.documentIds.length); + }); + + it('fills every casilla the form definition declares', async () => { + const { h, cookie } = await signedIn(); + const declaration = DeclarationDto.parse( + await ( + await json(h, cookie, '/api/declarations/generate', { + method: 'POST', + body: JSON.stringify({ formCode: '120', period: LAST_MONTH }), + }) + ).json(), + ); + + expect(declaration.values.length).toBeGreaterThan(0); + for (const value of declaration.values) { + expect(value.casilla).toMatch(/^c-/); + expect(value.label.length).toBeGreaterThan(0); + expect(Number.isInteger(value.amount)).toBe(true); + } + + const byCasilla = Object.fromEntries(declaration.values.map((v) => [v.casilla, v.amount])); + expect(byCasilla['c-debito-fiscal']).toBe(declaration.summary['debito']); + expect(byCasilla['c-credito-fiscal']).toBe(declaration.summary['credito']); + }); + + it('builds an annual 515 too', async () => { + const { h, cookie } = await signedIn(); + const response = await json(h, cookie, '/api/declarations/generate', { + method: 'POST', + body: JSON.stringify({ formCode: '515', period: '2026' }), + }); + expect(response.status).toBe(201); + + const declaration = DeclarationDto.parse(await response.json()); + expect(declaration.summary).toHaveProperty('netIncome'); + expect(declaration.summary).toHaveProperty('perCategory'); + expect(declaration.summary['tax']).toBeGreaterThan(0); + }); + + it('rejects a period that does not match the form', async () => { + const { h, cookie } = await signedIn(); + for (const body of [ + { formCode: '120', period: '2026' }, + { formCode: '515', period: '2026-08' }, + { formCode: '120', period: 'agosto' }, + ]) { + const response = await json(h, cookie, '/api/declarations/generate', { + method: 'POST', + body: JSON.stringify(body), + }); + expect(response.status, JSON.stringify(body)).toBe(400); + } + }); + + it('refuses to overwrite an approved declaration', async () => { + const { h, cookie } = await signedIn(); + const approvedPeriod = addMonths(THIS_MONTH, -2); + + const response = await json(h, cookie, '/api/declarations/generate', { + method: 'POST', + body: JSON.stringify({ formCode: '120', period: approvedPeriod }), + }); + expect(response.status).toBe(409); + expect(ErrorEnvelope.parse(await response.json()).error.code).toBe('conflict'); + }); +}); + +describe('approving', () => { + it('approves numbers the user has seen, and only once', async () => { + const { h, cookie } = await signedIn(); + const { items } = DeclarationListDto.parse( + await (await json(h, cookie, '/api/declarations')).json(), + ); + const ready = items.find((item) => item.status === 'ready'); + expect(ready).toBeDefined(); + + const approved = DeclarationDto.parse( + await ( + await json(h, cookie, `/api/declarations/${ready?.id}/approve`, { method: 'POST' }) + ).json(), + ); + expect(approved.status).toBe('approved'); + expect(approved.approvedAt).not.toBeNull(); + // Approving does not change the numbers. + expect(approved.summary).toEqual(ready?.summary); + + const again = await json(h, cookie, `/api/declarations/${ready?.id}/approve`, { method: 'POST' }); + expect(again.status).toBe(409); + }); + + it('queues the PDF so the download is ready', async () => { + const { h, cookie } = await signedIn(); + const { items } = DeclarationListDto.parse( + await (await json(h, cookie, '/api/declarations')).json(), + ); + const ready = items.find((item) => item.status === 'ready'); + + await json(h, cookie, `/api/declarations/${ready?.id}/approve`, { method: 'POST' }); + + const jobs = await h.deps.handle.db + .selectFrom('jobs') + .selectAll() + .where('type', '=', 'generate_declaration_pdf') + .execute(); + expect(jobs).toHaveLength(1); + + expect(await h.runJobs()).toBe(1); + const row = await h.deps.handle.db + .selectFrom('declarations') + .select('pdf_file_id') + .where('id', '=', ready?.id ?? '') + .executeTakeFirstOrThrow(); + expect(row.pdf_file_id).not.toBeNull(); + }); +}); + +/** CONTRACTS.md 5.4. */ +describe('a document that moves invalidates the declaration that counted it', () => { + it('flips a ready declaration back to draft and the dashboard follows', async () => { + const { h, cookie } = await signedIn(); + + const { items } = DeclarationListDto.parse( + await (await json(h, cookie, '/api/declarations')).json(), + ); + const ready = items.find((item) => item.status === 'ready'); + expect(ready?.period).toBe(LAST_MONTH); + + // Nothing overdue, or overdue would win the next-action slot outright. + await clearOverdue(h, cookie); + + // The dashboard is offering it for review. + const before = (await (await json(h, cookie, '/api/dashboard')).json()) as { + nextAction: { kind: string; declarationId?: string }; + }; + expect(before.nextAction.kind).toBe('declaration_ready'); + expect(before.nextAction.declarationId).toBe(ready?.id); + + // Edit a confirmed document inside that period. + const document = await h.deps.handle.db + .selectFrom('documents') + .innerJoin('user', 'user.id', 'documents.user_id') + .select(['documents.id as id', 'documents.total as total']) + .where('user.email', '=', 'maria@demo.local') + .where('documents.status', '=', 'confirmed') + .where('documents.issue_date', 'like', `${LAST_MONTH}%`) + .executeTakeFirstOrThrow(); + + const patched = await json(h, cookie, `/api/documents/${document.id}`, { + method: 'PATCH', + body: JSON.stringify({ total: document.total + 50_000 }), + }); + expect(patched.status).toBe(200); + + const after = DeclarationDto.parse( + await (await json(h, cookie, `/api/declarations/${ready?.id}`)).json(), + ); + expect(after.status).toBe('draft'); + + // And the dashboard no longer offers it for review. + const dashboard = (await (await json(h, cookie, '/api/dashboard')).json()) as { + nextAction: { kind: string }; + }; + expect(dashboard.nextAction.kind).not.toBe('declaration_ready'); + }); + + it('leaves an approved declaration alone', async () => { + const { h, cookie } = await signedIn(); + const approvedPeriod = addMonths(THIS_MONTH, -2); + + const document = await h.deps.handle.db + .selectFrom('documents') + .innerJoin('user', 'user.id', 'documents.user_id') + .select(['documents.id as id', 'documents.total as total']) + .where('user.email', '=', 'maria@demo.local') + .where('documents.issue_date', 'like', `${approvedPeriod}%`) + .executeTakeFirstOrThrow(); + + await json(h, cookie, `/api/documents/${document.id}`, { + method: 'PATCH', + body: JSON.stringify({ total: document.total + 1_000 }), + }); + + const row = await h.deps.handle.db + .selectFrom('declarations') + .select('status') + .where('period', '=', approvedPeriod) + .executeTakeFirstOrThrow(); + // Already filed on: rewriting it behind the user is the one thing not allowed. + expect(row.status).toBe('approved'); + }); + + it('refuses to approve numbers that changed since they were shown', async () => { + const { h, cookie } = await signedIn(); + const { items } = DeclarationListDto.parse( + await (await json(h, cookie, '/api/declarations')).json(), + ); + const ready = items.find((item) => item.status === 'ready'); + + // Change a document, then force the declaration back to ready without regenerating, + // which is exactly the race the 409 exists for. + const document = await h.deps.handle.db + .selectFrom('documents') + .innerJoin('user', 'user.id', 'documents.user_id') + .select(['documents.id as id', 'documents.total as total']) + .where('user.email', '=', 'maria@demo.local') + .where('documents.status', '=', 'confirmed') + .where('documents.issue_date', 'like', `${LAST_MONTH}%`) + .executeTakeFirstOrThrow(); + + await json(h, cookie, `/api/documents/${document.id}`, { + method: 'PATCH', + body: JSON.stringify({ total: document.total + 77_000 }), + }); + await h.deps.handle.db + .updateTable('declarations') + .set({ status: 'ready' }) + .where('id', '=', ready?.id ?? '') + .execute(); + + const response = await json(h, cookie, `/api/declarations/${ready?.id}/approve`, { + method: 'POST', + }); + expect(response.status).toBe(409); + + const body = ErrorEnvelope.parse(await response.json()); + expect(body.error.message).toContain('Revisalos'); + + // The stored numbers are now the true ones, waiting for another look. + const after = DeclarationDto.parse( + await (await json(h, cookie, `/api/declarations/${ready?.id}`)).json(), + ); + expect(after.status).toBe('draft'); + expect(after.summary['purchases']).not.toBe(ready?.summary['purchases']); + }); +}); + +describe('mark filed', () => { + it('records it only after approval', async () => { + const { h, cookie } = await signedIn(); + const { items } = DeclarationListDto.parse( + await (await json(h, cookie, '/api/declarations')).json(), + ); + const ready = items.find((item) => item.status === 'ready'); + + // Not approved yet: nothing to file. + expect( + (await json(h, cookie, `/api/declarations/${ready?.id}/mark-filed`, { method: 'POST' })).status, + ).toBe(404); + + await json(h, cookie, `/api/declarations/${ready?.id}/approve`, { method: 'POST' }); + const filed = DeclarationDto.parse( + await ( + await json(h, cookie, `/api/declarations/${ready?.id}/mark-filed`, { method: 'POST' }) + ).json(), + ); + expect(filed.filedMarkedAt).not.toBeNull(); + }); +}); + +describe('the PDF', () => { + it('streams a real PDF and caches it', async () => { + const { h, cookie } = await signedIn(); + const { items } = DeclarationListDto.parse( + await (await json(h, cookie, '/api/declarations')).json(), + ); + const declaration = items[0]; + + const response = await json(h, cookie, `/api/declarations/${declaration?.id}/pdf`); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/pdf'); + expect(response.headers.get('content-disposition')).toContain('formulario-120-'); + + const bytes = new Uint8Array(await response.arrayBuffer()); + expect(new TextDecoder().decode(bytes.slice(0, 5))).toBe('%PDF-'); + expect(bytes.byteLength).toBeGreaterThan(1000); + + // Second download is the same cached document, byte for byte. + const again = new Uint8Array( + await (await json(h, cookie, `/api/declarations/${declaration?.id}/pdf`)).arrayBuffer(), + ); + expect(again.byteLength).toBe(bytes.byteLength); + }); + + it('belongs to its owner and nobody else', async () => { + const { h, cookie } = await signedIn(); + const { items } = DeclarationListDto.parse( + await (await json(h, cookie, '/api/declarations')).json(), + ); + + const carlos = await h.signIn('carlos@demo.local', 'demo-carlos-1'); + const response = await h.app.request(`/api/declarations/${items[0]?.id}/pdf`, { + headers: { cookie: carlos }, + }); + expect(response.status).toBe(404); + }); +}); diff --git a/apps/api/src/modules/declarations/index.ts b/apps/api/src/modules/declarations/index.ts new file mode 100644 index 0000000..4d980c2 --- /dev/null +++ b/apps/api/src/modules/declarations/index.ts @@ -0,0 +1,14 @@ +export { assemble, toDeclarationDto, type Assembled } from './assemble'; +export { + generateDeclaration, + approveDeclaration, + markFiled, + getDeclaration, + listDeclarations, + invalidateForDocument, + type DeclarationsDeps, + type GenerateOutcome, + type ApproveOutcome, +} from './service'; +export { renderDeclarationPdf } from './pdf'; +export { ensurePdf } from './store'; diff --git a/apps/api/src/modules/declarations/pdf.ts b/apps/api/src/modules/declarations/pdf.ts new file mode 100644 index 0000000..01843bd --- /dev/null +++ b/apps/api/src/modules/declarations/pdf.ts @@ -0,0 +1,140 @@ +import type { DeclarationDto } from '@impuestos/contracts'; +import { formatGs } from '@impuestos/i18n'; +import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib'; + +const PAGE = { width: 595.28, height: 841.89 }; // A4 in points +const MARGIN = 48; +const LINE_HEIGHT = 20; + +const ACCENT = rgb(0.06, 0.3, 0.3); +const INK = rgb(0.13, 0.14, 0.16); +const MUTED = rgb(0.45, 0.47, 0.5); +const RULE = rgb(0.85, 0.86, 0.88); + +/** + * The declaration as a document someone can hold next to Marangatu while they type. + * + * Spanish in every locale, deliberately: it mirrors the official DNIT form, and a form + * whose labels have been translated is harder to transcribe from, not easier + * (COPY.md multi-language policy, FLOWS.md section 1). + */ +export async function renderDeclarationPdf(args: { + declaration: DeclarationDto; + taxpayer: { fullName: string; ruc: string | null; rucDv: string | null; ci: string | null }; + generatedAt: Date; +}): Promise { + const pdf = await PDFDocument.create(); + pdf.setTitle(`Formulario ${args.declaration.formCode} ${args.declaration.period}`); + pdf.setCreator('Impuestos'); + + const regular = await pdf.embedFont(StandardFonts.Helvetica); + const bold = await pdf.embedFont(StandardFonts.HelveticaBold); + const mono = await pdf.embedFont(StandardFonts.Courier); + + const page = pdf.addPage([PAGE.width, PAGE.height]); + let y = PAGE.height - MARGIN; + + page.drawText(`Formulario ${args.declaration.formCode}`, { + x: MARGIN, + y: y - 22, + size: 22, + font: bold, + color: ACCENT, + }); + page.drawText( + args.declaration.formCode === '120' + ? 'Declaracion jurada del Impuesto al Valor Agregado' + : 'Declaracion jurada del Impuesto a la Renta Personal', + { x: MARGIN, y: y - 40, size: 10, font: regular, color: MUTED }, + ); + y -= 62; + + const identity = args.taxpayer.ruc + ? `${args.taxpayer.ruc}-${args.taxpayer.rucDv ?? ''}` + : (args.taxpayer.ci ?? ''); + + for (const [label, value] of [ + ['Contribuyente', args.taxpayer.fullName], + ['RUC', identity], + ['Periodo', args.declaration.period], + ['Estado', STATUS_LABEL[args.declaration.status]], + ] as const) { + page.drawText(label, { x: MARGIN, y, size: 9, font: regular, color: MUTED }); + page.drawText(value, { x: MARGIN + 110, y, size: 10, font: regular, color: INK }); + y -= 16; + } + + y -= 12; + page.drawLine({ + start: { x: MARGIN, y }, + end: { x: PAGE.width - MARGIN, y }, + thickness: 1, + color: RULE, + }); + y -= 24; + + page.drawText('Casilla', { x: MARGIN, y, size: 9, font: bold, color: MUTED }); + page.drawText('Concepto', { x: MARGIN + 90, y, size: 9, font: bold, color: MUTED }); + page.drawText('Importe', { x: PAGE.width - MARGIN - 110, y, size: 9, font: bold, color: MUTED }); + y -= 6; + page.drawLine({ + start: { x: MARGIN, y }, + end: { x: PAGE.width - MARGIN, y }, + thickness: 0.5, + color: RULE, + }); + y -= LINE_HEIGHT; + + for (const value of args.declaration.values) { + if (y < MARGIN + 80) break; // one page is enough for both forms + page.drawText(value.casilla, { x: MARGIN, y, size: 8, font: mono, color: MUTED }); + page.drawText(value.label, { x: MARGIN + 90, y, size: 10, font: regular, color: INK }); + + // Right aligned, monospace: a column of figures has to line up to be checkable. + const amount = formatGs(value.amount); + const width = mono.widthOfTextAtSize(amount, 10); + page.drawText(amount, { + x: PAGE.width - MARGIN - width, + y, + size: 10, + font: mono, + color: INK, + }); + y -= LINE_HEIGHT; + } + + page.drawLine({ + start: { x: MARGIN, y: y + 8 }, + end: { x: PAGE.width - MARGIN, y: y + 8 }, + thickness: 1, + color: RULE, + }); + + page.drawText( + `Generado el ${args.generatedAt.toISOString().slice(0, 10)} con las reglas ${args.declaration.rulesVersion}. ` + + 'Este documento no es una presentacion: sirve para transcribir los valores en Marangatu.', + { x: MARGIN, y: MARGIN + 24, size: 8, font: regular, color: MUTED, maxWidth: PAGE.width - MARGIN * 2, lineHeight: 11 }, + ); + + // Unapproved numbers are marked as such on the page itself, so a printed draft can + // never be mistaken for the figures the user actually agreed to. + if (args.declaration.status !== 'approved') { + page.drawText('BORRADOR', { + x: 120, + y: 300, + size: 90, + font: bold, + color: rgb(0.85, 0.2, 0.2), + opacity: 0.14, + rotate: degrees(35), + }); + } + + return pdf.save(); +} + +const STATUS_LABEL: Record = { + draft: 'Borrador', + ready: 'Lista para revisar', + approved: 'Aprobada', +}; diff --git a/apps/api/src/modules/declarations/service.ts b/apps/api/src/modules/declarations/service.ts new file mode 100644 index 0000000..dff2c97 --- /dev/null +++ b/apps/api/src/modules/declarations/service.ts @@ -0,0 +1,234 @@ +import type { DeclarationDto, ProfileDto } from '@impuestos/contracts'; +import type { Kysely } from 'kysely'; +import { uuidv7 } from 'uuidv7'; +import type { Database } from '../../db/schema'; +import { RULES_VERSION, assemble, toDeclarationDto } from './assemble'; + +export interface DeclarationsDeps { + db: Kysely; +} + +export type GenerateOutcome = + | { ok: true; declaration: DeclarationDto } + | { ok: false; reason: 'already_approved' }; + +/** + * Builds, or rebuilds, the declaration for a period. A generated declaration is `ready`: + * the numbers are settled and waiting for a person to agree with them. + * + * An approved declaration is never overwritten. Once the user has put their name to a set + * of numbers, regenerating them behind their back is the one thing this must not do. + */ +export async function generateDeclaration( + deps: DeclarationsDeps, + args: { userId: string; profile: ProfileDto; formCode: '120' | '515'; period: string }, +): Promise { + const existing = await findByPeriod(deps, args); + if (existing?.status === 'approved') return { ok: false, reason: 'already_approved' }; + + const assembled = await assemble(deps.db, args); + const now = new Date().toISOString(); + + const values = { + values: JSON.stringify(assembled.values), + summary: JSON.stringify(assembled.summary), + document_ids: JSON.stringify(assembled.documentIds), + rules_version: RULES_VERSION, + status: 'ready' as const, + }; + + if (existing) { + await deps.db + .updateTable('declarations') + .set({ ...values, pdf_file_id: null }) + .where('id', '=', existing.id) + .execute(); + return { ok: true, declaration: await requireDeclaration(deps, args.userId, existing.id) }; + } + + const id = uuidv7(); + await deps.db + .insertInto('declarations') + .values({ + id, + user_id: args.userId, + form_code: args.formCode, + period: args.period, + pdf_file_id: null, + created_at: now, + approved_at: null, + filed_marked_at: null, + ...values, + }) + .execute(); + + return { ok: true, declaration: await requireDeclaration(deps, args.userId, id) }; +} + +export type ApproveOutcome = + | { ok: true; declaration: DeclarationDto } + | { ok: false; reason: 'not_found' | 'already_approved' | 'stale' }; + +/** + * Approves what the user is looking at, and nothing else. The numbers are recomputed + * first: if the documents moved since the declaration was generated, the answer is a 409 + * telling them to look again, never a silent approval of figures they never saw. + */ +export async function approveDeclaration( + deps: DeclarationsDeps, + args: { userId: string; profile: ProfileDto; id: string }, +): Promise { + const row = await deps.db + .selectFrom('declarations') + .selectAll() + .where('id', '=', args.id) + .where('user_id', '=', args.userId) + .executeTakeFirst(); + + if (!row) return { ok: false, reason: 'not_found' }; + if (row.status === 'approved') return { ok: false, reason: 'already_approved' }; + + const fresh = await assemble(deps.db, { + userId: args.userId, + profile: args.profile, + formCode: row.form_code, + period: row.period, + }); + + if (JSON.stringify(fresh.summary) !== row.summary) { + // The underlying documents changed. Store the new numbers as a draft so the review + // screen shows what is true now, and make the user look again. + await deps.db + .updateTable('declarations') + .set({ + status: 'draft', + values: JSON.stringify(fresh.values), + summary: JSON.stringify(fresh.summary), + document_ids: JSON.stringify(fresh.documentIds), + pdf_file_id: null, + }) + .where('id', '=', row.id) + .execute(); + return { ok: false, reason: 'stale' }; + } + + await deps.db + .updateTable('declarations') + .set({ status: 'approved', approved_at: new Date().toISOString() }) + .where('id', '=', row.id) + .execute(); + + return { ok: true, declaration: await requireDeclaration(deps, args.userId, row.id) }; +} + +export async function markFiled( + deps: DeclarationsDeps, + args: { userId: string; id: string }, +): Promise { + const row = await deps.db + .selectFrom('declarations') + .select(['id', 'status']) + .where('id', '=', args.id) + .where('user_id', '=', args.userId) + .executeTakeFirst(); + + // Only an approved declaration can have been filed: the checklist is what follows approval. + if (!row || row.status !== 'approved') return null; + + await deps.db + .updateTable('declarations') + .set({ filed_marked_at: new Date().toISOString() }) + .where('id', '=', row.id) + .execute(); + + return requireDeclaration(deps, args.userId, row.id); +} + +export async function getDeclaration( + deps: DeclarationsDeps, + userId: string, + id: string, +): Promise { + const row = await deps.db + .selectFrom('declarations') + .selectAll() + .where('id', '=', id) + .where('user_id', '=', userId) + .executeTakeFirst(); + return row ? toDeclarationDto(row) : null; +} + +export async function listDeclarations( + deps: DeclarationsDeps, + userId: string, + year?: string, +): Promise<{ items: DeclarationDto[]; total: number }> { + let query = deps.db.selectFrom('declarations').selectAll().where('user_id', '=', userId); + if (year) query = query.where('period', 'like', `${year}%`); + + const rows = await query.orderBy('period', 'desc').orderBy('form_code').execute(); + return { items: rows.map(toDeclarationDto), total: rows.length }; +} + +/** + * CONTRACTS.md 5.4: a document that moves invalidates any declaration that counted it. + * The declaration falls back to `draft`, which is what the dashboard and the list surface + * as needing another look. An approved one is left alone: it has already been filed on. + */ +export async function invalidateForDocument( + deps: DeclarationsDeps, + args: { userId: string; issueDate: string }, +): Promise { + const month = args.issueDate.slice(0, 7); + const year = args.issueDate.slice(0, 4); + + const affected = await deps.db + .selectFrom('declarations') + .select(['id']) + .where('user_id', '=', args.userId) + .where('status', '=', 'ready') + .where((eb) => + eb.or([ + eb.and([eb('form_code', '=', '120'), eb('period', '=', month)]), + eb.and([eb('form_code', '=', '515'), eb('period', '=', year)]), + ]), + ) + .execute(); + + if (affected.length === 0) return []; + + await deps.db + .updateTable('declarations') + .set({ status: 'draft', pdf_file_id: null }) + .where( + 'id', + 'in', + affected.map((row) => row.id), + ) + .execute(); + + return affected.map((row) => row.id); +} + +async function findByPeriod( + deps: DeclarationsDeps, + args: { userId: string; formCode: '120' | '515'; period: string }, +) { + return deps.db + .selectFrom('declarations') + .select(['id', 'status']) + .where('user_id', '=', args.userId) + .where('form_code', '=', args.formCode) + .where('period', '=', args.period) + .executeTakeFirst(); +} + +async function requireDeclaration( + deps: DeclarationsDeps, + userId: string, + id: string, +): Promise { + const declaration = await getDeclaration(deps, userId, id); + if (!declaration) throw new Error('declaration disappeared immediately after being written'); + return declaration; +} diff --git a/apps/api/src/modules/declarations/store.ts b/apps/api/src/modules/declarations/store.ts new file mode 100644 index 0000000..4637303 --- /dev/null +++ b/apps/api/src/modules/declarations/store.ts @@ -0,0 +1,79 @@ +import type { DeclarationDto } from '@impuestos/contracts'; +import type { Kysely } from 'kysely'; +import { uuidv7 } from 'uuidv7'; +import type { Database } from '../../db/schema'; +import { sha256 } from '../documents/dedupe'; +import { getProfile } from '../pii'; +import type { StorageDriver } from '../storage'; +import { storageKey } from '../storage'; +import { renderDeclarationPdf } from './pdf'; + +/** + * Returns the stored PDF, rendering and caching it on first request. + * + * Cached against the declaration rather than regenerated per download, so what the user + * downloads twice is byte for byte the same document. Every write that changes the + * numbers clears `pdf_file_id`, which is what makes the cache safe. + */ +export async function ensurePdf( + deps: { db: Kysely; storage: StorageDriver }, + args: { userId: string; declaration: DeclarationDto }, +): Promise<{ data: Uint8Array; filename: string }> { + const filename = `formulario-${args.declaration.formCode}-${args.declaration.period}.pdf`; + + const row = await deps.db + .selectFrom('declarations') + .select('pdf_file_id') + .where('id', '=', args.declaration.id) + .executeTakeFirst(); + + if (row?.pdf_file_id) { + const file = await deps.db + .selectFrom('document_files') + .select('path') + .where('id', '=', row.pdf_file_id) + .executeTakeFirst(); + + if (file) { + const stream = await deps.storage.getStream(file.path); + return { data: new Uint8Array(await new Response(stream).arrayBuffer()), filename }; + } + } + + const profile = await getProfile(deps.db, args.userId); + const data = await renderDeclarationPdf({ + declaration: args.declaration, + taxpayer: { + fullName: profile?.fullName ?? '', + ruc: profile?.ruc ?? null, + rucDv: profile?.rucDv ?? null, + ci: profile?.ci ?? null, + }, + generatedAt: new Date(), + }); + + const id = uuidv7(); + const path = storageKey(args.userId, `${id}.pdf`); + await deps.storage.put(path, data, 'application/pdf'); + + await deps.db + .insertInto('document_files') + .values({ + id, + driver: deps.storage.kind, + path, + mime: 'application/pdf', + size: data.byteLength, + sha256: sha256(data), + created_at: new Date().toISOString(), + }) + .execute(); + + await deps.db + .updateTable('declarations') + .set({ pdf_file_id: id }) + .where('id', '=', args.declaration.id) + .execute(); + + return { data, filename }; +} diff --git a/apps/api/src/modules/documents/service.ts b/apps/api/src/modules/documents/service.ts index 77552f4..bef2ad2 100644 --- a/apps/api/src/modules/documents/service.ts +++ b/apps/api/src/modules/documents/service.ts @@ -9,6 +9,7 @@ import { RULES_VERSION, classify, type ClassificationInput } from '@impuestos/ru import type { Kysely } from 'kysely'; import { uuidv7 } from 'uuidv7'; import type { Database, DocumentsTable } from '../../db/schema'; +import { invalidateForDocument } from '../declarations/service'; import { getProfile } from '../pii'; import type { StorageDriver } from '../storage'; import { dedupeHash } from './dedupe'; @@ -328,9 +329,32 @@ export async function confirmDocument( .where('id', '=', id) .where('user_id', '=', userId) .execute(); + + await invalidateDeclarations(deps, userId, id); return getDocument(deps, userId, id); } +/** + * A document that changes makes any declaration that counted it out of date. The + * declaration falls back to draft rather than quietly stating numbers that no longer hold + * (CONTRACTS.md 5.4). + */ +async function invalidateDeclarations( + deps: DocumentsDeps, + userId: string, + documentId: string, +): Promise { + const row = await deps.db + .selectFrom('documents') + .select('issue_date') + .where('id', '=', documentId) + .where('user_id', '=', userId) + .executeTakeFirst(); + if (!row) return; + + await invalidateForDocument({ db: deps.db }, { userId, issueDate: row.issue_date }); +} + export async function rejectDocument( deps: DocumentsDeps, userId: string, @@ -352,6 +376,7 @@ export async function rejectDocument( .where('id', '=', id) .execute(); + await invalidateDeclarations(deps, userId, id); return getDocument(deps, userId, id); } @@ -402,6 +427,7 @@ export async function patchDocument( } await classifyDocument(deps, id); + await invalidateDeclarations(deps, userId, id); return getDocument(deps, userId, id); } @@ -454,6 +480,7 @@ export async function patchClassification( .where('document_id', '=', id) .execute(); + await invalidateDeclarations(deps, userId, id); return getDocument(deps, userId, id); } diff --git a/apps/api/src/modules/jobs/handlers.ts b/apps/api/src/modules/jobs/handlers.ts index e6c0c36..6a77c86 100644 --- a/apps/api/src/modules/jobs/handlers.ts +++ b/apps/api/src/modules/jobs/handlers.ts @@ -1,5 +1,6 @@ import type { Kysely } from 'kysely'; import type { Database, DocumentsTable } from '../../db/schema'; +import { ensurePdf, getDeclaration } from '../declarations'; import { classifyDocument, createOrMerge, type DocumentsDeps } from '../documents/service'; import type { OcrProvider } from '../documents/ocr'; import { recordIngestError } from '../ingest/errors'; @@ -103,6 +104,21 @@ export function createHandlers(deps: HandlerDeps): JobHandlers { await sendNotification({ db: deps.db, channels: deps.channels }, { userId, notification }); }, + /** + * Pre-renders the PDF after approval so the download on the success screen is + * instant. The route renders on demand too, so a failure here costs a wait, not a + * missing document. + */ + generate_declaration_pdf: async ({ payload }) => { + const userId = String(payload['userId'] ?? ''); + const declarationId = String(payload['declarationId'] ?? ''); + if (!userId || !declarationId) throw new Error('generate_declaration_pdf needs both ids'); + + const declaration = await getDeclaration({ db: deps.db }, userId, declarationId); + if (!declaration) return; + await ensurePdf({ db: deps.db, storage: deps.storage }, { userId, declaration }); + }, + deadline_sweep: async () => { await runDeadlineSweep(sweepDeps); }, diff --git a/apps/web/app/[locale]/(app)/declaraciones/[id]/declaration-detail.tsx b/apps/web/app/[locale]/(app)/declaraciones/[id]/declaration-detail.tsx new file mode 100644 index 0000000..4b3280f --- /dev/null +++ b/apps/web/app/[locale]/(app)/declaraciones/[id]/declaration-detail.tsx @@ -0,0 +1,248 @@ +'use client'; + +import { isApiError, type DeclarationDto } from '@impuestos/contracts'; +import { formatDateLong, formatGs, formatGsAmount, type Locale } from '@impuestos/i18n'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useRef, useState } from 'react'; +import { FormPreview } from '@/components/form-preview'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useT } from '@/i18n/t'; +import { api } from '@/lib/api'; +import { FilingChecklist } from './filing-checklist'; + +/** + * Flow D2 and D3. The human summary first, the official layout underneath, and one + * primary action. Nothing here files anything: the checklist that follows approval is how + * the user presents it themselves. + */ +export function DeclarationDetail({ id, locale }: { id: string; locale: string }) { + const t = useT(); + const queryClient = useQueryClient(); + const confirmDialog = useRef(null); + const [staleWarning, setStaleWarning] = useState(null); + + const declaration = useQuery({ + queryKey: ['declarations', id], + queryFn: ({ signal }) => api.getDeclaration(id, signal), + retry: (count, error) => !isApiError(error) && count < 1, + }); + + const approve = useMutation({ + mutationFn: () => api.approveDeclaration(id), + onSuccess: async () => { + setStaleWarning(null); + confirmDialog.current?.close(); + await queryClient.invalidateQueries({ queryKey: ['declarations'] }); + await queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + }, + onError: (error) => { + confirmDialog.current?.close(); + // A 409 means the documents moved: the numbers on screen are stale, not wrong. + setStaleWarning(isApiError(error) ? error.message : t('common.error.generic')); + void queryClient.invalidateQueries({ queryKey: ['declarations', id] }); + }, + }); + + if (declaration.isPending) { + return ( + + + + ); + } + + if (declaration.isError || !declaration.data) { + const missing = isApiError(declaration.error) && declaration.error.code === 'not_found'; + return ( + +

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

+ {!missing ? ( + + ) : null} +
+ ); + } + + const data = declaration.data; + const approved = data.status === 'approved'; + // Spanish in every locale, like the form it names (COPY.md multi-language policy). + const officialTitle = `Formulario ${data.formCode}`; + + return ( +
+
+

{officialTitle}

+

{data.period}

+
+ + {staleWarning ? ( + +

+ {staleWarning} +

+
+ ) : null} + + +

{t('decl.summary.title')}

+

{humanSummary(data, t)}

+ +
+ + + + {approved ? ( + + ) : ( + <> + {/* Sticky footer: the primary action stays reachable however long the form is. */} +
+
+ + {t('decl.downloadPdf')} + + +
+
+ + +
+

+ {t('decl.approve.confirmTitle')} +

+

+ {t('decl.approve.confirmBody', { + form: data.formCode, + period: data.period, + amount: formatGsAmount(payable(data)), + })} +

+
+ + +
+
+
+ + )} + + {approved && data.approvedAt ? ( +

+ {t('decl.approvedAt', { date: formatDateLong(locale as Locale, data.approvedAt.slice(0, 10)) })} +

+ ) : null} +
+ ); +} + +type Translate = ReturnType; + +/** FLOWS.md D2: the sentence comes before the form, always. */ +function humanSummary(declaration: DeclarationDto, t: Translate): string { + const summary = declaration.summary; + + if (declaration.formCode === '120') { + const aPagar = Number(summary['aPagar'] ?? 0); + const result = + aPagar > 0 + ? t('decl.f120.toPay', { amount: formatGsAmount(aPagar) }) + : t('decl.f120.inFavor', { amount: formatGsAmount(Number(summary['saldoAFavor'] ?? 0)) }); + + return t('decl.f120.summary', { + sales: formatGsAmount(Number(summary['sales'] ?? 0)), + purchases: formatGsAmount(Number(summary['purchases'] ?? 0)), + result, + }); + } + + return t('decl.f515.summary', { + income: formatGsAmount(Number(summary['grossIncome'] ?? 0)), + deductions: formatGsAmount(Number(summary['totalDeductions'] ?? 0)), + tax: formatGsAmount(Number(summary['tax'] ?? 0)), + }); +} + +function Breakdown({ declaration }: { declaration: DeclarationDto }) { + const t = useT(); + const summary = declaration.summary; + + const rows: [string, number][] = + declaration.formCode === '120' + ? [ + [t('decl.breakdown.debito'), Number(summary['debito'] ?? 0)], + [t('decl.breakdown.credito'), Number(summary['credito'] ?? 0)], + [t('decl.breakdown.saldoAnterior'), Number(summary['saldoAnterior'] ?? 0)], + ] + : [ + [t('profile.income'), Number(summary['grossIncome'] ?? 0)], + [t('docs.detail.deductible'), Number(summary['totalDeductions'] ?? 0)], + [t('decl.f515.netIncome'), Number(summary['netIncome'] ?? 0)], + ]; + + const capExcess = Number(summary['capExcess'] ?? 0); + + return ( +
+ {rows.map(([label, amount]) => ( +
+
{label}
+
{formatGs(amount)}
+
+ ))} + + {capExcess > 0 ? ( +

+ {t('decl.f515.capNote', { amount: formatGsAmount(capExcess) })} +

+ ) : null} +
+ ); +} + +function payable(declaration: DeclarationDto): number { + const summary = declaration.summary; + return declaration.formCode === '120' + ? Number(summary['aPagar'] ?? 0) || Number(summary['saldoAFavor'] ?? 0) + : Number(summary['tax'] ?? 0); +} diff --git a/apps/web/app/[locale]/(app)/declaraciones/[id]/filing-checklist.tsx b/apps/web/app/[locale]/(app)/declaraciones/[id]/filing-checklist.tsx new file mode 100644 index 0000000..cdd4bd2 --- /dev/null +++ b/apps/web/app/[locale]/(app)/declaraciones/[id]/filing-checklist.tsx @@ -0,0 +1,140 @@ +'use client'; + +import type { DeclarationDto } from '@impuestos/contracts'; +import { formatDateLong, formatGs, type Locale } from '@impuestos/i18n'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { Check, Copy } from 'lucide-react'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { useT } from '@/i18n/t'; +import { api } from '@/lib/api'; + +/** + * Flow D3. The platform never files anything: it lays out the steps and puts every value + * one tap from the clipboard, so transcribing into Marangatu is mechanical rather than + * error prone. Marking it filed is the user telling us, not us finding out. + */ +export function FilingChecklist({ + declaration, + locale, +}: { + declaration: DeclarationDto; + locale: Locale; +}) { + const t = useT(); + const queryClient = useQueryClient(); + const [copied, setCopied] = useState(null); + + const markFiled = useMutation({ + mutationFn: () => api.markDeclarationFiled(declaration.id), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ['declarations'] }); + await queryClient.invalidateQueries({ queryKey: ['deadlines'] }); + await queryClient.invalidateQueries({ queryKey: ['dashboard'] }); + }, + }); + + const filed = declaration.filedMarkedAt !== null; + + async function copy(casilla: string, amount: number): Promise { + // The bare digits, which is what the Marangatu field takes. + await navigator.clipboard?.writeText(String(amount)); + setCopied(casilla); + setTimeout(() => setCopied((current) => (current === casilla ? null : current)), 2000); + } + + if (filed) { + return ( + + {/* A short celebration belongs here (FLOWS.md D3); the motion pass is phase 7. */} +

+ {t('decl.filed.title')} +

+

{t('decl.filed.body')}

+ {declaration.filedMarkedAt ? ( +

+ {t('decl.filedAt', { + date: formatDateLong(locale, declaration.filedMarkedAt.slice(0, 10)), + })} +

+ ) : null} +
+ ); + } + + return ( + +
+

{t('decl.checklist.title')}

+

{t('decl.checklist.intro')}

+
+ +
    +
  1. {t('decl.checklist.step1')}
  2. +
  3. {t('decl.checklist.step2', { form: declaration.formCode, period: declaration.period })}
  4. +
  5. {t('decl.checklist.step3')}
  6. +
  7. {t('decl.checklist.step4')}
  8. +
+ +
    + {declaration.values.map((value) => ( +
  • + + {value.label} + {value.casilla} + + + {formatGs(value.amount)} + + +
  • + ))} +
+ + {copied ? ( +

+ {t('decl.checklist.copied')} +

+ ) : null} + +
+ + {t('decl.downloadPdf')} + + + {markFiled.isError ? ( +

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

+ ) : null} + + +

{t('decl.notFiledYet')}

+
+
+ ); +} diff --git a/apps/web/app/[locale]/(app)/declaraciones/[id]/page.tsx b/apps/web/app/[locale]/(app)/declaraciones/[id]/page.tsx new file mode 100644 index 0000000..8f4a3a0 --- /dev/null +++ b/apps/web/app/[locale]/(app)/declaraciones/[id]/page.tsx @@ -0,0 +1,12 @@ +import { setRequestLocale } from 'next-intl/server'; +import { DeclarationDetail } from './declaration-detail'; + +export default async function DeclarationPage({ + params, +}: { + params: Promise<{ locale: string; id: string }>; +}) { + const { locale, id } = await params; + setRequestLocale(locale); + return ; +} diff --git a/apps/web/app/[locale]/(app)/declaraciones/declarations-screen.tsx b/apps/web/app/[locale]/(app)/declaraciones/declarations-screen.tsx new file mode 100644 index 0000000..784f062 --- /dev/null +++ b/apps/web/app/[locale]/(app)/declaraciones/declarations-screen.tsx @@ -0,0 +1,160 @@ +'use client'; + +import type { DeclarationDto } from '@impuestos/contracts'; +import { formatGs, formatMonthName, type Locale } from '@impuestos/i18n'; +import { addMonths, formatPeriod } from '@impuestos/rules'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { FileCheck } from 'lucide-react'; +import { Button } 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, useRouter } from '@/i18n/navigation'; +import { useT } from '@/i18n/t'; +import { api } from '@/lib/api'; +import { cn } from '@/lib/utils'; + +/** Flow D1: grouped by year, newest first, with a way to prepare the open period. */ +export function DeclarationsScreen({ locale }: { locale: string }) { + const t = useT(); + const router = useRouter(); + const queryClient = useQueryClient(); + + const declarations = useQuery({ + queryKey: ['declarations'], + queryFn: ({ signal }) => api.listDeclarations(undefined, signal), + }); + + // The month just closed is the one there is a complete picture for. + const now = new Date(); + const openPeriod = addMonths(formatPeriod(now.getUTCFullYear(), now.getUTCMonth() + 1), -1); + const alreadyPrepared = declarations.data?.items.some( + (item) => item.formCode === '120' && item.period === openPeriod, + ); + + const generate = useMutation({ + mutationFn: () => api.generateDeclaration({ formCode: '120', period: openPeriod }), + onSuccess: async (declaration) => { + await queryClient.invalidateQueries({ queryKey: ['declarations'] }); + router.push(`/declaraciones/${declaration.id}`); + }, + }); + + const byYear = groupByYear(declarations.data?.items ?? []); + + return ( +
+

{t('decl.title')}

+ + {!alreadyPrepared ? ( + +

+ {t('decl.generate', { form: '120', period: formatMonthName(locale as Locale, openPeriod) })} +

+ {generate.isError ? ( +

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

+ ) : null} + +
+ ) : null} + + {declarations.isPending ? ( + + + + ) : null} + + {declarations.isError ? ( + +

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

+ +
+ ) : null} + + {declarations.data && declarations.data.items.length === 0 ? ( + + } + title={t('decl.empty')} + body={t('decl.emptyBody')} + /> + + ) : null} + + {byYear.map(([year, items]) => ( +
+

{year}

+ +
    + {items.map((declaration) => ( +
  • + + + + + {declaration.formCode} + + {declaration.period} + + + + {headline(declaration)} + +
  • + ))} +
+
+
+ ))} +
+ ); +} + +function StatusChip({ declaration }: { declaration: DeclarationDto }) { + const t = useT(); + return ( + + {t(`decl.status.${declaration.status}` as const)} + + ); +} + +/** The one number that matters on a row: what it costs, or what it saves. */ +function headline(declaration: DeclarationDto): string { + const summary = declaration.summary; + const amount = + declaration.formCode === '120' + ? Number(summary['aPagar'] ?? 0) || Number(summary['saldoAFavor'] ?? 0) + : Number(summary['tax'] ?? 0); + return formatGs(amount); +} + +function groupByYear(items: DeclarationDto[]): [string, DeclarationDto[]][] { + const groups = new Map(); + for (const item of items) { + const year = item.period.slice(0, 4); + groups.set(year, [...(groups.get(year) ?? []), item]); + } + return [...groups.entries()].sort((a, b) => b[0].localeCompare(a[0])); +} diff --git a/apps/web/app/[locale]/(app)/declaraciones/page.tsx b/apps/web/app/[locale]/(app)/declaraciones/page.tsx new file mode 100644 index 0000000..f21216e --- /dev/null +++ b/apps/web/app/[locale]/(app)/declaraciones/page.tsx @@ -0,0 +1,12 @@ +import { setRequestLocale } from 'next-intl/server'; +import { DeclarationsScreen } from './declarations-screen'; + +export default async function DeclarationsPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + setRequestLocale(locale); + return ; +} diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx index 9f666e2..279c8b1 100644 --- a/apps/web/src/components/app-shell.tsx +++ b/apps/web/src/components/app-shell.tsx @@ -1,15 +1,20 @@ 'use client'; -import { FileText, Home, Inbox, Plus, CalendarClock } from 'lucide-react'; +import { CalendarClock, FileCheck, FileText, Home, Inbox, Plus } from 'lucide-react'; import type { ComponentType } from 'react'; import { Link, usePathname } from '@/i18n/navigation'; import { useT } from '@/i18n/t'; import { cn } from '@/lib/utils'; -const TABS: { href: string; key: 'inicio' | 'bandeja' | 'comprobantes' | 'vencimientos'; Icon: ComponentType<{ className?: string }> }[] = [ +const TABS: { + href: string; + key: 'inicio' | 'bandeja' | 'comprobantes' | 'declaraciones' | 'vencimientos'; + Icon: ComponentType<{ className?: string }>; +}[] = [ { href: '/inicio', key: 'inicio', Icon: Home }, { href: '/bandeja', key: 'bandeja', Icon: Inbox }, { href: '/comprobantes', key: 'comprobantes', Icon: FileText }, + { href: '/declaraciones', key: 'declaraciones', Icon: FileCheck }, { href: '/vencimientos', key: 'vencimientos', Icon: CalendarClock }, ]; @@ -22,26 +27,37 @@ export function AppShell() { const t = useT(); const pathname = usePathname(); + /** + * The scan button belongs on the screens you arrive at, not on a detail screen that + * already has its own primary action. On a phone the two overlap, and a floating button + * that swallows the tap meant for "Aprobar" is worse than no floating button. + */ + const onDetailScreen = TABS.some( + ({ href }) => pathname !== href && pathname.startsWith(`${href}/`), + ); + return ( <> - - - {t('scan.fab')} - + {!onDetailScreen ? ( + + + {t('scan.fab')} + + ) : null}