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}