From 0d7651b17cf5f10bd2379503db14c89cda04cee2 Mon Sep 17 00:00:00 2001 From: Michilis Date: Thu, 3 Sep 2026 23:33:10 +0000 Subject: [PATCH] phase-2: identity, from the landing hook to the profile screen Flows A1 to A6 and E3 end to end. A visitor types a RUC on the landing page, sees their real filing dates, registers, verifies a six digit code, grants consent, completes a three step setup and lands on the first run screen, with the profile, consent and audit rows to show for it. API: public RUC lookup behind a token bucket (10/min/IP), the full /me surface (profile, dependents, consents, notification prefs, data export, account deletion), an append-only audit module that exports an insert and nothing else, and a PII module that is the only thing allowed near those tables. Deletion and consent revocation both freeze the account and drop every session, reusing better-auth's ban flag rather than adding a second notion of disabled. Nothing is destroyed yet: the purge is a job for phase 4. deadlineDigit is always derived server side, never accepted from the client. Web: landing with the RUC hook, registration, OTP verification, consent, the setup wizard, the profile screen with "Tus datos", and legal pages that ship as marked placeholders per COPY.md section 13. Money, Skeleton, Switch and EmptyState components added. The seed is now complete for identity: Maria at 4123456-1, filing digit 6 and day 19, with a dependant, consents and prefs; Carlos as an IVA-only company. Two real defects found by building the screens and fixed with tests: the OTP boxes dropped a digit because the handler fired effects inside a setState updater that React 19 invokes twice, and the switch knob rendered outside its track because translate-x-5.5 does not resolve. 232 vitest tests, 26 Playwright tests across mobile and desktop, coverage still 100% on the rules, typecheck and lint clean. Co-Authored-By: Claude Opus 5 --- DECISIONS.md | 64 +++ README.md | 5 +- apps/api/src/db/seed.ts | 202 ++++++++- apps/api/src/http/app.test.ts | 10 +- apps/api/src/http/app.ts | 2 + apps/api/src/http/routes/lookup.ts | 24 ++ apps/api/src/http/routes/me.test.ts | 252 ++++++++++++ apps/api/src/http/routes/me.ts | 167 +++++++- apps/api/src/lib/rate-limit.test.ts | 37 ++ apps/api/src/lib/rate-limit.ts | 54 +++ apps/api/src/modules/audit/index.ts | 51 +++ apps/api/src/modules/lookup/index.ts | 86 ++++ apps/api/src/modules/lookup/lookup.test.ts | 62 +++ apps/api/src/modules/pii/account.ts | 87 ++++ apps/api/src/modules/pii/consents.ts | 81 ++++ apps/api/src/modules/pii/dependents.ts | 68 +++ apps/api/src/modules/pii/index.ts | 6 +- .../api/src/modules/pii/notification-prefs.ts | 60 +++ apps/api/src/modules/pii/profiles.ts | 103 ++++- .../app/[locale]/(app)/configuracion/page.tsx | 8 + .../(app)/configuracion/setup-wizard.tsx | 386 ++++++++++++++++++ .../(app)/consentimiento/consent-form.tsx | 95 +++++ .../[locale]/(app)/consentimiento/page.tsx | 8 + apps/web/app/[locale]/(app)/inicio/page.tsx | 64 +-- apps/web/app/[locale]/(app)/layout.tsx | 24 ++ .../app/[locale]/(app)/perfil/danger-zone.tsx | 132 ++++++ .../(app)/perfil/dependents-section.tsx | 116 ++++++ .../(app)/perfil/identity-section.tsx | 150 +++++++ .../(app)/perfil/notifications-section.tsx | 109 +++++ apps/web/app/[locale]/(app)/perfil/page.tsx | 8 + .../[locale]/(app)/perfil/profile-screen.tsx | 64 +++ .../app/[locale]/(auth)/login/login-form.tsx | 9 +- .../web/app/[locale]/(auth)/registro/page.tsx | 8 + .../(auth)/registro/register-form.tsx | 102 +++++ .../app/[locale]/(auth)/verificar/page.tsx | 21 + .../[locale]/(auth)/verificar/verify-form.tsx | 140 +++++++ apps/web/app/[locale]/(marketing)/layout.tsx | 35 ++ .../(marketing)/legal/placeholder.tsx | 19 + .../(marketing)/legal/privacidad/page.tsx | 10 + .../(marketing)/legal/terminos/page.tsx | 10 + apps/web/app/[locale]/(marketing)/page.tsx | 50 +++ .../web/app/[locale]/(marketing)/ruc-hook.tsx | 111 +++++ apps/web/app/[locale]/page.tsx | 10 - apps/web/package.json | 1 + apps/web/src/components/ui/button.tsx | 6 +- apps/web/src/components/ui/empty-state.tsx | 23 ++ apps/web/src/components/ui/money.tsx | 28 ++ apps/web/src/components/ui/skeleton.tsx | 50 +++ apps/web/src/components/ui/switch.tsx | 45 ++ apps/web/src/lib/auth-client.ts | 4 +- e2e/db.ts | 67 +++ e2e/language.spec.ts | 8 +- e2e/onboarding.spec.ts | 87 ++++ e2e/profile.spec.ts | 111 +++++ package.json | 1 + packages/contracts/src/client.ts | 54 ++- packages/contracts/src/dto.ts | 89 +++- packages/contracts/src/index.ts | 20 +- packages/i18n/src/catalogs/en.ts | 71 ++++ packages/i18n/src/catalogs/es.extra.ts | 71 ++++ pnpm-lock.yaml | 12 +- 61 files changed, 3776 insertions(+), 82 deletions(-) create mode 100644 apps/api/src/http/routes/lookup.ts create mode 100644 apps/api/src/http/routes/me.test.ts create mode 100644 apps/api/src/lib/rate-limit.test.ts create mode 100644 apps/api/src/lib/rate-limit.ts create mode 100644 apps/api/src/modules/audit/index.ts create mode 100644 apps/api/src/modules/lookup/index.ts create mode 100644 apps/api/src/modules/lookup/lookup.test.ts create mode 100644 apps/api/src/modules/pii/account.ts create mode 100644 apps/api/src/modules/pii/consents.ts create mode 100644 apps/api/src/modules/pii/dependents.ts create mode 100644 apps/api/src/modules/pii/notification-prefs.ts create mode 100644 apps/web/app/[locale]/(app)/configuracion/page.tsx create mode 100644 apps/web/app/[locale]/(app)/configuracion/setup-wizard.tsx create mode 100644 apps/web/app/[locale]/(app)/consentimiento/consent-form.tsx create mode 100644 apps/web/app/[locale]/(app)/consentimiento/page.tsx create mode 100644 apps/web/app/[locale]/(app)/layout.tsx create mode 100644 apps/web/app/[locale]/(app)/perfil/danger-zone.tsx create mode 100644 apps/web/app/[locale]/(app)/perfil/dependents-section.tsx create mode 100644 apps/web/app/[locale]/(app)/perfil/identity-section.tsx create mode 100644 apps/web/app/[locale]/(app)/perfil/notifications-section.tsx create mode 100644 apps/web/app/[locale]/(app)/perfil/page.tsx create mode 100644 apps/web/app/[locale]/(app)/perfil/profile-screen.tsx create mode 100644 apps/web/app/[locale]/(auth)/registro/page.tsx create mode 100644 apps/web/app/[locale]/(auth)/registro/register-form.tsx create mode 100644 apps/web/app/[locale]/(auth)/verificar/page.tsx create mode 100644 apps/web/app/[locale]/(auth)/verificar/verify-form.tsx create mode 100644 apps/web/app/[locale]/(marketing)/layout.tsx create mode 100644 apps/web/app/[locale]/(marketing)/legal/placeholder.tsx create mode 100644 apps/web/app/[locale]/(marketing)/legal/privacidad/page.tsx create mode 100644 apps/web/app/[locale]/(marketing)/legal/terminos/page.tsx create mode 100644 apps/web/app/[locale]/(marketing)/page.tsx create mode 100644 apps/web/app/[locale]/(marketing)/ruc-hook.tsx delete mode 100644 apps/web/app/[locale]/page.tsx create mode 100644 apps/web/src/components/ui/empty-state.tsx create mode 100644 apps/web/src/components/ui/money.tsx create mode 100644 apps/web/src/components/ui/skeleton.tsx create mode 100644 apps/web/src/components/ui/switch.tsx create mode 100644 e2e/db.ts create mode 100644 e2e/onboarding.spec.ts create mode 100644 e2e/profile.spec.ts diff --git a/DECISIONS.md b/DECISIONS.md index e49cbda..193961b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -209,3 +209,67 @@ the Playwright default base URL uses localhost. This deviates from the literal example values in SPEC.md section 15, which use port 3000. The compose stack still publishes 3000 and is unaffected. + + +--- + +## Phase 2 + +### The seed is now complete for identity +Maria is an individual whose RUC is her CI plus a check digit: base `4123456`, DV computed +by `computeRucDv`, so her filing digit is 6 and her day is the 19th, which is the example +FLOWS.md uses throughout. Carlos is a company registered for IVA only, so the IVA-only view +has real data behind it. Both have consents, notification preferences and the audit rows +CONTRACTS.md section 4 asks for. Documents arrive in phase 3. + +### `GET /lookup/ruc/:number` reads a bare number as a CI +A number with a hyphen and a check digit is a RUC and the digit is verified. A bare number +is a CI, which is what individuals type when their RUC is their CI plus a digit; the filing +day is identical either way, which is the only thing the landing card shows. Neither form +touches the database: the endpoint reveals only what the number itself already encodes. + +An unparseable number is a 200 with `valid: false`, per CONTRACTS.md section 3, so the +landing corrects the user inline rather than dead ending. + +### Freezing an account reuses better-auth's ban flag +Both a deletion and a data processing consent revocation set `banned` with a distinct +`banReason` and drop every session. That reuses the check better-auth's sign in path +already performs, rather than introducing a second, parallel notion of a disabled account +that some code path would eventually forget to check. Nothing is destroyed yet: the purge +is a job, which lands with the jobs module in phase 4 (`softDeleteAccount` carries the TODO). + +### `deadlineDigit` is derived, never accepted from the client +`PUT /me/profile` takes `ProfileInput`, which is `ProfileDto` minus `deadlineDigit`. The +digit decides real filing dates, so it is computed from the identity document server side. +The document itself is read only on the profile screen for the same reason: changing it is +a support conversation, not a text field. + +### Deactivating a dependant rather than deleting it +A confirmed document may already be classified against a dependant, and that classification +has to keep making sense. `DELETE /me/dependents/:id` sets `active = 0`; the list filters on +it. Scoped by `user_id`, so one user cannot touch another's row (404, tested). + +### Two real defects found by building the screens +**The OTP boxes dropped a digit.** The handler spread the pasted code inside a `setState` +updater and fired the focus move and the verify request from in there. React 19 invokes +updaters twice in development to check they are pure, so the effects ran twice and a digit +was lost. The spread is now computed outside the updater. Caught by golden path 1. + +**The switch knob rendered outside its track.** `translate-x-5.5` does not resolve, so the +knob sat 44px along a 44px track, entirely outside it. Now `left-0.5` plus `translate-x-5`, +both on the spacing scale. `e2e/profile.spec.ts` asserts the knob stays within the track +bounds in both states, because this is invisible to every other kind of test. + +### Deferred, deliberately +- **Motion.** FLOWS.md A2 and A6 call for GSAP (the card flip, the deadline stagger, the + first savings counter) and A1 for a canvas hero effect. The phase plan puts the motion + pass and the canvas touches in phase 7, so the screens here are built with the right + structure and states and no animation. +- **Push and Telegram.** Hidden until configured (FLOWS.md section 9). Setup step 3 offers + only email today; `POST /push/subscribe` needs the service worker and arrives in phase 7. +- **`/comprobantes`.** The first run buttons point at it. Ingestion is phase 3. + +### The legal pages ship as marked placeholders +`/legal/privacidad` and `/legal/terminos` render "Documento en preparacion" and say a +document is being drafted, per COPY.md section 13. No legal text was generated. +**TODO: human written before launch.** diff --git a/README.md b/README.md index dd791a5..6141f41 100644 --- a/README.md +++ b/README.md @@ -7,8 +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 1 of 8.** Foundation and the tax rules. `packages/rules` is complete and -> covered; there is no ingestion, dashboard or declaration UI on top of it yet. +> **Status: phase 2 of 8.** Foundation, the tax rules, and identity: landing, sign up, +> onboarding and the profile screen all work end to end. Ingestion, the dashboard and +> declarations are still ahead. --- diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts index d30f065..4983244 100644 --- a/apps/api/src/db/seed.ts +++ b/apps/api/src/db/seed.ts @@ -1,4 +1,8 @@ +import { computeRucDv } from '@impuestos/rules'; +import { uuidv7 } from 'uuidv7'; import type { Auth, Role } from '../auth/options'; +import { writeAudit } from '../modules/audit'; +import { CONSENT_TEXT_VERSION } from '../modules/pii'; import type { DbHandle } from './index'; export interface SeedAccount { @@ -11,10 +15,6 @@ export interface SeedAccount { /** * Accounts per CONTRACTS.md section 4. Deterministic and idempotent: running the seed * twice leaves the same rows. - * - * Their profiles, documents and declarations are seeded by the phases that own those - * tables. Until then `GET /me/profile` correctly answers 404 for each of them, which is - * the documented state for a user who has not finished setup. */ export const SEED_ACCOUNTS: readonly SeedAccount[] = [ { email: 'superadmin@demo.local', password: 'demo-superadmin-1', name: 'Super Admin', role: 'superadmin' }, @@ -23,6 +23,17 @@ export const SEED_ACCOUNTS: readonly SeedAccount[] = [ { email: 'carlos@demo.local', password: 'demo-carlos-1', name: 'Carlos Benitez', role: 'user' }, ]; +/** + * The showcase account: an individual whose RUC is her CI plus a check digit, registered + * for both IVA and IRP. Base 4123456 ends in 6, so her filing day is the 19th, which is + * the example FLOWS.md uses throughout. + */ +export const MARIA_RUC_BASE = '4123456'; +/** IVA only, to exercise the view with no IRP anywhere in it. */ +export const CARLOS_RUC_BASE = '80012345'; + +const SEEDED_AT = '2026-01-15T12:00:00.000Z'; + export interface SeedResult { created: string[]; existing: string[]; @@ -30,9 +41,10 @@ export interface SeedResult { export async function seed(handle: DbHandle, auth: Auth): Promise { const result: SeedResult = { created: [], existing: [] }; + const db = handle.db; for (const account of SEED_ACCOUNTS) { - const found = await handle.db + const found = await db .selectFrom('user') .select('id') .where('email', '=', account.email) @@ -49,14 +61,190 @@ export async function seed(handle: DbHandle, auth: Auth): Promise { // Roles and verification are set directly: the sign up endpoint always creates a // plain unverified `user`, and demo accounts need to be usable straight away. - await handle.db + await db .updateTable('user') - .set({ role: account.role, emailVerified: 1, updatedAt: new Date().toISOString() }) + .set({ role: account.role, emailVerified: 1, updatedAt: SEEDED_AT }) .where('email', '=', account.email) .execute(); result.created.push(account.email); } + await seedProfiles(handle); + await seedAuditTrail(handle); return result; } + +async function seedProfiles(handle: DbHandle): Promise { + const db = handle.db; + + const maria = await userIdFor(handle, 'maria@demo.local'); + const carlos = await userIdFor(handle, 'carlos@demo.local'); + + await upsertProfileRow(handle, { + userId: maria, + fullName: 'Maria Gonzalez', + docType: 'ruc', + ruc: MARIA_RUC_BASE, + rucDv: String(computeRucDv(MARIA_RUC_BASE)), + ci: MARIA_RUC_BASE, + taxpayerKind: 'individual', + obligations: [ + { code: 'iva_120', active: true, since: '2024-01-01' }, + { code: 'irp_515', active: true, since: '2024-01-01' }, + ], + irpGrossEstimate: 180_000_000, + }); + + await upsertProfileRow(handle, { + userId: carlos, + fullName: 'Benitez y Asociados SRL', + docType: 'ruc', + ruc: CARLOS_RUC_BASE, + rucDv: String(computeRucDv(CARLOS_RUC_BASE)), + ci: null, + taxpayerKind: 'company', + obligations: [{ code: 'iva_120', active: true, since: '2023-06-01' }], + irpGrossEstimate: null, + }); + + const hasDependent = await db + .selectFrom('dependents') + .select('id') + .where('user_id', '=', maria) + .executeTakeFirst(); + + if (!hasDependent) { + await db + .insertInto('dependents') + .values({ + id: uuidv7(), + user_id: maria, + display_name: 'Lucas Gonzalez', + relationship: 'hijo', + doc_number: null, + active: 1, + created_at: SEEDED_AT, + updated_at: SEEDED_AT, + }) + .execute(); + } + + for (const userId of [maria, carlos]) { + for (const kind of ['data_processing', 'notifications'] as const) { + const existing = await db + .selectFrom('consents') + .select('id') + .where('user_id', '=', userId) + .where('kind', '=', kind) + .executeTakeFirst(); + if (existing) continue; + + await db + .insertInto('consents') + .values({ + id: uuidv7(), + user_id: userId, + kind, + granted_at: SEEDED_AT, + revoked_at: null, + text_version: CONSENT_TEXT_VERSION, + }) + .execute(); + } + + const prefs = await db + .selectFrom('notification_prefs') + .select('user_id') + .where('user_id', '=', userId) + .executeTakeFirst(); + if (!prefs) { + await db + .insertInto('notification_prefs') + .values({ + user_id: userId, + push_enabled: 0, + email_enabled: 1, + telegram_chat_id: null, + digest_hour: 9, + }) + .execute(); + } + } +} + +/** CONTRACTS.md section 4: the audit table starts with the role assignments. */ +async function seedAuditTrail(handle: DbHandle): Promise { + const existing = await handle.db + .selectFrom('audit_log') + .select('id') + .where('action', '=', 'admin.role_change') + .executeTakeFirst(); + if (existing) return; + + const superadmin = await userIdFor(handle, 'superadmin@demo.local'); + + for (const email of ['staff@demo.local'] as const) { + await writeAudit(handle.db, { + actorUserId: superadmin, + actorRole: 'superadmin', + subjectUserId: await userIdFor(handle, email), + action: 'admin.role_change', + resource: 'user', + detail: { role: 'staff', seeded: true }, + }); + } +} + +interface ProfileSeed { + userId: string; + fullName: string; + docType: 'ruc' | 'ci'; + ruc: string | null; + rucDv: string | null; + ci: string | null; + taxpayerKind: 'individual' | 'company'; + obligations: { code: 'iva_120' | 'irp_515'; active: boolean; since: string }[]; + irpGrossEstimate: number | null; +} + +async function upsertProfileRow(handle: DbHandle, seed: ProfileSeed): Promise { + const existing = await handle.db + .selectFrom('profiles') + .select('user_id') + .where('user_id', '=', seed.userId) + .executeTakeFirst(); + if (existing) return; + + const base = seed.ruc ?? seed.ci; + if (!base) throw new Error(`seed profile for ${seed.fullName} needs a RUC or a CI`); + + await handle.db + .insertInto('profiles') + .values({ + user_id: seed.userId, + full_name: seed.fullName, + doc_type: seed.docType, + ruc: seed.ruc, + ruc_dv: seed.rucDv, + ci: seed.ci, + taxpayer_kind: seed.taxpayerKind, + deadline_digit: Number(base[base.length - 1]), + obligations: JSON.stringify(seed.obligations), + irp_gross_estimate: seed.irpGrossEstimate, + auto_confirm_days: 7, + locale: 'es', + created_at: SEEDED_AT, + updated_at: SEEDED_AT, + }) + .execute(); +} + +async function userIdFor(handle: DbHandle, email: string): Promise { + const row = await handle.db + .selectFrom('user') + .select('id') + .where('email', '=', email) + .executeTakeFirstOrThrow(); + return row.id; +} diff --git a/apps/api/src/http/app.test.ts b/apps/api/src/http/app.test.ts index 4a94f5c..528ba10 100644 --- a/apps/api/src/http/app.test.ts +++ b/apps/api/src/http/app.test.ts @@ -70,9 +70,17 @@ describe('error envelope', () => { }); describe('sessions', () => { - it('signs a seeded account in and answers 404 until setup is complete', async () => { + it('signs a seeded account in and serves its profile', async () => { const cookie = await h.signIn('maria@demo.local', 'demo-maria-1'); const response = await h.app.request('/api/me/profile', { headers: { cookie } }); + expect(response.status).toBe(200); + }); + + // Staff accounts are seeded without a profile, which is the same state a brand new + // sign up is in: the client reads the 404 and routes to onboarding. + it('answers 404 for an account that has not finished setup', async () => { + const cookie = await h.signIn('staff@demo.local', 'demo-staff-1'); + const response = await h.app.request('/api/me/profile', { headers: { cookie } }); expect(response.status).toBe(404); expect(ErrorEnvelope.parse(await response.json()).error.code).toBe('not_found'); }); diff --git a/apps/api/src/http/app.ts b/apps/api/src/http/app.ts index bc3c49c..96d23e7 100644 --- a/apps/api/src/http/app.ts +++ b/apps/api/src/http/app.ts @@ -3,6 +3,7 @@ import type { AppDeps, AppEnv } from './context'; import { HttpError, toEnvelope } from './errors'; import { liveness, readiness } from './health'; import { localeMiddleware, sessionMiddleware } from './middleware'; +import { lookupRoutes } from './routes/lookup'; import { meRoutes } from './routes/me'; export interface AppHandle { @@ -45,6 +46,7 @@ export function createApp(deps: AppDeps): AppHandle { const api = new Hono(); api.use('*', sessionMiddleware(deps)); api.use('*', localeMiddleware(deps)); + api.route('/lookup', lookupRoutes()); api.route('/me', meRoutes(deps)); app.route('/api', api); diff --git a/apps/api/src/http/routes/lookup.ts b/apps/api/src/http/routes/lookup.ts new file mode 100644 index 0000000..ab31bc5 --- /dev/null +++ b/apps/api/src/http/routes/lookup.ts @@ -0,0 +1,24 @@ +import { Hono } from 'hono'; +import { createTokenBucket } from '../../lib/rate-limit'; +import { lookupNumber } from '../../modules/lookup'; +import type { AppEnv } from '../context'; +import { HttpError } from '../errors'; + +/** CONTRACTS.md section 3: 10 per minute per IP on the public lookup. */ +const limiter = createTokenBucket({ capacity: 10, refillMs: 60_000 }); + +export function lookupRoutes(): Hono { + const routes = new Hono(); + + routes.get('/ruc/:number', (c) => { + const ip = + c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? + c.req.header('x-real-ip') ?? + 'unknown'; + if (!limiter.take(ip)) throw new HttpError('rate_limited'); + + return c.json(lookupNumber(c.req.param('number'), new Date())); + }); + + return routes; +} diff --git a/apps/api/src/http/routes/me.test.ts b/apps/api/src/http/routes/me.test.ts new file mode 100644 index 0000000..9c8b46f --- /dev/null +++ b/apps/api/src/http/routes/me.test.ts @@ -0,0 +1,252 @@ +import { DataExportDto, DependentDto, ProfileDto } from '@impuestos/contracts'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { createHarness, type Harness } from '../../test/harness'; + +let h: Harness; +let cookie: string; + +const PROFILE_INPUT = { + fullName: 'Maria Gonzalez', + docType: 'ruc' as const, + ruc: '4123456', + rucDv: '1', + ci: '4123456', + taxpayerKind: 'individual' as const, + obligations: [{ code: 'iva_120' as const, active: true, since: '2026-01-01' }], + irpGrossEstimate: 180_000_000, + autoConfirmDays: 7, + locale: 'es' as const, +}; + +beforeAll(async () => { + h = await createHarness(); +}); +afterAll(async () => { + await h.close(); +}); +beforeEach(async () => { + cookie = await h.signIn('maria@demo.local', 'demo-maria-1'); +}); + +const json = (path: string, init: RequestInit = {}) => + h.app.request(path, { + ...init, + headers: { cookie, 'content-type': 'application/json', ...(init.headers ?? {}) }, + }); + +describe('GET /me/profile', () => { + it('returns the seeded profile', async () => { + const response = await json('/api/me/profile'); + expect(response.status).toBe(200); + + const profile = ProfileDto.parse(await response.json()); + expect(profile.fullName).toBe('Maria Gonzalez'); + expect(profile.ruc).toBe('4123456'); + // Base ends in 6, so her filing day is the 19th. + expect(profile.deadlineDigit).toBe(6); + expect(profile.irpGrossEstimate).toBe(180_000_000); + }); + + it('needs a session', async () => { + expect((await h.app.request('/api/me/profile')).status).toBe(401); + }); +}); + +describe('PUT /me/profile', () => { + it('derives the deadline digit rather than trusting the client', async () => { + const response = await json('/api/me/profile', { + method: 'PUT', + // A deadlineDigit in the body is ignored: the schema does not accept it and the + // value is computed from the document. + body: JSON.stringify({ ...PROFILE_INPUT, ruc: '4123450', ci: '4123450', deadlineDigit: 9 }), + }); + + expect(response.status).toBe(200); + expect(ProfileDto.parse(await response.json()).deadlineDigit).toBe(0); + }); + + it('rejects a body that is not a profile', async () => { + const response = await json('/api/me/profile', { + method: 'PUT', + body: JSON.stringify({ fullName: '' }), + }); + expect(response.status).toBe(400); + }); + + it('writes an audit row naming the actor and the subject', async () => { + await json('/api/me/profile', { method: 'PUT', body: JSON.stringify(PROFILE_INPUT) }); + + const rows = await h.deps.handle.db + .selectFrom('audit_log') + .selectAll() + .where('action', '=', 'profile.update') + .execute(); + + expect(rows.length).toBeGreaterThan(0); + const row = rows.at(-1); + expect(row?.actor_user_id).toBe(row?.subject_user_id); + expect(row?.resource).toBe('profiles'); + }); +}); + +describe('dependents', () => { + it('creates, lists and deactivates', async () => { + const created = DependentDto.parse( + await ( + await json('/api/me/dependents', { + method: 'POST', + body: JSON.stringify({ displayName: 'Ana Gonzalez', relationship: 'hijo' }), + }) + ).json(), + ); + expect(created.displayName).toBe('Ana Gonzalez'); + + const listed = DependentDto.array().parse(await (await json('/api/me/dependents')).json()); + expect(listed.map((d) => d.displayName)).toContain('Ana Gonzalez'); + + const deleted = await json(`/api/me/dependents/${created.id}`, { method: 'DELETE' }); + expect(deleted.status).toBe(200); + + const after = DependentDto.array().parse(await (await json('/api/me/dependents')).json()); + expect(after.map((d) => d.id)).not.toContain(created.id); + }); + + it('cannot deactivate a dependent belonging to someone else', async () => { + const created = DependentDto.parse( + await ( + await json('/api/me/dependents', { + method: 'POST', + body: JSON.stringify({ displayName: 'Ana Gonzalez', relationship: 'hijo' }), + }) + ).json(), + ); + + const carlos = await h.signIn('carlos@demo.local', 'demo-carlos-1'); + const response = await h.app.request(`/api/me/dependents/${created.id}`, { + method: 'DELETE', + headers: { cookie: carlos }, + }); + expect(response.status).toBe(404); + }); + + it('rejects an empty name', async () => { + const response = await json('/api/me/dependents', { + method: 'POST', + body: JSON.stringify({ displayName: ' ', relationship: 'hijo' }), + }); + expect(response.status).toBe(400); + }); +}); + +describe('GET /me/data-export', () => { + it('contains the seeded profile and offers itself as a download', async () => { + const response = await json('/api/me/data-export'); + expect(response.status).toBe(200); + expect(response.headers.get('content-disposition')).toContain('attachment'); + + const data = DataExportDto.parse(await response.json()); + expect(data.account.email).toBe('maria@demo.local'); + expect(data.profile?.fullName).toBe('Maria Gonzalez'); + expect(data.dependents.map((d) => d.displayName)).toContain('Lucas Gonzalez'); + expect(data.consents.map((c) => c.kind)).toContain('data_processing'); + expect(data.notificationPrefs?.digestHour).toBe(9); + }); + + // CONTRACTS.md 5.6: one taxpayer's export must never carry another's rows. + it('carries nothing belonging to another user', async () => { + const mine = DataExportDto.parse(await (await json('/api/me/data-export')).json()); + + const carlos = await h.signIn('carlos@demo.local', 'demo-carlos-1'); + const theirs = DataExportDto.parse( + await (await h.app.request('/api/me/data-export', { headers: { cookie: carlos } })).json(), + ); + + expect(theirs.account.email).toBe('carlos@demo.local'); + expect(theirs.profile?.fullName).toBe('Benitez y Asociados SRL'); + expect(theirs.dependents).toEqual([]); + + const myDocumentIds = new Set(mine.documents.map((d) => d['id'])); + for (const document of theirs.documents) { + expect(myDocumentIds.has(document['id'])).toBe(false); + } + }); + + it('writes an audit row', async () => { + await json('/api/me/data-export'); + const rows = await h.deps.handle.db + .selectFrom('audit_log') + .selectAll() + .where('action', '=', 'data.export') + .execute(); + expect(rows.length).toBeGreaterThan(0); + }); +}); + +describe('notification prefs', () => { + it('returns defaults and applies a partial update', async () => { + const before = await (await json('/api/me/notification-prefs')).json(); + expect(before).toMatchObject({ emailEnabled: true, digestHour: 9 }); + + const after = await ( + await json('/api/me/notification-prefs', { + method: 'PATCH', + body: JSON.stringify({ digestHour: 20 }), + }) + ).json(); + expect(after).toMatchObject({ digestHour: 20, emailEnabled: true }); + }); + + it('rejects an hour outside the day', async () => { + const response = await json('/api/me/notification-prefs', { + method: 'PATCH', + body: JSON.stringify({ digestHour: 24 }), + }); + expect(response.status).toBe(400); + }); +}); + +describe('DELETE /me/account', () => { + it('refuses unless the confirmation matches the email', async () => { + const response = await json('/api/me/account', { + method: 'DELETE', + body: JSON.stringify({ confirmText: 'not-my-email' }), + }); + expect(response.status).toBe(400); + }); + + it('freezes the account, drops every session and audits it', async () => { + const carlos = await h.signIn('carlos@demo.local', 'demo-carlos-1'); + const response = await h.app.request('/api/me/account', { + method: 'DELETE', + headers: { cookie: carlos, 'content-type': 'application/json' }, + body: JSON.stringify({ confirmText: 'Carlos@Demo.Local' }), + }); + expect(response.status).toBe(200); + + const user = await h.deps.handle.db + .selectFrom('user') + .select(['id', 'banned', 'banReason']) + .where('email', '=', 'carlos@demo.local') + .executeTakeFirstOrThrow(); + expect(user.banned).toBe(1); + expect(user.banReason).toBe('account_deleted'); + + const sessions = await h.deps.handle.db + .selectFrom('session') + .select('id') + .where('userId', '=', user.id) + .execute(); + expect(sessions).toEqual([]); + + // The session cookie is now worthless. + expect((await h.app.request('/api/me/profile', { headers: { cookie: carlos } })).status).toBe(401); + + const audited = await h.deps.handle.db + .selectFrom('audit_log') + .select('id') + .where('action', '=', 'account.delete') + .where('subject_user_id', '=', user.id) + .execute(); + expect(audited.length).toBe(1); + }); +}); diff --git a/apps/api/src/http/routes/me.ts b/apps/api/src/http/routes/me.ts index 4921f01..bda97c2 100644 --- a/apps/api/src/http/routes/me.ts +++ b/apps/api/src/http/routes/me.ts @@ -1,19 +1,180 @@ +import { + ConsentInput, + DeleteAccountInput, + DependentInput, + NotificationPrefsInput, + ProfileInput, +} from '@impuestos/contracts'; import { Hono } from 'hono'; -import { getProfile } from '../../modules/pii'; -import type { AppDeps, AppEnv } from '../context'; +import type { z } from 'zod'; +import { writeAudit } from '../../modules/audit'; +import { + buildDataExport, + createDependent, + deactivateDependent, + getNotificationPrefs, + getProfile, + listDependents, + setConsent, + softDeleteAccount, + updateNotificationPrefs, + upsertProfile, +} from '../../modules/pii'; +import type { AppDeps, AppEnv, SessionUser } from '../context'; import { HttpError } from '../errors'; import { requireUser } from '../middleware'; export function meRoutes(deps: AppDeps): Hono { const routes = new Hono(); + const db = deps.handle.db; // 404 until setup is complete: the client routes to onboarding (CONTRACTS.md section 3). routes.get('/profile', async (c) => { const user = requireUser(c); - const profile = await getProfile(deps.handle.db, user.id); + const profile = await getProfile(db, user.id); if (!profile) throw new HttpError('not_found'); return c.json(profile); }); + routes.put('/profile', async (c) => { + const user = requireUser(c); + const input = parse(ProfileInput, await body(c)); + + if (input.docType === 'ruc' && !input.ruc && !input.ci) { + throw new HttpError('validation_error', { field: 'ruc' }); + } + if (input.docType === 'ci' && !input.ci && !input.ruc) { + throw new HttpError('validation_error', { field: 'ci' }); + } + + const { profile, created } = await upsertProfile(db, user.id, input); + await audit(c, deps, user, { + action: created ? 'profile.create' : 'profile.update', + resource: 'profiles', + detail: { docType: profile.docType, taxpayerKind: profile.taxpayerKind }, + }); + return c.json(profile); + }); + + routes.get('/dependents', async (c) => { + const user = requireUser(c); + return c.json(await listDependents(db, user.id)); + }); + + routes.post('/dependents', async (c) => { + const user = requireUser(c); + const dependent = await createDependent(db, user.id, parse(DependentInput, await body(c))); + await audit(c, deps, user, { + action: 'dependent.create', + resource: `dependents/${dependent.id}`, + }); + return c.json(dependent, 201); + }); + + routes.delete('/dependents/:id', async (c) => { + const user = requireUser(c); + const id = c.req.param('id'); + if (!(await deactivateDependent(db, user.id, id))) throw new HttpError('not_found'); + await audit(c, deps, user, { action: 'dependent.delete', resource: `dependents/${id}` }); + return c.json({ ok: true } as const); + }); + + /** + * Revoking data processing consent is a withdrawal of the basis on which we hold the + * data at all, so it freezes the account and drops every session, exactly like a + * deletion (CONTRACTS.md section 3). + */ + routes.post('/consents', async (c) => { + const user = requireUser(c); + const input = parse(ConsentInput, await body(c)); + + await setConsent(db, user.id, input.kind, input.granted); + await audit(c, deps, user, { + action: input.granted ? 'consent.grant' : 'consent.revoke', + resource: `consents/${input.kind}`, + detail: { kind: input.kind }, + }); + + if (input.kind === 'data_processing' && !input.granted) { + await softDeleteAccount(db, user.id, 'consent_revoked'); + } + return c.json({ ok: true } as const); + }); + + routes.get('/notification-prefs', async (c) => { + const user = requireUser(c); + return c.json(await getNotificationPrefs(db, user.id)); + }); + + routes.patch('/notification-prefs', async (c) => { + const user = requireUser(c); + const prefs = await updateNotificationPrefs(db, user.id, parse(NotificationPrefsInput, await body(c))); + await audit(c, deps, user, { + action: 'notification_prefs.update', + resource: 'notification_prefs', + }); + return c.json(prefs); + }); + + routes.get('/data-export', async (c) => { + const user = requireUser(c); + const data = await buildDataExport(db, user.id); + await audit(c, deps, user, { action: 'data.export', resource: 'data-export' }); + + const filename = `impuestos-datos-${new Date().toISOString().slice(0, 10)}.json`; + c.header('content-disposition', `attachment; filename="${filename}"`); + return c.json(data); + }); + + routes.delete('/account', async (c) => { + const user = requireUser(c); + const input = parse(DeleteAccountInput, await body(c)); + + // Typing the email is the second confirmation. Compared case insensitively because + // the keyboard on a phone will capitalise the first letter. + if (input.confirmText.trim().toLowerCase() !== user.email.toLowerCase()) { + throw new HttpError('validation_error', { field: 'confirmText' }); + } + + await audit(c, deps, user, { action: 'account.delete', resource: 'user' }); + await softDeleteAccount(db, user.id, 'account_deleted'); + return c.json({ ok: true } as const); + }); + return routes; } + +async function body(c: { req: { json: () => Promise } }): Promise { + try { + return await c.req.json(); + } catch { + throw new HttpError('validation_error'); + } +} + +function parse(schema: z.ZodType, value: unknown): T { + const result = schema.safeParse(value); + if (!result.success) { + const issue = result.error.issues[0]; + throw new HttpError('validation_error', { + ...(issue?.path.length ? { field: issue.path.join('.') } : {}), + detail: result.error.issues, + }); + } + return result.data; +} + +function audit( + c: { req: { header: (name: string) => string | undefined } }, + deps: AppDeps, + user: SessionUser, + entry: { action: Parameters[1]['action']; resource: string; detail?: Record }, +): Promise { + return writeAudit(deps.handle.db, { + actorUserId: user.id, + actorRole: user.role, + subjectUserId: user.id, + ip: c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? null, + ...entry, + }); +} diff --git a/apps/api/src/lib/rate-limit.test.ts b/apps/api/src/lib/rate-limit.test.ts new file mode 100644 index 0000000..e8cdbf8 --- /dev/null +++ b/apps/api/src/lib/rate-limit.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { createTokenBucket } from './rate-limit'; + +describe('createTokenBucket', () => { + it('allows a full burst then refuses', () => { + const limiter = createTokenBucket({ capacity: 10, refillMs: 60_000, now: () => 0 }); + for (let i = 0; i < 10; i++) expect(limiter.take('ip'), `call ${i}`).toBe(true); + expect(limiter.take('ip')).toBe(false); + }); + + it('keeps buckets separate per key', () => { + const limiter = createTokenBucket({ capacity: 1, refillMs: 60_000, now: () => 0 }); + expect(limiter.take('a')).toBe(true); + expect(limiter.take('a')).toBe(false); + expect(limiter.take('b')).toBe(true); + }); + + it('refills over time', () => { + let clock = 0; + const limiter = createTokenBucket({ capacity: 10, refillMs: 60_000, now: () => clock }); + for (let i = 0; i < 10; i++) limiter.take('ip'); + expect(limiter.take('ip')).toBe(false); + + clock += 6_000; // a tenth of the window is one token + expect(limiter.take('ip')).toBe(true); + expect(limiter.take('ip')).toBe(false); + }); + + it('never refills past capacity', () => { + let clock = 0; + const limiter = createTokenBucket({ capacity: 2, refillMs: 1_000, now: () => clock }); + clock += 1_000_000; + expect(limiter.take('ip')).toBe(true); + expect(limiter.take('ip')).toBe(true); + expect(limiter.take('ip')).toBe(false); + }); +}); diff --git a/apps/api/src/lib/rate-limit.ts b/apps/api/src/lib/rate-limit.ts new file mode 100644 index 0000000..1add0db --- /dev/null +++ b/apps/api/src/lib/rate-limit.ts @@ -0,0 +1,54 @@ +/** + * Token bucket, in memory, per replica (SPEC.md section 6). + * + * Per replica limits are deliberate at this scale: the alternative is a shared store on + * the request path for an endpoint that only guards a public lookup. N replicas means N + * times the limit, which is documented in the README. The interface is here so swapping + * in a shared implementation later touches one file. + */ +export interface RateLimiter { + /** False when the caller is over budget. */ + take(key: string, cost?: number): boolean; +} + +interface Bucket { + tokens: number; + updatedAt: number; +} + +export function createTokenBucket(options: { + /** Bucket size, which is also the burst allowance. */ + capacity: number; + /** Milliseconds for a full refill. */ + refillMs: number; + now?: () => number; +}): RateLimiter { + const buckets = new Map(); + const now = options.now ?? Date.now; + const ratePerMs = options.capacity / options.refillMs; + + return { + take(key, cost = 1) { + const at = now(); + const bucket = buckets.get(key) ?? { tokens: options.capacity, updatedAt: at }; + + const refilled = Math.min( + options.capacity, + bucket.tokens + (at - bucket.updatedAt) * ratePerMs, + ); + const allowed = refilled >= cost; + + buckets.set(key, { tokens: allowed ? refilled - cost : refilled, updatedAt: at }); + + // Buckets that have refilled to full carry no state worth keeping. Dropping them + // keeps the map bounded by the number of recently active clients. + if (buckets.size > 10_000) { + for (const [otherKey, other] of buckets) { + if (other.tokens >= options.capacity && otherKey !== key) buckets.delete(otherKey); + } + } + + return allowed; + }, + }; +} diff --git a/apps/api/src/modules/audit/index.ts b/apps/api/src/modules/audit/index.ts new file mode 100644 index 0000000..97194b8 --- /dev/null +++ b/apps/api/src/modules/audit/index.ts @@ -0,0 +1,51 @@ +import type { Kysely } from 'kysely'; +import { uuidv7 } from 'uuidv7'; +import type { Database } from '../../db/schema'; + +/** + * The audit log is append only. This module exports an insert and nothing else: there is + * no update or delete anywhere in the codebase, which is the whole guarantee. + */ + +export type AuditAction = + | 'profile.create' + | 'profile.update' + | 'consent.grant' + | 'consent.revoke' + | 'dependent.create' + | 'dependent.delete' + | 'notification_prefs.update' + | 'data.export' + | 'account.delete' + | 'admin.user_lookup' + | 'admin.user_view' + | 'admin.role_change' + | 'admin.file_access'; + +export interface AuditEntry { + actorUserId: string; + actorRole: string; + action: AuditAction; + /** Whose data this touched. Equal to the actor for a user acting on themselves. */ + subjectUserId?: string | null; + resource: string; + detail?: Record | undefined; + ip?: string | null; +} + +export async function writeAudit(db: Kysely, entry: AuditEntry): Promise { + await db + .insertInto('audit_log') + .values({ + id: uuidv7(), + actor_user_id: entry.actorUserId, + actor_role: entry.actorRole, + action: entry.action, + subject_user_id: entry.subjectUserId ?? entry.actorUserId, + resource: entry.resource, + detail: entry.detail === undefined ? null : JSON.stringify(entry.detail), + ip: entry.ip ?? null, + created_at: new Date().toISOString(), + }) + .execute(); +} diff --git a/apps/api/src/modules/lookup/index.ts b/apps/api/src/modules/lookup/index.ts new file mode 100644 index 0000000..670935d --- /dev/null +++ b/apps/api/src/modules/lookup/index.ts @@ -0,0 +1,86 @@ +import type { LookupDto } from '@impuestos/contracts'; +import { + computeRucDv, + deadlineDay, + deadlineDigit, + dueDateFor, + formatIsoDate, + fromDate, + nextDeadline, + addMonths, +} from '@impuestos/rules'; + +const RUC_WITH_DV = /^(\d{1,8})-(\d)$/; +const BARE_DIGITS = /^\d{1,8}$/; + +/** + * The landing hook: type a RUC or a CI and see your own filing dates before creating an + * account. Public, so it reveals only what the number itself already encodes: the check + * digit and the calendario perpetuo day. It never touches the database. + * + * An unparseable number is a valid response with `valid: false`, not an error, so the + * landing can correct the user inline instead of dead ending (CONTRACTS.md section 3). + */ +export function lookupNumber(raw: string, now: Date): LookupDto { + const cleaned = raw.trim().replace(/[.\s]/g, ''); + + const withDv = RUC_WITH_DV.exec(cleaned); + if (withDv) { + const base = withDv[1] as string; + const dv = Number(withDv[2]); + return build({ base, dv, docType: 'ruc', valid: computeRucDv(base) === dv, now }); + } + + if (BARE_DIGITS.test(cleaned)) { + // A bare number is read as a CI. Individuals whose RUC is their CI plus a check digit + // can type either, and the filing day is the same for both. + return build({ base: cleaned, dv: null, docType: 'ci', valid: true, now }); + } + + return invalid(); +} + +function build(args: { + base: string; + dv: number | null; + docType: 'ruc' | 'ci'; + valid: boolean; + now: Date; +}): LookupDto { + if (!args.valid) return { ...invalid(), docType: args.docType, base: args.base, dv: args.dv }; + + const digit = deadlineDigit(args.base); + return { + valid: true, + docType: args.docType, + base: args.base, + dv: args.dv, + deadlineDigit: digit, + deadlineDay: deadlineDay(digit), + nextDeadlines: nextThreeIvaDeadlines(digit, args.now), + }; +} + +/** The next three monthly IVA due dates, which is what the landing card shows. */ +function nextThreeIvaDeadlines(digit: number, now: Date): string[] { + const first = nextDeadline({ digit, obligation: 'iva_120', from: now }); + return [0, 1, 2].map((offset) => + formatIsoDate( + offset === 0 + ? fromDate(first.dueDate) + : dueDateFor('iva_120', addMonths(first.period, offset), digit), + ), + ); +} + +function invalid(): LookupDto { + return { + valid: false, + docType: 'ruc', + base: '', + dv: null, + deadlineDigit: 0, + deadlineDay: deadlineDay(0), + nextDeadlines: ['', '', ''], + }; +} diff --git a/apps/api/src/modules/lookup/lookup.test.ts b/apps/api/src/modules/lookup/lookup.test.ts new file mode 100644 index 0000000..3d68636 --- /dev/null +++ b/apps/api/src/modules/lookup/lookup.test.ts @@ -0,0 +1,62 @@ +import { computeRucDv } from '@impuestos/rules'; +import { describe, expect, it } from 'vitest'; +import { lookupNumber } from './index'; + +const NOW = new Date('2026-09-03T12:00:00Z'); + +describe('lookupNumber', () => { + it('validates a RUC and derives the filing day from the base', () => { + const dv = computeRucDv('4123456'); + const result = lookupNumber(`4123456-${dv}`, NOW); + + expect(result.valid).toBe(true); + expect(result.docType).toBe('ruc'); + expect(result.base).toBe('4123456'); + expect(result.dv).toBe(dv); + // Base ends in 6, and RULES.md maps digit 6 to the 19th. + expect(result.deadlineDigit).toBe(6); + expect(result.deadlineDay).toBe(19); + }); + + it('returns the next three monthly deadlines in order', () => { + const dv = computeRucDv('4123456'); + const result = lookupNumber(`4123456-${dv}`, NOW); + + expect(result.nextDeadlines).toHaveLength(3); + // 2026-09-19 is a Saturday, so the first rolls to the Monday. + expect(result.nextDeadlines).toEqual(['2026-09-21', '2026-10-19', '2026-11-19']); + expect([...result.nextDeadlines].sort()).toEqual(result.nextDeadlines); + }); + + it('reads a bare number as a CI, which individuals may type either way', () => { + const result = lookupNumber('4123456', NOW); + expect(result.valid).toBe(true); + expect(result.docType).toBe('ci'); + expect(result.dv).toBeNull(); + expect(result.deadlineDay).toBe(19); + }); + + it('reports a wrong check digit as invalid rather than as an error', () => { + const dv = computeRucDv('4123456'); + const result = lookupNumber(`4123456-${(dv + 1) % 10}`, NOW); + expect(result.valid).toBe(false); + expect(result.base).toBe('4123456'); + }); + + it('tolerates the dots and spaces people actually type', () => { + const dv = computeRucDv('4123456'); + expect(lookupNumber(` 4.123.456-${dv} `, NOW).valid).toBe(true); + }); + + it('rejects letters and over long numbers without throwing', () => { + for (const value of ['abc', '', '123456789-1', '4123456-', '-1', '4123456-12']) { + expect(lookupNumber(value, NOW).valid, value).toBe(false); + } + }); + + it('always returns a well formed shape, even when invalid', () => { + const result = lookupNumber('nonsense', NOW); + expect(result.nextDeadlines).toHaveLength(3); + expect(result.deadlineDay).toBeGreaterThan(0); + }); +}); diff --git a/apps/api/src/modules/pii/account.ts b/apps/api/src/modules/pii/account.ts new file mode 100644 index 0000000..ac73f73 --- /dev/null +++ b/apps/api/src/modules/pii/account.ts @@ -0,0 +1,87 @@ +import type { DataExportDto } from '@impuestos/contracts'; +import type { Kysely } from 'kysely'; +import type { Database } from '../../db/schema'; +import { listConsents } from './consents'; +import { listDependents } from './dependents'; +import { getNotificationPrefs } from './notification-prefs'; +import { getProfile } from './profiles'; + +/** + * Everything the platform holds about one user, in one file (SPEC.md section 14). + * Strictly scoped by `user_id` on every query: an export must never leak another + * taxpayer's comprobantes. + */ +export async function buildDataExport( + db: Kysely, + userId: string, +): Promise { + const account = await db + .selectFrom('user') + .select(['email', 'createdAt']) + .where('id', '=', userId) + .executeTakeFirstOrThrow(); + + const documents = await db + .selectFrom('documents') + .selectAll() + .where('user_id', '=', userId) + .orderBy('issue_date') + .execute(); + + const documentIds = documents.map((document) => document.id); + + const classifications = + documentIds.length === 0 + ? [] + : await db + .selectFrom('classifications') + .selectAll() + .where('document_id', 'in', documentIds) + .execute(); + + const declarations = await db + .selectFrom('declarations') + .selectAll() + .where('user_id', '=', userId) + .orderBy('period') + .execute(); + + return { + exportedAt: new Date().toISOString(), + account: { email: account.email, createdAt: account.createdAt }, + profile: await getProfile(db, userId), + dependents: await listDependents(db, userId), + consents: await listConsents(db, userId), + notificationPrefs: await getNotificationPrefs(db, userId), + documents, + classifications, + declarations, + }; +} + +export type FreezeReason = 'account_deleted' | 'consent_revoked'; + +/** + * Soft delete (SPEC.md section 14): the account is frozen and every session dropped, so + * the user is signed out everywhere and cannot sign back in, but nothing is destroyed + * yet. The irreversible purge runs as a job. + * + * Freezing reuses better-auth's ban flag, which its sign in path already checks, rather + * than adding a second parallel notion of a disabled account. + */ +export async function softDeleteAccount( + db: Kysely, + userId: string, + reason: FreezeReason, +): Promise { + await db + .updateTable('user') + .set({ banned: 1, banReason: reason, banExpires: null, updatedAt: new Date().toISOString() }) + .where('id', '=', userId) + .execute(); + + await db.deleteFrom('session').where('userId', '=', userId).execute(); + + // TODO(phase-4): enqueue `purge_user` once the jobs module exists, so the frozen + // account's rows and files are actually destroyed after the retention window. +} diff --git a/apps/api/src/modules/pii/consents.ts b/apps/api/src/modules/pii/consents.ts new file mode 100644 index 0000000..b9d5452 --- /dev/null +++ b/apps/api/src/modules/pii/consents.ts @@ -0,0 +1,81 @@ +import type { ConsentDto, ConsentKind } from '@impuestos/contracts'; +import type { Kysely } from 'kysely'; +import { uuidv7 } from 'uuidv7'; +import type { Database } from '../../db/schema'; + +/** + * The version of the consent text the user actually agreed to. Bump it whenever the + * wording on the consent screen changes materially, so an old grant is never mistaken + * for agreement to new wording. + */ +export const CONSENT_TEXT_VERSION = '2026-09-v1'; + +export async function listConsents(db: Kysely, userId: string): Promise { + const rows = await db + .selectFrom('consents') + .selectAll() + .where('user_id', '=', userId) + .orderBy('granted_at') + .execute(); + + return rows.map((row) => ({ + kind: row.kind, + granted: row.revoked_at === null, + grantedAt: row.granted_at, + revokedAt: row.revoked_at, + textVersion: row.text_version, + })); +} + +export async function hasConsent( + db: Kysely, + userId: string, + kind: ConsentKind, +): Promise { + const row = await db + .selectFrom('consents') + .select('id') + .where('user_id', '=', userId) + .where('kind', '=', kind) + .where('revoked_at', 'is', null) + .executeTakeFirst(); + return row !== undefined; +} + +/** + * Grants or revokes. Both are recorded: a revocation stamps the existing row rather than + * deleting it, so the history of what was agreed and when survives. + */ +export async function setConsent( + db: Kysely, + userId: string, + kind: ConsentKind, + granted: boolean, +): Promise { + const now = new Date().toISOString(); + + if (!granted) { + await db + .updateTable('consents') + .set({ revoked_at: now }) + .where('user_id', '=', userId) + .where('kind', '=', kind) + .where('revoked_at', 'is', null) + .execute(); + return; + } + + if (await hasConsent(db, userId, kind)) return; + + await db + .insertInto('consents') + .values({ + id: uuidv7(), + user_id: userId, + kind, + granted_at: now, + revoked_at: null, + text_version: CONSENT_TEXT_VERSION, + }) + .execute(); +} diff --git a/apps/api/src/modules/pii/dependents.ts b/apps/api/src/modules/pii/dependents.ts new file mode 100644 index 0000000..0929852 --- /dev/null +++ b/apps/api/src/modules/pii/dependents.ts @@ -0,0 +1,68 @@ +import type { DependentDto, DependentInput } from '@impuestos/contracts'; +import type { Kysely } from 'kysely'; +import { uuidv7 } from 'uuidv7'; +import type { Database } from '../../db/schema'; + +export async function listDependents( + db: Kysely, + userId: string, +): Promise { + const rows = await db + .selectFrom('dependents') + .selectAll() + .where('user_id', '=', userId) + .where('active', '=', 1) + .orderBy('created_at') + .execute(); + + return rows.map((row) => ({ + id: row.id, + displayName: row.display_name, + relationship: row.relationship, + active: row.active === 1, + })); +} + +export async function createDependent( + db: Kysely, + userId: string, + input: DependentInput, +): Promise { + const now = new Date().toISOString(); + const id = uuidv7(); + + await db + .insertInto('dependents') + .values({ + id, + user_id: userId, + display_name: input.displayName, + relationship: input.relationship, + doc_number: input.docNumber ?? null, + active: 1, + created_at: now, + updated_at: now, + }) + .execute(); + + return { id, displayName: input.displayName, relationship: input.relationship, active: true }; +} + +/** + * Deactivates rather than deletes: a confirmed document may already be classified against + * this dependent, and that classification has to keep making sense. + */ +export async function deactivateDependent( + db: Kysely, + userId: string, + id: string, +): Promise { + const result = await db + .updateTable('dependents') + .set({ active: 0, updated_at: new Date().toISOString() }) + .where('id', '=', id) + .where('user_id', '=', userId) + .executeTakeFirst(); + + return Number(result.numUpdatedRows) > 0; +} diff --git a/apps/api/src/modules/pii/index.ts b/apps/api/src/modules/pii/index.ts index 85c4f1b..9c92287 100644 --- a/apps/api/src/modules/pii/index.ts +++ b/apps/api/src/modules/pii/index.ts @@ -3,4 +3,8 @@ * file: the eslint boundary rule in eslint.config.js enforces it, so every read of a * profile, dependant or consent goes through a function that can audit itself. */ -export { getProfile, getLocale } from './profiles'; +export { getProfile, upsertProfile, getLocale, deriveDeadlineDigit } from './profiles'; +export { listDependents, createDependent, deactivateDependent } from './dependents'; +export { listConsents, hasConsent, setConsent, CONSENT_TEXT_VERSION } from './consents'; +export { getNotificationPrefs, updateNotificationPrefs } from './notification-prefs'; +export { buildDataExport, softDeleteAccount } from './account'; diff --git a/apps/api/src/modules/pii/notification-prefs.ts b/apps/api/src/modules/pii/notification-prefs.ts new file mode 100644 index 0000000..c24a5f9 --- /dev/null +++ b/apps/api/src/modules/pii/notification-prefs.ts @@ -0,0 +1,60 @@ +import type { NotificationPrefsDto, NotificationPrefsInput } from '@impuestos/contracts'; +import type { Kysely } from 'kysely'; +import type { Database } from '../../db/schema'; + +const DEFAULTS: NotificationPrefsDto = { + pushEnabled: false, + emailEnabled: true, + telegramChatId: null, + digestHour: 9, +}; + +/** Returns the defaults rather than null, so the profile screen always has something to render. */ +export async function getNotificationPrefs( + db: Kysely, + userId: string, +): Promise { + const row = await db + .selectFrom('notification_prefs') + .selectAll() + .where('user_id', '=', userId) + .executeTakeFirst(); + + if (!row) return { ...DEFAULTS }; + return { + pushEnabled: row.push_enabled === 1, + emailEnabled: row.email_enabled === 1, + telegramChatId: row.telegram_chat_id, + digestHour: row.digest_hour, + }; +} + +export async function updateNotificationPrefs( + db: Kysely, + userId: string, + input: NotificationPrefsInput, +): Promise { + const current = await getNotificationPrefs(db, userId); + const next: NotificationPrefsDto = { ...current, ...input }; + + const values = { + push_enabled: next.pushEnabled ? 1 : 0, + email_enabled: next.emailEnabled ? 1 : 0, + telegram_chat_id: next.telegramChatId, + digest_hour: next.digestHour, + }; + + const existing = await db + .selectFrom('notification_prefs') + .select('user_id') + .where('user_id', '=', userId) + .executeTakeFirst(); + + if (existing) { + await db.updateTable('notification_prefs').set(values).where('user_id', '=', userId).execute(); + } else { + await db.insertInto('notification_prefs').values({ user_id: userId, ...values }).execute(); + } + + return next; +} diff --git a/apps/api/src/modules/pii/profiles.ts b/apps/api/src/modules/pii/profiles.ts index 6ba50f3..3b86c5c 100644 --- a/apps/api/src/modules/pii/profiles.ts +++ b/apps/api/src/modules/pii/profiles.ts @@ -1,5 +1,6 @@ -import { Obligation, type ProfileDto } from '@impuestos/contracts'; +import { Obligation, type ProfileDto, type ProfileInput } from '@impuestos/contracts'; import { type Locale, isLocale } from '@impuestos/i18n'; +import { deadlineDigit } from '@impuestos/rules'; import type { Kysely } from 'kysely'; import { z } from 'zod'; import type { Database } from '../../db/schema'; @@ -17,21 +18,65 @@ export async function getProfile(db: Kysely, userId: string): Promise< .selectAll() .where('user_id', '=', userId) .executeTakeFirst(); - if (!row) return null; + return row ? toDto(row) : null; +} - return { - fullName: row.full_name, - docType: row.doc_type, - ruc: row.ruc, - rucDv: row.ruc_dv, - ci: row.ci, - taxpayerKind: row.taxpayer_kind, - deadlineDigit: row.deadline_digit, - obligations: Obligations.parse(JSON.parse(row.obligations)), - irpGrossEstimate: row.irp_gross_estimate, - autoConfirmDays: row.auto_confirm_days, - locale: row.locale, +/** + * Creates or replaces the profile. `deadlineDigit` is never accepted from the client: + * it is derived from the identity document, because it decides real filing dates. + */ +export async function upsertProfile( + db: Kysely, + userId: string, + input: ProfileInput, +): Promise<{ profile: ProfileDto; created: boolean }> { + const now = new Date().toISOString(); + const digit = deriveDeadlineDigit(input); + + const existing = await db + .selectFrom('profiles') + .select('user_id') + .where('user_id', '=', userId) + .executeTakeFirst(); + + const values = { + full_name: input.fullName, + doc_type: input.docType, + ruc: input.ruc, + ruc_dv: input.rucDv, + ci: input.ci, + taxpayer_kind: input.taxpayerKind, + deadline_digit: digit, + obligations: JSON.stringify(input.obligations), + irp_gross_estimate: input.irpGrossEstimate, + auto_confirm_days: input.autoConfirmDays, + locale: input.locale, + updated_at: now, }; + + if (existing) { + await db.updateTable('profiles').set(values).where('user_id', '=', userId).execute(); + } else { + await db + .insertInto('profiles') + .values({ user_id: userId, created_at: now, ...values }) + .execute(); + } + + const profile = await getProfile(db, userId); + if (!profile) throw new Error('profile disappeared immediately after being written'); + return { profile, created: !existing }; +} + +/** + * The last digit of the identity document decides the filing day for the rest of the + * user's life with us (RULES.md section 2), so it comes from the RUC base when there is + * one and from the CI otherwise. + */ +export function deriveDeadlineDigit(input: Pick): number { + const base = input.docType === 'ruc' ? (input.ruc ?? input.ci) : (input.ci ?? input.ruc); + if (!base) throw new Error('profile needs a RUC or a CI to derive the deadline digit'); + return deadlineDigit(base); } /** @@ -46,3 +91,33 @@ export async function getLocale(db: Kysely, userId: string): Promise }) { + const { locale } = await params; + setRequestLocale(locale); + return ; +} diff --git a/apps/web/app/[locale]/(app)/configuracion/setup-wizard.tsx b/apps/web/app/[locale]/(app)/configuracion/setup-wizard.tsx new file mode 100644 index 0000000..a945cc2 --- /dev/null +++ b/apps/web/app/[locale]/(app)/configuracion/setup-wizard.tsx @@ -0,0 +1,386 @@ +'use client'; + +import type { LookupDto, Obligation, Relationship } from '@impuestos/contracts'; +import { formatGsAmount, isLocale } from '@impuestos/i18n'; +import { useMutation } from '@tanstack/react-query'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { useRouter } from '@/i18n/navigation'; +import { useT } from '@/i18n/t'; +import { api } from '@/lib/api'; +import { cn } from '@/lib/utils'; + +const TOTAL_STEPS = 3; +const RELATIONSHIPS: Relationship[] = ['conyuge', 'hijo', 'padre', 'otro']; + +interface Dependent { + displayName: string; + relationship: Relationship; +} + +/** Flow A5: three steps, progress dots, nothing that cannot be changed later in /perfil. */ +export function SetupWizard({ locale }: { locale: string }) { + const t = useT(); + const router = useRouter(); + + const [step, setStep] = useState(1); + const [fullName, setFullName] = useState(''); + const [doc, setDoc] = useState(''); + const [lookup, setLookup] = useState(null); + const [taxpayerKind, setTaxpayerKind] = useState<'individual' | 'company'>('individual'); + const [hasIva, setHasIva] = useState(false); + const [hasIrp, setHasIrp] = useState(false); + const [income, setIncome] = useState(''); + const [dependents, setDependents] = useState([]); + const [emailAlerts, setEmailAlerts] = useState(true); + + const check = useMutation({ + mutationFn: (value: string) => api.lookupRuc(value), + onSuccess: setLookup, + }); + + const save = useMutation({ + mutationFn: async () => { + const resolved = lookup?.valid ? lookup : await api.lookupRuc(doc.trim()); + if (!resolved.valid) throw new Error('invalid_document'); + + const since = new Date().toISOString().slice(0, 10); + const obligations: Obligation[] = []; + // "No estoy seguro" leaves both off, which lands on the IRP view (FLOWS.md A5). + if (hasIva) obligations.push({ code: 'iva_120', active: true, since }); + if (hasIrp || (!hasIva && !hasIrp)) obligations.push({ code: 'irp_515', active: true, since }); + + await api.putProfile({ + fullName: fullName.trim(), + docType: resolved.docType, + ruc: resolved.docType === 'ruc' ? resolved.base : null, + rucDv: resolved.dv === null ? null : String(resolved.dv), + ci: resolved.docType === 'ci' ? resolved.base : resolved.base, + taxpayerKind, + obligations, + irpGrossEstimate: income === '' ? null : Number(income.replace(/\D/g, '')), + autoConfirmDays: 7, + locale: isLocale(locale) ? locale : 'es', + }); + + for (const dependent of dependents) { + await api.createDependent(dependent); + } + await api.patchNotificationPrefs({ emailEnabled: emailAlerts }); + }, + onSuccess: () => router.push('/inicio'), + }); + + const canContinue = + step === 1 ? fullName.trim().length > 0 && lookup?.valid === true : true; + + return ( +
+ + + {step === 1 ? ( + +

{t('setup.step1.title')}

+ +
+ + setFullName(event.target.value)} + /> +
+ +
+ + { + setDoc(event.target.value); + setLookup(null); + }} + onBlur={() => { + if (doc.trim().length > 0) check.mutate(doc.trim()); + }} + /> +

+ {t('setup.docHelp')} +

+ {lookup !== null && !lookup.valid ? ( +

+ {t('landing.invalidDoc')} +

+ ) : null} + {lookup?.valid ? ( +

+ {t('setup.deadlineNote', { day: lookup.deadlineDay })} +

+ ) : null} +
+ +
+ + {t('setup.taxpayerKind.q')} + +
+ setTaxpayerKind('individual')} + title={t('setup.taxpayerKind.ind')} + /> + setTaxpayerKind('company')} + title={t('setup.taxpayerKind.com')} + /> +
+
+
+ ) : null} + + {step === 2 ? ( + +

{t('setup.step2.title')}

+ +
+ setHasIva(!hasIva)} + title={t('setup.oblig.iva.title')} + body={t('setup.oblig.iva.body')} + /> + setHasIrp(!hasIrp)} + title={t('setup.oblig.irp.title')} + body={t('setup.oblig.irp.body')} + /> + +
+ + {hasIrp ? ( + <> +
+ + setIncome(event.target.value.replace(/\D/g, ''))} + /> +

+ {t('setup.income.help')} +

+
+ + + + ) : null} +
+ ) : null} + + {step === 3 ? ( + +

{t('setup.step3.title')}

+

{t('setup.push.body')}

+ + + + {save.isError ? ( +

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

+ ) : null} +
+ ) : null} + +
+ {step > 1 ? ( + + ) : null} + + {step < TOTAL_STEPS ? ( + + ) : ( + + )} +
+
+ ); +} + +function ProgressDots({ step }: { step: number }) { + const t = useT(); + return ( +
+ {Array.from({ length: TOTAL_STEPS }).map((_, index) => ( + + ))} +
+ ); +} + +function ChoiceCard({ + selected, + onSelect, + title, + body, +}: { + selected: boolean; + onSelect: () => void; + title: string; + body?: string; +}) { + return ( + + ); +} + +function DependentsEditor({ + dependents, + onChange, + relationships, +}: { + dependents: Dependent[]; + onChange: (next: Dependent[]) => void; + relationships: Relationship[]; +}) { + const t = useT(); + const [name, setName] = useState(''); + const [relationship, setRelationship] = useState('hijo'); + + return ( +
+
+

{t('setup.dependents.title')}

+

{t('setup.dependents.help')}

+
+ + {dependents.length > 0 ? ( +
    + {dependents.map((dependent, index) => ( +
  • + + {dependent.displayName} ({t(`relationship.${dependent.relationship}` as const)}) + + +
  • + ))} +
+ ) : null} + +
+ setName(event.target.value)} + /> + + +
+
+ ); +} diff --git a/apps/web/app/[locale]/(app)/consentimiento/consent-form.tsx b/apps/web/app/[locale]/(app)/consentimiento/consent-form.tsx new file mode 100644 index 0000000..7e11c9f --- /dev/null +++ b/apps/web/app/[locale]/(app)/consentimiento/consent-form.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { useMutation } from '@tanstack/react-query'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Switch } from '@/components/ui/switch'; +import { Link, useRouter } from '@/i18n/navigation'; +import { useT } from '@/i18n/t'; +import { api } from '@/lib/api'; + +/** + * Flow A4. Plain language, two switches, one screen. Data processing is required to + * continue; notifications are genuinely optional and nothing nags about them later. + */ +export function ConsentForm() { + const t = useT(); + const router = useRouter(); + const [dataProcessing, setDataProcessing] = useState(false); + const [notifications, setNotifications] = useState(true); + + const grant = useMutation({ + mutationFn: async () => { + await api.postConsent({ kind: 'data_processing', granted: true }); + await api.postConsent({ kind: 'notifications', granted: notifications }); + }, + onSuccess: () => router.push('/configuracion'), + }); + + return ( + +
+

{t('consent.title')}

+

{t('consent.intro')}

+
    + {['consent.bullet1', 'consent.bullet2', 'consent.bullet3'].map((key) => ( +
  • + + ✓ + + {t(key as 'consent.bullet1')} +
  • + ))} +
+
+ +
+ + +
+ + {!dataProcessing ? ( +

{t('consent.required')}

+ ) : null} + + {grant.isError ? ( +

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

+ ) : null} + +
+ + + {t('consent.policyLink')} + +
+
+ ); +} diff --git a/apps/web/app/[locale]/(app)/consentimiento/page.tsx b/apps/web/app/[locale]/(app)/consentimiento/page.tsx new file mode 100644 index 0000000..de73ec4 --- /dev/null +++ b/apps/web/app/[locale]/(app)/consentimiento/page.tsx @@ -0,0 +1,8 @@ +import { setRequestLocale } from 'next-intl/server'; +import { ConsentForm } from './consent-form'; + +export default async function ConsentPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + setRequestLocale(locale); + return ; +} diff --git a/apps/web/app/[locale]/(app)/inicio/page.tsx b/apps/web/app/[locale]/(app)/inicio/page.tsx index f65d364..9d8bb13 100644 --- a/apps/web/app/[locale]/(app)/inicio/page.tsx +++ b/apps/web/app/[locale]/(app)/inicio/page.tsx @@ -1,15 +1,17 @@ import { isApiError } from '@impuestos/contracts'; +import { ScanLine } from 'lucide-react'; import { setRequestLocale } from 'next-intl/server'; -import { getT } from '@/i18n/t'; -import { redirect } from '@/i18n/navigation'; +import { buttonClasses } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; -import { LanguageSwitcher } from '@/components/language-switcher'; +import { EmptyState } from '@/components/ui/empty-state'; +import { Link, redirect } from '@/i18n/navigation'; +import { getT } from '@/i18n/t'; import { serverApi } from '@/lib/api-server'; /** - * Phase 0 shell. It exists to prove the whole chain end to end: browser to the Next - * rewrite, to the API, through the typed client, with the session cookie intact. - * The real dashboard (FLOWS.md Flow C) replaces this in phase 4. + * 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. */ export default async function InicioPage({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params; @@ -19,34 +21,40 @@ export default async function InicioPage({ params }: { params: Promise<{ locale: const api = await serverApi(); const profile = await api.getProfile().catch((error: unknown) => { if (!isApiError(error)) throw error; - // 404 is the documented state for a user who has not finished setup yet. if (error.code === 'not_found') return null; if (error.code === 'unauthorized') redirect({ href: '/login', locale }); throw error; }); - return ( -
-
-

{t('home.title')}

- -
+ // No profile means setup was never finished, so send them there rather than showing an + // empty shell they cannot act on. + if (!profile) redirect({ href: '/configuracion', locale }); -
- - {profile ? ( - <> -

{t('setup.fullName')}

-

{profile.fullName}

- - ) : ( - <> -

{t('setup.step1.title')}

-

{t('setup.income.help')}

- - )} -
-
+ return ( +
+
+

{t('home.title')}

+

{profile?.fullName}

+
+ + + } + title={t('home.firstRun.title')} + body={t('home.firstRun.body')} + action={ +
+ {/* Scanning and manual entry arrive with the ingestion pipeline in phase 3. */} + + {t('home.firstRun.scan')} + + + {t('home.firstRun.manual')} + +
+ } + /> +
); } diff --git a/apps/web/app/[locale]/(app)/layout.tsx b/apps/web/app/[locale]/(app)/layout.tsx new file mode 100644 index 0000000..ac35f0c --- /dev/null +++ b/apps/web/app/[locale]/(app)/layout.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react'; +import { LanguageSwitcher } from '@/components/language-switcher'; +import { Link } from '@/i18n/navigation'; +import { useT } from '@/i18n/t'; + +export default function AppLayout({ children }: { children: ReactNode }) { + const t = useT(); + return ( +
+
+ + {t('common.appName')} + +
+ + + {t('profile.title')} + +
+
+
{children}
+
+ ); +} diff --git a/apps/web/app/[locale]/(app)/perfil/danger-zone.tsx b/apps/web/app/[locale]/(app)/perfil/danger-zone.tsx new file mode 100644 index 0000000..157de8e --- /dev/null +++ b/apps/web/app/[locale]/(app)/perfil/danger-zone.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useT } from '@/i18n/t'; +import { api } from '@/lib/api'; +import { authClient } from '@/lib/auth-client'; + +/** + * "Tus datos", the trust section of FLOWS.md E3: what we hold, the consent behind it, + * and the two ways to take it back. The export is a plain link so the browser downloads + * it; deletion is a native dialog that will not proceed until the email is typed out. + */ +export function DangerZone() { + const t = useT(); + const dialog = useRef(null); + const [confirmText, setConfirmText] = useState(''); + + const session = useQuery({ + queryKey: ['session'], + queryFn: async () => (await authClient.getSession()).data, + }); + const email = session.data?.user.email ?? ''; + + const revoke = useMutation({ + mutationFn: () => api.postConsent({ kind: 'data_processing', granted: false }), + onSuccess: () => window.location.assign('/'), + }); + + const remove = useMutation({ + mutationFn: () => api.deleteAccount({ confirmText }), + onSuccess: () => window.location.assign('/'), + }); + + return ( + +
+

{t('profile.myData.title')}

+

{t('profile.myData.body')}

+
+ +

{t('profile.myData.stored')}

+ + {/* A plain link, not a fetch: the browser saves the file and the API sets the name. */} + + {t('profile.myData.export')} + + +
+

+ {t('profile.consent.revokeWarn')} +

+ +
+ +
+

{t('profile.myData.deleteWarn')}

+ +
+ + +
+

{t('profile.myData.delete')}

+

+ {t('profile.myData.deleteWarn')} +

+ +
+ + setConfirmText(event.target.value)} + /> +
+ + {remove.isError ? ( +

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

+ ) : null} + +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/(app)/perfil/dependents-section.tsx b/apps/web/app/[locale]/(app)/perfil/dependents-section.tsx new file mode 100644 index 0000000..b70b5fb --- /dev/null +++ b/apps/web/app/[locale]/(app)/perfil/dependents-section.tsx @@ -0,0 +1,116 @@ +'use client'; + +import type { Relationship } from '@impuestos/contracts'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useT } from '@/i18n/t'; +import { api } from '@/lib/api'; + +const RELATIONSHIPS: Relationship[] = ['conyuge', 'hijo', 'padre', 'otro']; + +export function DependentsSection() { + const t = useT(); + const queryClient = useQueryClient(); + const [name, setName] = useState(''); + const [relationship, setRelationship] = useState('hijo'); + + const dependents = useQuery({ + queryKey: ['me', 'dependents'], + queryFn: ({ signal }) => api.listDependents(signal), + }); + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['me', 'dependents'] }); + + const add = useMutation({ + mutationFn: () => api.createDependent({ displayName: name.trim(), relationship }), + onSuccess: async () => { + setName(''); + await invalidate(); + }, + }); + + const remove = useMutation({ + mutationFn: (id: string) => api.deleteDependent(id), + onSuccess: invalidate, + }); + + return ( + +
+

{t('profile.dependents')}

+

{t('setup.dependents.help')}

+
+ + {dependents.isPending ? : null} + + {dependents.isError ? ( +
+

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

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

{t('profile.dependents.empty')}

+ ) : null} + + {dependents.data && dependents.data.length > 0 ? ( +
    + {dependents.data.map((dependent) => ( +
  • + + {dependent.displayName} ({t(`relationship.${dependent.relationship}` as const)}) + + +
  • + ))} +
+ ) : null} + +
+ setName(event.target.value)} + /> + + +
+
+ ); +} diff --git a/apps/web/app/[locale]/(app)/perfil/identity-section.tsx b/apps/web/app/[locale]/(app)/perfil/identity-section.tsx new file mode 100644 index 0000000..dd679aa --- /dev/null +++ b/apps/web/app/[locale]/(app)/perfil/identity-section.tsx @@ -0,0 +1,150 @@ +'use client'; + +import type { ProfileDto } from '@impuestos/contracts'; +import { formatGsAmount } from '@impuestos/i18n'; +import { deadlineDay } from '@impuestos/rules'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { useT } from '@/i18n/t'; +import { api } from '@/lib/api'; + +export function IdentitySection({ profile }: { profile: ProfileDto }) { + const t = useT(); + const queryClient = useQueryClient(); + + const [fullName, setFullName] = useState(profile.fullName); + const [income, setIncome] = useState( + profile.irpGrossEstimate === null ? '' : String(profile.irpGrossEstimate), + ); + const [autoConfirmDays, setAutoConfirmDays] = useState(String(profile.autoConfirmDays)); + const [obligations, setObligations] = useState(profile.obligations); + + const save = useMutation({ + mutationFn: () => + api.putProfile({ + ...profile, + fullName: fullName.trim(), + obligations, + irpGrossEstimate: income === '' ? null : Number(income), + autoConfirmDays: Number(autoConfirmDays || '0'), + }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['me'] }), + }); + + function toggle(code: 'iva_120' | 'irp_515') { + const existing = obligations.find((obligation) => obligation.code === code); + if (existing) { + setObligations( + obligations.map((obligation) => + obligation.code === code ? { ...obligation, active: !obligation.active } : obligation, + ), + ); + return; + } + setObligations([ + ...obligations, + { code, active: true, since: new Date().toISOString().slice(0, 10) }, + ]); + } + + const active = (code: 'iva_120' | 'irp_515') => + obligations.some((obligation) => obligation.code === code && obligation.active); + + return ( + +

{t('profile.identity')}

+ +
+ + setFullName(event.target.value)} /> +
+ + {/* The document is read only: it decides real filing dates, so changing it is a + support conversation rather than a text field. */} +
+ + +

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

+
+ +
+ + {t('profile.obligations')} + + + +
+ + {active('irp_515') ? ( +
+ + setIncome(event.target.value.replace(/\D/g, ''))} + /> +
+ ) : null} + +
+ + setAutoConfirmDays(event.target.value.replace(/\D/g, ''))} + /> +

+ {t('profile.autoConfirm.help')} +

+
+ +
+ + {save.isSuccess ? {t('profile.saved')} : null} + {save.isError ? ( + + {t('common.error.generic')} + + ) : null} +
+
+ ); +} diff --git a/apps/web/app/[locale]/(app)/perfil/notifications-section.tsx b/apps/web/app/[locale]/(app)/perfil/notifications-section.tsx new file mode 100644 index 0000000..163f572 --- /dev/null +++ b/apps/web/app/[locale]/(app)/perfil/notifications-section.tsx @@ -0,0 +1,109 @@ +'use client'; + +import type { ProfileDto } from '@impuestos/contracts'; +import { SUPPORTED_LOCALES, type Locale } from '@impuestos/i18n'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Card } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Switch } from '@/components/ui/switch'; +import { usePathname, useRouter } from '@/i18n/navigation'; +import { useT } from '@/i18n/t'; +import { api } from '@/lib/api'; + +const LABEL_KEY = { es: 'common.languageEs', en: 'common.languageEn' } as const; + +export function NotificationsSection({ profile }: { profile: ProfileDto }) { + const t = useT(); + const queryClient = useQueryClient(); + const router = useRouter(); + const pathname = usePathname(); + + const prefs = useQuery({ + queryKey: ['me', 'notification-prefs'], + queryFn: ({ signal }) => api.getNotificationPrefs(signal), + }); + + const update = useMutation({ + mutationFn: api.patchNotificationPrefs, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['me', 'notification-prefs'] }), + }); + + /** + * Changing the language here persists it to the profile, because the API sends + * notifications, emails and error messages in the locale stored there. + */ + const setLocale = useMutation({ + mutationFn: (locale: Locale) => api.putProfile({ ...profile, locale }), + onSuccess: async (_data, locale) => { + await queryClient.invalidateQueries({ queryKey: ['me'] }); + router.replace(pathname, { locale }); + }, + }); + + return ( + +

{t('profile.notifications')}

+ + {prefs.isPending ? : null} + + {prefs.isError ? ( +

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

+ ) : null} + + {prefs.data ? ( +
+ + + + + {/* Push and Telegram are hidden until they are configured (FLOWS.md section 9). + They are wired with the notification channels in phase 4. */} +
+ ) : null} + +
+ {t('common.language')} + +
+
+ ); +} diff --git a/apps/web/app/[locale]/(app)/perfil/page.tsx b/apps/web/app/[locale]/(app)/perfil/page.tsx new file mode 100644 index 0000000..c929dcb --- /dev/null +++ b/apps/web/app/[locale]/(app)/perfil/page.tsx @@ -0,0 +1,8 @@ +import { setRequestLocale } from 'next-intl/server'; +import { ProfileScreen } from './profile-screen'; + +export default async function ProfilePage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + setRequestLocale(locale); + return ; +} diff --git a/apps/web/app/[locale]/(app)/perfil/profile-screen.tsx b/apps/web/app/[locale]/(app)/perfil/profile-screen.tsx new file mode 100644 index 0000000..198709b --- /dev/null +++ b/apps/web/app/[locale]/(app)/perfil/profile-screen.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { isApiError } from '@impuestos/contracts'; +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 { DangerZone } from './danger-zone'; +import { DependentsSection } from './dependents-section'; +import { IdentitySection } from './identity-section'; +import { NotificationsSection } from './notifications-section'; + +/** + * Flow E3. This screen is the trust feature: what we hold, who saw it, and the two + * buttons that take it all back. Everything on it is reachable in one scroll. + */ +export function ProfileScreen() { + const t = useT(); + + const profile = useQuery({ + queryKey: ['me'], + queryFn: ({ signal }) => api.getProfile(signal), + retry: (count, error) => !isApiError(error) && count < 1, + }); + + if (profile.isPending) { + return ( +
+ + + + + + +
+ ); + } + + if (profile.isError) { + const notSetUp = isApiError(profile.error) && profile.error.code === 'not_found'; + return ( + +

+ {notSetUp ? t('setup.step1.title') : t('common.error.generic')} +

+ +
+ ); + } + + return ( +
+

{t('profile.title')}

+ + + + +
+ ); +} diff --git a/apps/web/app/[locale]/(auth)/login/login-form.tsx b/apps/web/app/[locale]/(auth)/login/login-form.tsx index 037ff11..4595781 100644 --- a/apps/web/app/[locale]/(auth)/login/login-form.tsx +++ b/apps/web/app/[locale]/(auth)/login/login-form.tsx @@ -1,7 +1,7 @@ 'use client'; import { useMutation } from '@tanstack/react-query'; -import { useRouter } from '@/i18n/navigation'; +import { Link, useRouter } from '@/i18n/navigation'; import { useT } from '@/i18n/t'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; @@ -70,6 +70,13 @@ export function LoginForm() { {signIn.isPending ? t('common.loading') : t('auth.login.submit')} + +

+ {t('auth.login.noAccount')}{' '} + + {t('auth.login.register')} + +

); } diff --git a/apps/web/app/[locale]/(auth)/registro/page.tsx b/apps/web/app/[locale]/(auth)/registro/page.tsx new file mode 100644 index 0000000..808713a --- /dev/null +++ b/apps/web/app/[locale]/(auth)/registro/page.tsx @@ -0,0 +1,8 @@ +import { setRequestLocale } from 'next-intl/server'; +import { RegisterForm } from './register-form'; + +export default async function RegisterPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + setRequestLocale(locale); + return ; +} diff --git a/apps/web/app/[locale]/(auth)/registro/register-form.tsx b/apps/web/app/[locale]/(auth)/registro/register-form.tsx new file mode 100644 index 0000000..a999b4c --- /dev/null +++ b/apps/web/app/[locale]/(auth)/registro/register-form.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { useMutation } from '@tanstack/react-query'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Link, useRouter } from '@/i18n/navigation'; +import { useT } from '@/i18n/t'; +import { authClient } from '@/lib/auth-client'; + +/** + * Flow A3. One screen, no marketing interruptions: email, password, and straight on to + * the six digit code. + */ +export function RegisterForm() { + const t = useT(); + const router = useRouter(); + const [emailTaken, setEmailTaken] = useState(false); + + const register = useMutation({ + mutationFn: async (form: { email: string; password: string }) => { + setEmailTaken(false); + const { error } = await authClient.signUp.email({ ...form, name: form.email }); + if (error) { + // better-auth reports an existing account with 422 USER_ALREADY_EXISTS. + if (error.status === 422 || error.code === 'USER_ALREADY_EXISTS') setEmailTaken(true); + throw new Error(error.code ?? 'sign_up_failed'); + } + await authClient.emailOtp.sendVerificationOtp({ email: form.email, type: 'email-verification' }); + return form.email; + }, + onSuccess: (email) => router.push(`/verificar?email=${encodeURIComponent(email)}`), + }); + + return ( + +

+ {t('auth.register.title')} +

+ +
{ + event.preventDefault(); + const data = new FormData(event.currentTarget); + register.mutate({ + email: String(data.get('email') ?? '').trim(), + password: String(data.get('password') ?? ''), + }); + }} + > +
+ + +
+ +
+ + +

+ {t('auth.register.passwordHint')} +

+
+ + {register.isError ? ( +

+ {emailTaken ? t('auth.register.emailTaken') : t('auth.register.failed')} +

+ ) : null} + + +
+ +

+ {t('auth.register.haveAccount')}{' '} + + {t('auth.register.signIn')} + +

+
+ ); +} diff --git a/apps/web/app/[locale]/(auth)/verificar/page.tsx b/apps/web/app/[locale]/(auth)/verificar/page.tsx new file mode 100644 index 0000000..eca0586 --- /dev/null +++ b/apps/web/app/[locale]/(auth)/verificar/page.tsx @@ -0,0 +1,21 @@ +import { setRequestLocale } from 'next-intl/server'; +import { Suspense } from 'react'; +import { Card } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { VerifyForm } from './verify-form'; + +export default async function VerifyPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + setRequestLocale(locale); + return ( + + + + } + > + + + ); +} diff --git a/apps/web/app/[locale]/(auth)/verificar/verify-form.tsx b/apps/web/app/[locale]/(auth)/verificar/verify-form.tsx new file mode 100644 index 0000000..84bca7f --- /dev/null +++ b/apps/web/app/[locale]/(auth)/verificar/verify-form.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { useMutation } from '@tanstack/react-query'; +import { useSearchParams } from 'next/navigation'; +import { useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { useRouter } from '@/i18n/navigation'; +import { useT } from '@/i18n/t'; +import { authClient } from '@/lib/auth-client'; +import { cn } from '@/lib/utils'; + +const LENGTH = 6; + +/** Flow A3: six boxes that advance as you type, and accept a pasted code whole. */ +export function VerifyForm() { + const t = useT(); + const router = useRouter(); + const email = useSearchParams().get('email') ?? ''; + const [digits, setDigits] = useState(() => Array.from({ length: LENGTH }, () => '')); + const [resent, setResent] = useState(false); + const inputs = useRef<(HTMLInputElement | null)[]>([]); + + const verify = useMutation({ + mutationFn: async (otp: string) => { + const { error } = await authClient.emailOtp.verifyEmail({ email, otp }); + if (error) throw new Error(error.code ?? 'verify_failed'); + }, + onSuccess: () => router.push('/consentimiento'), + }); + + const resend = useMutation({ + mutationFn: async () => { + setResent(false); + const { error } = await authClient.emailOtp.sendVerificationOtp({ + email, + type: 'email-verification', + }); + if (error) throw new Error(error.code ?? 'resend_failed'); + }, + onSuccess: () => setResent(true), + }); + + /** + * Takes one typed digit or a whole pasted code and spreads it across the boxes from + * `index` on. Deliberately computed outside the state updater: React invokes updaters + * twice in development to check they are pure, which would fire the focus move and the + * verify request twice. + */ + function place(index: number, raw: string) { + const characters = raw.replace(/\D/g, '').split(''); + if (characters.length === 0) return; + + const next = [...digits]; + for (let offset = 0; offset < characters.length && index + offset < LENGTH; offset++) { + next[index + offset] = characters[offset] as string; + } + setDigits(next); + + inputs.current[Math.min(index + characters.length, LENGTH - 1)]?.focus(); + + const code = next.join(''); + if (code.length === LENGTH && !next.includes('')) verify.mutate(code); + } + + return ( + +
+

{t('auth.otp.title')}

+

+ {t('auth.otp.body', { email })} +

+
+ +
{ + event.preventDefault(); + const code = digits.join(''); + if (code.length === LENGTH) verify.mutate(code); + }} + > + +
+ {digits.map((digit, index) => ( + { + inputs.current[index] = element; + }} + id={`otp-${index}`} + inputMode="numeric" + autoComplete={index === 0 ? 'one-time-code' : 'off'} + maxLength={LENGTH} + value={digit} + aria-label={`${index + 1}`} + aria-invalid={verify.isError} + onChange={(event) => place(index, event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Backspace' && digit === '' && index > 0) { + inputs.current[index - 1]?.focus(); + } + }} + className={cn( + 'tnum h-14 w-full rounded-2xl border bg-[var(--surface-raised)] text-center text-xl', + 'focus-visible:border-accent-500 focus-visible:outline-2 focus-visible:outline-accent-500/40', + verify.isError && 'border-overdue', + )} + /> + ))} +
+ + {verify.isError ? ( +

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

+ ) : null} + {resent ?

{t('auth.otp.resent')}

: null} + + +
+ +
+ +

{t('auth.otp.devHint')}

+
+
+ ); +} diff --git a/apps/web/app/[locale]/(marketing)/layout.tsx b/apps/web/app/[locale]/(marketing)/layout.tsx new file mode 100644 index 0000000..1b146cc --- /dev/null +++ b/apps/web/app/[locale]/(marketing)/layout.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react'; +import { LanguageSwitcher } from '@/components/language-switcher'; +import { Link } from '@/i18n/navigation'; +import { useT } from '@/i18n/t'; + +export default function MarketingLayout({ children }: { children: ReactNode }) { + const t = useT(); + return ( +
+
+ + {t('common.appName')} + +
+ + + {t('auth.login.submit')} + +
+
+ +
{children}
+ +
+ {t('common.appName')} + + {t('landing.footer.privacy')} + + + {t('landing.footer.terms')} + +
+
+ ); +} diff --git a/apps/web/app/[locale]/(marketing)/legal/placeholder.tsx b/apps/web/app/[locale]/(marketing)/legal/placeholder.tsx new file mode 100644 index 0000000..af1206d --- /dev/null +++ b/apps/web/app/[locale]/(marketing)/legal/placeholder.tsx @@ -0,0 +1,19 @@ +import { Card } from '@/components/ui/card'; +import { getT } from '@/i18n/t'; + +/** + * COPY.md section 13: these ship as clearly marked placeholders. Never generate legal + * text. A human writes both documents before launch, tracked in DECISIONS.md. + */ +export async function LegalPlaceholder({ title, locale }: { title: string; locale: string }) { + const t = await getT(locale); + return ( +
+

{title}

+ +

{t('legal.placeholder')}

+

{t('legal.placeholderBody')}

+
+
+ ); +} diff --git a/apps/web/app/[locale]/(marketing)/legal/privacidad/page.tsx b/apps/web/app/[locale]/(marketing)/legal/privacidad/page.tsx new file mode 100644 index 0000000..dfecc91 --- /dev/null +++ b/apps/web/app/[locale]/(marketing)/legal/privacidad/page.tsx @@ -0,0 +1,10 @@ +import { setRequestLocale } from 'next-intl/server'; +import { LegalPlaceholder } from '../placeholder'; +import { getT } from '@/i18n/t'; + +export default async function PrivacyPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getT(locale); + return ; +} diff --git a/apps/web/app/[locale]/(marketing)/legal/terminos/page.tsx b/apps/web/app/[locale]/(marketing)/legal/terminos/page.tsx new file mode 100644 index 0000000..f984f2f --- /dev/null +++ b/apps/web/app/[locale]/(marketing)/legal/terminos/page.tsx @@ -0,0 +1,10 @@ +import { setRequestLocale } from 'next-intl/server'; +import { LegalPlaceholder } from '../placeholder'; +import { getT } from '@/i18n/t'; + +export default async function TermsPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getT(locale); + return ; +} diff --git a/apps/web/app/[locale]/(marketing)/page.tsx b/apps/web/app/[locale]/(marketing)/page.tsx new file mode 100644 index 0000000..9994d2a --- /dev/null +++ b/apps/web/app/[locale]/(marketing)/page.tsx @@ -0,0 +1,50 @@ +import { setRequestLocale } from 'next-intl/server'; +import { Card } from '@/components/ui/card'; +import { getT } from '@/i18n/t'; +import { RucHook } from './ruc-hook'; + +export default async function LandingPage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getT(locale); + + const values = [ + { title: t('landing.value1.title'), body: t('landing.value1.body') }, + { title: t('landing.value2.title'), body: t('landing.value2.body') }, + { title: t('landing.value3.title'), body: t('landing.value3.body') }, + ]; + + return ( +
+ {/* A subtle canvas effect belongs behind this hero (FLOWS.md A1). It lands with the + rest of the motion work in phase 7, reduced-motion safe and degrading to this. */} +
+

+ {t('landing.hero.title')} +

+

+ {t('landing.hero.subtitle')} +

+
+ +
+
+ +
+ {values.map((value) => ( + +

{value.title}

+

{value.body}

+
+ ))} +
+ +
+ +

{t('landing.pricing.title')}

+

{t('landing.pricing.body')}

+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/(marketing)/ruc-hook.tsx b/apps/web/app/[locale]/(marketing)/ruc-hook.tsx new file mode 100644 index 0000000..7a2e697 --- /dev/null +++ b/apps/web/app/[locale]/(marketing)/ruc-hook.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { isApiError, type LookupDto } from '@impuestos/contracts'; +import { formatDateLong, type Locale } from '@impuestos/i18n'; +import { useMutation } from '@tanstack/react-query'; +import { useLocale } from 'next-intl'; +import { useState } from 'react'; +import { Button, buttonClasses } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Link } from '@/i18n/navigation'; +import { useT } from '@/i18n/t'; +import { api } from '@/lib/api'; + +/** + * Flow A2. Type a RUC or CI and see your own filing dates before signing up: the whole + * point is that the first screen already knows something true about you. + */ +export function RucHook() { + const t = useT(); + const locale = useLocale() as Locale; + const [value, setValue] = useState(''); + + const lookup = useMutation({ + mutationFn: (input) => api.lookupRuc(input), + }); + + const result = lookup.data; + const invalid = result !== undefined && !result.valid; + + return ( +
+
{ + event.preventDefault(); + const trimmed = value.trim(); + if (trimmed.length > 0) lookup.mutate(trimmed); + }} + > + + {/* Stacks at 390px so the call to action never wraps to two lines. */} +
+ setValue(event.target.value)} + /> + +
+ + {invalid ? ( + + ) : null} + + {lookup.isError ? ( +

+ {isApiError(lookup.error) ? lookup.error.message : t('common.error.generic')} +

+ ) : null} +
+ + {lookup.isPending ? ( + + + + ) : null} + + {result?.valid ? : null} +
+ ); +} + +function Preview({ result, locale }: { result: LookupDto; locale: Locale }) { + const t = useT(); + const [d1, d2, d3] = result.nextDeadlines as [string, string, string]; + + return ( + +

{t('landing.preview.title')}

+

+ {t('landing.preview.deadline', { day: result.deadlineDay })} +

+

+ {t('landing.preview.next', { + d1: formatDateLong(locale, d1), + d2: formatDateLong(locale, d2), + d3: formatDateLong(locale, d3), + })} +

+ + {t('landing.preview.cta')} + +
+ ); +} diff --git a/apps/web/app/[locale]/page.tsx b/apps/web/app/[locale]/page.tsx deleted file mode 100644 index ce011a4..0000000 --- a/apps/web/app/[locale]/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -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/package.json b/apps/web/package.json index 7da1919..d5062d2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "dependencies": { "@impuestos/contracts": "workspace:*", "@impuestos/i18n": "workspace:*", + "@impuestos/rules": "workspace:*", "@tanstack/react-query": "^5.102.8", "better-auth": "^1.7.2", "class-variance-authority": "^0.7.1", diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 02be9f3..6b3b694 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -2,7 +2,7 @@ import { cva, type VariantProps } from 'class-variance-authority'; import type { ButtonHTMLAttributes } from 'react'; import { cn } from '@/lib/utils'; -const button = cva( +export const buttonClasses = 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', @@ -23,8 +23,8 @@ const button = cva( }, ); -export type ButtonProps = ButtonHTMLAttributes & VariantProps; +export type ButtonProps = ButtonHTMLAttributes & VariantProps; export function Button({ className, variant, size, block, ...props }: ButtonProps) { - return + ); +} diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts index ce3ee12..f797e94 100644 --- a/apps/web/src/lib/auth-client.ts +++ b/apps/web/src/lib/auth-client.ts @@ -1,6 +1,6 @@ 'use client'; -import { adminClient } from 'better-auth/client/plugins'; +import { adminClient, emailOTPClient } from 'better-auth/client/plugins'; import { createAuthClient } from 'better-auth/react'; /** @@ -9,5 +9,5 @@ import { createAuthClient } from 'better-auth/react'; */ export const authClient = createAuthClient({ basePath: '/api/auth', - plugins: [adminClient()], + plugins: [adminClient(), emailOTPClient()], }); diff --git a/e2e/db.ts b/e2e/db.ts new file mode 100644 index 0000000..20932f0 --- /dev/null +++ b/e2e/db.ts @@ -0,0 +1,67 @@ +import Database from 'better-sqlite3'; +import { fileURLToPath } from 'node:url'; + +/** + * Read only access to the stack's database, used to assert the rows a flow was supposed + * to write and to read the emailed verification code (which development prints to the + * server console rather than sending). Only valid against a local SQLite stack. + */ +const DB_PATH = + process.env['E2E_DB_PATH'] ?? fileURLToPath(new URL('../apps/api/data/app.db', import.meta.url)); + +function open(): Database.Database { + return new Database(DB_PATH, { readonly: true, fileMustExist: true }); +} + +function query(run: (db: Database.Database) => T): T { + const db = open(); + try { + return run(db); + } finally { + db.close(); + } +} + +/** better-auth stores the code as `:` against the recipient's address. */ +export function readVerificationOtp(email: string): string { + return query((db) => { + const row = db + .prepare('select value from verification where identifier = ? order by createdAt desc limit 1') + .get(`email-verification-otp-${email}`) as { value: string } | undefined; + if (!row) throw new Error(`no verification code was issued for ${email}`); + return (row.value.split(':')[0] ?? '').trim(); + }); +} + +export function readProfile(email: string): Record | undefined { + return query( + (db) => + db + .prepare( + 'select p.* from profiles p join user u on u.id = p.user_id where u.email = ?', + ) + .get(email) as Record | undefined, + ); +} + +export function readAuditActions(email: string): string[] { + return query((db) => { + const rows = db + .prepare( + 'select a.action from audit_log a join user u on u.id = a.subject_user_id where u.email = ? order by a.created_at', + ) + .all(email) as { action: string }[]; + return rows.map((row) => row.action); + }); +} + +export function readConsents(email: string): { kind: string; revoked: boolean }[] { + return query((db) => { + const rows = db + .prepare( + 'select c.kind, c.revoked_at from consents c join user u on u.id = c.user_id where u.email = ?', + ) + .all(email) as { kind: string; revoked_at: string | null }[]; + return rows.map((row) => ({ kind: row.kind, revoked: row.revoked_at !== null })); + }); +} diff --git a/e2e/language.spec.ts b/e2e/language.spec.ts index 569865f..5db4056 100644 --- a/e2e/language.spec.ts +++ b/e2e/language.spec.ts @@ -26,11 +26,11 @@ test.describe('sign in screen', () => { // The en catalog exists for expats and international users (COPY.md section 0-EN), // so the root honours the browser language and falls back to es, the default. - test('the root follows the browser language', async ({ browser }) => { + test('the landing page follows the browser language', async ({ browser }) => { for (const [locale, expected] of [ - ['es-PY', /\/es\/login$/], - ['en-US', /\/en\/login$/], - ['pt-BR', /\/es\/login$/], + ['es-PY', /\/es$/], + ['en-US', /\/en$/], + ['pt-BR', /\/es$/], ] as const) { const context = await browser.newContext({ locale }); const page = await context.newPage(); diff --git a/e2e/onboarding.spec.ts b/e2e/onboarding.spec.ts new file mode 100644 index 0000000..6566e95 --- /dev/null +++ b/e2e/onboarding.spec.ts @@ -0,0 +1,87 @@ +import { es } from '@impuestos/i18n'; +import { expect, test } from '@playwright/test'; +import { readAuditActions, readConsents, readProfile, readVerificationOtp } from './db'; + +/** + * Golden path 1 (SPEC.md section 13): a stranger arrives at the landing page and leaves + * with a working account, a consent record and a profile. Every step is the real thing, + * including the six digit code, which is read back out of the database because + * development prints it to the server console instead of sending it. + */ +test.describe('onboarding', () => { + test('takes a new visitor from the landing page to their first run screen', async ({ + page, + }, testInfo) => { + const email = `nuevo-${testInfo.project.name}-${Date.now()}@demo.local`; + const password = 'una-contrasena-larga'; + + // A1 and A2: the landing hook answers with this taxpayer's real filing day. + await page.goto('/es'); + await expect(page.getByRole('heading', { name: es['landing.hero.title'] })).toBeVisible(); + + await page.getByLabel(es['landing.hero.inputLabel']).fill('4123456-1'); + await page.getByRole('button', { name: es['landing.hero.cta'] }).click(); + + // Base ends in 6, so RULES.md puts the deadline on the 19th. + await expect(page.getByText('19', { exact: false }).first()).toBeVisible(); + await page.getByRole('link', { name: es['landing.preview.cta'] }).click(); + + // A3: account creation, then the six digit code. + await expect(page).toHaveURL(/\/es\/registro$/); + await page.getByLabel(es['auth.register.email']).fill(email); + await page.getByLabel(es['auth.register.password']).fill(password); + await page.getByRole('button', { name: es['auth.register.submit'] }).click(); + + await expect(page).toHaveURL(/\/es\/verificar/); + await expect(page.getByRole('heading', { name: es['auth.otp.title'] })).toBeVisible(); + + await expect.poll(() => safeOtp(email), { timeout: 10_000 }).toMatch(/^\d{6}$/); + const otp = readVerificationOtp(email); + // The first box takes the whole pasted code and spreads it across the six. + await page.getByLabel('1', { exact: true }).fill(otp); + + // A4: consent. The primary action stays disabled until data processing is granted. + await expect(page).toHaveURL(/\/es\/consentimiento$/); + const accept = page.getByRole('button', { name: es['consent.accept'] }); + await expect(accept).toBeDisabled(); + + await page.getByRole('switch', { name: es['consent.dataProcessing'] }).click(); + await expect(accept).toBeEnabled(); + await accept.click(); + + // A5: three steps. + await expect(page).toHaveURL(/\/es\/configuracion$/); + await page.getByLabel(es['setup.fullName']).fill('Nueva Contribuyente'); + await page.getByLabel(es['setup.docLabel']).fill('4123456-1'); + await page.getByLabel(es['setup.docLabel']).blur(); + await expect(page.getByText(es['setup.deadlineNote'].replace('{day}', '19'))).toBeVisible(); + + await page.getByRole('button', { name: es['common.continue'] }).click(); + await page.getByRole('button', { name: es['setup.oblig.iva.title'] }).click(); + await page.getByRole('button', { name: es['common.continue'] }).click(); + await page.getByRole('button', { name: es['setup.finish'] }).click(); + + // A6: the guided first run state. + await expect(page).toHaveURL(/\/es\/inicio$/); + await expect(page.getByRole('heading', { name: es['home.firstRun.title'] })).toBeVisible(); + await expect(page.getByRole('link', { name: es['home.firstRun.scan'] })).toBeVisible(); + + // The flow left the rows behind that it was supposed to. + const profile = readProfile(email); + expect(profile?.['full_name']).toBe('Nueva Contribuyente'); + expect(profile?.['ruc']).toBe('4123456'); + expect(profile?.['deadline_digit']).toBe(6); + + expect(readConsents(email)).toContainEqual({ kind: 'data_processing', revoked: false }); + expect(readAuditActions(email)).toContain('consent.grant'); + expect(readAuditActions(email)).toContain('profile.create'); + }); +}); + +function safeOtp(email: string): string { + try { + return readVerificationOtp(email); + } catch { + return ''; + } +} diff --git a/e2e/profile.spec.ts b/e2e/profile.spec.ts new file mode 100644 index 0000000..47e74c5 --- /dev/null +++ b/e2e/profile.spec.ts @@ -0,0 +1,111 @@ +import { es } from '@impuestos/i18n'; +import { expect, test } from '@playwright/test'; +import { readAuditActions } from './db'; + +/** Flow E3. The trust screen: what we hold, and the two ways to take it back. */ +test.describe('profile', () => { + 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'); + await page.getByRole('button', { name: es['auth.login.submit'] }).click(); + await expect(page).toHaveURL(/\/es\/inicio$/); + }); + + test('shows the seeded identity, dependants and the data section', async ({ page }) => { + await page.goto('/es/perfil'); + + await expect(page.getByLabel(es['setup.fullName'])).toHaveValue('Maria Gonzalez'); + await expect(page.getByLabel(es['setup.docLabel'])).toHaveValue('4123456-1'); + // Base ends in 6, so RULES.md puts her deadline on the 19th. + await expect(page.getByText(es['vto.explainer'].replace('{digit}', '6').replace('{day}', '19'))).toBeVisible(); + + await expect(page.getByText('Lucas Gonzalez')).toBeVisible(); + await expect( + page.getByRole('heading', { name: es['profile.myData.title'], exact: true }), + ).toBeVisible(); + await expect(page.getByRole('link', { name: es['profile.myData.export'] })).toBeVisible(); + }); + + test('adds and removes a dependant', async ({ page }) => { + await page.goto('/es/perfil'); + + const name = `Prueba ${Date.now()}`; + await page.getByLabel(es['setup.dependents.name'], { exact: true }).fill(name); + await page.getByRole('button', { name: es['setup.dependents.add'] }).click(); + await expect(page.getByText(name)).toBeVisible(); + + await page + .getByRole('listitem') + .filter({ hasText: name }) + .getByRole('button', { name: es['common.remove'] }) + .click(); + await expect(page.getByText(name)).toBeHidden(); + + expect(readAuditActions('maria@demo.local')).toContain('dependent.create'); + expect(readAuditActions('maria@demo.local')).toContain('dependent.delete'); + }); + + test('the export downloads a file holding the profile', async ({ page }) => { + await page.goto('/es/perfil'); + + const download = page.waitForEvent('download'); + await page.getByRole('link', { name: es['profile.myData.export'] }).click(); + const file = await download; + + expect(file.suggestedFilename()).toMatch(/^impuestos-datos-\d{4}-\d{2}-\d{2}\.json$/); + const stream = await file.createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(chunk as Buffer); + const data = JSON.parse(Buffer.concat(chunks).toString('utf8')) as { + profile: { fullName: string } | null; + account: { email: string }; + }; + + expect(data.account.email).toBe('maria@demo.local'); + expect(data.profile?.fullName).toBe('Maria Gonzalez'); + expect(readAuditActions('maria@demo.local')).toContain('data.export'); + }); + + test('deleting the account asks for the email before it will proceed', async ({ page }) => { + await page.goto('/es/perfil'); + await page.getByRole('button', { name: es['profile.myData.delete'] }).click(); + + const confirm = page.getByRole('button', { name: es['profile.myData.deleteCta'] }); + await expect(confirm).toBeDisabled(); + + await page.getByLabel(es['profile.myData.deletePrompt'].replace('{email}', 'maria@demo.local')).fill('nope@demo.local'); + await expect(confirm).toBeDisabled(); + + // Stops short of confirming: this account is the showcase data for every other test. + await page.getByRole('button', { name: es['common.cancel'] }).click(); + }); +}); + +/** The toggle knob used to render outside its track, which read as a broken control. */ +test.describe('switch', () => { + test('keeps its knob inside the track in both states', 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'); + await page.getByRole('button', { name: es['auth.login.submit'] }).click(); + await expect(page).toHaveURL(/\/es\/inicio$/); + await page.goto('/es/perfil'); + + const toggle = page.getByRole('switch', { name: es['setup.oblig.iva.title'] }); + await expect(toggle).toBeVisible(); + + for (const _ of [0, 1]) { + const track = await toggle.boundingBox(); + const knob = await toggle.locator('span').boundingBox(); + expect(track).not.toBeNull(); + expect(knob).not.toBeNull(); + expect(knob?.x ?? 0).toBeGreaterThanOrEqual(track?.x ?? 0); + expect((knob?.x ?? 0) + (knob?.width ?? 0)).toBeLessThanOrEqual( + (track?.x ?? 0) + (track?.width ?? 0), + ); + await toggle.click(); + await page.waitForTimeout(250); + } + }); +}); diff --git a/package.json b/package.json index 049e490..9f7d8d5 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@playwright/test": "^1.62.1", "@types/node": "^26.4.1", "@vitest/coverage-v8": "^5.0.0", + "better-sqlite3": "^13.0.3", "eslint": "^10.9.1", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.1.1", diff --git a/packages/contracts/src/client.ts b/packages/contracts/src/client.ts index e23ddf7..e8e7a46 100644 --- a/packages/contracts/src/client.ts +++ b/packages/contracts/src/client.ts @@ -1,5 +1,17 @@ import type { z } from 'zod'; -import { ProfileDto } from './dto'; +import { + type ConsentInput, + DataExportDto, + type DeleteAccountInput, + DependentDto, + type DependentInput, + LookupDto, + NotificationPrefsDto, + type NotificationPrefsInput, + OkDto, + ProfileDto, + type ProfileInput, +} from './dto'; import { ApiError, ErrorEnvelope } from './errors'; export interface ApiClientOptions { @@ -60,8 +72,48 @@ export function createApiClient(options: ApiClientOptions = {}) { return { request, + + // Public + lookupRuc: (numberOrRuc: string, signal?: AbortSignal) => + request('GET', `/lookup/ruc/${encodeURIComponent(numberOrRuc)}`, { + schema: LookupDto, + ...(signal ? { signal } : {}), + }), + + // Profile getProfile: (signal?: AbortSignal) => request('GET', '/me/profile', { schema: ProfileDto, ...(signal ? { signal } : {}) }), + putProfile: (body: ProfileInput) => request('PUT', '/me/profile', { schema: ProfileDto, body }), + + // Dependents + listDependents: (signal?: AbortSignal) => + request('GET', '/me/dependents', { + schema: DependentDto.array(), + ...(signal ? { signal } : {}), + }), + createDependent: (body: DependentInput) => + request('POST', '/me/dependents', { schema: DependentDto, body }), + deleteDependent: (id: string) => + request('DELETE', `/me/dependents/${encodeURIComponent(id)}`, { schema: OkDto }), + + // Consent and account + postConsent: (body: ConsentInput) => request('POST', '/me/consents', { schema: OkDto, body }), + getDataExport: (signal?: AbortSignal) => + request('GET', '/me/data-export', { + schema: DataExportDto, + ...(signal ? { signal } : {}), + }), + deleteAccount: (body: DeleteAccountInput) => + request('DELETE', '/me/account', { schema: OkDto, body }), + + // Notifications + getNotificationPrefs: (signal?: AbortSignal) => + request('GET', '/me/notification-prefs', { + schema: NotificationPrefsDto, + ...(signal ? { signal } : {}), + }), + patchNotificationPrefs: (body: NotificationPrefsInput) => + request('PATCH', '/me/notification-prefs', { schema: NotificationPrefsDto, body }), }; } diff --git a/packages/contracts/src/dto.ts b/packages/contracts/src/dto.ts index 012317b..6d7f21c 100644 --- a/packages/contracts/src/dto.ts +++ b/packages/contracts/src/dto.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { DocType, LocaleCode, ObligationCode, TaxpayerKind } from './enums'; +import { DocType, IrpCategory, LocaleCode, ObligationCode, TaxpayerKind } from './enums'; /** * DTO schemas are added as their phase lands. Field names come from CONTRACTS.md @@ -28,6 +28,90 @@ export const ProfileDto = z.object({ }); export type ProfileDto = z.infer; +/** `PUT /me/profile` body: the profile minus deadlineDigit, which is derived. */ +export const ProfileInput = ProfileDto.omit({ deadlineDigit: true }).extend({ + fullName: z.string().trim().min(1).max(200), + irpGrossEstimate: z.number().int().min(0).max(1_000_000_000_000).nullable(), + autoConfirmDays: z.number().int().min(0).max(90), +}); +export type ProfileInput = z.infer; + +export const Relationship = z.enum(['conyuge', 'hijo', 'padre', 'otro']); +export type Relationship = z.infer; + +export const DependentDto = z.object({ + id: z.string(), + displayName: z.string(), + relationship: Relationship, + active: z.boolean(), +}); +export type DependentDto = z.infer; + +export const DependentInput = z.object({ + displayName: z.string().trim().min(1).max(120), + relationship: Relationship, + docNumber: z.string().trim().max(20).nullable().optional(), +}); +export type DependentInput = z.infer; + +export const ConsentKind = z.enum(['data_processing', 'notifications']); +export type ConsentKind = z.infer; + +export const ConsentDto = z.object({ + kind: ConsentKind, + granted: z.boolean(), + grantedAt: z.string(), + revokedAt: z.string().nullable(), + textVersion: z.string(), +}); +export type ConsentDto = z.infer; + +export const ConsentInput = z.object({ kind: ConsentKind, granted: z.boolean() }); +export type ConsentInput = z.infer; + +export const NotificationPrefsDto = z.object({ + pushEnabled: z.boolean(), + emailEnabled: z.boolean(), + telegramChatId: z.string().nullable(), + digestHour: z.number().int().min(0).max(23), +}); +export type NotificationPrefsDto = z.infer; + +export const NotificationPrefsInput = NotificationPrefsDto.partial(); +export type NotificationPrefsInput = z.infer; + +/** + * `GET /lookup/ruc/:number`. An unparseable number is a 200 with `valid: false`, not an + * error: the landing shows it inline and never dead ends (CONTRACTS.md section 3). + */ +export const LookupDto = z.object({ + valid: z.boolean(), + docType: DocType, + base: z.string(), + dv: z.number().int().nullable(), + deadlineDigit: z.number().int().min(0).max(9), + deadlineDay: z.number().int().min(1).max(31), + nextDeadlines: z.array(z.string()).length(3), +}); +export type LookupDto = z.infer; + +/** Everything the platform holds about one user, as a downloadable file. */ +export const DataExportDto = z.object({ + exportedAt: z.string(), + account: z.object({ email: z.string(), createdAt: z.string() }), + profile: ProfileDto.nullable(), + dependents: z.array(DependentDto), + consents: z.array(ConsentDto), + notificationPrefs: NotificationPrefsDto.nullable(), + documents: z.array(z.record(z.string(), z.unknown())), + classifications: z.array(z.record(z.string(), z.unknown())), + declarations: z.array(z.record(z.string(), z.unknown())), +}); +export type DataExportDto = z.infer; + +export const DeleteAccountInput = z.object({ confirmText: z.string() }); +export type DeleteAccountInput = z.infer; + export const OkDto = z.object({ ok: z.literal(true) }); export type OkDto = z.infer; @@ -39,3 +123,6 @@ export const ReadyDto = z.object({ checks: z.record(z.string(), z.enum(['ok', 'error', 'pending'])), }); export type ReadyDto = z.infer; + +/** Re-exported so callers get the category vocabulary from one place. */ +export { IrpCategory }; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 2a42e78..84018cd 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -17,6 +17,24 @@ export { export { ERROR_CODES, ErrorCode, ErrorEnvelope, ApiError, isApiError } from './errors'; -export { Obligation, ProfileDto, OkDto, HealthDto, ReadyDto } from './dto'; +export { + Obligation, + ProfileDto, + ProfileInput, + Relationship, + DependentDto, + DependentInput, + ConsentKind, + ConsentDto, + ConsentInput, + NotificationPrefsDto, + NotificationPrefsInput, + LookupDto, + DataExportDto, + DeleteAccountInput, + OkDto, + HealthDto, + ReadyDto, +} from './dto'; export { createApiClient, type ApiClient, type ApiClientOptions } from './client'; diff --git a/packages/i18n/src/catalogs/en.ts b/packages/i18n/src/catalogs/en.ts index 02983e2..902cecc 100644 --- a/packages/i18n/src/catalogs/en.ts +++ b/packages/i18n/src/catalogs/en.ts @@ -230,6 +230,77 @@ export const en: Record = { "classification.reason.irp_sale_is_income": "This is income of yours, not a deductible expense.", "classification.reason.irp_not_irp_taxpayer": "You are not registered for IRP, so it is not deducted.", + // Registration and verification (Flow A3) + "auth.register.submit": "Create my account", + "auth.register.passwordHint": "At least 8 characters.", + "auth.register.haveAccount": "Already have an account?", + "auth.register.signIn": "Sign in", + "auth.register.failed": "We could not create your account. Check the email and try again.", + "auth.register.emailTaken": "There is already an account with that email. Try signing in.", + "auth.login.register": "Create one", + "auth.otp.label": "6 digit code", + "auth.otp.submit": "Verify", + "auth.otp.failed": "That code does not match or it expired. Ask for a new one.", + "auth.otp.resent": "We sent you a new code.", + "auth.otp.devHint": "In development the code is printed to the server console.", + + // Landing (Flow A1 and A2) + "landing.lookup.checking": "Looking it up...", + "landing.pricing.title": "Pricing", + "landing.pricing.body": "We are still working out the plans. In the meantime it is free.", + "landing.footer.privacy": "Privacy", + "landing.footer.terms": "Terms", + "landing.preview.kindInd": "Individual", + "landing.preview.kindCom": "Company", + + // Consent (Flow A4) + "consent.required": "Without this we cannot store your facturas or build your declarations.", + "consent.accept": "I agree, continue", + + // Profile setup (Flow A5) + "setup.progress": "Step {step} of {total}", + "setup.docLabel": "Your RUC or CI", + "setup.docHelp": "With the verifier digit if you have a RUC, for example 4123456-1.", + "setup.deadlineNote": "Your vencimientos (due dates) will fall on day {day} of every month.", + "setup.dependents.name": "Name", + "setup.dependents.rel": "Relationship", + "setup.finish": "Finish", + "relationship.conyuge": "Spouse", + "relationship.hijo": "Son or daughter", + "relationship.padre": "Parent", + "relationship.otro": "Other", + + // Profile (Flow E3) + "profile.identity": "Your tax details", + "profile.obligations": "What you are registered for", + "profile.dependents": "Dependants", + "profile.dependents.empty": "You have not added anyone yet.", + "profile.income": "Estimated annual income", + "profile.notifications": "Alerts", + "profile.autoConfirm": "Automatic confirmation", + "profile.autoConfirm.help": "High confidence facturas confirm themselves after this many days. Set it to 0 to turn it off.", + "profile.saved": "Saved ✓", + "profile.myData.stored": "Your profile, your dependants, your facturas, your classifications and your declarations.", + "profile.myData.deletePrompt": "Type {email} to confirm.", + "profile.myData.deleteCta": "Delete permanently", + "profile.consent.revokeWarn": "Revoking consent signs you out and freezes the account.", + "notif.push": "Push notifications", + "notif.email": "Email", + "notif.telegram": "Telegram", + "notif.channelUnavailable": "This channel is not configured yet.", + + // Legal (COPY.md section 13: placeholder until a human writes them) + "legal.privacy.title": "Privacy policy", + "legal.terms.title": "Terms and conditions", + "legal.placeholder": "Document in preparation", + "legal.placeholderBody": "We are drafting this document with legal advice. It will be published before launch.", + + // Common + "common.add": "Add", + "common.remove": "Remove", + "common.saving": "Saving...", + "common.skip": "Skip", + // Chrome (es.extra.ts) "common.language": "Language", "common.languageEs": "Español", diff --git a/packages/i18n/src/catalogs/es.extra.ts b/packages/i18n/src/catalogs/es.extra.ts index 3a14a6b..5e9238e 100644 --- a/packages/i18n/src/catalogs/es.extra.ts +++ b/packages/i18n/src/catalogs/es.extra.ts @@ -31,6 +31,77 @@ export const esExtra = { "classification.reason.irp_sale_is_income": "Es un ingreso tuyo, no un gasto deducible.", "classification.reason.irp_not_irp_taxpayer": "No tenés IRP activo, asi que no se descuenta.", + // Registration and verification (Flow A3) + "auth.register.submit": "Crear mi cuenta", + "auth.register.passwordHint": "Al menos 8 caracteres.", + "auth.register.haveAccount": "¿Ya tenés cuenta?", + "auth.register.signIn": "Entrá", + "auth.register.failed": "No pudimos crear tu cuenta. Revisá el email y probá de nuevo.", + "auth.register.emailTaken": "Ya hay una cuenta con ese email. Probá entrar.", + "auth.login.register": "Creá una", + "auth.otp.label": "Codigo de 6 digitos", + "auth.otp.submit": "Verificar", + "auth.otp.failed": "Ese codigo no coincide o ya vencio. Pedí uno nuevo.", + "auth.otp.resent": "Te enviamos un codigo nuevo.", + "auth.otp.devHint": "En desarrollo el codigo se imprime en la consola del servidor.", + + // Landing (Flow A1 and A2) + "landing.lookup.checking": "Buscando...", + "landing.pricing.title": "Precio", + "landing.pricing.body": "Estamos definiendo los planes. Mientras tanto, usalo gratis.", + "landing.footer.privacy": "Privacidad", + "landing.footer.terms": "Terminos", + "landing.preview.kindInd": "Persona fisica", + "landing.preview.kindCom": "Empresa", + + // Consent (Flow A4) + "consent.required": "Sin esto no podemos guardar tus facturas ni armar tus declaraciones.", + "consent.accept": "Acepto y sigo", + + // Profile setup (Flow A5) + "setup.progress": "Paso {step} de {total}", + "setup.docLabel": "Tu RUC o CI", + "setup.docHelp": "Con el digito verificador si tenés RUC, por ejemplo 4123456-1.", + "setup.deadlineNote": "Tus vencimientos van a caer el dia {day} de cada mes.", + "setup.dependents.name": "Nombre", + "setup.dependents.rel": "Parentesco", + "setup.finish": "Terminar", + "relationship.conyuge": "Conyuge", + "relationship.hijo": "Hijo o hija", + "relationship.padre": "Padre o madre", + "relationship.otro": "Otro", + + // Profile (Flow E3) + "profile.identity": "Tus datos fiscales", + "profile.obligations": "Tus obligaciones", + "profile.dependents": "Familiares a cargo", + "profile.dependents.empty": "Todavia no cargaste ninguno.", + "profile.income": "Ingreso anual estimado", + "profile.notifications": "Avisos", + "profile.autoConfirm": "Confirmacion automatica", + "profile.autoConfirm.help": "Las facturas con alta confianza se confirman solas pasados estos dias. Poné 0 para desactivarlo.", + "profile.saved": "Guardado ✓", + "profile.myData.stored": "Tu perfil, tus familiares a cargo, tus facturas, tus clasificaciones y tus declaraciones.", + "profile.myData.deletePrompt": "Escribí {email} para confirmar.", + "profile.myData.deleteCta": "Eliminar definitivamente", + "profile.consent.revokeWarn": "Si revocas el consentimiento cerramos tu sesion y congelamos la cuenta.", + "notif.push": "Notificaciones push", + "notif.email": "Email", + "notif.telegram": "Telegram", + "notif.channelUnavailable": "Este canal todavia no esta configurado.", + + // Legal (COPY.md section 13: placeholder until a human writes them) + "legal.privacy.title": "Politica de privacidad", + "legal.terms.title": "Terminos y condiciones", + "legal.placeholder": "Documento en preparacion", + "legal.placeholderBody": "Estamos redactando este documento con asesoria legal. Va a estar publicado antes del lanzamiento.", + + // Common + "common.add": "Agregar", + "common.remove": "Quitar", + "common.saving": "Guardando...", + "common.skip": "Omitir", + // Chrome "common.language": "Idioma", "common.languageEs": "Español", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ba4624..f3d0d7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@vitest/coverage-v8': specifier: ^5.0.0 version: 5.0.0(vitest@5.0.0) + better-sqlite3: + specifier: ^13.0.3 + version: 13.0.3 eslint: specifier: ^10.9.1 version: 10.9.1(jiti@2.7.0) @@ -61,7 +64,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 @@ -108,12 +111,15 @@ importers: '@impuestos/i18n': specifier: workspace:* version: link:../../packages/i18n + '@impuestos/rules': + specifier: workspace:* + version: link:../../packages/rules '@tanstack/react-query': specifier: ^5.102.8 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 @@ -4387,7 +4393,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)