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
+15
View File
@@ -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"
}
}
+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';
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@impuestos/i18n",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
}
}
@@ -0,0 +1,86 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { en } from './en';
import { esCopy } from './es';
import { es } from './index';
const COPY_MD = fileURLToPath(new URL('../../../../docs/COPY.md', import.meta.url));
/** Re-derives the key/value pairs from COPY.md the same way the catalog was generated. */
function parseCopyMd(): Map<string, string> {
const entries = new Map<string, string>();
let inBlock = false;
for (const line of readFileSync(COPY_MD, 'utf8').split('\n')) {
if (line.startsWith('```')) {
inBlock = !inBlock;
continue;
}
if (!inBlock) continue;
const match = /^([A-Za-z][A-Za-z0-9_.]*)\s+=\s(.*)$/.exec(line);
if (!match) continue;
const [, key = '', value = ''] = match;
// The only editorial annotation in COPY.md, stripped when the catalog was generated.
entries.set(key, key === 'common.appName' ? 'Impuestos' : value);
}
return entries;
}
describe('es catalog', () => {
const copy = parseCopyMd();
it('carries every string in COPY.md verbatim', () => {
for (const [key, value] of copy) {
expect(esCopy[key as keyof typeof esCopy], `key ${key}`).toBe(value);
}
});
it('is exactly COPY.md, no more and no less', () => {
expect(Object.keys(esCopy).sort()).toEqual([...copy.keys()].sort());
});
it('is never overridden by the extra catalog', () => {
for (const key of Object.keys(esCopy)) {
expect(es[key as keyof typeof es], `key ${key}`).toBe(esCopy[key as keyof typeof esCopy]);
}
});
});
describe('catalog parity', () => {
it('es and en have identical key sets', () => {
expect(Object.keys(en).sort()).toEqual(Object.keys(es).sort());
});
it('en has no empty strings', () => {
for (const [key, value] of Object.entries(en)) {
expect(value.trim().length, `key ${key}`).toBeGreaterThan(0);
}
});
it('en uses exactly the same interpolation params as es', () => {
const params = (value: string) => [...value.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort();
for (const key of Object.keys(es) as (keyof typeof es)[]) {
expect(params(en[key]), `key ${key}`).toEqual(params(es[key]));
}
});
it('uses no em dashes in any locale (COPY.md section 0)', () => {
for (const catalog of [es, en]) {
for (const [key, value] of Object.entries(catalog)) {
expect(value.includes('—'), `key ${key}`).toBe(false);
}
}
});
it('uses only the two permitted emoji', () => {
const permitted = /[✓\u{1F525}]/u;
const emoji = /\p{Extended_Pictographic}/u;
for (const catalog of [es, en]) {
for (const [key, value] of Object.entries(catalog)) {
for (const char of value) {
if (emoji.test(char)) expect(permitted.test(char), `key ${key}: ${char}`).toBe(true);
}
}
}
});
});
+234
View File
@@ -0,0 +1,234 @@
// English catalog. Same keys as es (enforced by the type below and by parity.test.ts).
// Written per COPY.md section 0-EN: natural English, Paraguayan tax terms kept in Spanish
// with a short gloss on first use per screen. No em dashes. Money always "Gs.".
import type { MessageKey } from './index';
export const en: Record<MessageKey, string> = {
// 1. Common
"common.appName": "Impuestos",
"common.continue": "Continue",
"common.back": "Back",
"common.save": "Save",
"common.cancel": "Cancel",
"common.confirm": "Confirm",
"common.edit": "Edit",
"common.delete": "Delete",
"common.retry": "Try again",
"common.close": "Close",
"common.loading": "Loading...",
"common.search": "Search",
"common.seeDetail": "See detail",
"common.optional": "(optional)",
"common.error.generic": "Something went wrong on our side. Try again in a moment.",
"common.error.offline": "No connection. Your changes are saved and will sync when you are back.",
"common.status.alDia": "Up to date",
"common.status.porVencer": "Due soon",
"common.status.enRevision": "In review",
"common.status.atrasado": "Overdue",
// 2. Landing
"landing.hero.title": "Your taxes, on autopilot.",
"landing.hero.subtitle": "Scan your facturas and we build your books, your deductions and your declarations. Never miss a vencimiento again.",
"landing.hero.inputLabel": "Enter your RUC or CI",
"landing.hero.cta": "See where I stand",
"landing.value1.title": "Scan and done",
"landing.value1.body": "Take a photo of any factura (invoice). We read it, verify it and file it under the right category for you.",
"landing.value2.title": "Always know what you owe",
"landing.value2.body": "Your IVA for the month and your IRP for the year, recalculated live with every factura you add.",
"landing.value3.title": "Declarations ready to file",
"landing.value3.body": "Your Formulario 120 and your 515 build themselves. You just review and approve.",
"landing.preview.title": "This is what DNIT already knows about you",
"landing.preview.deadline": "Your vencimientos (due dates) fall on day {day} of every month",
"landing.preview.next": "Coming up: {d1}, {d2} and {d3}",
"landing.preview.cta": "Create my free account",
"landing.invalidDoc": "That number does not look valid. Check the verifier digit (the number after the dash).",
// 3. Auth
"auth.register.title": "Create your account",
"auth.register.email": "Your email",
"auth.register.password": "Choose a password",
"auth.login.title": "Sign in to your account",
"auth.otp.title": "Check your email",
"auth.otp.body": "We sent a 6 digit code to {email}.",
"auth.otp.resend": "Resend code",
"auth.logout": "Sign out",
// 4. Consent
"consent.title": "Before we start, the important part",
"consent.intro": "We store your facturas and your tax data to build your taxes. Nothing more, nothing less.",
"consent.bullet1": "Your data is yours: download it or delete it whenever you want.",
"consent.bullet2": "We never sell or share your information.",
"consent.bullet3": "Every time our team accesses your data, it gets logged.",
"consent.dataProcessing": "I agree to my data being processed for this service",
"consent.notifications": "I want alerts about vencimientos (due dates) and news",
"consent.policyLink": "Read the full policy",
// 5. Profile setup
"setup.step1.title": "Confirm your details",
"setup.fullName": "Full name",
"setup.taxpayerKind.q": "Are you an individual or a company?",
"setup.taxpayerKind.ind": "Individual",
"setup.taxpayerKind.com": "Company",
"setup.step2.title": "What are you registered for?",
"setup.oblig.iva.title": "Monthly IVA",
"setup.oblig.iva.body": "I have an active RUC and I invoice with IVA",
"setup.oblig.irp.title": "IRP",
"setup.oblig.irp.body": "I earn more than Gs. 80 million a year",
"setup.oblig.unsure": "I am not sure",
"setup.income.q": "How much do you expect to earn this year?",
"setup.income.help": "We use this to project your IRP. You can change it whenever you want.",
"setup.dependents.title": "Your dependants",
"setup.dependents.help": "Expenses for the family members you support can be deductible too.",
"setup.dependents.add": "Add a dependant",
"setup.dependents.skip": "I will do this later",
"setup.step3.title": "Alerts",
"setup.push.title": "Turn on notifications",
"setup.push.body": "One timely alert beats a thousand late fees. We only send what matters.",
"setup.push.cta": "Turn on",
"setup.push.later": "Not now",
// 6. Dashboard (Mi situacion)
"home.title": "Where I stand",
"home.iva.title": "IVA for {month}",
"home.iva.aPagar": "To pay",
"home.iva.aFavor": "In your favor",
"home.iva.breakdown": "Debit Gs. {debito}, credit Gs. {credito}",
"home.irp.title": "Projected IRP {year}",
"home.irp.delta": "You brought it down Gs. {amount} this month thanks to your deductions",
"home.irp.belowThreshold": "For now you are below the Gs. 80 million threshold. This is for information only.",
"home.next.allClear": "All up to date ✓",
"home.next.upcoming": "Next vencimiento: {date}",
"home.next.dueSoon": "Your {form} is due in {days} days",
"home.next.reviewCta": "Review and approve",
"home.next.bandeja": "You have {count} facturas to review",
"home.next.bandejaCta": "Go to the inbox",
"home.insight.gapTitle": "You are leaving deductions on the table",
"home.insight.gapBody": "You barely added any {category} facturas this year, and they are deductible.",
"home.insight.monthClose": "You closed {month} with Gs. {savings} in new deductions.",
"home.firstRun.title": "Let us start with your first factura",
"home.firstRun.body": "Scan any factura (invoice) you have on hand and watch what happens.",
"home.firstRun.scan": "Scan my first factura",
"home.firstRun.manual": "Or enter it by hand",
// 7. Scan and manual entry
"scan.fab": "Scan",
"scan.hint": "Point at the QR on the factura",
"scan.noQrHint": "No QR on the factura? Photograph it anyway",
"scan.upload": "Upload a file",
"scan.processing": "Reading your factura...",
"scan.verified": "Verified ✓",
"scan.registered": "Recorded",
"scan.ocrLowConfidence": "Check the highlighted fields, we could not read them clearly.",
"scan.duplicate.title": "You already had this factura",
"scan.duplicate.body": "We recorded it on {date}. Nothing gets duplicated.",
"scan.result.suggested": "We suggest: {category}",
"scan.result.confirm": "Confirm",
"scan.result.changeCat": "Change category",
"scan.result.discard": "Discard",
"manual.title": "Enter a factura by hand",
"manual.emitterRuc": "RUC of the issuer",
"manual.emitterName": "Name or business name",
"manual.total": "Total",
"manual.ivaSplit.q": "All at 10%?",
"manual.date": "Date",
"manual.saved": "Factura saved ✓",
// 8. Bandeja
"bandeja.title": "Inbox",
"bandeja.empty": "Inbox clear ✓",
"bandeja.emptyBody": "When you scan a factura or a new one reaches us, it shows up here for you to confirm.",
"bandeja.confirm": "Confirm",
"bandeja.reject.title": "Why are you discarding it?",
"bandeja.reject.notMine": "Not mine",
"bandeja.reject.duplicate": "It is a duplicate",
"bandeja.reject.other": "Another reason",
"bandeja.autoConfirmNote": "High confidence facturas confirm themselves after {days} days.",
"bandeja.autoConfirmLink": "Change",
"categories.alimentacion": "Food",
"categories.salud": "Health",
"categories.educacion": "Education",
"categories.vivienda": "Housing",
"categories.vestimenta": "Clothing",
"categories.esparcimiento": "Leisure",
"categories.vehiculo": "Vehicle",
"categories.familiares": "Dependants",
"categories.none": "No category",
// 9. Declarations
"decl.title": "Declarations",
"decl.generate": "Prepare {form} for {period}",
"decl.status.draft": "Draft",
"decl.status.ready": "Ready to review",
"decl.status.approved": "Approved",
"decl.f120.summary": "You sold Gs. {sales} and bought Gs. {purchases}. {result}",
"decl.f120.toPay": "You owe Gs. {amount}.",
"decl.f120.inFavor": "You carry a credit of Gs. {amount} into next month.",
"decl.f515.storyTitle": "Your {year}",
"decl.f515.capNote": "Gs. {amount} could not be deducted because of the 1% cap on RESIMPLE purchases.",
"decl.preview.draftMark": "BORRADOR",
"decl.approve": "Approve",
"decl.approve.confirmTitle": "Approve this declaration?",
"decl.approve.confirmBody": "You are about to approve your {form} for {period} for Gs. {amount}.",
"decl.downloadPdf": "Download PDF",
"decl.checklist.title": "File it in Marangatu",
"decl.checklist.intro": "Follow these steps with your Marangatu password. The values are ready to copy.",
"decl.checklist.copyValue": "Copy value",
"decl.checklist.done": "I already filed it",
"decl.filed.title": "Declaration filed ✓",
"decl.filed.body": "We will remind you before the payment date. Nice work.",
"decl.streak": "{count} periods in a row up to date 🔥",
// 10. Documents, deadlines, profile
"docs.title": "Documents",
"docs.filter.month": "Month",
"docs.filter.category": "Category",
"docs.detail.trail": "Change history",
"vto.title": "Due dates",
"vto.explainer": "Because your RUC ends in {digit}, your vencimientos fall on day {day} of every month.",
"profile.title": "Profile",
"profile.myData.title": "Your data",
"profile.myData.body": "This is everything we store about you.",
"profile.myData.export": "Download my data",
"profile.myData.delete": "Delete my account",
"profile.myData.deleteWarn": "Everything goes: your facturas, your declarations and your account. There is no undo.",
"profile.consent.revoke": "Revoke consent",
"notif.digestHour": "Daily summary time",
// 11. Notifications (templates)
"push.digest": "{bandejaCount} facturas to review · next vencimiento {date}",
"push.deadline.t2": "Your {form} is due the day after tomorrow: Gs. {amount}",
"push.deadline.t0": "Your {form} is due today: Gs. {amount}",
"push.declReady": "Your {form} for {period} is ready to review",
"push.savings": "We found Gs. {amount} of new credit this month",
"email.subject.deadline": "Your {form} is due on {date}",
"telegram.linked": "Done, I will ping you here. Only what matters.",
// Error envelope (es.extra.ts)
"error.validation_error": "Check what you entered, something does not add up.",
"error.unauthorized": "You need to sign in to see this.",
"error.forbidden": "You do not have permission to do this.",
"error.not_found": "We could not find what you were looking for.",
"error.conflict": "This changed since you last saw it. Refresh and try again.",
"error.rate_limited": "That was a lot of attempts in a row. Wait a moment and try again.",
"error.ocr_unavailable": "We cannot read photos right now. Enter the factura by hand.",
// Auth screens (es.extra.ts)
"auth.login.password": "Your password",
"auth.login.submit": "Sign in",
"auth.login.failed": "That email and password do not match. Try again.",
"auth.login.noAccount": "Do not have an account yet?",
// Chrome (es.extra.ts)
"common.language": "Language",
"common.languageEs": "Español",
"common.languageEn": "English",
// 12. Admin
"admin.users.title": "Users",
"admin.users.auditBanner": "Every access to user data is logged.",
"admin.errors.title": "Ingestion errors",
"admin.errors.resolve": "Mark resolved",
"admin.audit.title": "Audit",
"admin.audit.export": "Export CSV",
};
+26
View File
@@ -0,0 +1,26 @@
// Strings the product needs that COPY.md does not list. They follow the tone rules in
// COPY.md section 0: voseo, plain words, never blame the user, no em dashes.
// Anything COPY.md does define belongs in es.ts and must stay verbatim.
export const esExtra = {
// Error envelope. Every non-2xx response carries one of these, localized to the
// requester. `internal` reuses common.error.generic.
"error.validation_error": "Revisá los datos que cargaste, hay algo que no cierra.",
"error.unauthorized": "Necesitás iniciar sesion para ver esto.",
"error.forbidden": "No tenés permiso para hacer esto.",
"error.not_found": "No encontramos lo que buscabas.",
"error.conflict": "Esto cambio desde la ultima vez que lo viste. Actualizá y probá de nuevo.",
"error.rate_limited": "Probaste muchas veces seguidas. Esperá un momento y volvé a intentar.",
"error.ocr_unavailable": "Por ahora no podemos leer fotos. Cargá la factura a mano.",
// Auth screens
"auth.login.password": "Tu contraseña",
"auth.login.submit": "Entrar",
"auth.login.failed": "Ese email y esa contraseña no coinciden. Probá de nuevo.",
"auth.login.noAccount": "¿Todavia no tenés cuenta?",
// Chrome
"common.language": "Idioma",
"common.languageEs": "Español",
"common.languageEn": "English",
} as const satisfies Record<string, string>;
+212
View File
@@ -0,0 +1,212 @@
// Generated from docs/COPY.md, verbatim. Do not paraphrase.
// Strings the product needs that COPY.md does not define live in es.extra.ts.
// The parity test in copy-parity.test.ts re-derives this file from COPY.md and fails on drift.
export const esCopy = {
// 1. Common
"common.appName": "Impuestos",
"common.continue": "Continuar",
"common.back": "Volver",
"common.save": "Guardar",
"common.cancel": "Cancelar",
"common.confirm": "Confirmar",
"common.edit": "Editar",
"common.delete": "Eliminar",
"common.retry": "Reintentar",
"common.close": "Cerrar",
"common.loading": "Cargando...",
"common.search": "Buscar",
"common.seeDetail": "Ver detalle",
"common.optional": "(opcional)",
"common.error.generic": "Algo salio mal de nuestro lado. Probá de nuevo en un momento.",
"common.error.offline": "Sin conexion. Tus cambios se guardan y se sincronizan al volver.",
"common.status.alDia": "Al dia",
"common.status.porVencer": "Por vencer",
"common.status.enRevision": "En revision",
"common.status.atrasado": "Atrasado",
// 2. Landing
"landing.hero.title": "Tus impuestos, en piloto automatico",
"landing.hero.subtitle": "Escaneá tus facturas y nosotros armamos tus libros, tus deducciones y tus declaraciones. Nunca mas un vencimiento olvidado.",
"landing.hero.inputLabel": "Ingresá tu RUC o CI",
"landing.hero.cta": "Ver mi situacion",
"landing.value1.title": "Escaneá y listo",
"landing.value1.body": "Sacale una foto a cualquier factura. La leemos, la verificamos y la clasificamos por vos.",
"landing.value2.title": "Sabé cuanto vas a pagar, siempre",
"landing.value2.body": "Tu IVA del mes y tu IRP del año, calculados en vivo con cada factura que cargás.",
"landing.value3.title": "Declaraciones listas para presentar",
"landing.value3.body": "Tu Formulario 120 y tu 515 se arman solos. Vos solo revisás y aprobás.",
"landing.preview.title": "Esto es lo que la DNIT ya sabe de vos",
"landing.preview.deadline": "Tus vencimientos caen el dia {day} de cada mes",
"landing.preview.next": "Proximos: {d1}, {d2} y {d3}",
"landing.preview.cta": "Crear mi cuenta gratis",
"landing.invalidDoc": "Ese numero no parece valido. Revisá el digito verificador (el numero despues del guion).",
// 3. Auth
"auth.register.title": "Creá tu cuenta",
"auth.register.email": "Tu email",
"auth.register.password": "Elegí una contraseña",
"auth.login.title": "Entrá a tu cuenta",
"auth.otp.title": "Revisá tu email",
"auth.otp.body": "Te enviamos un codigo de 6 digitos a {email}.",
"auth.otp.resend": "Reenviar codigo",
"auth.logout": "Cerrar sesion",
// 4. Consent
"consent.title": "Antes de empezar, lo importante",
"consent.intro": "Guardamos tus facturas y tus datos fiscales para armar tus impuestos. Nada mas, nada menos.",
"consent.bullet1": "Tus datos son tuyos: los podes descargar o borrar cuando quieras.",
"consent.bullet2": "Nunca vendemos ni compartimos tu informacion.",
"consent.bullet3": "Todo acceso de nuestro equipo a tus datos queda registrado.",
"consent.dataProcessing": "Acepto el tratamiento de mis datos para este servicio",
"consent.notifications": "Quiero recibir avisos de vencimientos y novedades",
"consent.policyLink": "Leer la politica completa",
// 5. Profile setup
"setup.step1.title": "Confirmá tus datos",
"setup.fullName": "Nombre completo",
"setup.taxpayerKind.q": "¿Sos persona o empresa?",
"setup.taxpayerKind.ind": "Persona fisica",
"setup.taxpayerKind.com": "Empresa",
"setup.step2.title": "¿Que obligaciones tenes?",
"setup.oblig.iva.title": "IVA mensual",
"setup.oblig.iva.body": "Tengo RUC activo y facturo con IVA",
"setup.oblig.irp.title": "IRP",
"setup.oblig.irp.body": "Gano mas de Gs. 80 millones al año",
"setup.oblig.unsure": "No estoy seguro",
"setup.income.q": "¿Cuanto estimás que vas a ganar este año?",
"setup.income.help": "Sirve para proyectar tu IRP. Lo podés cambiar cuando quieras.",
"setup.dependents.title": "Tus familiares a cargo",
"setup.dependents.help": "Los gastos de tus familiares a cargo tambien pueden ser deducibles.",
"setup.dependents.add": "Agregar familiar",
"setup.dependents.skip": "Lo hago despues",
"setup.step3.title": "Avisos",
"setup.push.title": "Activá las notificaciones",
"setup.push.body": "Un aviso a tiempo vale mas que mil recargos. Te avisamos solo lo importante.",
"setup.push.cta": "Activar",
"setup.push.later": "Ahora no",
// 6. Dashboard (Mi situacion)
"home.title": "Mi situacion",
"home.iva.title": "IVA de {month}",
"home.iva.aPagar": "A pagar",
"home.iva.aFavor": "A tu favor",
"home.iva.breakdown": "Debito Gs. {debito}, credito Gs. {credito}",
"home.irp.title": "IRP proyectado {year}",
"home.irp.delta": "Bajaste Gs. {amount} este mes gracias a tus deducciones",
"home.irp.belowThreshold": "Por ahora estas debajo del minimo de Gs. 80 millones. Esto es solo informativo.",
"home.next.allClear": "Todo al dia ✓",
"home.next.upcoming": "Proximo vencimiento: {date}",
"home.next.dueSoon": "Vence tu {form} en {days} dias",
"home.next.reviewCta": "Revisar y aprobar",
"home.next.bandeja": "Tenes {count} facturas por revisar",
"home.next.bandejaCta": "Ir a la bandeja",
"home.insight.gapTitle": "Te estas perdiendo deducciones",
"home.insight.gapBody": "Casi no cargaste facturas de {category} este año, y son deducibles.",
"home.insight.monthClose": "Cerraste {month} con Gs. {savings} en deducciones nuevas.",
"home.firstRun.title": "Empecemos con tu primera factura",
"home.firstRun.body": "Escaneá cualquier factura que tengas a mano y mirá lo que pasa.",
"home.firstRun.scan": "Escanear mi primera factura",
"home.firstRun.manual": "O cargala a mano",
// 7. Scan and manual entry
"scan.fab": "Escanear",
"scan.hint": "Enfocá el QR de la factura",
"scan.noQrHint": "¿Factura sin QR? Sacale una foto igual",
"scan.upload": "Subir archivo",
"scan.processing": "Leyendo tu factura...",
"scan.verified": "Verificado ✓",
"scan.registered": "Registrada",
"scan.ocrLowConfidence": "Revisá los campos marcados, no los pudimos leer bien.",
"scan.duplicate.title": "Ya tenias esta factura",
"scan.duplicate.body": "La registramos el {date}. No se duplica nada.",
"scan.result.suggested": "Sugerimos: {category}",
"scan.result.confirm": "Confirmar",
"scan.result.changeCat": "Cambiar categoria",
"scan.result.discard": "Descartar",
"manual.title": "Cargar factura a mano",
"manual.emitterRuc": "RUC del que emitio",
"manual.emitterName": "Nombre o razon social",
"manual.total": "Total",
"manual.ivaSplit.q": "¿Todo al 10%?",
"manual.date": "Fecha",
"manual.saved": "Factura guardada ✓",
// 8. Bandeja
"bandeja.title": "Bandeja",
"bandeja.empty": "Bandeja limpia ✓",
"bandeja.emptyBody": "Cuando escanees o recibamos facturas nuevas, aparecen aca para que las confirmes.",
"bandeja.confirm": "Confirmar",
"bandeja.reject.title": "¿Por que la descartas?",
"bandeja.reject.notMine": "No es mia",
"bandeja.reject.duplicate": "Esta duplicada",
"bandeja.reject.other": "Otro motivo",
"bandeja.autoConfirmNote": "Las facturas con alta confianza se confirman solas en {days} dias.",
"bandeja.autoConfirmLink": "Cambiar",
"categories.alimentacion": "Alimentacion",
"categories.salud": "Salud",
"categories.educacion": "Educacion",
"categories.vivienda": "Vivienda",
"categories.vestimenta": "Vestimenta",
"categories.esparcimiento": "Esparcimiento",
"categories.vehiculo": "Vehiculo",
"categories.familiares": "Familiares",
"categories.none": "Sin categoria",
// 9. Declarations
"decl.title": "Declaraciones",
"decl.generate": "Preparar {form} de {period}",
"decl.status.draft": "Borrador",
"decl.status.ready": "Lista para revisar",
"decl.status.approved": "Aprobada",
"decl.f120.summary": "Vendiste Gs. {sales} y compraste Gs. {purchases}. {result}",
"decl.f120.toPay": "Te corresponde pagar Gs. {amount}.",
"decl.f120.inFavor": "Te queda un saldo a favor de Gs. {amount} para el mes que viene.",
"decl.f515.storyTitle": "Tu año {year}",
"decl.f515.capNote": "Gs. {amount} no se pudieron deducir por el tope del 1% en compras a RESIMPLE.",
"decl.preview.draftMark": "BORRADOR",
"decl.approve": "Aprobar",
"decl.approve.confirmTitle": "¿Aprobas esta declaracion?",
"decl.approve.confirmBody": "Vas a aprobar tu {form} de {period} por Gs. {amount}.",
"decl.downloadPdf": "Descargar PDF",
"decl.checklist.title": "Presentala en Marangatu",
"decl.checklist.intro": "Segui estos pasos con tu clave de Marangatu. Los valores ya estan listos para copiar.",
"decl.checklist.copyValue": "Copiar valor",
"decl.checklist.done": "Ya la presente",
"decl.filed.title": "Declaracion presentada ✓",
"decl.filed.body": "Te avisamos antes de la fecha de pago. Buen trabajo.",
"decl.streak": "{count} periodos seguidos al dia 🔥",
// 10. Documents, deadlines, profile
"docs.title": "Comprobantes",
"docs.filter.month": "Mes",
"docs.filter.category": "Categoria",
"docs.detail.trail": "Historial de cambios",
"vto.title": "Vencimientos",
"vto.explainer": "Por tu RUC terminado en {digit}, tus vencimientos caen el dia {day} de cada mes.",
"profile.title": "Perfil",
"profile.myData.title": "Tus datos",
"profile.myData.body": "Esto es todo lo que guardamos sobre vos.",
"profile.myData.export": "Descargar mis datos",
"profile.myData.delete": "Eliminar mi cuenta",
"profile.myData.deleteWarn": "Se borra todo: tus facturas, tus declaraciones y tu cuenta. No hay vuelta atras.",
"profile.consent.revoke": "Revocar consentimiento",
"notif.digestHour": "Hora del resumen diario",
// 11. Notifications (templates)
"push.digest": "{bandejaCount} facturas por revisar · proximo vencimiento {date}",
"push.deadline.t2": "Pasado mañana vence tu {form}: Gs. {amount}",
"push.deadline.t0": "Hoy vence tu {form}: Gs. {amount}",
"push.declReady": "Tu {form} de {period} esta lista para revisar",
"push.savings": "Encontramos Gs. {amount} de credito nuevo este mes",
"email.subject.deadline": "Vence tu {form} el {date}",
"telegram.linked": "Listo, te aviso por aca. Solo lo importante.",
// 12. Admin
"admin.users.title": "Usuarios",
"admin.users.auditBanner": "Todos los accesos a datos de usuarios quedan registrados.",
"admin.errors.title": "Errores de ingesta",
"admin.errors.resolve": "Marcar resuelto",
"admin.audit.title": "Auditoria",
"admin.audit.export": "Exportar CSV",
} as const satisfies Record<string, string>;
+11
View File
@@ -0,0 +1,11 @@
import { esCopy } from './es';
import { esExtra } from './es.extra';
/** The Spanish catalog: COPY.md verbatim, plus the strings COPY.md does not define. */
export const es = { ...esCopy, ...esExtra };
/** Every message key in the product. The `en` catalog is typed against this. */
export type MessageKey = keyof typeof es;
export { esCopy } from './es';
export { esExtra } from './es.extra';
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import {
formatDateLong,
formatDateShort,
formatGs,
formatGsAmount,
formatMonthName,
} from './format';
describe('formatGs', () => {
it('groups thousands with dots', () => {
expect(formatGs(1234567)).toBe('Gs. 1.234.567');
expect(formatGs(1000)).toBe('Gs. 1.000');
expect(formatGs(999)).toBe('Gs. 999');
expect(formatGs(0)).toBe('Gs. 0');
expect(formatGs(180000000)).toBe('Gs. 180.000.000');
});
it('puts the sign before the unit', () => {
expect(formatGs(-1234)).toBe('-Gs. 1.234');
});
it('exposes the bare amount for catalog strings that already say Gs.', () => {
expect(formatGsAmount(430000)).toBe('430.000');
});
it('rejects non finite values rather than rendering NaN', () => {
expect(() => formatGs(Number.NaN)).toThrow();
});
});
describe('dates', () => {
it('formats long dates per locale', () => {
expect(formatDateLong('es', '2026-09-19')).toBe('19 de septiembre');
expect(formatDateLong('en', '2026-09-19')).toBe('September 19');
});
it('formats short dates per locale', () => {
expect(formatDateShort('es', '2026-09-19')).toBe('19 sep');
expect(formatDateShort('en', '2026-09-19')).toBe('Sep 19');
});
it('does not shift the day across timezones', () => {
expect(formatDateShort('es', '2026-01-01')).toBe('1 ene');
expect(formatDateShort('es', '2026-12-31')).toBe('31 dic');
});
it('names months from a period', () => {
expect(formatMonthName('es', '2026-08')).toBe('agosto');
expect(formatMonthName('en', '2026-08')).toBe('August');
});
it('rejects malformed dates', () => {
expect(() => formatDateLong('es', '2026-9-1')).toThrow();
});
});
+69
View File
@@ -0,0 +1,69 @@
import type { Locale } from './locales';
/**
* Groups an integer with Paraguayan thousands separators: 1234567 -> "1.234.567".
* Deliberately not Intl: money formatting must be byte-identical in Node, the
* browser and tests, and it never varies by locale (FLOWS.md section 1).
*/
export function formatGsAmount(value: number): string {
if (!Number.isFinite(value)) throw new Error(`formatGsAmount: not a finite number: ${value}`);
const rounded = Math.trunc(value);
const sign = rounded < 0 ? '-' : '';
const digits = Math.abs(rounded).toString();
let grouped = '';
for (let i = 0; i < digits.length; i++) {
if (i > 0 && (digits.length - i) % 3 === 0) grouped += '.';
grouped += digits[i];
}
return sign + grouped;
}
/** The one and only way to render money: `Gs. 1.234.567`. Never localized. */
export function formatGs(value: number): string {
const amount = formatGsAmount(value);
return amount.startsWith('-') ? `-Gs. ${amount.slice(1)}` : `Gs. ${amount}`;
}
const MONTHS: Record<Locale, readonly string[]> = {
es: ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'],
en: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
};
const MONTHS_SHORT: Record<Locale, readonly string[]> = {
es: ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'],
en: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
};
/** Splits a `YYYY-MM-DD` date without going through Date, which would shift by timezone. */
function parts(isoDate: string): { year: number; month: number; day: number } {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
if (!match) throw new Error(`expected a YYYY-MM-DD date, got: ${isoDate}`);
return { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) };
}
function monthName(locale: Locale, month: number, list: Record<Locale, readonly string[]>): string {
const name = list[locale][month - 1];
if (name === undefined) throw new Error(`month out of range: ${month}`);
return name;
}
/** es: "19 de septiembre". en: "September 19". */
export function formatDateLong(locale: Locale, isoDate: string): string {
const { month, day } = parts(isoDate);
const name = monthName(locale, month, MONTHS);
return locale === 'es' ? `${day} de ${name}` : `${name} ${day}`;
}
/** es: "19 sep". en: "Sep 19". */
export function formatDateShort(locale: Locale, isoDate: string): string {
const { month, day } = parts(isoDate);
const name = monthName(locale, month, MONTHS_SHORT);
return locale === 'es' ? `${day} ${name}` : `${name} ${day}`;
}
/** es: "septiembre". en: "September". For `{month}` params in the catalogs. */
export function formatMonthName(locale: Locale, period: string): string {
const match = /^(\d{4})-(\d{2})$/.exec(period);
if (!match) throw new Error(`expected a YYYY-MM period, got: ${period}`);
return monthName(locale, Number(match[2]), MONTHS);
}
+26
View File
@@ -0,0 +1,26 @@
export { es, esCopy, esExtra, type MessageKey } from './catalogs/index';
export { en } from './catalogs/en';
export {
catalogs,
t,
unflatten,
resolveKey,
NAMESPACE_KEYS,
NAMESPACE_LEAF,
type Catalog,
type MessageParams,
} from './t';
export {
SUPPORTED_LOCALES,
DEFAULT_LOCALE,
isLocale,
localeFromAcceptLanguage,
type Locale,
} from './locales';
export {
formatGs,
formatGsAmount,
formatDateLong,
formatDateShort,
formatMonthName,
} from './format';
+33
View File
@@ -0,0 +1,33 @@
/** Adding a locale: add the code here and add one catalog file. Nothing else. */
export const SUPPORTED_LOCALES = ['es', 'en'] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'es';
export function isLocale(value: unknown): value is Locale {
return typeof value === 'string' && (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
/**
* Picks a locale from an `Accept-Language` header. Used by the API only as the
* last fallback: a stored `profiles.locale` always wins when the user is known.
*/
export function localeFromAcceptLanguage(header: string | null | undefined): Locale {
if (!header) return DEFAULT_LOCALE;
const ranked = header
.split(',')
.map((part) => {
const [tag = '', ...rest] = part.trim().split(';');
const q = rest.find((p) => p.trim().startsWith('q='))?.split('=')[1];
return { tag: tag.trim().toLowerCase(), q: q ? Number(q) : 1 };
})
.filter((entry) => entry.tag.length > 0 && Number.isFinite(entry.q))
.sort((a, b) => b.q - a.q);
for (const { tag } of ranked) {
const base = tag.split('-')[0];
if (isLocale(base)) return base;
}
return DEFAULT_LOCALE;
}
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { localeFromAcceptLanguage } from './locales';
import { t, unflatten } from './t';
describe('t', () => {
it('returns the string for the locale', () => {
expect(t('es', 'bandeja.empty')).toBe('Bandeja limpia ✓');
expect(t('en', 'bandeja.empty')).toBe('Inbox clear ✓');
});
it('interpolates params', () => {
expect(t('es', 'landing.preview.deadline', { day: 19 })).toBe(
'Tus vencimientos caen el dia 19 de cada mes',
);
});
it('leaves unknown placeholders alone instead of printing undefined', () => {
expect(t('es', 'auth.otp.body', {})).toContain('{email}');
});
});
describe('unflatten', () => {
it('nests dotted keys for next-intl', () => {
const nested = unflatten({ 'a.b': 'x', 'a.c': 'y' } as never) as {
a: { b: string; c: string };
};
expect(nested.a).toEqual({ b: 'x', c: 'y' });
});
});
describe('localeFromAcceptLanguage', () => {
it('picks the highest ranked supported locale', () => {
expect(localeFromAcceptLanguage('en-US,en;q=0.9,es;q=0.8')).toBe('en');
expect(localeFromAcceptLanguage('pt-BR,pt;q=0.9,es;q=0.7')).toBe('es');
});
it('falls back to es', () => {
expect(localeFromAcceptLanguage('pt-BR')).toBe('es');
expect(localeFromAcceptLanguage(null)).toBe('es');
});
});
+67
View File
@@ -0,0 +1,67 @@
import { en } from './catalogs/en';
import { es, type MessageKey } from './catalogs/index';
import { DEFAULT_LOCALE, type Locale } from './locales';
export type Catalog = Record<MessageKey, string>;
export const catalogs: Record<Locale, Catalog> = { es, en };
export type MessageParams = Record<string, string | number>;
/**
* Server-side translator. The web app uses next-intl over the same catalogs;
* the API uses this directly for error envelopes, notifications and emails.
*/
export function t(locale: Locale, key: MessageKey, params?: MessageParams): string {
const template = catalogs[locale][key] ?? catalogs[DEFAULT_LOCALE][key];
return interpolate(template, params);
}
function interpolate(template: string, params?: MessageParams): string {
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (whole, name: string) => {
const value = params[name];
return value === undefined ? whole : String(value);
});
}
/**
* Reserved child for a key that is both a message and a namespace. COPY.md defines
* `decl.approve` (the button) alongside `decl.approve.confirmTitle`, which a nested
* tree cannot express directly, so the message moves to `decl.approve._`.
* Callers never see this: `resolveKey` rewrites the key for them.
*/
export const NAMESPACE_LEAF = '_';
/** Keys that are both a message and a namespace, computed from the catalog itself. */
export const NAMESPACE_KEYS: ReadonlySet<string> = new Set(
Object.keys(es).filter((key) => Object.keys(es).some((other) => other.startsWith(`${key}.`))),
);
/** Maps a COPY.md key onto the path it occupies in the nested tree next-intl walks. */
export function resolveKey(key: string): string {
return NAMESPACE_KEYS.has(key) ? `${key}.${NAMESPACE_LEAF}` : key;
}
/**
* Turns the flat catalog into the nested shape next-intl expects.
* Catalogs stay flat so they map key for key onto COPY.md.
*/
export function unflatten(catalog: Catalog): Record<string, unknown> {
const root: Record<string, unknown> = {};
for (const [path, value] of Object.entries(catalog)) {
const segments = resolveKey(path).split('.');
let node = root;
for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i] as string;
const next = node[segment];
if (next === undefined) node[segment] = {};
else if (typeof next !== 'object') {
throw new Error(`catalog key collision at "${segments.slice(0, i + 1).join('.')}"`);
}
node = node[segment] as Record<string, unknown>;
}
node[segments[segments.length - 1] as string] = value;
}
return root;
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*.ts"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@impuestos/rules",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
}
}
+5
View File
@@ -0,0 +1,5 @@
/**
* Pure tax logic per RULES.md. Stamped onto every classification and declaration
* so recomputation is always traceable to the rule set that produced it.
*/
export const RULES_VERSION = '0.1.0';
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src/**/*.ts"]
}