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:
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@impuestos/contracts",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.5.4"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user