phase-1: packages/rules complete, 100% covered

Every algorithm in docs/RULES.md, implemented exactly as written: the RUC
check digit, the calendario perpetuo with weekend and holiday roll forward,
CDC and KUDE QR parsing, keyword classification, computeF120, computeF515 and
the dashboard projections, plus the two form definitions.

Both worked examples reproduce verbatim on the first implementation: F120 at
Gs. 277.273 to pay with the Gs. 350.000 flip case, F515 at Gs. 11.290.000 on a
5,65% effective rate. 162 tests over the package, coverage enforced at 100%
statements, branches, functions and lines; the only exclusions are three
bounded-loop guards marked v8 ignore with a comment saying why.

No tax rule was invented. All six TODO-TAX-VERIFY items from RULES.md have a
test pinning today's behaviour and a row in the DECISIONS.md register, so
verification later is a red/green diff. Where RULES.md was silent the choice is
marked SPEC-GAP in the code and listed too, the notable one being that
taxpayer.hasIrp gates the deduction amount.

No floats anywhere in a money path: money is a branded Pyg of whole guaranies
and percentages go through integer arithmetic rounded half up.

Also: contracts now takes IRP_CATEGORIES from rules rather than declaring the
eight strings twice; classification returns reason codes with catalog strings
in both locales, so the detail sheet localizes; apps/api/.env.example now names
the same web port as apps/web/.env.example, without which a fresh checkout
fails sign in on the origin check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-09-03 22:15:28 +00:00
co-authored by Claude Opus 5
parent ae2ea20b7e
commit 80b10c958e
45 changed files with 3317 additions and 48 deletions
+5 -2
View File
@@ -6,7 +6,9 @@ NODE_ENV=development
# Port the Hono server listens on. The web app proxies /api here.
PORT=4000
# User facing origin. Used for links in emails, push payloads and Telegram messages.
APP_PUBLIC_URL=http://localhost:3000
# Must name the same port as apps/web/.env PORT, or better-auth rejects the sign in
# request as a foreign origin.
APP_PUBLIC_URL=http://localhost:3005
# server = serves HTTP. worker = runs the job poller and sweeps, serves only /healthz.
ROLE=server
@@ -24,7 +26,8 @@ DATABASE_URL=sqlite:./data/app.db
# Signing key for sessions. At least 32 characters. Generate: openssl rand -base64 32
BETTER_AUTH_SECRET=change-me-to-at-least-32-characters-long
# Public origin cookies are issued for. Auth routes are proxied, so this is the web origin.
BETTER_AUTH_URL=http://localhost:3000
# Keep in step with APP_PUBLIC_URL and apps/web/.env PORT.
BETTER_AUTH_URL=http://localhost:3005
# local | s3. local needs one shared volume across replicas; s3 is required to scale out.
STORAGE_DRIVER=local
+6
View File
@@ -1,5 +1,11 @@
# apps/web environment. The web app holds no secrets: it renders UI and proxies /api.
# Port the Next server listens on for `pnpm dev` and `pnpm start`. Read from this file by
# the dev/start scripts; a real environment variable wins over it. Containers get the port
# from the image and the compose WEB_PORT variable instead, so this is a no-op there.
# Change it and apps/api/.env APP_PUBLIC_URL and BETTER_AUTH_URL must name the same port.
PORT=3005
# Where the API can be reached from the web container. Used only server side, by the
# Next rewrite. In k8s this is the api Service DNS name, for example http://api:4000.
API_INTERNAL_URL=http://localhost:4000
+1 -1
View File
@@ -4,7 +4,7 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
reactStrictMode: true,
// The workspace packages ship TypeScript source with no build step.
transpilePackages: ['@impuestos/contracts', '@impuestos/i18n'],
transpilePackages: ['@impuestos/contracts', '@impuestos/i18n', '@impuestos/rules'],
output: 'standalone',
// /api is proxied at runtime by app/api/[...path]/route.ts rather than by a rewrite,
// so API_INTERNAL_URL stays a runtime setting. See the comment in that file.
+2 -2
View File
@@ -4,9 +4,9 @@
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --port 3000",
"dev": "node scripts/next-with-env.mjs dev",
"build": "next build",
"start": "next start --port 3000",
"start": "node scripts/next-with-env.mjs start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
+25
View File
@@ -0,0 +1,25 @@
// Runs the Next CLI with apps/web/.env already in process.env.
//
// Next loads .env itself, but only after its CLI has parsed arguments, and the port is one
// of those arguments (`-p`, defaulting to the PORT environment variable). PORT from the file
// would therefore arrive too late. Passing `--env-file` to node instead is not an option:
// the dev server re-execs itself with the parent's execArgv in NODE_OPTIONS, where node
// refuses that flag. Loading the file here and importing the CLI in this process avoids both.
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseEnv } from 'node:util';
// A real environment variable always wins over the file, matching apps/api/src/lib/env.ts:
// process.loadEnvFile overwrites process.env, which would let a stale checked out `.env`
// beat the values a container or a one off command passed in.
const envFile = join(dirname(dirname(fileURLToPath(import.meta.url))), '.env');
if (existsSync(envFile)) {
for (const [key, value] of Object.entries(parseEnv(readFileSync(envFile, 'utf8')))) {
if (process.env[key] === undefined && typeof value === 'string') process.env[key] = value;
}
}
// The CLI parses process.argv, which still carries the subcommand this was invoked with.
await import('next/dist/bin/next');