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
@@ -209,3 +209,67 @@ the Playwright default base URL uses localhost.
|
|||||||
|
|
||||||
This deviates from the literal example values in SPEC.md section 15, which use port 3000.
|
This deviates from the literal example values in SPEC.md section 15, which use port 3000.
|
||||||
The compose stack still publishes 3000 and is unaffected.
|
The compose stack still publishes 3000 and is unaffected.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2
|
||||||
|
|
||||||
|
### The seed is now complete for identity
|
||||||
|
Maria is an individual whose RUC is her CI plus a check digit: base `4123456`, DV computed
|
||||||
|
by `computeRucDv`, so her filing digit is 6 and her day is the 19th, which is the example
|
||||||
|
FLOWS.md uses throughout. Carlos is a company registered for IVA only, so the IVA-only view
|
||||||
|
has real data behind it. Both have consents, notification preferences and the audit rows
|
||||||
|
CONTRACTS.md section 4 asks for. Documents arrive in phase 3.
|
||||||
|
|
||||||
|
### `GET /lookup/ruc/:number` reads a bare number as a CI
|
||||||
|
A number with a hyphen and a check digit is a RUC and the digit is verified. A bare number
|
||||||
|
is a CI, which is what individuals type when their RUC is their CI plus a digit; the filing
|
||||||
|
day is identical either way, which is the only thing the landing card shows. Neither form
|
||||||
|
touches the database: the endpoint reveals only what the number itself already encodes.
|
||||||
|
|
||||||
|
An unparseable number is a 200 with `valid: false`, per CONTRACTS.md section 3, so the
|
||||||
|
landing corrects the user inline rather than dead ending.
|
||||||
|
|
||||||
|
### Freezing an account reuses better-auth's ban flag
|
||||||
|
Both a deletion and a data processing consent revocation set `banned` with a distinct
|
||||||
|
`banReason` and drop every session. That reuses the check better-auth's sign in path
|
||||||
|
already performs, rather than introducing a second, parallel notion of a disabled account
|
||||||
|
that some code path would eventually forget to check. Nothing is destroyed yet: the purge
|
||||||
|
is a job, which lands with the jobs module in phase 4 (`softDeleteAccount` carries the TODO).
|
||||||
|
|
||||||
|
### `deadlineDigit` is derived, never accepted from the client
|
||||||
|
`PUT /me/profile` takes `ProfileInput`, which is `ProfileDto` minus `deadlineDigit`. The
|
||||||
|
digit decides real filing dates, so it is computed from the identity document server side.
|
||||||
|
The document itself is read only on the profile screen for the same reason: changing it is
|
||||||
|
a support conversation, not a text field.
|
||||||
|
|
||||||
|
### Deactivating a dependant rather than deleting it
|
||||||
|
A confirmed document may already be classified against a dependant, and that classification
|
||||||
|
has to keep making sense. `DELETE /me/dependents/:id` sets `active = 0`; the list filters on
|
||||||
|
it. Scoped by `user_id`, so one user cannot touch another's row (404, tested).
|
||||||
|
|
||||||
|
### Two real defects found by building the screens
|
||||||
|
**The OTP boxes dropped a digit.** The handler spread the pasted code inside a `setState`
|
||||||
|
updater and fired the focus move and the verify request from in there. React 19 invokes
|
||||||
|
updaters twice in development to check they are pure, so the effects ran twice and a digit
|
||||||
|
was lost. The spread is now computed outside the updater. Caught by golden path 1.
|
||||||
|
|
||||||
|
**The switch knob rendered outside its track.** `translate-x-5.5` does not resolve, so the
|
||||||
|
knob sat 44px along a 44px track, entirely outside it. Now `left-0.5` plus `translate-x-5`,
|
||||||
|
both on the spacing scale. `e2e/profile.spec.ts` asserts the knob stays within the track
|
||||||
|
bounds in both states, because this is invisible to every other kind of test.
|
||||||
|
|
||||||
|
### Deferred, deliberately
|
||||||
|
- **Motion.** FLOWS.md A2 and A6 call for GSAP (the card flip, the deadline stagger, the
|
||||||
|
first savings counter) and A1 for a canvas hero effect. The phase plan puts the motion
|
||||||
|
pass and the canvas touches in phase 7, so the screens here are built with the right
|
||||||
|
structure and states and no animation.
|
||||||
|
- **Push and Telegram.** Hidden until configured (FLOWS.md section 9). Setup step 3 offers
|
||||||
|
only email today; `POST /push/subscribe` needs the service worker and arrives in phase 7.
|
||||||
|
- **`/comprobantes`.** The first run buttons point at it. Ingestion is phase 3.
|
||||||
|
|
||||||
|
### The legal pages ship as marked placeholders
|
||||||
|
`/legal/privacidad` and `/legal/terminos` render "Documento en preparacion" and say a
|
||||||
|
document is being drafted, per COPY.md section 13. No legal text was generated.
|
||||||
|
**TODO: human written before launch.**
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ and ready to file yourself.
|
|||||||
Working name. See `docs/` for the specifications, `DECISIONS.md` for choices made along the
|
Working name. See `docs/` for the specifications, `DECISIONS.md` for choices made along the
|
||||||
way and the gaps that still need answers.
|
way and the gaps that still need answers.
|
||||||
|
|
||||||
> **Status: phase 1 of 8.** Foundation and the tax rules. `packages/rules` is complete and
|
> **Status: phase 2 of 8.** Foundation, the tax rules, and identity: landing, sign up,
|
||||||
> covered; there is no ingestion, dashboard or declaration UI on top of it yet.
|
> onboarding and the profile screen all work end to end. Ingestion, the dashboard and
|
||||||
|
> declarations are still ahead.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+195
-7
@@ -1,4 +1,8 @@
|
|||||||
|
import { computeRucDv } from '@impuestos/rules';
|
||||||
|
import { uuidv7 } from 'uuidv7';
|
||||||
import type { Auth, Role } from '../auth/options';
|
import type { Auth, Role } from '../auth/options';
|
||||||
|
import { writeAudit } from '../modules/audit';
|
||||||
|
import { CONSENT_TEXT_VERSION } from '../modules/pii';
|
||||||
import type { DbHandle } from './index';
|
import type { DbHandle } from './index';
|
||||||
|
|
||||||
export interface SeedAccount {
|
export interface SeedAccount {
|
||||||
@@ -11,10 +15,6 @@ export interface SeedAccount {
|
|||||||
/**
|
/**
|
||||||
* Accounts per CONTRACTS.md section 4. Deterministic and idempotent: running the seed
|
* Accounts per CONTRACTS.md section 4. Deterministic and idempotent: running the seed
|
||||||
* twice leaves the same rows.
|
* 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[] = [
|
export const SEED_ACCOUNTS: readonly SeedAccount[] = [
|
||||||
{ email: 'superadmin@demo.local', password: 'demo-superadmin-1', name: 'Super Admin', role: 'superadmin' },
|
{ 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' },
|
{ 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 {
|
export interface SeedResult {
|
||||||
created: string[];
|
created: string[];
|
||||||
existing: string[];
|
existing: string[];
|
||||||
@@ -30,9 +41,10 @@ export interface SeedResult {
|
|||||||
|
|
||||||
export async function seed(handle: DbHandle, auth: Auth): Promise<SeedResult> {
|
export async function seed(handle: DbHandle, auth: Auth): Promise<SeedResult> {
|
||||||
const result: SeedResult = { created: [], existing: [] };
|
const result: SeedResult = { created: [], existing: [] };
|
||||||
|
const db = handle.db;
|
||||||
|
|
||||||
for (const account of SEED_ACCOUNTS) {
|
for (const account of SEED_ACCOUNTS) {
|
||||||
const found = await handle.db
|
const found = await db
|
||||||
.selectFrom('user')
|
.selectFrom('user')
|
||||||
.select('id')
|
.select('id')
|
||||||
.where('email', '=', account.email)
|
.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
|
// 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.
|
// plain unverified `user`, and demo accounts need to be usable straight away.
|
||||||
await handle.db
|
await db
|
||||||
.updateTable('user')
|
.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)
|
.where('email', '=', account.email)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
result.created.push(account.email);
|
result.created.push(account.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await seedProfiles(handle);
|
||||||
|
await seedAuditTrail(handle);
|
||||||
return result;
|
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', () => {
|
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 cookie = await h.signIn('maria@demo.local', 'demo-maria-1');
|
||||||
const response = await h.app.request('/api/me/profile', { headers: { cookie } });
|
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(response.status).toBe(404);
|
||||||
expect(ErrorEnvelope.parse(await response.json()).error.code).toBe('not_found');
|
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 { HttpError, toEnvelope } from './errors';
|
||||||
import { liveness, readiness } from './health';
|
import { liveness, readiness } from './health';
|
||||||
import { localeMiddleware, sessionMiddleware } from './middleware';
|
import { localeMiddleware, sessionMiddleware } from './middleware';
|
||||||
|
import { lookupRoutes } from './routes/lookup';
|
||||||
import { meRoutes } from './routes/me';
|
import { meRoutes } from './routes/me';
|
||||||
|
|
||||||
export interface AppHandle {
|
export interface AppHandle {
|
||||||
@@ -45,6 +46,7 @@ export function createApp(deps: AppDeps): AppHandle {
|
|||||||
const api = new Hono<AppEnv>();
|
const api = new Hono<AppEnv>();
|
||||||
api.use('*', sessionMiddleware(deps));
|
api.use('*', sessionMiddleware(deps));
|
||||||
api.use('*', localeMiddleware(deps));
|
api.use('*', localeMiddleware(deps));
|
||||||
|
api.route('/lookup', lookupRoutes());
|
||||||
api.route('/me', meRoutes(deps));
|
api.route('/me', meRoutes(deps));
|
||||||
|
|
||||||
app.route('/api', api);
|
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 { Hono } from 'hono';
|
||||||
import { getProfile } from '../../modules/pii';
|
import type { z } from 'zod';
|
||||||
import type { AppDeps, AppEnv } from '../context';
|
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 { HttpError } from '../errors';
|
||||||
import { requireUser } from '../middleware';
|
import { requireUser } from '../middleware';
|
||||||
|
|
||||||
export function meRoutes(deps: AppDeps): Hono<AppEnv> {
|
export function meRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||||
const routes = new 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).
|
// 404 until setup is complete: the client routes to onboarding (CONTRACTS.md section 3).
|
||||||
routes.get('/profile', async (c) => {
|
routes.get('/profile', async (c) => {
|
||||||
const user = requireUser(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');
|
if (!profile) throw new HttpError('not_found');
|
||||||
return c.json(profile);
|
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;
|
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
|
* 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.
|
* 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 { type Locale, isLocale } from '@impuestos/i18n';
|
||||||
|
import { deadlineDigit } from '@impuestos/rules';
|
||||||
import type { Kysely } from 'kysely';
|
import type { Kysely } from 'kysely';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { Database } from '../../db/schema';
|
import type { Database } from '../../db/schema';
|
||||||
@@ -17,21 +18,65 @@ export async function getProfile(db: Kysely<Database>, userId: string): Promise<
|
|||||||
.selectAll()
|
.selectAll()
|
||||||
.where('user_id', '=', userId)
|
.where('user_id', '=', userId)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
if (!row) return null;
|
return row ? toDto(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
/**
|
||||||
fullName: row.full_name,
|
* Creates or replaces the profile. `deadlineDigit` is never accepted from the client:
|
||||||
docType: row.doc_type,
|
* it is derived from the identity document, because it decides real filing dates.
|
||||||
ruc: row.ruc,
|
*/
|
||||||
rucDv: row.ruc_dv,
|
export async function upsertProfile(
|
||||||
ci: row.ci,
|
db: Kysely<Database>,
|
||||||
taxpayerKind: row.taxpayer_kind,
|
userId: string,
|
||||||
deadlineDigit: row.deadline_digit,
|
input: ProfileInput,
|
||||||
obligations: Obligations.parse(JSON.parse(row.obligations)),
|
): Promise<{ profile: ProfileDto; created: boolean }> {
|
||||||
irpGrossEstimate: row.irp_gross_estimate,
|
const now = new Date().toISOString();
|
||||||
autoConfirmDays: row.auto_confirm_days,
|
const digit = deriveDeadlineDigit(input);
|
||||||
locale: row.locale,
|
|
||||||
|
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();
|
.executeTakeFirst();
|
||||||
return row && isLocale(row.locale) ? row.locale : null;
|
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 { isApiError } from '@impuestos/contracts';
|
||||||
|
import { ScanLine } from 'lucide-react';
|
||||||
import { setRequestLocale } from 'next-intl/server';
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
import { getT } from '@/i18n/t';
|
import { buttonClasses } from '@/components/ui/button';
|
||||||
import { redirect } from '@/i18n/navigation';
|
|
||||||
import { Card } from '@/components/ui/card';
|
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';
|
import { serverApi } from '@/lib/api-server';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 0 shell. It exists to prove the whole chain end to end: browser to the Next
|
* Flow A6, the first run state. The dashboard proper (Flow C, with the position header,
|
||||||
* rewrite, to the API, through the typed client, with the session cookie intact.
|
* next action strip and insight feed) replaces this body in phase 4; deadlines come from
|
||||||
* The real dashboard (FLOWS.md Flow C) replaces this in phase 4.
|
* 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 }> }) {
|
export default async function InicioPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
@@ -19,34 +21,40 @@ export default async function InicioPage({ params }: { params: Promise<{ locale:
|
|||||||
const api = await serverApi();
|
const api = await serverApi();
|
||||||
const profile = await api.getProfile().catch((error: unknown) => {
|
const profile = await api.getProfile().catch((error: unknown) => {
|
||||||
if (!isApiError(error)) throw error;
|
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 === 'not_found') return null;
|
||||||
if (error.code === 'unauthorized') redirect({ href: '/login', locale });
|
if (error.code === 'unauthorized') redirect({ href: '/login', locale });
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
// No profile means setup was never finished, so send them there rather than showing an
|
||||||
<div className="mx-auto flex min-h-dvh max-w-2xl flex-col">
|
// empty shell they cannot act on.
|
||||||
<header className="flex items-center justify-between px-5 py-4">
|
if (!profile) redirect({ href: '/configuracion', locale });
|
||||||
<h1 className="text-base font-semibold tracking-tight">{t('home.title')}</h1>
|
|
||||||
<LanguageSwitcher />
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="px-5 pb-16">
|
return (
|
||||||
<Card className="space-y-2">
|
<div className="space-y-6 py-4">
|
||||||
{profile ? (
|
<div>
|
||||||
<>
|
<h1 className="text-2xl font-semibold tracking-tight">{t('home.title')}</h1>
|
||||||
<p className="text-sm text-[var(--text-muted)]">{t('setup.fullName')}</p>
|
<p className="text-sm text-[var(--text-muted)]">{profile?.fullName}</p>
|
||||||
<p className="text-2xl font-semibold tracking-tight">{profile.fullName}</p>
|
</div>
|
||||||
</>
|
|
||||||
) : (
|
<Card>
|
||||||
<>
|
<EmptyState
|
||||||
<h2 className="text-xl font-semibold tracking-tight">{t('setup.step1.title')}</h2>
|
icon={<ScanLine aria-hidden className="size-8" />}
|
||||||
<p className="text-sm text-[var(--text-muted)]">{t('setup.income.help')}</p>
|
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>
|
</Card>
|
||||||
</main>
|
|
||||||
</div>
|
</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';
|
'use client';
|
||||||
|
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { useRouter } from '@/i18n/navigation';
|
import { Link, useRouter } from '@/i18n/navigation';
|
||||||
import { useT } from '@/i18n/t';
|
import { useT } from '@/i18n/t';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
@@ -70,6 +70,13 @@ export function LoginForm() {
|
|||||||
{signIn.isPending ? t('common.loading') : t('auth.login.submit')}
|
{signIn.isPending ? t('common.loading') : t('auth.login.submit')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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>
|
</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": {
|
"dependencies": {
|
||||||
"@impuestos/contracts": "workspace:*",
|
"@impuestos/contracts": "workspace:*",
|
||||||
"@impuestos/i18n": "workspace:*",
|
"@impuestos/i18n": "workspace:*",
|
||||||
|
"@impuestos/rules": "workspace:*",
|
||||||
"@tanstack/react-query": "^5.102.8",
|
"@tanstack/react-query": "^5.102.8",
|
||||||
"better-auth": "^1.7.2",
|
"better-auth": "^1.7.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
|||||||
import type { ButtonHTMLAttributes } from 'react';
|
import type { ButtonHTMLAttributes } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
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 ' +
|
'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 ' +
|
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600 ' +
|
||||||
'disabled:pointer-events-none disabled:opacity-50',
|
'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) {
|
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';
|
'use client';
|
||||||
|
|
||||||
import { adminClient } from 'better-auth/client/plugins';
|
import { adminClient, emailOTPClient } from 'better-auth/client/plugins';
|
||||||
import { createAuthClient } from 'better-auth/react';
|
import { createAuthClient } from 'better-auth/react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,5 +9,5 @@ import { createAuthClient } from 'better-auth/react';
|
|||||||
*/
|
*/
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
basePath: '/api/auth',
|
basePath: '/api/auth',
|
||||||
plugins: [adminClient()],
|
plugins: [adminClient(), emailOTPClient()],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read only access to the stack's database, used to assert the rows a flow was supposed
|
||||||
|
* to write and to read the emailed verification code (which development prints to the
|
||||||
|
* server console rather than sending). Only valid against a local SQLite stack.
|
||||||
|
*/
|
||||||
|
const DB_PATH =
|
||||||
|
process.env['E2E_DB_PATH'] ?? fileURLToPath(new URL('../apps/api/data/app.db', import.meta.url));
|
||||||
|
|
||||||
|
function open(): Database.Database {
|
||||||
|
return new Database(DB_PATH, { readonly: true, fileMustExist: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function query<T>(run: (db: Database.Database) => T): T {
|
||||||
|
const db = open();
|
||||||
|
try {
|
||||||
|
return run(db);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** better-auth stores the code as `<otp>:<attempts>` against the recipient's address. */
|
||||||
|
export function readVerificationOtp(email: string): string {
|
||||||
|
return query((db) => {
|
||||||
|
const row = db
|
||||||
|
.prepare('select value from verification where identifier = ? order by createdAt desc limit 1')
|
||||||
|
.get(`email-verification-otp-${email}`) as { value: string } | undefined;
|
||||||
|
if (!row) throw new Error(`no verification code was issued for ${email}`);
|
||||||
|
return (row.value.split(':')[0] ?? '').trim();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readProfile(email: string): Record<string, unknown> | undefined {
|
||||||
|
return query(
|
||||||
|
(db) =>
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
'select p.* from profiles p join user u on u.id = p.user_id where u.email = ?',
|
||||||
|
)
|
||||||
|
.get(email) as Record<string, unknown> | undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAuditActions(email: string): string[] {
|
||||||
|
return query((db) => {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
'select a.action from audit_log a join user u on u.id = a.subject_user_id where u.email = ? order by a.created_at',
|
||||||
|
)
|
||||||
|
.all(email) as { action: string }[];
|
||||||
|
return rows.map((row) => row.action);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readConsents(email: string): { kind: string; revoked: boolean }[] {
|
||||||
|
return query((db) => {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
'select c.kind, c.revoked_at from consents c join user u on u.id = c.user_id where u.email = ?',
|
||||||
|
)
|
||||||
|
.all(email) as { kind: string; revoked_at: string | null }[];
|
||||||
|
return rows.map((row) => ({ kind: row.kind, revoked: row.revoked_at !== null }));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -26,11 +26,11 @@ test.describe('sign in screen', () => {
|
|||||||
|
|
||||||
// The en catalog exists for expats and international users (COPY.md section 0-EN),
|
// The en catalog exists for expats and international users (COPY.md section 0-EN),
|
||||||
// so the root honours the browser language and falls back to es, the default.
|
// so the root honours the browser language and falls back to es, the default.
|
||||||
test('the root follows the browser language', async ({ browser }) => {
|
test('the landing page follows the browser language', async ({ browser }) => {
|
||||||
for (const [locale, expected] of [
|
for (const [locale, expected] of [
|
||||||
['es-PY', /\/es\/login$/],
|
['es-PY', /\/es$/],
|
||||||
['en-US', /\/en\/login$/],
|
['en-US', /\/en$/],
|
||||||
['pt-BR', /\/es\/login$/],
|
['pt-BR', /\/es$/],
|
||||||
] as const) {
|
] as const) {
|
||||||
const context = await browser.newContext({ locale });
|
const context = await browser.newContext({ locale });
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { es } from '@impuestos/i18n';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { readAuditActions, readConsents, readProfile, readVerificationOtp } from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Golden path 1 (SPEC.md section 13): a stranger arrives at the landing page and leaves
|
||||||
|
* with a working account, a consent record and a profile. Every step is the real thing,
|
||||||
|
* including the six digit code, which is read back out of the database because
|
||||||
|
* development prints it to the server console instead of sending it.
|
||||||
|
*/
|
||||||
|
test.describe('onboarding', () => {
|
||||||
|
test('takes a new visitor from the landing page to their first run screen', async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
const email = `nuevo-${testInfo.project.name}-${Date.now()}@demo.local`;
|
||||||
|
const password = 'una-contrasena-larga';
|
||||||
|
|
||||||
|
// A1 and A2: the landing hook answers with this taxpayer's real filing day.
|
||||||
|
await page.goto('/es');
|
||||||
|
await expect(page.getByRole('heading', { name: es['landing.hero.title'] })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel(es['landing.hero.inputLabel']).fill('4123456-1');
|
||||||
|
await page.getByRole('button', { name: es['landing.hero.cta'] }).click();
|
||||||
|
|
||||||
|
// Base ends in 6, so RULES.md puts the deadline on the 19th.
|
||||||
|
await expect(page.getByText('19', { exact: false }).first()).toBeVisible();
|
||||||
|
await page.getByRole('link', { name: es['landing.preview.cta'] }).click();
|
||||||
|
|
||||||
|
// A3: account creation, then the six digit code.
|
||||||
|
await expect(page).toHaveURL(/\/es\/registro$/);
|
||||||
|
await page.getByLabel(es['auth.register.email']).fill(email);
|
||||||
|
await page.getByLabel(es['auth.register.password']).fill(password);
|
||||||
|
await page.getByRole('button', { name: es['auth.register.submit'] }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/es\/verificar/);
|
||||||
|
await expect(page.getByRole('heading', { name: es['auth.otp.title'] })).toBeVisible();
|
||||||
|
|
||||||
|
await expect.poll(() => safeOtp(email), { timeout: 10_000 }).toMatch(/^\d{6}$/);
|
||||||
|
const otp = readVerificationOtp(email);
|
||||||
|
// The first box takes the whole pasted code and spreads it across the six.
|
||||||
|
await page.getByLabel('1', { exact: true }).fill(otp);
|
||||||
|
|
||||||
|
// A4: consent. The primary action stays disabled until data processing is granted.
|
||||||
|
await expect(page).toHaveURL(/\/es\/consentimiento$/);
|
||||||
|
const accept = page.getByRole('button', { name: es['consent.accept'] });
|
||||||
|
await expect(accept).toBeDisabled();
|
||||||
|
|
||||||
|
await page.getByRole('switch', { name: es['consent.dataProcessing'] }).click();
|
||||||
|
await expect(accept).toBeEnabled();
|
||||||
|
await accept.click();
|
||||||
|
|
||||||
|
// A5: three steps.
|
||||||
|
await expect(page).toHaveURL(/\/es\/configuracion$/);
|
||||||
|
await page.getByLabel(es['setup.fullName']).fill('Nueva Contribuyente');
|
||||||
|
await page.getByLabel(es['setup.docLabel']).fill('4123456-1');
|
||||||
|
await page.getByLabel(es['setup.docLabel']).blur();
|
||||||
|
await expect(page.getByText(es['setup.deadlineNote'].replace('{day}', '19'))).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: es['common.continue'] }).click();
|
||||||
|
await page.getByRole('button', { name: es['setup.oblig.iva.title'] }).click();
|
||||||
|
await page.getByRole('button', { name: es['common.continue'] }).click();
|
||||||
|
await page.getByRole('button', { name: es['setup.finish'] }).click();
|
||||||
|
|
||||||
|
// A6: the guided first run state.
|
||||||
|
await expect(page).toHaveURL(/\/es\/inicio$/);
|
||||||
|
await expect(page.getByRole('heading', { name: es['home.firstRun.title'] })).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: es['home.firstRun.scan'] })).toBeVisible();
|
||||||
|
|
||||||
|
// The flow left the rows behind that it was supposed to.
|
||||||
|
const profile = readProfile(email);
|
||||||
|
expect(profile?.['full_name']).toBe('Nueva Contribuyente');
|
||||||
|
expect(profile?.['ruc']).toBe('4123456');
|
||||||
|
expect(profile?.['deadline_digit']).toBe(6);
|
||||||
|
|
||||||
|
expect(readConsents(email)).toContainEqual({ kind: 'data_processing', revoked: false });
|
||||||
|
expect(readAuditActions(email)).toContain('consent.grant');
|
||||||
|
expect(readAuditActions(email)).toContain('profile.create');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function safeOtp(email: string): string {
|
||||||
|
try {
|
||||||
|
return readVerificationOtp(email);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { es } from '@impuestos/i18n';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { readAuditActions } from './db';
|
||||||
|
|
||||||
|
/** Flow E3. The trust screen: what we hold, and the two ways to take it back. */
|
||||||
|
test.describe('profile', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/es/login');
|
||||||
|
await page.getByLabel(es['auth.register.email']).fill('maria@demo.local');
|
||||||
|
await page.getByLabel(es['auth.login.password']).fill('demo-maria-1');
|
||||||
|
await page.getByRole('button', { name: es['auth.login.submit'] }).click();
|
||||||
|
await expect(page).toHaveURL(/\/es\/inicio$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows the seeded identity, dependants and the data section', async ({ page }) => {
|
||||||
|
await page.goto('/es/perfil');
|
||||||
|
|
||||||
|
await expect(page.getByLabel(es['setup.fullName'])).toHaveValue('Maria Gonzalez');
|
||||||
|
await expect(page.getByLabel(es['setup.docLabel'])).toHaveValue('4123456-1');
|
||||||
|
// Base ends in 6, so RULES.md puts her deadline on the 19th.
|
||||||
|
await expect(page.getByText(es['vto.explainer'].replace('{digit}', '6').replace('{day}', '19'))).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByText('Lucas Gonzalez')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: es['profile.myData.title'], exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: es['profile.myData.export'] })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds and removes a dependant', async ({ page }) => {
|
||||||
|
await page.goto('/es/perfil');
|
||||||
|
|
||||||
|
const name = `Prueba ${Date.now()}`;
|
||||||
|
await page.getByLabel(es['setup.dependents.name'], { exact: true }).fill(name);
|
||||||
|
await page.getByRole('button', { name: es['setup.dependents.add'] }).click();
|
||||||
|
await expect(page.getByText(name)).toBeVisible();
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('listitem')
|
||||||
|
.filter({ hasText: name })
|
||||||
|
.getByRole('button', { name: es['common.remove'] })
|
||||||
|
.click();
|
||||||
|
await expect(page.getByText(name)).toBeHidden();
|
||||||
|
|
||||||
|
expect(readAuditActions('maria@demo.local')).toContain('dependent.create');
|
||||||
|
expect(readAuditActions('maria@demo.local')).toContain('dependent.delete');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the export downloads a file holding the profile', async ({ page }) => {
|
||||||
|
await page.goto('/es/perfil');
|
||||||
|
|
||||||
|
const download = page.waitForEvent('download');
|
||||||
|
await page.getByRole('link', { name: es['profile.myData.export'] }).click();
|
||||||
|
const file = await download;
|
||||||
|
|
||||||
|
expect(file.suggestedFilename()).toMatch(/^impuestos-datos-\d{4}-\d{2}-\d{2}\.json$/);
|
||||||
|
const stream = await file.createReadStream();
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of stream) chunks.push(chunk as Buffer);
|
||||||
|
const data = JSON.parse(Buffer.concat(chunks).toString('utf8')) as {
|
||||||
|
profile: { fullName: string } | null;
|
||||||
|
account: { email: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(data.account.email).toBe('maria@demo.local');
|
||||||
|
expect(data.profile?.fullName).toBe('Maria Gonzalez');
|
||||||
|
expect(readAuditActions('maria@demo.local')).toContain('data.export');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting the account asks for the email before it will proceed', async ({ page }) => {
|
||||||
|
await page.goto('/es/perfil');
|
||||||
|
await page.getByRole('button', { name: es['profile.myData.delete'] }).click();
|
||||||
|
|
||||||
|
const confirm = page.getByRole('button', { name: es['profile.myData.deleteCta'] });
|
||||||
|
await expect(confirm).toBeDisabled();
|
||||||
|
|
||||||
|
await page.getByLabel(es['profile.myData.deletePrompt'].replace('{email}', 'maria@demo.local')).fill('nope@demo.local');
|
||||||
|
await expect(confirm).toBeDisabled();
|
||||||
|
|
||||||
|
// Stops short of confirming: this account is the showcase data for every other test.
|
||||||
|
await page.getByRole('button', { name: es['common.cancel'] }).click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The toggle knob used to render outside its track, which read as a broken control. */
|
||||||
|
test.describe('switch', () => {
|
||||||
|
test('keeps its knob inside the track in both states', async ({ page }) => {
|
||||||
|
await page.goto('/es/login');
|
||||||
|
await page.getByLabel(es['auth.register.email']).fill('maria@demo.local');
|
||||||
|
await page.getByLabel(es['auth.login.password']).fill('demo-maria-1');
|
||||||
|
await page.getByRole('button', { name: es['auth.login.submit'] }).click();
|
||||||
|
await expect(page).toHaveURL(/\/es\/inicio$/);
|
||||||
|
await page.goto('/es/perfil');
|
||||||
|
|
||||||
|
const toggle = page.getByRole('switch', { name: es['setup.oblig.iva.title'] });
|
||||||
|
await expect(toggle).toBeVisible();
|
||||||
|
|
||||||
|
for (const _ of [0, 1]) {
|
||||||
|
const track = await toggle.boundingBox();
|
||||||
|
const knob = await toggle.locator('span').boundingBox();
|
||||||
|
expect(track).not.toBeNull();
|
||||||
|
expect(knob).not.toBeNull();
|
||||||
|
expect(knob?.x ?? 0).toBeGreaterThanOrEqual(track?.x ?? 0);
|
||||||
|
expect((knob?.x ?? 0) + (knob?.width ?? 0)).toBeLessThanOrEqual(
|
||||||
|
(track?.x ?? 0) + (track?.width ?? 0),
|
||||||
|
);
|
||||||
|
await toggle.click();
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/node": "^26.4.1",
|
"@types/node": "^26.4.1",
|
||||||
"@vitest/coverage-v8": "^5.0.0",
|
"@vitest/coverage-v8": "^5.0.0",
|
||||||
|
"better-sqlite3": "^13.0.3",
|
||||||
"eslint": "^10.9.1",
|
"eslint": "^10.9.1",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import { ProfileDto } from './dto';
|
import {
|
||||||
|
type ConsentInput,
|
||||||
|
DataExportDto,
|
||||||
|
type DeleteAccountInput,
|
||||||
|
DependentDto,
|
||||||
|
type DependentInput,
|
||||||
|
LookupDto,
|
||||||
|
NotificationPrefsDto,
|
||||||
|
type NotificationPrefsInput,
|
||||||
|
OkDto,
|
||||||
|
ProfileDto,
|
||||||
|
type ProfileInput,
|
||||||
|
} from './dto';
|
||||||
import { ApiError, ErrorEnvelope } from './errors';
|
import { ApiError, ErrorEnvelope } from './errors';
|
||||||
|
|
||||||
export interface ApiClientOptions {
|
export interface ApiClientOptions {
|
||||||
@@ -60,8 +72,48 @@ export function createApiClient(options: ApiClientOptions = {}) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
request,
|
request,
|
||||||
|
|
||||||
|
// Public
|
||||||
|
lookupRuc: (numberOrRuc: string, signal?: AbortSignal) =>
|
||||||
|
request('GET', `/lookup/ruc/${encodeURIComponent(numberOrRuc)}`, {
|
||||||
|
schema: LookupDto,
|
||||||
|
...(signal ? { signal } : {}),
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Profile
|
||||||
getProfile: (signal?: AbortSignal) =>
|
getProfile: (signal?: AbortSignal) =>
|
||||||
request('GET', '/me/profile', { schema: ProfileDto, ...(signal ? { signal } : {}) }),
|
request('GET', '/me/profile', { schema: ProfileDto, ...(signal ? { signal } : {}) }),
|
||||||
|
putProfile: (body: ProfileInput) => request('PUT', '/me/profile', { schema: ProfileDto, body }),
|
||||||
|
|
||||||
|
// Dependents
|
||||||
|
listDependents: (signal?: AbortSignal) =>
|
||||||
|
request('GET', '/me/dependents', {
|
||||||
|
schema: DependentDto.array(),
|
||||||
|
...(signal ? { signal } : {}),
|
||||||
|
}),
|
||||||
|
createDependent: (body: DependentInput) =>
|
||||||
|
request('POST', '/me/dependents', { schema: DependentDto, body }),
|
||||||
|
deleteDependent: (id: string) =>
|
||||||
|
request('DELETE', `/me/dependents/${encodeURIComponent(id)}`, { schema: OkDto }),
|
||||||
|
|
||||||
|
// Consent and account
|
||||||
|
postConsent: (body: ConsentInput) => request('POST', '/me/consents', { schema: OkDto, body }),
|
||||||
|
getDataExport: (signal?: AbortSignal) =>
|
||||||
|
request('GET', '/me/data-export', {
|
||||||
|
schema: DataExportDto,
|
||||||
|
...(signal ? { signal } : {}),
|
||||||
|
}),
|
||||||
|
deleteAccount: (body: DeleteAccountInput) =>
|
||||||
|
request('DELETE', '/me/account', { schema: OkDto, body }),
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
getNotificationPrefs: (signal?: AbortSignal) =>
|
||||||
|
request('GET', '/me/notification-prefs', {
|
||||||
|
schema: NotificationPrefsDto,
|
||||||
|
...(signal ? { signal } : {}),
|
||||||
|
}),
|
||||||
|
patchNotificationPrefs: (body: NotificationPrefsInput) =>
|
||||||
|
request('PATCH', '/me/notification-prefs', { schema: NotificationPrefsDto, body }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { DocType, LocaleCode, ObligationCode, TaxpayerKind } from './enums';
|
import { DocType, IrpCategory, LocaleCode, ObligationCode, TaxpayerKind } from './enums';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO schemas are added as their phase lands. Field names come from CONTRACTS.md
|
* DTO schemas are added as their phase lands. Field names come from CONTRACTS.md
|
||||||
@@ -28,6 +28,90 @@ export const ProfileDto = z.object({
|
|||||||
});
|
});
|
||||||
export type ProfileDto = z.infer<typeof ProfileDto>;
|
export type ProfileDto = z.infer<typeof ProfileDto>;
|
||||||
|
|
||||||
|
/** `PUT /me/profile` body: the profile minus deadlineDigit, which is derived. */
|
||||||
|
export const ProfileInput = ProfileDto.omit({ deadlineDigit: true }).extend({
|
||||||
|
fullName: z.string().trim().min(1).max(200),
|
||||||
|
irpGrossEstimate: z.number().int().min(0).max(1_000_000_000_000).nullable(),
|
||||||
|
autoConfirmDays: z.number().int().min(0).max(90),
|
||||||
|
});
|
||||||
|
export type ProfileInput = z.infer<typeof ProfileInput>;
|
||||||
|
|
||||||
|
export const Relationship = z.enum(['conyuge', 'hijo', 'padre', 'otro']);
|
||||||
|
export type Relationship = z.infer<typeof Relationship>;
|
||||||
|
|
||||||
|
export const DependentDto = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
displayName: z.string(),
|
||||||
|
relationship: Relationship,
|
||||||
|
active: z.boolean(),
|
||||||
|
});
|
||||||
|
export type DependentDto = z.infer<typeof DependentDto>;
|
||||||
|
|
||||||
|
export const DependentInput = z.object({
|
||||||
|
displayName: z.string().trim().min(1).max(120),
|
||||||
|
relationship: Relationship,
|
||||||
|
docNumber: z.string().trim().max(20).nullable().optional(),
|
||||||
|
});
|
||||||
|
export type DependentInput = z.infer<typeof DependentInput>;
|
||||||
|
|
||||||
|
export const ConsentKind = z.enum(['data_processing', 'notifications']);
|
||||||
|
export type ConsentKind = z.infer<typeof ConsentKind>;
|
||||||
|
|
||||||
|
export const ConsentDto = z.object({
|
||||||
|
kind: ConsentKind,
|
||||||
|
granted: z.boolean(),
|
||||||
|
grantedAt: z.string(),
|
||||||
|
revokedAt: z.string().nullable(),
|
||||||
|
textVersion: z.string(),
|
||||||
|
});
|
||||||
|
export type ConsentDto = z.infer<typeof ConsentDto>;
|
||||||
|
|
||||||
|
export const ConsentInput = z.object({ kind: ConsentKind, granted: z.boolean() });
|
||||||
|
export type ConsentInput = z.infer<typeof ConsentInput>;
|
||||||
|
|
||||||
|
export const NotificationPrefsDto = z.object({
|
||||||
|
pushEnabled: z.boolean(),
|
||||||
|
emailEnabled: z.boolean(),
|
||||||
|
telegramChatId: z.string().nullable(),
|
||||||
|
digestHour: z.number().int().min(0).max(23),
|
||||||
|
});
|
||||||
|
export type NotificationPrefsDto = z.infer<typeof NotificationPrefsDto>;
|
||||||
|
|
||||||
|
export const NotificationPrefsInput = NotificationPrefsDto.partial();
|
||||||
|
export type NotificationPrefsInput = z.infer<typeof NotificationPrefsInput>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /lookup/ruc/:number`. An unparseable number is a 200 with `valid: false`, not an
|
||||||
|
* error: the landing shows it inline and never dead ends (CONTRACTS.md section 3).
|
||||||
|
*/
|
||||||
|
export const LookupDto = z.object({
|
||||||
|
valid: z.boolean(),
|
||||||
|
docType: DocType,
|
||||||
|
base: z.string(),
|
||||||
|
dv: z.number().int().nullable(),
|
||||||
|
deadlineDigit: z.number().int().min(0).max(9),
|
||||||
|
deadlineDay: z.number().int().min(1).max(31),
|
||||||
|
nextDeadlines: z.array(z.string()).length(3),
|
||||||
|
});
|
||||||
|
export type LookupDto = z.infer<typeof LookupDto>;
|
||||||
|
|
||||||
|
/** Everything the platform holds about one user, as a downloadable file. */
|
||||||
|
export const DataExportDto = z.object({
|
||||||
|
exportedAt: z.string(),
|
||||||
|
account: z.object({ email: z.string(), createdAt: z.string() }),
|
||||||
|
profile: ProfileDto.nullable(),
|
||||||
|
dependents: z.array(DependentDto),
|
||||||
|
consents: z.array(ConsentDto),
|
||||||
|
notificationPrefs: NotificationPrefsDto.nullable(),
|
||||||
|
documents: z.array(z.record(z.string(), z.unknown())),
|
||||||
|
classifications: z.array(z.record(z.string(), z.unknown())),
|
||||||
|
declarations: z.array(z.record(z.string(), z.unknown())),
|
||||||
|
});
|
||||||
|
export type DataExportDto = z.infer<typeof DataExportDto>;
|
||||||
|
|
||||||
|
export const DeleteAccountInput = z.object({ confirmText: z.string() });
|
||||||
|
export type DeleteAccountInput = z.infer<typeof DeleteAccountInput>;
|
||||||
|
|
||||||
export const OkDto = z.object({ ok: z.literal(true) });
|
export const OkDto = z.object({ ok: z.literal(true) });
|
||||||
export type OkDto = z.infer<typeof OkDto>;
|
export type OkDto = z.infer<typeof OkDto>;
|
||||||
|
|
||||||
@@ -39,3 +123,6 @@ export const ReadyDto = z.object({
|
|||||||
checks: z.record(z.string(), z.enum(['ok', 'error', 'pending'])),
|
checks: z.record(z.string(), z.enum(['ok', 'error', 'pending'])),
|
||||||
});
|
});
|
||||||
export type ReadyDto = z.infer<typeof ReadyDto>;
|
export type ReadyDto = z.infer<typeof ReadyDto>;
|
||||||
|
|
||||||
|
/** Re-exported so callers get the category vocabulary from one place. */
|
||||||
|
export { IrpCategory };
|
||||||
|
|||||||
@@ -17,6 +17,24 @@ export {
|
|||||||
|
|
||||||
export { ERROR_CODES, ErrorCode, ErrorEnvelope, ApiError, isApiError } from './errors';
|
export { ERROR_CODES, ErrorCode, ErrorEnvelope, ApiError, isApiError } from './errors';
|
||||||
|
|
||||||
export { Obligation, ProfileDto, OkDto, HealthDto, ReadyDto } from './dto';
|
export {
|
||||||
|
Obligation,
|
||||||
|
ProfileDto,
|
||||||
|
ProfileInput,
|
||||||
|
Relationship,
|
||||||
|
DependentDto,
|
||||||
|
DependentInput,
|
||||||
|
ConsentKind,
|
||||||
|
ConsentDto,
|
||||||
|
ConsentInput,
|
||||||
|
NotificationPrefsDto,
|
||||||
|
NotificationPrefsInput,
|
||||||
|
LookupDto,
|
||||||
|
DataExportDto,
|
||||||
|
DeleteAccountInput,
|
||||||
|
OkDto,
|
||||||
|
HealthDto,
|
||||||
|
ReadyDto,
|
||||||
|
} from './dto';
|
||||||
|
|
||||||
export { createApiClient, type ApiClient, type ApiClientOptions } from './client';
|
export { createApiClient, type ApiClient, type ApiClientOptions } from './client';
|
||||||
|
|||||||
@@ -230,6 +230,77 @@ export const en: Record<MessageKey, string> = {
|
|||||||
"classification.reason.irp_sale_is_income": "This is income of yours, not a deductible expense.",
|
"classification.reason.irp_sale_is_income": "This is income of yours, not a deductible expense.",
|
||||||
"classification.reason.irp_not_irp_taxpayer": "You are not registered for IRP, so it is not deducted.",
|
"classification.reason.irp_not_irp_taxpayer": "You are not registered for IRP, so it is not deducted.",
|
||||||
|
|
||||||
|
// Registration and verification (Flow A3)
|
||||||
|
"auth.register.submit": "Create my account",
|
||||||
|
"auth.register.passwordHint": "At least 8 characters.",
|
||||||
|
"auth.register.haveAccount": "Already have an account?",
|
||||||
|
"auth.register.signIn": "Sign in",
|
||||||
|
"auth.register.failed": "We could not create your account. Check the email and try again.",
|
||||||
|
"auth.register.emailTaken": "There is already an account with that email. Try signing in.",
|
||||||
|
"auth.login.register": "Create one",
|
||||||
|
"auth.otp.label": "6 digit code",
|
||||||
|
"auth.otp.submit": "Verify",
|
||||||
|
"auth.otp.failed": "That code does not match or it expired. Ask for a new one.",
|
||||||
|
"auth.otp.resent": "We sent you a new code.",
|
||||||
|
"auth.otp.devHint": "In development the code is printed to the server console.",
|
||||||
|
|
||||||
|
// Landing (Flow A1 and A2)
|
||||||
|
"landing.lookup.checking": "Looking it up...",
|
||||||
|
"landing.pricing.title": "Pricing",
|
||||||
|
"landing.pricing.body": "We are still working out the plans. In the meantime it is free.",
|
||||||
|
"landing.footer.privacy": "Privacy",
|
||||||
|
"landing.footer.terms": "Terms",
|
||||||
|
"landing.preview.kindInd": "Individual",
|
||||||
|
"landing.preview.kindCom": "Company",
|
||||||
|
|
||||||
|
// Consent (Flow A4)
|
||||||
|
"consent.required": "Without this we cannot store your facturas or build your declarations.",
|
||||||
|
"consent.accept": "I agree, continue",
|
||||||
|
|
||||||
|
// Profile setup (Flow A5)
|
||||||
|
"setup.progress": "Step {step} of {total}",
|
||||||
|
"setup.docLabel": "Your RUC or CI",
|
||||||
|
"setup.docHelp": "With the verifier digit if you have a RUC, for example 4123456-1.",
|
||||||
|
"setup.deadlineNote": "Your vencimientos (due dates) will fall on day {day} of every month.",
|
||||||
|
"setup.dependents.name": "Name",
|
||||||
|
"setup.dependents.rel": "Relationship",
|
||||||
|
"setup.finish": "Finish",
|
||||||
|
"relationship.conyuge": "Spouse",
|
||||||
|
"relationship.hijo": "Son or daughter",
|
||||||
|
"relationship.padre": "Parent",
|
||||||
|
"relationship.otro": "Other",
|
||||||
|
|
||||||
|
// Profile (Flow E3)
|
||||||
|
"profile.identity": "Your tax details",
|
||||||
|
"profile.obligations": "What you are registered for",
|
||||||
|
"profile.dependents": "Dependants",
|
||||||
|
"profile.dependents.empty": "You have not added anyone yet.",
|
||||||
|
"profile.income": "Estimated annual income",
|
||||||
|
"profile.notifications": "Alerts",
|
||||||
|
"profile.autoConfirm": "Automatic confirmation",
|
||||||
|
"profile.autoConfirm.help": "High confidence facturas confirm themselves after this many days. Set it to 0 to turn it off.",
|
||||||
|
"profile.saved": "Saved ✓",
|
||||||
|
"profile.myData.stored": "Your profile, your dependants, your facturas, your classifications and your declarations.",
|
||||||
|
"profile.myData.deletePrompt": "Type {email} to confirm.",
|
||||||
|
"profile.myData.deleteCta": "Delete permanently",
|
||||||
|
"profile.consent.revokeWarn": "Revoking consent signs you out and freezes the account.",
|
||||||
|
"notif.push": "Push notifications",
|
||||||
|
"notif.email": "Email",
|
||||||
|
"notif.telegram": "Telegram",
|
||||||
|
"notif.channelUnavailable": "This channel is not configured yet.",
|
||||||
|
|
||||||
|
// Legal (COPY.md section 13: placeholder until a human writes them)
|
||||||
|
"legal.privacy.title": "Privacy policy",
|
||||||
|
"legal.terms.title": "Terms and conditions",
|
||||||
|
"legal.placeholder": "Document in preparation",
|
||||||
|
"legal.placeholderBody": "We are drafting this document with legal advice. It will be published before launch.",
|
||||||
|
|
||||||
|
// Common
|
||||||
|
"common.add": "Add",
|
||||||
|
"common.remove": "Remove",
|
||||||
|
"common.saving": "Saving...",
|
||||||
|
"common.skip": "Skip",
|
||||||
|
|
||||||
// Chrome (es.extra.ts)
|
// Chrome (es.extra.ts)
|
||||||
"common.language": "Language",
|
"common.language": "Language",
|
||||||
"common.languageEs": "Español",
|
"common.languageEs": "Español",
|
||||||
|
|||||||
@@ -31,6 +31,77 @@ export const esExtra = {
|
|||||||
"classification.reason.irp_sale_is_income": "Es un ingreso tuyo, no un gasto deducible.",
|
"classification.reason.irp_sale_is_income": "Es un ingreso tuyo, no un gasto deducible.",
|
||||||
"classification.reason.irp_not_irp_taxpayer": "No tenés IRP activo, asi que no se descuenta.",
|
"classification.reason.irp_not_irp_taxpayer": "No tenés IRP activo, asi que no se descuenta.",
|
||||||
|
|
||||||
|
// Registration and verification (Flow A3)
|
||||||
|
"auth.register.submit": "Crear mi cuenta",
|
||||||
|
"auth.register.passwordHint": "Al menos 8 caracteres.",
|
||||||
|
"auth.register.haveAccount": "¿Ya tenés cuenta?",
|
||||||
|
"auth.register.signIn": "Entrá",
|
||||||
|
"auth.register.failed": "No pudimos crear tu cuenta. Revisá el email y probá de nuevo.",
|
||||||
|
"auth.register.emailTaken": "Ya hay una cuenta con ese email. Probá entrar.",
|
||||||
|
"auth.login.register": "Creá una",
|
||||||
|
"auth.otp.label": "Codigo de 6 digitos",
|
||||||
|
"auth.otp.submit": "Verificar",
|
||||||
|
"auth.otp.failed": "Ese codigo no coincide o ya vencio. Pedí uno nuevo.",
|
||||||
|
"auth.otp.resent": "Te enviamos un codigo nuevo.",
|
||||||
|
"auth.otp.devHint": "En desarrollo el codigo se imprime en la consola del servidor.",
|
||||||
|
|
||||||
|
// Landing (Flow A1 and A2)
|
||||||
|
"landing.lookup.checking": "Buscando...",
|
||||||
|
"landing.pricing.title": "Precio",
|
||||||
|
"landing.pricing.body": "Estamos definiendo los planes. Mientras tanto, usalo gratis.",
|
||||||
|
"landing.footer.privacy": "Privacidad",
|
||||||
|
"landing.footer.terms": "Terminos",
|
||||||
|
"landing.preview.kindInd": "Persona fisica",
|
||||||
|
"landing.preview.kindCom": "Empresa",
|
||||||
|
|
||||||
|
// Consent (Flow A4)
|
||||||
|
"consent.required": "Sin esto no podemos guardar tus facturas ni armar tus declaraciones.",
|
||||||
|
"consent.accept": "Acepto y sigo",
|
||||||
|
|
||||||
|
// Profile setup (Flow A5)
|
||||||
|
"setup.progress": "Paso {step} de {total}",
|
||||||
|
"setup.docLabel": "Tu RUC o CI",
|
||||||
|
"setup.docHelp": "Con el digito verificador si tenés RUC, por ejemplo 4123456-1.",
|
||||||
|
"setup.deadlineNote": "Tus vencimientos van a caer el dia {day} de cada mes.",
|
||||||
|
"setup.dependents.name": "Nombre",
|
||||||
|
"setup.dependents.rel": "Parentesco",
|
||||||
|
"setup.finish": "Terminar",
|
||||||
|
"relationship.conyuge": "Conyuge",
|
||||||
|
"relationship.hijo": "Hijo o hija",
|
||||||
|
"relationship.padre": "Padre o madre",
|
||||||
|
"relationship.otro": "Otro",
|
||||||
|
|
||||||
|
// Profile (Flow E3)
|
||||||
|
"profile.identity": "Tus datos fiscales",
|
||||||
|
"profile.obligations": "Tus obligaciones",
|
||||||
|
"profile.dependents": "Familiares a cargo",
|
||||||
|
"profile.dependents.empty": "Todavia no cargaste ninguno.",
|
||||||
|
"profile.income": "Ingreso anual estimado",
|
||||||
|
"profile.notifications": "Avisos",
|
||||||
|
"profile.autoConfirm": "Confirmacion automatica",
|
||||||
|
"profile.autoConfirm.help": "Las facturas con alta confianza se confirman solas pasados estos dias. Poné 0 para desactivarlo.",
|
||||||
|
"profile.saved": "Guardado ✓",
|
||||||
|
"profile.myData.stored": "Tu perfil, tus familiares a cargo, tus facturas, tus clasificaciones y tus declaraciones.",
|
||||||
|
"profile.myData.deletePrompt": "Escribí {email} para confirmar.",
|
||||||
|
"profile.myData.deleteCta": "Eliminar definitivamente",
|
||||||
|
"profile.consent.revokeWarn": "Si revocas el consentimiento cerramos tu sesion y congelamos la cuenta.",
|
||||||
|
"notif.push": "Notificaciones push",
|
||||||
|
"notif.email": "Email",
|
||||||
|
"notif.telegram": "Telegram",
|
||||||
|
"notif.channelUnavailable": "Este canal todavia no esta configurado.",
|
||||||
|
|
||||||
|
// Legal (COPY.md section 13: placeholder until a human writes them)
|
||||||
|
"legal.privacy.title": "Politica de privacidad",
|
||||||
|
"legal.terms.title": "Terminos y condiciones",
|
||||||
|
"legal.placeholder": "Documento en preparacion",
|
||||||
|
"legal.placeholderBody": "Estamos redactando este documento con asesoria legal. Va a estar publicado antes del lanzamiento.",
|
||||||
|
|
||||||
|
// Common
|
||||||
|
"common.add": "Agregar",
|
||||||
|
"common.remove": "Quitar",
|
||||||
|
"common.saving": "Guardando...",
|
||||||
|
"common.skip": "Omitir",
|
||||||
|
|
||||||
// Chrome
|
// Chrome
|
||||||
"common.language": "Idioma",
|
"common.language": "Idioma",
|
||||||
"common.languageEs": "Español",
|
"common.languageEs": "Español",
|
||||||
|
|||||||
Generated
+9
-3
@@ -23,6 +23,9 @@ importers:
|
|||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.0(vitest@5.0.0)
|
version: 5.0.0(vitest@5.0.0)
|
||||||
|
better-sqlite3:
|
||||||
|
specifier: ^13.0.3
|
||||||
|
version: 13.0.3
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^10.9.1
|
specifier: ^10.9.1
|
||||||
version: 10.9.1(jiti@2.7.0)
|
version: 10.9.1(jiti@2.7.0)
|
||||||
@@ -61,7 +64,7 @@ importers:
|
|||||||
version: link:../../packages/rules
|
version: link:../../packages/rules
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.7.2
|
specifier: ^1.7.2
|
||||||
version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0)
|
version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)))
|
||||||
better-sqlite3:
|
better-sqlite3:
|
||||||
specifier: ^13.0.3
|
specifier: ^13.0.3
|
||||||
version: 13.0.3
|
version: 13.0.3
|
||||||
@@ -108,12 +111,15 @@ importers:
|
|||||||
'@impuestos/i18n':
|
'@impuestos/i18n':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/i18n
|
version: link:../../packages/i18n
|
||||||
|
'@impuestos/rules':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/rules
|
||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: ^5.102.8
|
specifier: ^5.102.8
|
||||||
version: 5.102.8(react@19.2.8)
|
version: 5.102.8(react@19.2.8)
|
||||||
better-auth:
|
better-auth:
|
||||||
specifier: ^1.7.2
|
specifier: ^1.7.2
|
||||||
version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0)
|
version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)))
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@@ -4387,7 +4393,7 @@ snapshots:
|
|||||||
|
|
||||||
baseline-browser-mapping@2.11.21: {}
|
baseline-browser-mapping@2.11.21: {}
|
||||||
|
|
||||||
better-auth@1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0):
|
better-auth@1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3)
|
'@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3)
|
||||||
'@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)
|
'@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)
|
||||||
|
|||||||
Reference in New Issue
Block a user