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

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

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

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

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

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

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

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

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

280 vitest tests, 51 Playwright tests, rules coverage still 100%, typecheck and
lint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-09-04 04:42:32 +00:00
co-authored by Claude Opus 5
parent b074456b70
commit 5dc284c5f2
42 changed files with 2915 additions and 66 deletions
@@ -43,12 +43,8 @@ export function DocumentsScreen({ locale }: { locale: string }) {
return (
<div className="space-y-4 py-4">
<div className="flex items-center justify-between gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{t('docs.title')}</h1>
<Link href="/escanear" className={buttonClasses({ size: 'md' })}>
{t('scan.fab')}
</Link>
</div>
{/* No scan button here: the shell carries a persistent one on every screen. */}
<h1 className="text-2xl font-semibold tracking-tight">{t('docs.title')}</h1>
<input
type="search"
@@ -0,0 +1,303 @@
'use client';
import type { DashboardDto } from '@impuestos/contracts';
import { formatDateLong, formatGs, formatGsAmount, formatMonthName, type Locale } from '@impuestos/i18n';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ScanLine, X } from 'lucide-react';
import { TraceableNumber } from '@/components/traceable-number';
import { Button, buttonClasses } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { EmptyState } from '@/components/ui/empty-state';
import { Skeleton } from '@/components/ui/skeleton';
import { Link } from '@/i18n/navigation';
import { useT } from '@/i18n/t';
import { api } from '@/lib/api';
/** Flow C: position header, then exactly one next action, then the insight feed. */
export function Dashboard({ locale, fullName }: { locale: Locale; fullName: string }) {
const t = useT();
const dashboard = useQuery({
queryKey: ['dashboard'],
queryFn: ({ signal }) => api.getDashboard(signal),
});
if (dashboard.isPending) {
return (
<div className="space-y-4 py-4">
<Card>
<Skeleton name="dashboard-position" />
</Card>
<Card>
<Skeleton name="bandeja-card" />
</Card>
</div>
);
}
if (dashboard.isError || !dashboard.data) {
return (
<Card className="my-4 space-y-3">
<p role="alert" className="text-sm">
{t('common.error.generic')}
</p>
<Button type="button" onClick={() => void dashboard.refetch()}>
{t('common.retry')}
</Button>
</Card>
);
}
const data = dashboard.data;
// Flow A6: until the first comprobante arrives there is nothing to show but the way in.
const nothingYet = !data.hasDocuments;
return (
<div className="space-y-4 py-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('home.title')}</h1>
<p className="text-sm text-[var(--text-muted)]">{fullName}</p>
</div>
{nothingYet ? (
<Card>
<EmptyState
icon={<ScanLine aria-hidden className="size-8" />}
title={t('home.firstRun.title')}
body={t('home.noPosition')}
action={
<Link href="/escanear" className={buttonClasses({ size: 'lg' })}>
{t('home.firstRun.scan')}
</Link>
}
/>
</Card>
) : null}
{!nothingYet ? (
<>
{/* Zone 1: the position, one card per obligation, swipeable on a phone. */}
<div className="flex snap-x snap-mandatory gap-4 overflow-x-auto pb-1">
{data.iva ? <IvaCard iva={data.iva} locale={locale} /> : null}
{data.irp ? <IrpCard irp={data.irp} locale={locale} /> : null}
</div>
{/* Zone 2: exactly one next action. */}
<NextAction action={data.nextAction} locale={locale} />
{/* Zone 3: the insight feed. */}
<InsightFeed insights={data.insights} locale={locale} />
</>
) : null}
</div>
);
}
function IvaCard({ iva, locale }: { iva: NonNullable<DashboardDto['iva']>; locale: Locale }) {
const t = useT();
const owes = iva.aPagar > 0;
return (
<Card className="min-w-[85%] shrink-0 snap-start space-y-2 sm:min-w-0 sm:flex-1">
<p className="text-sm font-medium text-[var(--text-muted)]">
{t('home.iva.title', { month: formatMonthName(locale, iva.period) })}
</p>
<p className="text-sm text-[var(--text-muted)]">
{owes ? t('home.iva.aPagar') : t('home.iva.aFavor')}
</p>
{/* Owing tax on time is neutral, never red (FLOWS.md section 1). */}
<TraceableNumber
value={owes ? iva.aPagar : iva.aFavor}
kind={owes ? 'iva_debito' : 'iva_credito'}
locale={locale}
className={owes ? undefined : 'text-positive'}
/>
<p className="text-sm text-[var(--text-muted)]">
{t('home.iva.breakdown', {
debito: formatGsAmount(iva.debito),
credito: formatGsAmount(iva.credito),
})}
</p>
</Card>
);
}
function IrpCard({ irp, locale }: { irp: NonNullable<DashboardDto['irp']>; locale: Locale }) {
const t = useT();
return (
<Card className="min-w-[85%] shrink-0 snap-start space-y-2 sm:min-w-0 sm:flex-1">
<p className="text-sm font-medium text-[var(--text-muted)]">
{t('home.irp.title', { year: irp.year })}
</p>
<TraceableNumber value={irp.projectedTax} kind="irp_deductions" locale={locale} />
{irp.monthDelta > 0 ? (
<p className="text-sm text-positive">
{t('home.irp.delta', { amount: formatGsAmount(irp.monthDelta) })}
</p>
) : null}
{irp.belowThreshold ? (
<p className="text-sm text-[var(--text-muted)] text-pretty">
{t('home.irp.belowThreshold')}
</p>
) : null}
</Card>
);
}
function NextAction({
action,
locale,
}: {
action: DashboardDto['nextAction'];
locale: Locale;
}) {
const t = useT();
switch (action.kind) {
case 'overdue':
return (
<Card className="space-y-3 border-overdue">
<p className="font-medium text-overdue">
{t('home.next.overdue', {
form: action.form ?? '120',
date: action.dueDate ? formatDateLong(locale, action.dueDate) : '',
})}
</p>
<Link href="/vencimientos" className={buttonClasses({ block: true })}>
{t('home.next.overdueCta')}
</Link>
</Card>
);
case 'declaration_ready':
return (
<Card className="space-y-3 border-attention">
<p className="font-medium">{t('decl.status.ready')}</p>
<Link
href={`/declaraciones/${action.declarationId ?? ''}`}
className={buttonClasses({ block: true })}
>
{t('home.next.reviewCta')}
</Link>
</Card>
);
case 'deadline_soon':
return (
<Card className="space-y-3 border-attention">
<p className="font-medium text-attention">
{t('home.next.upcoming', {
date: action.dueDate ? formatDateLong(locale, action.dueDate) : '',
})}
</p>
<Link href="/vencimientos" className={buttonClasses({ block: true })}>
{t('home.next.deadlineCta')}
</Link>
</Card>
);
case 'bandeja':
return (
<Card className="space-y-3">
<p className="font-medium">{t('home.next.bandeja', { count: action.count ?? 0 })}</p>
<Link href="/bandeja" className={buttonClasses({ block: true })}>
{t('home.next.bandejaCta')}
</Link>
</Card>
);
case 'all_clear':
return (
<Card className="space-y-1">
<p className="font-medium text-positive">{t('home.next.allClear')}</p>
{action.dueDate ? (
<p className="text-sm text-[var(--text-muted)]">
{t('home.next.upcoming', { date: formatDateLong(locale, action.dueDate) })}
</p>
) : null}
</Card>
);
}
}
function InsightFeed({
insights,
locale,
}: {
insights: DashboardDto['insights'];
locale: Locale;
}) {
const t = useT();
const queryClient = useQueryClient();
const dismiss = useMutation({
mutationFn: (id: string) => api.dismissInsight(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
});
if (insights.length === 0) return null;
return (
<ul className="space-y-3">
{insights.map((insight) => (
<li key={insight.id}>
<Card className="space-y-2">
<p className="font-medium">{insightTitle(insight, t)}</p>
<p className="text-sm text-[var(--text-muted)] text-pretty">
{insightBody(insight, t, locale)}
</p>
<Button
type="button"
variant="ghost"
disabled={dismiss.isPending}
onClick={() => dismiss.mutate(insight.id)}
>
<X aria-hidden className="size-4" />
{t('home.insight.dismiss')}
</Button>
</Card>
</li>
))}
</ul>
);
}
type Translate = ReturnType<typeof useT>;
function insightTitle(insight: DashboardDto['insights'][number], t: Translate): string {
if (insight.kind === 'deduction_gap') return t('home.insight.gapTitle');
if (insight.kind === 'month_close') return t('decl.status.ready');
return t('vto.title');
}
function insightBody(
insight: DashboardDto['insights'][number],
t: Translate,
locale: Locale,
): string {
if (insight.kind === 'deduction_gap') {
const category = String(insight.params['category'] ?? 'none');
return t('home.insight.gapBody', {
category: t(`categories.${category}` as 'categories.none'),
});
}
if (insight.kind === 'month_close') {
return t('home.insight.monthClose', {
month: formatMonthName(locale, String(insight.params['month'] ?? '')),
savings: formatGsAmount(Number(insight.params['savings'] ?? 0)),
});
}
return t('home.insight.deadline', {
form: String(insight.params['form'] ?? '120'),
date: formatDateLong(locale, String(insight.params['date'] ?? '')),
});
}
export { formatGs };
+7 -36
View File
@@ -1,22 +1,18 @@
import { isApiError } from '@impuestos/contracts';
import { ScanLine } from 'lucide-react';
import type { Locale } from '@impuestos/i18n';
import { setRequestLocale } from 'next-intl/server';
import { buttonClasses } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { EmptyState } from '@/components/ui/empty-state';
import { Link, redirect } from '@/i18n/navigation';
import { getT } from '@/i18n/t';
import { redirect } from '@/i18n/navigation';
import { serverApi } from '@/lib/api-server';
import { Dashboard } from './dashboard';
/**
* Flow A6, the first run state. The dashboard proper (Flow C, with the position header,
* next action strip and insight feed) replaces this body in phase 4; deadlines come from
* the deadline engine then, not from a second copy of the rules in the browser.
* Flow C. The profile is fetched server side so an unfinished setup redirects before
* anything renders; the position itself loads on the client, where TanStack Query keeps it
* fresh as documents are confirmed.
*/
export default async function InicioPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getT(locale);
const api = await serverApi();
const profile = await api.getProfile().catch((error: unknown) => {
@@ -30,30 +26,5 @@ export default async function InicioPage({ params }: { params: Promise<{ locale:
// empty shell they cannot act on.
if (!profile) redirect({ href: '/configuracion', locale });
return (
<div className="space-y-6 py-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('home.title')}</h1>
<p className="text-sm text-[var(--text-muted)]">{profile?.fullName}</p>
</div>
<Card>
<EmptyState
icon={<ScanLine aria-hidden className="size-8" />}
title={t('home.firstRun.title')}
body={t('home.firstRun.body')}
action={
<div className="flex flex-col items-center gap-2">
<Link href="/escanear" className={buttonClasses({ size: 'lg' })}>
{t('home.firstRun.scan')}
</Link>
<Link href="/comprobantes/nuevo" className="text-sm text-accent-700 hover:underline">
{t('home.firstRun.manual')}
</Link>
</div>
}
/>
</Card>
</div>
);
return <Dashboard locale={locale as Locale} fullName={profile?.fullName ?? ''} />;
}
+4 -1
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react';
import { AppShell } from '@/components/app-shell';
import { LanguageSwitcher } from '@/components/language-switcher';
import { Link } from '@/i18n/navigation';
import { useT } from '@/i18n/t';
@@ -18,7 +19,9 @@ export default function AppLayout({ children }: { children: ReactNode }) {
</Link>
</div>
</header>
<main className="flex-1 px-5 pb-16">{children}</main>
{/* Bottom padding clears the tab bar and the scan button above it. */}
<main className="flex-1 px-5 pb-36">{children}</main>
<AppShell />
</div>
);
}
@@ -0,0 +1,124 @@
'use client';
import type { DeadlineDto } from '@impuestos/contracts';
import { formatDateLong, type Locale } from '@impuestos/i18n';
import { deadlineDay } from '@impuestos/rules';
import { useQuery } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { useT } from '@/i18n/t';
import { api } from '@/lib/api';
import { cn } from '@/lib/utils';
/** Flow E2: a vertical timeline of everything coming, with the day rule explained. */
export function DeadlinesScreen({ locale }: { locale: string }) {
const t = useT();
const profile = useQuery({ queryKey: ['me'], queryFn: ({ signal }) => api.getProfile(signal) });
const deadlines = useQuery({
queryKey: ['deadlines'],
queryFn: ({ signal }) => api.getUpcomingDeadlines(12, signal),
});
return (
<div className="space-y-4 py-4">
<h1 className="text-2xl font-semibold tracking-tight">{t('vto.title')}</h1>
{profile.data ? (
<Card>
<p className="text-sm text-[var(--text-muted)] text-pretty">
{t('vto.explainer', {
digit: profile.data.deadlineDigit,
day: deadlineDay(profile.data.deadlineDigit),
})}
</p>
</Card>
) : null}
{deadlines.isPending ? (
<Card>
<Skeleton name="doc-list-item" count={6} />
</Card>
) : null}
{deadlines.isError ? (
<Card className="space-y-3">
<p role="alert" className="text-sm">
{t('common.error.generic')}
</p>
<Button type="button" onClick={() => void deadlines.refetch()}>
{t('common.retry')}
</Button>
</Card>
) : null}
{deadlines.data?.length === 0 ? (
<Card>
<p className="text-sm text-[var(--text-muted)]">{t('vto.empty')}</p>
</Card>
) : null}
{deadlines.data && deadlines.data.length > 0 ? (
<ol className="space-y-3">
{deadlines.data.map((deadline) => (
<li key={`${deadline.obligation}-${deadline.period}`}>
<DeadlineRow deadline={deadline} locale={locale as Locale} />
</li>
))}
</ol>
) : null}
</div>
);
}
function DeadlineRow({ deadline, locale }: { deadline: DeadlineDto; locale: Locale }) {
const t = useT();
const days = daysUntil(deadline.dueDate);
return (
<Card className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<p className="font-medium">{t(`obligation.${deadline.obligation}` as const)}</p>
<p className="text-sm text-[var(--text-muted)]">{deadline.period}</p>
<p className="text-sm">{formatDateLong(locale, deadline.dueDate)}</p>
</div>
<div className="shrink-0 space-y-1 text-right">
<DeadlinePill status={deadline.status} label={t(`vto.status.${deadline.status}` as const)} />
{deadline.status !== 'done' ? (
<p className="text-xs text-[var(--text-muted)]">
{days === 0
? t('vto.today')
: days > 0
? t('vto.daysLeft', { days })
: t('vto.daysLate', { days: Math.abs(days) })}
</p>
) : null}
</div>
</Card>
);
}
/** Overdue is the only thing that earns red (FLOWS.md section 1). */
function DeadlinePill({ status, label }: { status: DeadlineDto['status']; label: string }) {
return (
<span
className={cn(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
status === 'done' && 'bg-positive-soft text-positive',
status === 'due_soon' && 'bg-attention-soft text-attention',
status === 'overdue' && 'bg-overdue-soft text-overdue',
status === 'upcoming' && 'bg-[var(--surface)] text-[var(--text-muted)]',
)}
>
{label}
</span>
);
}
function daysUntil(isoDate: string): number {
const today = new Date();
const startOfToday = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
return Math.round((Date.parse(`${isoDate}T00:00:00Z`) - startOfToday) / 86_400_000);
}
@@ -0,0 +1,8 @@
import { setRequestLocale } from 'next-intl/server';
import { DeadlinesScreen } from './deadlines-screen';
export default async function DeadlinesPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
setRequestLocale(locale);
return <DeadlinesScreen locale={locale} />;
}
+67
View File
@@ -0,0 +1,67 @@
'use client';
import { FileText, Home, Inbox, Plus, CalendarClock } from 'lucide-react';
import type { ComponentType } from 'react';
import { Link, usePathname } from '@/i18n/navigation';
import { useT } from '@/i18n/t';
import { cn } from '@/lib/utils';
const TABS: { href: string; key: 'inicio' | 'bandeja' | 'comprobantes' | 'vencimientos'; Icon: ComponentType<{ className?: string }> }[] = [
{ href: '/inicio', key: 'inicio', Icon: Home },
{ href: '/bandeja', key: 'bandeja', Icon: Inbox },
{ href: '/comprobantes', key: 'comprobantes', Icon: FileText },
{ href: '/vencimientos', key: 'vencimientos', Icon: CalendarClock },
];
/**
* The tab bar and the scan button that sits above it, on every signed in screen
* (FLOWS.md B1). Scanning is the one action worth a permanent affordance: it is what the
* product is for.
*/
export function AppShell() {
const t = useT();
const pathname = usePathname();
return (
<>
<Link
href="/escanear"
aria-label={t('scan.fab')}
className={cn(
'fixed right-5 bottom-24 z-20 flex items-center gap-2 rounded-full bg-accent-600 px-5 py-3.5',
'font-medium text-white shadow-lg transition-colors hover:bg-accent-700',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600',
)}
>
<Plus aria-hidden className="size-5" />
{t('scan.fab')}
</Link>
<nav
aria-label={t('common.appName')}
className="fixed inset-x-0 bottom-0 z-10 border-t bg-[var(--surface-raised)]"
>
<ul className="mx-auto flex max-w-2xl">
{TABS.map(({ href, key, Icon }) => {
const active = pathname === href || pathname.startsWith(`${href}/`);
return (
<li key={href} className="flex-1">
<Link
href={href}
aria-current={active ? 'page' : undefined}
className={cn(
'flex flex-col items-center gap-1 py-2.5 text-xs transition-colors',
active ? 'text-accent-700' : 'text-[var(--text-muted)]',
)}
>
<Icon aria-hidden className="size-5" />
{t(`nav.${key}` as const)}
</Link>
</li>
);
})}
</ul>
</nav>
</>
);
}
@@ -0,0 +1,100 @@
'use client';
import type { TraceKind } from '@impuestos/contracts';
import { formatDateShort, formatGs, type Locale } from '@impuestos/i18n';
import { useQuery } from '@tanstack/react-query';
import { useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { Link } from '@/i18n/navigation';
import { useT } from '@/i18n/t';
import { api } from '@/lib/api';
import { cn } from '@/lib/utils';
/**
* A money figure that opens the documents behind it (FLOWS.md C1). Nothing on the
* dashboard is a number the user cannot get underneath.
*/
export function TraceableNumber({
value,
kind,
locale,
size = 'lg',
className,
}: {
value: number;
kind: TraceKind;
locale: Locale;
size?: 'md' | 'lg';
className?: string;
}) {
const t = useT();
const dialog = useRef<HTMLDialogElement>(null);
const trace = useQuery({
queryKey: ['dashboard', 'trace', kind],
queryFn: ({ signal }) => api.getTrace(kind, signal),
enabled: false,
});
return (
<>
<button
type="button"
onClick={() => {
void trace.refetch();
dialog.current?.showModal();
}}
className={cn(
'tnum text-left underline decoration-dotted underline-offset-4',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600',
size === 'lg' ? 'text-4xl font-semibold tracking-tight' : 'font-medium',
className,
)}
>
{formatGs(value)}
</button>
<dialog
ref={dialog}
aria-label={t('home.trace.title')}
className="m-auto w-[min(32rem,calc(100vw-2rem))] rounded-2xl border bg-[var(--surface-raised)] p-6 text-[var(--text)] backdrop:bg-black/40"
>
<div className="space-y-4">
<h2 className="text-lg font-semibold tracking-tight">{t('home.trace.title')}</h2>
{trace.isFetching ? <Skeleton name="doc-list-item" count={4} /> : null}
{trace.data && trace.data.items.length === 0 ? (
<p className="text-sm text-[var(--text-muted)]">{t('home.trace.empty')}</p>
) : null}
{trace.data && trace.data.items.length > 0 ? (
<ul className="max-h-80 divide-y overflow-y-auto">
{trace.data.items.map((item) => (
<li key={item.documentId}>
<Link
href={`/comprobantes/${item.documentId}`}
className="flex items-center justify-between gap-4 py-3 text-sm"
>
<span className="min-w-0">
<span className="block truncate font-medium">{item.emitterName}</span>
<span className="text-[var(--text-muted)]">
{formatDateShort(locale, item.issueDate)}
</span>
</span>
<span className="tnum shrink-0">{formatGs(item.amount)}</span>
</Link>
</li>
))}
</ul>
) : null}
<Button type="button" variant="secondary" block onClick={() => dialog.current?.close()}>
{t('common.close')}
</Button>
</div>
</dialog>
</>
);
}