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>
147 lines
6.0 KiB
Markdown
147 lines
6.0 KiB
Markdown
# Impuestos
|
|
|
|
Tax automation for Paraguayan taxpayers: scan comprobantes, classify them for IVA credit
|
|
and IRP deductions, watch a live tax position, and get Formulario 120 and 515 pre-filled
|
|
and ready to file yourself.
|
|
|
|
Working name. See `docs/` for the specifications, `DECISIONS.md` for choices made along the
|
|
way and the gaps that still need answers.
|
|
|
|
> **Status: phase 0 of 8.** Foundation only. There is no tax logic, no ingestion and no
|
|
> dashboard yet: `packages/rules` is waiting on `docs/RULES.md`, which is not in the repo.
|
|
|
|
---
|
|
|
|
## Quick start
|
|
|
|
```bash
|
|
pnpm install
|
|
cp apps/api/.env.example apps/api/.env
|
|
cp apps/web/.env.example apps/web/.env
|
|
pnpm db:migrate
|
|
pnpm db:seed
|
|
pnpm dev
|
|
```
|
|
|
|
The web app is on http://localhost:3000, the API on http://localhost:4000. `db:seed`
|
|
prints the development sign in details for the four demo accounts.
|
|
|
|
Or the whole thing in containers, with nothing installed but Docker:
|
|
|
|
```bash
|
|
docker compose up
|
|
```
|
|
|
|
That builds both images, migrates, seeds and serves http://localhost:3000. Only the web
|
|
port is published: the browser talks to one origin and `/api` is forwarded internally.
|
|
|
|
## Architecture
|
|
|
|
```
|
|
Browser ──► apps/web (Next.js, stateless)
|
|
│ app/api/[...path]/route.ts ──► API_INTERNAL_URL
|
|
▼
|
|
apps/api (Hono on Node, stateless)
|
|
├── Better Auth (sessions in the database)
|
|
├── Kysely ──► SQLite or Postgres
|
|
├── StorageDriver ──► local disk or S3 compatible
|
|
├── Jobs (portable table and poller)
|
|
└── Notifications (push, email, Telegram)
|
|
```
|
|
|
|
`apps/api` is the only process that touches the database or storage. `apps/web` renders UI,
|
|
holds no secrets beyond `API_INTERNAL_URL`, and reaches the API only through the typed
|
|
client in `packages/contracts`. Because the browser only ever sees the web origin, cookies
|
|
are first party and there is no CORS configuration anywhere.
|
|
|
|
| Path | What lives there |
|
|
|---|---|
|
|
| `apps/api` | HTTP, auth, database, jobs, storage, notifications |
|
|
| `apps/web` | Every screen, PWA, no database access at all |
|
|
| `packages/rules` | Tax math, form definitions, calendario. Pure, no I/O |
|
|
| `packages/contracts` | Zod schemas and the typed API client |
|
|
| `packages/i18n` | Message catalogs, `t()`, `formatGs`, date formatting |
|
|
| `deploy/` | Compose stack and k8s manifests |
|
|
| `docs/` | The specifications this is built from |
|
|
|
|
Module boundaries are enforced by eslint, not convention: `packages/*` cannot import from
|
|
`apps/*`, only `modules/pii` reaches the PII tables, the web app cannot import a database
|
|
driver, and user facing strings cannot be written inline in JSX.
|
|
|
|
## Commands
|
|
|
|
| Command | What it does |
|
|
|---|---|
|
|
| `pnpm dev` | Both apps in watch mode |
|
|
| `pnpm test` | Vitest across the workspace |
|
|
| `pnpm test:e2e` | Playwright against a running stack (`E2E_BASE_URL` to point it) |
|
|
| `pnpm typecheck` | `tsc --noEmit` in every package |
|
|
| `pnpm lint` | eslint, including the module boundary and inline copy rules |
|
|
| `pnpm db:migrate` | Auth tables, then our migrations |
|
|
| `pnpm db:seed` | Demo accounts, idempotent |
|
|
|
|
## Configuration
|
|
|
|
Every variable is documented in `apps/api/.env.example` and `apps/web/.env.example`, and
|
|
validated with Zod at boot: a bad value stops the process with a message naming the
|
|
variable and the problem. A test asserts `.env.example` and the schema never drift apart.
|
|
|
|
The two that decide everything else:
|
|
|
|
- **`DATABASE_URL`** picks the dialect. `sqlite:./data/app.db` or `postgres://...`. Nothing
|
|
else selects it, and the same migrations run on both.
|
|
- **`STORAGE_DRIVER`** picks the file backend: `local` or `s3`. `s3` additionally requires
|
|
a bucket, a region and both keys, checked at boot.
|
|
|
|
## Deployment modes
|
|
|
|
| Mode | Database | Storage | api replicas | Worker |
|
|
|---|---|---|---|---|
|
|
| Combined (one VPS) | SQLite | local volume | 1 | inline |
|
|
| Split small | SQLite | shared volume | 1 | inline |
|
|
| Scaled (k8s) | Postgres | S3 | N | 1+ dedicated, `JOBS_INLINE=false` |
|
|
|
|
Both apps are stateless: no in process state that breaks with N replicas, no local writes
|
|
outside the storage driver, `/healthz` and `/readyz` on both, and SIGTERM fails readiness
|
|
first, then drains in flight requests for up to 25 seconds before closing the pool.
|
|
|
|
Two things bound how far the combined mode scales, and both fail loudly rather than
|
|
quietly corrupting: SQLite has a single writer, so the API refuses `JOBS_INLINE=false` and
|
|
logs a scaling notice at boot; the local storage driver needs one shared volume across
|
|
every replica, which it also says at boot. Scaling past one API replica means Postgres and
|
|
S3. The k8s manifests assume both.
|
|
|
|
Rate limiting is in memory and therefore per replica. At this scale that is deliberate;
|
|
the limiter is behind an interface for when it is not.
|
|
|
|
## Adding a locale
|
|
|
|
1. Add the code to `SUPPORTED_LOCALES` in `packages/i18n/src/locales.ts`.
|
|
2. Add one catalog file next to `es.ts` and `en.ts`, typed as `Record<MessageKey, string>`,
|
|
so the compiler lists anything you missed.
|
|
|
|
That is the whole change. Routing, the switcher, the API's notification and error
|
|
localization and the date formatting all read from that array. `formatGs` never localizes:
|
|
money is always `Gs. 1.234.567`. Official form previews and PDFs stay Spanish in every
|
|
locale, because they mirror DNIT forms.
|
|
|
|
## Adding a form version
|
|
|
|
Form definitions live in `packages/rules` and every declaration stores the `rulesVersion`
|
|
that produced it, so an old declaration always renders with the rules it was computed
|
|
under. Bump `RULES_VERSION`, add the new definition beside the old one, and leave the old
|
|
one in place. (Fully specified once `docs/RULES.md` lands.)
|
|
|
|
## Testing
|
|
|
|
Vitest covers the rules package, the API services against SQLite in memory, and catalog
|
|
parity. Playwright covers the golden paths against a running stack. CI runs the suite
|
|
against both SQLite and Postgres.
|
|
|
|
`pnpm test:e2e` does not start anything: bring the stack up first, then point it at the
|
|
right origin.
|
|
|
|
```bash
|
|
E2E_BASE_URL=http://localhost:3000 pnpm test:e2e
|
|
```
|