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