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
+84 -1
View File
@@ -441,7 +441,14 @@ The suite also erodes the seed it runs against: after a run, documents that star
bandeja are confirmed and insights are dismissed, so the next run is not the same run. bandeja are confirmed and insights are dismissed, so the next run is not the same run.
`pnpm db:reset` rebuilds the local database from the migrations and the seed, and the `pnpm db:reset` rebuilds the local database from the migrations and the seed, and the
README says to use it before an e2e run. It refuses to touch anything but a local SQLite README says to use it before an e2e run. It refuses to touch anything but a local SQLite
file, because it is destructive by definition. file, because it is destructive by definition, and it warns that the API has to be stopped
first: deleting a file the running process holds open leaves it writing to an inode nothing
else can see, which looks like half the suite breaking at once.
Where a test can avoid depending on a fresh seed, it does. Golden path 4 generates its own
declaration for a month that has documents and no declaration yet, because approving is a
one way door. The ones that genuinely cannot, like scanning into an empty bandeja, say so
in the assertion message rather than timing out on a locator.
The dashboard's insight test self-skips once every insight for that account has been The dashboard's insight test self-skips once every insight for that account has been
dismissed, since dismissals persist by design; the persistence guarantee itself is asserted dismissed, since dismissals persist by design; the persistence guarantee itself is asserted
against the API from a fresh database. against the API from a fresh database.
@@ -453,3 +460,79 @@ against the API from a fresh database.
obtains one is not in v1 scope. obtains one is not in v1 scope.
- **Declarations.** `declaration_ready` is in the next-action priority and the sweep reads - **Declarations.** `declaration_ready` is in the next-action priority and the sweep reads
from it, but nothing sets a declaration to `ready` until phase 5. from it, but nothing sets a declaration to `ready` until phase 5.
---
## Phase 5
### A generated declaration is `ready`, and approval is a promise about what was seen
Generating assembles the numbers and marks them `ready`: settled, waiting for a person to
agree. Approving recomputes them first, and if the documents moved in between it returns a
409, stores the new figures as a `draft`, and asks the user to look again. Approving
figures nobody saw is the one outcome that must be impossible, and it is the reason the
recompute happens on the way in rather than at render time.
An approved declaration is never regenerated or invalidated. Once a person has put their
name to a set of numbers, rewriting them behind their back is worse than any staleness.
### A document that moves invalidates the declaration that counted it
CONTRACTS.md 5.4. Confirming, rejecting, editing or reclassifying a document flips any
`ready` declaration covering its period back to `draft`, which is what the dashboard reads
to stop offering it for review. The month decides for a 120 and the year for a 515.
### The PDF is cached against the declaration, not regenerated per download
Two downloads of the same declaration are byte for byte the same document, which matters
when someone is transcribing from a printout. Every write that changes the numbers clears
`pdf_file_id`, which is what makes the cache safe. Approval enqueues a render so the
download on the success screen is instant; the route renders on demand too, so a failed
job costs a wait rather than a missing document.
### The form preview and the PDF stay Spanish in both locales
FLOWS.md section 1 and the COPY.md policy. The casilla labels mirror the DNIT form, and a
form whose labels have been translated is harder to transcribe from, not easier. The
caption around the preview says so in the reader's language. Two strings are hoisted out
of JSX with the reason written down, because the inline copy lint rule is right to flag
them and the answer is a comment, not an exception.
### The platform never files anything
Flow D3 is a checklist, not a submission: numbered steps, every casilla one tap from the
clipboard, and "Ya la presente" as the user telling us rather than us finding out. Marking
filed is only possible after approval, because the checklist is what follows approval.
### The seeded declarations are computed, not written down
CONTRACTS.md section 4 asks for a ready F120 for the previous month and an approved one two
months back. Both are assembled through the same code path the product uses, so their
figures agree with the documents behind them. A fixture whose numbers disagreed with its
own documents would be worse than no fixture.
### The declarations tab joined the shell
Five tabs now. Vencimientos stays reachable from the dashboard's deadline card and its
overdue action, so nothing lost a route.
### A test that had to grow
The next-action priority test now walks two rungs of the ladder rather than one: with the
seeded `ready` declaration in place, `declaration_ready` correctly outranks `bandeja`, and
the test approves it to check the fall through. The 5.4 test clears the outstanding
periods first, because `overdue` outranks everything and Maria has never filed a 515.
### Three defects the screenshots caught
**The scan button swallowed the tap meant for "Aprobar".** The floating button and the
sticky approve footer overlap on a 390px screen, and the desktop e2e never saw it because
the viewport is wider. The button now appears on the screens you arrive at, not on a detail
screen that has its own primary action, and a test asserts it.
**The summary breakdown was mislabelled.** It called the debito "A pagar" and the saldo
anterior "Casilla", because it reused keys meant for other screens. Three labels of its own
now.
**Five tab labels collided at 390px.** Smaller, truncating labels rather than dropping a
tab, since every one of them is a place the user needs to reach.
### Deferred, deliberately
- **Motion.** The confetti on approval and the canvas moment on the filed screen are
FLOWS.md D3; the motion pass and the canvas touches are phase 7. The screens are built
with the right structure and states.
- **Payment reminders after filing.** FLOWS.md D3 mentions scheduling one on "Ya lo
presente". The deadline sweep already covers T-2 and T-0 for the period; a separate
payment date is not in RULES.md and was not invented.
+4 -3
View File
@@ -7,9 +7,10 @@ and ready to file yourself.
Working name. See `docs/` for the specifications, `DECISIONS.md` for choices made along the Working name. See `docs/` for the specifications, `DECISIONS.md` for choices made along the
way and the gaps that still need answers. way and the gaps that still need answers.
> **Status: phase 4 of 8.** Everything up to a live tax position: scan a comprobante, > **Status: phase 5 of 8.** The whole taxpayer path works: scan a comprobante, confirm it,
> confirm it, and watch the IVA and IRP figures move, with deadlines tracked and reminders > watch the position move, and take the resulting Formulario 120 or 515 from review to
> queued. Declarations and the admin area are still ahead. > approved to a PDF you file yourself in Marangatu. The admin area, the PWA and the
> scale-out work are still ahead.
--- ---
+1
View File
@@ -26,6 +26,7 @@
"hono": "^4.13.5", "hono": "^4.13.5",
"kysely": "^0.29.5", "kysely": "^0.29.5",
"nodemailer": "^9.1.1", "nodemailer": "^9.1.1",
"pdf-lib": "^1.17.1",
"pg": "^8.23.0", "pg": "^8.23.0",
"uuidv7": "^1.2.1", "uuidv7": "^1.2.1",
"web-push": "^3.6.7", "web-push": "^3.6.7",
+5
View File
@@ -22,6 +22,11 @@ if (!isSqliteUrl(env.DATABASE_URL)) {
process.exit(1); 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); const file = sqliteFile(env.DATABASE_URL);
if (file !== ':memory:') { if (file !== ':memory:') {
for (const suffix of ['', '-wal', '-shm']) rmSync(`${file}${suffix}`, { force: true }); 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 { writeAudit } from '../modules/audit';
import { CONSENT_TEXT_VERSION } from '../modules/pii'; import { CONSENT_TEXT_VERSION } from '../modules/pii';
import type { DbHandle } from './index'; import type { DbHandle } from './index';
import { seedDeclarations } from './seed-declarations';
import { seedDocuments } from './seed-documents'; import { seedDocuments } from './seed-documents';
export interface SeedAccount { export interface SeedAccount {
@@ -77,6 +78,7 @@ export async function seed(
await seedProfiles(handle); await seedProfiles(handle);
await seedDocuments(handle, options.now ?? new Date()); await seedDocuments(handle, options.now ?? new Date());
await seedDeclarations(handle, options.now ?? new Date());
await seedAuditTrail(handle); await seedAuditTrail(handle);
return result; return result;
} }
+2
View File
@@ -4,6 +4,7 @@ import { HttpError, toEnvelope } from './errors';
import { liveness, readiness } from './health'; import { liveness, readiness } from './health';
import { localeMiddleware, sessionMiddleware } from './middleware'; import { localeMiddleware, sessionMiddleware } from './middleware';
import { dashboardRoutes, deadlineRoutes } from './routes/dashboard'; import { dashboardRoutes, deadlineRoutes } from './routes/dashboard';
import { declarationRoutes } from './routes/declarations';
import { documentRoutes } from './routes/documents'; import { documentRoutes } from './routes/documents';
import { fileRoutes } from './routes/files'; import { fileRoutes } from './routes/files';
import { lookupRoutes } from './routes/lookup'; import { lookupRoutes } from './routes/lookup';
@@ -55,6 +56,7 @@ export function createApp(deps: AppDeps): AppHandle {
api.route('/files', fileRoutes(deps)); api.route('/files', fileRoutes(deps));
api.route('/dashboard', dashboardRoutes(deps)); api.route('/dashboard', dashboardRoutes(deps));
api.route('/deadlines', deadlineRoutes(deps)); api.route('/deadlines', deadlineRoutes(deps));
api.route('/declarations', declarationRoutes(deps));
app.route('/api', api); 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(); 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 h = (harness = await createHarness({ now: NOW }));
const cookie = await h.signIn('maria@demo.local', 'demo-maria-1'); const cookie = await h.signIn('maria@demo.local', 'demo-maria-1');
@@ -221,6 +221,20 @@ describe('GET /dashboard', () => {
.execute(); .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( const after = DashboardDto.parse(
await (await h.app.request('/api/dashboard', { headers: { cookie } })).json(), 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 type { Kysely } from 'kysely';
import { uuidv7 } from 'uuidv7'; import { uuidv7 } from 'uuidv7';
import type { Database, DocumentsTable } from '../../db/schema'; import type { Database, DocumentsTable } from '../../db/schema';
import { invalidateForDocument } from '../declarations/service';
import { getProfile } from '../pii'; import { getProfile } from '../pii';
import type { StorageDriver } from '../storage'; import type { StorageDriver } from '../storage';
import { dedupeHash } from './dedupe'; import { dedupeHash } from './dedupe';
@@ -328,9 +329,32 @@ export async function confirmDocument(
.where('id', '=', id) .where('id', '=', id)
.where('user_id', '=', userId) .where('user_id', '=', userId)
.execute(); .execute();
await invalidateDeclarations(deps, userId, id);
return getDocument(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( export async function rejectDocument(
deps: DocumentsDeps, deps: DocumentsDeps,
userId: string, userId: string,
@@ -352,6 +376,7 @@ export async function rejectDocument(
.where('id', '=', id) .where('id', '=', id)
.execute(); .execute();
await invalidateDeclarations(deps, userId, id);
return getDocument(deps, userId, id); return getDocument(deps, userId, id);
} }
@@ -402,6 +427,7 @@ export async function patchDocument(
} }
await classifyDocument(deps, id); await classifyDocument(deps, id);
await invalidateDeclarations(deps, userId, id);
return getDocument(deps, userId, id); return getDocument(deps, userId, id);
} }
@@ -454,6 +480,7 @@ export async function patchClassification(
.where('document_id', '=', id) .where('document_id', '=', id)
.execute(); .execute();
await invalidateDeclarations(deps, userId, id);
return getDocument(deps, userId, id); return getDocument(deps, userId, id);
} }
+16
View File
@@ -1,5 +1,6 @@
import type { Kysely } from 'kysely'; import type { Kysely } from 'kysely';
import type { Database, DocumentsTable } from '../../db/schema'; import type { Database, DocumentsTable } from '../../db/schema';
import { ensurePdf, getDeclaration } from '../declarations';
import { classifyDocument, createOrMerge, type DocumentsDeps } from '../documents/service'; import { classifyDocument, createOrMerge, type DocumentsDeps } from '../documents/service';
import type { OcrProvider } from '../documents/ocr'; import type { OcrProvider } from '../documents/ocr';
import { recordIngestError } from '../ingest/errors'; 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 }); 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 () => { deadline_sweep: async () => {
await runDeadlineSweep(sweepDeps); 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} />;
}
+36 -17
View File
@@ -1,15 +1,20 @@
'use client'; '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 type { ComponentType } from 'react';
import { Link, usePathname } from '@/i18n/navigation'; import { Link, usePathname } from '@/i18n/navigation';
import { useT } from '@/i18n/t'; import { useT } from '@/i18n/t';
import { cn } from '@/lib/utils'; 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: '/inicio', key: 'inicio', Icon: Home },
{ href: '/bandeja', key: 'bandeja', Icon: Inbox }, { href: '/bandeja', key: 'bandeja', Icon: Inbox },
{ href: '/comprobantes', key: 'comprobantes', Icon: FileText }, { href: '/comprobantes', key: 'comprobantes', Icon: FileText },
{ href: '/declaraciones', key: 'declaraciones', Icon: FileCheck },
{ href: '/vencimientos', key: 'vencimientos', Icon: CalendarClock }, { href: '/vencimientos', key: 'vencimientos', Icon: CalendarClock },
]; ];
@@ -22,26 +27,37 @@ export function AppShell() {
const t = useT(); const t = useT();
const pathname = usePathname(); 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 ( return (
<> <>
<Link {!onDetailScreen ? (
href="/escanear" <Link
aria-label={t('scan.fab')} href="/escanear"
className={cn( aria-label={t('scan.fab')}
'fixed right-5 bottom-24 z-20 flex items-center gap-2 rounded-full bg-accent-600 px-5 py-3.5', className={cn(
'font-medium text-white shadow-lg transition-colors hover:bg-accent-700', 'fixed right-5 bottom-24 z-20 flex items-center gap-2 rounded-full bg-accent-600 px-5 py-3.5',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600', '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')} <Plus aria-hidden className="size-5" />
</Link> {t('scan.fab')}
</Link>
) : null}
<nav <nav
aria-label={t('common.appName')} aria-label={t('common.appName')}
className="fixed inset-x-0 bottom-0 z-10 border-t bg-[var(--surface-raised)]" 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 }) => { {TABS.map(({ href, key, Icon }) => {
const active = pathname === href || pathname.startsWith(`${href}/`); const active = pathname === href || pathname.startsWith(`${href}/`);
return ( return (
@@ -50,12 +66,15 @@ export function AppShell() {
href={href} href={href}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
className={cn( 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)]', active ? 'text-accent-700' : 'text-[var(--text-muted)]',
)} )}
> >
<Icon aria-hidden className="size-5" /> <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> </Link>
</li> </li>
); );
+83
View File
@@ -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>
);
}
+30
View File
@@ -65,3 +65,33 @@ export function readConsents(email: string): { kind: string; revoked: boolean }[
return rows.map((row) => ({ kind: row.kind, revoked: row.revoked_at !== null })); return rows.map((row) => ({ kind: row.kind, revoked: row.revoked_at !== null }));
}); });
} }
/**
* The newest month that has confirmed documents but no declaration yet.
*
* Approving is a one way door, so a test that always used the same period would only pass
* on a freshly seeded database. Picking an unused month lets the declaration flow run
* repeatably against a stack that has already been exercised.
*/
export function findMonthWithoutDeclaration(email: string): string | null {
return query((db) => {
const row = db
.prepare(
`select substr(d.issue_date, 1, 7) as period
from documents d
join user u on u.id = d.user_id
where u.email = ? and d.status = 'confirmed'
and not exists (
select 1 from declarations dec
where dec.user_id = d.user_id
and dec.form_code = '120'
and dec.period = substr(d.issue_date, 1, 7)
)
group by period
order by period desc
limit 1`,
)
.get(email) as { period: string } | undefined;
return row?.period ?? null;
});
}
+149
View File
@@ -0,0 +1,149 @@
import { es } from '@impuestos/i18n';
import { expect, test, type Page } from '@playwright/test';
import { findMonthWithoutDeclaration } from './db';
/**
* Golden path 4 (SPEC.md section 13): review a declaration, approve it, take the PDF, and
* walk the checklist that files it in Marangatu by hand.
*
* Serial and desktop only: approving is a one way door on shared demo data, and two
* workers approving the same declaration is a race the product is right to reject.
*/
test.describe.configure({ mode: 'serial' });
async function signIn(page: Page): Promise<void> {
await page.goto('/es/login');
await page.getByLabel(es['auth.register.email']).fill('maria@demo.local');
await page.getByLabel(es['auth.login.password']).fill('demo-maria-1');
await page.getByRole('button', { name: es['auth.login.submit'] }).click();
await expect(page).toHaveURL(/\/es\/inicio$/);
}
test.describe('golden path 4: review, approve, download, file', () => {
test.beforeEach(() => {
test.skip(test.info().project.name !== 'desktop', 'approving mutates shared seed data');
});
test('takes a declaration all the way from generated to filed', async ({ page }) => {
await signIn(page);
// A month with documents and no declaration yet: approving cannot be undone, so the
// test needs a period it has not already consumed.
const period = findMonthWithoutDeclaration('maria@demo.local');
expect(
period,
'every month with documents already has a declaration; run pnpm db:reset first',
).not.toBeNull();
await page.goto('/es/declaraciones');
await page.evaluate(async (target) => {
const response = await fetch('/api/declarations/generate', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ formCode: '120', period: target }),
});
if (!response.ok) throw new Error(`generate failed: ${response.status}`);
}, period);
await page.reload();
const ready = page
.locator('main')
.getByRole('link')
.filter({ hasText: es['decl.status.ready'] })
.first();
await expect(ready).toBeVisible();
await ready.click();
// D2: the sentence comes before the form.
await expect(page.getByRole('heading', { name: es['decl.summary.title'] })).toBeVisible();
await expect(page.getByText(/Vendiste Gs\. [\d.]+ y compraste Gs\. [\d.]+/)).toBeVisible();
// The official layout, in Spanish, marked as a draft until it is approved.
await expect(page.getByRole('heading', { name: es['decl.preview.title'] })).toBeVisible();
await expect(page.getByText(es['decl.preview.spanishNote'])).toBeVisible();
await expect(page.getByText('Debito fiscal')).toBeVisible();
await expect(page.getByText(es['decl.preview.draftMark'])).toBeVisible();
// D3: approving asks once, restating the number.
await page.getByRole('button', { name: es['decl.approve'], exact: true }).click();
const confirm = page.getByRole('dialog');
await expect(confirm).toBeVisible();
await expect(confirm.getByText(/Vas a aprobar tu 120 de/)).toBeVisible();
await confirm.getByRole('button', { name: es['decl.approve'], exact: true }).click();
// The checklist replaces the approve footer.
await expect(page.getByRole('heading', { name: es['decl.checklist.title'] })).toBeVisible();
await expect(page.getByText(es['decl.preview.draftMark'])).toBeHidden();
// Every casilla is one tap from the clipboard.
await expect(page.getByRole('button', { name: es['decl.checklist.copyValue'] }).first()).toBeVisible();
// The PDF is a real one.
const download = page.waitForEvent('download');
await page.getByRole('link', { name: es['decl.downloadPdf'] }).click();
const file = await download;
expect(file.suggestedFilename()).toMatch(/^formulario-120-\d{4}-\d{2}\.pdf$/);
const stream = await file.createReadStream();
const chunks: Buffer[] = [];
for await (const chunk of stream) chunks.push(chunk as Buffer);
const bytes = Buffer.concat(chunks);
expect(bytes.subarray(0, 5).toString()).toBe('%PDF-');
expect(bytes.byteLength).toBeGreaterThan(1000);
// "Ya la presente" is the user telling us, and it sticks.
await page.getByRole('button', { name: es['decl.checklist.done'] }).click();
await expect(page.getByRole('heading', { name: es['decl.filed.title'] })).toBeVisible();
await page.reload();
await expect(page.getByRole('heading', { name: es['decl.filed.title'] })).toBeVisible();
});
test('generating a period that is already approved is refused', async ({ page }) => {
await signIn(page);
await page.goto('/es/declaraciones');
// Whatever is on the list, an approved row is not offered for regeneration.
const approved = page.getByRole('link').filter({ hasText: es['decl.status.approved'] }).first();
await expect(approved).toBeVisible();
await approved.click();
await expect(page.getByRole('button', { name: es['decl.approve'], exact: true })).toBeHidden();
});
});
/** Read only, so it runs on a phone viewport too. */
test.describe('the declarations list', () => {
/**
* The scan button used to float over the sticky approve footer on a phone and swallow
* the tap. Detail screens have their own primary action, so it is not shown there.
*/
test('does not float the scan button over a detail screen', async ({ page }) => {
await signIn(page);
await page.goto('/es/declaraciones');
await expect(page.getByRole('link', { name: es['scan.fab'] })).toBeVisible();
// Scoped to main: the tab bar is a list too.
await page.locator('main').getByRole('listitem').first().getByRole('link').first().click();
await expect(page.getByRole('heading', { name: es['decl.summary.title'] })).toBeVisible();
await expect(page.getByRole('link', { name: es['scan.fab'] })).toBeHidden();
});
test('groups by year and shows the form, period and amount', async ({ page }) => {
await signIn(page);
await page.goto('/es/declaraciones');
await expect(page.getByRole('heading', { name: es['decl.title'] })).toBeVisible();
const rows = page.locator('main').getByRole('listitem');
await expect(rows.first()).toBeVisible();
await expect(rows.first()).toContainText('120');
await expect(rows.first()).toContainText(/Gs\. [\d.]+/);
});
test('is reachable from the tab bar', async ({ page }) => {
await signIn(page);
await page.getByRole('link', { name: es['nav.declaraciones'], exact: true }).click();
await expect(page).toHaveURL(/\/es\/declaraciones$/);
});
});
+6 -1
View File
@@ -55,8 +55,13 @@ test.describe('golden path 2: scan a QR comprobante and confirm it', () => {
await page.getByRole('link', { name: es['scan.goToBandeja'] }).click(); await page.getByRole('link', { name: es['scan.goToBandeja'] }).click();
await expect(page).toHaveURL(/\/es\/bandeja$/); await expect(page).toHaveURL(/\/es\/bandeja$/);
// Confirming empties the bandeja, so a suite run consumes what it needs. The reset in
// the README is the precondition; this says so rather than timing out on a locator.
const card = page.getByTestId('bandeja-card'); const card = page.getByTestId('bandeja-card');
await expect(card).toBeVisible(); await expect(
card,
'the bandeja is empty: run pnpm db:reset with the stack stopped before the suite',
).toBeVisible();
const emitter = (await card.locator('p').first().textContent()) ?? ''; const emitter = (await card.locator('p').first().textContent()) ?? '';
await page.getByRole('button', { name: es['bandeja.confirm'] }).click(); await page.getByRole('button', { name: es['bandeja.confirm'] }).click();
+26
View File
@@ -8,6 +8,9 @@ import {
type DependentInput, type DependentInput,
DashboardDto, DashboardDto,
DeadlineDto, DeadlineDto,
DeclarationDto,
DeclarationListDto,
type GenerateDeclarationInput,
DocumentDto, DocumentDto,
DocumentListDto, DocumentListDto,
type DocumentListQuery, type DocumentListQuery,
@@ -189,6 +192,29 @@ export function createApiClient(options: ApiClientOptions = {}) {
...(signal ? { signal } : {}), ...(signal ? { signal } : {}),
}), }),
// Declarations
listDeclarations: (year?: string, signal?: AbortSignal) =>
request('GET', '/declarations', {
schema: DeclarationListDto,
...(year ? { query: { year } } : {}),
...(signal ? { signal } : {}),
}),
generateDeclaration: (body: GenerateDeclarationInput) =>
request('POST', '/declarations/generate', { schema: DeclarationDto, body }),
getDeclaration: (id: string, signal?: AbortSignal) =>
request('GET', `/declarations/${encodeURIComponent(id)}`, {
schema: DeclarationDto,
...(signal ? { signal } : {}),
}),
approveDeclaration: (id: string) =>
request('POST', `/declarations/${encodeURIComponent(id)}/approve`, { schema: DeclarationDto }),
markDeclarationFiled: (id: string) =>
request('POST', `/declarations/${encodeURIComponent(id)}/mark-filed`, {
schema: DeclarationDto,
}),
/** The PDF is a stream, so it is linked to rather than fetched. */
declarationPdfUrl: (id: string) => `/api/declarations/${encodeURIComponent(id)}/pdf`,
// Notifications // Notifications
getNotificationPrefs: (signal?: AbortSignal) => getNotificationPrefs: (signal?: AbortSignal) =>
request('GET', '/me/notification-prefs', { request('GET', '/me/notification-prefs', {
+44
View File
@@ -248,6 +248,50 @@ export const IngestErrorDto = z.object({
}); });
export type IngestErrorDto = z.infer<typeof IngestErrorDto>; export type IngestErrorDto = z.infer<typeof IngestErrorDto>;
export const DeclarationStatus = z.enum(['draft', 'ready', 'approved']);
export type DeclarationStatus = z.infer<typeof DeclarationStatus>;
export const FormValueDto = z.object({
casilla: z.string(),
label: z.string(),
amount: z.number().int(),
});
export type FormValueDto = z.infer<typeof FormValueDto>;
export const DeclarationDto = z.object({
id: z.string(),
formCode: FormCode,
period: z.string(),
status: DeclarationStatus,
/**
* Human numbers, keys per form (CONTRACTS.md section 2):
* F120: sales, purchases, debito, credito, saldoAnterior, aPagar, saldoAFavor
* F515: grossIncome, perCategory (nested), capExcess, totalDeductions, netIncome,
* tax, effectiveRate
*/
summary: z.record(z.string(), z.unknown()),
values: z.array(FormValueDto),
rulesVersion: z.string(),
documentCount: z.number().int(),
createdAt: z.string(),
approvedAt: z.string().nullable(),
filedMarkedAt: z.string().nullable(),
});
export type DeclarationDto = z.infer<typeof DeclarationDto>;
export const GenerateDeclarationInput = z.object({
formCode: FormCode,
/** "YYYY-MM" for a 120, "YYYY" for a 515. */
period: z.string().regex(/^\d{4}(-\d{2})?$/),
});
export type GenerateDeclarationInput = z.infer<typeof GenerateDeclarationInput>;
export const DeclarationListDto = z.object({
items: z.array(DeclarationDto),
total: z.number().int(),
});
export type DeclarationListDto = z.infer<typeof DeclarationListDto>;
export const DashboardDto = z.object({ export const DashboardDto = z.object({
iva: z iva: z
.object({ .object({
+5
View File
@@ -43,6 +43,11 @@ export {
DocumentListQuery, DocumentListQuery,
DocumentListDto, DocumentListDto,
IngestErrorDto, IngestErrorDto,
DeclarationStatus,
FormValueDto,
DeclarationDto,
DeclarationListDto,
GenerateDeclarationInput,
DashboardDto, DashboardDto,
DeadlineDto, DeadlineDto,
TraceDto, TraceDto,
+31
View File
@@ -366,6 +366,7 @@ export const en: Record<MessageKey, string> = {
"nav.inicio": "Home", "nav.inicio": "Home",
"nav.bandeja": "Inbox", "nav.bandeja": "Inbox",
"nav.comprobantes": "Documents", "nav.comprobantes": "Documents",
"nav.declaraciones": "Declarations",
"nav.vencimientos": "Due dates", "nav.vencimientos": "Due dates",
"vto.empty": "You have no due dates yet. Check what you are registered for in your profile.", "vto.empty": "You have no due dates yet. Check what you are registered for in your profile.",
"vto.status.upcoming": "Upcoming", "vto.status.upcoming": "Upcoming",
@@ -378,6 +379,36 @@ export const en: Record<MessageKey, string> = {
"obligation.iva_120": "Monthly IVA", "obligation.iva_120": "Monthly IVA",
"obligation.irp_515": "Annual IRP", "obligation.irp_515": "Annual IRP",
// Declarations (phase 5)
"decl.stale": "Facturas from this period changed, so we rebuilt the numbers. Check them before approving.",
"decl.empty": "You have not prepared any declarations yet.",
"decl.emptyBody": "Once you have confirmed facturas for a period, we build the form for you.",
"decl.generating": "Building your declaration...",
"decl.period": "Period",
"decl.form": "Form",
"decl.amount": "Amount",
"decl.review": "Review",
"decl.summary.title": "In short",
"decl.detail.title": "The detail",
"decl.preview.title": "How the form looks",
"decl.preview.spanishNote": "Official DNIT format (in Spanish)",
"decl.preview.casilla": "Box",
"decl.preview.concept": "Item",
"decl.breakdown.debito": "Output IVA",
"decl.breakdown.credito": "Input IVA",
"decl.breakdown.saldoAnterior": "Credit carried in",
"decl.f515.summary": "You earned Gs. {income} and deducted Gs. {deductions}. You owe Gs. {tax}.",
"decl.f515.netIncome": "Taxable net income",
"decl.f515.effectiveRate": "Effective rate on your income: {rate}%",
"decl.checklist.step1": "Sign in to Marangatu with your access key.",
"decl.checklist.step2": "Pick Formulario {form} and period {period}.",
"decl.checklist.step3": "Copy each value into the box it belongs to.",
"decl.checklist.step4": "Submit the declaration and keep the receipt Marangatu gives you.",
"decl.checklist.copied": "Copied ✓",
"decl.notFiledYet": "You have not marked it as filed yet.",
"decl.approvedAt": "Approved on {date}",
"decl.filedAt": "Filed on {date}",
// Chrome (es.extra.ts) // Chrome (es.extra.ts)
"common.language": "Language", "common.language": "Language",
"common.languageEs": "Español", "common.languageEs": "Español",
+31
View File
@@ -167,6 +167,7 @@ export const esExtra = {
"nav.inicio": "Inicio", "nav.inicio": "Inicio",
"nav.bandeja": "Bandeja", "nav.bandeja": "Bandeja",
"nav.comprobantes": "Comprobantes", "nav.comprobantes": "Comprobantes",
"nav.declaraciones": "Declaraciones",
"nav.vencimientos": "Vencimientos", "nav.vencimientos": "Vencimientos",
"vto.empty": "No tenes vencimientos cargados. Revisá tus obligaciones en el perfil.", "vto.empty": "No tenes vencimientos cargados. Revisá tus obligaciones en el perfil.",
"vto.status.upcoming": "Proximo", "vto.status.upcoming": "Proximo",
@@ -179,6 +180,36 @@ export const esExtra = {
"obligation.iva_120": "IVA mensual", "obligation.iva_120": "IVA mensual",
"obligation.irp_515": "IRP anual", "obligation.irp_515": "IRP anual",
// Declarations (phase 5)
"decl.stale": "Cambiaron facturas de este periodo, asi que rehicimos los numeros. Revisalos antes de aprobar.",
"decl.empty": "Todavia no preparaste ninguna declaracion.",
"decl.emptyBody": "Cuando tengas facturas confirmadas de un periodo, preparamos el formulario por vos.",
"decl.generating": "Armando tu declaracion...",
"decl.period": "Periodo",
"decl.form": "Formulario",
"decl.amount": "Importe",
"decl.review": "Revisar",
"decl.summary.title": "En pocas palabras",
"decl.detail.title": "El detalle",
"decl.preview.title": "Asi se ve el formulario",
"decl.preview.spanishNote": "Formato oficial DNIT (en español)",
"decl.preview.casilla": "Casilla",
"decl.preview.concept": "Concepto",
"decl.breakdown.debito": "Debito fiscal",
"decl.breakdown.credito": "Credito fiscal",
"decl.breakdown.saldoAnterior": "Saldo a favor anterior",
"decl.f515.summary": "Ganaste Gs. {income} y deduciste Gs. {deductions}. Te corresponde pagar Gs. {tax}.",
"decl.f515.netIncome": "Renta neta imponible",
"decl.f515.effectiveRate": "Tasa efectiva sobre tus ingresos: {rate}%",
"decl.checklist.step1": "Entrá a Marangatu con tu clave de acceso.",
"decl.checklist.step2": "Elegí el Formulario {form} y el periodo {period}.",
"decl.checklist.step3": "Copiá cada valor en la casilla que corresponde.",
"decl.checklist.step4": "Enviá la declaracion y guardá el comprobante que te da Marangatu.",
"decl.checklist.copied": "Copiado ✓",
"decl.notFiledYet": "Todavia no la marcaste como presentada.",
"decl.approvedAt": "Aprobada el {date}",
"decl.filedAt": "Presentada el {date}",
// Chrome // Chrome
"common.language": "Idioma", "common.language": "Idioma",
"common.languageEs": "Español", "common.languageEs": "Español",
+37
View File
@@ -89,6 +89,9 @@ importers:
nodemailer: nodemailer:
specifier: ^9.1.1 specifier: ^9.1.1
version: 9.1.1 version: 9.1.1
pdf-lib:
specifier: ^1.17.1
version: 1.17.1
pg: pg:
specifier: ^8.23.0 specifier: ^8.23.0
version: 8.23.0 version: 8.23.0
@@ -1205,6 +1208,12 @@ packages:
resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
'@pdf-lib/standard-fonts@1.0.0':
resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==}
'@pdf-lib/upng@1.0.1':
resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
'@playwright/test@1.62.1': '@playwright/test@1.62.1':
resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -2957,6 +2966,9 @@ packages:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
path-exists@4.0.0: path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -2971,6 +2983,9 @@ packages:
pathe@2.0.3: pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pdf-lib@1.17.1:
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
pg-cloudflare@1.4.0: pg-cloudflare@1.4.0:
resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
@@ -3369,6 +3384,9 @@ packages:
ts-interface-checker@0.1.13: ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -4423,6 +4441,14 @@ snapshots:
'@parcel/watcher-win32-arm64': 2.6.0 '@parcel/watcher-win32-arm64': 2.6.0
'@parcel/watcher-win32-x64': 2.6.0 '@parcel/watcher-win32-x64': 2.6.0
'@pdf-lib/standard-fonts@1.0.0':
dependencies:
pako: 1.0.11
'@pdf-lib/upng@1.0.1':
dependencies:
pako: 1.0.11
'@playwright/test@1.62.1': '@playwright/test@1.62.1':
dependencies: dependencies:
playwright: 1.62.1 playwright: 1.62.1
@@ -6156,6 +6182,8 @@ snapshots:
p-try@2.2.0: {} p-try@2.2.0: {}
pako@1.0.11: {}
path-exists@4.0.0: {} path-exists@4.0.0: {}
path-key@3.1.1: {} path-key@3.1.1: {}
@@ -6164,6 +6192,13 @@ snapshots:
pathe@2.0.3: {} pathe@2.0.3: {}
pdf-lib@1.17.1:
dependencies:
'@pdf-lib/standard-fonts': 1.0.0
'@pdf-lib/upng': 1.0.1
pako: 1.0.11
tslib: 1.14.1
pg-cloudflare@1.4.0: pg-cloudflare@1.4.0:
optional: true optional: true
@@ -6632,6 +6667,8 @@ snapshots:
ts-interface-checker@0.1.13: {} ts-interface-checker@0.1.13: {}
tslib@1.14.1: {}
tslib@2.8.1: {} tslib@2.8.1: {}
tsup@8.5.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.13)(typescript@5.9.3): tsup@8.5.1(@swc/core@1.16.1(@swc/helpers@0.5.23))(jiti@2.7.0)(postcss@8.5.28)(tsx@4.23.13)(typescript@5.9.3):