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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
80b10c958e
commit
0d7651b17c
+195
-7
@@ -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<SeedResult> {
|
||||
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<SeedResult> {
|
||||
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string> {
|
||||
const row = await handle.db
|
||||
.selectFrom('user')
|
||||
.select('id')
|
||||
.where('email', '=', email)
|
||||
.executeTakeFirstOrThrow();
|
||||
return row.id;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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<AppEnv>();
|
||||
api.use('*', sessionMiddleware(deps));
|
||||
api.use('*', localeMiddleware(deps));
|
||||
api.route('/lookup', lookupRoutes());
|
||||
api.route('/me', meRoutes(deps));
|
||||
|
||||
app.route('/api', api);
|
||||
|
||||
@@ -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<AppEnv> {
|
||||
const routes = new Hono<AppEnv>();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<AppEnv> {
|
||||
const routes = new Hono<AppEnv>();
|
||||
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<unknown> } }): Promise<unknown> {
|
||||
try {
|
||||
return await c.req.json();
|
||||
} catch {
|
||||
throw new HttpError('validation_error');
|
||||
}
|
||||
}
|
||||
|
||||
function parse<T>(schema: z.ZodType<T>, 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<typeof writeAudit>[1]['action']; resource: string; detail?: Record<string, unknown> },
|
||||
): Promise<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, Bucket>();
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown> | undefined;
|
||||
ip?: string | null;
|
||||
}
|
||||
|
||||
export async function writeAudit(db: Kysely<Database>, entry: AuditEntry): Promise<void> {
|
||||
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();
|
||||
}
|
||||
@@ -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: ['', '', ''],
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<Database>,
|
||||
userId: string,
|
||||
): Promise<DataExportDto> {
|
||||
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<Database>,
|
||||
userId: string,
|
||||
reason: FreezeReason,
|
||||
): Promise<void> {
|
||||
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.
|
||||
}
|
||||
@@ -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<Database>, userId: string): Promise<ConsentDto[]> {
|
||||
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<Database>,
|
||||
userId: string,
|
||||
kind: ConsentKind,
|
||||
): Promise<boolean> {
|
||||
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<Database>,
|
||||
userId: string,
|
||||
kind: ConsentKind,
|
||||
granted: boolean,
|
||||
): Promise<void> {
|
||||
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();
|
||||
}
|
||||
@@ -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<Database>,
|
||||
userId: string,
|
||||
): Promise<DependentDto[]> {
|
||||
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<Database>,
|
||||
userId: string,
|
||||
input: DependentInput,
|
||||
): Promise<DependentDto> {
|
||||
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<Database>,
|
||||
userId: string,
|
||||
id: string,
|
||||
): Promise<boolean> {
|
||||
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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<Database>,
|
||||
userId: string,
|
||||
): Promise<NotificationPrefsDto> {
|
||||
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<Database>,
|
||||
userId: string,
|
||||
input: NotificationPrefsInput,
|
||||
): Promise<NotificationPrefsDto> {
|
||||
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;
|
||||
}
|
||||
@@ -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<Database>, 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<Database>,
|
||||
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<ProfileInput, 'docType' | 'ruc' | 'ci'>): 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<Database>, userId: string): Promise<L
|
||||
.executeTakeFirst();
|
||||
return row && isLocale(row.locale) ? row.locale : null;
|
||||
}
|
||||
|
||||
interface ProfileRow {
|
||||
full_name: string;
|
||||
doc_type: 'ruc' | 'ci';
|
||||
ruc: string | null;
|
||||
ruc_dv: string | null;
|
||||
ci: string | null;
|
||||
taxpayer_kind: 'individual' | 'company';
|
||||
deadline_digit: number;
|
||||
obligations: string;
|
||||
irp_gross_estimate: number | null;
|
||||
auto_confirm_days: number;
|
||||
locale: 'es' | 'en';
|
||||
}
|
||||
|
||||
function toDto(row: ProfileRow): ProfileDto {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import { SetupWizard } from './setup-wizard';
|
||||
|
||||
export default async function SetupPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
return <SetupWizard locale={locale} />;
|
||||
}
|
||||
@@ -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<LookupDto | null>(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<Dependent[]>([]);
|
||||
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 (
|
||||
<div className="space-y-6 py-4">
|
||||
<ProgressDots step={step} />
|
||||
|
||||
{step === 1 ? (
|
||||
<Card className="space-y-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('setup.step1.title')}</h1>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="fullName">{t('setup.fullName')}</Label>
|
||||
<Input
|
||||
id="fullName"
|
||||
value={fullName}
|
||||
autoComplete="name"
|
||||
onChange={(event) => setFullName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="doc">{t('setup.docLabel')}</Label>
|
||||
<Input
|
||||
id="doc"
|
||||
inputMode="numeric"
|
||||
value={doc}
|
||||
aria-invalid={lookup !== null && !lookup.valid}
|
||||
aria-describedby="doc-help"
|
||||
onChange={(event) => {
|
||||
setDoc(event.target.value);
|
||||
setLookup(null);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (doc.trim().length > 0) check.mutate(doc.trim());
|
||||
}}
|
||||
/>
|
||||
<p id="doc-help" className="text-sm text-[var(--text-muted)]">
|
||||
{t('setup.docHelp')}
|
||||
</p>
|
||||
{lookup !== null && !lookup.valid ? (
|
||||
<p role="alert" className="text-sm text-overdue">
|
||||
{t('landing.invalidDoc')}
|
||||
</p>
|
||||
) : null}
|
||||
{lookup?.valid ? (
|
||||
<p className="text-sm text-positive">
|
||||
{t('setup.deadlineNote', { day: lookup.deadlineDay })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<fieldset className="space-y-2">
|
||||
<legend className="text-sm font-medium text-[var(--text-muted)]">
|
||||
{t('setup.taxpayerKind.q')}
|
||||
</legend>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ChoiceCard
|
||||
selected={taxpayerKind === 'individual'}
|
||||
onSelect={() => setTaxpayerKind('individual')}
|
||||
title={t('setup.taxpayerKind.ind')}
|
||||
/>
|
||||
<ChoiceCard
|
||||
selected={taxpayerKind === 'company'}
|
||||
onSelect={() => setTaxpayerKind('company')}
|
||||
title={t('setup.taxpayerKind.com')}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{step === 2 ? (
|
||||
<Card className="space-y-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('setup.step2.title')}</h1>
|
||||
|
||||
<div className="space-y-2">
|
||||
<ChoiceCard
|
||||
selected={hasIva}
|
||||
onSelect={() => setHasIva(!hasIva)}
|
||||
title={t('setup.oblig.iva.title')}
|
||||
body={t('setup.oblig.iva.body')}
|
||||
/>
|
||||
<ChoiceCard
|
||||
selected={hasIrp}
|
||||
onSelect={() => setHasIrp(!hasIrp)}
|
||||
title={t('setup.oblig.irp.title')}
|
||||
body={t('setup.oblig.irp.body')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full py-2 text-sm text-[var(--text-muted)] underline"
|
||||
onClick={() => {
|
||||
setHasIva(false);
|
||||
setHasIrp(false);
|
||||
setStep(3);
|
||||
}}
|
||||
>
|
||||
{t('setup.oblig.unsure')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasIrp ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="income">{t('setup.income.q')}</Label>
|
||||
<Input
|
||||
id="income"
|
||||
inputMode="numeric"
|
||||
value={income === '' ? '' : formatGsAmount(Number(income))}
|
||||
aria-describedby="income-help"
|
||||
onChange={(event) => setIncome(event.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<p id="income-help" className="text-sm text-[var(--text-muted)]">
|
||||
{t('setup.income.help')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DependentsEditor
|
||||
dependents={dependents}
|
||||
onChange={setDependents}
|
||||
relationships={RELATIONSHIPS}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{step === 3 ? (
|
||||
<Card className="space-y-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('setup.step3.title')}</h1>
|
||||
<p className="text-sm text-[var(--text-muted)] text-pretty">{t('setup.push.body')}</p>
|
||||
|
||||
<label className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm">{t('notif.email')}</span>
|
||||
<Switch
|
||||
checked={emailAlerts}
|
||||
onCheckedChange={setEmailAlerts}
|
||||
label={t('notif.email')}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{save.isError ? (
|
||||
<p role="alert" className="text-sm text-overdue">
|
||||
{t('common.error.generic')}
|
||||
</p>
|
||||
) : null}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-3">
|
||||
{step > 1 ? (
|
||||
<Button type="button" variant="secondary" onClick={() => setStep(step - 1)}>
|
||||
{t('common.back')}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{step < TOTAL_STEPS ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
block
|
||||
disabled={!canContinue}
|
||||
onClick={() => setStep(step + 1)}
|
||||
>
|
||||
{t('common.continue')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
block
|
||||
disabled={save.isPending}
|
||||
onClick={() => save.mutate()}
|
||||
>
|
||||
{save.isPending ? t('common.saving') : t('setup.finish')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressDots({ step }: { step: number }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
role="status"
|
||||
aria-label={t('setup.progress', { step, total: TOTAL_STEPS })}
|
||||
>
|
||||
{Array.from({ length: TOTAL_STEPS }).map((_, index) => (
|
||||
<span
|
||||
key={index}
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'h-1.5 flex-1 rounded-full transition-colors',
|
||||
index < step ? 'bg-accent-600' : 'bg-[var(--border-subtle)]',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChoiceCard({
|
||||
selected,
|
||||
onSelect,
|
||||
title,
|
||||
body,
|
||||
}: {
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
title: string;
|
||||
body?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
'rounded-2xl border p-4 text-left transition-colors',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600',
|
||||
selected ? 'border-accent-600 bg-accent-50' : 'bg-[var(--surface-raised)]',
|
||||
)}
|
||||
>
|
||||
<span className="block font-medium">{title}</span>
|
||||
{body ? (
|
||||
<span className="mt-1 block text-sm text-[var(--text-muted)] text-pretty">{body}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DependentsEditor({
|
||||
dependents,
|
||||
onChange,
|
||||
relationships,
|
||||
}: {
|
||||
dependents: Dependent[];
|
||||
onChange: (next: Dependent[]) => void;
|
||||
relationships: Relationship[];
|
||||
}) {
|
||||
const t = useT();
|
||||
const [name, setName] = useState('');
|
||||
const [relationship, setRelationship] = useState<Relationship>('hijo');
|
||||
|
||||
return (
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<div>
|
||||
<h2 className="font-medium">{t('setup.dependents.title')}</h2>
|
||||
<p className="text-sm text-[var(--text-muted)] text-pretty">{t('setup.dependents.help')}</p>
|
||||
</div>
|
||||
|
||||
{dependents.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{dependents.map((dependent, index) => (
|
||||
<li key={`${dependent.displayName}-${index}`} className="flex items-center justify-between gap-3 text-sm">
|
||||
<span>
|
||||
{dependent.displayName} ({t(`relationship.${dependent.relationship}` as const)})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-overdue underline"
|
||||
onClick={() => onChange(dependents.filter((_, i) => i !== index))}
|
||||
>
|
||||
{t('common.remove')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input
|
||||
aria-label={t('setup.dependents.name')}
|
||||
placeholder={t('setup.dependents.name')}
|
||||
value={name}
|
||||
className="flex-1"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<select
|
||||
aria-label={t('setup.dependents.rel')}
|
||||
value={relationship}
|
||||
onChange={(event) => setRelationship(event.target.value as Relationship)}
|
||||
className="h-12 rounded-2xl border bg-[var(--surface-raised)] px-3 text-base"
|
||||
>
|
||||
{relationships.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`relationship.${value}` as const)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={name.trim().length === 0}
|
||||
onClick={() => {
|
||||
onChange([...dependents, { displayName: name.trim(), relationship }]);
|
||||
setName('');
|
||||
}}
|
||||
>
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-balance">{t('consent.title')}</h1>
|
||||
<p className="text-sm text-[var(--text-muted)] text-pretty">{t('consent.intro')}</p>
|
||||
<ul className="space-y-2 text-sm text-[var(--text-muted)]">
|
||||
{['consent.bullet1', 'consent.bullet2', 'consent.bullet3'].map((key) => (
|
||||
<li key={key} className="flex gap-2">
|
||||
<span aria-hidden className="text-positive">
|
||||
✓
|
||||
</span>
|
||||
<span className="text-pretty">{t(key as 'consent.bullet1')}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-start justify-between gap-4">
|
||||
<span className="text-sm text-pretty">{t('consent.dataProcessing')}</span>
|
||||
<Switch
|
||||
checked={dataProcessing}
|
||||
onCheckedChange={setDataProcessing}
|
||||
label={t('consent.dataProcessing')}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-start justify-between gap-4">
|
||||
<span className="text-sm text-pretty">{t('consent.notifications')}</span>
|
||||
<Switch
|
||||
checked={notifications}
|
||||
onCheckedChange={setNotifications}
|
||||
label={t('consent.notifications')}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!dataProcessing ? (
|
||||
<p className="text-sm text-[var(--text-muted)]">{t('consent.required')}</p>
|
||||
) : null}
|
||||
|
||||
{grant.isError ? (
|
||||
<p role="alert" className="text-sm text-overdue">
|
||||
{t('common.error.generic')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
block
|
||||
disabled={!dataProcessing || grant.isPending}
|
||||
onClick={() => grant.mutate()}
|
||||
>
|
||||
{grant.isPending ? t('common.saving') : t('consent.accept')}
|
||||
</Button>
|
||||
<Link
|
||||
href="/legal/privacidad"
|
||||
className="block text-center text-sm text-accent-700 hover:underline"
|
||||
>
|
||||
{t('consent.policyLink')}
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 <ConsentForm />;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mx-auto flex min-h-dvh max-w-2xl flex-col">
|
||||
<header className="flex items-center justify-between px-5 py-4">
|
||||
<h1 className="text-base font-semibold tracking-tight">{t('home.title')}</h1>
|
||||
<LanguageSwitcher />
|
||||
</header>
|
||||
// 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 });
|
||||
|
||||
<main className="px-5 pb-16">
|
||||
<Card className="space-y-2">
|
||||
{profile ? (
|
||||
<>
|
||||
<p className="text-sm text-[var(--text-muted)]">{t('setup.fullName')}</p>
|
||||
<p className="text-2xl font-semibold tracking-tight">{profile.fullName}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold tracking-tight">{t('setup.step1.title')}</h2>
|
||||
<p className="text-sm text-[var(--text-muted)]">{t('setup.income.help')}</p>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</main>
|
||||
return (
|
||||
<div className="space-y-6 py-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('home.title')}</h1>
|
||||
<p className="text-sm text-[var(--text-muted)]">{profile?.fullName}</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon={<ScanLine aria-hidden className="size-8" />}
|
||||
title={t('home.firstRun.title')}
|
||||
body={t('home.firstRun.body')}
|
||||
action={
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{/* Scanning and manual entry arrive with the ingestion pipeline in phase 3. */}
|
||||
<Link href="/comprobantes" className={buttonClasses({ size: 'lg' })}>
|
||||
{t('home.firstRun.scan')}
|
||||
</Link>
|
||||
<Link href="/comprobantes" className="text-sm text-accent-700 hover:underline">
|
||||
{t('home.firstRun.manual')}
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto flex min-h-dvh w-full max-w-2xl flex-col">
|
||||
<header className="flex items-center justify-between px-5 py-4">
|
||||
<Link href="/inicio" className="text-base font-semibold tracking-tight">
|
||||
{t('common.appName')}
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<LanguageSwitcher />
|
||||
<Link href="/perfil" className="text-sm font-medium text-accent-700 hover:underline">
|
||||
{t('profile.title')}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 px-5 pb-16">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDialogElement>(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 (
|
||||
<Card className="space-y-5">
|
||||
<div>
|
||||
<h2 className="font-semibold tracking-tight">{t('profile.myData.title')}</h2>
|
||||
<p className="text-sm text-[var(--text-muted)] text-pretty">{t('profile.myData.body')}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--text-muted)] text-pretty">{t('profile.myData.stored')}</p>
|
||||
|
||||
{/* A plain link, not a fetch: the browser saves the file and the API sets the name. */}
|
||||
<a
|
||||
href="/api/me/data-export"
|
||||
download
|
||||
className="inline-flex text-sm font-medium text-accent-700 hover:underline"
|
||||
>
|
||||
{t('profile.myData.export')}
|
||||
</a>
|
||||
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<p className="text-sm text-[var(--text-muted)] text-pretty">
|
||||
{t('profile.consent.revokeWarn')}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={revoke.isPending}
|
||||
onClick={() => revoke.mutate()}
|
||||
>
|
||||
{t('profile.consent.revoke')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<p className="text-sm text-overdue text-pretty">{t('profile.myData.deleteWarn')}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="border-overdue text-overdue"
|
||||
onClick={() => dialog.current?.showModal()}
|
||||
>
|
||||
{t('profile.myData.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<dialog
|
||||
ref={dialog}
|
||||
aria-label={t('profile.myData.delete')}
|
||||
className="m-auto w-[min(28rem,calc(100vw-2rem))] rounded-2xl border bg-[var(--surface-raised)] p-6 text-[var(--text)] backdrop:bg-black/40"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold tracking-tight">{t('profile.myData.delete')}</h3>
|
||||
<p className="text-sm text-[var(--text-muted)] text-pretty">
|
||||
{t('profile.myData.deleteWarn')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="confirmText">{t('profile.myData.deletePrompt', { email })}</Label>
|
||||
<Input
|
||||
id="confirmText"
|
||||
value={confirmText}
|
||||
autoComplete="off"
|
||||
aria-invalid={remove.isError}
|
||||
onChange={(event) => setConfirmText(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{remove.isError ? (
|
||||
<p role="alert" className="text-sm text-overdue">
|
||||
{t('common.error.generic')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={() => dialog.current?.close()}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="bg-overdue hover:bg-overdue"
|
||||
disabled={
|
||||
remove.isPending ||
|
||||
confirmText.trim().toLowerCase() !== email.toLowerCase() ||
|
||||
email === ''
|
||||
}
|
||||
onClick={() => remove.mutate()}
|
||||
>
|
||||
{t('profile.myData.deleteCta')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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<Relationship>('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 (
|
||||
<Card className="space-y-4">
|
||||
<div>
|
||||
<h2 className="font-semibold tracking-tight">{t('profile.dependents')}</h2>
|
||||
<p className="text-sm text-[var(--text-muted)] text-pretty">{t('setup.dependents.help')}</p>
|
||||
</div>
|
||||
|
||||
{dependents.isPending ? <Skeleton name="doc-list-item" count={2} /> : null}
|
||||
|
||||
{dependents.isError ? (
|
||||
<div className="space-y-2">
|
||||
<p role="alert" className="text-sm text-overdue">
|
||||
{t('common.error.generic')}
|
||||
</p>
|
||||
<Button type="button" variant="secondary" onClick={() => void dependents.refetch()}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{dependents.data?.length === 0 ? (
|
||||
<p className="text-sm text-[var(--text-muted)]">{t('profile.dependents.empty')}</p>
|
||||
) : null}
|
||||
|
||||
{dependents.data && dependents.data.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
{dependents.data.map((dependent) => (
|
||||
<li key={dependent.id} className="flex items-center justify-between gap-3 text-sm">
|
||||
<span>
|
||||
{dependent.displayName} ({t(`relationship.${dependent.relationship}` as const)})
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-overdue underline disabled:opacity-50"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => remove.mutate(dependent.id)}
|
||||
>
|
||||
{t('common.remove')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2 border-t pt-4">
|
||||
<Input
|
||||
aria-label={t('setup.dependents.name')}
|
||||
placeholder={t('setup.dependents.name')}
|
||||
value={name}
|
||||
className="flex-1"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
<select
|
||||
aria-label={t('setup.dependents.rel')}
|
||||
value={relationship}
|
||||
onChange={(event) => setRelationship(event.target.value as Relationship)}
|
||||
className="h-12 rounded-2xl border bg-[var(--surface-raised)] px-3 text-base"
|
||||
>
|
||||
{RELATIONSHIPS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`relationship.${value}` as const)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={name.trim().length === 0 || add.isPending}
|
||||
onClick={() => add.mutate()}
|
||||
>
|
||||
{t('setup.dependents.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="space-y-5">
|
||||
<h2 className="font-semibold tracking-tight">{t('profile.identity')}</h2>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="fullName">{t('setup.fullName')}</Label>
|
||||
<Input id="fullName" value={fullName} onChange={(event) => setFullName(event.target.value)} />
|
||||
</div>
|
||||
|
||||
{/* The document is read only: it decides real filing dates, so changing it is a
|
||||
support conversation rather than a text field. */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="doc">{t('setup.docLabel')}</Label>
|
||||
<Input
|
||||
id="doc"
|
||||
readOnly
|
||||
value={
|
||||
profile.docType === 'ruc' && profile.ruc
|
||||
? `${profile.ruc}-${profile.rucDv ?? ''}`
|
||||
: (profile.ci ?? '')
|
||||
}
|
||||
className="bg-transparent text-[var(--text-muted)]"
|
||||
/>
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
{t('vto.explainer', {
|
||||
digit: profile.deadlineDigit,
|
||||
day: deadlineDay(profile.deadlineDigit),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<fieldset className="space-y-3">
|
||||
<legend className="text-sm font-medium text-[var(--text-muted)]">
|
||||
{t('profile.obligations')}
|
||||
</legend>
|
||||
<label className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm">{t('setup.oblig.iva.title')}</span>
|
||||
<Switch
|
||||
checked={active('iva_120')}
|
||||
onCheckedChange={() => toggle('iva_120')}
|
||||
label={t('setup.oblig.iva.title')}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm">{t('setup.oblig.irp.title')}</span>
|
||||
<Switch
|
||||
checked={active('irp_515')}
|
||||
onCheckedChange={() => toggle('irp_515')}
|
||||
label={t('setup.oblig.irp.title')}
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{active('irp_515') ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="income">{t('profile.income')}</Label>
|
||||
<Input
|
||||
id="income"
|
||||
inputMode="numeric"
|
||||
value={income === '' ? '' : formatGsAmount(Number(income))}
|
||||
onChange={(event) => setIncome(event.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="autoConfirm">{t('profile.autoConfirm')}</Label>
|
||||
<Input
|
||||
id="autoConfirm"
|
||||
inputMode="numeric"
|
||||
value={autoConfirmDays}
|
||||
aria-describedby="autoConfirm-help"
|
||||
onChange={(event) => setAutoConfirmDays(event.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<p id="autoConfirm-help" className="text-sm text-[var(--text-muted)] text-pretty">
|
||||
{t('profile.autoConfirm.help')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{save.isPending ? t('common.saving') : t('common.save')}
|
||||
</Button>
|
||||
{save.isSuccess ? <span className="text-sm text-positive">{t('profile.saved')}</span> : null}
|
||||
{save.isError ? (
|
||||
<span role="alert" className="text-sm text-overdue">
|
||||
{t('common.error.generic')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="space-y-5">
|
||||
<h2 className="font-semibold tracking-tight">{t('profile.notifications')}</h2>
|
||||
|
||||
{prefs.isPending ? <Skeleton name="doc-list-item" count={2} /> : null}
|
||||
|
||||
{prefs.isError ? (
|
||||
<p role="alert" className="text-sm text-overdue">
|
||||
{t('common.error.generic')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{prefs.data ? (
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm">{t('notif.email')}</span>
|
||||
<Switch
|
||||
checked={prefs.data.emailEnabled}
|
||||
onCheckedChange={(emailEnabled) => update.mutate({ emailEnabled })}
|
||||
label={t('notif.email')}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm">{t('notif.digestHour')}</span>
|
||||
<select
|
||||
aria-label={t('notif.digestHour')}
|
||||
value={prefs.data.digestHour}
|
||||
onChange={(event) => update.mutate({ digestHour: Number(event.target.value) })}
|
||||
className="tnum h-11 rounded-2xl border bg-[var(--surface-raised)] px-3"
|
||||
>
|
||||
{Array.from({ length: 24 }).map((_, hour) => {
|
||||
// A clock time, not copy: the same in every locale.
|
||||
const label = `${String(hour).padStart(2, '0')}:00`;
|
||||
return (
|
||||
<option key={hour} value={hour}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{/* Push and Telegram are hidden until they are configured (FLOWS.md section 9).
|
||||
They are wired with the notification channels in phase 4. */}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between gap-4 border-t pt-4">
|
||||
<span className="text-sm">{t('common.language')}</span>
|
||||
<select
|
||||
aria-label={t('common.language')}
|
||||
value={profile.locale}
|
||||
disabled={setLocale.isPending}
|
||||
onChange={(event) => setLocale.mutate(event.target.value as Locale)}
|
||||
className="h-11 rounded-2xl border bg-[var(--surface-raised)] px-3"
|
||||
>
|
||||
{SUPPORTED_LOCALES.map((code) => (
|
||||
<option key={code} value={code}>
|
||||
{t(LABEL_KEY[code])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 <ProfileScreen />;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-4 py-4">
|
||||
<Card>
|
||||
<Skeleton name="profile-section" />
|
||||
</Card>
|
||||
<Card>
|
||||
<Skeleton name="profile-section" />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (profile.isError) {
|
||||
const notSetUp = isApiError(profile.error) && profile.error.code === 'not_found';
|
||||
return (
|
||||
<Card className="my-4 space-y-3">
|
||||
<p role="alert" className="text-sm">
|
||||
{notSetUp ? t('setup.step1.title') : t('common.error.generic')}
|
||||
</p>
|
||||
<Button type="button" onClick={() => void profile.refetch()}>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-4">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('profile.title')}</h1>
|
||||
<IdentitySection profile={profile.data} />
|
||||
<DependentsSection />
|
||||
<NotificationsSection profile={profile.data} />
|
||||
<DangerZone />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
{t('auth.login.noAccount')}{' '}
|
||||
<Link href="/registro" className="font-medium text-accent-700 hover:underline">
|
||||
{t('auth.login.register')}
|
||||
</Link>
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <RegisterForm />;
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="space-y-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-balance">
|
||||
{t('auth.register.title')}
|
||||
</h1>
|
||||
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
register.mutate({
|
||||
email: String(data.get('email') ?? '').trim(),
|
||||
password: String(data.get('password') ?? ''),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">{t('auth.register.email')}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
aria-invalid={register.isError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="password">{t('auth.register.password')}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
minLength={8}
|
||||
required
|
||||
aria-describedby="password-hint"
|
||||
aria-invalid={register.isError}
|
||||
/>
|
||||
<p id="password-hint" className="text-sm text-[var(--text-muted)]">
|
||||
{t('auth.register.passwordHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{register.isError ? (
|
||||
<p role="alert" className="text-sm text-overdue">
|
||||
{emailTaken ? t('auth.register.emailTaken') : t('auth.register.failed')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" size="lg" block disabled={register.isPending}>
|
||||
{register.isPending ? t('common.loading') : t('auth.register.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
{t('auth.register.haveAccount')}{' '}
|
||||
<Link href="/login" className="font-medium text-accent-700 hover:underline">
|
||||
{t('auth.register.signIn')}
|
||||
</Link>
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Card>
|
||||
<Skeleton name="profile-section" />
|
||||
</Card>
|
||||
}
|
||||
>
|
||||
<VerifyForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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<string[]>(() => 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 (
|
||||
<Card className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-balance">{t('auth.otp.title')}</h1>
|
||||
<p className="text-sm text-[var(--text-muted)] text-pretty">
|
||||
{t('auth.otp.body', { email })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const code = digits.join('');
|
||||
if (code.length === LENGTH) verify.mutate(code);
|
||||
}}
|
||||
>
|
||||
<Label htmlFor="otp-0">{t('auth.otp.label')}</Label>
|
||||
<div className="flex gap-2" role="group" aria-label={t('auth.otp.label')}>
|
||||
{digits.map((digit, index) => (
|
||||
<input
|
||||
key={index}
|
||||
ref={(element) => {
|
||||
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',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{verify.isError ? (
|
||||
<p role="alert" className="text-sm text-overdue">
|
||||
{t('auth.otp.failed')}
|
||||
</p>
|
||||
) : null}
|
||||
{resent ? <p className="text-sm text-positive">{t('auth.otp.resent')}</p> : null}
|
||||
|
||||
<Button type="submit" size="lg" block disabled={verify.isPending}>
|
||||
{verify.isPending ? t('common.loading') : t('auth.otp.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
block
|
||||
disabled={resend.isPending}
|
||||
onClick={() => resend.mutate()}
|
||||
>
|
||||
{t('auth.otp.resend')}
|
||||
</Button>
|
||||
<p className="text-center text-xs text-[var(--text-muted)]">{t('auth.otp.devHint')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
<header className="mx-auto flex w-full max-w-5xl items-center justify-between px-5 py-4">
|
||||
<Link href="/" className="text-base font-semibold tracking-tight">
|
||||
{t('common.appName')}
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<LanguageSwitcher />
|
||||
<Link href="/login" className="text-sm font-medium text-accent-700 hover:underline">
|
||||
{t('auth.login.submit')}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1">{children}</main>
|
||||
|
||||
<footer className="mx-auto flex w-full max-w-5xl flex-wrap items-center gap-x-6 gap-y-2 px-5 py-8 text-sm text-[var(--text-muted)]">
|
||||
<span>{t('common.appName')}</span>
|
||||
<Link href="/legal/privacidad" className="hover:underline">
|
||||
{t('landing.footer.privacy')}
|
||||
</Link>
|
||||
<Link href="/legal/terminos" className="hover:underline">
|
||||
{t('landing.footer.terms')}
|
||||
</Link>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mx-auto w-full max-w-2xl px-5 py-12">
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-balance">{title}</h1>
|
||||
<Card className="mt-6 space-y-2">
|
||||
<p className="font-medium text-attention">{t('legal.placeholder')}</p>
|
||||
<p className="text-sm text-[var(--text-muted)] text-pretty">{t('legal.placeholderBody')}</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <LegalPlaceholder title={t('legal.privacy.title')} locale={locale} />;
|
||||
}
|
||||
@@ -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 <LegalPlaceholder title={t('legal.terms.title')} locale={locale} />;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mx-auto w-full max-w-5xl px-5">
|
||||
{/* 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. */}
|
||||
<section className="py-14 sm:py-20">
|
||||
<h1 className="max-w-2xl text-4xl font-semibold tracking-tight text-balance sm:text-5xl">
|
||||
{t('landing.hero.title')}
|
||||
</h1>
|
||||
<p className="mt-4 max-w-xl text-lg text-[var(--text-muted)] text-pretty">
|
||||
{t('landing.hero.subtitle')}
|
||||
</p>
|
||||
<div className="mt-8 max-w-md">
|
||||
<RucHook />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 pb-14 sm:grid-cols-3">
|
||||
{values.map((value) => (
|
||||
<Card key={value.title} className="space-y-2">
|
||||
<h2 className="font-semibold tracking-tight">{value.title}</h2>
|
||||
<p className="text-sm text-[var(--text-muted)] text-pretty">{value.body}</p>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="pb-16">
|
||||
<Card className="space-y-2">
|
||||
<h2 className="font-semibold tracking-tight">{t('landing.pricing.title')}</h2>
|
||||
<p className="text-sm text-[var(--text-muted)]">{t('landing.pricing.body')}</p>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<LookupDto, unknown, string>({
|
||||
mutationFn: (input) => api.lookupRuc(input),
|
||||
});
|
||||
|
||||
const result = lookup.data;
|
||||
const invalid = result !== undefined && !result.valid;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > 0) lookup.mutate(trimmed);
|
||||
}}
|
||||
>
|
||||
<Label htmlFor="ruc">{t('landing.hero.inputLabel')}</Label>
|
||||
{/* Stacks at 390px so the call to action never wraps to two lines. */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
id="ruc"
|
||||
name="ruc"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
aria-invalid={invalid}
|
||||
aria-describedby={invalid ? 'ruc-error' : undefined}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="whitespace-nowrap"
|
||||
disabled={lookup.isPending || value.trim().length === 0}
|
||||
>
|
||||
{lookup.isPending ? t('landing.lookup.checking') : t('landing.hero.cta')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{invalid ? (
|
||||
<p id="ruc-error" role="alert" className="text-sm text-overdue">
|
||||
{t('landing.invalidDoc')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{lookup.isError ? (
|
||||
<p role="alert" className="text-sm text-overdue">
|
||||
{isApiError(lookup.error) ? lookup.error.message : t('common.error.generic')}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
{lookup.isPending ? (
|
||||
<Card>
|
||||
<Skeleton name="dashboard-position" />
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{result?.valid ? <Preview result={result} locale={locale} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Preview({ result, locale }: { result: LookupDto; locale: Locale }) {
|
||||
const t = useT();
|
||||
const [d1, d2, d3] = result.nextDeadlines as [string, string, string];
|
||||
|
||||
return (
|
||||
<Card className="space-y-3">
|
||||
<p className="text-sm font-medium text-[var(--text-muted)]">{t('landing.preview.title')}</p>
|
||||
<p className="text-lg font-semibold tracking-tight text-balance">
|
||||
{t('landing.preview.deadline', { day: result.deadlineDay })}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-muted)]">
|
||||
{t('landing.preview.next', {
|
||||
d1: formatDateLong(locale, d1),
|
||||
d2: formatDateLong(locale, d2),
|
||||
d3: formatDateLong(locale, d3),
|
||||
})}
|
||||
</p>
|
||||
<Link href="/registro" className={buttonClasses({ className: 'mt-2 w-full sm:w-auto' })}>
|
||||
{t('landing.preview.cta')}
|
||||
</Link>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<HTMLButtonElement> & VariantProps<typeof button>;
|
||||
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonClasses>;
|
||||
|
||||
export function Button({ className, variant, size, block, ...props }: ButtonProps) {
|
||||
return <button className={cn(button({ variant, size, block }), className)} {...props} />;
|
||||
return <button className={cn(buttonClasses({ variant, size, block }), className)} {...props} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** One illustration, one sentence, one action. Never a dead end (FLOWS.md section 10). */
|
||||
export function EmptyState({
|
||||
icon,
|
||||
title,
|
||||
body,
|
||||
action,
|
||||
}: {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
body?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 px-6 py-12 text-center">
|
||||
{icon ? <div className="text-accent-600">{icon}</div> : null}
|
||||
<h2 className="text-xl font-semibold tracking-tight text-balance">{title}</h2>
|
||||
{body ? <p className="max-w-sm text-sm text-[var(--text-muted)] text-pretty">{body}</p> : null}
|
||||
{action ? <div className="pt-2">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { formatGs } from '@impuestos/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* The only way money reaches the screen. Always `Gs. 1.234.567`, never localized, always
|
||||
* tabular so columns of figures line up (FLOWS.md section 1).
|
||||
*/
|
||||
export function Money({
|
||||
value,
|
||||
size = 'md',
|
||||
className,
|
||||
}: {
|
||||
value: number;
|
||||
size?: 'md' | 'lg';
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'tnum',
|
||||
size === 'lg' ? 'text-4xl font-semibold tracking-tight' : 'font-medium',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{formatGs(value)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Content never loads behind a spinner (FLOWS.md section 1): every data screen shows a
|
||||
* skeleton shaped like the content that is coming.
|
||||
*
|
||||
* `name` identifies the shape the way FLOWS.md section 1 names them, so a screen asks for
|
||||
* `dashboard-position` rather than composing boxes at the call site.
|
||||
*/
|
||||
export type SkeletonName =
|
||||
| 'dashboard-position'
|
||||
| 'bandeja-card'
|
||||
| 'doc-list-item'
|
||||
| 'declaration-summary'
|
||||
| 'admin-user-row'
|
||||
| 'profile-section'
|
||||
| 'text-line';
|
||||
|
||||
const SHAPES: Record<SkeletonName, string[]> = {
|
||||
'dashboard-position': ['h-4 w-24', 'h-10 w-48', 'h-4 w-56'],
|
||||
'bandeja-card': ['h-4 w-32', 'h-8 w-40', 'h-4 w-24'],
|
||||
'doc-list-item': ['h-4 w-40', 'h-4 w-20'],
|
||||
'declaration-summary': ['h-4 w-28', 'h-8 w-44', 'h-4 w-full', 'h-4 w-3/4'],
|
||||
'admin-user-row': ['h-4 w-full'],
|
||||
'profile-section': ['h-4 w-32', 'h-11 w-full', 'h-11 w-full'],
|
||||
'text-line': ['h-4 w-full'],
|
||||
};
|
||||
|
||||
export function Skeleton({
|
||||
name,
|
||||
count = 1,
|
||||
className,
|
||||
}: {
|
||||
name: SkeletonName;
|
||||
count?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('space-y-3', className)} aria-hidden data-skeleton={name}>
|
||||
{Array.from({ length: count }).flatMap((_, group) =>
|
||||
(SHAPES[name] as string[]).map((shape, index) => (
|
||||
<div
|
||||
key={`${group}-${index}`}
|
||||
className={cn('animate-pulse rounded-full bg-[var(--border-subtle)]', shape)}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** A native button carrying the switch role: keyboard and screen reader support for free. */
|
||||
export function Switch({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
id,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onCheckedChange: (next: boolean) => void;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
id={id}
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
className={cn(
|
||||
'relative h-6 w-11 shrink-0 rounded-full transition-colors',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600',
|
||||
'disabled:opacity-50',
|
||||
checked ? 'bg-accent-600' : 'bg-[var(--border-subtle)]',
|
||||
)}
|
||||
>
|
||||
{/* Offsets come from the spacing scale: 2px inset, then 20px of travel across the
|
||||
44px track. Fractional utilities do not resolve here and put the thumb outside. */}
|
||||
<span
|
||||
className={cn(
|
||||
'absolute top-0.5 left-0.5 size-5 rounded-full bg-white shadow-sm transition-transform',
|
||||
checked ? 'translate-x-5' : 'translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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()],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user