GSAP carries the counter roll-ups, the bandeja card physics, the dialog transitions and the three success moments FLOWS.md allows. Every one of them checks prefers-reduced-motion first and does nothing when it is set. boneyard and canvas-ui are not what SPEC.md's stack table says they are: on npm the names belong to two abandoned projects that do neither job. The skeletons were already ours; the two canvas spots are now sixty lines each with no dependency. DECISIONS.md records the substitution. The app installs, keeps a scan taken with no network in IndexedDB and sends it when there is one, falls back to a page that explains itself, and can push a deadline notice. Reading the log of what is queued is the source of truth, so the notice clears when the capture actually lands. The CSP now allows scripts by per-request nonce rather than by 'unsafe-inline'. That forced /offline to render per request: a prerendered page carries a build-time nonce no live policy matches, so its scripts were blocked and it never hydrated. Two crashes fixed on the way. web-push throws on a VAPID subject that is not https: or mailto:, and the code handed it APP_PUBLIC_URL, so any machine with push keys died at boot; a misconfigured optional channel now switches itself off and says why. And a subscription the push service answers 410 for is deleted rather than retried forever. Lighthouse on the production build: accessibility 100, best practices 96, SEO 100, performance 73. The performance number is not trustworthy on this machine and DECISIONS.md says why; total blocking time did fall from 17.6s to 1.7s once the hero canvas stopped drawing at full resolution every frame and the landing page stopped importing GSAP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
227 lines
10 KiB
Markdown
227 lines
10 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 7 of 8.** The whole taxpayer path works: scan a comprobante, confirm it,
|
|
> watch the position move, and take the resulting Formulario 120 or 515 from review to
|
|
> approved to a PDF you file yourself in Marangatu. Staff have a console: look an account
|
|
> up, work the ingestion error queue, read and export the audit log. It installs to a home
|
|
> screen, keeps a capture taken with no signal and sends it later, and can send a push when
|
|
> a deadline is close. The scale-out work is the last phase.
|
|
|
|
---
|
|
|
|
## 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:3005, the API on http://localhost:4000. `db:seed`
|
|
prints the development sign in details for the four demo accounts.
|
|
|
|
Two of those accounts reach the staff console at `/es/usuarios`, `/es/errores` and
|
|
`/es/auditoria`: `staff@demo.local` can search accounts, work the error queue and read the
|
|
audit log, and `superadmin@demo.local` can also change roles. Everyone else gets a plain
|
|
"team only" page and a 403 from the API.
|
|
|
|
The web port lives in `apps/web/.env` and `apps/api/.env` has to name the same one in
|
|
`APP_PUBLIC_URL` and `BETTER_AUTH_URL`. Auth checks the request Origin, so a mismatch
|
|
fails sign in with a 403 that looks nothing like a port problem. `localhost` and
|
|
`127.0.0.1` count as different origins too.
|
|
|
|
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:coverage` | The tax rules with coverage, which fails below 100% |
|
|
| `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 and their comprobantes, idempotent |
|
|
| `pnpm db:reset` | Drops the local SQLite file and rebuilds it. Destructive, SQLite only |
|
|
| `pnpm --filter @impuestos/api fixtures` | Redraws the scan fixtures under `/fixtures` |
|
|
|
|
## 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.
|
|
|
|
## Installing it, offline and push
|
|
|
|
`app/manifest.ts` and the icons in `apps/web/public` make the web app installable; the
|
|
icons are generated from one SVG by `apps/web/scripts/generate-icons.mjs` and committed, so
|
|
a build never needs a browser.
|
|
|
|
The service worker (`apps/web/src/lib/service-worker.js`) caches the app shell and the
|
|
`/offline` page and nothing else. No API response is cached: a tax figure that is quietly
|
|
out of date is worse than one that is honestly missing.
|
|
|
|
A scan taken with no network is stored whole in IndexedDB and sent when there is one. The
|
|
chip in the shell says so and clears itself.
|
|
|
|
Push needs VAPID keys on the API:
|
|
|
|
```bash
|
|
npx web-push generate-vapid-keys
|
|
```
|
|
|
|
Put them in `apps/api/.env` as `PUSH_VAPID_PUBLIC_KEY` and `PUSH_VAPID_PRIVATE_KEY`, plus
|
|
`PUSH_VAPID_SUBJECT` (an `https:` or `mailto:` URL) unless `APP_PUBLIC_URL` is already
|
|
https. Without keys the offer is hidden everywhere; with keys and no usable subject, push
|
|
switches itself off and logs why rather than taking the API down.
|
|
|
|
## 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.
|
|
|
|
## The tax rules
|
|
|
|
Everything that decides a number lives in `packages/rules` and nothing there does I/O, so
|
|
it is all directly testable. `docs/RULES.md` is authoritative for it and the module names
|
|
follow that document's sections: `ruc.ts`, `calendario.ts`, `cdc.ts`, `classification.ts`,
|
|
`f120.ts`, `f515.ts`, `projections.ts`.
|
|
|
|
Three rules hold the package together:
|
|
|
|
- **No floats in a money path.** Money is a branded `Pyg` of whole guaranies, percentages
|
|
go through `percentOf`, which is integer arithmetic rounded half up, and `pyg()` throws
|
|
on anything that is not a safe integer.
|
|
- **Never guess a tax rule.** Anything `docs/RULES.md` does not state carries a
|
|
`TODO-TAX-VERIFY` comment and a test that pins today's behaviour, so verifying it later
|
|
is a red/green diff. `DECISIONS.md` has the register of all six.
|
|
- **Data, not code.** The holiday table, the keyword to category map and the form
|
|
definitions are plain data files. Extending them for a new year or a new keyword is not
|
|
a code change.
|
|
|
|
Coverage is enforced at 100%: `pnpm test:coverage` fails below it.
|
|
|
|
## Adding a form version
|
|
|
|
Form definitions live in `packages/rules/src/forms` and every declaration stores the
|
|
`rulesVersion` that produced it, so an old declaration always renders with the rules it was
|
|
computed under. Add `f120.v2.ts` beside `f120.v1.ts`, bump `RULES_VERSION`, and leave the
|
|
old definition in place: it is what old declarations still render through.
|
|
|
|
## 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. The suite signs in as the demo accounts and confirms, rejects and scans
|
|
against their data, so it changes the state it runs in. Reset before a run, with the stack
|
|
stopped, or the second run has a different bandeja than the first:
|
|
|
|
```bash
|
|
pnpm db:reset
|
|
```
|
|
|
|
Then start the stack and run it:
|
|
|
|
```bash
|
|
E2E_BASE_URL=http://localhost:3005 pnpm test:e2e
|
|
```
|
|
|
|
A dev server compiles each route the first time it is asked for, which is slow enough to
|
|
push a sign in past the five seconds Playwright waits on a navigation. `e2e/global-setup.ts`
|
|
asks for every screen once before the suite starts, so the cost is paid before the first
|
|
test rather than by it. Against a heavily loaded dev server, one worker is still steadier
|
|
than two:
|
|
|
|
```bash
|
|
E2E_BASE_URL=http://localhost:3005 npx playwright test --workers=1
|
|
```
|