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,12 @@
|
|||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
**/.next
|
||||||
|
**/dist
|
||||||
|
**/data
|
||||||
|
**/coverage
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
**/.env
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
docs
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
coverage/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
apps/*/data/
|
||||||
|
data/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
.DS_Store
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
# DECISIONS
|
||||||
|
|
||||||
|
One entry per decision that is not already obvious from the specs. Newest phase last.
|
||||||
|
|
||||||
|
Markers used in the code:
|
||||||
|
- `// SPEC-GAP:` the specs did not settle this and a choice was made here.
|
||||||
|
- `// TODO-TAX-VERIFY:` a tax rule that RULES.md does not state. Never invented, always flagged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Blocking gaps in the source material
|
||||||
|
|
||||||
|
### RULES.md is missing
|
||||||
|
`docs/` ships SPEC.md, FLOWS.md, COPY.md and CONTRACTS.md. RULES.md, which the prompt
|
||||||
|
names as authoritative for every tax rule, is not present. Phase 1 is entirely RULES.md
|
||||||
|
and phases 4 and 5 depend on it.
|
||||||
|
|
||||||
|
Consequence for phase 0: `packages/rules` exists and exports only `RULES_VERSION`.
|
||||||
|
Nothing in this phase computes a tax number, a check digit or a deadline, so nothing was
|
||||||
|
invented. The seed deliberately stops short of profiles for the same reason: CONTRACTS.md
|
||||||
|
section 4 asks for RUC base `4123456` "with computed DV" and a `deadlineDigit`, both of
|
||||||
|
which are RULES.md algorithms. Seeding accounts only keeps phase 0 honest.
|
||||||
|
|
||||||
|
**Needed before phase 1 starts.**
|
||||||
|
|
||||||
|
### `boneyard` and `canvas-ui` are not the packages the prompt means
|
||||||
|
Both names resolve on npm to unrelated projects: `boneyard@0.1.4` is a 2015 Backbone
|
||||||
|
"architectural toolkit", `canvas-ui@0.2.3` is a Mesosphere Bootstrap theme. Neither does
|
||||||
|
skeleton loading or canvas effects. Neither is needed before phase 7.
|
||||||
|
|
||||||
|
Plan unless corrected: keep the *behaviour* the prompt specifies (skeletons on every
|
||||||
|
content load and never a spinner; canvas effects in exactly two places, degrading
|
||||||
|
gracefully) behind a single `<Skeleton name>` component and a single effect component, so
|
||||||
|
swapping in the real library later is a one file change.
|
||||||
|
|
||||||
|
**Please confirm the intended packages before phase 7.**
|
||||||
|
|
||||||
|
### Neither named skill is installed
|
||||||
|
`ponytail` and `ui-ux-pro-max-skill` are not available in this environment. Their stated
|
||||||
|
intent was applied by hand: nothing speculative, no unused configuration, and FLOWS.md
|
||||||
|
section 1 as the design constraint.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0
|
||||||
|
|
||||||
|
### Migrations: better-auth generates its own four tables, we own the rest
|
||||||
|
`src/db/migrator.ts` runs two ordered steps: better-auth's `getMigrations()` creates and
|
||||||
|
updates `user`, `session`, `account` and `verification`, then the Kysely migrator applies
|
||||||
|
`src/db/migrations`. Delegating the auth tables keeps them in step with the installed
|
||||||
|
better-auth version and emits correct DDL for both dialects, so no hand written dialect
|
||||||
|
SQL was needed for them. Upgrading better-auth in a way that adds a column means adding a
|
||||||
|
migration that calls the same generator again.
|
||||||
|
|
||||||
|
### The whole schema ships in migration `001_core`, not phase by phase
|
||||||
|
Every table in SPEC.md section 5 is created now. The schema is fully specified and stable;
|
||||||
|
splitting it across phases would produce a pile of migration files and no benefit before
|
||||||
|
release. Later phases add modules on top, not tables.
|
||||||
|
|
||||||
|
### Timestamps and money are portable by construction
|
||||||
|
Timestamps are ISO-8601 text and dates are `YYYY-MM-DD` text in both dialects: they sort
|
||||||
|
chronologically as strings, so no dialect specific date type or comparison is needed
|
||||||
|
anywhere. Money is `bigint`, because guaranies pass int4 at about Gs. 2.100.000.000, and
|
||||||
|
`apps/api/src/db/postgres.ts` registers an int8 parser that returns a number and throws
|
||||||
|
outside the safe integer range.
|
||||||
|
|
||||||
|
### `/api` is proxied by a route handler, not a Next rewrite
|
||||||
|
SPEC-GAP against SPEC.md section 2, which specifies Next rewrites. Next bakes rewrite
|
||||||
|
destinations into the build manifest, so `API_INTERNAL_URL` would become a build time
|
||||||
|
value and one image could not serve both compose and k8s. `apps/web/app/api/[...path]/route.ts`
|
||||||
|
forwards at request time instead. The single origin model is unchanged: the browser only
|
||||||
|
ever sees the web origin, cookies stay first party, and there is still no CORS anywhere.
|
||||||
|
|
||||||
|
### Workspace packages ship TypeScript source with no build step
|
||||||
|
`packages/*` have no `dist`. The web app lists them in `transpilePackages` and tsup bundles
|
||||||
|
them into the API. Their relative imports are extensionless, because Turbopack does not
|
||||||
|
rewrite a `.js` specifier onto a `.ts` source file.
|
||||||
|
|
||||||
|
### The es catalog is split from the strings COPY.md does not define
|
||||||
|
`catalogs/es.ts` is generated from COPY.md and is verbatim; `copy-parity.test.ts` re-derives
|
||||||
|
it from `docs/COPY.md` on every run and fails on any drift, in either direction. Strings the
|
||||||
|
product needs that COPY.md does not list (the seven error envelope messages, three sign in
|
||||||
|
labels, the language switcher) live in `catalogs/es.extra.ts` and follow the tone rules in
|
||||||
|
COPY.md section 0. `es` is the merge of the two.
|
||||||
|
|
||||||
|
### `decl.approve` is both a message and a namespace
|
||||||
|
COPY.md defines `decl.approve` (the button) alongside `decl.approve.confirmTitle`. A nested
|
||||||
|
message tree cannot hold both, and next-intl walks a nested tree. `unflatten` moves such a
|
||||||
|
message to a reserved `_` child and `resolveKey` maps the key for callers, so components
|
||||||
|
still address messages by their COPY.md key. `apps/web/src/i18n/t.ts` is the wrapper; it is
|
||||||
|
computed from the catalog, so a future collision is handled without another change.
|
||||||
|
|
||||||
|
### Locale negotiation on `/`
|
||||||
|
next-intl's default detection is left on: a browser asking for English lands on `/en`,
|
||||||
|
anything else falls back to `es`. The en catalog exists for expats and international users
|
||||||
|
(COPY.md section 0-EN), which is exactly the population whose browser is in English. An
|
||||||
|
explicit choice through the switcher always wins and is in the URL.
|
||||||
|
|
||||||
|
### SQLite refuses `JOBS_INLINE=false`
|
||||||
|
Implemented literally as SPEC.md section 15 instructs, which is narrower than the mode
|
||||||
|
matrix in the same section: that table allows "SQLite, 1 dedicated worker" under Split
|
||||||
|
small. Two pollers cannot be made safe against a single writer, so the boot check wins and
|
||||||
|
SQLite stays single process. Worth reconciling in SPEC.md.
|
||||||
|
|
||||||
|
### Deferred to the phase that needs them, deliberately
|
||||||
|
- `RateLimiter`: SPEC.md section 6 specifies a token bucket, but the first endpoint with a
|
||||||
|
stated limit is `GET /lookup/ruc/:number` in phase 2. better-auth's own rate limiting
|
||||||
|
covers the auth routes until then.
|
||||||
|
- DTO schemas in `packages/contracts`: enums, the error envelope, the client and
|
||||||
|
`ProfileDto` exist because phase 0 uses them. The rest arrive with their endpoints.
|
||||||
|
- Storage in `/readyz`: the check covers the database and pending migrations. The storage
|
||||||
|
driver probe is added in phase 3 with the driver.
|
||||||
|
|
||||||
|
### Smaller choices
|
||||||
|
- TypeScript 5.9, not 7.x: `typescript-eslint@8` declares `typescript <6.1.0`.
|
||||||
|
- `better-sqlite3` is kept out of `onlyBuiltDependencies`: it ships prebuilt binaries, so
|
||||||
|
letting pnpm run the implicit `node-gyp rebuild` would compile it for nothing and force a
|
||||||
|
toolchain into the image.
|
||||||
|
- The language switcher is a native `<select>`: one dependency fewer than a popover, and
|
||||||
|
the better mobile and keyboard experience for a two item choice.
|
||||||
|
- `apps/web/proxy.ts`, not `middleware.ts`: Next 16 deprecates the middleware convention.
|
||||||
|
- The dark mode palette and the `dark` variant are wired now; the toggle itself is phase 7
|
||||||
|
polish. System preference works today.
|
||||||
|
- `audit_log` is append only by construction: the module exposes no update or delete. A
|
||||||
|
database trigger was written and then removed, because it would have been the only piece
|
||||||
|
of dialect specific SQL outside the two files SPEC.md section 5 allows.
|
||||||
|
|
||||||
|
### Legal pages
|
||||||
|
`/legal/privacidad` and `/legal/terminos` do not exist yet (they belong to phase 2's
|
||||||
|
marketing routes). Per COPY.md section 13 they will ship with clearly marked placeholder
|
||||||
|
content and no generated legal text. **TODO: human written before launch.**
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# apps/api environment. Copy to .env and adjust. Every variable is validated at boot
|
||||||
|
# by src/lib/env.ts, which fails fast with the exact problem.
|
||||||
|
|
||||||
|
# development | test | production
|
||||||
|
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
|
||||||
|
|
||||||
|
# server = serves HTTP. worker = runs the job poller and sweeps, serves only /healthz.
|
||||||
|
ROLE=server
|
||||||
|
# Run the job poller inside the server process. Set false only when a dedicated
|
||||||
|
# worker exists, which requires Postgres (SQLite is single writer).
|
||||||
|
JOBS_INLINE=true
|
||||||
|
JOBS_POLL_INTERVAL_MS=2000
|
||||||
|
# A running job whose lock is older than this returns to pending, for crash recovery.
|
||||||
|
JOBS_STALE_MINUTES=10
|
||||||
|
|
||||||
|
# sqlite:./data/app.db, sqlite::memory: or postgres://user:pass@host:5432/db
|
||||||
|
# The dialect is chosen from this scheme. Nothing else selects it.
|
||||||
|
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
|
||||||
|
|
||||||
|
# local | s3. local needs one shared volume across replicas; s3 is required to scale out.
|
||||||
|
STORAGE_DRIVER=local
|
||||||
|
STORAGE_LOCAL_PATH=./data/files
|
||||||
|
# Only read when STORAGE_DRIVER=s3. Bucket, region and both keys are then required.
|
||||||
|
S3_ENDPOINT=
|
||||||
|
S3_REGION=
|
||||||
|
S3_BUCKET=
|
||||||
|
S3_ACCESS_KEY_ID=
|
||||||
|
S3_SECRET_ACCESS_KEY=
|
||||||
|
# Needed by MinIO and most non AWS S3 implementations.
|
||||||
|
S3_FORCE_PATH_STYLE=true
|
||||||
|
|
||||||
|
# Optional. Without it, scans with no QR go straight to the manual form instead of OCR.
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
OCR_MODEL=claude-sonnet-4-6
|
||||||
|
|
||||||
|
# Optional. Web push is hidden in the UI when unset. Generate: npx web-push generate-vapid-keys
|
||||||
|
PUSH_VAPID_PUBLIC_KEY=
|
||||||
|
PUSH_VAPID_PRIVATE_KEY=
|
||||||
|
|
||||||
|
# Optional. Without SMTP_HOST, verification codes and emails are logged to stdout.
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
SMTP_FROM=
|
||||||
|
|
||||||
|
# Optional. Telegram is hidden as a notification channel when unset.
|
||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
|
||||||
|
# Locale for anonymous requests. Signed in users are served their profiles.locale.
|
||||||
|
DEFAULT_LOCALE=es
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# Build stage: the whole workspace is needed because apps/api imports the packages/*
|
||||||
|
# source directly and tsup bundles it in.
|
||||||
|
FROM node:22-slim AS build
|
||||||
|
ENV PNPM_HOME=/pnpm PATH=/pnpm:$PATH
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /repo
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
|
||||||
|
COPY apps/api/package.json apps/api/
|
||||||
|
COPY apps/web/package.json apps/web/
|
||||||
|
COPY packages/contracts/package.json packages/contracts/
|
||||||
|
COPY packages/i18n/package.json packages/i18n/
|
||||||
|
COPY packages/rules/package.json packages/rules/
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY packages packages
|
||||||
|
COPY apps/api apps/api
|
||||||
|
COPY docs docs
|
||||||
|
RUN pnpm --filter @impuestos/api build
|
||||||
|
RUN pnpm --filter @impuestos/api deploy --prod --legacy /prod/api
|
||||||
|
|
||||||
|
FROM node:22-slim AS runtime
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Owns ./data, the SQLite file and the local storage driver's files.
|
||||||
|
RUN mkdir -p /app/data && chown -R node:node /app
|
||||||
|
|
||||||
|
COPY --from=build --chown=node:node /prod/api/node_modules ./node_modules
|
||||||
|
COPY --from=build --chown=node:node /repo/apps/api/dist ./dist
|
||||||
|
COPY --from=build --chown=node:node /repo/apps/api/package.json ./package.json
|
||||||
|
|
||||||
|
USER node
|
||||||
|
EXPOSE 4000
|
||||||
|
|
||||||
|
# SIGTERM is handled in src/index.ts: fail readiness, drain, close the pool, exit 0.
|
||||||
|
# No init shim, so node stays PID 1 and receives the signal directly.
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "@impuestos/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsup",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"db:migrate": "tsx src/db/migrate.cli.ts",
|
||||||
|
"db:seed": "tsx src/db/seed.cli.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/node-server": "^2.1.1",
|
||||||
|
"@impuestos/contracts": "workspace:*",
|
||||||
|
"@impuestos/i18n": "workspace:*",
|
||||||
|
"@impuestos/rules": "workspace:*",
|
||||||
|
"better-auth": "^1.7.2",
|
||||||
|
"better-sqlite3": "^13.0.3",
|
||||||
|
"hono": "^4.13.5",
|
||||||
|
"kysely": "^0.29.5",
|
||||||
|
"pg": "^8.23.0",
|
||||||
|
"uuidv7": "^1.2.1",
|
||||||
|
"zod": "^4.5.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/better-sqlite3": "^9.6.0",
|
||||||
|
"@types/node": "^26.4.1",
|
||||||
|
"@types/pg": "^8.23.1",
|
||||||
|
"tsup": "^8.5.1",
|
||||||
|
"tsx": "^4.23.13",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { betterAuth } from 'better-auth';
|
||||||
|
import { admin, emailOTP } from 'better-auth/plugins';
|
||||||
|
import { createAccessControl } from 'better-auth/plugins/access';
|
||||||
|
import { adminAc, defaultStatements, userAc } from 'better-auth/plugins/admin/access';
|
||||||
|
import type { Kysely } from 'kysely';
|
||||||
|
import type { Dialect } from '../db/index';
|
||||||
|
import type { Database } from '../db/schema';
|
||||||
|
import type { Env } from '../lib/env';
|
||||||
|
|
||||||
|
export const ROLES = ['user', 'accountant', 'staff', 'superadmin'] as const;
|
||||||
|
export type Role = (typeof ROLES)[number];
|
||||||
|
|
||||||
|
/** Roles that reach the `(admin)` area. `accountant` is dormant in v1. */
|
||||||
|
export const ADMIN_ROLES: readonly Role[] = ['staff', 'superadmin'];
|
||||||
|
|
||||||
|
const ac = createAccessControl(defaultStatements);
|
||||||
|
|
||||||
|
const roles = {
|
||||||
|
user: ac.newRole(userAc.statements),
|
||||||
|
/** Dormant in v1: the contador console is out of scope. Has no permissions yet. */
|
||||||
|
accountant: ac.newRole({}),
|
||||||
|
/** Support: can find and read users, cannot change roles or ban. */
|
||||||
|
staff: ac.newRole({ user: ['list', 'get'], session: ['list'] }),
|
||||||
|
superadmin: ac.newRole(adminAc.statements),
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AuthDeps {
|
||||||
|
db: Kysely<Database>;
|
||||||
|
dialect: Dialect;
|
||||||
|
env: Env;
|
||||||
|
/** Delivers the 6 digit verification code. Logs to stdout when SMTP is unset. */
|
||||||
|
sendOtp: (args: { email: string; otp: string; type: string }) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAuth(deps: AuthDeps) {
|
||||||
|
const { db, dialect, env } = deps;
|
||||||
|
return betterAuth({
|
||||||
|
// better-auth types its adapter against Kysely<any>; our Database interface is
|
||||||
|
// narrower, so the instance is widened here rather than loosening the app wide type.
|
||||||
|
database: { db: db as unknown as Kysely<Record<string, never>>, type: dialect },
|
||||||
|
basePath: '/api/auth',
|
||||||
|
baseURL: env.BETTER_AUTH_URL,
|
||||||
|
secret: env.BETTER_AUTH_SECRET,
|
||||||
|
trustedOrigins: [env.APP_PUBLIC_URL, env.BETTER_AUTH_URL],
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
minPasswordLength: 8,
|
||||||
|
requireEmailVerification: false,
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
expiresIn: 60 * 60 * 24 * 30,
|
||||||
|
updateAge: 60 * 60 * 24,
|
||||||
|
},
|
||||||
|
advanced: {
|
||||||
|
defaultCookieAttributes: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: env.NODE_ENV === 'production',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
admin({ ac, roles, defaultRole: 'user', adminRoles: [...ADMIN_ROLES] }),
|
||||||
|
emailOTP({
|
||||||
|
otpLength: 6,
|
||||||
|
expiresIn: 10 * 60,
|
||||||
|
sendVerificationOTP: async ({ email, otp, type }) => {
|
||||||
|
await deps.sendOtp({ email, otp, type });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Auth = ReturnType<typeof createAuth>;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Kysely } from 'kysely';
|
||||||
|
import { isPostgresUrl, isSqliteUrl } from '../lib/env';
|
||||||
|
import { createPostgresDb } from './postgres';
|
||||||
|
import type { Database } from './schema';
|
||||||
|
import { createSqliteDb } from './sqlite';
|
||||||
|
|
||||||
|
export type Dialect = 'sqlite' | 'postgres';
|
||||||
|
|
||||||
|
export interface DbHandle {
|
||||||
|
db: Kysely<Database>;
|
||||||
|
dialect: Dialect;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dialectOf(databaseUrl: string): Dialect {
|
||||||
|
if (isSqliteUrl(databaseUrl)) return 'sqlite';
|
||||||
|
if (isPostgresUrl(databaseUrl)) return 'postgres';
|
||||||
|
throw new Error(
|
||||||
|
`DATABASE_URL must start with sqlite:, file:, postgres:// or postgresql://, got: ${databaseUrl}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDb(databaseUrl: string): DbHandle {
|
||||||
|
const dialect = dialectOf(databaseUrl);
|
||||||
|
const handle = dialect === 'sqlite' ? createSqliteDb(databaseUrl) : createPostgresDb(databaseUrl);
|
||||||
|
return { ...handle, dialect };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Database } from './schema';
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createDb } from './index';
|
||||||
|
import { loadEnv } from '../lib/env';
|
||||||
|
import { migrateToLatest } from './migrator';
|
||||||
|
|
||||||
|
const env = loadEnv();
|
||||||
|
const handle = createDb(env.DATABASE_URL);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { auth, applied } = await migrateToLatest(handle, env);
|
||||||
|
console.info(`[migrate] dialect: ${handle.dialect}`);
|
||||||
|
console.info(`[migrate] better-auth tables synced: ${auth.length > 0 ? auth.join(', ') : 'none'}`);
|
||||||
|
console.info(`[migrate] migrations applied: ${applied.length > 0 ? applied.join(', ') : 'none'}`);
|
||||||
|
console.info('[migrate] up to date');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[migrate] failed:', error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import type { Kysely } from 'kysely';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every table in SPEC.md section 5 that is not owned by better-auth.
|
||||||
|
* Portable: only `text`, `integer`, `real` and `bigint` column types are used.
|
||||||
|
*/
|
||||||
|
export async function up(db: Kysely<unknown>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.createTable('profiles')
|
||||||
|
.addColumn('user_id', 'text', (c) => c.primaryKey().references('user.id').onDelete('cascade'))
|
||||||
|
.addColumn('full_name', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('doc_type', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('ruc', 'text')
|
||||||
|
.addColumn('ruc_dv', 'text')
|
||||||
|
.addColumn('ci', 'text')
|
||||||
|
.addColumn('taxpayer_kind', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('deadline_digit', 'integer', (c) => c.notNull())
|
||||||
|
.addColumn('obligations', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('irp_gross_estimate', 'bigint')
|
||||||
|
.addColumn('auto_confirm_days', 'integer', (c) => c.notNull().defaultTo(7))
|
||||||
|
.addColumn('locale', 'text', (c) => c.notNull().defaultTo('es'))
|
||||||
|
.addColumn('created_at', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('updated_at', 'text', (c) => c.notNull())
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createTable('dependents')
|
||||||
|
.addColumn('id', 'text', (c) => c.primaryKey())
|
||||||
|
.addColumn('user_id', 'text', (c) => c.notNull().references('user.id').onDelete('cascade'))
|
||||||
|
.addColumn('display_name', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('relationship', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('doc_number', 'text')
|
||||||
|
.addColumn('active', 'integer', (c) => c.notNull().defaultTo(1))
|
||||||
|
.addColumn('created_at', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('updated_at', 'text', (c) => c.notNull())
|
||||||
|
.execute();
|
||||||
|
await db.schema.createIndex('dependents_user_idx').on('dependents').column('user_id').execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createTable('consents')
|
||||||
|
.addColumn('id', 'text', (c) => c.primaryKey())
|
||||||
|
.addColumn('user_id', 'text', (c) => c.notNull().references('user.id').onDelete('cascade'))
|
||||||
|
.addColumn('kind', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('granted_at', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('revoked_at', 'text')
|
||||||
|
.addColumn('text_version', 'text', (c) => c.notNull())
|
||||||
|
.execute();
|
||||||
|
await db.schema.createIndex('consents_user_idx').on('consents').column('user_id').execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createTable('document_files')
|
||||||
|
.addColumn('id', 'text', (c) => c.primaryKey())
|
||||||
|
.addColumn('driver', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('path', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('mime', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('size', 'integer', (c) => c.notNull())
|
||||||
|
.addColumn('sha256', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('created_at', 'text', (c) => c.notNull())
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createTable('documents')
|
||||||
|
.addColumn('id', 'text', (c) => c.primaryKey())
|
||||||
|
.addColumn('user_id', 'text', (c) => c.notNull().references('user.id').onDelete('cascade'))
|
||||||
|
.addColumn('source', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('status', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('cdc', 'text')
|
||||||
|
.addColumn('qr_url', 'text')
|
||||||
|
.addColumn('doc_kind', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('direction', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('emitter_ruc', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('emitter_dv', 'text')
|
||||||
|
.addColumn('emitter_name', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('receiver_doc', 'text')
|
||||||
|
.addColumn('issue_date', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('currency', 'text', (c) => c.notNull().defaultTo('PYG'))
|
||||||
|
.addColumn('total', 'bigint', (c) => c.notNull())
|
||||||
|
.addColumn('amount_iva10', 'bigint', (c) => c.notNull().defaultTo(0))
|
||||||
|
.addColumn('amount_iva5', 'bigint', (c) => c.notNull().defaultTo(0))
|
||||||
|
.addColumn('amount_exenta', 'bigint', (c) => c.notNull().defaultTo(0))
|
||||||
|
.addColumn('iva10', 'bigint', (c) => c.notNull().defaultTo(0))
|
||||||
|
.addColumn('iva5', 'bigint', (c) => c.notNull().defaultTo(0))
|
||||||
|
.addColumn('supplier_regime_hint', 'text', (c) => c.notNull().defaultTo('unknown'))
|
||||||
|
.addColumn('verified_dnit', 'integer', (c) => c.notNull().defaultTo(0))
|
||||||
|
.addColumn('verification_status', 'text', (c) => c.notNull().defaultTo('unverified'))
|
||||||
|
.addColumn('dedupe_hash', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('file_id', 'text', (c) => c.references('document_files.id').onDelete('set null'))
|
||||||
|
.addColumn('raw_extraction', 'text')
|
||||||
|
.addColumn('created_at', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('confirmed_at', 'text')
|
||||||
|
.execute();
|
||||||
|
await db.schema
|
||||||
|
.createIndex('documents_user_dedupe_uidx')
|
||||||
|
.on('documents')
|
||||||
|
.columns(['user_id', 'dedupe_hash'])
|
||||||
|
.unique()
|
||||||
|
.execute();
|
||||||
|
await db.schema
|
||||||
|
.createIndex('documents_user_status_idx')
|
||||||
|
.on('documents')
|
||||||
|
.columns(['user_id', 'status'])
|
||||||
|
.execute();
|
||||||
|
await db.schema
|
||||||
|
.createIndex('documents_user_issue_date_idx')
|
||||||
|
.on('documents')
|
||||||
|
.columns(['user_id', 'issue_date'])
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createTable('classifications')
|
||||||
|
.addColumn('document_id', 'text', (c) =>
|
||||||
|
c.primaryKey().references('documents.id').onDelete('cascade'),
|
||||||
|
)
|
||||||
|
.addColumn('iva_credit_eligible', 'integer', (c) => c.notNull().defaultTo(0))
|
||||||
|
.addColumn('iva_credit_amount', 'bigint', (c) => c.notNull().defaultTo(0))
|
||||||
|
.addColumn('irp_category', 'text', (c) => c.notNull().defaultTo('none'))
|
||||||
|
.addColumn('irp_deductible_amount', 'bigint', (c) => c.notNull().defaultTo(0))
|
||||||
|
.addColumn('dependent_id', 'text', (c) => c.references('dependents.id').onDelete('set null'))
|
||||||
|
.addColumn('confidence', 'real', (c) => c.notNull().defaultTo(0))
|
||||||
|
.addColumn('decided_by', 'text', (c) => c.notNull().defaultTo('auto'))
|
||||||
|
.addColumn('rules_version', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('updated_at', 'text', (c) => c.notNull())
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createTable('declarations')
|
||||||
|
.addColumn('id', 'text', (c) => c.primaryKey())
|
||||||
|
.addColumn('user_id', 'text', (c) => c.notNull().references('user.id').onDelete('cascade'))
|
||||||
|
.addColumn('form_code', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('period', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('status', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('values', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('summary', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('pdf_file_id', 'text', (c) => c.references('document_files.id').onDelete('set null'))
|
||||||
|
.addColumn('rules_version', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('document_ids', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('created_at', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('approved_at', 'text')
|
||||||
|
.addColumn('filed_marked_at', 'text')
|
||||||
|
.execute();
|
||||||
|
await db.schema
|
||||||
|
.createIndex('declarations_user_form_period_uidx')
|
||||||
|
.on('declarations')
|
||||||
|
.columns(['user_id', 'form_code', 'period'])
|
||||||
|
.unique()
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createTable('jobs')
|
||||||
|
.addColumn('id', 'text', (c) => c.primaryKey())
|
||||||
|
.addColumn('type', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('payload', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('status', 'text', (c) => c.notNull().defaultTo('pending'))
|
||||||
|
.addColumn('run_at', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('attempts', 'integer', (c) => c.notNull().defaultTo(0))
|
||||||
|
.addColumn('max_attempts', 'integer', (c) => c.notNull().defaultTo(5))
|
||||||
|
.addColumn('locked_by', 'text')
|
||||||
|
.addColumn('locked_at', 'text')
|
||||||
|
.addColumn('last_error', 'text')
|
||||||
|
.addColumn('created_at', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('updated_at', 'text', (c) => c.notNull())
|
||||||
|
.execute();
|
||||||
|
await db.schema
|
||||||
|
.createIndex('jobs_status_run_at_idx')
|
||||||
|
.on('jobs')
|
||||||
|
.columns(['status', 'run_at'])
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createTable('ingest_errors')
|
||||||
|
.addColumn('id', 'text', (c) => c.primaryKey())
|
||||||
|
.addColumn('user_id', 'text', (c) => c.references('user.id').onDelete('set null'))
|
||||||
|
.addColumn('document_id', 'text', (c) => c.references('documents.id').onDelete('set null'))
|
||||||
|
.addColumn('stage', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('message', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('payload', 'text')
|
||||||
|
.addColumn('status', 'text', (c) => c.notNull().defaultTo('open'))
|
||||||
|
.addColumn('resolved_by', 'text')
|
||||||
|
.addColumn('resolved_at', 'text')
|
||||||
|
.addColumn('created_at', 'text', (c) => c.notNull())
|
||||||
|
.execute();
|
||||||
|
await db.schema
|
||||||
|
.createIndex('ingest_errors_status_stage_idx')
|
||||||
|
.on('ingest_errors')
|
||||||
|
.columns(['status', 'stage'])
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createTable('audit_log')
|
||||||
|
.addColumn('id', 'text', (c) => c.primaryKey())
|
||||||
|
.addColumn('actor_user_id', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('actor_role', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('action', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('subject_user_id', 'text')
|
||||||
|
.addColumn('resource', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('detail', 'text')
|
||||||
|
.addColumn('ip', 'text')
|
||||||
|
.addColumn('created_at', 'text', (c) => c.notNull())
|
||||||
|
.execute();
|
||||||
|
await db.schema
|
||||||
|
.createIndex('audit_log_created_at_idx')
|
||||||
|
.on('audit_log')
|
||||||
|
.column('created_at')
|
||||||
|
.execute();
|
||||||
|
await db.schema
|
||||||
|
.createIndex('audit_log_subject_idx')
|
||||||
|
.on('audit_log')
|
||||||
|
.column('subject_user_id')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createTable('notification_prefs')
|
||||||
|
.addColumn('user_id', 'text', (c) => c.primaryKey().references('user.id').onDelete('cascade'))
|
||||||
|
.addColumn('push_enabled', 'integer', (c) => c.notNull().defaultTo(0))
|
||||||
|
.addColumn('email_enabled', 'integer', (c) => c.notNull().defaultTo(1))
|
||||||
|
.addColumn('telegram_chat_id', 'text')
|
||||||
|
.addColumn('digest_hour', 'integer', (c) => c.notNull().defaultTo(9))
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createTable('push_subscriptions')
|
||||||
|
.addColumn('id', 'text', (c) => c.primaryKey())
|
||||||
|
.addColumn('user_id', 'text', (c) => c.notNull().references('user.id').onDelete('cascade'))
|
||||||
|
.addColumn('endpoint', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('keys', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('created_at', 'text', (c) => c.notNull())
|
||||||
|
.execute();
|
||||||
|
await db.schema
|
||||||
|
.createIndex('push_subscriptions_user_idx')
|
||||||
|
.on('push_subscriptions')
|
||||||
|
.column('user_id')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<unknown>): Promise<void> {
|
||||||
|
for (const table of [
|
||||||
|
'push_subscriptions',
|
||||||
|
'notification_prefs',
|
||||||
|
'audit_log',
|
||||||
|
'ingest_errors',
|
||||||
|
'jobs',
|
||||||
|
'declarations',
|
||||||
|
'classifications',
|
||||||
|
'documents',
|
||||||
|
'document_files',
|
||||||
|
'consents',
|
||||||
|
'dependents',
|
||||||
|
'profiles',
|
||||||
|
]) {
|
||||||
|
await db.schema.dropTable(table).ifExists().execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Migration, MigrationProvider } from 'kysely/migration';
|
||||||
|
import * as core from './001_core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrations are listed statically rather than read from disk: the production image
|
||||||
|
* is a single bundled file with no migrations directory to scan.
|
||||||
|
*/
|
||||||
|
const migrations: Record<string, Migration> = {
|
||||||
|
'001_core': core,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const migrationProvider: MigrationProvider = {
|
||||||
|
getMigrations: async () => migrations,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MIGRATION_NAMES = Object.keys(migrations);
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { getMigrations } from 'better-auth/db/migration';
|
||||||
|
import { Migrator, type MigrationResultSet } from 'kysely/migration';
|
||||||
|
import { createAuth } from '../auth/options';
|
||||||
|
import type { Env } from '../lib/env';
|
||||||
|
import type { DbHandle } from './index';
|
||||||
|
import { migrationProvider } from './migrations/index';
|
||||||
|
import { withMigrationLock } from './postgres';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two ordered steps:
|
||||||
|
* 1. better-auth creates and updates its own four tables. Delegating keeps the auth
|
||||||
|
* schema in step with the installed version and emits correct DDL per dialect,
|
||||||
|
* with no hand written dialect SQL here.
|
||||||
|
* 2. the Kysely migrator applies our migrations from src/db/migrations.
|
||||||
|
*/
|
||||||
|
export async function migrateToLatest(
|
||||||
|
handle: DbHandle,
|
||||||
|
env: Env,
|
||||||
|
): Promise<{ auth: string[]; applied: string[] }> {
|
||||||
|
const run = async () => {
|
||||||
|
const auth = await migrateAuthTables(handle, env);
|
||||||
|
const results = await kyselyMigrator(handle).migrateToLatest();
|
||||||
|
return { auth, applied: reportResults(results) };
|
||||||
|
};
|
||||||
|
|
||||||
|
return handle.dialect === 'postgres' ? withMigrationLock(handle.db, run) : run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateAuthTables(handle: DbHandle, env: Env): Promise<string[]> {
|
||||||
|
const auth = createAuth({
|
||||||
|
db: handle.db,
|
||||||
|
dialect: handle.dialect,
|
||||||
|
env,
|
||||||
|
sendOtp: async () => undefined,
|
||||||
|
});
|
||||||
|
const plan = await getMigrations(auth.options);
|
||||||
|
const created = (plan.toBeCreated ?? []).map((table) => table.table);
|
||||||
|
const altered = (plan.toBeAdded ?? []).map((table) => table.table);
|
||||||
|
await plan.runMigrations();
|
||||||
|
return [...new Set([...created, ...altered])];
|
||||||
|
}
|
||||||
|
|
||||||
|
function kyselyMigrator(handle: DbHandle): Migrator {
|
||||||
|
return new Migrator({ db: handle.db, provider: migrationProvider });
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportResults(results: MigrationResultSet): string[] {
|
||||||
|
if (results.error) throw results.error;
|
||||||
|
const applied: string[] = [];
|
||||||
|
for (const result of results.results ?? []) {
|
||||||
|
if (result.status === 'Success') applied.push(result.migrationName);
|
||||||
|
else if (result.status === 'Error') {
|
||||||
|
throw new Error(`migration failed: ${result.migrationName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Readiness check: are there migrations this build knows about that the database lacks? */
|
||||||
|
export async function pendingMigrations(handle: DbHandle): Promise<string[]> {
|
||||||
|
const migrations = await kyselyMigrator(handle).getMigrations();
|
||||||
|
return migrations.filter((m) => m.executedAt === undefined).map((m) => m.name);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { Kysely, PostgresDialect, sql } from 'kysely';
|
||||||
|
import pg from 'pg';
|
||||||
|
import type { Database } from './schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of the two files allowed to contain dialect specific SQL (SPEC.md section 5).
|
||||||
|
*
|
||||||
|
* Timestamps are stored as ISO-8601 text in our own tables, so the driver is told to
|
||||||
|
* hand back `numeric` as a number and nothing else needs a type parser.
|
||||||
|
*/
|
||||||
|
export function createPostgresDb(databaseUrl: string): {
|
||||||
|
db: Kysely<Database>;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
} {
|
||||||
|
const pool = new pg.Pool({ connectionString: databaseUrl, max: 10 });
|
||||||
|
const db = new Kysely<Database>({ dialect: new PostgresDialect({ pool }) });
|
||||||
|
return {
|
||||||
|
db,
|
||||||
|
close: async () => {
|
||||||
|
await db.destroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards concurrent `db:migrate` runs (k8s runs it as an initContainer on every api
|
||||||
|
* replica). Advisory locks are released when the session ends, so a crashed migrator
|
||||||
|
* cannot wedge the next one.
|
||||||
|
*/
|
||||||
|
export const MIGRATION_ADVISORY_LOCK_KEY = 4120515;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Money is stored as `bigint`: guaranies overflow int4 at about Gs. 2.100.000.000,
|
||||||
|
* which real turnover passes. Node reads int8 as a string by default, so it is parsed
|
||||||
|
* back to a number here. Safe to Gs. 9.007.199.254.740.991.
|
||||||
|
*/
|
||||||
|
pg.types.setTypeParser(pg.types.builtins.INT8, (value) => {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isSafeInteger(parsed)) throw new Error(`bigint out of safe range: ${value}`);
|
||||||
|
return parsed;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialises `db:migrate` across replicas. k8s runs it as an initContainer on every
|
||||||
|
* api pod, so two migrators can start at the same moment.
|
||||||
|
*/
|
||||||
|
export async function withMigrationLock<T>(db: Kysely<Database>, fn: () => Promise<T>): Promise<T> {
|
||||||
|
await sql`select pg_advisory_lock(${sql.lit(MIGRATION_ADVISORY_LOCK_KEY)})`.execute(db);
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
await sql`select pg_advisory_unlock(${sql.lit(MIGRATION_ADVISORY_LOCK_KEY)})`.execute(db);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
/**
|
||||||
|
* The single Kysely database interface, shared by both dialects.
|
||||||
|
*
|
||||||
|
* Portability rules (SPEC.md section 5):
|
||||||
|
* ids text, UUIDv7 generated by the app
|
||||||
|
* timestamps text, ISO-8601 UTC (sorts chronologically in both dialects)
|
||||||
|
* dates text, YYYY-MM-DD
|
||||||
|
* money integer guaranies, never a float
|
||||||
|
* json text, parsed through a Zod schema at the module boundary
|
||||||
|
* booleans integer 0/1
|
||||||
|
*
|
||||||
|
* The `better-auth` owned tables are declared here so seeds and admin queries are
|
||||||
|
* typed, but only `src/auth` and `src/modules/admin` may write to them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Database {
|
||||||
|
// better-auth owned
|
||||||
|
user: UserTable;
|
||||||
|
session: SessionTable;
|
||||||
|
account: AccountTable;
|
||||||
|
verification: VerificationTable;
|
||||||
|
|
||||||
|
// pii, only src/modules/pii may touch these three
|
||||||
|
profiles: ProfilesTable;
|
||||||
|
dependents: DependentsTable;
|
||||||
|
consents: ConsentsTable;
|
||||||
|
|
||||||
|
document_files: DocumentFilesTable;
|
||||||
|
documents: DocumentsTable;
|
||||||
|
classifications: ClassificationsTable;
|
||||||
|
declarations: DeclarationsTable;
|
||||||
|
jobs: JobsTable;
|
||||||
|
ingest_errors: IngestErrorsTable;
|
||||||
|
audit_log: AuditLogTable;
|
||||||
|
notification_prefs: NotificationPrefsTable;
|
||||||
|
push_subscriptions: PushSubscriptionsTable;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserTable {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
emailVerified: number;
|
||||||
|
image: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
role: string | null;
|
||||||
|
banned: number | null;
|
||||||
|
banReason: string | null;
|
||||||
|
banExpires: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionTable {
|
||||||
|
id: string;
|
||||||
|
expiresAt: string;
|
||||||
|
token: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
ipAddress: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
userId: string;
|
||||||
|
impersonatedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountTable {
|
||||||
|
id: string;
|
||||||
|
issuer: string;
|
||||||
|
accountId: string;
|
||||||
|
providerId: string;
|
||||||
|
userId: string;
|
||||||
|
accessToken: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
idToken: string | null;
|
||||||
|
accessTokenExpiresAt: string | null;
|
||||||
|
refreshTokenExpiresAt: string | null;
|
||||||
|
scope: string | null;
|
||||||
|
password: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerificationTable {
|
||||||
|
id: string;
|
||||||
|
identifier: string;
|
||||||
|
value: string;
|
||||||
|
expiresAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfilesTable {
|
||||||
|
user_id: string;
|
||||||
|
full_name: string;
|
||||||
|
doc_type: 'ruc' | 'ci';
|
||||||
|
ruc: string | null;
|
||||||
|
ruc_dv: string | null;
|
||||||
|
ci: string | null;
|
||||||
|
taxpayer_kind: 'individual' | 'company';
|
||||||
|
deadline_digit: number;
|
||||||
|
/** json: { code, active, since }[] */
|
||||||
|
obligations: string;
|
||||||
|
irp_gross_estimate: number | null;
|
||||||
|
auto_confirm_days: number;
|
||||||
|
locale: 'es' | 'en';
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DependentsTable {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
display_name: string;
|
||||||
|
relationship: 'conyuge' | 'hijo' | 'padre' | 'otro';
|
||||||
|
doc_number: string | null;
|
||||||
|
active: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConsentsTable {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
kind: 'data_processing' | 'notifications';
|
||||||
|
granted_at: string;
|
||||||
|
revoked_at: string | null;
|
||||||
|
text_version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentFilesTable {
|
||||||
|
id: string;
|
||||||
|
driver: 'local' | 's3';
|
||||||
|
path: string;
|
||||||
|
mime: string;
|
||||||
|
size: number;
|
||||||
|
sha256: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentsTable {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
source: 'scan_qr' | 'scan_ocr' | 'manual';
|
||||||
|
status: 'needs_review' | 'confirmed' | 'rejected';
|
||||||
|
cdc: string | null;
|
||||||
|
qr_url: string | null;
|
||||||
|
doc_kind: 'factura' | 'autofactura' | 'nota_credito' | 'nota_debito' | 'boleta_resimple' | 'otro';
|
||||||
|
direction: 'purchase' | 'sale';
|
||||||
|
emitter_ruc: string;
|
||||||
|
emitter_dv: string | null;
|
||||||
|
emitter_name: string;
|
||||||
|
receiver_doc: string | null;
|
||||||
|
issue_date: string;
|
||||||
|
currency: 'PYG';
|
||||||
|
total: number;
|
||||||
|
amount_iva10: number;
|
||||||
|
amount_iva5: number;
|
||||||
|
amount_exenta: number;
|
||||||
|
iva10: number;
|
||||||
|
iva5: number;
|
||||||
|
supplier_regime_hint: 'normal' | 'resimple' | 'unknown';
|
||||||
|
verified_dnit: number;
|
||||||
|
verification_status: 'unverified' | 'valid' | 'invalid' | 'error';
|
||||||
|
dedupe_hash: string;
|
||||||
|
file_id: string | null;
|
||||||
|
/** json, the raw OCR or QR extraction that produced this row */
|
||||||
|
raw_extraction: string | null;
|
||||||
|
created_at: string;
|
||||||
|
confirmed_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClassificationsTable {
|
||||||
|
document_id: string;
|
||||||
|
iva_credit_eligible: number;
|
||||||
|
iva_credit_amount: number;
|
||||||
|
irp_category: string;
|
||||||
|
irp_deductible_amount: number;
|
||||||
|
dependent_id: string | null;
|
||||||
|
confidence: number;
|
||||||
|
decided_by: 'auto' | 'user' | 'staff';
|
||||||
|
rules_version: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeclarationsTable {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
form_code: '120' | '515';
|
||||||
|
period: string;
|
||||||
|
status: 'draft' | 'ready' | 'approved';
|
||||||
|
/** json: { casilla, label, amount }[] */
|
||||||
|
values: string;
|
||||||
|
/** json: form specific summary numbers */
|
||||||
|
summary: string;
|
||||||
|
pdf_file_id: string | null;
|
||||||
|
rules_version: string;
|
||||||
|
/** json: string[] */
|
||||||
|
document_ids: string;
|
||||||
|
created_at: string;
|
||||||
|
approved_at: string | null;
|
||||||
|
filed_marked_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobsTable {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
/** json */
|
||||||
|
payload: string;
|
||||||
|
status: 'pending' | 'running' | 'done' | 'failed' | 'dead';
|
||||||
|
run_at: string;
|
||||||
|
attempts: number;
|
||||||
|
max_attempts: number;
|
||||||
|
locked_by: string | null;
|
||||||
|
locked_at: string | null;
|
||||||
|
last_error: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IngestErrorsTable {
|
||||||
|
id: string;
|
||||||
|
user_id: string | null;
|
||||||
|
document_id: string | null;
|
||||||
|
stage: 'qr_parse' | 'ocr' | 'dedupe' | 'verify' | 'job' | 'other';
|
||||||
|
message: string;
|
||||||
|
/** json */
|
||||||
|
payload: string | null;
|
||||||
|
status: 'open' | 'resolved';
|
||||||
|
resolved_by: string | null;
|
||||||
|
resolved_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append only. Nothing in the codebase may update or delete a row here. */
|
||||||
|
export interface AuditLogTable {
|
||||||
|
id: string;
|
||||||
|
actor_user_id: string;
|
||||||
|
actor_role: string;
|
||||||
|
action: string;
|
||||||
|
subject_user_id: string | null;
|
||||||
|
resource: string;
|
||||||
|
/** json */
|
||||||
|
detail: string | null;
|
||||||
|
ip: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationPrefsTable {
|
||||||
|
user_id: string;
|
||||||
|
push_enabled: number;
|
||||||
|
email_enabled: number;
|
||||||
|
telegram_chat_id: string | null;
|
||||||
|
digest_hour: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushSubscriptionsTable {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
endpoint: string;
|
||||||
|
/** json */
|
||||||
|
keys: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { createAuth } from '../auth/options';
|
||||||
|
import { loadEnv } from '../lib/env';
|
||||||
|
import { createDb } from './index';
|
||||||
|
import { pendingMigrations } from './migrator';
|
||||||
|
import { SEED_ACCOUNTS, seed } from './seed';
|
||||||
|
|
||||||
|
const env = loadEnv();
|
||||||
|
const handle = createDb(env.DATABASE_URL);
|
||||||
|
const auth = createAuth({ db: handle.db, dialect: handle.dialect, env, sendOtp: async () => undefined });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pending = await pendingMigrations(handle);
|
||||||
|
if (pending.length > 0) {
|
||||||
|
console.error(`[seed] run pnpm db:migrate first, pending: ${pending.join(', ')}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { created, existing } = await seed(handle, auth);
|
||||||
|
if (created.length > 0) console.info(`[seed] created: ${created.join(', ')}`);
|
||||||
|
if (existing.length > 0) console.info(`[seed] already present: ${existing.join(', ')}`);
|
||||||
|
|
||||||
|
console.info('\n[seed] development sign in details:');
|
||||||
|
for (const account of SEED_ACCOUNTS) {
|
||||||
|
console.info(` ${account.email.padEnd(24)} ${account.password} (${account.role})`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[seed] failed:', error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { SEED_ACCOUNTS, seed } from './seed';
|
||||||
|
import { createHarness } from '../test/harness';
|
||||||
|
|
||||||
|
describe('seed', () => {
|
||||||
|
it('creates every account with its role, pre verified', async () => {
|
||||||
|
const h = await createHarness();
|
||||||
|
const rows = await h.deps.handle.db
|
||||||
|
.selectFrom('user')
|
||||||
|
.select(['email', 'role', 'emailVerified'])
|
||||||
|
.orderBy('email')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
expect(rows).toHaveLength(SEED_ACCOUNTS.length);
|
||||||
|
for (const account of SEED_ACCOUNTS) {
|
||||||
|
const row = rows.find((r) => r.email === account.email);
|
||||||
|
expect(row, account.email).toBeDefined();
|
||||||
|
expect(row?.role).toBe(account.role);
|
||||||
|
expect(row?.emailVerified).toBeTruthy();
|
||||||
|
}
|
||||||
|
await h.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent', async () => {
|
||||||
|
const h = await createHarness();
|
||||||
|
const again = await seed(h.deps.handle, h.deps.auth);
|
||||||
|
expect(again.created).toEqual([]);
|
||||||
|
expect(again.existing).toHaveLength(SEED_ACCOUNTS.length);
|
||||||
|
|
||||||
|
const { count } = await h.deps.handle.db
|
||||||
|
.selectFrom('user')
|
||||||
|
.select((eb) => eb.fn.countAll<number>().as('count'))
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
expect(Number(count)).toBe(SEED_ACCOUNTS.length);
|
||||||
|
await h.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { Auth, Role } from '../auth/options';
|
||||||
|
import type { DbHandle } from './index';
|
||||||
|
|
||||||
|
export interface SeedAccount {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
name: string;
|
||||||
|
role: Role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accounts per CONTRACTS.md section 4. Deterministic and idempotent: running the seed
|
||||||
|
* twice leaves the same rows.
|
||||||
|
*
|
||||||
|
* Their profiles, documents and declarations are seeded by the phases that own those
|
||||||
|
* tables. Until then `GET /me/profile` correctly answers 404 for each of them, which is
|
||||||
|
* the documented state for a user who has not finished setup.
|
||||||
|
*/
|
||||||
|
export const SEED_ACCOUNTS: readonly SeedAccount[] = [
|
||||||
|
{ email: 'superadmin@demo.local', password: 'demo-superadmin-1', name: 'Super Admin', role: 'superadmin' },
|
||||||
|
{ email: 'staff@demo.local', password: 'demo-staff-1', name: 'Staff Demo', role: 'staff' },
|
||||||
|
{ email: 'maria@demo.local', password: 'demo-maria-1', name: 'Maria Gonzalez', role: 'user' },
|
||||||
|
{ email: 'carlos@demo.local', password: 'demo-carlos-1', name: 'Carlos Benitez', role: 'user' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface SeedResult {
|
||||||
|
created: string[];
|
||||||
|
existing: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seed(handle: DbHandle, auth: Auth): Promise<SeedResult> {
|
||||||
|
const result: SeedResult = { created: [], existing: [] };
|
||||||
|
|
||||||
|
for (const account of SEED_ACCOUNTS) {
|
||||||
|
const found = await handle.db
|
||||||
|
.selectFrom('user')
|
||||||
|
.select('id')
|
||||||
|
.where('email', '=', account.email)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
result.existing.push(account.email);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await auth.api.signUpEmail({
|
||||||
|
body: { email: account.email, password: account.password, name: account.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Roles and verification are set directly: the sign up endpoint always creates a
|
||||||
|
// plain unverified `user`, and demo accounts need to be usable straight away.
|
||||||
|
await handle.db
|
||||||
|
.updateTable('user')
|
||||||
|
.set({ role: account.role, emailVerified: 1, updatedAt: new Date().toISOString() })
|
||||||
|
.where('email', '=', account.email)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
result.created.push(account.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import SQLite from 'better-sqlite3';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { Kysely, SqliteDialect } from 'kysely';
|
||||||
|
import type { Database } from './schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One of the two files allowed to contain dialect specific SQL (SPEC.md section 5).
|
||||||
|
*/
|
||||||
|
export function createSqliteDb(databaseUrl: string): { db: Kysely<Database>; close: () => Promise<void> } {
|
||||||
|
const file = sqliteFile(databaseUrl);
|
||||||
|
if (file !== ':memory:') mkdirSync(dirname(file), { recursive: true });
|
||||||
|
|
||||||
|
const sqlite = new SQLite(file);
|
||||||
|
sqlite.pragma('journal_mode = WAL');
|
||||||
|
sqlite.pragma('busy_timeout = 5000');
|
||||||
|
sqlite.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
const db = new Kysely<Database>({ dialect: new SqliteDialect({ database: sqlite }) });
|
||||||
|
return {
|
||||||
|
db,
|
||||||
|
close: async () => {
|
||||||
|
await db.destroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `sqlite::memory:`, `sqlite:./data/app.db` and `file:./data/app.db` all work. */
|
||||||
|
export function sqliteFile(databaseUrl: string): string {
|
||||||
|
const path = databaseUrl.replace(/^sqlite:/, '').replace(/^file:/, '');
|
||||||
|
if (path === ':memory:' || path === '' || path === '//:memory:') return ':memory:';
|
||||||
|
return resolve(path);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { ErrorEnvelope } from '@impuestos/contracts';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { createHarness, type Harness } from '../test/harness';
|
||||||
|
|
||||||
|
let h: Harness;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
h = await createHarness();
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await h.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('health endpoints', () => {
|
||||||
|
it('reports liveness without touching the database', async () => {
|
||||||
|
const response = await h.app.request('/healthz');
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports readiness with the database reachable and migrations current', async () => {
|
||||||
|
const response = await h.app.request('/readyz');
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
ok: true,
|
||||||
|
checks: { database: 'ok', migrations: 'ok' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails both once draining starts, so the load balancer stops routing here', async () => {
|
||||||
|
h.startDraining();
|
||||||
|
expect((await h.app.request('/healthz')).status).toBe(503);
|
||||||
|
const ready = await h.app.request('/readyz');
|
||||||
|
expect(ready.status).toBe(503);
|
||||||
|
expect(await ready.json()).toMatchObject({ ok: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error envelope', () => {
|
||||||
|
it('is returned for an unauthenticated request', async () => {
|
||||||
|
const response = await h.app.request('/api/me/profile');
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
const body = ErrorEnvelope.parse(await response.json());
|
||||||
|
expect(body.error.code).toBe('unauthorized');
|
||||||
|
expect(body.error.message).toBe('Necesitás iniciar sesion para ver esto.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is localized from Accept-Language when nobody is signed in', async () => {
|
||||||
|
const response = await h.app.request('/api/me/profile', {
|
||||||
|
headers: { 'accept-language': 'en-US,en;q=0.9,es;q=0.8' },
|
||||||
|
});
|
||||||
|
const body = ErrorEnvelope.parse(await response.json());
|
||||||
|
expect(body.error.message).toBe('You need to sign in to see this.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to es for an unsupported language', async () => {
|
||||||
|
const response = await h.app.request('/api/me/profile', {
|
||||||
|
headers: { 'accept-language': 'pt-BR' },
|
||||||
|
});
|
||||||
|
const body = ErrorEnvelope.parse(await response.json());
|
||||||
|
expect(body.error.message).toBe('Necesitás iniciar sesion para ver esto.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is used for unknown paths too, never a plain text 404', async () => {
|
||||||
|
const response = await h.app.request('/api/does-not-exist');
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.headers.get('content-type')).toContain('application/json');
|
||||||
|
expect(ErrorEnvelope.parse(await response.json()).error.code).toBe('not_found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sessions', () => {
|
||||||
|
it('signs a seeded account in and answers 404 until setup is complete', async () => {
|
||||||
|
const cookie = await h.signIn('maria@demo.local', 'demo-maria-1');
|
||||||
|
const response = await h.app.request('/api/me/profile', { headers: { cookie } });
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(ErrorEnvelope.parse(await response.json()).error.code).toBe('not_found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the wrong password', async () => {
|
||||||
|
await expect(h.signIn('maria@demo.local', 'wrong-password')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import type { AppDeps, AppEnv } from './context';
|
||||||
|
import { HttpError, toEnvelope } from './errors';
|
||||||
|
import { liveness, readiness } from './health';
|
||||||
|
import { localeMiddleware, sessionMiddleware } from './middleware';
|
||||||
|
import { meRoutes } from './routes/me';
|
||||||
|
|
||||||
|
export interface AppHandle {
|
||||||
|
app: Hono<AppEnv>;
|
||||||
|
/** Flips readiness off and makes /healthz report draining. Called on SIGTERM. */
|
||||||
|
startDraining: () => void;
|
||||||
|
/** Requests currently being handled, so shutdown can wait for them. */
|
||||||
|
inFlight: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApp(deps: AppDeps): AppHandle {
|
||||||
|
const app = new Hono<AppEnv>();
|
||||||
|
let draining = false;
|
||||||
|
let inFlight = 0;
|
||||||
|
|
||||||
|
app.use('*', async (_c, next) => {
|
||||||
|
inFlight += 1;
|
||||||
|
try {
|
||||||
|
await next();
|
||||||
|
} finally {
|
||||||
|
inFlight -= 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.onError((error, c) => {
|
||||||
|
const { status, body } = toEnvelope(error, c.get('locale') ?? 'es', deps.env.NODE_ENV !== 'production');
|
||||||
|
if (status >= 500) console.error('[api] unhandled error', error);
|
||||||
|
return c.json(body, status);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/healthz', (c) => c.json(liveness(draining), draining ? 503 : 200));
|
||||||
|
app.get('/readyz', async (c) => {
|
||||||
|
const result = await readiness(deps, draining);
|
||||||
|
return c.json(result, result.ok ? 200 : 503);
|
||||||
|
});
|
||||||
|
|
||||||
|
// better-auth owns everything under /api/auth. It reads and writes cookies itself.
|
||||||
|
app.on(['GET', 'POST'], '/api/auth/*', (c) => deps.auth.handler(c.req.raw));
|
||||||
|
|
||||||
|
const api = new Hono<AppEnv>();
|
||||||
|
api.use('*', sessionMiddleware(deps));
|
||||||
|
api.use('*', localeMiddleware(deps));
|
||||||
|
api.route('/me', meRoutes(deps));
|
||||||
|
|
||||||
|
app.route('/api', api);
|
||||||
|
|
||||||
|
// The API only ever speaks JSON, so an unknown path gets the same envelope as
|
||||||
|
// everything else rather than Hono's plain text 404.
|
||||||
|
app.notFound((c) => {
|
||||||
|
const { status, body } = toEnvelope(new HttpError('not_found'), c.get('locale') ?? 'es', false);
|
||||||
|
return c.json(body, status);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
startDraining: () => {
|
||||||
|
draining = true;
|
||||||
|
},
|
||||||
|
inFlight: () => inFlight,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Locale } from '@impuestos/i18n';
|
||||||
|
import type { Auth } from '../auth/options';
|
||||||
|
import type { DbHandle } from '../db/index';
|
||||||
|
import type { Env } from '../lib/env';
|
||||||
|
|
||||||
|
export interface SessionUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppDeps {
|
||||||
|
env: Env;
|
||||||
|
handle: DbHandle;
|
||||||
|
auth: Auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hono context typing shared by every route and middleware. */
|
||||||
|
export interface AppEnv {
|
||||||
|
Variables: {
|
||||||
|
locale: Locale;
|
||||||
|
user: SessionUser | null;
|
||||||
|
deps: AppDeps;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { type ErrorCode, ErrorEnvelope } from '@impuestos/contracts';
|
||||||
|
import { type Locale, type MessageKey, t } from '@impuestos/i18n';
|
||||||
|
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||||
|
|
||||||
|
const STATUS: Record<ErrorCode, ContentfulStatusCode> = {
|
||||||
|
validation_error: 400,
|
||||||
|
unauthorized: 401,
|
||||||
|
forbidden: 403,
|
||||||
|
not_found: 404,
|
||||||
|
conflict: 409,
|
||||||
|
rate_limited: 429,
|
||||||
|
ocr_unavailable: 503,
|
||||||
|
internal: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MESSAGE_KEY: Record<ErrorCode, MessageKey> = {
|
||||||
|
validation_error: 'error.validation_error',
|
||||||
|
unauthorized: 'error.unauthorized',
|
||||||
|
forbidden: 'error.forbidden',
|
||||||
|
not_found: 'error.not_found',
|
||||||
|
conflict: 'error.conflict',
|
||||||
|
rate_limited: 'error.rate_limited',
|
||||||
|
ocr_unavailable: 'error.ocr_unavailable',
|
||||||
|
internal: 'common.error.generic',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown anywhere in the API. `code` picks both the status and the user facing message,
|
||||||
|
* which is looked up in the requester's locale when the response is built.
|
||||||
|
*/
|
||||||
|
export class HttpError extends Error {
|
||||||
|
readonly code: ErrorCode;
|
||||||
|
readonly field: string | undefined;
|
||||||
|
readonly detail: unknown;
|
||||||
|
/** Overrides the default message for this code with more specific copy. */
|
||||||
|
readonly messageKey: MessageKey | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
code: ErrorCode,
|
||||||
|
options: { field?: string; detail?: unknown; messageKey?: MessageKey; cause?: unknown } = {},
|
||||||
|
) {
|
||||||
|
super(code, options.cause === undefined ? undefined : { cause: options.cause });
|
||||||
|
this.name = 'HttpError';
|
||||||
|
this.code = code;
|
||||||
|
this.field = options.field;
|
||||||
|
this.detail = options.detail;
|
||||||
|
this.messageKey = options.messageKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusFor(code: ErrorCode): ContentfulStatusCode {
|
||||||
|
return STATUS[code];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the envelope from CONTRACTS.md section 1. Technical detail is dev only. */
|
||||||
|
export function toEnvelope(
|
||||||
|
error: unknown,
|
||||||
|
locale: Locale,
|
||||||
|
isDevelopment: boolean,
|
||||||
|
): { status: ContentfulStatusCode; body: ErrorEnvelope } {
|
||||||
|
const httpError =
|
||||||
|
error instanceof HttpError ? error : new HttpError('internal', { cause: error });
|
||||||
|
|
||||||
|
const body: ErrorEnvelope = {
|
||||||
|
error: {
|
||||||
|
code: httpError.code,
|
||||||
|
message: t(locale, httpError.messageKey ?? MESSAGE_KEY[httpError.code]),
|
||||||
|
...(httpError.field === undefined ? {} : { field: httpError.field }),
|
||||||
|
...(isDevelopment ? { detail: httpError.detail ?? describe(error) } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { status: statusFor(httpError.code), body: ErrorEnvelope.parse(body) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(error: unknown): unknown {
|
||||||
|
if (error instanceof Error) return { name: error.name, message: error.message };
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { sql } from 'kysely';
|
||||||
|
import { pendingMigrations } from '../db/migrator';
|
||||||
|
import type { AppDeps } from './context';
|
||||||
|
|
||||||
|
export type CheckState = 'ok' | 'error' | 'pending';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liveness: the process is up and the event loop is turning. Never touches the
|
||||||
|
* database, so a database blip does not get the container killed.
|
||||||
|
*/
|
||||||
|
export function liveness(draining: boolean): { ok: boolean } {
|
||||||
|
return { ok: !draining };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Readiness: this replica can serve traffic. Checked by the orchestrator and flipped
|
||||||
|
* to not-ready as soon as SIGTERM arrives, so the load balancer stops sending work
|
||||||
|
* while in flight requests drain.
|
||||||
|
*/
|
||||||
|
export async function readiness(
|
||||||
|
deps: AppDeps,
|
||||||
|
draining: boolean,
|
||||||
|
): Promise<{ ok: boolean; checks: Record<string, CheckState> }> {
|
||||||
|
if (draining) return { ok: false, checks: { draining: 'error' } };
|
||||||
|
|
||||||
|
const checks: Record<string, CheckState> = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sql`select 1`.execute(deps.handle.db);
|
||||||
|
checks['database'] = 'ok';
|
||||||
|
} catch {
|
||||||
|
checks['database'] = 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checks['database'] === 'ok') {
|
||||||
|
try {
|
||||||
|
checks['migrations'] = (await pendingMigrations(deps.handle)).length === 0 ? 'ok' : 'pending';
|
||||||
|
} catch {
|
||||||
|
checks['migrations'] = 'error';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
checks['migrations'] = 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: Object.values(checks).every((state) => state === 'ok'), checks };
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { DEFAULT_LOCALE, isLocale, localeFromAcceptLanguage } from '@impuestos/i18n';
|
||||||
|
import type { MiddlewareHandler } from 'hono';
|
||||||
|
import { getLocale } from '../modules/pii';
|
||||||
|
import type { AppDeps, AppEnv, SessionUser } from './context';
|
||||||
|
import { HttpError } from './errors';
|
||||||
|
|
||||||
|
/** Resolves the session once per request so handlers never call better-auth directly. */
|
||||||
|
export function sessionMiddleware(deps: AppDeps): MiddlewareHandler<AppEnv> {
|
||||||
|
return async (c, next) => {
|
||||||
|
const session = await deps.auth.api.getSession({ headers: c.req.raw.headers });
|
||||||
|
const user: SessionUser | null = session
|
||||||
|
? {
|
||||||
|
id: session.user.id,
|
||||||
|
email: session.user.email,
|
||||||
|
role: typeof session.user.role === 'string' ? session.user.role : 'user',
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
c.set('user', user);
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locale precedence per CONTRACTS.md section 1: stored profile locale, then
|
||||||
|
* Accept-Language, then DEFAULT_LOCALE.
|
||||||
|
*/
|
||||||
|
export function localeMiddleware(deps: AppDeps): MiddlewareHandler<AppEnv> {
|
||||||
|
const fallback = isLocale(deps.env.DEFAULT_LOCALE) ? deps.env.DEFAULT_LOCALE : DEFAULT_LOCALE;
|
||||||
|
return async (c, next) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const stored = user ? await getLocale(deps.handle.db, user.id) : null;
|
||||||
|
c.set('locale', stored ?? localeFromAcceptLanguage(c.req.header('accept-language') ?? fallback));
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the signed in user or throws the 401 envelope. */
|
||||||
|
export function requireUser(c: { get: (key: 'user') => SessionUser | null }): SessionUser {
|
||||||
|
const user = c.get('user');
|
||||||
|
if (!user) throw new HttpError('unauthorized');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Role checks are re-validated in every handler, never only at the router. */
|
||||||
|
export function requireRole(
|
||||||
|
c: { get: (key: 'user') => SessionUser | null },
|
||||||
|
allowed: readonly string[],
|
||||||
|
): SessionUser {
|
||||||
|
const user = requireUser(c);
|
||||||
|
if (!allowed.includes(user.role)) throw new HttpError('forbidden');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { getProfile } from '../../modules/pii';
|
||||||
|
import type { AppDeps, AppEnv } from '../context';
|
||||||
|
import { HttpError } from '../errors';
|
||||||
|
import { requireUser } from '../middleware';
|
||||||
|
|
||||||
|
export function meRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||||
|
const routes = new Hono<AppEnv>();
|
||||||
|
|
||||||
|
// 404 until setup is complete: the client routes to onboarding (CONTRACTS.md section 3).
|
||||||
|
routes.get('/profile', async (c) => {
|
||||||
|
const user = requireUser(c);
|
||||||
|
const profile = await getProfile(deps.handle.db, user.id);
|
||||||
|
if (!profile) throw new HttpError('not_found');
|
||||||
|
return c.json(profile);
|
||||||
|
});
|
||||||
|
|
||||||
|
return routes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { serve } from '@hono/node-server';
|
||||||
|
import { createAuth } from './auth/options';
|
||||||
|
import { createDb } from './db/index';
|
||||||
|
import { pendingMigrations } from './db/migrator';
|
||||||
|
import { createApp } from './http/app';
|
||||||
|
import type { AppDeps } from './http/context';
|
||||||
|
import { loadEnv } from './lib/env';
|
||||||
|
import { createOtpSender } from './modules/notifications/mailer';
|
||||||
|
|
||||||
|
const DRAIN_TIMEOUT_MS = 25_000;
|
||||||
|
|
||||||
|
const env = loadEnv();
|
||||||
|
const handle = createDb(env.DATABASE_URL);
|
||||||
|
const auth = createAuth({
|
||||||
|
db: handle.db,
|
||||||
|
dialect: handle.dialect,
|
||||||
|
env,
|
||||||
|
sendOtp: createOtpSender(env),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deps: AppDeps = { env, handle, auth };
|
||||||
|
const { app, startDraining, inFlight } = createApp(deps);
|
||||||
|
|
||||||
|
const pending = await pendingMigrations(handle).catch(() => ['<database unreachable>']);
|
||||||
|
if (pending.length > 0) {
|
||||||
|
console.warn(`[boot] pending migrations: ${pending.join(', ')}. Run pnpm db:migrate.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.ROLE === 'worker') {
|
||||||
|
// The worker shares this image and this bootstrap. It serves only the health
|
||||||
|
// endpoints; the job poller is wired in with the jobs module.
|
||||||
|
console.info('[boot] role=worker');
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = serve({ fetch: app.fetch, port: env.PORT, hostname: '0.0.0.0' }, (info) => {
|
||||||
|
console.info(`[boot] role=${env.ROLE} dialect=${handle.dialect} listening on :${info.port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
async function shutdown(signal: string): Promise<void> {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
console.info(`[shutdown] ${signal}: draining`);
|
||||||
|
|
||||||
|
// Fail readiness first so the load balancer stops routing here, then stop accepting.
|
||||||
|
startDraining();
|
||||||
|
server.close();
|
||||||
|
|
||||||
|
const deadline = Date.now() + DRAIN_TIMEOUT_MS;
|
||||||
|
while (inFlight() > 0 && Date.now() < deadline) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
if (inFlight() > 0) {
|
||||||
|
console.warn(`[shutdown] ${inFlight()} requests still in flight after drain timeout`);
|
||||||
|
if ('closeAllConnections' in server) server.closeAllConnections();
|
||||||
|
}
|
||||||
|
|
||||||
|
await handle.close();
|
||||||
|
console.info('[shutdown] done');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { ENV_KEYS, parseEnv } from './env';
|
||||||
|
|
||||||
|
const MINIMAL = {
|
||||||
|
DATABASE_URL: 'sqlite:./data/app.db',
|
||||||
|
BETTER_AUTH_SECRET: 'a'.repeat(32),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('env', () => {
|
||||||
|
it('accepts the minimal set and applies documented defaults', () => {
|
||||||
|
const result = parseEnv(MINIMAL);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(result.env?.PORT).toBe(4000);
|
||||||
|
expect(result.env?.ROLE).toBe('server');
|
||||||
|
expect(result.env?.JOBS_INLINE).toBe(true);
|
||||||
|
expect(result.env?.STORAGE_DRIVER).toBe('local');
|
||||||
|
expect(result.env?.DEFAULT_LOCALE).toBe('es');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names every missing variable in one readable message', () => {
|
||||||
|
const result = parseEnv({});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.message).toContain('DATABASE_URL');
|
||||||
|
expect(result.message).toContain('BETTER_AUTH_SECRET');
|
||||||
|
expect(result.message).toContain('.env.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a short auth secret', () => {
|
||||||
|
const result = parseEnv({ ...MINIMAL, BETTER_AUTH_SECRET: 'too-short' });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.message).toContain('at least 32 characters');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires the s3 settings when the s3 driver is selected', () => {
|
||||||
|
const result = parseEnv({ ...MINIMAL, STORAGE_DRIVER: 's3' });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.message).toContain('S3_BUCKET');
|
||||||
|
expect(result.message).toContain('S3_SECRET_ACCESS_KEY');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts s3 once it is fully configured', () => {
|
||||||
|
const result = parseEnv({
|
||||||
|
...MINIMAL,
|
||||||
|
STORAGE_DRIVER: 's3',
|
||||||
|
S3_BUCKET: 'facturas',
|
||||||
|
S3_REGION: 'us-east-1',
|
||||||
|
S3_ACCESS_KEY_ID: 'key',
|
||||||
|
S3_SECRET_ACCESS_KEY: 'secret',
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// SPEC.md section 15: SQLite is single writer, so a second poller cannot be safe.
|
||||||
|
it('refuses a dedicated worker on SQLite', () => {
|
||||||
|
const result = parseEnv({ ...MINIMAL, JOBS_INLINE: 'false' });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.message).toContain('JOBS_INLINE');
|
||||||
|
expect(result.message).toContain('Postgres');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a dedicated worker on Postgres', () => {
|
||||||
|
const result = parseEnv({
|
||||||
|
...MINIMAL,
|
||||||
|
DATABASE_URL: 'postgres://user:pass@localhost:5432/impuestos',
|
||||||
|
JOBS_INLINE: 'false',
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses booleans in every spelling .env allows', () => {
|
||||||
|
expect(parseEnv({ ...MINIMAL, JOBS_INLINE: '0' }).ok).toBe(false);
|
||||||
|
expect(parseEnv({ ...MINIMAL, S3_FORCE_PATH_STYLE: '0' }).env?.S3_FORCE_PATH_STYLE).toBe(false);
|
||||||
|
expect(parseEnv({ ...MINIMAL, S3_FORCE_PATH_STYLE: 'true' }).env?.S3_FORCE_PATH_STYLE).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// SPEC.md section 14: .env.example completeness is enforced, not trusted.
|
||||||
|
describe('.env.example', () => {
|
||||||
|
it('documents every variable the schema knows about', () => {
|
||||||
|
const text = readFileSync(new URL('../../.env.example', import.meta.url), 'utf8');
|
||||||
|
const documented = new Set(
|
||||||
|
text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => /^([A-Z0-9_]+)=/.exec(line.trim())?.[1])
|
||||||
|
.filter((name): name is string => name !== undefined),
|
||||||
|
);
|
||||||
|
|
||||||
|
const declared = ENV_KEYS;
|
||||||
|
expect([...declared].filter((name) => !documented.has(name))).toEqual([]);
|
||||||
|
expect([...documented].filter((name) => !declared.includes(name))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { parseEnv as parseEnvFile } from 'node:util';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/** `.env` accepts the usual spellings for a boolean. */
|
||||||
|
const booleanish = z
|
||||||
|
.union([z.boolean(), z.enum(['true', 'false', '1', '0'])])
|
||||||
|
.transform((value) => value === true || value === 'true' || value === '1');
|
||||||
|
|
||||||
|
const optionalString = z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => (value === undefined || value.length === 0 ? undefined : value));
|
||||||
|
|
||||||
|
const EnvObject = z.object({
|
||||||
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
|
PORT: z.coerce.number().int().positive().max(65535).default(4000),
|
||||||
|
/** User facing origin. Used for links in emails and notifications. */
|
||||||
|
APP_PUBLIC_URL: z.url().default('http://localhost:3000'),
|
||||||
|
|
||||||
|
ROLE: z.enum(['server', 'worker']).default('server'),
|
||||||
|
JOBS_INLINE: booleanish.default(true),
|
||||||
|
JOBS_POLL_INTERVAL_MS: z.coerce.number().int().positive().default(2000),
|
||||||
|
JOBS_STALE_MINUTES: z.coerce.number().int().positive().default(10),
|
||||||
|
|
||||||
|
DATABASE_URL: z.string().trim().min(1),
|
||||||
|
|
||||||
|
BETTER_AUTH_SECRET: z.string().min(32, 'must be at least 32 characters'),
|
||||||
|
/** Public origin cookies are issued for. The web app proxies /api, so this is the web origin. */
|
||||||
|
BETTER_AUTH_URL: z.url().default('http://localhost:3000'),
|
||||||
|
|
||||||
|
STORAGE_DRIVER: z.enum(['local', 's3']).default('local'),
|
||||||
|
STORAGE_LOCAL_PATH: z.string().trim().default('./data/files'),
|
||||||
|
S3_ENDPOINT: optionalString,
|
||||||
|
S3_REGION: optionalString,
|
||||||
|
S3_BUCKET: optionalString,
|
||||||
|
S3_ACCESS_KEY_ID: optionalString,
|
||||||
|
S3_SECRET_ACCESS_KEY: optionalString,
|
||||||
|
S3_FORCE_PATH_STYLE: booleanish.default(true),
|
||||||
|
|
||||||
|
ANTHROPIC_API_KEY: optionalString,
|
||||||
|
OCR_MODEL: z.string().trim().default('claude-sonnet-4-6'),
|
||||||
|
|
||||||
|
PUSH_VAPID_PUBLIC_KEY: optionalString,
|
||||||
|
PUSH_VAPID_PRIVATE_KEY: optionalString,
|
||||||
|
|
||||||
|
SMTP_HOST: optionalString,
|
||||||
|
SMTP_PORT: z.coerce.number().int().positive().max(65535).default(587),
|
||||||
|
SMTP_USER: optionalString,
|
||||||
|
SMTP_PASS: optionalString,
|
||||||
|
SMTP_FROM: optionalString,
|
||||||
|
|
||||||
|
TELEGRAM_BOT_TOKEN: optionalString,
|
||||||
|
|
||||||
|
DEFAULT_LOCALE: z.enum(['es', 'en']).default('es'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Every variable name the schema knows about. The .env.example test checks against this. */
|
||||||
|
export const ENV_KEYS = Object.keys(EnvObject.shape);
|
||||||
|
|
||||||
|
export const EnvSchema = EnvObject.superRefine((env, ctx) => {
|
||||||
|
if (env.STORAGE_DRIVER === 's3') {
|
||||||
|
for (const key of ['S3_BUCKET', 'S3_REGION', 'S3_ACCESS_KEY_ID', 'S3_SECRET_ACCESS_KEY'] as const) {
|
||||||
|
if (env[key] === undefined) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: [key],
|
||||||
|
message: 'is required when STORAGE_DRIVER=s3',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPEC.md section 15: SQLite has a single writer, so a second poller in another
|
||||||
|
// process cannot be made safe. Refusing JOBS_INLINE=false keeps SQLite single process.
|
||||||
|
if (isSqliteUrl(env.DATABASE_URL) && !env.JOBS_INLINE && env.ROLE === 'server') {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['JOBS_INLINE'],
|
||||||
|
message:
|
||||||
|
'cannot be false on SQLite: a dedicated worker needs Postgres. ' +
|
||||||
|
'Either keep JOBS_INLINE=true or point DATABASE_URL at Postgres.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Env = z.infer<typeof EnvSchema>;
|
||||||
|
|
||||||
|
export function isSqliteUrl(databaseUrl: string): boolean {
|
||||||
|
return databaseUrl.startsWith('sqlite:') || databaseUrl.startsWith('file:');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPostgresUrl(databaseUrl: string): boolean {
|
||||||
|
return databaseUrl.startsWith('postgres://') || databaseUrl.startsWith('postgresql://');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParseResult {
|
||||||
|
ok: boolean;
|
||||||
|
env?: Env;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseEnv(source: Record<string, string | undefined>): ParseResult {
|
||||||
|
const result = EnvSchema.safeParse(source);
|
||||||
|
if (result.success) return { ok: true, env: result.data };
|
||||||
|
|
||||||
|
const lines = result.error.issues.map((issue) => {
|
||||||
|
const name = issue.path.join('.') || '(root)';
|
||||||
|
return ` ${name}: ${issue.message}`;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
message: [
|
||||||
|
'Invalid environment for apps/api. Fix these and start again:',
|
||||||
|
...lines,
|
||||||
|
'',
|
||||||
|
'Every variable is documented in apps/api/.env.example.',
|
||||||
|
].join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads `.env` into process.env when the file exists, without a dotenv dependency.
|
||||||
|
*
|
||||||
|
* A real environment variable always wins over the file: `process.loadEnvFile` overwrites
|
||||||
|
* process.env, which would let a stale checked out `.env` silently beat the values a
|
||||||
|
* container or a one off command passed in. Containers ship no `.env` at all, so this is
|
||||||
|
* a no-op there.
|
||||||
|
*/
|
||||||
|
function loadDotEnvFile(path = '.env'): void {
|
||||||
|
if (!existsSync(path)) return;
|
||||||
|
const fromFile = parseEnvFile(readFileSync(path, 'utf8'));
|
||||||
|
for (const [key, value] of Object.entries(fromFile)) {
|
||||||
|
if (process.env[key] === undefined && typeof value === 'string') process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses process.env or exits. Called once at boot, before anything opens a connection. */
|
||||||
|
export function loadEnv(source?: Record<string, string | undefined>): Env {
|
||||||
|
if (source === undefined) loadDotEnvFile();
|
||||||
|
const result = parseEnv(source ?? process.env);
|
||||||
|
if (!result.ok || !result.env) {
|
||||||
|
console.error(result.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
warnAboutScaling(result.env);
|
||||||
|
return result.env;
|
||||||
|
}
|
||||||
|
|
||||||
|
function warnAboutScaling(env: Env): void {
|
||||||
|
if (isSqliteUrl(env.DATABASE_URL)) {
|
||||||
|
console.info(
|
||||||
|
'[boot] SQLite: this deployment is limited to a single API process. ' +
|
||||||
|
'Point DATABASE_URL at Postgres to run more than one replica.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (env.STORAGE_DRIVER === 'local') {
|
||||||
|
console.info(
|
||||||
|
'[boot] Local storage driver: every replica must mount the same volume at ' +
|
||||||
|
`${env.STORAGE_LOCAL_PATH}. Use STORAGE_DRIVER=s3 for multi replica deployments.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Env } from '../../lib/env';
|
||||||
|
|
||||||
|
export interface OtpMessage {
|
||||||
|
email: string;
|
||||||
|
otp: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 0 delivery: without SMTP_HOST configured the code goes to stdout, which is what
|
||||||
|
* local development and the compose stack rely on. Real SMTP delivery and localized
|
||||||
|
* templates land with the notifications module.
|
||||||
|
*/
|
||||||
|
export function createOtpSender(env: Env): (message: OtpMessage) => Promise<void> {
|
||||||
|
return async ({ email, otp, type }) => {
|
||||||
|
if (env.SMTP_HOST === undefined) {
|
||||||
|
console.info(`[auth] verification code for ${email} (${type}): ${otp}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// TODO(phase-4): send through SMTP with the recipient's locale.
|
||||||
|
console.info(`[auth] verification code for ${email} (${type}): ${otp}`);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Public surface of the pii module. Nothing outside this directory may reach past this
|
||||||
|
* file: the eslint boundary rule in eslint.config.js enforces it, so every read of a
|
||||||
|
* profile, dependant or consent goes through a function that can audit itself.
|
||||||
|
*/
|
||||||
|
export { getProfile, getLocale } from './profiles';
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Obligation, type ProfileDto } from '@impuestos/contracts';
|
||||||
|
import { type Locale, isLocale } from '@impuestos/i18n';
|
||||||
|
import type { Kysely } from 'kysely';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { Database } from '../../db/schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The only module allowed to read or write `profiles`, `dependents` and `consents`
|
||||||
|
* (SPEC.md section 4). Everything else goes through these functions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Obligations = z.array(Obligation);
|
||||||
|
|
||||||
|
export async function getProfile(db: Kysely<Database>, userId: string): Promise<ProfileDto | null> {
|
||||||
|
const row = await db
|
||||||
|
.selectFrom('profiles')
|
||||||
|
.selectAll()
|
||||||
|
.where('user_id', '=', userId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
fullName: row.full_name,
|
||||||
|
docType: row.doc_type,
|
||||||
|
ruc: row.ruc,
|
||||||
|
rucDv: row.ruc_dv,
|
||||||
|
ci: row.ci,
|
||||||
|
taxpayerKind: row.taxpayer_kind,
|
||||||
|
deadlineDigit: row.deadline_digit,
|
||||||
|
obligations: Obligations.parse(JSON.parse(row.obligations)),
|
||||||
|
irpGrossEstimate: row.irp_gross_estimate,
|
||||||
|
autoConfirmDays: row.auto_confirm_days,
|
||||||
|
locale: row.locale,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives every server generated string for this user: error envelopes, notifications
|
||||||
|
* and emails. Returns null when the user has not finished setup yet.
|
||||||
|
*/
|
||||||
|
export async function getLocale(db: Kysely<Database>, userId: string): Promise<Locale | null> {
|
||||||
|
const row = await db
|
||||||
|
.selectFrom('profiles')
|
||||||
|
.select('locale')
|
||||||
|
.where('user_id', '=', userId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
return row && isLocale(row.locale) ? row.locale : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { createAuth } from '../auth/options';
|
||||||
|
import { createDb } from '../db/index';
|
||||||
|
import { migrateToLatest } from '../db/migrator';
|
||||||
|
import { seed } from '../db/seed';
|
||||||
|
import { createApp, type AppHandle } from '../http/app';
|
||||||
|
import type { AppDeps } from '../http/context';
|
||||||
|
import { type Env, parseEnv } from '../lib/env';
|
||||||
|
|
||||||
|
export const TEST_ENV: Record<string, string> = {
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
DATABASE_URL: 'sqlite::memory:',
|
||||||
|
BETTER_AUTH_SECRET: 'test-secret-that-is-long-enough-32chars',
|
||||||
|
BETTER_AUTH_URL: 'http://localhost:3000',
|
||||||
|
APP_PUBLIC_URL: 'http://localhost:3000',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Harness extends AppHandle {
|
||||||
|
deps: AppDeps;
|
||||||
|
env: Env;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
/** Signs in a seeded account and returns the cookie header for later requests. */
|
||||||
|
signIn: (email: string, password: string) => Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createHarness(overrides: Record<string, string> = {}): Promise<Harness> {
|
||||||
|
const parsed = parseEnv({ ...TEST_ENV, ...overrides });
|
||||||
|
if (!parsed.ok || !parsed.env) throw new Error(parsed.message);
|
||||||
|
const env = parsed.env;
|
||||||
|
|
||||||
|
const handle = createDb(env.DATABASE_URL);
|
||||||
|
await migrateToLatest(handle, env);
|
||||||
|
|
||||||
|
const auth = createAuth({ db: handle.db, dialect: handle.dialect, env, sendOtp: async () => undefined });
|
||||||
|
await seed(handle, auth);
|
||||||
|
|
||||||
|
const deps: AppDeps = { env, handle, auth };
|
||||||
|
const appHandle = createApp(deps);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...appHandle,
|
||||||
|
deps,
|
||||||
|
env,
|
||||||
|
close: () => handle.close(),
|
||||||
|
signIn: async (email, password) => {
|
||||||
|
const response = await appHandle.app.request('/api/auth/sign-in/email', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`sign in failed: ${response.status} ${await response.text()}`);
|
||||||
|
const cookie = response.headers.get('set-cookie');
|
||||||
|
if (!cookie) throw new Error('sign in returned no cookie');
|
||||||
|
return cookie.split(';')[0] ?? '';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "tsup.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'tsup';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The workspace packages are pure TypeScript source with no build step, so they are
|
||||||
|
* bundled in here. Everything from node_modules stays external, which keeps the native
|
||||||
|
* better-sqlite3 binding loading normally at runtime.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
entry: { index: 'src/index.ts', 'db/migrate.cli': 'src/db/migrate.cli.ts', 'db/seed.cli': 'src/db/seed.cli.ts' },
|
||||||
|
format: ['esm'],
|
||||||
|
target: 'node22',
|
||||||
|
platform: 'node',
|
||||||
|
outDir: 'dist',
|
||||||
|
clean: true,
|
||||||
|
sourcemap: true,
|
||||||
|
noExternal: [/^@impuestos\//],
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# apps/web environment. The web app holds no secrets: it renders UI and proxies /api.
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Locale used before a visitor picks one. Must be a locale packages/i18n ships.
|
||||||
|
NEXT_PUBLIC_DEFAULT_LOCALE=es
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:22-slim AS build
|
||||||
|
ENV PNPM_HOME=/pnpm PATH=/pnpm:$PATH
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /repo
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
|
||||||
|
COPY apps/api/package.json apps/api/
|
||||||
|
COPY apps/web/package.json apps/web/
|
||||||
|
COPY packages/contracts/package.json packages/contracts/
|
||||||
|
COPY packages/i18n/package.json packages/i18n/
|
||||||
|
COPY packages/rules/package.json packages/rules/
|
||||||
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY packages packages
|
||||||
|
COPY apps/web apps/web
|
||||||
|
COPY docs docs
|
||||||
|
RUN pnpm --filter @impuestos/web build
|
||||||
|
|
||||||
|
FROM node:22-slim AS runtime
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV PORT=3000 HOSTNAME=0.0.0.0
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# `output: standalone` emits a server with only the modules it actually imports.
|
||||||
|
COPY --from=build --chown=node:node /repo/apps/web/.next/standalone ./
|
||||||
|
COPY --from=build --chown=node:node /repo/apps/web/.next/static ./apps/web/.next/static
|
||||||
|
|
||||||
|
USER node
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "apps/web/server.js"]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { isApiError } from '@impuestos/contracts';
|
||||||
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
|
import { getT } from '@/i18n/t';
|
||||||
|
import { redirect } from '@/i18n/navigation';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { LanguageSwitcher } from '@/components/language-switcher';
|
||||||
|
import { serverApi } from '@/lib/api-server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 0 shell. It exists to prove the whole chain end to end: browser to the Next
|
||||||
|
* rewrite, to the API, through the typed client, with the session cookie intact.
|
||||||
|
* The real dashboard (FLOWS.md Flow C) replaces this in phase 4.
|
||||||
|
*/
|
||||||
|
export default async function InicioPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
const t = await getT(locale);
|
||||||
|
|
||||||
|
const api = await serverApi();
|
||||||
|
const profile = await api.getProfile().catch((error: unknown) => {
|
||||||
|
if (!isApiError(error)) throw error;
|
||||||
|
// 404 is the documented state for a user who has not finished setup yet.
|
||||||
|
if (error.code === 'not_found') return null;
|
||||||
|
if (error.code === 'unauthorized') redirect({ href: '/login', locale });
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex min-h-dvh max-w-2xl flex-col">
|
||||||
|
<header className="flex items-center justify-between px-5 py-4">
|
||||||
|
<h1 className="text-base font-semibold tracking-tight">{t('home.title')}</h1>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="px-5 pb-16">
|
||||||
|
<Card className="space-y-2">
|
||||||
|
{profile ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">{t('setup.fullName')}</p>
|
||||||
|
<p className="text-2xl font-semibold tracking-tight">{profile.fullName}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2 className="text-xl font-semibold tracking-tight">{t('setup.step1.title')}</h2>
|
||||||
|
<p className="text-sm text-[var(--text-muted)]">{t('setup.income.help')}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { LanguageSwitcher } from '@/components/language-switcher';
|
||||||
|
import { useT } from '@/i18n/t';
|
||||||
|
|
||||||
|
export default function AuthLayout({ children }: { children: ReactNode }) {
|
||||||
|
const t = useT();
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh flex-col">
|
||||||
|
<header className="flex items-center justify-between px-5 py-4">
|
||||||
|
<span className="text-base font-semibold tracking-tight">{t('common.appName')}</span>
|
||||||
|
<LanguageSwitcher />
|
||||||
|
</header>
|
||||||
|
<main className="flex flex-1 items-center justify-center px-5 pb-16">
|
||||||
|
<div className="w-full max-w-sm">{children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from '@/i18n/navigation';
|
||||||
|
import { useT } from '@/i18n/t';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { authClient } from '@/lib/auth-client';
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const t = useT();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const signIn = useMutation({
|
||||||
|
mutationFn: async (form: { email: string; password: string }) => {
|
||||||
|
const { error } = await authClient.signIn.email(form);
|
||||||
|
if (error) throw new Error(error.message ?? 'sign_in_failed');
|
||||||
|
},
|
||||||
|
onSuccess: () => router.push('/inicio'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight text-balance">{t('auth.login.title')}</h1>
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="space-y-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const data = new FormData(event.currentTarget);
|
||||||
|
signIn.mutate({
|
||||||
|
email: String(data.get('email') ?? ''),
|
||||||
|
password: String(data.get('password') ?? ''),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="email">{t('auth.register.email')}</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
aria-invalid={signIn.isError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="password">{t('auth.login.password')}</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
aria-invalid={signIn.isError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{signIn.isError ? (
|
||||||
|
<p role="alert" className="text-sm text-overdue">
|
||||||
|
{t('auth.login.failed')}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button type="submit" size="lg" block disabled={signIn.isPending}>
|
||||||
|
{signIn.isPending ? t('common.loading') : t('auth.login.submit')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
|
import { LoginForm } from './login-form';
|
||||||
|
|
||||||
|
export default async function LoginPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
|
setRequestLocale(locale);
|
||||||
|
return <LoginForm />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { isLocale } from '@impuestos/i18n';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { Inter } from 'next/font/google';
|
||||||
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
|
import { setRequestLocale } from 'next-intl/server';
|
||||||
|
import { getT } from '@/i18n/t';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Providers } from '@/components/providers';
|
||||||
|
import { routing } from '@/i18n/routing';
|
||||||
|
import '../globals.css';
|
||||||
|
|
||||||
|
// Self hosted by next/font: no third party font request at runtime, which keeps the
|
||||||
|
// CSP tight (SPEC.md section 14).
|
||||||
|
const inter = Inter({ subsets: ['latin'], variable: '--font-inter', display: 'swap' });
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
return routing.locales.map((locale) => ({ locale }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(props: {
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}): Promise<Metadata> {
|
||||||
|
const { locale } = await props.params;
|
||||||
|
const t = await getT(locale);
|
||||||
|
return { title: t('common.appName') };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LocaleLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
params: Promise<{ locale: string }>;
|
||||||
|
}) {
|
||||||
|
const { locale } = await params;
|
||||||
|
if (!isLocale(locale)) notFound();
|
||||||
|
setRequestLocale(locale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang={locale} className={inter.variable} suppressHydrationWarning>
|
||||||
|
<body className="min-h-dvh font-sans antialiased">
|
||||||
|
<NextIntlClientProvider>
|
||||||
|
<Providers>{children}</Providers>
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { redirect } from '@/i18n/navigation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The landing page (FLOWS.md Flow A1) is built in phase 2. Until then the root goes
|
||||||
|
* straight to sign in so the shell is reachable.
|
||||||
|
*/
|
||||||
|
export default async function LandingPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||||
|
const { locale } = await params;
|
||||||
|
redirect({ href: '/login', locale });
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single origin proxy: the browser only ever talks to the web origin, and everything
|
||||||
|
* under /api is forwarded to the API container. Cookies stay first party, so there is
|
||||||
|
* no CORS anywhere in v1.
|
||||||
|
*
|
||||||
|
* SPEC-GAP: SPEC.md section 2 specifies Next rewrites for this. Next bakes rewrite
|
||||||
|
* destinations into the build manifest, which would make API_INTERNAL_URL a build time
|
||||||
|
* value and stop one image from running in both compose and k8s. A route handler reads
|
||||||
|
* it per request instead, which is what "all configuration via .env" requires.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
/** Set by the proxy or the runtime, never forwarded verbatim. */
|
||||||
|
const STRIPPED_REQUEST_HEADERS = new Set([
|
||||||
|
'host',
|
||||||
|
'connection',
|
||||||
|
'content-length',
|
||||||
|
'transfer-encoding',
|
||||||
|
'accept-encoding',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const STRIPPED_RESPONSE_HEADERS = new Set(['content-encoding', 'content-length', 'transfer-encoding']);
|
||||||
|
|
||||||
|
function apiBaseUrl(): string {
|
||||||
|
return (process.env['API_INTERNAL_URL'] ?? 'http://localhost:4000').replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxy(request: NextRequest): Promise<Response> {
|
||||||
|
const incoming = new URL(request.url);
|
||||||
|
const target = `${apiBaseUrl()}${incoming.pathname}${incoming.search}`;
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
request.headers.forEach((value, key) => {
|
||||||
|
if (!STRIPPED_REQUEST_HEADERS.has(key.toLowerCase())) headers.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
|
||||||
|
const upstream = await fetch(target, {
|
||||||
|
method: request.method,
|
||||||
|
headers,
|
||||||
|
redirect: 'manual',
|
||||||
|
...(hasBody ? { body: request.body, duplex: 'half' } : {}),
|
||||||
|
} as RequestInit & { duplex?: 'half' });
|
||||||
|
|
||||||
|
const responseHeaders = new Headers();
|
||||||
|
upstream.headers.forEach((value, key) => {
|
||||||
|
const name = key.toLowerCase();
|
||||||
|
if (name === 'set-cookie') return;
|
||||||
|
if (!STRIPPED_RESPONSE_HEADERS.has(name)) responseHeaders.set(key, value);
|
||||||
|
});
|
||||||
|
// Session and CSRF cookies arrive as several Set-Cookie headers and must stay separate.
|
||||||
|
for (const cookie of upstream.headers.getSetCookie()) {
|
||||||
|
responseHeaders.append('set-cookie', cookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(upstream.body, { status: upstream.status, headers: responseHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = proxy;
|
||||||
|
export const POST = proxy;
|
||||||
|
export const PUT = proxy;
|
||||||
|
export const PATCH = proxy;
|
||||||
|
export const DELETE = proxy;
|
||||||
|
export const HEAD = proxy;
|
||||||
|
export const OPTIONS = proxy;
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Design tokens, FLOWS.md section 1. Calm fintech: one accent, four semantic status
|
||||||
|
* colors used consistently everywhere, generous whitespace, big confident numbers.
|
||||||
|
*/
|
||||||
|
@theme {
|
||||||
|
--font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||||
|
|
||||||
|
/* Accent: deep teal. Primary actions and links only, nothing else. */
|
||||||
|
--color-accent-50: oklch(0.97 0.02 190);
|
||||||
|
--color-accent-100: oklch(0.93 0.04 190);
|
||||||
|
--color-accent-200: oklch(0.87 0.07 190);
|
||||||
|
--color-accent-400: oklch(0.68 0.11 190);
|
||||||
|
--color-accent-500: oklch(0.58 0.11 190);
|
||||||
|
--color-accent-600: oklch(0.48 0.1 191);
|
||||||
|
--color-accent-700: oklch(0.4 0.085 192);
|
||||||
|
--color-accent-900: oklch(0.27 0.055 194);
|
||||||
|
|
||||||
|
/* Status. positive = a favor, al dia. attention = action required.
|
||||||
|
overdue = missed deadlines and invalid documents ONLY, never "tax to pay".
|
||||||
|
neutral = everything else. */
|
||||||
|
--color-positive: oklch(0.62 0.14 155);
|
||||||
|
--color-positive-soft: oklch(0.95 0.04 155);
|
||||||
|
--color-attention: oklch(0.75 0.15 78);
|
||||||
|
--color-attention-soft: oklch(0.96 0.05 85);
|
||||||
|
--color-overdue: oklch(0.58 0.19 25);
|
||||||
|
--color-overdue-soft: oklch(0.95 0.04 25);
|
||||||
|
--color-neutral-fg: oklch(0.45 0.02 250);
|
||||||
|
|
||||||
|
--radius-card: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Explicit toggle wins over the system preference in both directions (FLOWS.md
|
||||||
|
section 1: dark mode from system preference plus a toggle). */
|
||||||
|
@custom-variant dark (&:where([data-theme='dark'], [data-theme='dark'] *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--surface: oklch(0.99 0.003 250);
|
||||||
|
--surface-raised: oklch(1 0 0);
|
||||||
|
--border-subtle: oklch(0.92 0.005 250);
|
||||||
|
--text: oklch(0.22 0.015 255);
|
||||||
|
--text-muted: oklch(0.52 0.015 255);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme='light']) {
|
||||||
|
--surface: oklch(0.18 0.012 255);
|
||||||
|
--surface-raised: oklch(0.23 0.014 255);
|
||||||
|
--border-subtle: oklch(0.31 0.012 255);
|
||||||
|
--text: oklch(0.96 0.004 250);
|
||||||
|
--text-muted: oklch(0.72 0.012 255);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
--surface: oklch(0.18 0.012 255);
|
||||||
|
--surface-raised: oklch(0.23 0.014 255);
|
||||||
|
--border-subtle: oklch(0.31 0.012 255);
|
||||||
|
--text: oklch(0.96 0.004 250);
|
||||||
|
--text-muted: oklch(0.72 0.012 255);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
border-color: var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Every money figure is tabular. FLOWS.md section 1. */
|
||||||
|
.tnum {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-feature-settings: 'tnum';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Motion is purposeful and always interruptible. This disables the non essential
|
||||||
|
kind globally, which the GSAP context mirrors. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** Liveness for the web container. Renders nothing and never calls the API. */
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export function GET(): Response {
|
||||||
|
return Response.json({ ok: true });
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
import "./.next/types/root-params.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import createNextIntlPlugin from 'next-intl/plugin';
|
||||||
|
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'],
|
||||||
|
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.
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createNextIntlPlugin('./src/i18n/request.ts')(nextConfig);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "@impuestos/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --port 3000",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start --port 3000",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@impuestos/contracts": "workspace:*",
|
||||||
|
"@impuestos/i18n": "workspace:*",
|
||||||
|
"@tanstack/react-query": "^5.102.8",
|
||||||
|
"better-auth": "^1.7.2",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.40.0",
|
||||||
|
"next": "^16.3.4",
|
||||||
|
"next-intl": "^4.14.2",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"zod": "^4.5.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
|
"@types/node": "^26.4.1",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.7",
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
plugins: { '@tailwindcss/postcss': {} },
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import createMiddleware from 'next-intl/middleware';
|
||||||
|
import { routing } from './src/i18n/routing';
|
||||||
|
|
||||||
|
export default createMiddleware(routing);
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Everything except /api (forwarded to the API by the rewrite), Next internals and
|
||||||
|
// static files. Locale routing must not touch API requests.
|
||||||
|
matcher: ['/((?!api|healthz|_next|_vercel|.*\\..*).*)'],
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { SUPPORTED_LOCALES, type Locale } from '@impuestos/i18n';
|
||||||
|
import { Globe } from 'lucide-react';
|
||||||
|
import { useLocale } from 'next-intl';
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { usePathname, useRouter } from '@/i18n/navigation';
|
||||||
|
import { useT } from '@/i18n/t';
|
||||||
|
|
||||||
|
const LABEL_KEY = { es: 'common.languageEs', en: 'common.languageEn' } as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact globe menu, header on marketing and auth screens (FLOWS.md section 1).
|
||||||
|
* Switching keeps the current page: the locale lives in the URL.
|
||||||
|
*
|
||||||
|
* A native select rather than a popover: it is one dependency fewer, and the platform
|
||||||
|
* control is the better mobile and keyboard experience for a two item choice.
|
||||||
|
*/
|
||||||
|
export function LanguageSwitcher() {
|
||||||
|
const t = useT();
|
||||||
|
const locale = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className="relative inline-flex items-center gap-2 text-sm text-[var(--text-muted)]">
|
||||||
|
<Globe aria-hidden className="size-4" />
|
||||||
|
<span className="sr-only">{t('common.language')}</span>
|
||||||
|
<select
|
||||||
|
aria-label={t('common.language')}
|
||||||
|
className="cursor-pointer appearance-none rounded-full bg-transparent py-1 pr-6 pl-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600"
|
||||||
|
disabled={isPending}
|
||||||
|
value={locale}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = event.target.value as Locale;
|
||||||
|
startTransition(() => {
|
||||||
|
router.replace(pathname, { locale: next });
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{SUPPORTED_LOCALES.map((code) => (
|
||||||
|
<option key={code} value={code}>
|
||||||
|
{t(LABEL_KEY[code])}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<svg
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute right-1 size-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="m6 9 6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
/** All server state goes through TanStack Query. One client per browser session. */
|
||||||
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
|
const [client] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: { queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import type { ButtonHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const button = cva(
|
||||||
|
'inline-flex items-center justify-center gap-2 rounded-full font-medium transition-colors ' +
|
||||||
|
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-600 ' +
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
primary: 'bg-accent-600 text-white hover:bg-accent-700',
|
||||||
|
secondary: 'border bg-[var(--surface-raised)] hover:bg-accent-50',
|
||||||
|
ghost: 'hover:bg-accent-50',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
md: 'h-11 px-5 text-sm',
|
||||||
|
lg: 'h-13 px-6 text-base',
|
||||||
|
},
|
||||||
|
block: { true: 'w-full', false: '' },
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'primary', size: 'md', block: false },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof button>;
|
||||||
|
|
||||||
|
export function Button({ className, variant, size, block, ...props }: ButtonProps) {
|
||||||
|
return <button className={cn(button({ variant, size, block }), className)} {...props} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { HTMLAttributes } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-2xl border bg-[var(--surface-raised)] p-6 shadow-[0_1px_2px_rgba(0,0,0,0.04)]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { InputHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className={cn(
|
||||||
|
'h-12 w-full rounded-2xl border bg-[var(--surface-raised)] px-4 text-base',
|
||||||
|
'placeholder:text-[var(--text-muted)]',
|
||||||
|
'focus-visible:border-accent-500 focus-visible:outline-2 focus-visible:outline-offset-0',
|
||||||
|
'focus-visible:outline-accent-500/40',
|
||||||
|
'aria-invalid:border-overdue',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { LabelHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function Label({ className, ...props }: LabelHTMLAttributes<HTMLLabelElement>) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={cn('block text-sm font-medium text-[var(--text-muted)]', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createNavigation } from 'next-intl/navigation';
|
||||||
|
import { routing } from './routing';
|
||||||
|
|
||||||
|
/** Locale aware Link and router. Switching language keeps the current page. */
|
||||||
|
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { DEFAULT_LOCALE, catalogs, isLocale, unflatten } from '@impuestos/i18n';
|
||||||
|
import { getRequestConfig } from 'next-intl/server';
|
||||||
|
|
||||||
|
export default getRequestConfig(async ({ requestLocale }) => {
|
||||||
|
const requested = await requestLocale;
|
||||||
|
const locale = isLocale(requested) ? requested : DEFAULT_LOCALE;
|
||||||
|
return { locale, messages: unflatten(catalogs[locale]) };
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from '@impuestos/i18n';
|
||||||
|
import { defineRouting } from 'next-intl/routing';
|
||||||
|
|
||||||
|
/** Adding a locale is a catalog file plus SUPPORTED_LOCALES. This picks it up for free. */
|
||||||
|
export const routing = defineRouting({
|
||||||
|
locales: [...SUPPORTED_LOCALES],
|
||||||
|
defaultLocale: DEFAULT_LOCALE,
|
||||||
|
localePrefix: 'always',
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { type MessageKey, resolveKey } from '@impuestos/i18n';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
type Params = Record<string, string | number | Date>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thin wrapper over next-intl so components address messages by their COPY.md key.
|
||||||
|
*
|
||||||
|
* next-intl walks a nested message tree, and one COPY.md key (`decl.approve`) is both a
|
||||||
|
* message and a namespace. `resolveKey` maps those onto their real path so the calling
|
||||||
|
* code never has to know.
|
||||||
|
*/
|
||||||
|
export function useT(): (key: MessageKey, params?: Params) => string {
|
||||||
|
const t = useTranslations();
|
||||||
|
return (key, params) => t(resolveKey(key), params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getT(locale: string): Promise<(key: MessageKey, params?: Params) => string> {
|
||||||
|
const t = await getTranslations({ locale });
|
||||||
|
return (key, params) => t(resolveKey(key), params);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { createApiClient } from '@impuestos/contracts';
|
||||||
|
import { headers } from 'next/headers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server side client for Server Components. It calls the API container directly and
|
||||||
|
* forwards the incoming cookie, since a server render has no browser to do it.
|
||||||
|
*/
|
||||||
|
export async function serverApi() {
|
||||||
|
const incoming = await headers();
|
||||||
|
const cookie = incoming.get('cookie');
|
||||||
|
return createApiClient({
|
||||||
|
baseUrl: process.env['API_INTERNAL_URL'] ?? 'http://localhost:4000',
|
||||||
|
...(cookie ? { headers: { cookie } } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createApiClient } from '@impuestos/contracts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser client. The base URL is empty on purpose: requests go to `/api` on the web
|
||||||
|
* origin and the Next rewrite forwards them, so the session cookie stays first party.
|
||||||
|
*/
|
||||||
|
export const api = createApiClient();
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { adminClient } from 'better-auth/client/plugins';
|
||||||
|
import { createAuthClient } from 'better-auth/react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth runs entirely in the API. This is the client half only: it posts to /api/auth,
|
||||||
|
* which the web app proxies. No better-auth server code exists in this app.
|
||||||
|
*/
|
||||||
|
export const authClient = createAuthClient({
|
||||||
|
basePath: '/api/auth',
|
||||||
|
plugins: [adminClient()],
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]): string {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["node"],
|
||||||
|
"jsx": "preserve",
|
||||||
|
"allowJs": true,
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Combined mode (SPEC.md section 15): one VPS, SQLite, local file storage,
|
||||||
|
# the job poller inline in the API process. Everything except OCR works with no
|
||||||
|
# external services. Run from the repository root: docker compose up
|
||||||
|
name: impuestos
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Applies migrations and seeds the demo accounts, then exits. The API waits for it,
|
||||||
|
# so `docker compose up` on a clean machine comes up ready to sign in to.
|
||||||
|
migrate:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apps/api/Dockerfile
|
||||||
|
command: sh -c "node dist/db/migrate.cli.js && node dist/db/seed.cli.js"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
DATABASE_URL: sqlite:/app/data/app.db
|
||||||
|
STORAGE_LOCAL_PATH: /app/data/files
|
||||||
|
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-compose-development-secret-change-me-32}
|
||||||
|
BETTER_AUTH_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
||||||
|
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
||||||
|
volumes:
|
||||||
|
- data:/app/data
|
||||||
|
restart: 'no'
|
||||||
|
|
||||||
|
api:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apps/api/Dockerfile
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 4000
|
||||||
|
ROLE: server
|
||||||
|
# SQLite is single writer, so the poller stays in this process.
|
||||||
|
JOBS_INLINE: 'true'
|
||||||
|
DATABASE_URL: sqlite:/app/data/app.db
|
||||||
|
STORAGE_DRIVER: local
|
||||||
|
STORAGE_LOCAL_PATH: /app/data/files
|
||||||
|
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:-compose-development-secret-change-me-32}
|
||||||
|
BETTER_AUTH_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
||||||
|
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
||||||
|
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||||
|
DEFAULT_LOCALE: ${DEFAULT_LOCALE:-es}
|
||||||
|
volumes:
|
||||||
|
# One shared volume: the SQLite file and the local storage driver both live here.
|
||||||
|
- data:/app/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'node', '-e', "fetch('http://127.0.0.1:4000/readyz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 5s
|
||||||
|
depends_on:
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
# Long enough for the 25s request drain in src/index.ts to finish.
|
||||||
|
stop_grace_period: 30s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: apps/web/Dockerfile
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
# Cluster internal name. The browser never sees this.
|
||||||
|
API_INTERNAL_URL: http://api:4000
|
||||||
|
NEXT_PUBLIC_DEFAULT_LOCALE: ${DEFAULT_LOCALE:-es}
|
||||||
|
# The only port published: the browser talks to one origin and /api is proxied.
|
||||||
|
ports:
|
||||||
|
- '${WEB_PORT:-3000}:3000'
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'node', '-e', "fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 5s
|
||||||
|
depends_on:
|
||||||
|
api:
|
||||||
|
condition: service_healthy
|
||||||
|
stop_grace_period: 30s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
data:
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# The compose stack lives in deploy/. This makes `docker compose up` work from the
|
||||||
|
# repository root, which is where the build context is anchored.
|
||||||
|
include:
|
||||||
|
- deploy/docker-compose.yml
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# CONTRACTS.md: API Shapes, Error Envelope, Seed Data
|
||||||
|
|
||||||
|
Authoritative for `packages/contracts` (Zod schemas, types inferred) and `src/db/seed.ts`. Field names are final: the agent must not rename.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Conventions
|
||||||
|
|
||||||
|
- All endpoints return `Content-Type: application/json` except file/PDF streams.
|
||||||
|
- IDs: UUIDv7 strings. Dates: `YYYY-MM-DD`. Timestamps: ISO-8601 UTC. Money: integer guaranies.
|
||||||
|
- Success: the resource or `{ ok: true }`. Lists: `{ items: T[], total: number, cursor?: string }` (cursor pagination, page size 50).
|
||||||
|
- **Error envelope (every non-2xx):**
|
||||||
|
```ts
|
||||||
|
{ error: { code: string, message: string, field?: string, detail?: unknown } }
|
||||||
|
```
|
||||||
|
Codes: `validation_error`, `unauthorized`, `forbidden`, `not_found`, `conflict`, `rate_limited`, `ocr_unavailable`, `internal`. `message` is user-safe copy from the `packages/i18n` catalogs, localized to the requester (profile locale when authenticated, else `Accept-Language`, else `es`); technical detail only in `detail` and only in development.
|
||||||
|
- Auth: session cookie (Better Auth, served by apps/api through the web proxy). Role guard failures: 403 `forbidden`, never 404-masking in v1.
|
||||||
|
- Locale: `PUT /me/profile` accepts `locale: "es"|"en"` and it drives all server-generated content for that user (notifications, emails, digest).
|
||||||
|
|
||||||
|
## 2. Schemas (packages/contracts)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// enums
|
||||||
|
IrpCategory = "alimentacion"|"salud"|"educacion"|"vivienda"|"vestimenta"|"esparcimiento"|"vehiculo"|"familiares"
|
||||||
|
DocSource = "scan_qr"|"scan_ocr"|"manual"
|
||||||
|
DocStatus = "needs_review"|"confirmed"|"rejected"
|
||||||
|
DocKind = "factura"|"autofactura"|"nota_credito"|"nota_debito"|"boleta_resimple"|"otro"
|
||||||
|
Direction = "purchase"|"sale"
|
||||||
|
FormCode = "120"|"515"
|
||||||
|
|
||||||
|
DocumentDto = {
|
||||||
|
id, source: DocSource, status: DocStatus, cdc: string|null, docKind: DocKind,
|
||||||
|
direction: Direction, emitterRuc: string, emitterDv: string|null, emitterName: string,
|
||||||
|
receiverDoc: string|null, issueDate: string, currency: "PYG",
|
||||||
|
total: number, amountIva10: number, amountIva5: number, amountExenta: number,
|
||||||
|
iva10: number, iva5: number, supplierRegimeHint: "normal"|"resimple"|"unknown",
|
||||||
|
verifiedDnit: boolean, verificationStatus: "unverified"|"valid"|"invalid"|"error",
|
||||||
|
fileUrl: string|null, classification: ClassificationDto|null,
|
||||||
|
createdAt, confirmedAt: string|null, merged?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
ClassificationDto = {
|
||||||
|
ivaCreditEligible: boolean, ivaCreditAmount: number,
|
||||||
|
irpCategory: IrpCategory|"none", irpDeductibleAmount: number,
|
||||||
|
dependentId: string|null, confidence: number,
|
||||||
|
decidedBy: "auto"|"user"|"staff", rulesVersion: string, reasons: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfileDto = {
|
||||||
|
fullName: string, docType: "ruc"|"ci", ruc: string|null, rucDv: string|null, ci: string|null,
|
||||||
|
taxpayerKind: "individual"|"company", deadlineDigit: number,
|
||||||
|
obligations: { code: "iva_120"|"irp_515", active: boolean, since: string }[],
|
||||||
|
irpGrossEstimate: number|null, autoConfirmDays: number, locale: "es"|"en"
|
||||||
|
}
|
||||||
|
|
||||||
|
DashboardDto = {
|
||||||
|
iva: { period: string, aPagar: number, aFavor: number, debito: number, credito: number } | null,
|
||||||
|
irp: { year: string, projectedTax: number, monthDelta: number, belowThreshold: boolean } | null,
|
||||||
|
nextAction: { kind: "overdue"|"declaration_ready"|"deadline_soon"|"bandeja"|"all_clear",
|
||||||
|
dueDate?: string, form?: FormCode, count?: number, declarationId?: string },
|
||||||
|
insights: { id: string, kind: "deduction_gap"|"month_close"|"deadline_preview",
|
||||||
|
params: Record<string,string|number> }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
DeclarationDto = {
|
||||||
|
id, formCode: FormCode, period: string, status: "draft"|"ready"|"approved",
|
||||||
|
summary: Record<string, number>, // human numbers, keys per form (below)
|
||||||
|
values: { casilla: string, label: string, amount: number }[],
|
||||||
|
rulesVersion: string, documentCount: number, createdAt, approvedAt: string|null,
|
||||||
|
filedMarkedAt: string|null
|
||||||
|
}
|
||||||
|
// summary keys F120: sales, purchases, debito, credito, saldoAnterior, aPagar, saldoAFavor
|
||||||
|
// summary keys F515: grossIncome, perCategory (nested), capExcess, totalDeductions, netIncome, tax, effectiveRate
|
||||||
|
|
||||||
|
DeadlineDto = { obligation: "iva_120"|"irp_515", period: string, dueDate: string,
|
||||||
|
status: "upcoming"|"due_soon"|"overdue"|"done", declarationId: string|null }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Endpoints (method, path, request → response)
|
||||||
|
|
||||||
|
**Public**
|
||||||
|
- `GET /lookup/ruc/:number` → `{ valid: boolean, docType: "ruc"|"ci", base: string, dv: number|null, deadlineDigit: number, deadlineDay: number, nextDeadlines: string[3] }`. Rate limit 10/min/IP. Invalid: 200 with `valid:false` (the landing handles it inline, not as an error).
|
||||||
|
|
||||||
|
**Me**
|
||||||
|
- `GET /me/profile` → ProfileDto (404 `not_found` until setup complete; client routes to setup)
|
||||||
|
- `PUT /me/profile` body: ProfileDto minus deadlineDigit (derived) → ProfileDto
|
||||||
|
- `GET/POST/DELETE /me/dependents` standard CRUD, `{ id, displayName, relationship, active }`
|
||||||
|
- `POST /me/consents` `{ kind, granted: boolean }` → `{ ok }` (revocation of data_processing triggers logout + account freeze flow)
|
||||||
|
- `GET /me/data-export` → JSON file download (profile, dependents, documents, classifications, declarations, consents, notification prefs)
|
||||||
|
- `DELETE /me/account` `{ confirmText: string }` must equal user email → `{ ok }`
|
||||||
|
- `GET/PATCH /me/notification-prefs`, `POST /push/subscribe { subscription }`
|
||||||
|
|
||||||
|
**Documents**
|
||||||
|
- `POST /documents/scan` multipart: `file` (required), `qrPayload` (optional string) → DocumentDto (with `merged` when deduped). If no QR and OCR unavailable → 200 `{ needsManual: true, fileId }` (client opens manual form bound to fileId).
|
||||||
|
- `POST /documents/manual` body: manual fields + optional `fileId` → DocumentDto
|
||||||
|
- `GET /documents?month&direction&category&status&q&cursor` → list
|
||||||
|
- `GET /documents/:id` → DocumentDto
|
||||||
|
- `PATCH /documents/:id` (field edits; recomputes classification, decidedBy stays "user" once touched) → DocumentDto
|
||||||
|
- `POST /documents/:id/confirm` → DocumentDto; `POST /documents/:id/reject { reason }` → DocumentDto
|
||||||
|
- `PATCH /documents/:id/classification` `{ irpCategory?, ivaCreditEligible?, dependentId? }` → DocumentDto
|
||||||
|
|
||||||
|
**Dashboard, declarations, deadlines**
|
||||||
|
- `GET /dashboard` → DashboardDto; `POST /dashboard/insights/:id/dismiss` → `{ ok }`
|
||||||
|
- `GET /declarations?year` → list
|
||||||
|
- `POST /declarations/generate { formCode, period }` → DeclarationDto (409 `conflict` if approved one exists for period)
|
||||||
|
- `GET /declarations/:id` → DeclarationDto
|
||||||
|
- `POST /declarations/:id/approve` → DeclarationDto (regenerates values first; 409 if underlying documents changed since ready, message tells user to review again)
|
||||||
|
- `POST /declarations/:id/mark-filed` → DeclarationDto
|
||||||
|
- `GET /declarations/:id/pdf` → application/pdf stream
|
||||||
|
- `GET /deadlines/upcoming?months=12` → DeadlineDto[]
|
||||||
|
|
||||||
|
**Admin (staff+; role checks server-side, every handler writes audit)**
|
||||||
|
- `GET /admin/users/search?q` → `{ items: { id, email, fullName, doc, createdAt }[] }`
|
||||||
|
- `GET /admin/users/:id/overview` → `{ profile: ProfileDto, counts: { documents, needsReview, declarations }, recentErrors: IngestErrorDto[] }`
|
||||||
|
- `GET /admin/errors?stage&status&cursor` → list of `IngestErrorDto = { id, userId, documentId, stage, message, status, createdAt }` plus dead jobs surfaced as stage `"job"`
|
||||||
|
- `POST /admin/errors/:id/resolve { note }` → `{ ok }`
|
||||||
|
- `POST /admin/jobs/:id/retry` → `{ ok }`
|
||||||
|
- `GET /admin/audit?actor&action&subject&from&to&cursor` → list; `GET /admin/audit/export.csv`
|
||||||
|
- Superadmin only: `POST /admin/users/:id/role { role }` → `{ ok }`
|
||||||
|
|
||||||
|
## 4. Seed data (src/db/seed.ts, deterministic, faker seeded with fixed value)
|
||||||
|
|
||||||
|
Accounts (dev passwords printed to console, all pre-verified):
|
||||||
|
1. `superadmin@demo.local` (superadmin)
|
||||||
|
2. `staff@demo.local` (staff)
|
||||||
|
3. `maria@demo.local` (user): **the showcase account.** Individual, CI-based, IRP + IVA. RUC base `4123456` with computed DV. Profile: fullName "Maria Gonzalez", irpGrossEstimate 180,000,000, one dependent ("Lucas Gonzalez", hijo). Documents: 34 purchases across the current year matching keyword categories (6 SUPERMERCADO REAL, 4 FARMACIA CATEDRAL, 3 COLEGIO SAN JOSE, 3 PETROBRAS ESTACION 12, 2 INMOBILIARIA DEL SOL alquiler, 2 BOUTIQUE ANDREA, 2 CINE ITAU, 2 from a RESIMPLE supplier "DESPENSA DON JUAN" with regime hint resimple, rest mixed), amounts realistic (groceries 180,000 to 950,000; rent 2,800,000 monthly; fuel 300,000 to 450,000), IVA split correct per category (rent at 5%, most goods at 10%). 8 sale facturas (services) of 4,000,000 to 9,000,000 each with 10% IVA. States: 26 confirmed, 5 needs_review (2 of them low-confidence OCR), 3 in prior months fully confirmed forming one complete declarable month (previous month) with a ready F120. One approved F120 two months back (creates saldoAnterior history = 0).
|
||||||
|
4. `carlos@demo.local` (user): company (SRL), IVA only, sparse data: 6 documents, 2 needs_review, no IRP. Exists to test the IVA-only view.
|
||||||
|
- Ingest errors: 2 open rows (one ocr stage low-quality image, one qr_parse malformed) linked to maria.
|
||||||
|
- Audit: seeded role assignment entries.
|
||||||
|
- Fixture images: `scripts/fixtures.ts` renders 3 PNG "KUDEs" (simple HTML to PNG or canvas): 2 with valid QR payloads (CDCs generated with RULES.md section 4 fields matching two seeded documents) and 1 without QR (manual/OCR path testing). Committed under `/fixtures`.
|
||||||
|
|
||||||
|
## 5. Non-negotiable behaviors to test against these contracts
|
||||||
|
|
||||||
|
1. Scanning fixture 1 twice → second response `merged: true`, document count unchanged.
|
||||||
|
2. Confirming all of maria's needs_review docs changes `GET /dashboard` numbers deterministically (assert exact guaranies using RULES.md math).
|
||||||
|
3. `POST /declarations/generate` for maria's previous month F120 → summary equals `computeF120` over her seeded docs (write the expected numbers into the test).
|
||||||
|
4. Editing a confirmed document that belongs to a `ready` declaration flips the declaration back to `draft` and the dashboard next-action reflects it.
|
||||||
|
5. Staff hitting `GET /admin/users/:id/overview` produces exactly one `admin.user_lookup` audit row visible via `GET /admin/audit`.
|
||||||
|
6. `data-export` for maria contains every document id she owns and zero of carlos's.
|
||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
# COPY.md: UI Copy (es, Paraguayan voseo)
|
||||||
|
|
||||||
|
Authoritative copy source. The agent transfers these verbatim into the `es` catalog of `packages/i18n` (structure mirrors the sections below) and must not rewrite them. Strings not listed here follow the tone rules in section 0 and get added to the same catalog.
|
||||||
|
|
||||||
|
**Multi-language policy (day 1):** the `en` catalog ships COMPLETE alongside `es`, same keys (type-checked parity). The agent writes the English itself following section 0-EN: natural English, not literal translation. Both locales are first-class: language switcher in the header and profile, locale routing, and the API localizes its user-facing output (errors, notifications, emails) per the user's stored locale. Official form previews and PDFs stay Spanish in every locale (they mirror DNIT forms); the UI around them localizes. New locales are one catalog file.
|
||||||
|
|
||||||
|
## 0-EN. English tone rules
|
||||||
|
- Audience: expats and international users in Paraguay. Friendly, direct, second person.
|
||||||
|
- Keep Paraguayan tax terms in Spanish with a short gloss on first use per screen: "factura (invoice)", "vencimiento (due date)", RUC, IVA, IRP, Formulario 120/515 stay as-is.
|
||||||
|
- Same restraint rules as Spanish: no exclamation stacking, only ✓ and 🔥, no em dashes, money always `Gs. 1.234.567`.
|
||||||
|
- Example anchors (match this register): landing hero = "Your taxes, on autopilot." / subtitle = "Scan your facturas and we build your books, your deductions and your declarations. Never miss a vencimiento again." / bandeja empty = "Inbox clear ✓".
|
||||||
|
|
||||||
|
## 0. Tone rules
|
||||||
|
- Voseo always: "ingresá", "revisá", "tenés", "podés". Never "ingrese/usted", never "tú".
|
||||||
|
- Plain words, no tax jargon on primary surfaces. Jargon allowed inside "Ver detalle" layers and the form preview.
|
||||||
|
- Money always via formatGs: `Gs. 1.234.567`.
|
||||||
|
- Never blame the user. Errors say what happened and what to do next.
|
||||||
|
- No exclamation stacking, max one "!" per screen. Emojis: only ✓ and 🔥 (streaks), nowhere else.
|
||||||
|
- Never use em dashes. Use commas, colons or parentheses.
|
||||||
|
- Dates: "19 de septiembre", short form "19 sep".
|
||||||
|
|
||||||
|
## 1. Common
|
||||||
|
```
|
||||||
|
common.appName = Impuestos (placeholder, rename at brand time)
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
## 13. Legal pages (placeholders, human-written before launch)
|
||||||
|
`/legal/privacidad`, `/legal/terminos`: ship with clearly marked placeholder content ("Documento en preparacion") and a TODO in DECISIONS.md. Never generate fake legal text.
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
# FLOWS.md: Screens, Flows and Design System
|
||||||
|
|
||||||
|
Companion to PROMPT.md and SPEC.md. This file is authoritative for UX. Apply the `ui-ux-pro-max-skill` within these constraints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Design system
|
||||||
|
|
||||||
|
**Feel:** calm fintech. A clean banking app, not accounting software. Generous whitespace, one accent color, big confident numbers. Mobile-first (design at 390px, scale up), fully responsive, dark mode supported (system preference + toggle).
|
||||||
|
|
||||||
|
**Tokens (Tailwind theme):**
|
||||||
|
- Accent: deep teal family (pick one scale, use for primary actions and links only).
|
||||||
|
- Semantic status colors, used EVERYWHERE consistently:
|
||||||
|
- `positive` (green): a favor, al dia, savings found.
|
||||||
|
- `attention` (amber): action required, needs review, T-10 to T-2 deadlines.
|
||||||
|
- `overdue` (red): ONLY for missed deadlines and invalid documents. Never use red for "tax to pay". Owing tax on time is neutral.
|
||||||
|
- `neutral` (slate): everything else.
|
||||||
|
- Typography: Inter (self-hosted) for UI; tabular-nums for every money figure. Money display component `<Money value>` renders `Gs. 1.234.567`, large variant for headline numbers.
|
||||||
|
- Radius: rounded-2xl cards, rounded-full pills. Shadows: subtle, one elevation step.
|
||||||
|
- Spanish voseo in all copy ("Ingresá tu RUC", "Revisá tus facturas"). No tax jargon at surface level; technical terms live behind "Ver detalle".
|
||||||
|
- **Multi-language:** `es` default and `en` fully shipped (COPY.md policy). Language switcher: compact globe menu in the header on marketing/auth screens, and a row in `/perfil` when authenticated (persists to profile and drives notification language). Locale is in the URL (`/es/...`, `/en/...`); switching preserves the current page. Dates localize; money never does (always `Gs.`). Form previews and PDFs remain Spanish in both locales, with a small caption in the active locale: "Formato oficial DNIT (en español)".
|
||||||
|
|
||||||
|
**Loading:** boneyard skeletons on every data screen (`<Skeleton name>` per component: `dashboard-position`, `bandeja-card`, `doc-list-item`, `declaration-summary`, `admin-user-row`, etc.). Content loads never show spinners.
|
||||||
|
|
||||||
|
**Component inventory (build once in `/components`):** `Money`, `StatusChip` (al dia | por vencer | en revision | atrasado), `DeadlinePill` (countdown), `TraceableNumber` (tappable money that opens source drill-down sheet), `DocCard`, `CategoryPicker` (8 IRP categories as icon grid bottom sheet), `FormPreview` (renders form definition + values as an official-looking document), `EmptyState` (illustration + one action), `ConfettiMoment` (GSAP, used sparingly).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Flow A: Landing and onboarding
|
||||||
|
|
||||||
|
**A1. Landing `/`** hero: headline "Tus impuestos, en piloto automatico", subline about scanning facturas and never missing a vencimiento. Single input: "Ingresá tu RUC o CI" + button "Ver mi situacion". canvas-ui permitted here: ONE subtle full-hero effect (e.g. Liquid or Ripple) behind the content, disabled on `prefers-reduced-motion`, graceful static fallback. Below: three value cards, pricing placeholder, footer with legal pages.
|
||||||
|
|
||||||
|
**A2. Instant preview (no account yet):** on submit, call the public lookup. Show a personalized card: formatted document, taxpayer kind guess, detected deadline day ("Tus vencimientos caen el dia 19 de cada mes") with the next 3 dates. GSAP: card flips in, deadline dates stagger. CTA: "Crear mi cuenta gratis". Invalid RUC/CI: inline validation with the check-digit explanation, never a dead end.
|
||||||
|
|
||||||
|
**A3. Account creation:** email + password, then email OTP verification screen (6-digit input, auto-advance). Better Auth flows. Keep to one screen each, no marketing interruptions.
|
||||||
|
|
||||||
|
**A4. Consent:** one screen, plain language: what we store, why, retention, revocation. Two switches: data processing (required to continue), notifications (optional). Link to full policy. Grant writes `consents` + audit.
|
||||||
|
|
||||||
|
**A5. Profile setup (max 3 steps, progress dots):**
|
||||||
|
1. Confirm identity: full name, doc from A2 prefilled, taxpayer kind.
|
||||||
|
2. "¿Que obligaciones tenes?": two big toggle cards: "IVA mensual (tengo RUC activo)" and "IRP (gano mas de Gs. 80 millones al año)". Either, both, or "No estoy seguro" (picks IRP-only view, flag for review). If IRP: optional annual income estimate slider/input (for projections) + add dependents (name + relationship, skippable).
|
||||||
|
3. Notifications: enable push (browser prompt behind an explainer card), optional email/Telegram if configured.
|
||||||
|
|
||||||
|
**A6. First-run state:** land on `/inicio` with a guided empty state: "Escanea tu primera factura" big button + "o cargala a mano". After the first confirmed document, GSAP counter rolls the first savings number: this is the wow, protect it.
|
||||||
|
|
||||||
|
## 3. Flow B: Ingestion
|
||||||
|
|
||||||
|
**B1. Scan:** persistent FAB `[+ Escanear]` on all `(app)` screens (bottom right, above tab bar). Opens full-screen camera with frame guide and torch toggle; also "Subir archivo" (image/PDF). Client QR decode runs live; on QR hit: instant haptic + green frame flash, auto-capture, no shutter needed.
|
||||||
|
- QR path result sheet (target under 3 seconds): "Verificado ✓" badge (or "Registrado" when verification is off), emitter name/RUC, date, total, suggested classification with confidence chip. Buttons: "Confirmar" (primary), "Cambiar categoria", "Descartar".
|
||||||
|
- No QR detected after 4 seconds: hint chip "¿Factura sin QR? Sacale una foto igual" → shutter → OCR path: uploading state on the card (boneyard shimmer), then same result sheet with per-field confidence; low-confidence fields get amber underline and tap-to-edit.
|
||||||
|
- No OCR configured: straight to manual form (B3) with the photo attached.
|
||||||
|
- Duplicate: sheet says "Ya tenias esta factura" with the existing card and a merge note. Never an error tone.
|
||||||
|
|
||||||
|
**B2. Offline:** captures queue locally with a chip "Se sincroniza al conectarte"; sync silently, notify only on failure.
|
||||||
|
|
||||||
|
**B3. Manual entry:** one screen form, big numeric keypad for amounts, RUC field with live check-digit validation, date defaulting to today, IVA split auto-computed from total with editable override ("¿Todo al 10%?" quick toggle). Same result sheet after save.
|
||||||
|
|
||||||
|
**B4. Bandeja `/bandeja`:** card stack of `needs_review` documents, newest first, count badge in tab bar.
|
||||||
|
- Card shows: emitter, date, `<Money>` total, suggested category icon + label, confidence chip, thumbnail corner (tap to zoom).
|
||||||
|
- Gestures: swipe right = confirm (GSAP: card flies right with green check trail), swipe left = opens CategoryPicker sheet then confirms, swipe down = reject sheet ("No es mia" / "Duplicada" / "Otro"). Buttons mirror every gesture (accessibility + desktop).
|
||||||
|
- Desktop: keyboard J/K navigate, Enter confirm, 1-8 category, X reject.
|
||||||
|
- Auto-confirm notice: subtle footer "Las facturas con alta confianza se confirman solas en 7 dias" linking to the setting.
|
||||||
|
- Empty state: "Bandeja limpia ✓" with a small GSAP checkmark draw-on.
|
||||||
|
|
||||||
|
## 4. Flow C: Dashboard `/inicio` ("Mi situacion")
|
||||||
|
|
||||||
|
Three stacked zones:
|
||||||
|
1. **Position header (one card, swipeable between obligations):** IVA card: "IVA de agosto" + big `<Money>` a pagar / a favor (colored by sign, never red), sub-line "debito Gs. X, credito Gs. Y". IRP card: "IRP proyectado 2026" + big number + delta chip "bajaste Gs. 900.000 este mes". Numbers are `TraceableNumber`s: tap opens a bottom sheet listing contributing documents with amounts, each tappable through to detail. GSAP counter roll-up on first paint and on value change.
|
||||||
|
2. **Next action strip:** exactly ONE card. Priority: overdue > declaration ready > deadline within 10 days > bandeja count > all-clear ("Todo al dia ✓ Proximo vencimiento: 19 sep"). One primary button.
|
||||||
|
3. **Insight feed:** stack of dismissible cards, max 3 visible: deduction gap ("Casi no cargaste facturas de Educacion este año, son deducibles"), monthly close summary, deadline preview. Dismissals persist.
|
||||||
|
|
||||||
|
## 5. Flow D: Declarations
|
||||||
|
|
||||||
|
**D1. List `/declaraciones`:** grouped by year, rows: form badge (120/515), period, status chip, amount. Generate button for the current open period when enough data exists.
|
||||||
|
|
||||||
|
**D2. Review `/declaraciones/:id`:** two layers:
|
||||||
|
- **Human summary first:** "Vendiste Gs. 12.4M, compraste Gs. 8.1M. Te corresponde pagar **Gs. 430.000**." Plus 3-4 line breakdown with TraceableNumbers.
|
||||||
|
- **Form preview below:** `FormPreview` renders the official-style layout from the rules form definition, every casilla filled, monospace values, watermark "BORRADOR" until approved.
|
||||||
|
- Sticky footer: "Aprobar" (primary) + "Descargar PDF".
|
||||||
|
|
||||||
|
**D3. Approve:** confirmation sheet restating the number → on approve: ConfettiMoment (short, tasteful), status → approved, then the **guided filing checklist**: numbered steps to present it in Marangatu yourself, with copy buttons per casilla value and a final "Ya lo presente" check that records completion and schedules the payment reminder. canvas-ui permitted here (second and last spot): a brief celebratory effect on the success screen, reduced-motion safe.
|
||||||
|
|
||||||
|
**D4. F515 annual:** same pattern, preceded by a "Tu año" story screen: income, each deduction category with totals and small bars, the final tax, share-nothing (no social buttons, this is private).
|
||||||
|
|
||||||
|
## 6. Flow E: Documents and profile
|
||||||
|
|
||||||
|
**E1. `/comprobantes`:** filterable list (month, direction, category, status), monthly totals header, search by emitter. Row: DocCard compact. Detail: full data, image viewer, classification editor, audit trail of changes, delete (soft, confirm).
|
||||||
|
|
||||||
|
**E2. `/vencimientos`:** vertical timeline of upcoming deadlines (12 months), each with DeadlinePill, obligation, linked declaration state. Personal deadline day explained at top ("Por tu RUC terminado en 6, tus vencimientos caen el dia 19").
|
||||||
|
|
||||||
|
**E3. `/perfil`:** identity data, obligations toggles, dependents CRUD, income estimate, notification prefs (channel toggles + digest hour), auto-confirm setting, **"Tus datos"** section: what we store (plain list), consent status with revoke, "Descargar mis datos" (JSON export), "Eliminar mi cuenta" (danger, double confirm). This screen is a trust feature; give it real design attention.
|
||||||
|
|
||||||
|
## 7. Flow H: Admin `(admin)`
|
||||||
|
|
||||||
|
Plain, dense, desktop-first, shadcn tables. No playfulness here.
|
||||||
|
- **/usuarios:** search by email/RUC/name → results table → user overview: profile summary, document counts, recent activity, links to their error rows. Every search and view writes an audit row; a banner reminds staff "Todos los accesos quedan registrados".
|
||||||
|
- **/errores:** table of ingest_errors + dead jobs, filters by stage/status, row expand shows payload, actions: retry job, resolve with note.
|
||||||
|
- **/auditoria:** filterable audit table (actor, action, subject, date range), read-only, export CSV.
|
||||||
|
- Superadmin extra: role management on user overview (with confirm + audit).
|
||||||
|
|
||||||
|
## 8. Motion system (GSAP)
|
||||||
|
|
||||||
|
Principles: fast (150-300ms), purposeful, interruptible, `prefers-reduced-motion` disables all non-essential motion globally (CSS + GSAP context).
|
||||||
|
- Counter roll-ups on headline Money values (dashboard, declaration summary).
|
||||||
|
- Bandeja card physics: drag with rotation, fly-out on commit, next card scales up.
|
||||||
|
- Sheet/dialog transitions: spring-ish ease, no bounce overdose.
|
||||||
|
- Success moments only at: first document confirmed, declaration approved, "Ya lo presente". Nowhere else.
|
||||||
|
- Skeleton→content: crossfade 150ms (boneyard handles layout, GSAP the fade).
|
||||||
|
|
||||||
|
## 9. Notification doctrine
|
||||||
|
|
||||||
|
- Bundled daily digest (default 09:00 local) when there is anything: bandeja count, upcoming deadline, savings found.
|
||||||
|
- Immediate sends ONLY: deadline T-2 and T-0 ("Mañana vence tu IVA: Gs. 430.000 → Revisar"), declaration ready, filing confirmation.
|
||||||
|
- Every notification: one number + one action deep link. Never two notifications where one suffices.
|
||||||
|
- Channels per prefs; unconfigured channels hidden everywhere.
|
||||||
|
|
||||||
|
## 10. States checklist (every screen ships all four)
|
||||||
|
|
||||||
|
1. boneyard skeleton, 2. empty state with one clear action, 3. error state with retry (friendly copy, never a stack trace), 4. content. Playwright asserts empty and content states on the golden paths.
|
||||||
+241
@@ -0,0 +1,241 @@
|
|||||||
|
# RULES.md: Exact Tax Logic, Algorithms, Worked Examples and Test Vectors
|
||||||
|
|
||||||
|
Authoritative for `packages/rules`. Every algorithm here must be implemented exactly as written and covered by the tests listed. Anything marked `TODO-TAX-VERIFY` is implemented as written now, flagged in code, and listed in DECISIONS.md for human verification before production.
|
||||||
|
|
||||||
|
All money values are integer guaranies (branded type `Pyg`). No floats in any money path. Percentages are computed as integer math with explicit rounding rules (round half up to whole guarani unless stated).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Constants
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const RULES_VERSION = "1.0.0";
|
||||||
|
|
||||||
|
export const IVA_RATE_10 = 10;
|
||||||
|
export const IVA_RATE_5 = 5;
|
||||||
|
|
||||||
|
export const IRP_THRESHOLD_ANNUAL = 80_000_000; // Gs., registration threshold
|
||||||
|
export const IRP_BRACKET_1_LIMIT = 50_000_000; // 8% up to here
|
||||||
|
export const IRP_BRACKET_2_LIMIT = 150_000_000; // 9% for the tranche above 1 up to here, 10% above
|
||||||
|
export const IRP_RATE_1 = 8;
|
||||||
|
export const IRP_RATE_2 = 9;
|
||||||
|
export const IRP_RATE_3 = 10;
|
||||||
|
|
||||||
|
export const RESIMPLE_SUPPLIER_DEDUCTION_CAP_PCT = 1; // 1% of gross annual income
|
||||||
|
|
||||||
|
export const DEADLINE_DAY_BY_DIGIT: Record<number, number> = {
|
||||||
|
0: 7, 1: 9, 2: 11, 3: 13, 4: 15, 5: 17, 6: 19, 7: 21, 8: 23, 9: 25,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`TODO-TAX-VERIFY`: IRP tranche boundaries and the 80M threshold against the live DNIT tables for the current fiscal year.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. RUC and check digit
|
||||||
|
|
||||||
|
Paraguayan RUC: base number (up to 8 digits) + hyphen + verification digit (DV). CI holders use their CI as RUC base.
|
||||||
|
|
||||||
|
**DV algorithm (modulo 11, basis 2):**
|
||||||
|
1. Take the base digits, process right to left.
|
||||||
|
2. Multiply each digit by factors 2, 3, 4, 5, 6, 7, 8, 9, then cycle back to 2.
|
||||||
|
3. Sum the products. `r = sum % 11`.
|
||||||
|
4. `dv = r > 1 ? 11 - r : 0`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function computeRucDv(base: string): number;
|
||||||
|
export function validateRuc(base: string, dv: number): boolean; // also rejects non-digits, length 1..8
|
||||||
|
export function deadlineDigit(base: string): number; // last digit of base (NOT the dv)
|
||||||
|
```
|
||||||
|
|
||||||
|
`TODO-TAX-VERIFY`: confirm the algorithm against at least 5 real published RUCs (DNIT publishes RUC lists; the seed uses synthetic RUCs generated WITH this algorithm so internal consistency holds regardless).
|
||||||
|
|
||||||
|
**Tests:** compute and validate round-trip for 20 generated bases; reject wrong dv; reject letters; digit extraction ignores dv.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Calendario perpetuo
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function deadlineDay(digit: number): number; // table above, throws on out of range
|
||||||
|
export function nextDeadline(opts: {
|
||||||
|
digit: number;
|
||||||
|
obligation: "iva_120" | "irp_515";
|
||||||
|
from: Date; // "now"
|
||||||
|
}): { period: string; dueDate: Date };
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- `iva_120`: declares month M, due in month M+1 on the digit day. Example: August 2026 IVA, digit 6 → due 2026-09-19 (before roll rules).
|
||||||
|
- `irp_515`: declares year Y, due in March of Y+1 on the digit day.
|
||||||
|
- **Roll-forward:** if the computed date is a Saturday, Sunday, or a holiday (section 3.1), advance day by day until a business day.
|
||||||
|
- Timezone: all deadline math in `America/Asuncion`, dates stored as `YYYY-MM-DD`.
|
||||||
|
|
||||||
|
### 3.1 Holidays (hardcoded table, extend yearly)
|
||||||
|
|
||||||
|
Fixed every year: `01-01, 03-01, 05-01, 05-14, 05-15, 06-12, 08-15, 09-29, 12-08, 12-25`.
|
||||||
|
|
||||||
|
Movable (Holy Thursday and Good Friday), by year:
|
||||||
|
- 2026: `2026-04-02`, `2026-04-03`
|
||||||
|
- 2027: `2027-03-25`, `2027-03-26`
|
||||||
|
- 2028: `2028-04-13`, `2028-04-14`
|
||||||
|
|
||||||
|
`TODO-TAX-VERIFY`: government-decreed one-off holidays and "dias no laborables trasladables" per year; the table is data (`holidays.ts`), updating it is a data change, not a code change.
|
||||||
|
|
||||||
|
**Tests:** digit 0 vs digit 9 spread; due date landing on Saturday rolls to Monday; due date landing on 2026-04-02 rolls past both holidays to 2026-04-06 (Monday); December IVA due in January of next year; year boundary for IRP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. CDC parsing
|
||||||
|
|
||||||
|
CDC = exactly 44 digits:
|
||||||
|
|
||||||
|
| Field | Length | Offset |
|
||||||
|
|---|---|---|
|
||||||
|
| tipoDocumento | 2 | 0 |
|
||||||
|
| rucEmisor | 8 | 2 |
|
||||||
|
| dvEmisor | 1 | 10 |
|
||||||
|
| establecimiento | 3 | 11 |
|
||||||
|
| puntoExpedicion | 3 | 14 |
|
||||||
|
| numeroDocumento | 7 | 17 |
|
||||||
|
| tipoContribuyente | 1 | 24 |
|
||||||
|
| fechaEmision (YYYYMMDD) | 8 | 25 |
|
||||||
|
| tipoEmision | 1 | 33 |
|
||||||
|
| codigoSeguridad | 9 | 34 |
|
||||||
|
| digitoVerificador | 1 | 43 |
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function parseCdc(cdc: string): CdcFields; // throws TypedError on length/charset/date validity
|
||||||
|
```
|
||||||
|
|
||||||
|
tipoDocumento map (store label): `01` factura electronica, `04` autofactura, `05` nota de credito, `06` nota de debito, `07` nota de remision. Unknown codes: keep code, label "otro".
|
||||||
|
|
||||||
|
**Canonical test vector (synthetic, used by fixtures too):**
|
||||||
|
`01 80069563 1 001 001 0001234 1 20260815 1 123456789 4` → concatenated: `"01800695631001001000123412026081511234567894"`. Wait: build programmatically in tests from the field table (concatenate parts) rather than hardcoding a string, then assert round-trip parse. The fixtures script (`scripts/fixtures.ts`) must generate CDCs the same way.
|
||||||
|
|
||||||
|
**QR payload:** the KUDE QR is a URL. Treat it as: parse as URL, read query param `Id` = CDC (44 digits). If present, also read `dTotGralOpe` (total) and `dTotIVA` (total IVA) as integers when parseable. All other params are stored opaque in `documents.qr_url`. Any URL whose host is not recognizable is still accepted if `Id` parses as a valid CDC (offline QRs from test fixtures). If no `Id` param, try: the raw string IS a 44-digit CDC.
|
||||||
|
|
||||||
|
**Tests:** round-trip; invalid length; invalid date (20261340); QR URL with and without `Id`; raw-CDC QR.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Classification
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ClassificationInput {
|
||||||
|
direction: "purchase" | "sale";
|
||||||
|
docKind: string;
|
||||||
|
emitterName: string; // uppercase-normalized
|
||||||
|
emitterRuc: string;
|
||||||
|
supplierRegimeHint: "normal" | "resimple" | "unknown";
|
||||||
|
taxpayer: { kind: "individual" | "company"; hasIva: boolean; hasIrp: boolean };
|
||||||
|
amounts: { total: Pyg; iva10: Pyg; iva5: Pyg };
|
||||||
|
}
|
||||||
|
export interface ClassificationSuggestion {
|
||||||
|
ivaCreditEligible: boolean;
|
||||||
|
ivaCreditAmount: Pyg; // iva10 + iva5 when eligible, else 0
|
||||||
|
irpCategory: IrpCategory | "none";
|
||||||
|
irpDeductibleAmount: Pyg; // total when deductible, else 0
|
||||||
|
confidence: number; // 0..1
|
||||||
|
reasons: string[]; // human-readable, for the UI detail sheet
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**IVA credit logic (purchases only):**
|
||||||
|
- eligible = `taxpayer.hasIva && direction === "purchase" && supplierRegimeHint !== "resimple" && (iva10 + iva5) > 0 && docKind !== "boleta_resimple"`.
|
||||||
|
- v1 assumes business purpose = true for IVA taxpayers; the user can toggle it off per document in the UI (that toggle overrides, `decided_by='user'`).
|
||||||
|
|
||||||
|
**IRP category by emitter keyword map** (`categoryHints.ts`, data not code; match on normalized emitter name, first hit wins, order as listed):
|
||||||
|
|
||||||
|
| Category | Keywords (contains, case/diacritic-insensitive) |
|
||||||
|
|---|---|
|
||||||
|
| salud | FARMACIA, FARMA, CLINICA, SANATORIO, HOSPITAL, LABORATORIO, ODONTO, OPTICA |
|
||||||
|
| educacion | COLEGIO, ESCUELA, UNIVERSIDAD, INSTITUTO, ACADEMIA, LIBRERIA |
|
||||||
|
| alimentacion | SUPERMERCADO, SUPER, DESPENSA, ALMACEN, MINIMARKET, CARNICERIA, PANADERIA, RESTAURANT, RESTAURANTE, PIZZERIA, COMIDAS |
|
||||||
|
| vehiculo | PETROBRAS, SHELL, PUMA, ESTACION, COMBUSTIBLE, TALLER, GOMERIA, REPUESTOS, LUBRICANTES |
|
||||||
|
| vivienda | INMOBILIARIA, ALQUILER, CONDOMINIO, FERRETERIA, ELECTRICIDAD, SANITARIOS, ANDE, ESSAP |
|
||||||
|
| vestimenta | BOUTIQUE, TIENDA, CALZADOS, MODAS, CONFECCIONES |
|
||||||
|
| esparcimiento | CINE, TEATRO, CLUB, GIMNASIO, GYM, TURISMO, HOTEL |
|
||||||
|
| familiares | (never keyword-assigned; only user-assigned with a dependent) |
|
||||||
|
|
||||||
|
- Confidence: keyword hit = 0.85; no hit = 0.4 with `irpCategory` set to the amount-weighted default `"alimentacion"`? NO: no hit → `irpCategory: "none"`, confidence 0.4, reasons include "Sin categoria sugerida". Never guess a category without a keyword hit.
|
||||||
|
- Sales documents: `irpCategory: "none"`, they count as income, not deductions.
|
||||||
|
- `esparcimiento` Paraguay-only rule: v1 treats all ingested comprobantes as Paraguayan (they have RUCs); rule noted for future foreign-expense support.
|
||||||
|
|
||||||
|
**Tests:** every keyword row; resimple supplier blocks IVA credit but NOT IRP deduction; sale never deductible; no-hit yields none/0.4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Formulario 120 computation (monthly IVA)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function computeF120(input: {
|
||||||
|
period: string; // "2026-08"
|
||||||
|
saldoAnterior: Pyg; // credit carried from previous period, >= 0
|
||||||
|
documents: F120Doc[]; // confirmed docs whose issue_date is in period
|
||||||
|
}): F120Result;
|
||||||
|
```
|
||||||
|
|
||||||
|
- `debito = sum(iva10 + iva5)` over confirmed SALE documents in the period.
|
||||||
|
- `credito = sum(ivaCreditAmount)` over confirmed PURCHASE documents with `ivaCreditEligible` in the period.
|
||||||
|
- `creditoTotal = credito + saldoAnterior`.
|
||||||
|
- If `debito > creditoTotal`: `aPagar = debito - creditoTotal`, `saldoAFavor = 0`.
|
||||||
|
- Else: `aPagar = 0`, `saldoAFavor = creditoTotal - debito` (becomes next period's `saldoAnterior`).
|
||||||
|
|
||||||
|
**Worked example (used verbatim as a test):**
|
||||||
|
Sales: 3 facturas with iva10 = 400,000 / 500,000 / 227,273 → debito 1,127,273.
|
||||||
|
Purchases eligible: iva10 total 610,000, iva5 total 90,000 → credito 700,000. saldoAnterior 150,000 → creditoTotal 850,000.
|
||||||
|
Result: aPagar = 277,273, saldoAFavor = 0.
|
||||||
|
Flip test: same purchases, sales debito only 500,000 → aPagar 0, saldoAFavor 350,000.
|
||||||
|
|
||||||
|
Form definition `forms/f120.v1.ts` maps: ventas gravadas 10/5 (base amounts), debito fiscal, compras gravadas 10/5, credito fiscal, saldo anterior, monto a pagar, saldo a favor. Casilla numbers are placeholder strings `"c-ventas-10"` etc. with `TODO-TAX-VERIFY: replace with official casilla numbers from live Marangatu F120 v4`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Formulario 515 computation (annual IRP-RSP)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function computeF515(input: {
|
||||||
|
year: string;
|
||||||
|
grossIncome: Pyg; // from profile estimate in v1 (sales docs when present add to it, take max)
|
||||||
|
documents: F515Doc[]; // confirmed purchase docs of the year with irpCategory != "none"
|
||||||
|
hasResimpleFlag: (doc) => boolean; // supplierRegimeHint === "resimple"
|
||||||
|
}): F515Result;
|
||||||
|
```
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
1. Sum deductions per category from `irpDeductibleAmount`.
|
||||||
|
2. **RESIMPLE cap:** sum deductible amounts whose supplier is resimple; cap that subtotal at `floor(grossIncome * 1 / 100)`; excess is reported as `capExcess` (UI shows "Gs. X no deducible por tope del 1%").
|
||||||
|
3. `totalDeductions = sum(categories) - capExcess`.
|
||||||
|
4. `netIncome = max(0, grossIncome - totalDeductions)`.
|
||||||
|
5. **Tax by tranches** (`TODO-TAX-VERIFY`: progressive-by-tranche interpretation):
|
||||||
|
- tranche1 = min(netIncome, 50,000,000) * 8%
|
||||||
|
- tranche2 = min(max(netIncome - 50,000,000, 0), 100,000,000) * 9%
|
||||||
|
- tranche3 = max(netIncome - 150,000,000, 0) * 10%
|
||||||
|
- `tax = round(t1 + t2 + t3)`.
|
||||||
|
6. `effectiveRate` (2 decimals, display only). If `grossIncome < IRP_THRESHOLD_ANNUAL`, result includes `belowThreshold: true` and the UI frames the output as informative.
|
||||||
|
|
||||||
|
**Worked example (verbatim test):**
|
||||||
|
grossIncome 200,000,000. Deductions: alimentacion 18,000,000; salud 9,500,000; educacion 12,000,000; vivienda 24,000,000; vehiculo 6,500,000; of which 3,000,000 came from RESIMPLE suppliers. Cap = 2,000,000 → capExcess 1,000,000. totalDeductions = 70,000,000 - 1,000,000 = 69,000,000. netIncome = 131,000,000. Tax = 50,000,000*8% + 81,000,000*9% = 4,000,000 + 7,290,000 = **11,290,000**. effectiveRate on gross = 5.65%.
|
||||||
|
|
||||||
|
**Bracket edge tests:** netIncome 49,999,999 / 50,000,000 / 50,000,001 / 150,000,000 / 150,000,001; zero income; deductions exceeding income floor at 0.
|
||||||
|
|
||||||
|
Form definition `forms/f515.v1.ts`: income, one line per category, cap adjustment line, net, tax, with placeholder casillas and the same TODO.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Projections (dashboard)
|
||||||
|
|
||||||
|
- IRP projection for year Y at date D: `computeF515` with year-to-date documents, grossIncome = profile estimate (fallback: annualized YTD sales when no estimate). Label clearly "proyeccion".
|
||||||
|
- "Savings this month" delta = tax with current deductions minus tax with deductions excluding the current month's confirmed docs.
|
||||||
|
- IVA month position = `computeF120` over the open month with live (confirmed) docs, `saldoAnterior` from the last approved F120 declaration (0 when none).
|
||||||
|
|
||||||
|
## 9. OCR extraction schema (documents module, not rules, listed here for completeness)
|
||||||
|
|
||||||
|
Zod schema the Anthropic call must return (strict JSON, temperature 0):
|
||||||
|
`{ emitter_ruc: string|null, emitter_dv: string|null, emitter_name: string|null, receiver_doc: string|null, doc_number: string|null, issue_date: "YYYY-MM-DD"|null, total: int|null, amount_iva10: int|null, amount_iva5: int|null, amount_exenta: int|null, iva10: int|null, iva5: int|null, confidence: { [field]: number } }`
|
||||||
|
Prompt requirements: instruct that Paraguayan facturas print IVA columns as "10%", "5%", "Exentas"; amounts use dots as thousand separators; return integers without separators; null when unreadable, never guess. Validate: if `total` present and components present, assert `|total - (base10+base5+exenta)| tolerance 1 Gs` where derivable; on mismatch lower confidence of money fields to 0.5.
|
||||||
|
|
||||||
|
## 10. Test coverage bar
|
||||||
|
|
||||||
|
`packages/rules`: 100% line coverage target, every worked example verbatim, every `TODO-TAX-VERIFY` has a test pinning current behavior (so verification later is a red/green diff, not archaeology).
|
||||||
+243
@@ -0,0 +1,243 @@
|
|||||||
|
# SPEC.md: Paraguay Tax Platform v1, Technical Specification
|
||||||
|
|
||||||
|
Companion to PROMPT.md (constraints, phases), FLOWS.md (UX), RULES.md (tax logic), COPY.md (copy/i18n), CONTRACTS.md (API shapes, seed). This file defines architecture, data, modules, configuration and deployment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Purpose and domain summary
|
||||||
|
|
||||||
|
Users are Paraguayan taxpayers identified by RUC (companies/independents) or CI (individuals). The platform ingests purchase/sale comprobantes, classifies them for IVA credit and IRP deductions, shows a live tax position, and produces pre-filled declarations:
|
||||||
|
|
||||||
|
- **Formulario 120:** monthly IVA declaration (debito from sales, credito from purchases).
|
||||||
|
- **Formulario 515:** annual IRP-RSP declaration (income minus deductible personal/family expenses).
|
||||||
|
|
||||||
|
Deadlines follow the **calendario perpetuo** (RULES.md section 3). Tax math, CDC parsing, classification and form computation live exclusively in `packages/rules` (RULES.md is authoritative). All money is integer guaranies, no floats anywhere in money paths.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architecture overview
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser ──► apps/web (Next.js, stateless)
|
||||||
|
│ Next rewrites: /api/* ──► API_INTERNAL_URL
|
||||||
|
▼
|
||||||
|
apps/api (Hono on Node, stateless)
|
||||||
|
├── Better Auth (sessions in DB)
|
||||||
|
├── Kysely ──► SQLite file OR Postgres
|
||||||
|
├── StorageDriver ──► local disk OR S3-compatible
|
||||||
|
├── Jobs (portable table + poller; inline or dedicated worker)
|
||||||
|
└── Notifications (push/email/telegram fan-out)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `apps/api` is the ONLY process touching the database and storage. It serves everything under `/api`, including the Better Auth routes.
|
||||||
|
- `apps/web` renders UI, holds zero secrets beyond `API_INTERNAL_URL`, and consumes the typed client from `packages/contracts`. Server Components may call the API server-side (forwarding cookies); all mutations go through TanStack Query on the client.
|
||||||
|
- Single-origin model: the browser only ever sees the web origin; the web app proxies `/api/*`. No CORS in v1. Exposing the API directly (mobile apps) is a future concern; do not add CORS scaffolding now.
|
||||||
|
- The dedicated worker is the same `apps/api` image started with `ROLE=worker`: it runs migrations check, the job poller and sweeps, and serves only `/healthz`. With `ROLE=server` (default) the poller runs inline only when `JOBS_INLINE=true` (default true, set false when a dedicated worker exists).
|
||||||
|
|
||||||
|
## 3. Stack
|
||||||
|
|
||||||
|
| Layer | Choice |
|
||||||
|
|---|---|
|
||||||
|
| API | Hono on Node 22, `@hono/node-server` |
|
||||||
|
| Frontend | Next.js latest stable, App Router |
|
||||||
|
| Language | TypeScript strict everywhere |
|
||||||
|
| DB | Kysely; SQLite (better-sqlite3) default, Postgres (pg) supported |
|
||||||
|
| Auth | Better Auth in apps/api (email+password, email OTP, admin plugin) |
|
||||||
|
| i18n | packages/i18n catalogs; next-intl in web; same catalogs in api for errors/notifications |
|
||||||
|
| Server state | TanStack Query over the typed hono/client |
|
||||||
|
| UI | Tailwind + shadcn/ui + boneyard + GSAP + canvas-ui (2 spots) |
|
||||||
|
| Validation | Zod at every boundary |
|
||||||
|
| PDF | pdf-lib |
|
||||||
|
| QR | BarcodeDetector + zxing-wasm fallback (client-side) |
|
||||||
|
| OCR | Anthropic API behind OcrProvider; disabled gracefully without key |
|
||||||
|
| Jobs | Portable jobs table + poller (section 10) |
|
||||||
|
| Tests | Vitest, Playwright; CI matrix sqlite+postgres |
|
||||||
|
| Monorepo | pnpm workspaces: apps/api, apps/web, packages/rules, packages/contracts, packages/i18n |
|
||||||
|
|
||||||
|
Pure packages (`rules`, `contracts`, `i18n`) have zero runtime deps besides Zod, no I/O, importable everywhere.
|
||||||
|
|
||||||
|
## 4. Repository layout and module boundaries
|
||||||
|
|
||||||
|
```
|
||||||
|
/apps/api
|
||||||
|
/src
|
||||||
|
/modules
|
||||||
|
/pii profiles, dependents, consents (ONLY module touching pii tables)
|
||||||
|
/documents comprobantes, files, ingestion pipeline
|
||||||
|
/classification rule application, bandeja logic
|
||||||
|
/declarations F120/F515 assembly, PDF
|
||||||
|
/deadlines calendario perpetuo, upcoming obligations
|
||||||
|
/notifications push/email/telegram fan-out, localized templates
|
||||||
|
/audit append-only audit writes + queries
|
||||||
|
/jobs poller, claim, handlers, sweeps
|
||||||
|
/storage StorageDriver: local | s3
|
||||||
|
/admin admin services (compose pii+audit)
|
||||||
|
/auth better-auth config, role guards
|
||||||
|
/db kysely factories, migrations, seed
|
||||||
|
/http hono app, routes, error envelope, healthz/readyz
|
||||||
|
/lib env, cdc helpers, dates, formatGs (re-export from i18n)
|
||||||
|
Dockerfile
|
||||||
|
/apps/web
|
||||||
|
/app
|
||||||
|
/[locale]
|
||||||
|
/(marketing) landing, RUC hook, legal
|
||||||
|
/(auth) login, register, verify
|
||||||
|
/(app) inicio, bandeja, comprobantes, declaraciones, vencimientos, perfil
|
||||||
|
/(admin) usuarios, errores, auditoria
|
||||||
|
/src (components, query hooks, i18n wiring, pwa)
|
||||||
|
Dockerfile
|
||||||
|
/packages/rules tax math, form definitions, calendario (pure, RULES.md)
|
||||||
|
/packages/contracts zod schemas + typed client (CONTRACTS.md)
|
||||||
|
/packages/i18n catalogs es/en, typed t(), formatGs, date formatting per locale
|
||||||
|
/deploy
|
||||||
|
docker-compose.yml combined mode
|
||||||
|
/k8s api.yaml, web.yaml, worker.yaml, ingress.yaml, configmap-example.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
**Boundary rules (eslint no-restricted-imports):** only `modules/pii` imports pii table types; UI never imports Kysely (web cannot: no DB deps in its package.json); `packages/*` import nothing from apps; user-facing strings only via `packages/i18n` (lint rule bans string literals in JSX text positions outside catalogs, allowlist for punctuation).
|
||||||
|
|
||||||
|
## 5. Database
|
||||||
|
|
||||||
|
Dialect from `DATABASE_URL` scheme. One Kysely `Database` interface (`apps/api/src/db/schema.ts`); factories `createSqliteDb` / `createPostgresDb`. Portable migrations via Kysely migrator:
|
||||||
|
- ids text UUIDv7 (app-generated), timestamps text ISO-8601 UTC, money integer guaranies, json text + Zod parse helper, booleans integer 0/1.
|
||||||
|
- Dialect-specific SQL only in the two factories and `jobs/claim.ts`.
|
||||||
|
- SQLite pragmas at boot: `journal_mode=WAL`, `busy_timeout=5000`, `foreign_keys=ON`.
|
||||||
|
- CI runs the whole suite on both dialects.
|
||||||
|
|
||||||
|
**Tables** (Better Auth manages its own; locale note: `profiles.locale` drives all server-side localization):
|
||||||
|
|
||||||
|
`profiles` (pii): `user_id` PK/FK, `full_name`, `doc_type` ('ruc'|'ci'), `ruc`, `ruc_dv`, `ci`, `taxpayer_kind` ('individual'|'company'), `deadline_digit` 0-9, `obligations` json [{code:'iva_120'|'irp_515', active, since}], `irp_gross_estimate` int null, `auto_confirm_days` int default 7 (0=off), `locale` ('es'|'en') default 'es', timestamps.
|
||||||
|
|
||||||
|
`dependents` (pii): `id`, `user_id`, `display_name`, `relationship` ('conyuge'|'hijo'|'padre'|'otro'), `doc_number` null, `active`, timestamps.
|
||||||
|
|
||||||
|
`consents` (pii): `id`, `user_id`, `kind` ('data_processing'|'notifications'), `granted_at`, `revoked_at` null, `text_version`.
|
||||||
|
|
||||||
|
`document_files`: `id`, `driver` ('local'|'s3'), `path`, `mime`, `size`, `sha256`, `created_at`.
|
||||||
|
|
||||||
|
`documents`: `id`, `user_id`, `source` ('scan_qr'|'scan_ocr'|'manual'), `status` ('needs_review'|'confirmed'|'rejected'), `cdc` null, `qr_url` null, `doc_kind` ('factura'|'autofactura'|'nota_credito'|'nota_debito'|'boleta_resimple'|'otro'), `direction` ('purchase'|'sale'), `emitter_ruc`, `emitter_dv` null, `emitter_name`, `receiver_doc` null, `issue_date`, `currency` 'PYG', `total`, `amount_iva10`, `amount_iva5`, `amount_exenta`, `iva10`, `iva5`, `supplier_regime_hint` ('normal'|'resimple'|'unknown'), `verified_dnit` bool, `verification_status` ('unverified'|'valid'|'invalid'|'error'), `dedupe_hash`, `file_id` null, `raw_extraction` json null, `created_at`, `confirmed_at` null. Unique `(user_id, dedupe_hash)`.
|
||||||
|
|
||||||
|
`classifications`: `document_id` PK/FK, `iva_credit_eligible`, `iva_credit_amount`, `irp_category` (8 categories | 'none'), `irp_deductible_amount`, `dependent_id` null, `confidence` real, `decided_by` ('auto'|'user'|'staff'), `rules_version`, `updated_at`.
|
||||||
|
|
||||||
|
`declarations`: `id`, `user_id`, `form_code` ('120'|'515'), `period`, `status` ('draft'|'ready'|'approved'), `values` json, `summary` json, `pdf_file_id` null, `rules_version`, `document_ids` json, `created_at`, `approved_at` null, `filed_marked_at` null. Unique `(user_id, form_code, period)`.
|
||||||
|
|
||||||
|
`jobs`: `id`, `type`, `payload` json, `status` ('pending'|'running'|'done'|'failed'|'dead'), `run_at`, `attempts`, `max_attempts` default 5, `locked_by` null, `locked_at` null, `last_error` null, timestamps. Index `(status, run_at)`.
|
||||||
|
|
||||||
|
`ingest_errors`: `id`, `user_id` null, `document_id` null, `stage` ('qr_parse'|'ocr'|'dedupe'|'verify'|'job'|'other'), `message`, `payload` json, `status` ('open'|'resolved'), `resolved_by` null, `resolved_at` null, `created_at`.
|
||||||
|
|
||||||
|
`audit_log` (append-only): `id`, `actor_user_id`, `actor_role`, `action`, `subject_user_id` null, `resource`, `detail` json, `ip` null, `created_at`.
|
||||||
|
|
||||||
|
`notification_prefs`: `user_id` PK, `push_enabled`, `email_enabled`, `telegram_chat_id` null, `digest_hour` default 9.
|
||||||
|
|
||||||
|
`push_subscriptions`: `id`, `user_id`, `endpoint`, `keys` json, `created_at`.
|
||||||
|
|
||||||
|
## 6. Auth and roles
|
||||||
|
|
||||||
|
Better Auth in apps/api: email+password, email OTP verification. Roles: `user` (default), `accountant` (dormant), `staff`, `superadmin`. Sessions DB-backed (stateless replicas). Cookies: httpOnly, sameSite=lax, secure in production; issued on the web origin because auth routes are proxied like everything else. Role checks re-validated in every handler. Superadmin role changes audited. Rate limiting: token bucket keyed by IP, in-memory per replica in v1 behind a `RateLimiter` interface (per-replica limits are acceptable at this scale; note in README).
|
||||||
|
|
||||||
|
## 7. Storage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface StorageDriver {
|
||||||
|
put(key, data, mime): Promise<void>;
|
||||||
|
getStream(key): Promise<ReadableStream>;
|
||||||
|
delete(key): Promise<void>;
|
||||||
|
url(key): Promise<string>; // signed URL (s3) or authenticated api route (local)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
LocalDriver under `STORAGE_LOCAL_PATH/<userId>/<uuid>`, served via authenticated `/api/files/:id` (ownership or staff, audited for staff). S3Driver via AWS SDK v3, any S3-compatible endpoint, path-style option, signed GETs 10 min. Keys never contain user filenames. **Scaling rule:** local driver requires a single shared volume; multi-replica requires S3 or an RWX volume (boot check warns, section 15).
|
||||||
|
|
||||||
|
## 8. Ingestion pipeline
|
||||||
|
|
||||||
|
1. Client captures image, attempts QR decode locally, uploads file + optional `qrPayload`.
|
||||||
|
2. `POST /api/documents/scan`: store file; QR present → parse URL → CDC → prefill → create document `scan_qr`; enqueue `verify_cdc` (Noop v1) + `classify_document`.
|
||||||
|
3. No QR + `ANTHROPIC_API_KEY` set → enqueue `ocr_extract` (Zod-constrained extraction per RULES.md section 9; low confidence flags fields). No key → `{ needsManual: true, fileId }`.
|
||||||
|
4. Dedupe on `(user_id, dedupe_hash)`: merge, prefer QR-sourced data, return `merged: true`.
|
||||||
|
5. Classification via packages/rules; auto-confirm sweep respects `profiles.auto_confirm_days`.
|
||||||
|
6. Failures → `ingest_errors` + user-visible retry affordance.
|
||||||
|
Offline: client queues in IndexedDB, syncs when online.
|
||||||
|
|
||||||
|
## 9. packages/rules
|
||||||
|
|
||||||
|
Implemented exactly per RULES.md (constants, RUC DV, calendario with holidays, CDC parsing, classification hints, computeF120, computeF515, projections). Exports `RULES_VERSION`, stamped on classifications and declarations. Test bar: RULES.md section 10.
|
||||||
|
|
||||||
|
## 10. Jobs
|
||||||
|
|
||||||
|
Portable poller inside apps/api:
|
||||||
|
- Runs when `ROLE=worker`, or inline when `ROLE=server && JOBS_INLINE=true`. Guard against duplicate pollers per process.
|
||||||
|
- Claim (`jobs/claim.ts`, the ONE dialect divergence): Postgres `SELECT ... FOR UPDATE SKIP LOCKED` then mark running with `locked_by=<instanceId>`; SQLite atomic conditional `UPDATE ... WHERE status='pending'` relying on single-writer serialization.
|
||||||
|
- Stale recovery: `running` jobs with `locked_at` older than `JOBS_STALE_MINUTES` (default 10) return to `pending` (crash safety).
|
||||||
|
- Retry backoff 1m/5m/25m/2h/12h, then `dead` (surfaced in admin errors as stage 'job').
|
||||||
|
- Types: `ocr_extract`, `classify_document`, `verify_cdc` (noop), `generate_declaration_pdf`, `send_notification`, `deadline_sweep` (daily), `auto_confirm_sweep` (daily), `digest_sweep` (hourly, respects per-user digest_hour and locale).
|
||||||
|
- Sweeps are idempotent (dedupe key per user+period+type in payload; skip if an identical done/pending job exists).
|
||||||
|
|
||||||
|
## 11. i18n (packages/i18n)
|
||||||
|
|
||||||
|
- Catalogs: `es.ts` (verbatim from COPY.md), `en.ts` (agent-written per COPY.md 0-EN), identical key sets enforced by a type-level check and a test.
|
||||||
|
- Typed `t(locale, key, params)` used by the API for: error envelope messages, notification/email/telegram templates, PDF cover labels (form bodies stay Spanish).
|
||||||
|
- Web: next-intl with `[locale]` routing, default `es`, language switcher in header and profile; switcher updates `profiles.locale` when authenticated (drives server-side notifications).
|
||||||
|
- Locale-aware date formatting; currency ALWAYS `formatGs` regardless of locale.
|
||||||
|
- Adding a locale = one new catalog file + adding the code to a `SUPPORTED_LOCALES` array.
|
||||||
|
|
||||||
|
## 12. API surface
|
||||||
|
|
||||||
|
As specified in CONTRACTS.md (shapes, error envelope, endpoints). Additions for this architecture:
|
||||||
|
- `GET /healthz` (liveness: process up) and `GET /readyz` (readiness: DB reachable, migrations current, storage driver responds) on the API; web exposes `GET /healthz` too.
|
||||||
|
- Localized `error.message` per requester locale (profile locale, else `Accept-Language`, else es).
|
||||||
|
- TanStack Query conventions: queryKeys `['me']`, `['documents', filters]`, `['dashboard']`, `['declarations']`, `['deadlines']`, `['admin', ...]`; optimistic updates ONLY for bandeja confirm/reclassify.
|
||||||
|
|
||||||
|
## 13. Testing
|
||||||
|
|
||||||
|
- Vitest: rules exhaustive; api module services against SQLite in-memory; i18n key-parity test.
|
||||||
|
- Playwright golden paths (against compose stack): (1) onboarding, (2) scan QR fixture → bandeja confirm, (3) manual entry + classification edit, (4) F120 generate → approve → PDF, (5) admin lookup → audit row. Plus: language switch persists after reload; offline scan queued then synced.
|
||||||
|
- Behavior pins from CONTRACTS.md section 5. CI matrix: sqlite + postgres service.
|
||||||
|
- Scale tests (Phase 8): with Postgres and 2 worker processes, enqueue 100 jobs, assert each claimed exactly once; SIGTERM drains in-flight HTTP before exit.
|
||||||
|
|
||||||
|
## 14. Security and privacy
|
||||||
|
|
||||||
|
PII module boundary + audited staff access; audit append-only; files private by default; Zod on every input; no raw SQL interpolation; secrets only via env; CSP, self-hosted fonts, no third-party scripts; soft-delete users + purge job stub; data export ships in v1. `.env.example` completeness enforced by a script against the env schema.
|
||||||
|
|
||||||
|
## 15. Deployment and scaling
|
||||||
|
|
||||||
|
**Configuration, apps/api/.env:**
|
||||||
|
```
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=4000
|
||||||
|
APP_PUBLIC_URL=http://localhost:3000 # user-facing origin (links in emails)
|
||||||
|
ROLE=server # server | worker
|
||||||
|
JOBS_INLINE=true
|
||||||
|
JOBS_POLL_INTERVAL_MS=2000
|
||||||
|
JOBS_STALE_MINUTES=10
|
||||||
|
DATABASE_URL=sqlite:./data/app.db # or postgres://...
|
||||||
|
BETTER_AUTH_SECRET=change-me-32-chars-min
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000 # public origin (cookies issued via proxy)
|
||||||
|
STORAGE_DRIVER=local # local | s3
|
||||||
|
STORAGE_LOCAL_PATH=./data/files
|
||||||
|
S3_ENDPOINT= S3_REGION= S3_BUCKET= S3_ACCESS_KEY_ID= S3_SECRET_ACCESS_KEY= S3_FORCE_PATH_STYLE=true
|
||||||
|
ANTHROPIC_API_KEY= # optional
|
||||||
|
OCR_MODEL=claude-sonnet-4-6
|
||||||
|
PUSH_VAPID_PUBLIC_KEY= PUSH_VAPID_PRIVATE_KEY=
|
||||||
|
SMTP_HOST= SMTP_PORT=587 SMTP_USER= SMTP_PASS= SMTP_FROM=
|
||||||
|
TELEGRAM_BOT_TOKEN= # optional
|
||||||
|
DEFAULT_LOCALE=es
|
||||||
|
```
|
||||||
|
**apps/web/.env:**
|
||||||
|
```
|
||||||
|
API_INTERNAL_URL=http://localhost:4000 # server-side proxy target (cluster DNS in k8s)
|
||||||
|
NEXT_PUBLIC_DEFAULT_LOCALE=es
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mode matrix (enforced by boot checks, hard fail or loud warn):**
|
||||||
|
|
||||||
|
| Mode | DB | Storage | api replicas | worker |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Combined (compose, one VPS) | SQLite | local volume | 1 | inline |
|
||||||
|
| Split small | SQLite | local shared volume | 1 | inline or 1 dedicated |
|
||||||
|
| Scaled (k8s/k3s) | Postgres required | S3 required (or RWX volume) | N | 1+ dedicated, JOBS_INLINE=false |
|
||||||
|
|
||||||
|
Boot checks in apps/api: SQLite + `ROLE=worker` running alongside another poller is not detectable, so instead: if `DATABASE_URL` is SQLite, refuse `JOBS_INLINE=false` (forcing single-process mode) and log a scaling notice; if driver=local, log the shared-volume requirement.
|
||||||
|
|
||||||
|
**docker-compose.yml (combined):** two services (api, web), one named volume mounted to api for `./data` (SQLite + files), healthchecks wired to /healthz, web depends_on api healthy, ports 3000 exposed only.
|
||||||
|
|
||||||
|
**k8s manifests (deploy/k8s/):** Deployments api (readiness `/readyz`, liveness `/healthz`, resources requests/limits, `terminationGracePeriodSeconds: 30`), web, worker (`ROLE=worker`, replicas 1 default, safe at N on Postgres); Services api+web (ClusterIP); Ingress to web only; ConfigMap/Secret examples; migrations run as an initContainer on api (`pnpm db:migrate`, safe concurrent: Kysely migrator + Postgres advisory lock taken in the migrate script). HPA example for api (CPU 70%). SQLite is explicitly unsupported on k8s manifests (values assume Postgres+S3); README says so.
|
||||||
|
|
||||||
|
**Statelessness rules (both apps):** no local file writes outside StorageDriver, no in-memory caches that affect correctness (rate limiter exempt and documented), push/OCR/SMTP clients constructed per process, graceful SIGTERM: stop accepting, drain (max 25s), close DB pool, exit 0.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { es, en } from '@impuestos/i18n';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('sign in screen', () => {
|
||||||
|
test('renders in Spanish by default', async ({ page }) => {
|
||||||
|
await page.goto('/es/login');
|
||||||
|
await expect(page.getByRole('heading', { name: es['auth.login.title'] })).toBeVisible();
|
||||||
|
await expect(page.getByLabel(es['auth.register.email'])).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: es['auth.login.submit'] })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the language switcher flips it to English and stays on the page', async ({ page }) => {
|
||||||
|
await page.goto('/es/login');
|
||||||
|
await page.getByLabel(es['common.language']).selectOption('en');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/en\/login$/);
|
||||||
|
await expect(page.getByRole('heading', { name: en['auth.login.title'] })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: en['auth.login.submit'] })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the choice survives a reload', async ({ page }) => {
|
||||||
|
await page.goto('/en/login');
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole('heading', { name: en['auth.login.title'] })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The en catalog exists for expats and international users (COPY.md section 0-EN),
|
||||||
|
// so the root honours the browser language and falls back to es, the default.
|
||||||
|
test('the root follows the browser language', async ({ browser }) => {
|
||||||
|
for (const [locale, expected] of [
|
||||||
|
['es-PY', /\/es\/login$/],
|
||||||
|
['en-US', /\/en\/login$/],
|
||||||
|
['pt-BR', /\/es\/login$/],
|
||||||
|
] as const) {
|
||||||
|
const context = await browser.newContext({ locale });
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page, locale).toHaveURL(expected);
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { es } from '@impuestos/i18n';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full first party chain in a real browser: the page posts to /api on the web
|
||||||
|
* origin, the proxy forwards it, better-auth sets the session cookie, and the next
|
||||||
|
* server render reads it back.
|
||||||
|
*/
|
||||||
|
test.describe('signing in', () => {
|
||||||
|
test('a seeded account reaches the app shell', async ({ page }) => {
|
||||||
|
await page.goto('/es/login');
|
||||||
|
await page.getByLabel(es['auth.register.email']).fill('maria@demo.local');
|
||||||
|
await page.getByLabel(es['auth.login.password']).fill('demo-maria-1');
|
||||||
|
await page.getByRole('button', { name: es['auth.login.submit'] }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/es\/inicio$/);
|
||||||
|
await expect(page.getByRole('heading', { name: es['home.title'] })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a wrong password says so without blaming the user', async ({ page }) => {
|
||||||
|
await page.goto('/es/login');
|
||||||
|
await page.getByLabel(es['auth.register.email']).fill('maria@demo.local');
|
||||||
|
await page.getByLabel(es['auth.login.password']).fill('not-the-password');
|
||||||
|
await page.getByRole('button', { name: es['auth.login.submit'] }).click();
|
||||||
|
|
||||||
|
// Scoped to the form: Next's route announcer is also role="alert".
|
||||||
|
await expect(page.locator('form').getByRole('alert')).toHaveText(es['auth.login.failed']);
|
||||||
|
await expect(page).toHaveURL(/\/es\/login$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the app shell is not reachable without a session', async ({ page }) => {
|
||||||
|
await page.goto('/es/inicio');
|
||||||
|
await expect(page).toHaveURL(/\/es\/login$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import react from 'eslint-plugin-react';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['**/dist/**', '**/.next/**', '**/node_modules/**', '**/coverage/**', '**/*.d.ts'],
|
||||||
|
},
|
||||||
|
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
// `any` is allowed only with a written reason, per the build constraints.
|
||||||
|
'@typescript-eslint/no-explicit-any': 'error',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/consistent-type-imports': ['error', { fixStyle: 'inline-type-imports' }],
|
||||||
|
eqeqeq: ['error', 'always'],
|
||||||
|
'no-console': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Module boundaries, SPEC.md section 4.
|
||||||
|
{
|
||||||
|
files: ['packages/**/*.ts'],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: ['**/apps/**', '@impuestos/api', '@impuestos/web'],
|
||||||
|
message:
|
||||||
|
'packages/* are pure and must not import from apps. Move the shared piece into a package.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
files: ['apps/api/src/**/*.ts'],
|
||||||
|
languageOptions: { globals: globals.node },
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
// Only modules/pii may reach the pii tables. Everything else goes through
|
||||||
|
// the functions that module exports, so PII access stays auditable.
|
||||||
|
group: ['**/modules/pii/*', '!**/modules/pii/index'],
|
||||||
|
message:
|
||||||
|
'Import from modules/pii (its index) instead of reaching into the pii module.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['apps/api/src/modules/pii/**/*.ts', 'apps/api/src/db/**/*.ts'],
|
||||||
|
rules: { 'no-restricted-imports': 'off' },
|
||||||
|
},
|
||||||
|
|
||||||
|
// The web app has no database access at all: it holds no DB dependency and may not
|
||||||
|
// import one even transitively through a shared package.
|
||||||
|
{
|
||||||
|
files: ['apps/web/**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
globals: { ...globals.browser, ...globals.node },
|
||||||
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||||
|
},
|
||||||
|
plugins: { react, 'react-hooks': reactHooks },
|
||||||
|
settings: { react: { version: 'detect' } },
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
paths: [
|
||||||
|
{ name: 'kysely', message: 'The web app never touches the database.' },
|
||||||
|
{ name: 'better-sqlite3', message: 'The web app never touches the database.' },
|
||||||
|
{ name: 'pg', message: 'The web app never touches the database.' },
|
||||||
|
{
|
||||||
|
name: 'better-auth',
|
||||||
|
message: 'Auth server code lives in apps/api. Use better-auth/react here.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Constraint 12: user facing copy comes from the catalogs, never inline.
|
||||||
|
// Punctuation and separators are allowed so layout markup stays readable.
|
||||||
|
'react/jsx-no-literals': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
noStrings: true,
|
||||||
|
allowedStrings: ['·', '/', '|', ':', ',', '.', '-', '+', '(', ')', '%', '✓'],
|
||||||
|
ignoreProps: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tests assert on real copy, so literals are the point there.
|
||||||
|
{
|
||||||
|
files: ['**/*.test.ts', '**/*.test.tsx', 'e2e/**/*.ts'],
|
||||||
|
rules: { 'react/jsx-no-literals': 'off' },
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "impuestos",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@10.32.1",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "pnpm -r --parallel dev",
|
||||||
|
"build": "pnpm -r build",
|
||||||
|
"typecheck": "pnpm -r typecheck",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"db:migrate": "pnpm --filter @impuestos/api db:migrate",
|
||||||
|
"db:seed": "pnpm --filter @impuestos/api db:seed"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@playwright/test": "^1.62.1",
|
||||||
|
"@types/node": "^26.4.1",
|
||||||
|
"eslint": "^10.9.1",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
|
"globals": "^17.12.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.69.0",
|
||||||
|
"vitest": "^5.0.0",
|
||||||
|
"@impuestos/i18n": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
@@ -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>;
|
||||||
@@ -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>;
|
||||||
@@ -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';
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@impuestos/rules",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user