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:
Michilis
2026-09-03 23:33:10 +00:00
co-authored by Claude Opus 5
parent 80b10c958e
commit 0d7651b17c
61 changed files with 3776 additions and 82 deletions
+195 -7
View File
@@ -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;
}
+9 -1
View File
@@ -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');
});
+2
View File
@@ -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);
+24
View File
@@ -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;
}
+252
View File
@@ -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);
});
});
+164 -3
View File
@@ -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,
});
}
+37
View File
@@ -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);
});
});
+54
View File
@@ -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;
},
};
}
+51
View File
@@ -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();
}
+86
View File
@@ -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);
});
});
+87
View File
@@ -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.
}
+81
View File
@@ -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();
}
+68
View File
@@ -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;
}
+5 -1
View File
@@ -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;
}
+89 -14
View File
@@ -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,
};
}