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 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-09-04 05:50:29 +00:00
co-authored by Claude Opus 5
parent 5dc284c5f2
commit 6620650e9e
33 changed files with 2522 additions and 23 deletions
+1
View File
@@ -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",
+5
View File
@@ -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 });
+82
View File
@@ -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<number> {
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<void> {
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();
}
+2
View File
@@ -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;
}
+2
View File
@@ -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);
+107
View File
@@ -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<AppEnv> {
const routes = new Hono<AppEnv>();
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;
}
@@ -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(),
);
@@ -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<string, unknown>;
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<Database>,
args: { userId: string; profile: ProfileDto; formCode: '120' | '515'; period: string },
): Promise<Assembled> {
return args.formCode === '120' ? assembleF120(db, args) : assembleF515(db, args);
}
async function confirmedDocuments(db: Kysely<Database>, 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<Database>,
args: { userId: string; period: string },
): Promise<Assembled> {
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<Database>,
userId: string,
period: string,
): Promise<number> {
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<Database>,
args: { userId: string; profile: ProfileDto; period: string },
): Promise<Assembled> {
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<string, unknown>,
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 };
@@ -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<void> {
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);
});
});
@@ -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';
+140
View File
@@ -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<Uint8Array> {
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<DeclarationDto['status'], string> = {
draft: 'Borrador',
ready: 'Lista para revisar',
approved: 'Aprobada',
};
@@ -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<Database>;
}
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<GenerateOutcome> {
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<ApproveOutcome> {
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<DeclarationDto | null> {
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<DeclarationDto | null> {
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<string[]> {
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<DeclarationDto> {
const declaration = await getDeclaration(deps, userId, id);
if (!declaration) throw new Error('declaration disappeared immediately after being written');
return declaration;
}
@@ -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<Database>; 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 };
}
+27
View File
@@ -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<void> {
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);
}
+16
View File
@@ -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);
},