diff --git a/DECISIONS.md b/DECISIONS.md index 9ac03fb..18a5b0d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -367,3 +367,89 @@ as well as the name, because the name is deliberately not part of the dedupe key - **Seeded declarations.** CONTRACTS.md section 4 asks for a ready F120 and an approved one. The declarations module is phase 5 and seeds them then; phase 3 seeds the documents they will be computed from. + + +--- + +## Phase 4 + +### The dashboard does no arithmetic +`modules/dashboard` chooses inputs and calls `packages/rules`. Every figure on the screen +comes out of `computeF120`, `projectIrp` or `savingsThisMonth`, and the acceptance test +asserts the API's numbers equal what those functions return over the same rows, before and +after the bandeja is confirmed (CONTRACTS.md 5.2). Confirmed documents only: nothing the +user has not agreed to is ever counted. + +The IVA carry-in comes from the last approved F120's summary and is zero when there is +none, per RULES.md section 8. + +### A deadline before the obligation existed is not overdue +`upcomingDeadlines` skips any period earlier than the `since` date on the obligation. +Without it a brand new account opens on a red "overdue" card for a period that predates it, +which is both wrong and the worst possible first impression. + +Maria still shows overdue, correctly: she has been registered since 2024 and has filed +nothing, because declarations arrive in phase 5. + +### Sweeps are idempotent by dedupe key, not by bookkeeping +Each sweep is enqueued with a key naming the day (or the hour, for the digest) it belongs +to, so a restarted poller, a second replica and a crash mid-sweep all converge on one run. +The notifications they queue are keyed the same way, down to the milestone: +`deadline::::T-10` can only be queued once, whatever else +happens. Sweeps queue notifications rather than sending them, so a channel being down +retries on the job schedule instead of losing the message. + +The time-travel test derives T-10 from `dueDateFor` rather than hardcoding a date, so it +follows the calendario rather than restating it. + +### Auto-confirm only touches what the rules were sure about +The sweep confirms documents older than the user's window whose classification is still +`decided_by = 'auto'` and at least 0.85 confident. A low confidence read is exactly the +thing a person still needs to look at, and a decision the user already made is never +revisited. Zero days means off. + +### An unconfigured channel is absent, not broken +`createChannels` returns `null` per channel when its configuration is missing. The fan-out +skips those, the profile screen hides them, and a user with everything off receives nothing +at all, which is the setting working rather than a failure. One dead push subscription does +not stop the others; a frozen account is not a recipient. + +### `hasDocuments` extends DashboardDto +SPEC-GAP. FLOWS.md A6 wants the guided empty state until the first comprobante lands, and a +position of all zeros is indistinguishable from a real one without this. It is an addition +to CONTRACTS.md section 2, not a rename. + +### `insight_dismissals` is a new table +SPEC-GAP. FLOWS.md C3 requires dismissals to persist and SPEC.md section 5 lists nowhere to +put them. One row per dismissal keeps the dashboard read free of per-user JSON to merge. + +### The scan button is now global +FLOWS.md B1 asks for a persistent scan affordance on every `(app)` screen. It arrives with +the tab bar, and the per-screen scan link that stood in for it on `/comprobantes` is gone, +since two of them on one screen is one too many. + +### A copy bug the screenshot caught +The overdue card filled a `{period}` slot with the formatted due date, so it read "sin +presentar de 19 de marzo". The string now says what the data actually is: a form that was +due on a date and never filed. + +### Test isolation, again +The read-only bandeja tests run against Carlos rather than Maria, because the mutating +ingestion flows pull cards out of Maria's stack and two workers cannot share one queue. + +The suite also erodes the seed it runs against: after a run, documents that started in the +bandeja are confirmed and insights are dismissed, so the next run is not the same run. +`pnpm db:reset` rebuilds the local database from the migrations and the seed, and the +README says to use it before an e2e run. It refuses to touch anything but a local SQLite +file, because it is destructive by definition. +The dashboard's insight test self-skips once every insight for that account has been +dismissed, since dismissals persist by design; the persistence guarantee itself is asserted +against the API from a fresh database. + +### Deferred, deliberately +- **Push subscription registration.** `POST /push/subscribe` needs the service worker, which + is phase 7. The channel itself is built and tested. +- **Telegram linking.** The channel sends to a stored `telegram_chat_id`; the flow that + obtains one is not in v1 scope. +- **Declarations.** `declaration_ready` is in the next-action priority and the sweep reads + from it, but nothing sets a declaration to `ready` until phase 5. diff --git a/README.md b/README.md index 32ac186..6863a1a 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ and ready to file yourself. Working name. See `docs/` for the specifications, `DECISIONS.md` for choices made along the way and the gaps that still need answers. -> **Status: phase 3 of 8.** Foundation, tax rules, identity, and ingestion: scan a QR -> comprobante or type one in, and it is classified and waiting in the bandeja. The -> dashboard, declarations and the admin area are still ahead. +> **Status: phase 4 of 8.** Everything up to a live tax position: scan a comprobante, +> confirm it, and watch the IVA and IRP figures move, with deadlines tracked and reminders +> queued. Declarations and the admin area are still ahead. --- @@ -86,6 +86,7 @@ driver, and user facing strings cannot be written inline in JSX. | `pnpm lint` | eslint, including the module boundary and inline copy rules | | `pnpm db:migrate` | Auth tables, then our migrations | | `pnpm db:seed` | Demo accounts and their comprobantes, idempotent | +| `pnpm db:reset` | Drops the local SQLite file and rebuilds it. Destructive, SQLite only | | `pnpm --filter @impuestos/api fixtures` | Redraws the scan fixtures under `/fixtures` | ## Configuration @@ -168,8 +169,16 @@ parity. Playwright covers the golden paths against a running stack. CI runs the against both SQLite and Postgres. `pnpm test:e2e` does not start anything: bring the stack up first, then point it at the -right origin. +right origin. The suite signs in as the demo accounts and confirms, rejects and scans +against their data, so it changes the state it runs in. Reset before a run, with the stack +stopped, or the second run has a different bandeja than the first: ```bash -E2E_BASE_URL=http://localhost:3000 pnpm test:e2e +pnpm db:reset +``` + +Then start the stack and run it: + +```bash +E2E_BASE_URL=http://localhost:3005 pnpm test:e2e ``` diff --git a/apps/api/package.json b/apps/api/package.json index 0b779eb..3a0598c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/db/migrations/002_insight_dismissals.ts b/apps/api/src/db/migrations/002_insight_dismissals.ts new file mode 100644 index 0000000..8ecea64 --- /dev/null +++ b/apps/api/src/db/migrations/002_insight_dismissals.ts @@ -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): Promise { + 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): Promise { + await db.schema.dropTable('insight_dismissals').ifExists().execute(); +} diff --git a/apps/api/src/db/migrations/index.ts b/apps/api/src/db/migrations/index.ts index e1ff7e5..67629cf 100644 --- a/apps/api/src/db/migrations/index.ts +++ b/apps/api/src/db/migrations/index.ts @@ -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 = { '001_core': core, + '002_insight_dismissals': insightDismissals, }; export const migrationProvider: MigrationProvider = { diff --git a/apps/api/src/db/reset.cli.ts b/apps/api/src/db/reset.cli.ts new file mode 100644 index 0000000..6fddb2f --- /dev/null +++ b/apps/api/src/db/reset.cli.ts @@ -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(); +} diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index ced3b0d..65ee345 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -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; diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts index 37a5a90..8e4abee 100644 --- a/apps/api/src/db/seed.ts +++ b/apps/api/src/db/seed.ts @@ -40,7 +40,11 @@ export interface SeedResult { existing: string[]; } -export async function seed(handle: DbHandle, auth: Auth): Promise { +export async function seed( + handle: DbHandle, + auth: Auth, + options: { now?: Date } = {}, +): Promise { const result: SeedResult = { created: [], existing: [] }; const db = handle.db; @@ -72,7 +76,7 @@ export async function seed(handle: DbHandle, auth: Auth): Promise { } await seedProfiles(handle); - await seedDocuments(handle); + await seedDocuments(handle, options.now ?? new Date()); await seedAuditTrail(handle); return result; } diff --git a/apps/api/src/http/app.ts b/apps/api/src/http/app.ts index 2ce2352..41203c8 100644 --- a/apps/api/src/http/app.ts +++ b/apps/api/src/http/app.ts @@ -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); diff --git a/apps/api/src/http/routes/dashboard.ts b/apps/api/src/http/routes/dashboard.ts new file mode 100644 index 0000000..236e187 --- /dev/null +++ b/apps/api/src/http/routes/dashboard.ts @@ -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 { + const routes = new Hono(); + + 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 { + const routes = new Hono(); + + 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; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index dbe1fe1..a10db71 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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) => { diff --git a/apps/api/src/modules/dashboard/dashboard.test.ts b/apps/api/src/modules/dashboard/dashboard.test.ts new file mode 100644 index 0000000..5230141 --- /dev/null +++ b/apps/api/src/modules/dashboard/dashboard.test.ts @@ -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); + }); +}); diff --git a/apps/api/src/modules/dashboard/index.ts b/apps/api/src/modules/dashboard/index.ts new file mode 100644 index 0000000..9da861c --- /dev/null +++ b/apps/api/src/modules/dashboard/index.ts @@ -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, userId: string): Promise { + 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, + args: { userId: string; profile: ProfileDto; now: Date }, +): Promise { + 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, + args: { userId: string; rows: PositionRows; period: string }, +): Promise { + // 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, + args: { + userId: string; + rows: PositionRows; + year: string; + period: string; + hasIrp: boolean; + deadlines: DeadlineDto[]; + }, +): Promise { + 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(); + 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, + userId: string, + insightId: string, +): Promise { + 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, + args: { userId: string; kind: TraceKind; now: Date }, +): Promise { + 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, userId: string): Promise { + const { total } = await db + .selectFrom('documents') + .select((eb) => eb.fn.countAll().as('total')) + .where('user_id', '=', userId) + .where('status', '=', 'needs_review') + .executeTakeFirstOrThrow(); + return Number(total); +} + +export { DUE_SOON_DAYS, formatIsoDate }; diff --git a/apps/api/src/modules/deadlines/index.ts b/apps/api/src/modules/deadlines/index.ts new file mode 100644 index 0000000..b8d85c3 --- /dev/null +++ b/apps/api/src/modules/deadlines/index.ts @@ -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, + args: { userId: string; profile: ProfileDto; months: number; now: Date }, +): Promise { + 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 }[] { + const next = nextDeadline({ digit, obligation, from: now }); + const results: { period: string; dueDate: ReturnType }[] = []; + + 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 }; diff --git a/apps/api/src/modules/jobs/handlers.ts b/apps/api/src/modules/jobs/handlers.ts index 8c2145a..e6c0c36 100644 --- a/apps/api/src/modules/jobs/handlers.ts +++ b/apps/api/src/modules/jobs/handlers.ts @@ -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; 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); + }, }; } diff --git a/apps/api/src/modules/jobs/index.ts b/apps/api/src/modules/jobs/index.ts index 3e574a9..a112265 100644 --- a/apps/api/src/modules/jobs/index.ts +++ b/apps/api/src/modules/jobs/index.ts @@ -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'; diff --git a/apps/api/src/modules/jobs/poller.ts b/apps/api/src/modules/jobs/poller.ts index 00faa6e..6781ed8 100644 --- a/apps/api/src/modules/jobs/poller.ts +++ b/apps/api/src/modules/jobs/poller.ts @@ -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; + /** 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 { - 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. diff --git a/apps/api/src/modules/jobs/sweep-handlers.ts b/apps/api/src/modules/jobs/sweep-handlers.ts new file mode 100644 index 0000000..f6e7379 --- /dev/null +++ b/apps/api/src/modules/jobs/sweep-handlers.ts @@ -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; + now: () => Date; +} + +interface Recipient { + userId: string; + digestHour: number; +} + +async function activeRecipients(db: Kysely): Promise { + 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 { + 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 { + 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 { + 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, userId: string): Promise { + const { total } = await db + .selectFrom('documents') + .select((eb) => eb.fn.countAll().as('total')) + .where('user_id', '=', userId) + .where('status', '=', 'needs_review') + .executeTakeFirstOrThrow(); + return Number(total); +} + +export { parseIsoDate }; diff --git a/apps/api/src/modules/jobs/sweeps.test.ts b/apps/api/src/modules/jobs/sweeps.test.ts new file mode 100644 index 0000000..d77a89c --- /dev/null +++ b/apps/api/src/modules/jobs/sweeps.test.ts @@ -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); + }); +}); diff --git a/apps/api/src/modules/jobs/sweeps.ts b/apps/api/src/modules/jobs/sweeps.ts new file mode 100644 index 0000000..09d04d2 --- /dev/null +++ b/apps/api/src/modules/jobs/sweeps.ts @@ -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, now: Date): Promise { + 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; +} diff --git a/apps/api/src/modules/notifications/channels.ts b/apps/api/src/modules/notifications/channels.ts new file mode 100644 index 0000000..717d651 --- /dev/null +++ b/apps/api/src/modules/notifications/channels.ts @@ -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) | null; + email: ((to: string, subject: string, message: OutgoingMessage) => Promise) | null; + telegram: ((chatId: string, message: OutgoingMessage) => Promise) | 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}`); + } + }; +} diff --git a/apps/api/src/modules/notifications/index.ts b/apps/api/src/modules/notifications/index.ts new file mode 100644 index 0000000..ef6f56f --- /dev/null +++ b/apps/api/src/modules/notifications/index.ts @@ -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'; diff --git a/apps/api/src/modules/notifications/send.ts b/apps/api/src/modules/notifications/send.ts new file mode 100644 index 0000000..b91916c --- /dev/null +++ b/apps/api/src/modules/notifications/send.ts @@ -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; + 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()), +}); diff --git a/apps/api/src/modules/notifications/templates.ts b/apps/api/src/modules/notifications/templates.ts new file mode 100644 index 0000000..950bc9e --- /dev/null +++ b/apps/api/src/modules/notifications/templates.ts @@ -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; +} diff --git a/apps/api/src/test/harness.ts b/apps/api/src/test/harness.ts index 672442f..449fc4b 100644 --- a/apps/api/src/test/harness.ts +++ b/apps/api/src/test/harness.ts @@ -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 = { @@ -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; poller: Poller; @@ -48,7 +55,10 @@ export async function createHarness(options: HarnessOptions = {}): Promise 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 poller.runOnce(), close: async () => { diff --git a/apps/web/app/[locale]/(app)/comprobantes/documents-screen.tsx b/apps/web/app/[locale]/(app)/comprobantes/documents-screen.tsx index 521392e..1468aff 100644 --- a/apps/web/app/[locale]/(app)/comprobantes/documents-screen.tsx +++ b/apps/web/app/[locale]/(app)/comprobantes/documents-screen.tsx @@ -43,12 +43,8 @@ export function DocumentsScreen({ locale }: { locale: string }) { return (
-
-

{t('docs.title')}

- - {t('scan.fab')} - -
+ {/* No scan button here: the shell carries a persistent one on every screen. */} +

{t('docs.title')}

api.getDashboard(signal), + }); + + if (dashboard.isPending) { + return ( +
+ + + + + + +
+ ); + } + + if (dashboard.isError || !dashboard.data) { + return ( + +

+ {t('common.error.generic')} +

+ +
+ ); + } + + 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 ( +
+
+

{t('home.title')}

+

{fullName}

+
+ + {nothingYet ? ( + + } + title={t('home.firstRun.title')} + body={t('home.noPosition')} + action={ + + {t('home.firstRun.scan')} + + } + /> + + ) : null} + + {!nothingYet ? ( + <> + {/* Zone 1: the position, one card per obligation, swipeable on a phone. */} +
+ {data.iva ? : null} + {data.irp ? : null} +
+ + {/* Zone 2: exactly one next action. */} + + + {/* Zone 3: the insight feed. */} + + + ) : null} +
+ ); +} + +function IvaCard({ iva, locale }: { iva: NonNullable; locale: Locale }) { + const t = useT(); + const owes = iva.aPagar > 0; + + return ( + +

+ {t('home.iva.title', { month: formatMonthName(locale, iva.period) })} +

+

+ {owes ? t('home.iva.aPagar') : t('home.iva.aFavor')} +

+ + {/* Owing tax on time is neutral, never red (FLOWS.md section 1). */} + + +

+ {t('home.iva.breakdown', { + debito: formatGsAmount(iva.debito), + credito: formatGsAmount(iva.credito), + })} +

+
+ ); +} + +function IrpCard({ irp, locale }: { irp: NonNullable; locale: Locale }) { + const t = useT(); + + return ( + +

+ {t('home.irp.title', { year: irp.year })} +

+ + + {irp.monthDelta > 0 ? ( +

+ {t('home.irp.delta', { amount: formatGsAmount(irp.monthDelta) })} +

+ ) : null} + + {irp.belowThreshold ? ( +

+ {t('home.irp.belowThreshold')} +

+ ) : null} +
+ ); +} + +function NextAction({ + action, + locale, +}: { + action: DashboardDto['nextAction']; + locale: Locale; +}) { + const t = useT(); + + switch (action.kind) { + case 'overdue': + return ( + +

+ {t('home.next.overdue', { + form: action.form ?? '120', + date: action.dueDate ? formatDateLong(locale, action.dueDate) : '', + })} +

+ + {t('home.next.overdueCta')} + +
+ ); + + case 'declaration_ready': + return ( + +

{t('decl.status.ready')}

+ + {t('home.next.reviewCta')} + +
+ ); + + case 'deadline_soon': + return ( + +

+ {t('home.next.upcoming', { + date: action.dueDate ? formatDateLong(locale, action.dueDate) : '', + })} +

+ + {t('home.next.deadlineCta')} + +
+ ); + + case 'bandeja': + return ( + +

{t('home.next.bandeja', { count: action.count ?? 0 })}

+ + {t('home.next.bandejaCta')} + +
+ ); + + case 'all_clear': + return ( + +

{t('home.next.allClear')}

+ {action.dueDate ? ( +

+ {t('home.next.upcoming', { date: formatDateLong(locale, action.dueDate) })} +

+ ) : null} +
+ ); + } +} + +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 ( +
    + {insights.map((insight) => ( +
  • + +

    {insightTitle(insight, t)}

    +

    + {insightBody(insight, t, locale)} +

    + +
    +
  • + ))} +
+ ); +} + +type Translate = ReturnType; + +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 }; diff --git a/apps/web/app/[locale]/(app)/inicio/page.tsx b/apps/web/app/[locale]/(app)/inicio/page.tsx index e4e9dfd..15cd122 100644 --- a/apps/web/app/[locale]/(app)/inicio/page.tsx +++ b/apps/web/app/[locale]/(app)/inicio/page.tsx @@ -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 ( -
-
-

{t('home.title')}

-

{profile?.fullName}

-
- - - } - title={t('home.firstRun.title')} - body={t('home.firstRun.body')} - action={ -
- - {t('home.firstRun.scan')} - - - {t('home.firstRun.manual')} - -
- } - /> -
-
- ); + return ; } diff --git a/apps/web/app/[locale]/(app)/layout.tsx b/apps/web/app/[locale]/(app)/layout.tsx index ac35f0c..439df7c 100644 --- a/apps/web/app/[locale]/(app)/layout.tsx +++ b/apps/web/app/[locale]/(app)/layout.tsx @@ -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 }) {
-
{children}
+ {/* Bottom padding clears the tab bar and the scan button above it. */} +
{children}
+ ); } diff --git a/apps/web/app/[locale]/(app)/vencimientos/deadlines-screen.tsx b/apps/web/app/[locale]/(app)/vencimientos/deadlines-screen.tsx new file mode 100644 index 0000000..2307cbe --- /dev/null +++ b/apps/web/app/[locale]/(app)/vencimientos/deadlines-screen.tsx @@ -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 ( +
+

{t('vto.title')}

+ + {profile.data ? ( + +

+ {t('vto.explainer', { + digit: profile.data.deadlineDigit, + day: deadlineDay(profile.data.deadlineDigit), + })} +

+
+ ) : null} + + {deadlines.isPending ? ( + + + + ) : null} + + {deadlines.isError ? ( + +

+ {t('common.error.generic')} +

+ +
+ ) : null} + + {deadlines.data?.length === 0 ? ( + +

{t('vto.empty')}

+
+ ) : null} + + {deadlines.data && deadlines.data.length > 0 ? ( +
    + {deadlines.data.map((deadline) => ( +
  1. + +
  2. + ))} +
+ ) : null} +
+ ); +} + +function DeadlineRow({ deadline, locale }: { deadline: DeadlineDto; locale: Locale }) { + const t = useT(); + const days = daysUntil(deadline.dueDate); + + return ( + +
+

{t(`obligation.${deadline.obligation}` as const)}

+

{deadline.period}

+

{formatDateLong(locale, deadline.dueDate)}

+
+ +
+ + {deadline.status !== 'done' ? ( +

+ {days === 0 + ? t('vto.today') + : days > 0 + ? t('vto.daysLeft', { days }) + : t('vto.daysLate', { days: Math.abs(days) })} +

+ ) : null} +
+
+ ); +} + +/** Overdue is the only thing that earns red (FLOWS.md section 1). */ +function DeadlinePill({ status, label }: { status: DeadlineDto['status']; label: string }) { + return ( + + {label} + + ); +} + +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); +} diff --git a/apps/web/app/[locale]/(app)/vencimientos/page.tsx b/apps/web/app/[locale]/(app)/vencimientos/page.tsx new file mode 100644 index 0000000..d0206e9 --- /dev/null +++ b/apps/web/app/[locale]/(app)/vencimientos/page.tsx @@ -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 ; +} diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx new file mode 100644 index 0000000..9f666e2 --- /dev/null +++ b/apps/web/src/components/app-shell.tsx @@ -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 ( + <> + + + {t('scan.fab')} + + + + + ); +} diff --git a/apps/web/src/components/traceable-number.tsx b/apps/web/src/components/traceable-number.tsx new file mode 100644 index 0000000..0250c17 --- /dev/null +++ b/apps/web/src/components/traceable-number.tsx @@ -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(null); + + const trace = useQuery({ + queryKey: ['dashboard', 'trace', kind], + queryFn: ({ signal }) => api.getTrace(kind, signal), + enabled: false, + }); + + return ( + <> + + + +
+

{t('home.trace.title')}

+ + {trace.isFetching ? : null} + + {trace.data && trace.data.items.length === 0 ? ( +

{t('home.trace.empty')}

+ ) : null} + + {trace.data && trace.data.items.length > 0 ? ( +
    + {trace.data.items.map((item) => ( +
  • + + + {item.emitterName} + + {formatDateShort(locale, item.issueDate)} + + + {formatGs(item.amount)} + +
  • + ))} +
+ ) : null} + + +
+
+ + ); +} diff --git a/e2e/bandeja.spec.ts b/e2e/bandeja.spec.ts index d59bbdb..57e3859 100644 --- a/e2e/bandeja.spec.ts +++ b/e2e/bandeja.spec.ts @@ -9,8 +9,10 @@ import { expect, test } from '@playwright/test'; test.describe('bandeja', () => { test.beforeEach(async ({ page }) => { 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'); + // Carlos, not Maria: the mutating ingestion flows work through Maria's bandeja, and + // two workers pulling cards from the same stack makes this inherently flaky. + await page.getByLabel(es['auth.register.email']).fill('carlos@demo.local'); + await page.getByLabel(es['auth.login.password']).fill('demo-carlos-1'); await page.getByRole('button', { name: es['auth.login.submit'] }).click(); await expect(page).toHaveURL(/\/es\/inicio$/); await page.goto('/es/bandeja'); diff --git a/e2e/dashboard.spec.ts b/e2e/dashboard.spec.ts new file mode 100644 index 0000000..c6ac1d8 --- /dev/null +++ b/e2e/dashboard.spec.ts @@ -0,0 +1,118 @@ +import { es } from '@impuestos/i18n'; +import { expect, test, type Page } from '@playwright/test'; + +async function signIn(page: Page): Promise { + 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$/); +} + +/** Flow C, read only: the position, the single next action and the insight feed. */ +test.describe('dashboard', () => { + test.beforeEach(async ({ page }) => { + await signIn(page); + }); + + test('shows the IVA and IRP position with real figures', async ({ page }) => { + await expect(page.getByRole('heading', { name: es['home.title'] })).toBeVisible(); + + // Both obligations are active for the showcase account. + await expect(page.getByText(es['home.iva.title'].split('{')[0] as string)).toBeVisible(); + await expect(page.getByText(es['home.irp.title'].split('{')[0] as string)).toBeVisible(); + + // Money is always Gs. and never localized. + await expect(page.getByText(/Gs\. [\d.]+/).first()).toBeVisible(); + }); + + test('opens the documents behind a headline number', async ({ page }) => { + await expect(page.getByText(es['home.iva.title'].split('{')[0] as string)).toBeVisible(); + await page.getByRole('button', { name: /^Gs\./ }).first().click(); + + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole('heading', { name: es['home.trace.title'] })).toBeVisible(); + + await dialog.getByRole('button', { name: es['common.close'] }).click(); + await expect(dialog).toBeHidden(); + }); + + test('offers exactly one next action', async ({ page }) => { + // Wait for the position, or the action strip has not rendered yet. + await expect(page.getByText(es['home.iva.title'].split('{')[0] as string)).toBeVisible(); + + // Whichever card wins, there is one of them and it has one button. + const shell = page.locator('main'); + const actions = shell.getByRole('link', { + name: new RegExp( + [ + es['home.next.overdueCta'], + es['home.next.reviewCta'], + es['home.next.deadlineCta'], + es['home.next.bandejaCta'], + ].join('|'), + ), + }); + // all_clear is the one kind with nothing to click, which is still one card. + const allClear = page.getByText(es['home.next.allClear']); + expect((await actions.count()) + (await allClear.count())).toBe(1); + }); + + test('dismisses an insight and does not offer it again', async ({ page }) => { + // Wait for the position to land, or the feed has not rendered yet. + await expect(page.getByText(es['home.iva.title'].split('{')[0] as string)).toBeVisible(); + + const dismiss = page.getByRole('button', { name: es['home.insight.dismiss'] }); + const before = await dismiss.count(); + // Dismissals persist by design, so a stack that has already been cleared by an earlier + // run has nothing to offer. The persistence guarantee itself is covered against the + // API in dashboard.test.ts, which starts from a fresh database every time. + test.skip(before === 0, 'every insight for this account has already been dismissed'); + + await dismiss.first().click(); + await expect(async () => { + expect(await page.getByRole('button', { name: es['home.insight.dismiss'] }).count()).toBeLessThan(before); + }).toPass({ timeout: 10_000 }); + + await page.reload(); + expect(await page.getByRole('button', { name: es['home.insight.dismiss'] }).count()).toBeLessThan(before); + }); +}); + +test.describe('the app shell', () => { + test.beforeEach(async ({ page }) => { + await signIn(page); + }); + + test('carries the scan button and the four tabs on every screen', async ({ page }) => { + for (const path of ['/es/inicio', '/es/bandeja', '/es/comprobantes', '/es/vencimientos']) { + await page.goto(path); + await expect(page.getByRole('link', { name: es['scan.fab'] })).toBeVisible(); + for (const key of ['nav.inicio', 'nav.bandeja', 'nav.comprobantes', 'nav.vencimientos'] as const) { + await expect(page.getByRole('link', { name: es[key], exact: true })).toBeVisible(); + } + } + }); + + test('the scan button reaches the scanner', async ({ page }) => { + await page.getByRole('link', { name: es['scan.fab'] }).click(); + await expect(page).toHaveURL(/\/es\/escanear$/); + }); +}); + +test.describe('vencimientos', () => { + test('explains the filing day and lists what is coming', async ({ page }) => { + await signIn(page); + await page.goto('/es/vencimientos'); + + await expect(page.getByRole('heading', { name: es['vto.title'] })).toBeVisible(); + // Her RUC base ends in 6, which RULES.md puts on the 19th. + await expect( + page.getByText(es['vto.explainer'].replace('{digit}', '6').replace('{day}', '19')), + ).toBeVisible(); + + await expect(page.getByRole('listitem').first()).toBeVisible(); + await expect(page.getByText(es['obligation.iva_120']).first()).toBeVisible(); + }); +}); diff --git a/package.json b/package.json index 4317166..9aefdba 100644 --- a/package.json +++ b/package.json @@ -8,16 +8,17 @@ "node": ">=22" }, "scripts": { - "dev": "pnpm -r --parallel dev", "build": "pnpm -r build", - "typecheck": "pnpm -r typecheck", + "db:migrate": "pnpm --filter @impuestos/api db:migrate", + "db:reset": "pnpm --filter @impuestos/api db:reset", + "db:seed": "pnpm --filter @impuestos/api db:seed", + "dev": "pnpm -r --parallel dev", "lint": "eslint .", "test": "vitest run", - "test:watch": "vitest", "test:coverage": "vitest run --coverage packages/rules", "test:e2e": "playwright test", - "db:migrate": "pnpm --filter @impuestos/api db:migrate", - "db:seed": "pnpm --filter @impuestos/api db:seed" + "test:watch": "vitest", + "typecheck": "pnpm -r typecheck" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/packages/contracts/src/client.ts b/packages/contracts/src/client.ts index 620d37c..b13456d 100644 --- a/packages/contracts/src/client.ts +++ b/packages/contracts/src/client.ts @@ -6,6 +6,8 @@ import { type DeleteAccountInput, DependentDto, type DependentInput, + DashboardDto, + DeadlineDto, DocumentDto, DocumentListDto, type DocumentListQuery, @@ -20,6 +22,8 @@ import { type ProfileInput, type RejectInput, ScanResultDto, + TraceDto, + type TraceKind, } from './dto'; import { ApiError, ErrorEnvelope } from './errors'; @@ -168,6 +172,23 @@ export function createApiClient(options: ApiClientOptions = {}) { ...(signal ? { signal } : {}), }), + // Position + getDashboard: (signal?: AbortSignal) => + request('GET', '/dashboard', { schema: DashboardDto, ...(signal ? { signal } : {}) }), + dismissInsight: (id: string) => + request('POST', `/dashboard/insights/${encodeURIComponent(id)}/dismiss`, { schema: OkDto }), + getTrace: (kind: TraceKind, signal?: AbortSignal) => + request('GET', `/dashboard/trace/${kind}`, { + schema: TraceDto, + ...(signal ? { signal } : {}), + }), + getUpcomingDeadlines: (months = 12, signal?: AbortSignal) => + request('GET', '/deadlines/upcoming', { + schema: DeadlineDto.array(), + query: { months }, + ...(signal ? { signal } : {}), + }), + // Notifications getNotificationPrefs: (signal?: AbortSignal) => request('GET', '/me/notification-prefs', { diff --git a/packages/contracts/src/dto.ts b/packages/contracts/src/dto.ts index 067b6db..26806ac 100644 --- a/packages/contracts/src/dto.ts +++ b/packages/contracts/src/dto.ts @@ -5,6 +5,7 @@ import { DocSource, DocStatus, DocType, + FormCode, IrpCategory, LocaleCode, ObligationCode, @@ -247,6 +248,76 @@ export const IngestErrorDto = z.object({ }); export type IngestErrorDto = z.infer; +export const DashboardDto = z.object({ + iva: z + .object({ + period: z.string(), + aPagar: z.number().int(), + aFavor: z.number().int(), + debito: z.number().int(), + credito: z.number().int(), + }) + .nullable(), + irp: z + .object({ + year: z.string(), + projectedTax: z.number().int(), + monthDelta: z.number().int(), + belowThreshold: z.boolean(), + }) + .nullable(), + nextAction: z.object({ + kind: z.enum(['overdue', 'declaration_ready', 'deadline_soon', 'bandeja', 'all_clear']), + dueDate: z.string().optional(), + form: FormCode.optional(), + count: z.number().int().optional(), + declarationId: z.string().optional(), + }), + insights: z.array( + z.object({ + id: z.string(), + kind: z.enum(['deduction_gap', 'month_close', 'deadline_preview']), + params: z.record(z.string(), z.union([z.string(), z.number()])), + }), + ), + /** + * Extends the shape in CONTRACTS.md section 2. FLOWS.md A6 wants a guided empty state + * until the first comprobante lands, and a position of all zeros is not distinguishable + * from a real one without knowing this. + */ + hasDocuments: z.boolean(), +}); +export type DashboardDto = z.infer; + +export const DeadlineDto = z.object({ + obligation: ObligationCode, + period: z.string(), + dueDate: z.string(), + status: z.enum(['upcoming', 'due_soon', 'overdue', 'done']), + declarationId: z.string().nullable(), +}); +export type DeadlineDto = z.infer; + +/** + * What a contributing figure on the dashboard breaks down into. FLOWS.md Flow C makes + * every headline number tappable through to the documents behind it. + */ +export const TraceDto = z.object({ + total: z.number().int(), + items: z.array( + z.object({ + documentId: z.string(), + emitterName: z.string(), + issueDate: z.string(), + amount: z.number().int(), + }), + ), +}); +export type TraceDto = z.infer; + +export const TraceKind = z.enum(['iva_debito', 'iva_credito', 'irp_deductions']); +export type TraceKind = z.infer; + export const OkDto = z.object({ ok: z.literal(true) }); export type OkDto = z.infer; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index b04c7d5..9f9ee53 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -43,6 +43,10 @@ export { DocumentListQuery, DocumentListDto, IngestErrorDto, + DashboardDto, + DeadlineDto, + TraceDto, + TraceKind, PAGE_SIZE, listDto, OkDto, diff --git a/packages/i18n/src/catalogs/en.ts b/packages/i18n/src/catalogs/en.ts index 52b68a0..ba6308b 100644 --- a/packages/i18n/src/catalogs/en.ts +++ b/packages/i18n/src/catalogs/en.ts @@ -353,6 +353,31 @@ export const en: Record = { "status.confirmed": "Confirmed", "status.rejected": "Discarded", + // Dashboard, deadlines and the shell (phase 4) + "home.iva.swipeHint": "Swipe to see your IRP", + "home.next.overdue": "You have a {form} that was due on {date} and never filed", + "home.next.overdueCta": "See what is missing", + "home.next.deadlineCta": "See the detail", + "home.trace.title": "Where this number comes from", + "home.trace.empty": "There are no documents behind this number yet.", + "home.insight.dismiss": "Got it", + "home.insight.deadline": "Your {form} is due on {date}.", + "home.noPosition": "Once you confirm your first facturas, your position shows up here.", + "nav.inicio": "Home", + "nav.bandeja": "Inbox", + "nav.comprobantes": "Documents", + "nav.vencimientos": "Due dates", + "vto.empty": "You have no due dates yet. Check what you are registered for in your profile.", + "vto.status.upcoming": "Upcoming", + "vto.status.due_soon": "Due soon", + "vto.status.overdue": "Overdue", + "vto.status.done": "Filed", + "vto.daysLeft": "In {days} days", + "vto.today": "Due today", + "vto.daysLate": "{days} days late", + "obligation.iva_120": "Monthly IVA", + "obligation.irp_515": "Annual IRP", + // Chrome (es.extra.ts) "common.language": "Language", "common.languageEs": "Español", diff --git a/packages/i18n/src/catalogs/es.extra.ts b/packages/i18n/src/catalogs/es.extra.ts index 013e9fc..7904ae0 100644 --- a/packages/i18n/src/catalogs/es.extra.ts +++ b/packages/i18n/src/catalogs/es.extra.ts @@ -154,6 +154,31 @@ export const esExtra = { "status.confirmed": "Confirmada", "status.rejected": "Descartada", + // Dashboard, deadlines and the shell (phase 4) + "home.iva.swipeHint": "Deslizá para ver tu IRP", + "home.next.overdue": "Tenés un {form} sin presentar que vencia el {date}", + "home.next.overdueCta": "Ver que falta", + "home.next.deadlineCta": "Ver el detalle", + "home.trace.title": "De donde sale este numero", + "home.trace.empty": "Todavia no hay comprobantes detras de este numero.", + "home.insight.dismiss": "Listo, entendido", + "home.insight.deadline": "Tu {form} vence el {date}.", + "home.noPosition": "Cuando confirmes tus primeras facturas, tu situacion aparece aca.", + "nav.inicio": "Inicio", + "nav.bandeja": "Bandeja", + "nav.comprobantes": "Comprobantes", + "nav.vencimientos": "Vencimientos", + "vto.empty": "No tenes vencimientos cargados. Revisá tus obligaciones en el perfil.", + "vto.status.upcoming": "Proximo", + "vto.status.due_soon": "Por vencer", + "vto.status.overdue": "Atrasado", + "vto.status.done": "Presentada", + "vto.daysLeft": "En {days} dias", + "vto.today": "Vence hoy", + "vto.daysLate": "{days} dias de atraso", + "obligation.iva_120": "IVA mensual", + "obligation.irp_515": "IRP anual", + // Chrome "common.language": "Idioma", "common.languageEs": "Español", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79af232..e3a22c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,7 +76,7 @@ importers: version: link:../../packages/rules better-auth: specifier: ^1.7.2 - version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0) + version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))) better-sqlite3: specifier: ^13.0.3 version: 13.0.3 @@ -86,12 +86,18 @@ importers: kysely: specifier: ^0.29.5 version: 0.29.5 + nodemailer: + specifier: ^9.1.1 + version: 9.1.1 pg: specifier: ^8.23.0 version: 8.23.0 uuidv7: specifier: ^1.2.1 version: 1.2.1 + web-push: + specifier: ^3.6.7 + version: 3.6.7 zod: specifier: ^4.5.4 version: 4.5.4 @@ -102,6 +108,9 @@ importers: '@types/node': specifier: ^26.4.1 version: 26.4.1 + '@types/nodemailer': + specifier: ^8.0.1 + version: 8.0.1 '@types/pg': specifier: ^8.23.1 version: 8.23.1 @@ -111,6 +120,9 @@ importers: '@types/qrcode': specifier: ^1.5.6 version: 1.5.6 + '@types/web-push': + specifier: ^3.6.4 + version: 3.6.4 pngjs: specifier: ^7.0.0 version: 7.0.0 @@ -143,7 +155,7 @@ importers: version: 5.102.8(react@19.2.8) better-auth: specifier: ^1.7.2 - version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0) + version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1688,6 +1700,9 @@ packages: '@types/node@26.4.1': resolution: {integrity: sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==} + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + '@types/pg@8.23.1': resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} @@ -1705,6 +1720,9 @@ packages: '@types/react@19.2.18': resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@types/web-push@3.6.4': + resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==} + '@typescript-eslint/eslint-plugin@8.69.0': resolution: {integrity: sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1805,6 +1823,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -1847,6 +1869,9 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1948,6 +1973,9 @@ packages: resolution: {integrity: sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==} engines: {node: '>=22'} + bn.js@4.12.5: + resolution: {integrity: sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==} + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} @@ -1963,6 +1991,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2102,6 +2133,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + electron-to-chromium@1.5.420: resolution: {integrity: sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==} @@ -2373,6 +2407,14 @@ packages: resolution: {integrity: sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==} engines: {node: '>=16.9.0'} + http_ece@1.2.0: + resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} + engines: {node: '>=16'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + icu-minify@4.14.2: resolution: {integrity: sha512-2eJ9ooR8xXWbe0IivG58TFL96AUp5ts8FJ4qSMzn8YRJXpLmtPNWcUkIZ8t3iz0+eaVzG+mN+rdNIigHDvYa2Q==} @@ -2388,6 +2430,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -2556,6 +2601,12 @@ packages: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2759,6 +2810,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -2766,6 +2820,9 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} @@ -2836,6 +2893,10 @@ packages: resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} engines: {node: '>=18'} + nodemailer@9.1.1: + resolution: {integrity: sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==} + engines: {node: '>=6.0.0'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -3098,6 +3159,9 @@ packages: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -3106,6 +3170,9 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3477,6 +3544,11 @@ packages: jsdom: optional: true + web-push@3.6.7: + resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} + engines: {node: '>= 16'} + hasBin: true + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -4680,6 +4752,10 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 26.4.1 + '@types/pg@8.23.1': dependencies: '@types/node': 26.4.1 @@ -4702,6 +4778,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/web-push@3.6.4': + dependencies: + '@types/node': 26.4.1 + '@typescript-eslint/eslint-plugin@8.69.0(@typescript-eslint/parser@8.69.0(eslint@10.9.1(jiti@2.7.0))(typescript@5.9.3))(eslint@10.9.1(jiti@2.7.0))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -4828,6 +4908,8 @@ snapshots: acorn@8.18.0: {} + agent-base@7.1.4: {} + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -4900,6 +4982,13 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.5 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} ast-v8-to-istanbul@1.0.5: @@ -4920,7 +5009,7 @@ snapshots: baseline-browser-mapping@2.11.21: {} - better-auth@1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0): + better-auth@1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))): dependencies: '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) @@ -4963,6 +5052,8 @@ snapshots: dependencies: node-addon-api: 8.9.2 + bn.js@4.12.5: {} + bowser@2.14.1: {} brace-expansion@1.1.18: @@ -4982,6 +5073,8 @@ snapshots: node-releases: 2.0.54 update-browserslist-db: 1.3.2(browserslist@4.28.8) + buffer-equal-constant-time@1.0.1: {} + bundle-require@5.1.0(esbuild@0.27.7): dependencies: esbuild: 0.27.7 @@ -5110,6 +5203,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + electron-to-chromium@1.5.420: {} emoji-regex@8.0.0: {} @@ -5537,6 +5634,15 @@ snapshots: hono@4.13.5: {} + http_ece@1.2.0: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + icu-minify@4.14.2: dependencies: '@formatjs/icu-messageformat-parser': 3.5.17 @@ -5547,6 +5653,8 @@ snapshots: imurmurhash@0.1.4: {} + inherits@2.0.4: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -5717,6 +5825,17 @@ snapshots: object.assign: 4.1.7 object.values: 1.2.1 + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -5868,6 +5987,8 @@ snapshots: math-intrinsics@1.1.0: {} + minimalistic-assert@1.0.1: {} + minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -5876,6 +5997,8 @@ snapshots: dependencies: brace-expansion: 1.1.18 + minimist@1.2.8: {} + mlly@1.8.2: dependencies: acorn: 8.18.0 @@ -5959,6 +6082,8 @@ snapshots: node-releases@2.0.54: {} + nodemailer@9.1.1: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -6257,6 +6382,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -6268,6 +6395,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safer-buffer@2.1.2: {} + scheduler@0.27.0: {} semver@6.3.1: {} @@ -6661,6 +6790,16 @@ snapshots: transitivePeerDependencies: - msw + web-push@3.6.7: + dependencies: + asn1.js: 5.4.1 + http_ece: 1.2.0 + https-proxy-agent: 7.0.6 + jws: 4.0.1 + minimist: 1.2.8 + transitivePeerDependencies: + - supports-color + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0