commit ae2ea20b7ebe64edba357eb2f637e44fced037e1 Author: Michilis Date: Thu Sep 3 21:46:35 2026 +0000 phase-0: foundation, both apps boot end to end Monorepo (pnpm workspaces) with two deployable apps and three pure packages. apps/api (Hono on Node): Zod validated env that fails fast and names the problem, Kysely factories for SQLite and Postgres chosen by DATABASE_URL scheme, portable migrations covering the whole SPEC section 5 schema, Better Auth with the four roles and seeded demo accounts, localized error envelope, /healthz and /readyz, graceful SIGTERM drain. Dialect specific SQL is confined to the two factories. apps/web (Next.js App Router): locale routed shell in es and en with a language switcher, sign in screen, and a runtime /api proxy so the browser only ever sees one origin and cookies stay first party. packages/i18n ships both catalogs complete; es is generated from COPY.md and a test re-derives it from the document on every run so it cannot drift. packages/contracts holds the Zod schemas and the typed client the web app uses. Verified: 43 vitest tests, 14 Playwright tests on mobile and desktop, typecheck and lint clean, migrate and seed from a clean database, sign in through the proxy with CSRF rejection of foreign origins. Not verified here: docker compose. This user has no access to the docker socket. RULES.md is absent from docs/, so packages/rules exports only RULES_VERSION and no tax rule, check digit or deadline was invented. See DECISIONS.md. Co-Authored-By: Claude Opus 5 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..02e8e10 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +**/node_modules +**/.next +**/dist +**/data +**/coverage +.git +.env +**/.env +test-results +playwright-report +docs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a52f7aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +.next/ +out/ +coverage/ +*.tsbuildinfo +.env +.env.local +apps/*/data/ +data/ +test-results/ +playwright-report/ +.DS_Store diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..319e41e --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +strict-peer-dependencies=false diff --git a/DECISIONS.md b/DECISIONS.md new file mode 100644 index 0000000..e3a9445 --- /dev/null +++ b/DECISIONS.md @@ -0,0 +1,131 @@ +# DECISIONS + +One entry per decision that is not already obvious from the specs. Newest phase last. + +Markers used in the code: +- `// SPEC-GAP:` the specs did not settle this and a choice was made here. +- `// TODO-TAX-VERIFY:` a tax rule that RULES.md does not state. Never invented, always flagged. + +--- + +## Blocking gaps in the source material + +### RULES.md is missing +`docs/` ships SPEC.md, FLOWS.md, COPY.md and CONTRACTS.md. RULES.md, which the prompt +names as authoritative for every tax rule, is not present. Phase 1 is entirely RULES.md +and phases 4 and 5 depend on it. + +Consequence for phase 0: `packages/rules` exists and exports only `RULES_VERSION`. +Nothing in this phase computes a tax number, a check digit or a deadline, so nothing was +invented. The seed deliberately stops short of profiles for the same reason: CONTRACTS.md +section 4 asks for RUC base `4123456` "with computed DV" and a `deadlineDigit`, both of +which are RULES.md algorithms. Seeding accounts only keeps phase 0 honest. + +**Needed before phase 1 starts.** + +### `boneyard` and `canvas-ui` are not the packages the prompt means +Both names resolve on npm to unrelated projects: `boneyard@0.1.4` is a 2015 Backbone +"architectural toolkit", `canvas-ui@0.2.3` is a Mesosphere Bootstrap theme. Neither does +skeleton loading or canvas effects. Neither is needed before phase 7. + +Plan unless corrected: keep the *behaviour* the prompt specifies (skeletons on every +content load and never a spinner; canvas effects in exactly two places, degrading +gracefully) behind a single `` component and a single effect component, so +swapping in the real library later is a one file change. + +**Please confirm the intended packages before phase 7.** + +### Neither named skill is installed +`ponytail` and `ui-ux-pro-max-skill` are not available in this environment. Their stated +intent was applied by hand: nothing speculative, no unused configuration, and FLOWS.md +section 1 as the design constraint. + +--- + +## Phase 0 + +### Migrations: better-auth generates its own four tables, we own the rest +`src/db/migrator.ts` runs two ordered steps: better-auth's `getMigrations()` creates and +updates `user`, `session`, `account` and `verification`, then the Kysely migrator applies +`src/db/migrations`. Delegating the auth tables keeps them in step with the installed +better-auth version and emits correct DDL for both dialects, so no hand written dialect +SQL was needed for them. Upgrading better-auth in a way that adds a column means adding a +migration that calls the same generator again. + +### The whole schema ships in migration `001_core`, not phase by phase +Every table in SPEC.md section 5 is created now. The schema is fully specified and stable; +splitting it across phases would produce a pile of migration files and no benefit before +release. Later phases add modules on top, not tables. + +### Timestamps and money are portable by construction +Timestamps are ISO-8601 text and dates are `YYYY-MM-DD` text in both dialects: they sort +chronologically as strings, so no dialect specific date type or comparison is needed +anywhere. Money is `bigint`, because guaranies pass int4 at about Gs. 2.100.000.000, and +`apps/api/src/db/postgres.ts` registers an int8 parser that returns a number and throws +outside the safe integer range. + +### `/api` is proxied by a route handler, not a Next rewrite +SPEC-GAP against SPEC.md section 2, which specifies Next rewrites. Next bakes rewrite +destinations into the build manifest, so `API_INTERNAL_URL` would become a build time +value and one image could not serve both compose and k8s. `apps/web/app/api/[...path]/route.ts` +forwards at request time instead. The single origin model is unchanged: the browser only +ever sees the web origin, cookies stay first party, and there is still no CORS anywhere. + +### Workspace packages ship TypeScript source with no build step +`packages/*` have no `dist`. The web app lists them in `transpilePackages` and tsup bundles +them into the API. Their relative imports are extensionless, because Turbopack does not +rewrite a `.js` specifier onto a `.ts` source file. + +### The es catalog is split from the strings COPY.md does not define +`catalogs/es.ts` is generated from COPY.md and is verbatim; `copy-parity.test.ts` re-derives +it from `docs/COPY.md` on every run and fails on any drift, in either direction. Strings the +product needs that COPY.md does not list (the seven error envelope messages, three sign in +labels, the language switcher) live in `catalogs/es.extra.ts` and follow the tone rules in +COPY.md section 0. `es` is the merge of the two. + +### `decl.approve` is both a message and a namespace +COPY.md defines `decl.approve` (the button) alongside `decl.approve.confirmTitle`. A nested +message tree cannot hold both, and next-intl walks a nested tree. `unflatten` moves such a +message to a reserved `_` child and `resolveKey` maps the key for callers, so components +still address messages by their COPY.md key. `apps/web/src/i18n/t.ts` is the wrapper; it is +computed from the catalog, so a future collision is handled without another change. + +### Locale negotiation on `/` +next-intl's default detection is left on: a browser asking for English lands on `/en`, +anything else falls back to `es`. The en catalog exists for expats and international users +(COPY.md section 0-EN), which is exactly the population whose browser is in English. An +explicit choice through the switcher always wins and is in the URL. + +### SQLite refuses `JOBS_INLINE=false` +Implemented literally as SPEC.md section 15 instructs, which is narrower than the mode +matrix in the same section: that table allows "SQLite, 1 dedicated worker" under Split +small. Two pollers cannot be made safe against a single writer, so the boot check wins and +SQLite stays single process. Worth reconciling in SPEC.md. + +### Deferred to the phase that needs them, deliberately +- `RateLimiter`: SPEC.md section 6 specifies a token bucket, but the first endpoint with a + stated limit is `GET /lookup/ruc/:number` in phase 2. better-auth's own rate limiting + covers the auth routes until then. +- DTO schemas in `packages/contracts`: enums, the error envelope, the client and + `ProfileDto` exist because phase 0 uses them. The rest arrive with their endpoints. +- Storage in `/readyz`: the check covers the database and pending migrations. The storage + driver probe is added in phase 3 with the driver. + +### Smaller choices +- TypeScript 5.9, not 7.x: `typescript-eslint@8` declares `typescript <6.1.0`. +- `better-sqlite3` is kept out of `onlyBuiltDependencies`: it ships prebuilt binaries, so + letting pnpm run the implicit `node-gyp rebuild` would compile it for nothing and force a + toolchain into the image. +- The language switcher is a native ` + + +
+ + +
+ + {signIn.isError ? ( +

+ {t('auth.login.failed')} +

+ ) : null} + + + + + ); +} diff --git a/apps/web/app/[locale]/(auth)/login/page.tsx b/apps/web/app/[locale]/(auth)/login/page.tsx new file mode 100644 index 0000000..c18847b --- /dev/null +++ b/apps/web/app/[locale]/(auth)/login/page.tsx @@ -0,0 +1,8 @@ +import { setRequestLocale } from 'next-intl/server'; +import { LoginForm } from './login-form'; + +export default async function LoginPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + setRequestLocale(locale); + return ; +} diff --git a/apps/web/app/[locale]/layout.tsx b/apps/web/app/[locale]/layout.tsx new file mode 100644 index 0000000..993cbda --- /dev/null +++ b/apps/web/app/[locale]/layout.tsx @@ -0,0 +1,49 @@ +import { isLocale } from '@impuestos/i18n'; +import type { Metadata } from 'next'; +import { Inter } from 'next/font/google'; +import { NextIntlClientProvider } from 'next-intl'; +import { setRequestLocale } from 'next-intl/server'; +import { getT } from '@/i18n/t'; +import { notFound } from 'next/navigation'; +import type { ReactNode } from 'react'; +import { Providers } from '@/components/providers'; +import { routing } from '@/i18n/routing'; +import '../globals.css'; + +// Self hosted by next/font: no third party font request at runtime, which keeps the +// CSP tight (SPEC.md section 14). +const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' }); + +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })); +} + +export async function generateMetadata(props: { + params: Promise<{ locale: string }>; +}): Promise { + const { locale } = await props.params; + const t = await getT(locale); + return { title: t('common.appName') }; +} + +export default async function LocaleLayout({ + children, + params, +}: { + children: ReactNode; + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + if (!isLocale(locale)) notFound(); + setRequestLocale(locale); + + return ( + + + + {children} + + + + ); +} diff --git a/apps/web/app/[locale]/page.tsx b/apps/web/app/[locale]/page.tsx new file mode 100644 index 0000000..ce011a4 --- /dev/null +++ b/apps/web/app/[locale]/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from '@/i18n/navigation'; + +/** + * The landing page (FLOWS.md Flow A1) is built in phase 2. Until then the root goes + * straight to sign in so the shell is reachable. + */ +export default async function LandingPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + redirect({ href: '/login', locale }); +} diff --git a/apps/web/app/api/[...path]/route.ts b/apps/web/app/api/[...path]/route.ts new file mode 100644 index 0000000..b4244dc --- /dev/null +++ b/apps/web/app/api/[...path]/route.ts @@ -0,0 +1,68 @@ +import type { NextRequest } from 'next/server'; + +/** + * Single origin proxy: the browser only ever talks to the web origin, and everything + * under /api is forwarded to the API container. Cookies stay first party, so there is + * no CORS anywhere in v1. + * + * SPEC-GAP: SPEC.md section 2 specifies Next rewrites for this. Next bakes rewrite + * destinations into the build manifest, which would make API_INTERNAL_URL a build time + * value and stop one image from running in both compose and k8s. A route handler reads + * it per request instead, which is what "all configuration via .env" requires. + */ + +export const dynamic = 'force-dynamic'; + +/** Set by the proxy or the runtime, never forwarded verbatim. */ +const STRIPPED_REQUEST_HEADERS = new Set([ + 'host', + 'connection', + 'content-length', + 'transfer-encoding', + 'accept-encoding', +]); + +const STRIPPED_RESPONSE_HEADERS = new Set(['content-encoding', 'content-length', 'transfer-encoding']); + +function apiBaseUrl(): string { + return (process.env['API_INTERNAL_URL'] ?? 'http://localhost:4000').replace(/\/$/, ''); +} + +async function proxy(request: NextRequest): Promise { + const incoming = new URL(request.url); + const target = `${apiBaseUrl()}${incoming.pathname}${incoming.search}`; + + const headers = new Headers(); + request.headers.forEach((value, key) => { + if (!STRIPPED_REQUEST_HEADERS.has(key.toLowerCase())) headers.set(key, value); + }); + + const hasBody = request.method !== 'GET' && request.method !== 'HEAD'; + const upstream = await fetch(target, { + method: request.method, + headers, + redirect: 'manual', + ...(hasBody ? { body: request.body, duplex: 'half' } : {}), + } as RequestInit & { duplex?: 'half' }); + + const responseHeaders = new Headers(); + upstream.headers.forEach((value, key) => { + const name = key.toLowerCase(); + if (name === 'set-cookie') return; + if (!STRIPPED_RESPONSE_HEADERS.has(name)) responseHeaders.set(key, value); + }); + // Session and CSRF cookies arrive as several Set-Cookie headers and must stay separate. + for (const cookie of upstream.headers.getSetCookie()) { + responseHeaders.append('set-cookie', cookie); + } + + return new Response(upstream.body, { status: upstream.status, headers: responseHeaders }); +} + +export const GET = proxy; +export const POST = proxy; +export const PUT = proxy; +export const PATCH = proxy; +export const DELETE = proxy; +export const HEAD = proxy; +export const OPTIONS = proxy; diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..2dbe3d1 --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,96 @@ +@import 'tailwindcss'; + +/* + * Design tokens, FLOWS.md section 1. Calm fintech: one accent, four semantic status + * colors used consistently everywhere, generous whitespace, big confident numbers. + */ +@theme { + --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif; + + /* Accent: deep teal. Primary actions and links only, nothing else. */ + --color-accent-50: oklch(0.97 0.02 190); + --color-accent-100: oklch(0.93 0.04 190); + --color-accent-200: oklch(0.87 0.07 190); + --color-accent-400: oklch(0.68 0.11 190); + --color-accent-500: oklch(0.58 0.11 190); + --color-accent-600: oklch(0.48 0.1 191); + --color-accent-700: oklch(0.4 0.085 192); + --color-accent-900: oklch(0.27 0.055 194); + + /* Status. positive = a favor, al dia. attention = action required. + overdue = missed deadlines and invalid documents ONLY, never "tax to pay". + neutral = everything else. */ + --color-positive: oklch(0.62 0.14 155); + --color-positive-soft: oklch(0.95 0.04 155); + --color-attention: oklch(0.75 0.15 78); + --color-attention-soft: oklch(0.96 0.05 85); + --color-overdue: oklch(0.58 0.19 25); + --color-overdue-soft: oklch(0.95 0.04 25); + --color-neutral-fg: oklch(0.45 0.02 250); + + --radius-card: 1rem; +} + +/* Explicit toggle wins over the system preference in both directions (FLOWS.md + section 1: dark mode from system preference plus a toggle). */ +@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *)); + +:root { + --surface: oklch(0.99 0.003 250); + --surface-raised: oklch(1 0 0); + --border-subtle: oklch(0.92 0.005 250); + --text: oklch(0.22 0.015 255); + --text-muted: oklch(0.52 0.015 255); + color-scheme: light; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + --surface: oklch(0.18 0.012 255); + --surface-raised: oklch(0.23 0.014 255); + --border-subtle: oklch(0.31 0.012 255); + --text: oklch(0.96 0.004 250); + --text-muted: oklch(0.72 0.012 255); + color-scheme: dark; + } +} + +:root[data-theme='dark'] { + --surface: oklch(0.18 0.012 255); + --surface-raised: oklch(0.23 0.014 255); + --border-subtle: oklch(0.31 0.012 255); + --text: oklch(0.96 0.004 250); + --text-muted: oklch(0.72 0.012 255); + color-scheme: dark; +} + +@layer base { + * { + border-color: var(--border-subtle); + } + + body { + background-color: var(--surface); + color: var(--text); + -webkit-font-smoothing: antialiased; + } + + /* Every money figure is tabular. FLOWS.md section 1. */ + .tnum { + font-variant-numeric: tabular-nums; + font-feature-settings: 'tnum'; + } +} + +/* Motion is purposeful and always interruptible. This disables the non essential + kind globally, which the GSAP context mirrors. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/apps/web/app/healthz/route.ts b/apps/web/app/healthz/route.ts new file mode 100644 index 0000000..343e016 --- /dev/null +++ b/apps/web/app/healthz/route.ts @@ -0,0 +1,6 @@ +/** Liveness for the web container. Renders nothing and never calls the API. */ +export const dynamic = 'force-dynamic'; + +export function GET(): Response { + return Response.json({ ok: true }); +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..ce4e94a --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,7 @@ +/// +/// +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts new file mode 100644 index 0000000..5ff1021 --- /dev/null +++ b/apps/web/next.config.ts @@ -0,0 +1,13 @@ +import createNextIntlPlugin from 'next-intl/plugin'; +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + reactStrictMode: true, + // The workspace packages ship TypeScript source with no build step. + transpilePackages: ['@impuestos/contracts', '@impuestos/i18n'], + output: 'standalone', + // /api is proxied at runtime by app/api/[...path]/route.ts rather than by a rewrite, + // so API_INTERNAL_URL stays a runtime setting. See the comment in that file. +}; + +export default createNextIntlPlugin('./src/i18n/request.ts')(nextConfig); diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..221153b --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,35 @@ +{ + "name": "@impuestos/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev --port 3000", + "build": "next build", + "start": "next start --port 3000", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@impuestos/contracts": "workspace:*", + "@impuestos/i18n": "workspace:*", + "@tanstack/react-query": "^5.102.8", + "better-auth": "^1.7.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.40.0", + "next": "^16.3.4", + "next-intl": "^4.14.2", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "tailwind-merge": "^3.6.0", + "zod": "^4.5.4" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.3.3", + "@types/node": "^26.4.1", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "tailwindcss": "^4.3.3", + "typescript": "^5.9.3" + } +} diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs new file mode 100644 index 0000000..d97b1eb --- /dev/null +++ b/apps/web/postcss.config.mjs @@ -0,0 +1,3 @@ +export default { + plugins: { '@tailwindcss/postcss': {} }, +}; diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts new file mode 100644 index 0000000..adc4043 --- /dev/null +++ b/apps/web/proxy.ts @@ -0,0 +1,10 @@ +import createMiddleware from 'next-intl/middleware'; +import { routing } from './src/i18n/routing'; + +export default createMiddleware(routing); + +export const config = { + // Everything except /api (forwarded to the API by the rewrite), Next internals and + // static files. Locale routing must not touch API requests. + matcher: ['/((?!api|healthz|_next|_vercel|.*\\..*).*)'], +}; diff --git a/apps/web/src/components/language-switcher.tsx b/apps/web/src/components/language-switcher.tsx new file mode 100644 index 0000000..fa53130 --- /dev/null +++ b/apps/web/src/components/language-switcher.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { SUPPORTED_LOCALES, type Locale } from '@impuestos/i18n'; +import { Globe } from 'lucide-react'; +import { useLocale } from 'next-intl'; +import { useTransition } from 'react'; +import { usePathname, useRouter } from '@/i18n/navigation'; +import { useT } from '@/i18n/t'; + +const LABEL_KEY = { es: 'common.languageEs', en: 'common.languageEn' } as const; + +/** + * Compact globe menu, header on marketing and auth screens (FLOWS.md section 1). + * Switching keeps the current page: the locale lives in the URL. + * + * A native select rather than a popover: it is one dependency fewer, and the platform + * control is the better mobile and keyboard experience for a two item choice. + */ +export function LanguageSwitcher() { + const t = useT(); + const locale = useLocale(); + const router = useRouter(); + const pathname = usePathname(); + const [isPending, startTransition] = useTransition(); + + return ( + + ); +} diff --git a/apps/web/src/components/providers.tsx b/apps/web/src/components/providers.tsx new file mode 100644 index 0000000..4af48ea --- /dev/null +++ b/apps/web/src/components/providers.tsx @@ -0,0 +1,15 @@ +'use client'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useState, type ReactNode } from 'react'; + +/** All server state goes through TanStack Query. One client per browser session. */ +export function Providers({ children }: { children: ReactNode }) { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false } }, + }), + ); + return {children}; +} diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx new file mode 100644 index 0000000..02be9f3 --- /dev/null +++ b/apps/web/src/components/ui/button.tsx @@ -0,0 +1,30 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import type { ButtonHTMLAttributes } from 'react'; +import { cn } from '@/lib/utils'; + +const button = cva( + 'inline-flex items-center justify-center gap-2 rounded-full font-medium transition-colors ' + + 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600 ' + + 'disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + primary: 'bg-accent-600 text-white hover:bg-accent-700', + secondary: 'border bg-[var(--surface-raised)] hover:bg-accent-50', + ghost: 'hover:bg-accent-50', + }, + size: { + md: 'h-11 px-5 text-sm', + lg: 'h-13 px-6 text-base', + }, + block: { true: 'w-full', false: '' }, + }, + defaultVariants: { variant: 'primary', size: 'md', block: false }, + }, +); + +export type ButtonProps = ButtonHTMLAttributes & VariantProps; + +export function Button({ className, variant, size, block, ...props }: ButtonProps) { + return