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:
co-authored by
Claude Opus 5
parent
5dc284c5f2
commit
6620650e9e
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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<HTMLDialogElement>(null);
|
||||
const [staleWarning, setStaleWarning] = useState<string | null>(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 (
|
||||
<Card className="my-4">
|
||||
<Skeleton name="declaration-summary" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (declaration.isError || !declaration.data) {
|
||||
const missing = isApiError(declaration.error) && declaration.error.code === 'not_found';
|
||||
return (
|
||||
<Card className="my-4 space-y-3">
|
||||
<p role="alert" className="text-sm">
|
||||
{missing ? t('error.not_found') : t('common.error.generic')}
|
||||
</p>
|
||||
{!missing ? (
|
||||
<Button type="button" onClick={() => void declaration.refetch()}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-4 py-4 pb-28">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{officialTitle}</h1>
|
||||
<p className="tnum text-sm text-[var(--text-muted)]">{data.period}</p>
|
||||
</div>
|
||||
|
||||
{staleWarning ? (
|
||||
<Card className="border-attention">
|
||||
<p role="alert" className="text-sm text-attention text-pretty">
|
||||
{staleWarning}
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="space-y-3">
|
||||
<h2 className="font-semibold tracking-tight">{t('decl.summary.title')}</h2>
|
||||
<p className="text-lg text-pretty">{humanSummary(data, t)}</p>
|
||||
<Breakdown declaration={data} />
|
||||
</Card>
|
||||
|
||||
<FormPreview
|
||||
formCode={data.formCode}
|
||||
period={data.period}
|
||||
values={data.values}
|
||||
draft={!approved}
|
||||
labels={{
|
||||
title: t('decl.preview.title'),
|
||||
spanishNote: t('decl.preview.spanishNote'),
|
||||
casilla: t('decl.preview.casilla'),
|
||||
concept: t('decl.preview.concept'),
|
||||
amount: t('decl.amount'),
|
||||
draftMark: t('decl.preview.draftMark'),
|
||||
}}
|
||||
/>
|
||||
|
||||
{approved ? (
|
||||
<FilingChecklist declaration={data} locale={locale as Locale} />
|
||||
) : (
|
||||
<>
|
||||
{/* Sticky footer: the primary action stays reachable however long the form is. */}
|
||||
<div className="fixed inset-x-0 bottom-16 z-10 border-t bg-[var(--surface-raised)] px-5 py-3">
|
||||
<div className="mx-auto flex max-w-2xl gap-2">
|
||||
<a
|
||||
href={api.declarationPdfUrl(data.id)}
|
||||
download
|
||||
className="flex h-11 flex-1 items-center justify-center rounded-full border text-sm font-medium"
|
||||
>
|
||||
{t('decl.downloadPdf')}
|
||||
</a>
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-1"
|
||||
onClick={() => confirmDialog.current?.showModal()}
|
||||
>
|
||||
{t('decl.approve')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog
|
||||
ref={confirmDialog}
|
||||
aria-label={t('decl.approve.confirmTitle')}
|
||||
className="m-auto w-[min(28rem,calc(100vw-2rem))] rounded-2xl border bg-[var(--surface-raised)] p-6 text-[var(--text)] backdrop:bg-black/40"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold tracking-tight">
|
||||
{t('decl.approve.confirmTitle')}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-muted)] text-pretty">
|
||||
{t('decl.approve.confirmBody', {
|
||||
form: data.formCode,
|
||||
period: data.period,
|
||||
amount: formatGsAmount(payable(data)),
|
||||
})}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => confirmDialog.current?.close()}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="button" disabled={approve.isPending} onClick={() => approve.mutate()}>
|
||||
{approve.isPending ? t('common.saving') : t('decl.approve')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</>
|
||||
)}
|
||||
|
||||
{approved && data.approvedAt ? (
|
||||
<p className="text-center text-xs text-[var(--text-muted)]">
|
||||
{t('decl.approvedAt', { date: formatDateLong(locale as Locale, data.approvedAt.slice(0, 10)) })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Translate = ReturnType<typeof useT>;
|
||||
|
||||
/** 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 (
|
||||
<dl className="space-y-2 text-sm">
|
||||
{rows.map(([label, amount]) => (
|
||||
<div key={label} className="flex items-baseline justify-between gap-4">
|
||||
<dt className="text-[var(--text-muted)]">{label}</dt>
|
||||
<dd className="tnum font-medium">{formatGs(amount)}</dd>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{capExcess > 0 ? (
|
||||
<p className="pt-2 text-[var(--text-muted)] text-pretty">
|
||||
{t('decl.f515.capNote', { amount: formatGsAmount(capExcess) })}
|
||||
</p>
|
||||
) : null}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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<string | null>(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<void> {
|
||||
// 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 (
|
||||
<Card className="space-y-2">
|
||||
{/* A short celebration belongs here (FLOWS.md D3); the motion pass is phase 7. */}
|
||||
<h2 className="text-xl font-semibold tracking-tight text-positive">
|
||||
{t('decl.filed.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-muted)] text-pretty">{t('decl.filed.body')}</p>
|
||||
{declaration.filedMarkedAt ? (
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
{t('decl.filedAt', {
|
||||
date: formatDateLong(locale, declaration.filedMarkedAt.slice(0, 10)),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="space-y-5">
|
||||
<div className="space-y-1">
|
||||
<h2 className="font-semibold tracking-tight">{t('decl.checklist.title')}</h2>
|
||||
<p className="text-sm text-[var(--text-muted)] text-pretty">{t('decl.checklist.intro')}</p>
|
||||
</div>
|
||||
|
||||
<ol className="list-inside list-decimal space-y-2 text-sm">
|
||||
<li>{t('decl.checklist.step1')}</li>
|
||||
<li>{t('decl.checklist.step2', { form: declaration.formCode, period: declaration.period })}</li>
|
||||
<li>{t('decl.checklist.step3')}</li>
|
||||
<li>{t('decl.checklist.step4')}</li>
|
||||
</ol>
|
||||
|
||||
<ul className="divide-y border-y">
|
||||
{declaration.values.map((value) => (
|
||||
<li key={value.casilla} className="flex items-center justify-between gap-3 py-2.5">
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-sm">{value.label}</span>
|
||||
<span className="font-mono text-xs text-[var(--text-muted)]">{value.casilla}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<span className="tnum text-sm font-medium">{formatGs(value.amount)}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('decl.checklist.copyValue')}
|
||||
onClick={() => void copy(value.casilla, value.amount)}
|
||||
className="rounded-full border p-2 hover:bg-accent-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600"
|
||||
>
|
||||
{copied === value.casilla ? (
|
||||
<Check aria-hidden className="size-4 text-positive" />
|
||||
) : (
|
||||
<Copy aria-hidden className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{copied ? (
|
||||
<p role="status" className="text-sm text-positive">
|
||||
{t('decl.checklist.copied')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<a
|
||||
href={api.declarationPdfUrl(declaration.id)}
|
||||
download
|
||||
className="flex h-11 items-center justify-center rounded-full border text-sm font-medium"
|
||||
>
|
||||
{t('decl.downloadPdf')}
|
||||
</a>
|
||||
|
||||
{markFiled.isError ? (
|
||||
<p role="alert" className="text-sm text-overdue">
|
||||
{t('common.error.generic')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
block
|
||||
disabled={markFiled.isPending}
|
||||
onClick={() => markFiled.mutate()}
|
||||
>
|
||||
{markFiled.isPending ? t('common.saving') : t('decl.checklist.done')}
|
||||
</Button>
|
||||
<p className="text-center text-xs text-[var(--text-muted)]">{t('decl.notFiledYet')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 <DeclarationDetail id={id} locale={locale} />;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-4 py-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('decl.title')}</h1>
|
||||
|
||||
{!alreadyPrepared ? (
|
||||
<Card className="space-y-3">
|
||||
<p className="font-medium">
|
||||
{t('decl.generate', { form: '120', period: formatMonthName(locale as Locale, openPeriod) })}
|
||||
</p>
|
||||
{generate.isError ? (
|
||||
<p role="alert" className="text-sm text-overdue">
|
||||
{t('common.error.generic')}
|
||||
</p>
|
||||
) : null}
|
||||
<Button type="button" block disabled={generate.isPending} onClick={() => generate.mutate()}>
|
||||
{generate.isPending ? t('decl.generating') : t('decl.generate', {
|
||||
form: '120',
|
||||
period: formatMonthName(locale as Locale, openPeriod),
|
||||
})}
|
||||
</Button>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{declarations.isPending ? (
|
||||
<Card>
|
||||
<Skeleton name="declaration-summary" />
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{declarations.isError ? (
|
||||
<Card className="space-y-3">
|
||||
<p role="alert" className="text-sm">
|
||||
{t('common.error.generic')}
|
||||
</p>
|
||||
<Button type="button" onClick={() => void declarations.refetch()}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{declarations.data && declarations.data.items.length === 0 ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={<FileCheck aria-hidden className="size-8" />}
|
||||
title={t('decl.empty')}
|
||||
body={t('decl.emptyBody')}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{byYear.map(([year, items]) => (
|
||||
<section key={year} className="space-y-2">
|
||||
<h2 className="tnum text-sm font-medium text-[var(--text-muted)]">{year}</h2>
|
||||
<Card className="divide-y p-0">
|
||||
<ul>
|
||||
{items.map((declaration) => (
|
||||
<li key={declaration.id} className="px-6">
|
||||
<Link
|
||||
href={`/declaraciones/${declaration.id}`}
|
||||
className="flex items-center justify-between gap-4 py-4"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="tnum rounded-full bg-accent-50 px-2 py-0.5 text-xs font-medium text-accent-700">
|
||||
{declaration.formCode}
|
||||
</span>
|
||||
<span className="tnum text-sm">{declaration.period}</span>
|
||||
</span>
|
||||
<StatusChip declaration={declaration} />
|
||||
</span>
|
||||
<span className="tnum shrink-0 font-medium">{headline(declaration)}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusChip({ declaration }: { declaration: DeclarationDto }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'mt-1.5 inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
declaration.status === 'approved' && 'bg-positive-soft text-positive',
|
||||
declaration.status === 'ready' && 'bg-attention-soft text-attention',
|
||||
declaration.status === 'draft' && 'bg-[var(--surface)] text-[var(--text-muted)]',
|
||||
)}
|
||||
>
|
||||
{t(`decl.status.${declaration.status}` as const)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<string, DeclarationDto[]>();
|
||||
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]));
|
||||
}
|
||||
@@ -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 <DeclarationsScreen locale={locale} />;
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<Link
|
||||
href="/escanear"
|
||||
aria-label={t('scan.fab')}
|
||||
className={cn(
|
||||
'fixed right-5 bottom-24 z-20 flex items-center gap-2 rounded-full bg-accent-600 px-5 py-3.5',
|
||||
'font-medium text-white shadow-lg transition-colors hover:bg-accent-700',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600',
|
||||
)}
|
||||
>
|
||||
<Plus aria-hidden className="size-5" />
|
||||
{t('scan.fab')}
|
||||
</Link>
|
||||
{!onDetailScreen ? (
|
||||
<Link
|
||||
href="/escanear"
|
||||
aria-label={t('scan.fab')}
|
||||
className={cn(
|
||||
'fixed right-5 bottom-24 z-20 flex items-center gap-2 rounded-full bg-accent-600 px-5 py-3.5',
|
||||
'font-medium text-white shadow-lg transition-colors hover:bg-accent-700',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600',
|
||||
)}
|
||||
>
|
||||
<Plus aria-hidden className="size-5" />
|
||||
{t('scan.fab')}
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
<nav
|
||||
aria-label={t('common.appName')}
|
||||
className="fixed inset-x-0 bottom-0 z-10 border-t bg-[var(--surface-raised)]"
|
||||
>
|
||||
<ul className="mx-auto flex max-w-2xl">
|
||||
<ul className="mx-auto flex max-w-2xl px-1">
|
||||
{TABS.map(({ href, key, Icon }) => {
|
||||
const active = pathname === href || pathname.startsWith(`${href}/`);
|
||||
return (
|
||||
@@ -50,12 +66,15 @@ export function AppShell() {
|
||||
href={href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1 py-2.5 text-xs transition-colors',
|
||||
'flex flex-col items-center gap-1 px-0.5 py-2.5 transition-colors',
|
||||
active ? 'text-accent-700' : 'text-[var(--text-muted)]',
|
||||
)}
|
||||
>
|
||||
<Icon aria-hidden className="size-5" />
|
||||
{t(`nav.${key}` as const)}
|
||||
{/* Five tabs on a 390px screen: the label has to give, not overlap. */}
|
||||
<span className="w-full truncate text-center text-[10px] leading-tight">
|
||||
{t(`nav.${key}` as const)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { FormValueDto } from '@impuestos/contracts';
|
||||
import { formatGs } from '@impuestos/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* The declaration laid out the way the official form is, casilla by casilla, so the
|
||||
* numbers can be transcribed straight across into Marangatu.
|
||||
*
|
||||
* The labels are Spanish in every locale, deliberately: this mirrors the DNIT form, and a
|
||||
* form whose labels have been translated is harder to copy from, not easier
|
||||
* (COPY.md multi-language policy). The caption around it says so in the reader's language.
|
||||
*/
|
||||
export function FormPreview({
|
||||
formCode,
|
||||
period,
|
||||
values,
|
||||
draft,
|
||||
labels,
|
||||
}: {
|
||||
formCode: string;
|
||||
period: string;
|
||||
values: FormValueDto[];
|
||||
draft: boolean;
|
||||
labels: { title: string; spanishNote: string; casilla: string; concept: string; amount: string; draftMark: string };
|
||||
}) {
|
||||
// The official designation, Spanish in every locale: COPY.md keeps "Formulario 120"
|
||||
// and "515" as-is in English, and this block mirrors the DNIT form.
|
||||
const officialTitle = `Formulario ${formCode}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 className="font-semibold tracking-tight">{labels.title}</h2>
|
||||
<span className="text-xs text-[var(--text-muted)]">{labels.spanishNote}</span>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-hidden rounded-2xl border bg-[var(--surface-raised)]">
|
||||
{draft ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 flex items-center justify-center text-5xl font-bold tracking-widest text-overdue/10 -rotate-12"
|
||||
>
|
||||
{labels.draftMark}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<div className="border-b px-5 py-4">
|
||||
<p className="font-semibold tracking-tight">{officialTitle}</p>
|
||||
<p className="tnum text-sm text-[var(--text-muted)]">{period}</p>
|
||||
</div>
|
||||
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-xs text-[var(--text-muted)]">
|
||||
<th scope="col" className="px-5 py-2 font-medium">
|
||||
{labels.casilla}
|
||||
</th>
|
||||
<th scope="col" className="py-2 font-medium">
|
||||
{labels.concept}
|
||||
</th>
|
||||
<th scope="col" className="px-5 py-2 text-right font-medium">
|
||||
{labels.amount}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{values.map((value) => (
|
||||
<tr key={value.casilla} className="border-b last:border-0">
|
||||
<td className="px-5 py-2.5 font-mono text-xs text-[var(--text-muted)]">
|
||||
{value.casilla}
|
||||
</td>
|
||||
<td className="py-2.5">{value.label}</td>
|
||||
<td className={cn('tnum px-5 py-2.5 text-right font-medium')}>
|
||||
{formatGs(value.amount)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user