phase-4: the live tax position, deadlines and the notifications behind them

The dashboard, the deadline engine, the three sweeps and the notification
channels. Confirm a comprobante and the IVA and IRP figures move; every
headline number opens the documents behind it.

The dashboard does no arithmetic of its own: it picks inputs and calls
packages/rules. The acceptance test asserts the API's numbers equal what
computeF120 and computeF515 produce over the same rows, before and after the
bandeja is confirmed, so a drift in either direction fails.

Deadlines come from the calendario perpetuo, skipping any period earlier than
the date the taxpayer took the obligation on. Without that guard a brand new
account opens on a red overdue card for a period that predates it.

Sweeps are idempotent by dedupe key rather than bookkeeping: a restarted
poller, a second replica and a crash mid-sweep all converge on one run, and one
reminder per user per period per milestone. They queue notifications rather
than sending them, so a channel being down retries on the job schedule. The
T-10 test derives the date from dueDateFor rather than restating the calendar.

Auto-confirm only touches what the rules were confident about and never a
decision the user already made. An unconfigured channel is absent rather than
broken: the fan-out skips it, the UI hides it, and a frozen account receives
nothing.

Web: the dashboard's three zones with swipeable position cards and traceable
numbers, the deadlines timeline, and the app shell with the tab bar and the
persistent scan button FLOWS.md asks for.

Two additions to the specs, both marked: hasDocuments on DashboardDto, without
which a position of all zeros is indistinguishable from a real one and the
first-run state never shows; and an insight_dismissals table, which FLOWS.md
requires and SPEC.md has nowhere to put.

Also: pnpm db:reset, because the e2e suite changes the seed it runs against and
a suite that is not repeatable is not a suite.

280 vitest tests, 51 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 04:42:32 +00:00
co-authored by Claude Opus 5
parent b074456b70
commit 5dc284c5f2
42 changed files with 2915 additions and 66 deletions
+6 -1
View File
@@ -10,7 +10,8 @@
"typecheck": "tsc --noEmit",
"db:migrate": "tsx src/db/migrate.cli.ts",
"db:seed": "tsx src/db/seed.cli.ts",
"fixtures": "tsx scripts/render-fixtures.ts"
"fixtures": "tsx scripts/render-fixtures.ts",
"db:reset": "tsx src/db/reset.cli.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.123.0",
@@ -24,16 +25,20 @@
"better-sqlite3": "^13.0.3",
"hono": "^4.13.5",
"kysely": "^0.29.5",
"nodemailer": "^9.1.1",
"pg": "^8.23.0",
"uuidv7": "^1.2.1",
"web-push": "^3.6.7",
"zod": "^4.5.4"
},
"devDependencies": {
"@types/better-sqlite3": "^9.6.0",
"@types/node": "^26.4.1",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.23.1",
"@types/pngjs": "^6.0.5",
"@types/qrcode": "^1.5.6",
"@types/web-push": "^3.6.4",
"pngjs": "^7.0.0",
"qrcode": "^1.5.4",
"tsup": "^8.5.1",
@@ -0,0 +1,20 @@
import type { Kysely } from 'kysely';
/**
* SPEC-GAP: FLOWS.md Flow C3 requires insight dismissals to persist, and SPEC.md section 5
* lists no table for them. One row per dismissal is the smallest thing that works, and it
* keeps the dashboard read free of any per-user JSON blob to merge.
*/
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.createTable('insight_dismissals')
.addColumn('user_id', 'text', (c) => c.notNull().references('user.id').onDelete('cascade'))
.addColumn('insight_id', 'text', (c) => c.notNull())
.addColumn('dismissed_at', 'text', (c) => c.notNull())
.addPrimaryKeyConstraint('insight_dismissals_pk', ['user_id', 'insight_id'])
.execute();
}
export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropTable('insight_dismissals').ifExists().execute();
}
+2
View File
@@ -1,5 +1,6 @@
import type { Migration, MigrationProvider } from 'kysely/migration';
import * as core from './001_core';
import * as insightDismissals from './002_insight_dismissals';
/**
* Migrations are listed statically rather than read from disk: the production image
@@ -7,6 +8,7 @@ import * as core from './001_core';
*/
const migrations: Record<string, Migration> = {
'001_core': core,
'002_insight_dismissals': insightDismissals,
};
export const migrationProvider: MigrationProvider = {
+48
View File
@@ -0,0 +1,48 @@
/**
* Drops the local SQLite database and rebuilds it from the migrations and the seed.
*
* The e2e suite signs in as the demo accounts and confirms, rejects and scans against
* their data, so a suite run leaves the stack in a different state than it found it.
* Running this first is what makes the suite repeatable.
*
* Refuses to touch anything but a local SQLite file: it is destructive by definition.
*/
import { rmSync } from 'node:fs';
import { createAuth } from '../auth/options';
import { isSqliteUrl, loadEnv } from '../lib/env';
import { createDb } from './index';
import { migrateToLatest } from './migrator';
import { seed } from './seed';
import { sqliteFile } from './sqlite';
const env = loadEnv();
if (!isSqliteUrl(env.DATABASE_URL)) {
console.error('[reset] refusing to run: DATABASE_URL is not a local SQLite file');
process.exit(1);
}
const file = sqliteFile(env.DATABASE_URL);
if (file !== ':memory:') {
for (const suffix of ['', '-wal', '-shm']) rmSync(`${file}${suffix}`, { force: true });
console.info(`[reset] removed ${file}`);
}
const handle = createDb(env.DATABASE_URL);
const auth = createAuth({
db: handle.db,
dialect: handle.dialect,
env,
sendOtp: async () => undefined,
});
try {
await migrateToLatest(handle, env);
const { created } = await seed(handle, auth);
console.info(`[reset] seeded ${created.length} accounts and their comprobantes`);
} catch (error) {
console.error('[reset] failed:', error);
process.exitCode = 1;
} finally {
await handle.close();
}
+8
View File
@@ -34,6 +34,7 @@ export interface Database {
audit_log: AuditLogTable;
notification_prefs: NotificationPrefsTable;
push_subscriptions: PushSubscriptionsTable;
insight_dismissals: InsightDismissalsTable;
}
export interface UserTable {
@@ -252,6 +253,13 @@ export interface NotificationPrefsTable {
digest_hour: number;
}
export interface InsightDismissalsTable {
user_id: string;
/** Stable per insight kind and subject, so the same card is not offered again. */
insight_id: string;
dismissed_at: string;
}
export interface PushSubscriptionsTable {
id: string;
user_id: string;
+6 -2
View File
@@ -40,7 +40,11 @@ export interface SeedResult {
existing: string[];
}
export async function seed(handle: DbHandle, auth: Auth): Promise<SeedResult> {
export async function seed(
handle: DbHandle,
auth: Auth,
options: { now?: Date } = {},
): Promise<SeedResult> {
const result: SeedResult = { created: [], existing: [] };
const db = handle.db;
@@ -72,7 +76,7 @@ export async function seed(handle: DbHandle, auth: Auth): Promise<SeedResult> {
}
await seedProfiles(handle);
await seedDocuments(handle);
await seedDocuments(handle, options.now ?? new Date());
await seedAuditTrail(handle);
return result;
}
+3
View File
@@ -3,6 +3,7 @@ import type { AppDeps, AppEnv } from './context';
import { HttpError, toEnvelope } from './errors';
import { liveness, readiness } from './health';
import { localeMiddleware, sessionMiddleware } from './middleware';
import { dashboardRoutes, deadlineRoutes } from './routes/dashboard';
import { documentRoutes } from './routes/documents';
import { fileRoutes } from './routes/files';
import { lookupRoutes } from './routes/lookup';
@@ -52,6 +53,8 @@ export function createApp(deps: AppDeps): AppHandle {
api.route('/me', meRoutes(deps));
api.route('/documents', documentRoutes(deps));
api.route('/files', fileRoutes(deps));
api.route('/dashboard', dashboardRoutes(deps));
api.route('/deadlines', deadlineRoutes(deps));
app.route('/api', api);
+56
View File
@@ -0,0 +1,56 @@
import { TraceKind } from '@impuestos/contracts';
import { Hono } from 'hono';
import { buildDashboard, dismissInsight, traceFor } from '../../modules/dashboard';
import { upcomingDeadlines } from '../../modules/deadlines';
import { getProfile } from '../../modules/pii';
import type { AppDeps, AppEnv } from '../context';
import { HttpError } from '../errors';
import { requireUser } from '../middleware';
export function dashboardRoutes(deps: AppDeps): Hono<AppEnv> {
const routes = new Hono<AppEnv>();
routes.get('/', async (c) => {
const user = requireUser(c);
const profile = await getProfile(deps.handle.db, user.id);
// No profile means onboarding is unfinished; there is no position to show yet.
if (!profile) throw new HttpError('not_found');
return c.json(await buildDashboard(deps.handle.db, { userId: user.id, profile, now: new Date() }));
});
routes.get('/trace/:kind', async (c) => {
const user = requireUser(c);
const kind = TraceKind.safeParse(c.req.param('kind'));
if (!kind.success) throw new HttpError('validation_error', { field: 'kind' });
return c.json(await traceFor(deps.handle.db, { userId: user.id, kind: kind.data, now: new Date() }));
});
routes.post('/insights/:id/dismiss', async (c) => {
const user = requireUser(c);
await dismissInsight(deps.handle.db, user.id, c.req.param('id'));
return c.json({ ok: true } as const);
});
return routes;
}
export function deadlineRoutes(deps: AppDeps): Hono<AppEnv> {
const routes = new Hono<AppEnv>();
routes.get('/upcoming', async (c) => {
const user = requireUser(c);
const profile = await getProfile(deps.handle.db, user.id);
if (!profile) throw new HttpError('not_found');
const requested = Number(c.req.query('months') ?? '12');
const months = Number.isFinite(requested) ? Math.min(Math.max(1, requested), 24) : 12;
return c.json(
await upcomingDeadlines(deps.handle.db, { userId: user.id, profile, months, now: new Date() }),
);
});
return routes;
}
+8 -2
View File
@@ -7,6 +7,7 @@ import type { AppDeps } from './http/context';
import { loadEnv } from './lib/env';
import { createHandlers, createPoller } from './modules/jobs';
import { createOcrProvider } from './modules/documents/ocr';
import { createChannels } from './modules/notifications';
import { createOtpSender } from './modules/notifications/mailer';
import { createStorage } from './modules/storage';
@@ -23,6 +24,7 @@ const auth = createAuth({
const storage = createStorage(env);
const ocr = createOcrProvider(env);
const channels = createChannels(env);
const ingest = { db: handle.db, storage, ocrEnabled: ocr !== null };
const deps: AppDeps = { env, handle, auth, storage, ingest };
@@ -43,7 +45,7 @@ const poller = wantsPoller
? createPoller({
db: handle.db,
dialect: handle.dialect,
handlers: createHandlers({ db: handle.db, storage, ocr }),
handlers: createHandlers({ db: handle.db, storage, ocr, channels }),
intervalMs: env.JOBS_POLL_INTERVAL_MS,
staleMinutes: env.JOBS_STALE_MINUTES,
})
@@ -51,7 +53,11 @@ const poller = wantsPoller
if (poller) {
poller.start();
console.info(`[boot] job poller running (role=${env.ROLE}, ocr=${ocr ? 'on' : 'off'})`);
const enabled = (['push', 'email', 'telegram'] as const).filter((name) => channels[name] !== null);
console.info(
`[boot] job poller running (role=${env.ROLE}, ocr=${ocr ? 'on' : 'off'}, ` +
`channels=${enabled.length > 0 ? enabled.join('+') : 'none'})`,
);
}
const server = serve({ fetch: app.fetch, port: env.PORT, hostname: '0.0.0.0' }, (info) => {
@@ -0,0 +1,279 @@
import { DashboardDto, TraceDto } from '@impuestos/contracts';
import { computeF120, computeF515, projectedGrossIncome, pyg } from '@impuestos/rules';
import { afterEach, describe, expect, it } from 'vitest';
import { createHarness, type Harness } from '../../test/harness';
/**
* A fixed clock: the seed places documents relative to "now", so the dashboard figures are
* only assertable against a date that does not move. The 4th is early enough in the month
* that the previous month is complete and the current one has a few documents in it.
*/
const NOW = new Date('2026-09-04T12:00:00Z');
let harness: Harness | null = null;
afterEach(async () => {
await harness?.close();
harness = null;
});
async function dashboardFor(h: Harness, email: string, password: string) {
const cookie = await h.signIn(email, password);
const response = await h.app.request('/api/dashboard', { headers: { cookie } });
expect(response.status).toBe(200);
return { cookie, dashboard: DashboardDto.parse(await response.json()) };
}
/** The same inputs the dashboard uses, read straight from the database. */
async function expectedFromSeed(h: Harness, email: string) {
const user = await h.deps.handle.db
.selectFrom('user')
.select('id')
.where('email', '=', email)
.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',
'classifications.irp_category as irpCategory',
'classifications.irp_deductible_amount as irpDeductibleAmount',
])
.where('documents.user_id', '=', user.id)
.where('documents.status', '=', 'confirmed')
.execute();
const f120 = computeF120({
period: '2026-09',
saldoAnterior: pyg(0),
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),
})),
});
const deductions = rows
.filter(
(row) =>
row.direction === 'purchase' &&
(row.irpCategory ?? 'none') !== 'none' &&
row.issue_date.startsWith('2026-'),
)
.map((row) => ({
id: row.id,
irpCategory: row.irpCategory as never,
irpDeductibleAmount: pyg(row.irpDeductibleAmount ?? 0),
supplierRegimeHint: row.supplier_regime_hint,
}));
const salesYtd = rows
.filter((row) => row.direction === 'sale' && row.issue_date.startsWith('2026-'))
.reduce((sum, row) => sum + row.total, 0);
const { grossIncome } = projectedGrossIncome({
profileEstimate: pyg(180_000_000),
salesYearToDate: pyg(salesYtd),
monthsElapsed: 9,
});
const f515 = computeF515({
year: '2026',
grossIncome,
documents: deductions,
hasResimpleFlag: (doc) => doc.supplierRegimeHint === 'resimple',
});
return { f120, f515, userId: user.id };
}
describe('GET /dashboard', () => {
it('needs a finished profile', async () => {
const h = (harness = await createHarness({ now: NOW }));
const cookie = await h.signIn('staff@demo.local', 'demo-staff-1');
expect((await h.app.request('/api/dashboard', { headers: { cookie } })).status).toBe(404);
});
it('matches the RULES.md math over the seeded documents', async () => {
const h = (harness = await createHarness({ now: NOW }));
const { dashboard } = await dashboardFor(h, 'maria@demo.local', 'demo-maria-1');
const expected = await expectedFromSeed(h, 'maria@demo.local');
expect(dashboard.iva).not.toBeNull();
expect(dashboard.iva?.period).toBe('2026-09');
expect(dashboard.iva?.debito).toBe(expected.f120.debito);
expect(dashboard.iva?.credito).toBe(expected.f120.credito);
expect(dashboard.iva?.aPagar).toBe(expected.f120.aPagar);
expect(dashboard.iva?.aFavor).toBe(expected.f120.saldoAFavor);
expect(dashboard.irp).not.toBeNull();
expect(dashboard.irp?.year).toBe('2026');
expect(dashboard.irp?.projectedTax).toBe(expected.f515.tax);
expect(dashboard.irp?.belowThreshold).toBe(false);
});
// CONTRACTS.md 5.2: confirming the bandeja moves the numbers by a knowable amount.
it('moves deterministically when the bandeja is confirmed', async () => {
const h = (harness = await createHarness({ now: NOW }));
const { cookie, dashboard: before } = await dashboardFor(h, 'maria@demo.local', 'demo-maria-1');
const pending = await h.deps.handle.db
.selectFrom('documents')
.innerJoin('user', 'user.id', 'documents.user_id')
.select(['documents.id as id', 'documents.iva10 as iva10', 'documents.iva5 as iva5'])
.where('user.email', '=', 'maria@demo.local')
.where('documents.status', '=', 'needs_review')
.execute();
expect(pending).toHaveLength(5);
// Every pending document is a purchase in the open month, so each one adds its IVA
// to the credit and nothing to the debit.
const addedCredit = pending.reduce((sum, row) => sum + row.iva10 + row.iva5, 0);
for (const document of pending) {
const response = await h.app.request(`/api/documents/${document.id}/confirm`, {
method: 'POST',
headers: { cookie },
});
expect(response.status).toBe(200);
}
const after = DashboardDto.parse(
await (await h.app.request('/api/dashboard', { headers: { cookie } })).json(),
);
expect(after.iva?.debito).toBe(before.iva?.debito);
expect(after.iva?.credito).toBe((before.iva?.credito ?? 0) + addedCredit);
// And it still equals what the rules produce over the new set.
const expected = await expectedFromSeed(h, 'maria@demo.local');
expect(after.iva?.credito).toBe(expected.f120.credito);
expect(after.iva?.aPagar).toBe(expected.f120.aPagar);
expect(after.iva?.aFavor).toBe(expected.f120.saldoAFavor);
expect(after.irp?.projectedTax).toBe(expected.f515.tax);
// The bandeja is empty now, so it is no longer the thing to do next.
expect(after.nextAction.kind).not.toBe('bandeja');
});
it('shows an IVA-only company no IRP position at all', async () => {
const h = (harness = await createHarness({ now: NOW }));
const { dashboard } = await dashboardFor(h, 'carlos@demo.local', 'demo-carlos-1');
expect(dashboard.iva).not.toBeNull();
expect(dashboard.irp).toBeNull();
});
it('picks exactly one next action, by the FLOWS.md priority', async () => {
const h = (harness = await createHarness({ now: NOW }));
const { dashboard } = await dashboardFor(h, 'maria@demo.local', 'demo-maria-1');
// Overdue outranks everything, and she is: she has been registered for IVA since 2024
// and has never filed anything, so the period before the current one is past its date.
// Declarations arrive in phase 5, which is what clears this.
expect(dashboard.nextAction.kind).toBe('overdue');
expect(dashboard.nextAction.dueDate).toBeDefined();
});
it('falls through to the bandeja once nothing is overdue', async () => {
const h = (harness = await createHarness({ now: NOW }));
const cookie = await h.signIn('maria@demo.local', 'demo-maria-1');
// Marking the outstanding periods filed is what a caught-up taxpayer looks like.
const user = await h.deps.handle.db
.selectFrom('user')
.select('id')
.where('email', '=', 'maria@demo.local')
.executeTakeFirstOrThrow();
const overdue = await h.app.request('/api/deadlines/upcoming?months=12', { headers: { cookie } });
const deadlines = (await overdue.json()) as { obligation: string; period: string; status: string }[];
for (const deadline of deadlines.filter((d) => d.status === 'overdue')) {
await h.deps.handle.db
.insertInto('declarations')
.values({
id: `filed-${deadline.obligation}-${deadline.period}`,
user_id: user.id,
form_code: deadline.obligation === 'iva_120' ? '120' : '515',
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();
}
const after = DashboardDto.parse(
await (await h.app.request('/api/dashboard', { headers: { cookie } })).json(),
);
expect(after.nextAction.kind).toBe('bandeja');
expect(after.nextAction.count).toBe(5);
});
});
describe('insights', () => {
it('offers at most three and remembers a dismissal', async () => {
const h = (harness = await createHarness({ now: NOW }));
const { cookie, dashboard } = await dashboardFor(h, 'maria@demo.local', 'demo-maria-1');
expect(dashboard.insights.length).toBeGreaterThan(0);
expect(dashboard.insights.length).toBeLessThanOrEqual(3);
const first = dashboard.insights[0];
expect(first).toBeDefined();
const dismissed = await h.app.request(
`/api/dashboard/insights/${encodeURIComponent(first?.id ?? '')}/dismiss`,
{ method: 'POST', headers: { cookie } },
);
expect(dismissed.status).toBe(200);
const after = DashboardDto.parse(
await (await h.app.request('/api/dashboard', { headers: { cookie } })).json(),
);
expect(after.insights.map((insight) => insight.id)).not.toContain(first?.id);
});
});
describe('traceable numbers', () => {
it('breaks the IVA credit down into the documents behind it', async () => {
const h = (harness = await createHarness({ now: NOW }));
const { cookie, dashboard } = await dashboardFor(h, 'maria@demo.local', 'demo-maria-1');
const trace = TraceDto.parse(
await (await h.app.request('/api/dashboard/trace/iva_credito', { headers: { cookie } })).json(),
);
expect(trace.total).toBe(dashboard.iva?.credito);
for (const item of trace.items) {
expect(item.issueDate.startsWith('2026-09')).toBe(true);
expect(item.amount).toBeGreaterThan(0);
}
});
it('rejects a trace kind it does not know', async () => {
const h = (harness = await createHarness({ now: NOW }));
const cookie = await h.signIn('maria@demo.local', 'demo-maria-1');
expect(
(await h.app.request('/api/dashboard/trace/nonsense', { headers: { cookie } })).status,
).toBe(400);
});
});
+398
View File
@@ -0,0 +1,398 @@
import type { DashboardDto, DeadlineDto, ProfileDto, TraceDto, TraceKind } from '@impuestos/contracts';
import {
IRP_CATEGORIES,
computeF120,
formatIsoDate,
periodOf,
projectIrp,
pyg,
savingsThisMonth,
todayInAsuncion,
type F120Doc,
type F515Doc,
type IrpCategory,
} from '@impuestos/rules';
import type { Kysely } from 'kysely';
import type { Database } from '../../db/schema';
import { DUE_SOON_DAYS, upcomingDeadlines } from '../deadlines';
/** A category with less than this for the year is worth pointing out (FLOWS.md C3). */
const DEDUCTION_GAP_THRESHOLD = 100_000;
const MAX_INSIGHTS = 3;
interface PositionRows {
documents: {
id: string;
direction: 'purchase' | 'sale';
issueDate: string;
total: number;
amountIva10: number;
amountIva5: number;
amountExenta: number;
iva10: number;
iva5: number;
emitterName: string;
ivaCreditEligible: boolean;
ivaCreditAmount: number;
irpCategory: string;
irpDeductibleAmount: number;
supplierRegimeHint: 'normal' | 'resimple' | 'unknown';
}[];
}
/** Confirmed documents only: the dashboard never counts anything the user has not agreed to. */
async function loadConfirmed(db: Kysely<Database>, userId: string): Promise<PositionRows> {
const rows = await db
.selectFrom('documents')
.leftJoin('classifications', 'classifications.document_id', 'documents.id')
.select([
'documents.id as id',
'documents.direction as direction',
'documents.issue_date as issueDate',
'documents.total as total',
'documents.amount_iva10 as amountIva10',
'documents.amount_iva5 as amountIva5',
'documents.amount_exenta as amountExenta',
'documents.iva10 as iva10',
'documents.iva5 as iva5',
'documents.emitter_name as emitterName',
'documents.supplier_regime_hint as supplierRegimeHint',
'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();
return {
documents: rows.map((row) => ({
...row,
ivaCreditEligible: row.ivaCreditEligible === 1,
ivaCreditAmount: row.ivaCreditAmount ?? 0,
irpCategory: row.irpCategory ?? 'none',
irpDeductibleAmount: row.irpDeductibleAmount ?? 0,
})),
};
}
function toF120Docs(rows: PositionRows): F120Doc[] {
return rows.documents.map((row) => ({
id: row.id,
direction: row.direction,
issueDate: row.issueDate,
total: pyg(row.total),
amountIva10: pyg(row.amountIva10),
amountIva5: pyg(row.amountIva5),
amountExenta: pyg(row.amountExenta),
iva10: pyg(row.iva10),
iva5: pyg(row.iva5),
ivaCreditEligible: row.ivaCreditEligible,
ivaCreditAmount: pyg(row.ivaCreditAmount),
}));
}
function toF515Docs(rows: PositionRows, year: string): (F515Doc & { issueDate: string })[] {
return rows.documents
.filter(
(row) =>
row.direction === 'purchase' &&
row.irpCategory !== 'none' &&
row.issueDate.startsWith(`${year}-`),
)
.map((row) => ({
id: row.id,
irpCategory: row.irpCategory as IrpCategory,
irpDeductibleAmount: pyg(row.irpDeductibleAmount),
supplierRegimeHint: row.supplierRegimeHint,
issueDate: row.issueDate,
}));
}
/**
* The live tax position. Every number here comes out of packages/rules over the user's
* confirmed documents: this module chooses the inputs and never does arithmetic of its own.
*/
export async function buildDashboard(
db: Kysely<Database>,
args: { userId: string; profile: ProfileDto; now: Date },
): Promise<DashboardDto> {
const today = todayInAsuncion(args.now);
const period = periodOf(today);
const year = String(today.year);
const rows = await loadConfirmed(db, args.userId);
const hasIva = args.profile.obligations.some((o) => o.code === 'iva_120' && o.active);
const hasIrp = args.profile.obligations.some((o) => o.code === 'irp_515' && o.active);
const iva = hasIva
? await ivaPosition(db, { userId: args.userId, rows, period })
: null;
const irp = hasIrp ? irpPosition(rows, { profile: args.profile, year, period, today }) : null;
const deadlines = await upcomingDeadlines(db, {
userId: args.userId,
profile: args.profile,
months: 12,
now: args.now,
});
const needsReview = await countNeedsReview(db, args.userId);
const readyDeclaration = await db
.selectFrom('declarations')
.select(['id', 'form_code'])
.where('user_id', '=', args.userId)
.where('status', '=', 'ready')
.orderBy('period', 'desc')
.executeTakeFirst();
return {
iva,
irp,
hasDocuments: rows.documents.length > 0 || needsReview > 0,
nextAction: nextAction({ deadlines, needsReview, readyDeclaration, today }),
insights: await insights(db, {
userId: args.userId,
rows,
year,
period,
hasIrp,
deadlines,
}),
};
}
async function ivaPosition(
db: Kysely<Database>,
args: { userId: string; rows: PositionRows; period: string },
): Promise<DashboardDto['iva']> {
// The carry-in is whatever the last approved declaration left behind, and zero when
// there is none (RULES.md section 8).
const previous = await db
.selectFrom('declarations')
.select('summary')
.where('user_id', '=', args.userId)
.where('form_code', '=', '120')
.where('status', '=', 'approved')
.where('period', '<', args.period)
.orderBy('period', 'desc')
.executeTakeFirst();
const saldoAnterior = previous ? readSaldoAFavor(previous.summary) : 0;
const result = computeF120({
period: args.period,
saldoAnterior: pyg(saldoAnterior),
documents: toF120Docs(args.rows),
});
return {
period: args.period,
aPagar: result.aPagar,
aFavor: result.saldoAFavor,
debito: result.debito,
credito: result.credito,
};
}
function readSaldoAFavor(summary: string): number {
try {
const parsed = JSON.parse(summary) as { saldoAFavor?: unknown };
return typeof parsed.saldoAFavor === 'number' ? parsed.saldoAFavor : 0;
} catch {
return 0;
}
}
function irpPosition(
rows: PositionRows,
args: {
profile: ProfileDto;
year: string;
period: string;
today: { year: number; month: number; day: number };
},
): DashboardDto['irp'] {
const documents = toF515Docs(rows, args.year);
const salesYearToDate = rows.documents
.filter((row) => row.direction === 'sale' && row.issueDate.startsWith(`${args.year}-`))
.reduce((sum, row) => sum + row.total, 0);
const projection = projectIrp({
year: args.year,
profileEstimate: args.profile.irpGrossEstimate === null ? null : pyg(args.profile.irpGrossEstimate),
salesYearToDate: pyg(salesYearToDate),
monthsElapsed: args.today.month,
documents,
});
return {
year: args.year,
projectedTax: projection.tax,
monthDelta: savingsThisMonth({
year: args.year,
grossIncome: projection.grossIncome,
documents,
period: args.period,
}),
belowThreshold: projection.belowThreshold,
};
}
/** FLOWS.md C2: exactly one card, chosen by this priority and nothing else. */
function nextAction(args: {
deadlines: DeadlineDto[];
needsReview: number;
readyDeclaration: { id: string; form_code: '120' | '515' } | undefined;
today: { year: number; month: number; day: number };
}): DashboardDto['nextAction'] {
const overdue = args.deadlines.find((deadline) => deadline.status === 'overdue');
if (overdue) {
return { kind: 'overdue', dueDate: overdue.dueDate, form: formCodeOf(overdue) };
}
if (args.readyDeclaration) {
return {
kind: 'declaration_ready',
declarationId: args.readyDeclaration.id,
form: args.readyDeclaration.form_code,
};
}
const soon = args.deadlines.find((deadline) => deadline.status === 'due_soon');
if (soon) return { kind: 'deadline_soon', dueDate: soon.dueDate, form: formCodeOf(soon) };
if (args.needsReview > 0) return { kind: 'bandeja', count: args.needsReview };
const next = args.deadlines.find((deadline) => deadline.status === 'upcoming');
return next ? { kind: 'all_clear', dueDate: next.dueDate } : { kind: 'all_clear' };
}
function formCodeOf(deadline: DeadlineDto): '120' | '515' {
return deadline.obligation === 'iva_120' ? '120' : '515';
}
async function insights(
db: Kysely<Database>,
args: {
userId: string;
rows: PositionRows;
year: string;
period: string;
hasIrp: boolean;
deadlines: DeadlineDto[];
},
): Promise<DashboardDto['insights']> {
const dismissed = new Set(
(
await db
.selectFrom('insight_dismissals')
.select('insight_id')
.where('user_id', '=', args.userId)
.execute()
).map((row) => row.insight_id),
);
const candidates: DashboardDto['insights'] = [];
if (args.hasIrp) {
const byCategory = new Map<string, number>();
for (const row of args.rows.documents) {
if (row.irpCategory === 'none' || !row.issueDate.startsWith(`${args.year}-`)) continue;
byCategory.set(row.irpCategory, (byCategory.get(row.irpCategory) ?? 0) + row.irpDeductibleAmount);
}
for (const category of IRP_CATEGORIES) {
if (category === 'familiares') continue; // never keyword assigned, so never a gap
if ((byCategory.get(category) ?? 0) >= DEDUCTION_GAP_THRESHOLD) continue;
candidates.push({
id: `deduction_gap:${args.year}:${category}`,
kind: 'deduction_gap',
params: { category },
});
}
}
const monthDeductions = args.rows.documents
.filter((row) => row.issueDate.startsWith(args.period) && row.irpDeductibleAmount > 0)
.reduce((sum, row) => sum + row.irpDeductibleAmount, 0);
if (monthDeductions > 0) {
candidates.push({
id: `month_close:${args.period}`,
kind: 'month_close',
params: { month: args.period, savings: monthDeductions },
});
}
const nextDeadline = args.deadlines.find((deadline) => deadline.status !== 'done');
if (nextDeadline) {
candidates.push({
id: `deadline_preview:${nextDeadline.obligation}:${nextDeadline.period}`,
kind: 'deadline_preview',
params: { date: nextDeadline.dueDate, form: formCodeOf(nextDeadline) },
});
}
return candidates.filter((insight) => !dismissed.has(insight.id)).slice(0, MAX_INSIGHTS);
}
export async function dismissInsight(
db: Kysely<Database>,
userId: string,
insightId: string,
): Promise<void> {
await db
.insertInto('insight_dismissals')
.values({ user_id: userId, insight_id: insightId, dismissed_at: new Date().toISOString() })
.onConflict((oc) => oc.columns(['user_id', 'insight_id']).doNothing())
.execute();
}
/** FLOWS.md C1: every headline figure opens the documents that produced it. */
export async function traceFor(
db: Kysely<Database>,
args: { userId: string; kind: TraceKind; now: Date },
): Promise<TraceDto> {
const today = todayInAsuncion(args.now);
const period = periodOf(today);
const year = String(today.year);
const rows = await loadConfirmed(db, args.userId);
const items = rows.documents
.filter((row) => {
if (args.kind === 'irp_deductions') {
return row.issueDate.startsWith(`${year}-`) && row.irpDeductibleAmount > 0;
}
if (!row.issueDate.startsWith(period)) return false;
return args.kind === 'iva_debito'
? row.direction === 'sale'
: row.direction === 'purchase' && row.ivaCreditEligible;
})
.map((row) => ({
documentId: row.id,
emitterName: row.emitterName,
issueDate: row.issueDate,
amount:
args.kind === 'irp_deductions'
? row.irpDeductibleAmount
: args.kind === 'iva_debito'
? row.iva10 + row.iva5
: row.ivaCreditAmount,
}))
.sort((a, b) => b.issueDate.localeCompare(a.issueDate));
return { total: items.reduce((sum, item) => sum + item.amount, 0), items };
}
async function countNeedsReview(db: Kysely<Database>, userId: string): Promise<number> {
const { total } = await db
.selectFrom('documents')
.select((eb) => eb.fn.countAll<number>().as('total'))
.where('user_id', '=', userId)
.where('status', '=', 'needs_review')
.executeTakeFirstOrThrow();
return Number(total);
}
export { DUE_SOON_DAYS, formatIsoDate };
+124
View File
@@ -0,0 +1,124 @@
import type { DeadlineDto, ProfileDto } from '@impuestos/contracts';
import {
addMonths,
compareDates,
dueDateFor,
formatIsoDate,
fromDate,
nextDeadline,
periodOf,
todayInAsuncion,
type Obligation,
} from '@impuestos/rules';
import type { Kysely } from 'kysely';
import type { Database } from '../../db/schema';
/** Amber from here in (FLOWS.md section 1: T-10 to T-2 are "por vencer"). */
export const DUE_SOON_DAYS = 10;
/**
* Every obligation the taxpayer is registered for, for the next `months`, with the state
* each one is in. The dates come from packages/rules; this module only decides which
* periods are relevant and joins the declarations that already exist.
*/
export async function upcomingDeadlines(
db: Kysely<Database>,
args: { userId: string; profile: ProfileDto; months: number; now: Date },
): Promise<DeadlineDto[]> {
const today = todayInAsuncion(args.now);
const declarations = await db
.selectFrom('declarations')
.select(['id', 'form_code', 'period', 'status', 'filed_marked_at'])
.where('user_id', '=', args.userId)
.execute();
const deadlines: DeadlineDto[] = [];
for (const obligation of args.profile.obligations) {
if (!obligation.active) continue;
for (const { period, dueDate } of periodsFor(obligation.code, args.profile.deadlineDigit, args.months, args.now)) {
// Nothing before the taxpayer took the obligation on. Without this a brand new
// account opens on an "overdue" card for a period that predates it.
if (period < periodFloor(obligation.since, obligation.code)) continue;
const declaration = declarations.find(
(row) => row.form_code === formFor(obligation.code) && row.period === period,
);
const daysAway = daysBetween(today, dueDate);
const filed = declaration?.filed_marked_at !== null && declaration?.filed_marked_at !== undefined;
deadlines.push({
obligation: obligation.code,
period,
dueDate: formatIsoDate(dueDate),
status: filed
? 'done'
: daysAway < 0
? 'overdue'
: daysAway <= DUE_SOON_DAYS
? 'due_soon'
: 'upcoming',
declarationId: declaration?.id ?? null,
});
}
}
return deadlines.sort((a, b) => a.dueDate.localeCompare(b.dueDate));
}
/**
* The periods whose due dates fall inside the window, starting from the next one still
* ahead. An overdue period is included too: it is the one the user most needs to see.
*/
function periodsFor(
obligation: Obligation,
digit: number,
months: number,
now: Date,
): { period: string; dueDate: ReturnType<typeof fromDate> }[] {
const next = nextDeadline({ digit, obligation, from: now });
const results: { period: string; dueDate: ReturnType<typeof fromDate> }[] = [];
if (obligation === 'iva_120') {
// One period before the next one, so a missed filing stays visible.
let period = addMonths(next.period, -1);
for (let step = 0; step <= months; step++) {
results.push({ period, dueDate: fromDate(toUtc(dueDateFor('iva_120', period, digit))) });
period = addMonths(period, 1);
}
return results;
}
const years = Math.max(1, Math.ceil(months / 12));
for (let offset = -1; offset < years; offset++) {
const period = String(Number(next.period) + offset);
results.push({ period, dueDate: fromDate(toUtc(dueDateFor('irp_515', period, digit))) });
}
return results;
}
function toUtc(civil: { year: number; month: number; day: number }): Date {
return new Date(Date.UTC(civil.year, civil.month - 1, civil.day));
}
function daysBetween(from: { year: number; month: number; day: number }, to: { year: number; month: number; day: number }): number {
return Math.round((toUtc(to).getTime() - toUtc(from).getTime()) / 86_400_000);
}
/** The earliest period an obligation can have, from the date it was taken on. */
function periodFloor(since: string, obligation: Obligation): string {
return obligation === 'iva_120' ? since.slice(0, 7) : since.slice(0, 4);
}
export function formFor(obligation: Obligation): '120' | '515' {
return obligation === 'iva_120' ? '120' : '515';
}
/** The period an IVA declaration would cover right now: the month just closed. */
export function currentIvaPeriod(now: Date): string {
return periodOf(todayInAsuncion(now));
}
export { compareDates };
+27
View File
@@ -3,16 +3,24 @@ import type { Database, DocumentsTable } from '../../db/schema';
import { classifyDocument, createOrMerge, type DocumentsDeps } from '../documents/service';
import type { OcrProvider } from '../documents/ocr';
import { recordIngestError } from '../ingest/errors';
import { sendNotification, type Channels, type NotificationKind } from '../notifications';
import type { StorageDriver } from '../storage';
import type { JobHandlers } from './poller';
import { runAutoConfirmSweep, runDeadlineSweep, runDigestSweep } from './sweep-handlers';
export interface HandlerDeps extends DocumentsDeps {
db: Kysely<Database>;
storage: StorageDriver;
ocr: OcrProvider | null;
channels: Channels;
/** Injectable so the sweeps can be tested against a chosen date. */
now?: () => Date;
}
export function createHandlers(deps: HandlerDeps): JobHandlers {
const now = deps.now ?? (() => new Date());
const sweepDeps = { db: deps.db, now };
return {
/**
* Reads a photographed factura and creates the document from what it found. Throwing
@@ -87,5 +95,24 @@ export function createHandlers(deps: HandlerDeps): JobHandlers {
.where('id', '=', documentId)
.execute();
},
send_notification: async ({ payload }) => {
const userId = String(payload['userId'] ?? '');
const notification = payload['notification'] as NotificationKind | undefined;
if (!userId || !notification) throw new Error('send_notification needs userId and notification');
await sendNotification({ db: deps.db, channels: deps.channels }, { userId, notification });
},
deadline_sweep: async () => {
await runDeadlineSweep(sweepDeps);
},
auto_confirm_sweep: async () => {
await runAutoConfirmSweep(sweepDeps);
},
digest_sweep: async () => {
await runDigestSweep(sweepDeps);
},
};
}
+7
View File
@@ -2,3 +2,10 @@ export { enqueue, nextRunAt, BACKOFF_MS, type EnqueueOptions, type JobType } fro
export { createPoller, type JobHandlers, type JobContext, type Poller } from './poller';
export { createHandlers, type HandlerDeps } from './handlers';
export { claimJob, recoverStaleJobs, type JobRow } from './claim';
export { SWEEPS, scheduleSweeps, sweepDedupeKey } from './sweeps';
export {
runDeadlineSweep,
runAutoConfirmSweep,
runDigestSweep,
IMMEDIATE_DEADLINE_DAYS,
} from './sweep-handlers';
+7 -1
View File
@@ -4,6 +4,7 @@ import type { Dialect } from '../../db/index';
import type { Database } from '../../db/schema';
import { claimJob, recoverStaleJobs, type JobRow } from './claim';
import { nextRunAt, type JobType } from './queue';
import { scheduleSweeps } from './sweeps';
export interface JobContext {
id: string;
@@ -23,6 +24,9 @@ export interface PollerOptions {
staleMinutes: number;
/** Called for every terminal failure, so the admin error queue sees dead jobs. */
onDead?: (job: JobRow, error: unknown) => Promise<void>;
/** Off in tests that queue their own work. */
scheduleSweeps?: boolean;
now?: () => Date;
}
export interface Poller {
@@ -94,7 +98,9 @@ export function createPoller(options: PollerOptions): Poller {
}
async function runOnce(): Promise<number> {
await recoverStaleJobs(options.db, options.staleMinutes, new Date());
const now = options.now?.() ?? new Date();
await recoverStaleJobs(options.db, options.staleMinutes, now);
if (options.scheduleSweeps !== false) await scheduleSweeps(options.db, now);
let processed = 0;
// Bounded so one tick cannot monopolise the process when the queue is deep.
+175
View File
@@ -0,0 +1,175 @@
import { formatIsoDate, parseIsoDate, todayInAsuncion } from '@impuestos/rules';
import type { Kysely } from 'kysely';
import type { Database } from '../../db/schema';
import { buildDashboard } from '../dashboard';
import { getProfile } from '../pii';
import type { NotificationKind } from '../notifications';
import { enqueue } from './queue';
/** RULES.md and FLOWS.md section 9: immediate sends are T-2 and T-0 only. */
export const IMMEDIATE_DEADLINE_DAYS = [2, 0] as const;
export interface SweepDeps {
db: Kysely<Database>;
now: () => Date;
}
interface Recipient {
userId: string;
digestHour: number;
}
async function activeRecipients(db: Kysely<Database>): Promise<Recipient[]> {
const rows = await db
.selectFrom('profiles')
.innerJoin('user', 'user.id', 'profiles.user_id')
.leftJoin('notification_prefs', 'notification_prefs.user_id', 'profiles.user_id')
.select(['profiles.user_id as userId', 'notification_prefs.digest_hour as digestHour'])
// A frozen account gets nothing.
.where((eb) => eb.or([eb('user.banned', 'is', null), eb('user.banned', '=', 0)]))
.execute();
return rows.map((row) => ({ userId: row.userId, digestHour: row.digestHour ?? 9 }));
}
/**
* Looks at every taxpayer's next obligation and queues the notifications that are due
* today. Queues rather than sends, so a channel being down retries on its own schedule.
*/
export async function runDeadlineSweep(deps: SweepDeps): Promise<number> {
const now = deps.now();
const today = todayInAsuncion(now);
let queued = 0;
for (const recipient of await activeRecipients(deps.db)) {
const profile = await getProfile(deps.db, recipient.userId);
if (!profile) continue;
const dashboard = await buildDashboard(deps.db, { userId: recipient.userId, profile, now });
const deadlines = await import('../deadlines').then((module) =>
module.upcomingDeadlines(deps.db, { userId: recipient.userId, profile, months: 2, now }),
);
for (const deadline of deadlines) {
if (deadline.status === 'done') continue;
const daysAway = Math.round(
(Date.parse(`${deadline.dueDate}T00:00:00Z`) -
Date.parse(`${formatIsoDate(today)}T00:00:00Z`)) /
86_400_000,
);
if (!IMMEDIATE_DEADLINE_DAYS.includes(daysAway as 2 | 0) && daysAway !== 10) continue;
const form = deadline.obligation === 'iva_120' ? '120' : '515';
const amount = deadline.obligation === 'iva_120' ? (dashboard.iva?.aPagar ?? 0) : (dashboard.irp?.projectedTax ?? 0);
const notification: NotificationKind =
daysAway === 0
? { kind: 'deadline_t0', form, amount, dueDate: deadline.dueDate }
: { kind: 'deadline_t2', form, amount, dueDate: deadline.dueDate };
const { deduped } = await enqueue(deps.db, {
type: 'send_notification',
payload: { userId: recipient.userId, notification },
// One reminder per user, per period, per milestone, whatever else happens.
dedupeKey: `deadline:${recipient.userId}:${deadline.obligation}:${deadline.period}:T-${daysAway}`,
});
if (!deduped) queued += 1;
}
}
return queued;
}
/**
* Confirms what the classifier was confident about and the user never got to, after the
* window they chose. Zero days off means never (FLOWS.md B4).
*/
export async function runAutoConfirmSweep(deps: SweepDeps): Promise<number> {
const now = deps.now();
let confirmed = 0;
const profiles = await deps.db
.selectFrom('profiles')
.select(['user_id', 'auto_confirm_days'])
.where('auto_confirm_days', '>', 0)
.execute();
for (const profile of profiles) {
const cutoff = new Date(now.getTime() - profile.auto_confirm_days * 86_400_000).toISOString();
const result = await deps.db
.updateTable('documents')
.set({ status: 'confirmed', confirmed_at: now.toISOString() })
.where('user_id', '=', profile.user_id)
.where('status', '=', 'needs_review')
.where('created_at', '<', cutoff)
.where((eb) =>
eb.exists(
eb
.selectFrom('classifications')
.select('document_id')
.whereRef('classifications.document_id', '=', 'documents.id')
// Only what the rules were confident about. A low confidence read is exactly
// the thing a person still needs to look at.
.where('confidence', '>=', 0.8)
.where('decided_by', '=', 'auto'),
),
)
.executeTakeFirst();
confirmed += Number(result.numUpdatedRows);
}
return confirmed;
}
/** One bundled digest per user, at their hour, and only when there is something to say. */
export async function runDigestSweep(deps: SweepDeps): Promise<number> {
const now = deps.now();
const hour = now.getUTCHours();
const today = formatIsoDate(todayInAsuncion(now));
let queued = 0;
for (const recipient of await activeRecipients(deps.db)) {
if (recipient.digestHour !== hour) continue;
const profile = await getProfile(deps.db, recipient.userId);
if (!profile) continue;
const dashboard = await buildDashboard(deps.db, { userId: recipient.userId, profile, now });
const bandejaCount = dashboard.nextAction.kind === 'bandeja' ? (dashboard.nextAction.count ?? 0) : await countNeedsReview(deps.db, recipient.userId);
const deadlines = await import('../deadlines').then((module) =>
module.upcomingDeadlines(deps.db, { userId: recipient.userId, profile, months: 2, now }),
);
const next = deadlines.find((deadline) => deadline.status !== 'done');
// Nothing to report is not a notification (FLOWS.md section 9).
if (bandejaCount === 0 || !next) continue;
const { deduped } = await enqueue(deps.db, {
type: 'send_notification',
payload: {
userId: recipient.userId,
notification: { kind: 'digest', bandejaCount, nextDeadline: next.dueDate },
},
dedupeKey: `digest:${recipient.userId}:${today}`,
});
if (!deduped) queued += 1;
}
return queued;
}
async function countNeedsReview(db: Kysely<Database>, userId: string): Promise<number> {
const { total } = await db
.selectFrom('documents')
.select((eb) => eb.fn.countAll<number>().as('total'))
.where('user_id', '=', userId)
.where('status', '=', 'needs_review')
.executeTakeFirstOrThrow();
return Number(total);
}
export { parseIsoDate };
+311
View File
@@ -0,0 +1,311 @@
import { computeRucDv, dueDateFor, formatIsoDate } from '@impuestos/rules';
import { afterEach, describe, expect, it } from 'vitest';
import { createHarness, type Harness } from '../../test/harness';
import type { Channels, OutgoingMessage } from '../notifications';
import { runAutoConfirmSweep, runDeadlineSweep, runDigestSweep } from './sweep-handlers';
import { scheduleSweeps, sweepDedupeKey } from './sweeps';
let harness: Harness | null = null;
afterEach(async () => {
await harness?.close();
harness = null;
});
/** Records what would have gone out, so the sweeps can be asserted without a network. */
function recordingChannels(): Channels & { sent: { channel: string; message: OutgoingMessage }[] } {
const sent: { channel: string; message: OutgoingMessage }[] = [];
return {
sent,
push: async (_targets, message) => {
sent.push({ channel: 'push', message });
},
email: async (_to, _subject, message) => {
sent.push({ channel: 'email', message });
},
telegram: async (_chatId, message) => {
sent.push({ channel: 'telegram', message });
},
};
}
describe('scheduleSweeps', () => {
it('queues each sweep once per window', async () => {
const h = (harness = await createHarness());
const now = new Date('2026-09-04T09:00:00Z');
expect(await scheduleSweeps(h.deps.handle.db, now)).toEqual([
'deadline_sweep',
'auto_confirm_sweep',
'digest_sweep',
]);
// A second tick in the same window adds nothing.
expect(await scheduleSweeps(h.deps.handle.db, now)).toEqual([]);
// The next hour brings the hourly one back, and only that one.
expect(await scheduleSweeps(h.deps.handle.db, new Date('2026-09-04T10:00:00Z'))).toEqual([
'digest_sweep',
]);
});
it('keys daily sweeps by the day in Asuncion and hourly ones by the hour', () => {
const key = sweepDedupeKey('deadline_sweep', new Date('2026-09-04T02:00:00Z'));
// 02:00 UTC is still the 3rd in Paraguay.
expect(key).toBe('deadline_sweep:2026-09-03');
expect(sweepDedupeKey('digest_sweep', new Date('2026-09-04T10:00:00Z'))).toBe(
'digest_sweep:2026-09-04:10',
);
});
});
/**
* The phase 4 acceptance case. Maria's RUC base ends in 6, so RULES.md puts her IVA
* deadline on the 19th; the clock is moved to exactly ten days before that date and the
* sweep is expected to queue her reminder.
*/
describe('deadline sweep, ten days out', () => {
it('queues a notification for a taxpayer whose deadline is ten days away', async () => {
const h = (harness = await createHarness());
// Derived, not hardcoded: whatever the calendario says the due date is, T-10 is the day
// the sweep has to fire.
const dueDate = formatIsoDate(dueDateFor('iva_120', '2026-08', 6));
const tenDaysBefore = new Date(Date.parse(`${dueDate}T12:00:00Z`) - 10 * 86_400_000);
const queued = await runDeadlineSweep({ db: h.deps.handle.db, now: () => tenDaysBefore });
expect(queued).toBeGreaterThan(0);
const jobs = await h.deps.handle.db
.selectFrom('jobs')
.selectAll()
.where('type', '=', 'send_notification')
.execute();
const maria = await h.deps.handle.db
.selectFrom('user')
.select('id')
.where('email', '=', 'maria@demo.local')
.executeTakeFirstOrThrow();
const hers = jobs.filter((job) => (JSON.parse(job.payload) as { userId: string }).userId === maria.id);
expect(hers.length).toBeGreaterThan(0);
// The dedupe key names the milestone, which is what keeps it to one reminder.
expect(hers.some((job) => job.payload.includes(':T-10'))).toBe(true);
expect(hers.some((job) => job.payload.includes('deadline_t2'))).toBe(true);
});
it('queues nothing on a day that is not a milestone', async () => {
const h = (harness = await createHarness());
const dueDate = formatIsoDate(dueDateFor('iva_120', '2026-08', 6));
const sevenDaysBefore = new Date(Date.parse(`${dueDate}T12:00:00Z`) - 7 * 86_400_000);
await runDeadlineSweep({ db: h.deps.handle.db, now: () => sevenDaysBefore });
const jobs = await h.deps.handle.db
.selectFrom('jobs')
.selectAll()
.where('type', '=', 'send_notification')
.execute();
expect(jobs).toEqual([]);
});
it('does not send the same reminder twice', async () => {
const h = (harness = await createHarness());
const dueDate = formatIsoDate(dueDateFor('iva_120', '2026-08', 6));
const tenDaysBefore = new Date(Date.parse(`${dueDate}T12:00:00Z`) - 10 * 86_400_000);
const first = await runDeadlineSweep({ db: h.deps.handle.db, now: () => tenDaysBefore });
const second = await runDeadlineSweep({ db: h.deps.handle.db, now: () => tenDaysBefore });
expect(first).toBeGreaterThan(0);
expect(second).toBe(0);
});
it('fires on the day itself', async () => {
const h = (harness = await createHarness());
const dueDate = formatIsoDate(dueDateFor('iva_120', '2026-08', 6));
await runDeadlineSweep({
db: h.deps.handle.db,
now: () => new Date(`${dueDate}T12:00:00Z`),
});
const jobs = await h.deps.handle.db
.selectFrom('jobs')
.selectAll()
.where('type', '=', 'send_notification')
.execute();
expect(jobs.some((job) => job.payload.includes('deadline_t0'))).toBe(true);
});
});
describe('the queued notification actually goes out', () => {
it('reaches the channels the user has enabled, in their language', async () => {
const channels = recordingChannels();
const h = (harness = await createHarness({ channels }));
const dueDate = formatIsoDate(dueDateFor('iva_120', '2026-08', 6));
const tenDaysBefore = new Date(Date.parse(`${dueDate}T12:00:00Z`) - 10 * 86_400_000);
await runDeadlineSweep({ db: h.deps.handle.db, now: () => tenDaysBefore });
await h.runJobs();
// Email is on for the seeded users, push is not, and no Telegram id is linked.
expect(channels.sent.some((entry) => entry.channel === 'email')).toBe(true);
expect(channels.sent.some((entry) => entry.channel === 'push')).toBe(false);
expect(channels.sent.some((entry) => entry.channel === 'telegram')).toBe(false);
// Spanish, because that is what her profile says.
const message = channels.sent.find((entry) => entry.channel === 'email');
expect(message?.message.body).toContain('vence tu 120');
expect(message?.message.path).toBe('/declaraciones');
});
it('sends in English when the profile says so', async () => {
const channels = recordingChannels();
const h = (harness = await createHarness({ channels }));
await h.deps.handle.db
.updateTable('profiles')
.set({ locale: 'en' })
.where((eb) =>
eb(
'user_id',
'=',
eb.selectFrom('user').select('id').where('email', '=', 'maria@demo.local'),
),
)
.execute();
const dueDate = formatIsoDate(dueDateFor('iva_120', '2026-08', 6));
await runDeadlineSweep({
db: h.deps.handle.db,
now: () => new Date(Date.parse(`${dueDate}T12:00:00Z`) - 10 * 86_400_000),
});
await h.runJobs();
expect(channels.sent.some((entry) => entry.message.body.includes('is due'))).toBe(true);
});
it('sends nothing to a frozen account', async () => {
const channels = recordingChannels();
const h = (harness = await createHarness({ channels }));
await h.deps.handle.db.updateTable('user').set({ banned: 1 }).execute();
const dueDate = formatIsoDate(dueDateFor('iva_120', '2026-08', 6));
await runDeadlineSweep({
db: h.deps.handle.db,
now: () => new Date(Date.parse(`${dueDate}T12:00:00Z`) - 10 * 86_400_000),
});
await h.runJobs();
expect(channels.sent).toEqual([]);
});
});
describe('auto confirm sweep', () => {
it('confirms high confidence documents once the window has passed', async () => {
const h = (harness = await createHarness());
// The seeded documents were created well in the past, so seven days have elapsed.
const confirmed = await runAutoConfirmSweep({
db: h.deps.handle.db,
now: () => new Date('2026-09-30T12:00:00Z'),
});
expect(confirmed).toBeGreaterThan(0);
const remaining = await h.deps.handle.db
.selectFrom('documents')
.leftJoin('classifications', 'classifications.document_id', 'documents.id')
.select(['documents.id as id', 'classifications.confidence as confidence'])
.where('documents.status', '=', 'needs_review')
.execute();
// Whatever is left is exactly what a person still needs to look at.
for (const row of remaining) expect(row.confidence ?? 0).toBeLessThan(0.8);
});
it('leaves everything alone when the setting is off', async () => {
const h = (harness = await createHarness());
await h.deps.handle.db.updateTable('profiles').set({ auto_confirm_days: 0 }).execute();
expect(
await runAutoConfirmSweep({
db: h.deps.handle.db,
now: () => new Date('2026-12-31T12:00:00Z'),
}),
).toBe(0);
});
it('never touches a decision the user already made', async () => {
const h = (harness = await createHarness());
const pending = await h.deps.handle.db
.selectFrom('documents')
.select('id')
.where('status', '=', 'needs_review')
.executeTakeFirstOrThrow();
await h.deps.handle.db
.updateTable('classifications')
.set({ decided_by: 'user', confidence: 0.99 })
.where('document_id', '=', pending.id)
.execute();
await runAutoConfirmSweep({
db: h.deps.handle.db,
now: () => new Date('2026-12-31T12:00:00Z'),
});
const after = await h.deps.handle.db
.selectFrom('documents')
.select('status')
.where('id', '=', pending.id)
.executeTakeFirstOrThrow();
expect(after.status).toBe('needs_review');
});
});
describe('digest sweep', () => {
it('queues one bundled digest at the user chosen hour', async () => {
const h = (harness = await createHarness());
const nineAm = new Date('2026-09-10T09:00:00Z');
expect(await runDigestSweep({ db: h.deps.handle.db, now: () => nineAm })).toBeGreaterThan(0);
// Once per day, whatever else happens.
expect(await runDigestSweep({ db: h.deps.handle.db, now: () => nineAm })).toBe(0);
});
it('says nothing at any other hour', async () => {
const h = (harness = await createHarness());
expect(
await runDigestSweep({
db: h.deps.handle.db,
now: () => new Date('2026-09-10T14:00:00Z'),
}),
).toBe(0);
});
it('says nothing when the bandeja is empty', async () => {
const h = (harness = await createHarness());
await h.deps.handle.db
.updateTable('documents')
.set({ status: 'confirmed' })
.where('status', '=', 'needs_review')
.execute();
expect(
await runDigestSweep({
db: h.deps.handle.db,
now: () => new Date('2026-09-10T09:00:00Z'),
}),
).toBe(0);
});
});
describe('the RUC the seed is built on', () => {
it('still has the check digit the deadline day depends on', () => {
// Base ends in 6, which RULES.md maps to the 19th. Every date in this file follows.
expect(computeRucDv('4123456')).toBe(1);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { formatIsoDate, todayInAsuncion } from '@impuestos/rules';
import type { Kysely } from 'kysely';
import type { Database } from '../../db/schema';
import { enqueue, type JobType } from './queue';
/**
* The recurring work. Each sweep is enqueued with a dedupe key naming the day (or hour) it
* belongs to, so a restarted poller, a second replica, or a crash mid-sweep can all try
* again without the user getting the same notification twice (SPEC.md section 10).
*/
export const SWEEPS: readonly { type: JobType; every: 'day' | 'hour' }[] = [
{ type: 'deadline_sweep', every: 'day' },
{ type: 'auto_confirm_sweep', every: 'day' },
{ type: 'digest_sweep', every: 'hour' },
];
export function sweepDedupeKey(type: JobType, now: Date): string {
const today = formatIsoDate(todayInAsuncion(now));
const definition = SWEEPS.find((sweep) => sweep.type === type);
return definition?.every === 'hour'
? `${type}:${today}:${String(now.getUTCHours()).padStart(2, '0')}`
: `${type}:${today}`;
}
/** Called on every poll tick. Cheap: it is a single indexed lookup per sweep. */
export async function scheduleSweeps(db: Kysely<Database>, now: Date): Promise<string[]> {
const scheduled: string[] = [];
for (const sweep of SWEEPS) {
const { deduped } = await enqueue(db, {
type: sweep.type,
payload: {},
dedupeKey: sweepDedupeKey(sweep.type, now),
});
if (!deduped) scheduled.push(sweep.type);
}
return scheduled;
}
@@ -0,0 +1,96 @@
import nodemailer from 'nodemailer';
import webpush from 'web-push';
import type { Env } from '../../lib/env';
export interface OutgoingMessage {
title: string;
body: string;
/** Path on APP_PUBLIC_URL. Every notification carries exactly one action (FLOWS.md 9). */
path: string;
}
export interface PushTarget {
endpoint: string;
keys: { p256dh: string; auth: string };
}
/**
* A channel that is not configured is absent, not broken: `null` means the UI hides it and
* the fan-out skips it (FLOWS.md section 9).
*/
export interface Channels {
push: ((targets: PushTarget[], message: OutgoingMessage) => Promise<void>) | null;
email: ((to: string, subject: string, message: OutgoingMessage) => Promise<void>) | null;
telegram: ((chatId: string, message: OutgoingMessage) => Promise<void>) | null;
}
export function createChannels(env: Env): Channels {
return {
push: createPush(env),
email: createEmail(env),
telegram: createTelegram(env),
};
}
function createPush(env: Env): Channels['push'] {
if (!env.PUSH_VAPID_PUBLIC_KEY || !env.PUSH_VAPID_PRIVATE_KEY) return null;
webpush.setVapidDetails(env.APP_PUBLIC_URL, env.PUSH_VAPID_PUBLIC_KEY, env.PUSH_VAPID_PRIVATE_KEY);
return async (targets, message) => {
const payload = JSON.stringify({
title: message.title,
body: message.body,
url: `${env.APP_PUBLIC_URL}${message.path}`,
});
// One dead subscription must not stop the others: a phone that was wiped is normal.
await Promise.allSettled(
targets.map((target) =>
webpush.sendNotification({ endpoint: target.endpoint, keys: target.keys }, payload),
),
);
};
}
function createEmail(env: Env): Channels['email'] {
if (!env.SMTP_HOST) return null;
const transport = nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_PORT === 465,
...(env.SMTP_USER && env.SMTP_PASS
? { auth: { user: env.SMTP_USER, pass: env.SMTP_PASS } }
: {}),
});
return async (to, subject, message) => {
await transport.sendMail({
from: env.SMTP_FROM ?? `no-reply@${new URL(env.APP_PUBLIC_URL).hostname}`,
to,
subject,
text: `${message.body}\n\n${env.APP_PUBLIC_URL}${message.path}`,
});
};
}
function createTelegram(env: Env): Channels['telegram'] {
if (!env.TELEGRAM_BOT_TOKEN) return null;
const url = `https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage`;
return async (chatId, message) => {
const response = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: `${message.body}\n${env.APP_PUBLIC_URL}${message.path}`,
disable_web_page_preview: true,
}),
});
if (!response.ok) {
throw new Error(`telegram rejected the message: ${response.status}`);
}
};
}
@@ -0,0 +1,4 @@
export { createChannels, type Channels, type OutgoingMessage, type PushTarget } from './channels';
export { renderNotification, emailSubject, type NotificationKind } from './templates';
export { sendNotification, type NotificationDeps } from './send';
export { createOtpSender } from './mailer';
@@ -0,0 +1,81 @@
import { DEFAULT_LOCALE, isLocale, type Locale } from '@impuestos/i18n';
import type { Kysely } from 'kysely';
import { z } from 'zod';
import type { Database } from '../../db/schema';
import type { Channels } from './channels';
import { emailSubject, renderNotification, type NotificationKind } from './templates';
const PushKeys = z.object({ p256dh: z.string(), auth: z.string() });
export interface NotificationDeps {
db: Kysely<Database>;
channels: Channels;
}
/**
* Fans one notification out to whichever channels the user has both enabled and that the
* deployment has configured. Silence is a valid outcome: a user with everything off gets
* nothing, and that is the setting working.
*/
export async function sendNotification(
deps: NotificationDeps,
args: { userId: string; notification: NotificationKind },
): Promise<{ delivered: string[] }> {
const recipient = await deps.db
.selectFrom('user')
.leftJoin('profiles', 'profiles.user_id', 'user.id')
.leftJoin('notification_prefs', 'notification_prefs.user_id', 'user.id')
.select([
'user.id as id',
'user.email as email',
'user.banned as banned',
'profiles.locale as locale',
'notification_prefs.push_enabled as pushEnabled',
'notification_prefs.email_enabled as emailEnabled',
'notification_prefs.telegram_chat_id as telegramChatId',
])
.where('user.id', '=', args.userId)
.executeTakeFirst();
// A frozen account is not a recipient.
if (!recipient || recipient.banned === 1) return { delivered: [] };
const locale: Locale = isLocale(recipient.locale) ? recipient.locale : DEFAULT_LOCALE;
const message = renderNotification(locale, args.notification);
const delivered: string[] = [];
if (deps.channels.push && recipient.pushEnabled === 1) {
const subscriptions = await deps.db
.selectFrom('push_subscriptions')
.select(['endpoint', 'keys'])
.where('user_id', '=', args.userId)
.execute();
const targets = subscriptions.flatMap((row) => {
const parsed = PushKeys.safeParse(JSON.parse(row.keys));
return parsed.success ? [{ endpoint: row.endpoint, keys: parsed.data }] : [];
});
if (targets.length > 0) {
await deps.channels.push(targets, message);
delivered.push('push');
}
}
if (deps.channels.email && recipient.emailEnabled === 1) {
await deps.channels.email(recipient.email, emailSubject(locale, args.notification), message);
delivered.push('email');
}
if (deps.channels.telegram && recipient.telegramChatId) {
await deps.channels.telegram(recipient.telegramChatId, message);
delivered.push('telegram');
}
return { delivered };
}
export const NotificationJobPayload = z.object({
userId: z.string(),
notification: z.record(z.string(), z.unknown()),
});
@@ -0,0 +1,64 @@
import { type Locale, type MessageKey, formatDateShort, formatGsAmount, t } from '@impuestos/i18n';
import type { OutgoingMessage } from './channels';
/**
* Every notification the platform sends, in the recipient's language. The catalogs are the
* only place any of this text exists, which is what makes adding a locale one file.
*/
export type NotificationKind =
| { kind: 'digest'; bandejaCount: number; nextDeadline: string }
| { kind: 'deadline_t2'; form: '120' | '515'; amount: number; dueDate: string }
| { kind: 'deadline_t0'; form: '120' | '515'; amount: number; dueDate: string }
| { kind: 'declaration_ready'; form: '120' | '515'; period: string; declarationId: string }
| { kind: 'savings'; amount: number };
export function renderNotification(locale: Locale, input: NotificationKind): OutgoingMessage {
const appName = t(locale, 'common.appName');
switch (input.kind) {
case 'digest':
return {
title: appName,
body: t(locale, 'push.digest', {
bandejaCount: input.bandejaCount,
date: formatDateShort(locale, input.nextDeadline),
}),
path: '/bandeja',
};
case 'deadline_t2':
case 'deadline_t0': {
const key: MessageKey = input.kind === 'deadline_t2' ? 'push.deadline.t2' : 'push.deadline.t0';
return {
title: appName,
body: t(locale, key, { form: input.form, amount: formatGsAmount(input.amount) }),
path: '/declaraciones',
};
}
case 'declaration_ready':
return {
title: appName,
body: t(locale, 'push.declReady', { form: input.form, period: input.period }),
path: `/declaraciones/${input.declarationId}`,
};
case 'savings':
return {
title: appName,
body: t(locale, 'push.savings', { amount: formatGsAmount(input.amount) }),
path: '/inicio',
};
}
}
/** Emails carry their own subject line; push and Telegram do not. */
export function emailSubject(locale: Locale, input: NotificationKind): string {
if (input.kind === 'deadline_t2' || input.kind === 'deadline_t0') {
return t(locale, 'email.subject.deadline', {
form: input.form,
date: formatDateShort(locale, input.dueDate),
});
}
return renderNotification(locale, input).body;
}
+17 -2
View File
@@ -10,6 +10,7 @@ import type { AppDeps } from '../http/context';
import { type Env, parseEnv } from '../lib/env';
import { createHandlers, createPoller, type Poller } from '../modules/jobs';
import type { OcrProvider } from '../modules/documents/ocr';
import { createChannels, type Channels } from '../modules/notifications';
import { createStorage, type StorageDriver } from '../modules/storage';
export const TEST_ENV: Record<string, string> = {
@@ -25,12 +26,18 @@ export interface HarnessOptions {
/** Stand in for the Anthropic call, so OCR paths are testable without a key. */
ocr?: OcrProvider | null;
storage?: StorageDriver;
channels?: Channels;
/** Freezes the clock, so seeded dates and dashboard figures are deterministic. */
now?: Date;
}
export interface Harness extends AppHandle {
deps: AppDeps;
env: Env;
storage: StorageDriver;
channels: Channels;
/** The clock the harness was built with, frozen when one was supplied. */
now: () => Date;
/** Drains the job queue. Tests call this instead of waiting on the interval. */
runJobs: () => Promise<number>;
poller: Poller;
@@ -48,7 +55,10 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
await migrateToLatest(handle, env);
const auth = createAuth({ db: handle.db, dialect: handle.dialect, env, sendOtp: async () => undefined });
await seed(handle, auth);
await seed(handle, auth, options.now ? { now: options.now } : {});
const channels = options.channels ?? createChannels(env);
const now = options.now ? () => options.now as Date : () => new Date();
const storage =
options.storage ??
@@ -62,9 +72,12 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
const poller = createPoller({
db: handle.db,
dialect: handle.dialect,
handlers: createHandlers({ db: handle.db, storage, ocr }),
handlers: createHandlers({ db: handle.db, storage, ocr, channels, now }),
intervalMs: env.JOBS_POLL_INTERVAL_MS,
staleMinutes: env.JOBS_STALE_MINUTES,
// Tests queue exactly the work they mean to exercise.
scheduleSweeps: false,
now,
});
return {
@@ -72,6 +85,8 @@ export async function createHarness(options: HarnessOptions = {}): Promise<Harne
deps,
env,
storage,
channels,
now,
poller,
runJobs: () => poller.runOnce(),
close: async () => {
@@ -43,12 +43,8 @@ export function DocumentsScreen({ locale }: { locale: string }) {
return (
<div className="space-y-4 py-4">
<div className="flex items-center justify-between gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{t('docs.title')}</h1>
<Link href="/escanear" className={buttonClasses({ size: 'md' })}>
{t('scan.fab')}
</Link>
</div>
{/* No scan button here: the shell carries a persistent one on every screen. */}
<h1 className="text-2xl font-semibold tracking-tight">{t('docs.title')}</h1>
<input
type="search"
@@ -0,0 +1,303 @@
'use client';
import type { DashboardDto } from '@impuestos/contracts';
import { formatDateLong, formatGs, formatGsAmount, formatMonthName, type Locale } from '@impuestos/i18n';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ScanLine, X } from 'lucide-react';
import { TraceableNumber } from '@/components/traceable-number';
import { Button, buttonClasses } 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 } from '@/i18n/navigation';
import { useT } from '@/i18n/t';
import { api } from '@/lib/api';
/** Flow C: position header, then exactly one next action, then the insight feed. */
export function Dashboard({ locale, fullName }: { locale: Locale; fullName: string }) {
const t = useT();
const dashboard = useQuery({
queryKey: ['dashboard'],
queryFn: ({ signal }) => api.getDashboard(signal),
});
if (dashboard.isPending) {
return (
<div className="space-y-4 py-4">
<Card>
<Skeleton name="dashboard-position" />
</Card>
<Card>
<Skeleton name="bandeja-card" />
</Card>
</div>
);
}
if (dashboard.isError || !dashboard.data) {
return (
<Card className="my-4 space-y-3">
<p role="alert" className="text-sm">
{t('common.error.generic')}
</p>
<Button type="button" onClick={() => void dashboard.refetch()}>
{t('common.retry')}
</Button>
</Card>
);
}
const data = dashboard.data;
// Flow A6: until the first comprobante arrives there is nothing to show but the way in.
const nothingYet = !data.hasDocuments;
return (
<div className="space-y-4 py-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('home.title')}</h1>
<p className="text-sm text-[var(--text-muted)]">{fullName}</p>
</div>
{nothingYet ? (
<Card>
<EmptyState
icon={<ScanLine aria-hidden className="size-8" />}
title={t('home.firstRun.title')}
body={t('home.noPosition')}
action={
<Link href="/escanear" className={buttonClasses({ size: 'lg' })}>
{t('home.firstRun.scan')}
</Link>
}
/>
</Card>
) : null}
{!nothingYet ? (
<>
{/* Zone 1: the position, one card per obligation, swipeable on a phone. */}
<div className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-1">
{data.iva ? <IvaCard iva={data.iva} locale={locale} /> : null}
{data.irp ? <IrpCard irp={data.irp} locale={locale} /> : null}
</div>
{/* Zone 2: exactly one next action. */}
<NextAction action={data.nextAction} locale={locale} />
{/* Zone 3: the insight feed. */}
<InsightFeed insights={data.insights} locale={locale} />
</>
) : null}
</div>
);
}
function IvaCard({ iva, locale }: { iva: NonNullable<DashboardDto['iva']>; locale: Locale }) {
const t = useT();
const owes = iva.aPagar > 0;
return (
<Card className="min-w-[85%] shrink-0 snap-start space-y-2 sm:min-w-0 sm:flex-1">
<p className="text-sm font-medium text-[var(--text-muted)]">
{t('home.iva.title', { month: formatMonthName(locale, iva.period) })}
</p>
<p className="text-sm text-[var(--text-muted)]">
{owes ? t('home.iva.aPagar') : t('home.iva.aFavor')}
</p>
{/* Owing tax on time is neutral, never red (FLOWS.md section 1). */}
<TraceableNumber
value={owes ? iva.aPagar : iva.aFavor}
kind={owes ? 'iva_debito' : 'iva_credito'}
locale={locale}
className={owes ? undefined : 'text-positive'}
/>
<p className="text-sm text-[var(--text-muted)]">
{t('home.iva.breakdown', {
debito: formatGsAmount(iva.debito),
credito: formatGsAmount(iva.credito),
})}
</p>
</Card>
);
}
function IrpCard({ irp, locale }: { irp: NonNullable<DashboardDto['irp']>; locale: Locale }) {
const t = useT();
return (
<Card className="min-w-[85%] shrink-0 snap-start space-y-2 sm:min-w-0 sm:flex-1">
<p className="text-sm font-medium text-[var(--text-muted)]">
{t('home.irp.title', { year: irp.year })}
</p>
<TraceableNumber value={irp.projectedTax} kind="irp_deductions" locale={locale} />
{irp.monthDelta > 0 ? (
<p className="text-sm text-positive">
{t('home.irp.delta', { amount: formatGsAmount(irp.monthDelta) })}
</p>
) : null}
{irp.belowThreshold ? (
<p className="text-sm text-[var(--text-muted)] text-pretty">
{t('home.irp.belowThreshold')}
</p>
) : null}
</Card>
);
}
function NextAction({
action,
locale,
}: {
action: DashboardDto['nextAction'];
locale: Locale;
}) {
const t = useT();
switch (action.kind) {
case 'overdue':
return (
<Card className="space-y-3 border-overdue">
<p className="font-medium text-overdue">
{t('home.next.overdue', {
form: action.form ?? '120',
date: action.dueDate ? formatDateLong(locale, action.dueDate) : '',
})}
</p>
<Link href="/vencimientos" className={buttonClasses({ block: true })}>
{t('home.next.overdueCta')}
</Link>
</Card>
);
case 'declaration_ready':
return (
<Card className="space-y-3 border-attention">
<p className="font-medium">{t('decl.status.ready')}</p>
<Link
href={`/declaraciones/${action.declarationId ?? ''}`}
className={buttonClasses({ block: true })}
>
{t('home.next.reviewCta')}
</Link>
</Card>
);
case 'deadline_soon':
return (
<Card className="space-y-3 border-attention">
<p className="font-medium text-attention">
{t('home.next.upcoming', {
date: action.dueDate ? formatDateLong(locale, action.dueDate) : '',
})}
</p>
<Link href="/vencimientos" className={buttonClasses({ block: true })}>
{t('home.next.deadlineCta')}
</Link>
</Card>
);
case 'bandeja':
return (
<Card className="space-y-3">
<p className="font-medium">{t('home.next.bandeja', { count: action.count ?? 0 })}</p>
<Link href="/bandeja" className={buttonClasses({ block: true })}>
{t('home.next.bandejaCta')}
</Link>
</Card>
);
case 'all_clear':
return (
<Card className="space-y-1">
<p className="font-medium text-positive">{t('home.next.allClear')}</p>
{action.dueDate ? (
<p className="text-sm text-[var(--text-muted)]">
{t('home.next.upcoming', { date: formatDateLong(locale, action.dueDate) })}
</p>
) : null}
</Card>
);
}
}
function InsightFeed({
insights,
locale,
}: {
insights: DashboardDto['insights'];
locale: Locale;
}) {
const t = useT();
const queryClient = useQueryClient();
const dismiss = useMutation({
mutationFn: (id: string) => api.dismissInsight(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
});
if (insights.length === 0) return null;
return (
<ul className="space-y-3">
{insights.map((insight) => (
<li key={insight.id}>
<Card className="space-y-2">
<p className="font-medium">{insightTitle(insight, t)}</p>
<p className="text-sm text-[var(--text-muted)] text-pretty">
{insightBody(insight, t, locale)}
</p>
<Button
type="button"
variant="ghost"
disabled={dismiss.isPending}
onClick={() => dismiss.mutate(insight.id)}
>
<X aria-hidden className="size-4" />
{t('home.insight.dismiss')}
</Button>
</Card>
</li>
))}
</ul>
);
}
type Translate = ReturnType<typeof useT>;
function insightTitle(insight: DashboardDto['insights'][number], t: Translate): string {
if (insight.kind === 'deduction_gap') return t('home.insight.gapTitle');
if (insight.kind === 'month_close') return t('decl.status.ready');
return t('vto.title');
}
function insightBody(
insight: DashboardDto['insights'][number],
t: Translate,
locale: Locale,
): string {
if (insight.kind === 'deduction_gap') {
const category = String(insight.params['category'] ?? 'none');
return t('home.insight.gapBody', {
category: t(`categories.${category}` as 'categories.none'),
});
}
if (insight.kind === 'month_close') {
return t('home.insight.monthClose', {
month: formatMonthName(locale, String(insight.params['month'] ?? '')),
savings: formatGsAmount(Number(insight.params['savings'] ?? 0)),
});
}
return t('home.insight.deadline', {
form: String(insight.params['form'] ?? '120'),
date: formatDateLong(locale, String(insight.params['date'] ?? '')),
});
}
export { formatGs };
+7 -36
View File
@@ -1,22 +1,18 @@
import { isApiError } from '@impuestos/contracts';
import { ScanLine } from 'lucide-react';
import type { Locale } from '@impuestos/i18n';
import { setRequestLocale } from 'next-intl/server';
import { buttonClasses } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { EmptyState } from '@/components/ui/empty-state';
import { Link, redirect } from '@/i18n/navigation';
import { getT } from '@/i18n/t';
import { redirect } from '@/i18n/navigation';
import { serverApi } from '@/lib/api-server';
import { Dashboard } from './dashboard';
/**
* Flow A6, the first run state. The dashboard proper (Flow C, with the position header,
* next action strip and insight feed) replaces this body in phase 4; deadlines come from
* the deadline engine then, not from a second copy of the rules in the browser.
* Flow C. The profile is fetched server side so an unfinished setup redirects before
* anything renders; the position itself loads on the client, where TanStack Query keeps it
* fresh as documents are confirmed.
*/
export default async function InicioPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getT(locale);
const api = await serverApi();
const profile = await api.getProfile().catch((error: unknown) => {
@@ -30,30 +26,5 @@ export default async function InicioPage({ params }: { params: Promise<{ locale:
// empty shell they cannot act on.
if (!profile) redirect({ href: '/configuracion', locale });
return (
<div className="space-y-6 py-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('home.title')}</h1>
<p className="text-sm text-[var(--text-muted)]">{profile?.fullName}</p>
</div>
<Card>
<EmptyState
icon={<ScanLine aria-hidden className="size-8" />}
title={t('home.firstRun.title')}
body={t('home.firstRun.body')}
action={
<div className="flex flex-col items-center gap-2">
<Link href="/escanear" className={buttonClasses({ size: 'lg' })}>
{t('home.firstRun.scan')}
</Link>
<Link href="/comprobantes/nuevo" className="text-sm text-accent-700 hover:underline">
{t('home.firstRun.manual')}
</Link>
</div>
}
/>
</Card>
</div>
);
return <Dashboard locale={locale as Locale} fullName={profile?.fullName ?? ''} />;
}
+4 -1
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react';
import { AppShell } from '@/components/app-shell';
import { LanguageSwitcher } from '@/components/language-switcher';
import { Link } from '@/i18n/navigation';
import { useT } from '@/i18n/t';
@@ -18,7 +19,9 @@ export default function AppLayout({ children }: { children: ReactNode }) {
</Link>
</div>
</header>
<main className="flex-1 px-5 pb-16">{children}</main>
{/* Bottom padding clears the tab bar and the scan button above it. */}
<main className="flex-1 px-5 pb-36">{children}</main>
<AppShell />
</div>
);
}
@@ -0,0 +1,124 @@
'use client';
import type { DeadlineDto } from '@impuestos/contracts';
import { formatDateLong, type Locale } from '@impuestos/i18n';
import { deadlineDay } from '@impuestos/rules';
import { useQuery } from '@tanstack/react-query';
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 { cn } from '@/lib/utils';
/** Flow E2: a vertical timeline of everything coming, with the day rule explained. */
export function DeadlinesScreen({ locale }: { locale: string }) {
const t = useT();
const profile = useQuery({ queryKey: ['me'], queryFn: ({ signal }) => api.getProfile(signal) });
const deadlines = useQuery({
queryKey: ['deadlines'],
queryFn: ({ signal }) => api.getUpcomingDeadlines(12, signal),
});
return (
<div className="space-y-4 py-4">
<h1 className="text-2xl font-semibold tracking-tight">{t('vto.title')}</h1>
{profile.data ? (
<Card>
<p className="text-sm text-[var(--text-muted)] text-pretty">
{t('vto.explainer', {
digit: profile.data.deadlineDigit,
day: deadlineDay(profile.data.deadlineDigit),
})}
</p>
</Card>
) : null}
{deadlines.isPending ? (
<Card>
<Skeleton name="doc-list-item" count={6} />
</Card>
) : null}
{deadlines.isError ? (
<Card className="space-y-3">
<p role="alert" className="text-sm">
{t('common.error.generic')}
</p>
<Button type="button" onClick={() => void deadlines.refetch()}>
{t('common.retry')}
</Button>
</Card>
) : null}
{deadlines.data?.length === 0 ? (
<Card>
<p className="text-sm text-[var(--text-muted)]">{t('vto.empty')}</p>
</Card>
) : null}
{deadlines.data && deadlines.data.length > 0 ? (
<ol className="space-y-3">
{deadlines.data.map((deadline) => (
<li key={`${deadline.obligation}-${deadline.period}`}>
<DeadlineRow deadline={deadline} locale={locale as Locale} />
</li>
))}
</ol>
) : null}
</div>
);
}
function DeadlineRow({ deadline, locale }: { deadline: DeadlineDto; locale: Locale }) {
const t = useT();
const days = daysUntil(deadline.dueDate);
return (
<Card className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<p className="font-medium">{t(`obligation.${deadline.obligation}` as const)}</p>
<p className="text-sm text-[var(--text-muted)]">{deadline.period}</p>
<p className="text-sm">{formatDateLong(locale, deadline.dueDate)}</p>
</div>
<div className="shrink-0 space-y-1 text-right">
<DeadlinePill status={deadline.status} label={t(`vto.status.${deadline.status}` as const)} />
{deadline.status !== 'done' ? (
<p className="text-xs text-[var(--text-muted)]">
{days === 0
? t('vto.today')
: days > 0
? t('vto.daysLeft', { days })
: t('vto.daysLate', { days: Math.abs(days) })}
</p>
) : null}
</div>
</Card>
);
}
/** Overdue is the only thing that earns red (FLOWS.md section 1). */
function DeadlinePill({ status, label }: { status: DeadlineDto['status']; label: string }) {
return (
<span
className={cn(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
status === 'done' && 'bg-positive-soft text-positive',
status === 'due_soon' && 'bg-attention-soft text-attention',
status === 'overdue' && 'bg-overdue-soft text-overdue',
status === 'upcoming' && 'bg-[var(--surface)] text-[var(--text-muted)]',
)}
>
{label}
</span>
);
}
function daysUntil(isoDate: string): number {
const today = new Date();
const startOfToday = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
return Math.round((Date.parse(`${isoDate}T00:00:00Z`) - startOfToday) / 86_400_000);
}
@@ -0,0 +1,8 @@
import { setRequestLocale } from 'next-intl/server';
import { DeadlinesScreen } from './deadlines-screen';
export default async function DeadlinesPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
setRequestLocale(locale);
return <DeadlinesScreen locale={locale} />;
}
+67
View File
@@ -0,0 +1,67 @@
'use client';
import { FileText, Home, Inbox, Plus, CalendarClock } from 'lucide-react';
import type { ComponentType } from 'react';
import { Link, usePathname } from '@/i18n/navigation';
import { useT } from '@/i18n/t';
import { cn } from '@/lib/utils';
const TABS: { href: string; key: 'inicio' | 'bandeja' | 'comprobantes' | 'vencimientos'; Icon: ComponentType<{ className?: string }> }[] = [
{ href: '/inicio', key: 'inicio', Icon: Home },
{ href: '/bandeja', key: 'bandeja', Icon: Inbox },
{ href: '/comprobantes', key: 'comprobantes', Icon: FileText },
{ href: '/vencimientos', key: 'vencimientos', Icon: CalendarClock },
];
/**
* The tab bar and the scan button that sits above it, on every signed in screen
* (FLOWS.md B1). Scanning is the one action worth a permanent affordance: it is what the
* product is for.
*/
export function AppShell() {
const t = useT();
const pathname = usePathname();
return (
<>
<Link
href="/escanear"
aria-label={t('scan.fab')}
className={cn(
'fixed right-5 bottom-24 z-20 flex items-center gap-2 rounded-full bg-accent-600 px-5 py-3.5',
'font-medium text-white shadow-lg transition-colors hover:bg-accent-700',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600',
)}
>
<Plus aria-hidden className="size-5" />
{t('scan.fab')}
</Link>
<nav
aria-label={t('common.appName')}
className="fixed inset-x-0 bottom-0 z-10 border-t bg-[var(--surface-raised)]"
>
<ul className="mx-auto flex max-w-2xl">
{TABS.map(({ href, key, Icon }) => {
const active = pathname === href || pathname.startsWith(`${href}/`);
return (
<li key={href} className="flex-1">
<Link
href={href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex flex-col items-center gap-1 py-2.5 text-xs transition-colors',
active ? 'text-accent-700' : 'text-[var(--text-muted)]',
)}
>
<Icon aria-hidden className="size-5" />
{t(`nav.${key}` as const)}
</Link>
</li>
);
})}
</ul>
</nav>
</>
);
}
@@ -0,0 +1,100 @@
'use client';
import type { TraceKind } from '@impuestos/contracts';
import { formatDateShort, formatGs, type Locale } from '@impuestos/i18n';
import { useQuery } from '@tanstack/react-query';
import { useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Link } from '@/i18n/navigation';
import { useT } from '@/i18n/t';
import { api } from '@/lib/api';
import { cn } from '@/lib/utils';
/**
* A money figure that opens the documents behind it (FLOWS.md C1). Nothing on the
* dashboard is a number the user cannot get underneath.
*/
export function TraceableNumber({
value,
kind,
locale,
size = 'lg',
className,
}: {
value: number;
kind: TraceKind;
locale: Locale;
size?: 'md' | 'lg';
className?: string;
}) {
const t = useT();
const dialog = useRef<HTMLDialogElement>(null);
const trace = useQuery({
queryKey: ['dashboard', 'trace', kind],
queryFn: ({ signal }) => api.getTrace(kind, signal),
enabled: false,
});
return (
<>
<button
type="button"
onClick={() => {
void trace.refetch();
dialog.current?.showModal();
}}
className={cn(
'tnum text-left underline decoration-dotted underline-offset-4',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600',
size === 'lg' ? 'text-4xl font-semibold tracking-tight' : 'font-medium',
className,
)}
>
{formatGs(value)}
</button>
<dialog
ref={dialog}
aria-label={t('home.trace.title')}
className="m-auto w-[min(32rem,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('home.trace.title')}</h2>
{trace.isFetching ? <Skeleton name="doc-list-item" count={4} /> : null}
{trace.data && trace.data.items.length === 0 ? (
<p className="text-sm text-[var(--text-muted)]">{t('home.trace.empty')}</p>
) : null}
{trace.data && trace.data.items.length > 0 ? (
<ul className="max-h-80 divide-y overflow-y-auto">
{trace.data.items.map((item) => (
<li key={item.documentId}>
<Link
href={`/comprobantes/${item.documentId}`}
className="flex items-center justify-between gap-4 py-3 text-sm"
>
<span className="min-w-0">
<span className="block truncate font-medium">{item.emitterName}</span>
<span className="text-[var(--text-muted)]">
{formatDateShort(locale, item.issueDate)}
</span>
</span>
<span className="tnum shrink-0">{formatGs(item.amount)}</span>
</Link>
</li>
))}
</ul>
) : null}
<Button type="button" variant="secondary" block onClick={() => dialog.current?.close()}>
{t('common.close')}
</Button>
</div>
</dialog>
</>
);
}