phase-0: foundation, both apps boot end to end

Monorepo (pnpm workspaces) with two deployable apps and three pure packages.

apps/api (Hono on Node): Zod validated env that fails fast and names the problem,
Kysely factories for SQLite and Postgres chosen by DATABASE_URL scheme, portable
migrations covering the whole SPEC section 5 schema, Better Auth with the four
roles and seeded demo accounts, localized error envelope, /healthz and /readyz,
graceful SIGTERM drain. Dialect specific SQL is confined to the two factories.

apps/web (Next.js App Router): locale routed shell in es and en with a language
switcher, sign in screen, and a runtime /api proxy so the browser only ever sees
one origin and cookies stay first party.

packages/i18n ships both catalogs complete; es is generated from COPY.md and a
test re-derives it from the document on every run so it cannot drift.
packages/contracts holds the Zod schemas and the typed client the web app uses.

Verified: 43 vitest tests, 14 Playwright tests on mobile and desktop, typecheck
and lint clean, migrate and seed from a clean database, sign in through the proxy
with CSRF rejection of foreign origins.

Not verified here: docker compose. This user has no access to the docker socket.

RULES.md is absent from docs/, so packages/rules exports only RULES_VERSION and
no tax rule, check digit or deadline was invented. See DECISIONS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-09-03 21:46:35 +00:00
co-authored by Claude Opus 5
commit ae2ea20b7e
106 changed files with 11541 additions and 0 deletions
+88
View File
@@ -0,0 +1,88 @@
import type { z } from 'zod';
import { ProfileDto } from './dto';
import { ApiError, ErrorEnvelope } from './errors';
export interface ApiClientOptions {
/**
* Origin the API is reached at. Empty string in the browser: the web app proxies
* `/api/*` so requests stay first party. Server components pass API_INTERNAL_URL.
*/
baseUrl?: string;
/** Forwarded on every request. Server components pass the incoming `cookie` header. */
headers?: Record<string, string>;
fetch?: typeof globalThis.fetch;
}
interface RequestOptions<T> {
schema: z.ZodType<T>;
query?: Record<string, string | number | boolean | undefined>;
body?: unknown;
signal?: AbortSignal;
}
export type ApiClient = ReturnType<typeof createApiClient>;
export function createApiClient(options: ApiClientOptions = {}) {
const baseUrl = (options.baseUrl ?? '').replace(/\/$/, '');
const doFetch = options.fetch ?? globalThis.fetch;
async function request<T>(
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
path: string,
opts: RequestOptions<T>,
): Promise<T> {
const url = new URL(`${baseUrl}/api${path}`, baseUrl || 'http://localhost');
for (const [key, value] of Object.entries(opts.query ?? {})) {
if (value !== undefined) url.searchParams.set(key, String(value));
}
const headers: Record<string, string> = { accept: 'application/json', ...options.headers };
let payload: string | undefined;
if (opts.body !== undefined) {
headers['content-type'] = 'application/json';
payload = JSON.stringify(opts.body);
}
const response = await doFetch(baseUrl ? url.toString() : `${url.pathname}${url.search}`, {
method,
headers,
credentials: 'include',
...(payload === undefined ? {} : { body: payload }),
...(opts.signal ? { signal: opts.signal } : {}),
});
const text = await response.text();
const json: unknown = text.length > 0 ? safeJson(text) : undefined;
if (!response.ok) throw toApiError(response.status, json);
return opts.schema.parse(json);
}
return {
request,
getProfile: (signal?: AbortSignal) =>
request('GET', '/me/profile', { schema: ProfileDto, ...(signal ? { signal } : {}) }),
};
}
function safeJson(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
function toApiError(status: number, json: unknown): ApiError {
const parsed = ErrorEnvelope.safeParse(json);
if (parsed.success) {
const { code, message, field, detail } = parsed.data.error;
return new ApiError({ code, message, status, field, detail });
}
return new ApiError({
code: status === 401 ? 'unauthorized' : 'internal',
message: 'Algo salio mal de nuestro lado. Proba de nuevo en un momento.',
status,
detail: json,
});
}
+41
View File
@@ -0,0 +1,41 @@
import { z } from 'zod';
import { DocType, LocaleCode, ObligationCode, TaxpayerKind } from './enums';
/**
* DTO schemas are added as their phase lands. Field names come from CONTRACTS.md
* section 2 and are final.
*/
export const Obligation = z.object({
code: ObligationCode,
active: z.boolean(),
since: z.string(),
});
export type Obligation = z.infer<typeof Obligation>;
export const ProfileDto = z.object({
fullName: z.string(),
docType: DocType,
ruc: z.string().nullable(),
rucDv: z.string().nullable(),
ci: z.string().nullable(),
taxpayerKind: TaxpayerKind,
deadlineDigit: z.number().int().min(0).max(9),
obligations: z.array(Obligation),
irpGrossEstimate: z.number().int().nullable(),
autoConfirmDays: z.number().int(),
locale: LocaleCode,
});
export type ProfileDto = z.infer<typeof ProfileDto>;
export const OkDto = z.object({ ok: z.literal(true) });
export type OkDto = z.infer<typeof OkDto>;
export const HealthDto = z.object({ ok: z.boolean() });
export type HealthDto = z.infer<typeof HealthDto>;
export const ReadyDto = z.object({
ok: z.boolean(),
checks: z.record(z.string(), z.enum(['ok', 'error', 'pending'])),
});
export type ReadyDto = z.infer<typeof ReadyDto>;
+58
View File
@@ -0,0 +1,58 @@
import { z } from 'zod';
export const IRP_CATEGORIES = [
'alimentacion',
'salud',
'educacion',
'vivienda',
'vestimenta',
'esparcimiento',
'vehiculo',
'familiares',
] as const;
export const IrpCategory = z.enum(IRP_CATEGORIES);
export type IrpCategory = z.infer<typeof IrpCategory>;
export const DocSource = z.enum(['scan_qr', 'scan_ocr', 'manual']);
export type DocSource = z.infer<typeof DocSource>;
export const DocStatus = z.enum(['needs_review', 'confirmed', 'rejected']);
export type DocStatus = z.infer<typeof DocStatus>;
export const DocKind = z.enum([
'factura',
'autofactura',
'nota_credito',
'nota_debito',
'boleta_resimple',
'otro',
]);
export type DocKind = z.infer<typeof DocKind>;
export const Direction = z.enum(['purchase', 'sale']);
export type Direction = z.infer<typeof Direction>;
export const FormCode = z.enum(['120', '515']);
export type FormCode = z.infer<typeof FormCode>;
export const ObligationCode = z.enum(['iva_120', 'irp_515']);
export type ObligationCode = z.infer<typeof ObligationCode>;
export const DocType = z.enum(['ruc', 'ci']);
export type DocType = z.infer<typeof DocType>;
export const TaxpayerKind = z.enum(['individual', 'company']);
export type TaxpayerKind = z.infer<typeof TaxpayerKind>;
export const SupplierRegimeHint = z.enum(['normal', 'resimple', 'unknown']);
export type SupplierRegimeHint = z.infer<typeof SupplierRegimeHint>;
export const VerificationStatus = z.enum(['unverified', 'valid', 'invalid', 'error']);
export type VerificationStatus = z.infer<typeof VerificationStatus>;
export const UserRole = z.enum(['user', 'accountant', 'staff', 'superadmin']);
export type UserRole = z.infer<typeof UserRole>;
export const LocaleCode = z.enum(['es', 'en']);
export type LocaleCode = z.infer<typeof LocaleCode>;
+53
View File
@@ -0,0 +1,53 @@
import { z } from 'zod';
export const ERROR_CODES = [
'validation_error',
'unauthorized',
'forbidden',
'not_found',
'conflict',
'rate_limited',
'ocr_unavailable',
'internal',
] as const;
export const ErrorCode = z.enum(ERROR_CODES);
export type ErrorCode = z.infer<typeof ErrorCode>;
/** The envelope every non-2xx response uses (CONTRACTS.md section 1). */
export const ErrorEnvelope = z.object({
error: z.object({
code: ErrorCode,
message: z.string(),
field: z.string().optional(),
detail: z.unknown().optional(),
}),
});
export type ErrorEnvelope = z.infer<typeof ErrorEnvelope>;
/** Thrown by the client for any non-2xx response. `message` is already user-safe copy. */
export class ApiError extends Error {
readonly code: ErrorCode;
readonly status: number;
readonly field: string | undefined;
readonly detail: unknown;
constructor(args: {
code: ErrorCode;
message: string;
status: number;
field?: string | undefined;
detail?: unknown;
}) {
super(args.message);
this.name = 'ApiError';
this.code = args.code;
this.status = args.status;
this.field = args.field;
this.detail = args.detail;
}
}
export function isApiError(value: unknown): value is ApiError {
return value instanceof ApiError;
}
+22
View File
@@ -0,0 +1,22 @@
export {
IRP_CATEGORIES,
IrpCategory,
DocSource,
DocStatus,
DocKind,
Direction,
FormCode,
ObligationCode,
DocType,
TaxpayerKind,
SupplierRegimeHint,
VerificationStatus,
UserRole,
LocaleCode,
} from './enums';
export { ERROR_CODES, ErrorCode, ErrorEnvelope, ApiError, isApiError } from './errors';
export { Obligation, ProfileDto, OkDto, HealthDto, ReadyDto } from './dto';
export { createApiClient, type ApiClient, type ApiClientOptions } from './client';