diff --git a/DECISIONS.md b/DECISIONS.md index e3a9445..e49cbda 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -10,18 +10,11 @@ Markers used in the code: ## 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.** +### RULES.md was missing during phase 0, supplied for phase 1 +`packages/rules` shipped phase 0 with only `RULES_VERSION` and the phase 0 seed stopped +short of profiles, because the RUC check digit and the deadline digit are RULES.md +algorithms. Both are implemented in phase 1 and the seed can now be completed. +**Resolved.** ### `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 @@ -129,3 +122,90 @@ SQLite stays single process. Worth reconciling in SPEC.md. `/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.** + + +--- + +## Phase 1 + +### TODO-TAX-VERIFY register + +Every item RULES.md flags, where it is implemented, and the test that pins today's +behaviour so verification is a red/green diff rather than archaeology. + +| # | What needs verifying | Implemented in | Pinned by | +|---|---|---|---| +| 1 | IRP tranche boundaries (50M, 150M) and the Gs. 80.000.000 registration threshold, against the live DNIT tables for the current fiscal year | `constants.ts` | `constants.test.ts` "pins the IRP tranche boundaries and threshold pending verification" | +| 2 | The RUC check digit algorithm (modulo 11, basis 2) against at least five real published RUCs | `ruc.ts` | `ruc.test.ts` "pins the check digit it computes today" and "agrees with an independent transcription" | +| 3 | Government decreed one off holidays and "dias no laborables trasladables", and movable holidays past 2028 | `holidays.ts` | `calendario.test.ts` "pins that movable holidays are only known for 2026 to 2028" | +| 4 | F120 casilla numbers, currently placeholder keys, against the live Marangatu F120 v4 | `forms/f120.v1.ts` | `forms.test.ts` "pins that they are still placeholders" | +| 5 | F515 casilla numbers, same | `forms/f515.v1.ts` | same test | +| 6 | The progressive-by-tranche reading of the IRP rates | `f515.ts` `taxForNetIncome` | `f515.test.ts` bracket edge suite, including the worked example | + +Nothing outside this list was invented. Where RULES.md was silent the gap is marked +`SPEC-GAP` in the code and listed below. + +### Both worked examples reproduce exactly +RULES.md section 6 gives F120 a Gs. 277.273 monto a pagar and a Gs. 350.000 flip case; +section 7 gives F515 a Gs. 11.290.000 tax at a 5,65% effective rate. Both are transcribed +verbatim as tests and both pass on the first implementation, which is the main evidence +that the integer money math and the rounding rule are right. + +### Coverage is enforced, not observed +`vitest.config.ts` fails the run below 100% statements, branches, functions and lines for +`packages/rules`. Three defensive throws carry an explicit `v8 ignore` and a comment +saying why they are unreachable: the bounded loops in `rollForward` and `nextDeadline` +guard against an edit to `holidays.ts` that would otherwise spin forever, and no input can +reach them with any sane holiday table. + +### Classification emits reason codes, not sentences +CONTRACTS.md types `reasons` as `string[]` and RULES.md gives one of them as Spanish text +("Sin categoria sugerida"). A pure, locale free package cannot emit user facing copy in +one language without breaking the en locale, which is a hard constraint. `classify` +therefore returns stable codes and `packages/i18n` carries a `classification.reason.*` +string per code, with RULES.md's wording used verbatim for the Spanish of that one. + +### SPEC-GAPs in the tax logic + +**`taxpayer.hasIrp` gates the deduction amount.** RULES.md states the IVA eligibility test +in full but never says whether being registered for IRP is required for a deduction. It is +the only field of the documented `ClassificationInput` that would otherwise go unused, so +it gates `irpDeductibleAmount`. The category is still suggested either way, so the data is +already correct if the user registers for IRP later. Pinned in `classification.test.ts`. + +**A sale reads as "no keyword hit", so its confidence is 0,4.** RULES.md defines confidence +only for the keyword map, and a sale can never hit it. Taking that literally rather than +inventing a number means sales never reach the auto confirm threshold. Worth a decision +before the auto confirm sweep ships in phase 4. Pinned in `classification.test.ts`. + +**"Annualized YTD sales" is straight line.** RULES.md section 8 names the fallback without +defining it: sales so far, scaled by the share of the year elapsed. It is only ever shown +as a "proyeccion" and never filed. + +**Ñ folds to N when normalising an emitter name.** The tilde is a diacritic to Unicode, so +"diacritic-insensitive" folds it. No keyword contains Ñ, so matching is unaffected, and +folding is the forgiving choice when a printed name spells it either way. + +**CDC type `07` maps to DocKind `otro`.** `nota de remision` is a known CDC document type +but has no DocKind of its own in CONTRACTS.md section 2. The code and its Spanish label +survive on the parsed fields, so nothing is lost. + +**`nextDeadline` returns a `Date`, as RULES.md types it.** Every calculation inside the +package works on civil year/month/day values, and the returned `Date` is midnight UTC of +that civil date, so it cannot drift a day with the host timezone. `America/Asuncion` is +consulted in exactly one place, `todayInAsuncion`. + +### The IRP category vocabulary lives in packages/rules +`packages/contracts` now imports `IRP_CATEGORIES` from `packages/rules` and builds its Zod +enum from it, rather than declaring the eight strings a second time. The vocabulary sits +next to the tax logic that uses it and the two cannot drift. + +### Local ports: web 3005, and the API must agree +`apps/web/.env.example` ships `PORT=3005` and `apps/api/.env.example` names the same port +in `APP_PUBLIC_URL` and `BETTER_AUTH_URL`. They have to agree: better-auth checks the +request Origin, so a mismatch makes sign in fail with a 403 that looks nothing like a port +problem. `http://localhost` and `http://127.0.0.1` are different origins too, which is why +the Playwright default base URL uses localhost. + +This deviates from the literal example values in SPEC.md section 15, which use port 3000. +The compose stack still publishes 3000 and is unaffected. diff --git a/README.md b/README.md index 16b6663..dd791a5 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ 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. +> **Status: phase 1 of 8.** Foundation and the tax rules. `packages/rules` is complete and +> covered; there is no ingestion, dashboard or declaration UI on top of it yet. --- @@ -23,9 +23,14 @@ pnpm db:seed pnpm dev ``` -The web app is on http://localhost:3000, the API on http://localhost:4000. `db:seed` +The web app is on http://localhost:3005, the API on http://localhost:4000. `db:seed` prints the development sign in details for the four demo accounts. +The web port lives in `apps/web/.env` and `apps/api/.env` has to name the same one in +`APP_PUBLIC_URL` and `BETTER_AUTH_URL`. Auth checks the request Origin, so a mismatch +fails sign in with a 403 that looks nothing like a port problem. `localhost` and +`127.0.0.1` count as different origins too. + Or the whole thing in containers, with nothing installed but Docker: ```bash @@ -74,6 +79,7 @@ driver, and user facing strings cannot be written inline in JSX. |---|---| | `pnpm dev` | Both apps in watch mode | | `pnpm test` | Vitest across the workspace | +| `pnpm test:coverage` | The tax rules with coverage, which fails below 100% | | `pnpm test:e2e` | Playwright against a running stack (`E2E_BASE_URL` to point it) | | `pnpm typecheck` | `tsc --noEmit` in every package | | `pnpm lint` | eslint, including the module boundary and inline copy rules | @@ -125,12 +131,33 @@ localization and the date formatting all read from that array. `formatGs` never money is always `Gs. 1.234.567`. Official form previews and PDFs stay Spanish in every locale, because they mirror DNIT forms. +## The tax rules + +Everything that decides a number lives in `packages/rules` and nothing there does I/O, so +it is all directly testable. `docs/RULES.md` is authoritative for it and the module names +follow that document's sections: `ruc.ts`, `calendario.ts`, `cdc.ts`, `classification.ts`, +`f120.ts`, `f515.ts`, `projections.ts`. + +Three rules hold the package together: + +- **No floats in a money path.** Money is a branded `Pyg` of whole guaranies, percentages + go through `percentOf`, which is integer arithmetic rounded half up, and `pyg()` throws + on anything that is not a safe integer. +- **Never guess a tax rule.** Anything `docs/RULES.md` does not state carries a + `TODO-TAX-VERIFY` comment and a test that pins today's behaviour, so verifying it later + is a red/green diff. `DECISIONS.md` has the register of all six. +- **Data, not code.** The holiday table, the keyword to category map and the form + definitions are plain data files. Extending them for a new year or a new keyword is not + a code change. + +Coverage is enforced at 100%: `pnpm test:coverage` fails below it. + ## Adding a form version -Form definitions live in `packages/rules` 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.) +Form definitions live in `packages/rules/src/forms` and every declaration stores the +`rulesVersion` that produced it, so an old declaration always renders with the rules it was +computed under. Add `f120.v2.ts` beside `f120.v1.ts`, bump `RULES_VERSION`, and leave the +old definition in place: it is what old declarations still render through. ## Testing diff --git a/apps/api/.env.example b/apps/api/.env.example index d34bd3c..eac8783 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -6,7 +6,9 @@ NODE_ENV=development # Port the Hono server listens on. The web app proxies /api here. PORT=4000 # User facing origin. Used for links in emails, push payloads and Telegram messages. -APP_PUBLIC_URL=http://localhost:3000 +# Must name the same port as apps/web/.env PORT, or better-auth rejects the sign in +# request as a foreign origin. +APP_PUBLIC_URL=http://localhost:3005 # server = serves HTTP. worker = runs the job poller and sweeps, serves only /healthz. ROLE=server @@ -24,7 +26,8 @@ DATABASE_URL=sqlite:./data/app.db # Signing key for sessions. At least 32 characters. Generate: openssl rand -base64 32 BETTER_AUTH_SECRET=change-me-to-at-least-32-characters-long # Public origin cookies are issued for. Auth routes are proxied, so this is the web origin. -BETTER_AUTH_URL=http://localhost:3000 +# Keep in step with APP_PUBLIC_URL and apps/web/.env PORT. +BETTER_AUTH_URL=http://localhost:3005 # local | s3. local needs one shared volume across replicas; s3 is required to scale out. STORAGE_DRIVER=local diff --git a/apps/web/.env.example b/apps/web/.env.example index e04191f..71e9e46 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,5 +1,11 @@ # apps/web environment. The web app holds no secrets: it renders UI and proxies /api. +# Port the Next server listens on for `pnpm dev` and `pnpm start`. Read from this file by +# the dev/start scripts; a real environment variable wins over it. Containers get the port +# from the image and the compose WEB_PORT variable instead, so this is a no-op there. +# Change it and apps/api/.env APP_PUBLIC_URL and BETTER_AUTH_URL must name the same port. +PORT=3005 + # Where the API can be reached from the web container. Used only server side, by the # Next rewrite. In k8s this is the api Service DNS name, for example http://api:4000. API_INTERNAL_URL=http://localhost:4000 diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 5ff1021..a949e88 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -4,7 +4,7 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { reactStrictMode: true, // The workspace packages ship TypeScript source with no build step. - transpilePackages: ['@impuestos/contracts', '@impuestos/i18n'], + transpilePackages: ['@impuestos/contracts', '@impuestos/i18n', '@impuestos/rules'], output: 'standalone', // /api is proxied at runtime by app/api/[...path]/route.ts rather than by a rewrite, // so API_INTERNAL_URL stays a runtime setting. See the comment in that file. diff --git a/apps/web/package.json b/apps/web/package.json index 221153b..7da1919 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,9 +4,9 @@ "private": true, "type": "module", "scripts": { - "dev": "next dev --port 3000", + "dev": "node scripts/next-with-env.mjs dev", "build": "next build", - "start": "next start --port 3000", + "start": "node scripts/next-with-env.mjs start", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/apps/web/scripts/next-with-env.mjs b/apps/web/scripts/next-with-env.mjs new file mode 100644 index 0000000..5167f5c --- /dev/null +++ b/apps/web/scripts/next-with-env.mjs @@ -0,0 +1,25 @@ +// Runs the Next CLI with apps/web/.env already in process.env. +// +// Next loads .env itself, but only after its CLI has parsed arguments, and the port is one +// of those arguments (`-p`, defaulting to the PORT environment variable). PORT from the file +// would therefore arrive too late. Passing `--env-file` to node instead is not an option: +// the dev server re-execs itself with the parent's execArgv in NODE_OPTIONS, where node +// refuses that flag. Loading the file here and importing the CLI in this process avoids both. + +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseEnv } from 'node:util'; + +// A real environment variable always wins over the file, matching apps/api/src/lib/env.ts: +// process.loadEnvFile overwrites process.env, which would let a stale checked out `.env` +// beat the values a container or a one off command passed in. +const envFile = join(dirname(dirname(fileURLToPath(import.meta.url))), '.env'); +if (existsSync(envFile)) { + for (const [key, value] of Object.entries(parseEnv(readFileSync(envFile, 'utf8')))) { + if (process.env[key] === undefined && typeof value === 'string') process.env[key] = value; + } +} + +// The CLI parses process.argv, which still carries the subcommand this was invoked with. +await import('next/dist/bin/next'); diff --git a/eslint.config.js b/eslint.config.js index 5e3ed3d..f16eb40 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -109,6 +109,12 @@ export default tseslint.config( }, }, + // Plain Node scripts: no JSX, no browser globals. + { + files: ['**/scripts/**/*.mjs', '*.config.{js,mjs,ts}', '**/*.config.{js,mjs,ts}'], + languageOptions: { globals: globals.node }, + }, + // Tests assert on real copy, so literals are the point there. { files: ['**/*.test.ts', '**/*.test.tsx', 'e2e/**/*.ts'], diff --git a/package.json b/package.json index fdf93f3..049e490 100644 --- a/package.json +++ b/package.json @@ -14,21 +14,23 @@ "lint": "eslint .", "test": "vitest run", "test:watch": "vitest", + "test:coverage": "vitest run --coverage packages/rules", "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", + "@impuestos/i18n": "workspace:*", "@playwright/test": "^1.62.1", "@types/node": "^26.4.1", + "@vitest/coverage-v8": "^5.0.0", "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:*" + "vitest": "^5.0.0" } } diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 9b7af34..c2dc523 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -10,6 +10,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@impuestos/rules": "workspace:*", "zod": "^4.5.4" } } diff --git a/packages/contracts/src/enums.ts b/packages/contracts/src/enums.ts index 37f427c..a00ba52 100644 --- a/packages/contracts/src/enums.ts +++ b/packages/contracts/src/enums.ts @@ -1,15 +1,9 @@ +import { IRP_CATEGORIES } from '@impuestos/rules'; import { z } from 'zod'; -export const IRP_CATEGORIES = [ - 'alimentacion', - 'salud', - 'educacion', - 'vivienda', - 'vestimenta', - 'esparcimiento', - 'vehiculo', - 'familiares', -] as const; +// The eight categories are defined once, in packages/rules, next to the tax logic that +// uses them. Re-exported here so API consumers get them from the contract package. +export { IRP_CATEGORIES }; export const IrpCategory = z.enum(IRP_CATEGORIES); export type IrpCategory = z.infer; diff --git a/packages/i18n/src/catalogs/en.ts b/packages/i18n/src/catalogs/en.ts index 1b41593..02983e2 100644 --- a/packages/i18n/src/catalogs/en.ts +++ b/packages/i18n/src/catalogs/en.ts @@ -219,6 +219,17 @@ export const en: Record = { "auth.login.failed": "That email and password do not match. Try again.", "auth.login.noAccount": "Do not have an account yet?", + // Classification reasons (es.extra.ts) + "classification.reason.iva_credit_eligible": "You can claim the IVA on this factura as credit.", + "classification.reason.iva_credit_not_iva_taxpayer": "You are not registered for IVA, so this factura earns no credit.", + "classification.reason.iva_credit_sale_document": "This is a sale of yours: it adds to debit, not to credit.", + "classification.reason.iva_credit_resimple_supplier": "The supplier is on RESIMPLE, so it earns no IVA credit.", + "classification.reason.iva_credit_no_iva_amount": "The factura shows no IVA separately.", + "classification.reason.irp_category_keyword": "Category suggested from the issuer name.", + "classification.reason.irp_no_category": "No category suggested", + "classification.reason.irp_sale_is_income": "This is income of yours, not a deductible expense.", + "classification.reason.irp_not_irp_taxpayer": "You are not registered for IRP, so it is not deducted.", + // Chrome (es.extra.ts) "common.language": "Language", "common.languageEs": "Español", diff --git a/packages/i18n/src/catalogs/es.extra.ts b/packages/i18n/src/catalogs/es.extra.ts index 6b65dc7..3a14a6b 100644 --- a/packages/i18n/src/catalogs/es.extra.ts +++ b/packages/i18n/src/catalogs/es.extra.ts @@ -19,6 +19,18 @@ export const esExtra = { "auth.login.failed": "Ese email y esa contraseña no coinciden. Probá de nuevo.", "auth.login.noAccount": "¿Todavia no tenés cuenta?", + // Why a document was classified the way it was. packages/rules emits reason codes, not + // sentences, so the detail sheet can show them in the reader's language. + "classification.reason.iva_credit_eligible": "Podés usar el IVA de esta factura como credito.", + "classification.reason.iva_credit_not_iva_taxpayer": "No tenés IVA activo, asi que esta factura no genera credito.", + "classification.reason.iva_credit_sale_document": "Es una venta tuya: suma al debito, no al credito.", + "classification.reason.iva_credit_resimple_supplier": "El proveedor esta en RESIMPLE, asi que no genera credito de IVA.", + "classification.reason.iva_credit_no_iva_amount": "La factura no tiene IVA discriminado.", + "classification.reason.irp_category_keyword": "Categoria sugerida por el nombre del emisor.", + "classification.reason.irp_no_category": "Sin categoria sugerida", + "classification.reason.irp_sale_is_income": "Es un ingreso tuyo, no un gasto deducible.", + "classification.reason.irp_not_irp_taxpayer": "No tenés IRP activo, asi que no se descuenta.", + // Chrome "common.language": "Idioma", "common.languageEs": "Español", diff --git a/packages/rules/src/calendario.test.ts b/packages/rules/src/calendario.test.ts new file mode 100644 index 0000000..006ac7e --- /dev/null +++ b/packages/rules/src/calendario.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest'; +import { deadlineDay, dueDateFor, nextDeadline, rollForward } from './calendario'; +import { formatIsoDate, fromDate, parseIsoDate } from './dates'; +import { RuleError } from './errors'; +import { hasMovableHolidays, isHoliday } from './holidays'; + +const iso = (date: Date) => formatIsoDate(fromDate(date)); + +describe('deadlineDay', () => { + it('spreads digit 0 and digit 9 across the month', () => { + expect(deadlineDay(0)).toBe(7); + expect(deadlineDay(9)).toBe(25); + expect(deadlineDay(6)).toBe(19); + }); + + it('throws rather than guessing for a digit outside the table', () => { + expect(() => deadlineDay(10)).toThrow(RuleError); + expect(() => deadlineDay(-1)).toThrow(expect.objectContaining({ code: 'out_of_range' })); + }); +}); + +describe('rollForward', () => { + it('leaves a business day alone', () => { + // 2026-09-18 is a Friday. + expect(formatIsoDate(rollForward(parseIsoDate('2026-09-18')))).toBe('2026-09-18'); + }); + + it('rolls a Saturday to the Monday', () => { + // 2026-08-15 is a Saturday and also a holiday; use a plain Saturday. + expect(formatIsoDate(rollForward(parseIsoDate('2026-09-19')))).toBe('2026-09-21'); + }); + + it('rolls a Sunday to the Monday', () => { + expect(formatIsoDate(rollForward(parseIsoDate('2026-09-20')))).toBe('2026-09-21'); + }); + + it('rolls past Holy Thursday and Good Friday to the Monday', () => { + // RULES.md section 3.1: 2026-04-02 and 2026-04-03 are holidays, then the weekend. + expect(formatIsoDate(rollForward(parseIsoDate('2026-04-02')))).toBe('2026-04-06'); + }); + + it('rolls a fixed holiday that lands mid week', () => { + // 2026-01-01 is a Thursday holiday, so the next business day is the Friday. + expect(formatIsoDate(rollForward(parseIsoDate('2026-01-01')))).toBe('2026-01-02'); + }); +}); + +describe('holidays', () => { + it('recognises the fixed table every year', () => { + for (const date of ['2026-01-01', '2027-03-01', '2028-05-01', '2030-12-25', '2026-09-29']) { + expect(isHoliday(parseIsoDate(date)), date).toBe(true); + } + }); + + it('recognises the movable table for the years it covers', () => { + expect(isHoliday(parseIsoDate('2027-03-25'))).toBe(true); + expect(isHoliday(parseIsoDate('2028-04-14'))).toBe(true); + }); + + it('treats an ordinary day as a working day', () => { + expect(isHoliday(parseIsoDate('2026-09-18'))).toBe(false); + }); + + // TODO-TAX-VERIFY pin: one off decreed holidays are not in the table, and the movable + // dates run out after 2028. This pins the behaviour so extending the table is a diff. + it('pins that movable holidays are only known for 2026 to 2028', () => { + expect(hasMovableHolidays(2026)).toBe(true); + expect(hasMovableHolidays(2028)).toBe(true); + expect(hasMovableHolidays(2029)).toBe(false); + // Easter 2029 falls on 2029-03-30 and is not treated as a holiday yet. + expect(isHoliday(parseIsoDate('2029-03-29'))).toBe(false); + }); +}); + +describe('dueDateFor iva_120', () => { + it('declares month M and is due in M+1 on the digit day', () => { + // RULES.md section 3 worked example: August 2026, digit 6, due 2026-09-19. + // 2026-09-19 is a Saturday, so the roll rule moves it to the Monday. + expect(formatIsoDate(dueDateFor('iva_120', '2026-08', 6))).toBe('2026-09-21'); + }); + + it('crosses the year boundary for December', () => { + expect(formatIsoDate(dueDateFor('iva_120', '2026-12', 0))).toBe('2027-01-07'); + }); + + it('gives digit 0 an earlier date than digit 9 in the same month', () => { + const first = dueDateFor('iva_120', '2026-06', 0); + const last = dueDateFor('iva_120', '2026-06', 9); + expect(formatIsoDate(first)).toBe('2026-07-07'); + expect(formatIsoDate(last)).toBe('2026-07-27'); // the 25th is a Saturday + }); + + it('rejects a malformed period', () => { + expect(() => dueDateFor('iva_120', '2026-13', 0)).toThrow(RuleError); + expect(() => dueDateFor('iva_120', '2026', 0)).toThrow(RuleError); + }); +}); + +describe('dueDateFor irp_515', () => { + it('declares year Y and is due in March of Y+1', () => { + expect(formatIsoDate(dueDateFor('irp_515', '2026', 0))).toBe('2027-03-08'); // 7th is a Sunday + expect(formatIsoDate(dueDateFor('irp_515', '2026', 8))).toBe('2027-03-23'); // a plain Tuesday + }); + + it('rolls past the movable holidays when March catches them', () => { + // Digit 9 falls on the 25th, which in 2027 is Holy Thursday. The 26th is Good Friday + // and the 27th and 28th are the weekend, so the deadline lands on Monday the 29th. + expect(formatIsoDate(dueDateFor('irp_515', '2026', 9))).toBe('2027-03-29'); + }); + + it('rejects a period that is not a year', () => { + expect(() => dueDateFor('irp_515', '2026-03', 0)).toThrow(RuleError); + }); +}); + +describe('nextDeadline', () => { + it('returns the month just closed when its due date is still ahead', () => { + const result = nextDeadline({ + digit: 6, + obligation: 'iva_120', + from: new Date('2026-09-03T12:00:00Z'), + }); + expect(result.period).toBe('2026-08'); + expect(iso(result.dueDate)).toBe('2026-09-21'); + }); + + it('moves on once the due date has passed', () => { + const result = nextDeadline({ + digit: 6, + obligation: 'iva_120', + from: new Date('2026-09-22T12:00:00Z'), + }); + expect(result.period).toBe('2026-09'); + expect(iso(result.dueDate)).toBe('2026-10-19'); + }); + + it('counts a deadline falling today as still due, not missed', () => { + const result = nextDeadline({ + digit: 6, + obligation: 'iva_120', + from: new Date('2026-09-21T12:00:00Z'), + }); + expect(result.period).toBe('2026-08'); + expect(iso(result.dueDate)).toBe('2026-09-21'); + }); + + it('reads the current day in Asuncion, not in the host timezone', () => { + // 2026-09-22T02:00Z is still 2026-09-21 in Asuncion (UTC-3), so the August period + // is due today rather than already past. + const result = nextDeadline({ + digit: 6, + obligation: 'iva_120', + from: new Date('2026-09-22T02:00:00Z'), + }); + expect(result.period).toBe('2026-08'); + }); + + it('finds the next annual IRP deadline', () => { + const result = nextDeadline({ + digit: 9, + obligation: 'irp_515', + from: new Date('2027-01-15T12:00:00Z'), + }); + expect(result.period).toBe('2026'); + expect(iso(result.dueDate)).toBe('2027-03-29'); + }); + + it('rolls to the following year once March has passed', () => { + const result = nextDeadline({ + digit: 9, + obligation: 'irp_515', + from: new Date('2027-06-01T12:00:00Z'), + }); + expect(result.period).toBe('2027'); + }); + + it('rejects a digit outside the table before doing any work', () => { + expect(() => + nextDeadline({ digit: 11, obligation: 'iva_120', from: new Date('2026-09-03T12:00:00Z') }), + ).toThrow(RuleError); + }); +}); diff --git a/packages/rules/src/calendario.ts b/packages/rules/src/calendario.ts new file mode 100644 index 0000000..f440b1a --- /dev/null +++ b/packages/rules/src/calendario.ts @@ -0,0 +1,112 @@ +import { DEADLINE_DAY_BY_DIGIT } from './constants'; +import { + type CivilDate, + addDays, + addMonths, + compareDates, + daysInMonth, + formatIsoDate, + formatPeriod, + isWeekend, + parsePeriod, + toDate, + todayInAsuncion, +} from './dates'; +import { RuleError } from './errors'; +import { isHoliday } from './holidays'; + +export type Obligation = 'iva_120' | 'irp_515'; + +export interface Deadline { + /** "YYYY-MM" for IVA, "YYYY" for IRP: the period being declared, not the month it is due. */ + period: string; + /** Midnight UTC of the civil due date, after weekend and holiday roll forward. */ + dueDate: Date; +} + +/** RULES.md section 1 table. Throws rather than guessing for a digit outside 0..9. */ +export function deadlineDay(digit: number): number { + const day = DEADLINE_DAY_BY_DIGIT[digit]; + if (day === undefined) { + throw new RuleError('out_of_range', `deadline digit must be 0 to 9, got: ${digit}`, { digit }); + } + return day; +} + +/** + * Advances day by day until a business day. A due date never moves earlier: rolling + * forward is what gives the taxpayer the extra day, never takes one away. + */ +export function rollForward(date: CivilDate): CivilDate { + let candidate = date; + // Bounded so a bad holiday table can never spin forever. + for (let step = 0; step < 30; step++) { + if (!isWeekend(candidate) && !isHoliday(candidate)) return candidate; + candidate = addDays(candidate, 1); + } + /* v8 ignore next 2 -- unreachable with any sane holiday table; a guard against an + edit to holidays.ts that would otherwise spin forever. */ + throw new RuleError('out_of_range', `no business day found after ${formatIsoDate(date)}`); +} + +/** + * The civil due date for one period. + * `iva_120` declares month M and is due in M+1; `irp_515` declares year Y and is due in + * March of Y+1 (RULES.md section 3). + */ +export function dueDateFor(obligation: Obligation, period: string, digit: number): CivilDate { + const day = deadlineDay(digit); + + if (obligation === 'iva_120') { + const { year, month } = parsePeriod(addMonths(period, 1)); + return rollForward({ year, month, day: clampDay(year, month, day) }); + } + + const year = Number(period); + if (!/^\d{4}$/.test(period) || !Number.isInteger(year)) { + throw new RuleError('invalid_date', `IRP period must be a year, got: ${period}`, { period }); + } + return rollForward({ year: year + 1, month: 3, day: clampDay(year + 1, 3, day) }); +} + +/** + * The next deadline still ahead of `from` for this taxpayer. A due date falling on `from` + * itself still counts: it is due today, not missed. + */ +export function nextDeadline(opts: { + digit: number; + obligation: Obligation; + from: Date; +}): Deadline { + const today = todayInAsuncion(opts.from); + deadlineDay(opts.digit); + + if (opts.obligation === 'iva_120') { + // Start with the period declared in the current month, which is the earliest one + // that can still be outstanding, and walk forward. + let period = formatPeriod(today.year, today.month); + period = addMonths(period, -1); + for (let step = 0; step < 24; step++) { + const due = dueDateFor('iva_120', period, opts.digit); + if (compareDates(due, today) >= 0) return { period, dueDate: toDate(due) }; + period = addMonths(period, 1); + } + /* v8 ignore next 2 -- the first candidate is always within a month of today. */ + throw new RuleError('out_of_range', 'no IVA deadline found within 24 months'); + } + + let year = today.year - 1; + for (let step = 0; step < 3; step++) { + const period = String(year); + const due = dueDateFor('irp_515', period, opts.digit); + if (compareDates(due, today) >= 0) return { period, dueDate: toDate(due) }; + year += 1; + } + /* v8 ignore next 2 -- the first candidate is always within a year of today. */ + throw new RuleError('out_of_range', 'no IRP deadline found within 3 years'); +} + +/** A deadline day of 25 is safe in every month, but the clamp keeps the rule total. */ +function clampDay(year: number, month: number, day: number): number { + return Math.min(day, daysInMonth(year, month)); +} diff --git a/packages/rules/src/categories.ts b/packages/rules/src/categories.ts new file mode 100644 index 0000000..d5784ef --- /dev/null +++ b/packages/rules/src/categories.ts @@ -0,0 +1,19 @@ +/** + * The eight IRP deduction categories. This is the source of the vocabulary: + * `packages/contracts` builds its Zod enum from this array so the two can never drift. + */ +export const IRP_CATEGORIES = [ + 'alimentacion', + 'salud', + 'educacion', + 'vivienda', + 'vestimenta', + 'esparcimiento', + 'vehiculo', + 'familiares', +] as const; + +export type IrpCategory = (typeof IRP_CATEGORIES)[number]; + +/** What a classification carries when no category applies. */ +export type IrpCategoryOrNone = IrpCategory | 'none'; diff --git a/packages/rules/src/categoryHints.ts b/packages/rules/src/categoryHints.ts new file mode 100644 index 0000000..d5d3b4b --- /dev/null +++ b/packages/rules/src/categoryHints.ts @@ -0,0 +1,95 @@ +import type { IrpCategory } from './categories'; + +/** + * RULES.md section 5. Data, not logic: adding a keyword is a change to this file only. + * + * Order is significant. The rows are tried top to bottom and the first hit wins, so a + * "SUPERMERCADO" is food before it is anything else. Within a row, longer keywords come + * before the shorter ones they contain. + * + * `familiares` is deliberately absent: it is only ever assigned by the user, together + * with the dependent the expense belongs to. + */ +export interface CategoryHint { + readonly category: IrpCategory; + readonly keywords: readonly string[]; +} + +export const CATEGORY_HINTS: readonly CategoryHint[] = [ + { + category: 'salud', + keywords: ['FARMACIA', 'FARMA', 'CLINICA', 'SANATORIO', 'HOSPITAL', 'LABORATORIO', 'ODONTO', 'OPTICA'], + }, + { + category: 'educacion', + keywords: ['COLEGIO', 'ESCUELA', 'UNIVERSIDAD', 'INSTITUTO', 'ACADEMIA', 'LIBRERIA'], + }, + { + category: 'alimentacion', + keywords: [ + 'SUPERMERCADO', + 'SUPER', + 'DESPENSA', + 'ALMACEN', + 'MINIMARKET', + 'CARNICERIA', + 'PANADERIA', + 'RESTAURANTE', + 'RESTAURANT', + 'PIZZERIA', + 'COMIDAS', + ], + }, + { + category: 'vehiculo', + keywords: [ + 'PETROBRAS', + 'SHELL', + 'PUMA', + 'ESTACION', + 'COMBUSTIBLE', + 'TALLER', + 'GOMERIA', + 'REPUESTOS', + 'LUBRICANTES', + ], + }, + { + category: 'vivienda', + keywords: [ + 'INMOBILIARIA', + 'ALQUILER', + 'CONDOMINIO', + 'FERRETERIA', + 'ELECTRICIDAD', + 'SANITARIOS', + 'ANDE', + 'ESSAP', + ], + }, + { + category: 'vestimenta', + keywords: ['BOUTIQUE', 'TIENDA', 'CALZADOS', 'MODAS', 'CONFECCIONES'], + }, + { + category: 'esparcimiento', + keywords: ['CINE', 'TEATRO', 'CLUB', 'GIMNASIO', 'GYM', 'TURISMO', 'HOTEL'], + }, +]; + +/** Uppercase, diacritics removed, so "Farmacia Catedral" and "FARMACIA" match. */ +export function normaliseEmitterName(name: string): string { + return name + .normalize('NFD') + .replace(/\p{Diacritic}/gu, '') + .toUpperCase(); +} + +/** First matching row wins. Returns null when nothing matches: never guess a category. */ +export function categoryForEmitter(emitterName: string): IrpCategory | null { + const normalised = normaliseEmitterName(emitterName); + for (const hint of CATEGORY_HINTS) { + if (hint.keywords.some((keyword) => normalised.includes(keyword))) return hint.category; + } + return null; +} diff --git a/packages/rules/src/cdc.test.ts b/packages/rules/src/cdc.test.ts new file mode 100644 index 0000000..ae7581f --- /dev/null +++ b/packages/rules/src/cdc.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from 'vitest'; +import { CDC_FIELDS, CDC_LENGTH, buildCdc, docKindFromCdc, parseCdc, parseQrPayload } from './cdc'; +import { RuleError } from './errors'; + +/** + * RULES.md section 4 canonical vector, assembled from the field table rather than + * hardcoded, exactly as the fixtures script must do. + */ +const CANONICAL_PARTS = { + tipoDocumento: '01', + rucEmisor: '80069563', + dvEmisor: '1', + establecimiento: '001', + puntoExpedicion: '001', + numeroDocumento: '0001234', + tipoContribuyente: '1', + fechaEmision: '20260815', + tipoEmision: '1', + codigoSeguridad: '123456789', + digitoVerificador: '4', +}; + +const CANONICAL_CDC = buildCdc(CANONICAL_PARTS); + +describe('the field table', () => { + it('sums to exactly 44 digits', () => { + expect(CDC_FIELDS.reduce((total, field) => total + field.length, 0)).toBe(CDC_LENGTH); + expect(CANONICAL_CDC).toHaveLength(CDC_LENGTH); + }); +}); + +describe('parseCdc', () => { + it('round trips the canonical vector', () => { + const parsed = parseCdc(CANONICAL_CDC); + expect(parsed.cdc).toBe(CANONICAL_CDC); + expect(parsed.tipoDocumento).toBe('01'); + expect(parsed.tipoDocumentoLabel).toBe('factura electronica'); + expect(parsed.rucEmisor).toBe('80069563'); + expect(parsed.dvEmisor).toBe('1'); + expect(parsed.establecimiento).toBe('001'); + expect(parsed.puntoExpedicion).toBe('001'); + expect(parsed.numeroDocumento).toBe('0001234'); + expect(parsed.tipoContribuyente).toBe('1'); + expect(parsed.fechaEmision).toBe('2026-08-15'); + expect(parsed.tipoEmision).toBe('1'); + expect(parsed.codigoSeguridad).toBe('123456789'); + expect(parsed.digitoVerificador).toBe('4'); + }); + + it('reassembles into the same string it parsed', () => { + const parsed = parseCdc(CANONICAL_CDC); + expect( + buildCdc({ ...CANONICAL_PARTS, fechaEmision: parsed.fechaEmision.replaceAll('-', '') }), + ).toBe(CANONICAL_CDC); + }); + + it('tolerates surrounding whitespace from a QR reader', () => { + expect(parseCdc(` ${CANONICAL_CDC}\n`).cdc).toBe(CANONICAL_CDC); + }); + + it('rejects the wrong length', () => { + expect(() => parseCdc(CANONICAL_CDC.slice(0, 43))).toThrow(RuleError); + expect(() => parseCdc(`${CANONICAL_CDC}5`)).toThrow( + expect.objectContaining({ code: 'invalid_cdc' }), + ); + }); + + it('rejects non digits', () => { + expect(() => parseCdc(`X${CANONICAL_CDC.slice(1)}`)).toThrow( + expect.objectContaining({ code: 'invalid_cdc' }), + ); + }); + + it('rejects an impossible date', () => { + const bad = buildCdc({ ...CANONICAL_PARTS, fechaEmision: '20261340' }); + expect(() => parseCdc(bad)).toThrow(expect.objectContaining({ code: 'invalid_date' })); + }); + + it('rejects 29 February in a common year', () => { + const bad = buildCdc({ ...CANONICAL_PARTS, fechaEmision: '20270229' }); + expect(() => parseCdc(bad)).toThrow(expect.objectContaining({ code: 'invalid_date' })); + }); + + it('accepts 29 February in a leap year', () => { + const leap = buildCdc({ ...CANONICAL_PARTS, fechaEmision: '20280229' }); + expect(parseCdc(leap).fechaEmision).toBe('2028-02-29'); + }); + + it('labels every document type in the map and keeps unknown codes', () => { + const labels: Record = { + '01': 'factura electronica', + '04': 'autofactura', + '05': 'nota de credito', + '06': 'nota de debito', + '07': 'nota de remision', + }; + for (const [code, label] of Object.entries(labels)) { + const parsed = parseCdc(buildCdc({ ...CANONICAL_PARTS, tipoDocumento: code })); + expect(parsed.tipoDocumentoLabel, code).toBe(label); + expect(parsed.tipoDocumento, code).toBe(code); + } + + const unknown = parseCdc(buildCdc({ ...CANONICAL_PARTS, tipoDocumento: '99' })); + expect(unknown.tipoDocumento).toBe('99'); + expect(unknown.tipoDocumentoLabel).toBe('otro'); + }); +}); + +describe('docKindFromCdc', () => { + it('maps onto the DocKind vocabulary in CONTRACTS.md', () => { + expect(docKindFromCdc('01')).toBe('factura'); + expect(docKindFromCdc('04')).toBe('autofactura'); + expect(docKindFromCdc('05')).toBe('nota_credito'); + expect(docKindFromCdc('06')).toBe('nota_debito'); + }); + + it('puts a nota de remision and anything unknown in otro', () => { + // 07 has no DocKind of its own; the code and label survive on the parsed fields. + expect(docKindFromCdc('07')).toBe('otro'); + expect(docKindFromCdc('99')).toBe('otro'); + }); +}); + +describe('buildCdc', () => { + it('rejects a field of the wrong width instead of producing a short CDC', () => { + expect(() => buildCdc({ ...CANONICAL_PARTS, rucEmisor: '123' })).toThrow(RuleError); + }); +}); + +describe('parseQrPayload', () => { + it('reads the CDC and the totals from a KUDE URL', () => { + const payload = + `https://ekuatia.set.gov.py/consultas/qr?nVersion=150&Id=${CANONICAL_CDC}` + + '&dFeEmiDE=2026-08-15&dRucRec=4123456&dTotGralOpe=1234567&dTotIVA=112233&cItems=3'; + const parsed = parseQrPayload(payload); + expect(parsed.cdc.cdc).toBe(CANONICAL_CDC); + expect(parsed.total).toBe(1_234_567); + expect(parsed.totalIva).toBe(112_233); + expect(parsed.raw).toBe(payload); + }); + + it('accepts an unrecognised host as long as the Id parses', () => { + const parsed = parseQrPayload(`https://example.invalid/anything?Id=${CANONICAL_CDC}`); + expect(parsed.cdc.cdc).toBe(CANONICAL_CDC); + }); + + it('leaves the totals null when the QR omits them or they are unparseable', () => { + const parsed = parseQrPayload( + `https://ekuatia.set.gov.py/qr?Id=${CANONICAL_CDC}&dTotGralOpe=abc`, + ); + expect(parsed.total).toBeNull(); + expect(parsed.totalIva).toBeNull(); + }); + + it('reads a total printed with thousand separators', () => { + const parsed = parseQrPayload( + `https://ekuatia.set.gov.py/qr?Id=${CANONICAL_CDC}&dTotGralOpe=1.234.567`, + ); + expect(parsed.total).toBe(1_234_567); + }); + + it('accepts a payload that is itself a bare CDC', () => { + const parsed = parseQrPayload(CANONICAL_CDC); + expect(parsed.cdc.cdc).toBe(CANONICAL_CDC); + expect(parsed.total).toBeNull(); + }); + + it('falls back to the whole string when the URL carries no Id', () => { + expect(() => parseQrPayload('https://ekuatia.set.gov.py/consultas/qr?nVersion=150')).toThrow( + expect.objectContaining({ code: 'invalid_cdc' }), + ); + }); + + it('rejects an empty payload', () => { + expect(() => parseQrPayload(' ')).toThrow(expect.objectContaining({ code: 'invalid_qr' })); + }); + + it('rejects a URL whose Id is not a valid CDC', () => { + expect(() => parseQrPayload('https://ekuatia.set.gov.py/qr?Id=12345')).toThrow( + expect.objectContaining({ code: 'invalid_cdc' }), + ); + }); +}); + +describe('QR totals outside the safe integer range', () => { + it('are dropped rather than rounded into a wrong number', () => { + const parsed = parseQrPayload( + `https://ekuatia.set.gov.py/qr?Id=${CANONICAL_CDC}&dTotGralOpe=99999999999999999999`, + ); + expect(parsed.total).toBeNull(); + }); +}); diff --git a/packages/rules/src/cdc.ts b/packages/rules/src/cdc.ts new file mode 100644 index 0000000..32356af --- /dev/null +++ b/packages/rules/src/cdc.ts @@ -0,0 +1,185 @@ +import { parseIsoDate } from './dates'; +import { RuleError } from './errors'; +import { type Pyg, pyg } from './money'; + +/** RULES.md section 4. Offsets and lengths are fixed and sum to exactly 44. */ +export interface CdcFields { + cdc: string; + tipoDocumento: string; + /** Spanish label for the code, or "otro" when the code is not in the map. */ + tipoDocumentoLabel: string; + rucEmisor: string; + dvEmisor: string; + establecimiento: string; + puntoExpedicion: string; + numeroDocumento: string; + tipoContribuyente: string; + /** YYYY-MM-DD, converted from the YYYYMMDD field. */ + fechaEmision: string; + tipoEmision: string; + codigoSeguridad: string; + digitoVerificador: string; +} + +interface FieldSpec { + readonly name: keyof CdcFields; + readonly length: number; +} + +/** The field table, in order. Offsets are derived so they cannot drift from the lengths. */ +export const CDC_FIELDS: readonly FieldSpec[] = [ + { name: 'tipoDocumento', length: 2 }, + { name: 'rucEmisor', length: 8 }, + { name: 'dvEmisor', length: 1 }, + { name: 'establecimiento', length: 3 }, + { name: 'puntoExpedicion', length: 3 }, + { name: 'numeroDocumento', length: 7 }, + { name: 'tipoContribuyente', length: 1 }, + { name: 'fechaEmision', length: 8 }, + { name: 'tipoEmision', length: 1 }, + { name: 'codigoSeguridad', length: 9 }, + { name: 'digitoVerificador', length: 1 }, +]; + +export const CDC_LENGTH = 44; + +const TIPO_DOCUMENTO_LABEL: Readonly> = { + '01': 'factura electronica', + '04': 'autofactura', + '05': 'nota de credito', + '06': 'nota de debito', + '07': 'nota de remision', +}; + +/** + * `documents.doc_kind` for a CDC document type. `07` has no DocKind of its own in + * CONTRACTS.md section 2, so it lands in "otro" alongside the unknown codes; the original + * code and label stay on the parsed fields either way. + */ +const DOC_KIND_BY_TIPO: Readonly> = { + '01': 'factura', + '04': 'autofactura', + '05': 'nota_credito', + '06': 'nota_debito', +}; + +export function docKindFromCdc(tipoDocumento: string): string { + return DOC_KIND_BY_TIPO[tipoDocumento] ?? 'otro'; +} + +export function parseCdc(cdc: string): CdcFields { + const value = cdc.trim(); + + if (value.length !== CDC_LENGTH) { + throw new RuleError('invalid_cdc', `CDC must be ${CDC_LENGTH} digits, got ${value.length}`, { + length: value.length, + }); + } + if (!/^\d+$/.test(value)) { + throw new RuleError('invalid_cdc', 'CDC must be digits only', { cdc: value }); + } + + const raw: Record = {}; + let offset = 0; + for (const field of CDC_FIELDS) { + raw[field.name] = value.slice(offset, offset + field.length); + offset += field.length; + } + + const fecha = raw['fechaEmision'] as string; + const fechaEmision = `${fecha.slice(0, 4)}-${fecha.slice(4, 6)}-${fecha.slice(6, 8)}`; + parseIsoDate(fechaEmision); // throws invalid_date for 20261340 and friends + + const tipoDocumento = raw['tipoDocumento'] as string; + + return { + cdc: value, + tipoDocumento, + tipoDocumentoLabel: TIPO_DOCUMENTO_LABEL[tipoDocumento] ?? 'otro', + rucEmisor: raw['rucEmisor'] as string, + dvEmisor: raw['dvEmisor'] as string, + establecimiento: raw['establecimiento'] as string, + puntoExpedicion: raw['puntoExpedicion'] as string, + numeroDocumento: raw['numeroDocumento'] as string, + tipoContribuyente: raw['tipoContribuyente'] as string, + fechaEmision, + tipoEmision: raw['tipoEmision'] as string, + codigoSeguridad: raw['codigoSeguridad'] as string, + digitoVerificador: raw['digitoVerificador'] as string, + }; +} + +/** Builds a CDC from its parts, so tests and the fixtures script never hardcode a string. */ +export function buildCdc(parts: { + tipoDocumento: string; + rucEmisor: string; + dvEmisor: string; + establecimiento: string; + puntoExpedicion: string; + numeroDocumento: string; + tipoContribuyente: string; + fechaEmision: string; // YYYYMMDD + tipoEmision: string; + codigoSeguridad: string; + digitoVerificador: string; +}): string { + let cdc = ''; + for (const field of CDC_FIELDS) { + const value = parts[field.name as keyof typeof parts]; + if (value.length !== field.length) { + throw new RuleError( + 'invalid_cdc', + `${field.name} must be ${field.length} characters, got "${value}"`, + ); + } + cdc += value; + } + return cdc; +} + +export interface QrPayload { + cdc: CdcFields; + /** Everything the QR carried, stored opaque in documents.qr_url. */ + raw: string; + /** dTotGralOpe, when the QR carried a parseable integer. */ + total: Pyg | null; + /** dTotIVA, when the QR carried a parseable integer. */ + totalIva: Pyg | null; +} + +/** + * The KUDE QR is a URL carrying the CDC in `Id`. The host is deliberately not checked: + * offline fixtures and older KUDEs use whatever host printed them, and the CDC either + * parses or it does not. A payload that is itself a bare 44 digit CDC is also accepted. + */ +export function parseQrPayload(payload: string): QrPayload { + const raw = payload.trim(); + + let idParam: string | null = null; + let total: Pyg | null = null; + let totalIva: Pyg | null = null; + + try { + const url = new URL(raw); + idParam = url.searchParams.get('Id'); + total = parseIntegerParam(url.searchParams.get('dTotGralOpe')); + totalIva = parseIntegerParam(url.searchParams.get('dTotIVA')); + } catch { + // Not a URL. The payload may still be a bare CDC, which is handled below. + } + + const candidate = idParam ?? raw; + if (candidate.length === 0) { + throw new RuleError('invalid_qr', 'QR payload carried no CDC', { raw }); + } + + return { cdc: parseCdc(candidate), raw, total, totalIva }; +} + +function parseIntegerParam(value: string | null): Pyg | null { + if (value === null) return null; + const normalised = value.trim().replace(/\./g, ''); + if (!/^-?\d+$/.test(normalised)) return null; + const parsed = Number(normalised); + return Number.isSafeInteger(parsed) ? pyg(parsed) : null; +} diff --git a/packages/rules/src/classification.test.ts b/packages/rules/src/classification.test.ts new file mode 100644 index 0000000..660711f --- /dev/null +++ b/packages/rules/src/classification.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from 'vitest'; +import { CATEGORY_HINTS, categoryForEmitter, normaliseEmitterName } from './categoryHints'; +import { + CONFIDENCE_KEYWORD_HIT, + CONFIDENCE_NO_HIT, + type ClassificationInput, + classify, +} from './classification'; +import { pyg } from './money'; + +function input(overrides: Partial = {}): ClassificationInput { + return { + direction: 'purchase', + docKind: 'factura', + emitterName: 'SUPERMERCADO REAL', + emitterRuc: '80069563', + supplierRegimeHint: 'normal', + taxpayer: { kind: 'individual', hasIva: true, hasIrp: true }, + amounts: { total: pyg(550_000), iva10: pyg(50_000), iva5: pyg(0) }, + ...overrides, + }; +} + +describe('normaliseEmitterName', () => { + it('uppercases and strips diacritics', () => { + expect(normaliseEmitterName('Farmacia Catedral')).toBe('FARMACIA CATEDRAL'); + expect(normaliseEmitterName('Almacén Mercadería')).toBe('ALMACEN MERCADERIA'); + }); + + // The tilde on Ñ is a diacritic to Unicode, so "diacritic-insensitive" folds it to N. + // No keyword contains Ñ, so matching is unaffected and the folding is the forgiving + // choice when a printed name spells it either way. + it('folds Ñ to N', () => { + expect(normaliseEmitterName('Ñanduti Modas')).toBe('NANDUTI MODAS'); + expect(categoryForEmitter('Ñanduti Modas')).toBe('vestimenta'); + }); +}); + +describe('categoryForEmitter', () => { + it('matches every keyword in the map', () => { + for (const hint of CATEGORY_HINTS) { + for (const keyword of hint.keywords) { + expect(categoryForEmitter(`EMPRESA ${keyword} SRL`), keyword).toBe(hint.category); + } + } + }); + + it('matches case and diacritic insensitively', () => { + expect(categoryForEmitter('farmacia catedral')).toBe('salud'); + expect(categoryForEmitter('Clínica Mediterráneo')).toBe('salud'); + }); + + it('takes the first matching row when several could apply', () => { + // FARMACIA (salud) is tried before TIENDA (vestimenta). + expect(categoryForEmitter('TIENDA FARMACIA CENTRAL')).toBe('salud'); + }); + + it('never assigns familiares from a keyword', () => { + expect(CATEGORY_HINTS.some((hint) => hint.category === 'familiares')).toBe(false); + expect(categoryForEmitter('FAMILIARES A CARGO')).toBeNull(); + }); + + it('returns null rather than guessing', () => { + expect(categoryForEmitter('SERVICIOS INTEGRALES SA')).toBeNull(); + expect(categoryForEmitter('')).toBeNull(); + }); + + it('classifies the seeded emitters the way the seed expects', () => { + expect(categoryForEmitter('SUPERMERCADO REAL')).toBe('alimentacion'); + expect(categoryForEmitter('FARMACIA CATEDRAL')).toBe('salud'); + expect(categoryForEmitter('COLEGIO SAN JOSE')).toBe('educacion'); + expect(categoryForEmitter('PETROBRAS ESTACION 12')).toBe('vehiculo'); + expect(categoryForEmitter('INMOBILIARIA DEL SOL')).toBe('vivienda'); + expect(categoryForEmitter('BOUTIQUE ANDREA')).toBe('vestimenta'); + expect(categoryForEmitter('CINE ITAU')).toBe('esparcimiento'); + expect(categoryForEmitter('DESPENSA DON JUAN')).toBe('alimentacion'); + }); +}); + +describe('classify, IVA credit', () => { + it('grants the credit on a normal purchase for an IVA taxpayer', () => { + const result = classify(input()); + expect(result.ivaCreditEligible).toBe(true); + expect(result.ivaCreditAmount).toBe(50_000); + expect(result.reasons).toContain('iva_credit_eligible'); + }); + + it('adds both IVA rates together', () => { + const result = classify( + input({ amounts: { total: pyg(1_000_000), iva10: pyg(60_000), iva5: pyg(15_000) } }), + ); + expect(result.ivaCreditAmount).toBe(75_000); + }); + + it('denies it to a taxpayer without IVA', () => { + const result = classify(input({ taxpayer: { kind: 'individual', hasIva: false, hasIrp: true } })); + expect(result.ivaCreditEligible).toBe(false); + expect(result.ivaCreditAmount).toBe(0); + expect(result.reasons).toContain('iva_credit_not_iva_taxpayer'); + }); + + it('denies it on a sale', () => { + const result = classify(input({ direction: 'sale' })); + expect(result.ivaCreditEligible).toBe(false); + expect(result.reasons).toContain('iva_credit_sale_document'); + }); + + it('denies it for a RESIMPLE supplier', () => { + const result = classify(input({ supplierRegimeHint: 'resimple' })); + expect(result.ivaCreditEligible).toBe(false); + expect(result.reasons).toContain('iva_credit_resimple_supplier'); + }); + + it('denies it for a boleta RESIMPLE even when the regime hint says otherwise', () => { + const result = classify(input({ docKind: 'boleta_resimple', supplierRegimeHint: 'unknown' })); + expect(result.ivaCreditEligible).toBe(false); + expect(result.reasons).toContain('iva_credit_resimple_supplier'); + }); + + it('denies it when the document carries no IVA', () => { + const result = classify( + input({ amounts: { total: pyg(100_000), iva10: pyg(0), iva5: pyg(0) } }), + ); + expect(result.ivaCreditEligible).toBe(false); + expect(result.reasons).toContain('iva_credit_no_iva_amount'); + }); + + it('allows an unknown supplier regime', () => { + expect(classify(input({ supplierRegimeHint: 'unknown' })).ivaCreditEligible).toBe(true); + }); +}); + +describe('classify, IRP deduction', () => { + it('deducts the full total when a keyword matches', () => { + const result = classify(input({ emitterName: 'FARMACIA CATEDRAL' })); + expect(result.irpCategory).toBe('salud'); + expect(result.irpDeductibleAmount).toBe(550_000); + expect(result.confidence).toBe(CONFIDENCE_KEYWORD_HIT); + expect(result.reasons).toContain('irp_category_keyword'); + }); + + // A RESIMPLE supplier blocks the IVA credit but not the deduction: the 1% cap is + // applied later, at declaration time (RULES.md section 7 step 2). + it('still deducts from a RESIMPLE supplier', () => { + const result = classify(input({ supplierRegimeHint: 'resimple' })); + expect(result.ivaCreditEligible).toBe(false); + expect(result.irpCategory).toBe('alimentacion'); + expect(result.irpDeductibleAmount).toBe(550_000); + }); + + it('suggests nothing when no keyword matches', () => { + const result = classify(input({ emitterName: 'SERVICIOS INTEGRALES SA' })); + expect(result.irpCategory).toBe('none'); + expect(result.irpDeductibleAmount).toBe(0); + expect(result.confidence).toBe(CONFIDENCE_NO_HIT); + expect(result.reasons).toContain('irp_no_category'); + }); + + it('never deducts a sale, which is income', () => { + const result = classify(input({ direction: 'sale', emitterName: 'FARMACIA CATEDRAL' })); + expect(result.irpCategory).toBe('none'); + expect(result.irpDeductibleAmount).toBe(0); + expect(result.reasons).toContain('irp_sale_is_income'); + }); + + // SPEC-GAP pin: RULES.md never says whether hasIrp gates the deduction. It does here, + // and the category is still suggested so the data is right if IRP is enabled later. + it('suggests the category but deducts nothing for a non IRP taxpayer', () => { + const result = classify( + input({ + emitterName: 'FARMACIA CATEDRAL', + taxpayer: { kind: 'company', hasIva: true, hasIrp: false }, + }), + ); + expect(result.irpCategory).toBe('salud'); + expect(result.irpDeductibleAmount).toBe(0); + expect(result.reasons).toContain('irp_not_irp_taxpayer'); + }); +}); + +describe('confidence', () => { + it('is 0.85 on a keyword hit and 0.4 otherwise', () => { + expect(classify(input()).confidence).toBe(0.85); + expect(classify(input({ emitterName: 'ACME SA' })).confidence).toBe(0.4); + }); + + // SPEC-GAP pin: RULES.md defines confidence only for the keyword map, and a sale can + // never hit it, so a sale reads as "no hit". That keeps sales out of auto confirm. + it('pins that a sale reads as no hit', () => { + expect(classify(input({ direction: 'sale' })).confidence).toBe(CONFIDENCE_NO_HIT); + }); +}); diff --git a/packages/rules/src/classification.ts b/packages/rules/src/classification.ts new file mode 100644 index 0000000..c2e7ccd --- /dev/null +++ b/packages/rules/src/classification.ts @@ -0,0 +1,103 @@ +import type { IrpCategoryOrNone } from './categories'; +import { categoryForEmitter } from './categoryHints'; +import { type Pyg, ZERO, pyg } from './money'; + +/** + * Reason codes, not sentences. The package is pure and locale free, so the UI maps these + * onto `classification.reason.*` in the message catalogs and shows them in the detail + * sheet in the reader's language. + */ +export type ClassificationReason = + | 'iva_credit_eligible' + | 'iva_credit_not_iva_taxpayer' + | 'iva_credit_sale_document' + | 'iva_credit_resimple_supplier' + | 'iva_credit_no_iva_amount' + | 'irp_category_keyword' + | 'irp_no_category' + | 'irp_sale_is_income' + | 'irp_not_irp_taxpayer'; + +export interface ClassificationInput { + direction: 'purchase' | 'sale'; + docKind: string; + emitterName: string; + 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; + irpCategory: IrpCategoryOrNone; + irpDeductibleAmount: Pyg; + confidence: number; + reasons: ClassificationReason[]; +} + +/** RULES.md section 5. Keyword hit. */ +export const CONFIDENCE_KEYWORD_HIT = 0.85; +/** RULES.md section 5. No keyword hit: suggest nothing rather than guess. */ +export const CONFIDENCE_NO_HIT = 0.4; + +export function classify(input: ClassificationInput): ClassificationSuggestion { + const reasons: ClassificationReason[] = []; + const isPurchase = input.direction === 'purchase'; + const ivaTotal = input.amounts.iva10 + input.amounts.iva5; + + // IVA credit, purchases only. v1 assumes business purpose for an IVA taxpayer; the user + // can turn it off per document, which overrides this and sets decided_by='user'. + let ivaCreditEligible = true; + if (!input.taxpayer.hasIva) { + ivaCreditEligible = false; + reasons.push('iva_credit_not_iva_taxpayer'); + } else if (!isPurchase) { + ivaCreditEligible = false; + reasons.push('iva_credit_sale_document'); + } else if (input.supplierRegimeHint === 'resimple' || input.docKind === 'boleta_resimple') { + ivaCreditEligible = false; + reasons.push('iva_credit_resimple_supplier'); + } else if (ivaTotal <= 0) { + ivaCreditEligible = false; + reasons.push('iva_credit_no_iva_amount'); + } else { + reasons.push('iva_credit_eligible'); + } + + // IRP category. A RESIMPLE supplier blocks the IVA credit but not the deduction: that + // is capped later, at declaration time (RULES.md section 7 step 2). + const keywordCategory = isPurchase ? categoryForEmitter(input.emitterName) : null; + let irpCategory: IrpCategoryOrNone = 'none'; + let confidence = CONFIDENCE_NO_HIT; + + if (!isPurchase) { + reasons.push('irp_sale_is_income'); + } else if (keywordCategory) { + irpCategory = keywordCategory; + confidence = CONFIDENCE_KEYWORD_HIT; + reasons.push('irp_category_keyword'); + } else { + reasons.push('irp_no_category'); + } + + // SPEC-GAP: RULES.md section 5 states the IVA eligibility test in full but never says + // whether `taxpayer.hasIrp` gates the deduction. It is the only field of the documented + // input that would otherwise go unused, so it gates the amount. The category is still + // suggested either way, so the data is already right if the user registers for IRP later. + let irpDeductibleAmount = ZERO; + if (irpCategory !== 'none') { + if (input.taxpayer.hasIrp) irpDeductibleAmount = pyg(input.amounts.total); + else reasons.push('irp_not_irp_taxpayer'); + } + + return { + ivaCreditEligible, + ivaCreditAmount: ivaCreditEligible ? pyg(ivaTotal) : ZERO, + irpCategory, + irpDeductibleAmount, + confidence, + reasons, + }; +} diff --git a/packages/rules/src/constants.test.ts b/packages/rules/src/constants.test.ts new file mode 100644 index 0000000..c129f99 --- /dev/null +++ b/packages/rules/src/constants.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { + DEADLINE_DAY_BY_DIGIT, + IRP_BRACKET_1_LIMIT, + IRP_BRACKET_2_LIMIT, + IRP_RATE_1, + IRP_RATE_2, + IRP_RATE_3, + IRP_THRESHOLD_ANNUAL, + IVA_RATE_10, + IVA_RATE_5, + RESIMPLE_SUPPLIER_DEDUCTION_CAP_PCT, + RULES_VERSION, +} from './constants'; + +describe('constants', () => { + it('matches RULES.md section 1', () => { + expect(RULES_VERSION).toBe('1.0.0'); + expect(IVA_RATE_10).toBe(10); + expect(IVA_RATE_5).toBe(5); + expect(RESIMPLE_SUPPLIER_DEDUCTION_CAP_PCT).toBe(1); + }); + + // TODO-TAX-VERIFY pin: IRP tranche boundaries and the 80M threshold. When these are + // checked against the live DNIT tables, this test is the red/green diff. + it('pins the IRP tranche boundaries and threshold pending verification', () => { + expect(IRP_THRESHOLD_ANNUAL).toBe(80_000_000); + expect(IRP_BRACKET_1_LIMIT).toBe(50_000_000); + expect(IRP_BRACKET_2_LIMIT).toBe(150_000_000); + expect([IRP_RATE_1, IRP_RATE_2, IRP_RATE_3]).toEqual([8, 9, 10]); + }); + + it('pins the calendario perpetuo table', () => { + expect(DEADLINE_DAY_BY_DIGIT).toEqual({ + 0: 7, 1: 9, 2: 11, 3: 13, 4: 15, 5: 17, 6: 19, 7: 21, 8: 23, 9: 25, + }); + }); + + it('spaces the deadline days two days apart across the digits', () => { + const days = Object.keys(DEADLINE_DAY_BY_DIGIT) + .map(Number) + .sort((a, b) => a - b) + .map((digit) => DEADLINE_DAY_BY_DIGIT[digit] as number); + for (let i = 1; i < days.length; i++) { + expect((days[i] as number) - (days[i - 1] as number)).toBe(2); + } + }); +}); diff --git a/packages/rules/src/constants.ts b/packages/rules/src/constants.ts new file mode 100644 index 0000000..3085014 --- /dev/null +++ b/packages/rules/src/constants.ts @@ -0,0 +1,39 @@ +/** RULES.md section 1. Every number the tax math depends on lives here. */ + +export const RULES_VERSION = '1.0.0'; + +export const IVA_RATE_10 = 10; +export const IVA_RATE_5 = 5; + +// TODO-TAX-VERIFY: IRP tranche boundaries and the 80M registration threshold against the +// live DNIT tables for the current fiscal year. Pinned by constants.test.ts, so verifying +// them later is a red/green diff. +/** Gs. Annual gross income at which IRP registration becomes obligatory. */ +export const IRP_THRESHOLD_ANNUAL = 80_000_000; +/** Gs. Taxed at 8% up to here. */ +export const IRP_BRACKET_1_LIMIT = 50_000_000; +/** Gs. The tranche above bracket 1 up to here is taxed at 9%, anything above at 10%. */ +export const IRP_BRACKET_2_LIMIT = 150_000_000; +export const IRP_RATE_1 = 8; +export const IRP_RATE_2 = 9; +export const IRP_RATE_3 = 10; + +/** Purchases from RESIMPLE suppliers deduct only up to this share of gross annual income. */ +export const RESIMPLE_SUPPLIER_DEDUCTION_CAP_PCT = 1; + +/** + * Calendario perpetuo: the day of the month an obligation falls due, by the last digit of + * the RUC base. RULES.md section 1. + */ +export const DEADLINE_DAY_BY_DIGIT: Readonly> = { + 0: 7, + 1: 9, + 2: 11, + 3: 13, + 4: 15, + 5: 17, + 6: 19, + 7: 21, + 8: 23, + 9: 25, +}; diff --git a/packages/rules/src/dates.test.ts b/packages/rules/src/dates.test.ts new file mode 100644 index 0000000..35252d9 --- /dev/null +++ b/packages/rules/src/dates.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; +import { + addDays, + addMonths, + compareDates, + dayOfWeek, + daysInMonth, + formatIsoDate, + formatPeriod, + fromDate, + isValidCivilDate, + isWeekend, + parseIsoDate, + parsePeriod, + periodOf, + toDate, + todayInAsuncion, +} from './dates'; +import { RuleError } from './errors'; + +describe('parseIsoDate', () => { + it('parses a well formed date', () => { + expect(parseIsoDate('2026-09-19')).toEqual({ year: 2026, month: 9, day: 19 }); + }); + + it('rejects the wrong shape', () => { + for (const value of ['2026-9-19', '19/09/2026', '2026-09', '', 'today']) { + expect(() => parseIsoDate(value), value).toThrow(RuleError); + } + }); + + it('rejects a date that does not exist', () => { + expect(() => parseIsoDate('2026-02-30')).toThrow( + expect.objectContaining({ code: 'invalid_date' }), + ); + expect(() => parseIsoDate('2026-13-01')).toThrow(RuleError); + expect(() => parseIsoDate('2026-00-10')).toThrow(RuleError); + }); + + it('round trips through formatIsoDate', () => { + for (const value of ['2026-01-01', '2026-12-31', '2028-02-29', '0001-01-01']) { + expect(formatIsoDate(parseIsoDate(value)), value).toBe(value); + } + }); +}); + +describe('isValidCivilDate', () => { + it('knows the length of each month', () => { + expect(isValidCivilDate({ year: 2026, month: 2, day: 28 })).toBe(true); + expect(isValidCivilDate({ year: 2026, month: 2, day: 29 })).toBe(false); + expect(isValidCivilDate({ year: 2028, month: 2, day: 29 })).toBe(true); + expect(isValidCivilDate({ year: 2026, month: 4, day: 31 })).toBe(false); + expect(isValidCivilDate({ year: 2026, month: 1, day: 0 })).toBe(false); + expect(isValidCivilDate({ year: 2026.5, month: 1, day: 1 })).toBe(false); + }); + + it('counts the days in a month', () => { + expect(daysInMonth(2026, 2)).toBe(28); + expect(daysInMonth(2028, 2)).toBe(29); + expect(daysInMonth(2026, 12)).toBe(31); + expect(daysInMonth(2026, 4)).toBe(30); + }); +}); + +describe('dayOfWeek', () => { + it('is 0 for Sunday and does not shift with the host timezone', () => { + expect(dayOfWeek(parseIsoDate('2026-09-20'))).toBe(0); + expect(dayOfWeek(parseIsoDate('2026-09-21'))).toBe(1); + expect(dayOfWeek(parseIsoDate('2026-09-19'))).toBe(6); + }); + + it('marks both weekend days', () => { + expect(isWeekend(parseIsoDate('2026-09-19'))).toBe(true); + expect(isWeekend(parseIsoDate('2026-09-20'))).toBe(true); + expect(isWeekend(parseIsoDate('2026-09-21'))).toBe(false); + }); +}); + +describe('addDays', () => { + it('crosses month and year boundaries', () => { + expect(formatIsoDate(addDays(parseIsoDate('2026-01-31'), 1))).toBe('2026-02-01'); + expect(formatIsoDate(addDays(parseIsoDate('2026-12-31'), 1))).toBe('2027-01-01'); + expect(formatIsoDate(addDays(parseIsoDate('2028-02-28'), 1))).toBe('2028-02-29'); + expect(formatIsoDate(addDays(parseIsoDate('2026-01-01'), -1))).toBe('2025-12-31'); + }); +}); + +describe('compareDates', () => { + it('orders by year, then month, then day', () => { + const a = parseIsoDate('2026-09-19'); + expect(compareDates(a, parseIsoDate('2026-09-19'))).toBe(0); + expect(compareDates(a, parseIsoDate('2026-09-20'))).toBeLessThan(0); + expect(compareDates(a, parseIsoDate('2026-08-31'))).toBeGreaterThan(0); + expect(compareDates(a, parseIsoDate('2027-01-01'))).toBeLessThan(0); + }); +}); + +describe('toDate and fromDate', () => { + it('round trip through midnight UTC', () => { + const civil = parseIsoDate('2026-09-19'); + expect(toDate(civil).toISOString()).toBe('2026-09-19T00:00:00.000Z'); + expect(fromDate(toDate(civil))).toEqual(civil); + }); +}); + +describe('todayInAsuncion', () => { + it('reads the civil date in Paraguay, not in UTC', () => { + // Asuncion is behind UTC, so just after midnight UTC it is still the previous day. + expect(formatIsoDate(todayInAsuncion(new Date('2026-09-20T02:00:00Z')))).toBe('2026-09-19'); + expect(formatIsoDate(todayInAsuncion(new Date('2026-09-19T12:00:00Z')))).toBe('2026-09-19'); + }); + + it('agrees with UTC late in the day', () => { + expect(formatIsoDate(todayInAsuncion(new Date('2026-09-19T23:00:00Z')))).toBe('2026-09-19'); + }); +}); + +describe('periods', () => { + it('parses and formats', () => { + expect(parsePeriod('2026-08')).toEqual({ year: 2026, month: 8 }); + expect(formatPeriod(2026, 8)).toBe('2026-08'); + expect(formatPeriod(2026, 12)).toBe('2026-12'); + }); + + it('rejects a malformed period', () => { + for (const value of ['2026', '2026-8', '2026-13', '2026-00', 'agosto']) { + expect(() => parsePeriod(value), value).toThrow(RuleError); + } + }); + + it('adds months across year boundaries in both directions', () => { + expect(addMonths('2026-08', 1)).toBe('2026-09'); + expect(addMonths('2026-12', 1)).toBe('2027-01'); + expect(addMonths('2026-01', -1)).toBe('2025-12'); + expect(addMonths('2026-08', -12)).toBe('2025-08'); + expect(addMonths('2026-08', 0)).toBe('2026-08'); + expect(addMonths('2026-08', 17)).toBe('2028-01'); + }); + + it('names the period a date belongs to', () => { + expect(periodOf(parseIsoDate('2026-08-31'))).toBe('2026-08'); + expect(periodOf(parseIsoDate('2026-09-01'))).toBe('2026-09'); + }); +}); diff --git a/packages/rules/src/dates.ts b/packages/rules/src/dates.ts new file mode 100644 index 0000000..a71b20d --- /dev/null +++ b/packages/rules/src/dates.ts @@ -0,0 +1,122 @@ +import { RuleError } from './errors'; + +/** + * A calendar date with no time and no zone. Deadlines are civil dates: "the 19th" is the + * 19th regardless of the hour, so every calculation here works on year/month/day and only + * `todayInAsuncion` ever consults a timezone. + */ +export interface CivilDate { + year: number; + month: number; // 1..12 + day: number; +} + +const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/; +const ISO_PERIOD = /^(\d{4})-(\d{2})$/; + +export function parseIsoDate(value: string): CivilDate { + const match = ISO_DATE.exec(value); + if (!match) throw new RuleError('invalid_date', `expected YYYY-MM-DD, got: ${value}`, { value }); + const date = { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) }; + if (!isValidCivilDate(date)) { + throw new RuleError('invalid_date', `not a real date: ${value}`, { value }); + } + return date; +} + +export function formatIsoDate(date: CivilDate): string { + const month = String(date.month).padStart(2, '0'); + const day = String(date.day).padStart(2, '0'); + return `${String(date.year).padStart(4, '0')}-${month}-${day}`; +} + +export function isValidCivilDate(date: CivilDate): boolean { + if (!Number.isInteger(date.year) || !Number.isInteger(date.month) || !Number.isInteger(date.day)) { + return false; + } + if (date.month < 1 || date.month > 12 || date.day < 1) return false; + return date.day <= daysInMonth(date.year, date.month); +} + +export function daysInMonth(year: number, month: number): number { + return new Date(Date.UTC(year, month, 0)).getUTCDate(); +} + +/** 0 = Sunday. Uses UTC arithmetic, so it never shifts with the host timezone. */ +export function dayOfWeek(date: CivilDate): number { + return new Date(Date.UTC(date.year, date.month - 1, date.day)).getUTCDay(); +} + +export function isWeekend(date: CivilDate): boolean { + const day = dayOfWeek(date); + return day === 0 || day === 6; +} + +export function addDays(date: CivilDate, days: number): CivilDate { + const shifted = new Date(Date.UTC(date.year, date.month - 1, date.day + days)); + return { + year: shifted.getUTCFullYear(), + month: shifted.getUTCMonth() + 1, + day: shifted.getUTCDate(), + }; +} + +export function compareDates(a: CivilDate, b: CivilDate): number { + if (a.year !== b.year) return a.year - b.year; + if (a.month !== b.month) return a.month - b.month; + return a.day - b.day; +} + +/** + * Midnight UTC of the civil date. `nextDeadline` returns a Date per the RULES.md + * signature; this is the one construction used for it, so a due date never drifts a day + * because of the host timezone. + */ +export function toDate(date: CivilDate): Date { + return new Date(Date.UTC(date.year, date.month - 1, date.day)); +} + +export function fromDate(date: Date): CivilDate { + return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1, day: date.getUTCDate() }; +} + +/** The only timezone aware function in the package. RULES.md section 3. */ +export const DEADLINE_TIMEZONE = 'America/Asuncion'; + +export function todayInAsuncion(now: Date): CivilDate { + // en-CA formats as YYYY-MM-DD, which is exactly the civil date in that zone. + const formatted = new Intl.DateTimeFormat('en-CA', { + timeZone: DEADLINE_TIMEZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(now); + return parseIsoDate(formatted); +} + +// Periods: "YYYY-MM" for a month, "YYYY" for a year. + +export function parsePeriod(period: string): { year: number; month: number } { + const match = ISO_PERIOD.exec(period); + if (!match) throw new RuleError('invalid_date', `expected YYYY-MM, got: ${period}`, { period }); + const month = Number(match[2]); + if (month < 1 || month > 12) { + throw new RuleError('invalid_date', `month out of range: ${period}`, { period }); + } + return { year: Number(match[1]), month }; +} + +export function formatPeriod(year: number, month: number): string { + return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}`; +} + +export function addMonths(period: string, months: number): string { + const { year, month } = parsePeriod(period); + const zeroBased = year * 12 + (month - 1) + months; + return formatPeriod(Math.floor(zeroBased / 12), (zeroBased % 12) + 1); +} + +/** The "YYYY-MM" a date falls in. */ +export function periodOf(date: CivilDate): string { + return formatPeriod(date.year, date.month); +} diff --git a/packages/rules/src/errors.ts b/packages/rules/src/errors.ts new file mode 100644 index 0000000..d70b67f --- /dev/null +++ b/packages/rules/src/errors.ts @@ -0,0 +1,28 @@ +export type RuleErrorCode = + | 'invalid_ruc' + | 'invalid_cdc' + | 'invalid_qr' + | 'invalid_date' + | 'invalid_money' + | 'out_of_range'; + +/** + * The typed error every rule throws. It carries a machine readable `code` so the API can + * map it onto an error envelope and an ingest_errors row, and it never carries user + * facing copy: this package is pure and locale free. + */ +export class RuleError extends Error { + readonly code: RuleErrorCode; + readonly detail: Readonly> | undefined; + + constructor(code: RuleErrorCode, message: string, detail?: Record) { + super(message); + this.name = 'RuleError'; + this.code = code; + this.detail = detail; + } +} + +export function isRuleError(value: unknown): value is RuleError { + return value instanceof RuleError; +} diff --git a/packages/rules/src/f120.test.ts b/packages/rules/src/f120.test.ts new file mode 100644 index 0000000..a7efbc3 --- /dev/null +++ b/packages/rules/src/f120.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import { type F120Doc, computeF120 } from './f120'; +import { pyg } from './money'; + +let counter = 0; + +function sale(iva10: number, opts: { issueDate?: string; total?: number } = {}): F120Doc { + counter += 1; + return { + id: `sale-${counter}`, + direction: 'sale', + issueDate: opts.issueDate ?? '2026-08-10', + total: pyg(opts.total ?? iva10 * 11), + amountIva10: pyg(iva10 * 10), + amountIva5: pyg(0), + amountExenta: pyg(0), + iva10: pyg(iva10), + iva5: pyg(0), + ivaCreditEligible: false, + ivaCreditAmount: pyg(0), + }; +} + +function purchase( + iva10: number, + iva5: number, + opts: { eligible?: boolean; issueDate?: string } = {}, +): F120Doc { + counter += 1; + const eligible = opts.eligible ?? true; + return { + id: `purchase-${counter}`, + direction: 'purchase', + issueDate: opts.issueDate ?? '2026-08-12', + total: pyg(iva10 * 11 + iva5 * 21), + amountIva10: pyg(iva10 * 10), + amountIva5: pyg(iva5 * 20), + amountExenta: pyg(0), + iva10: pyg(iva10), + iva5: pyg(iva5), + ivaCreditEligible: eligible, + ivaCreditAmount: pyg(eligible ? iva10 + iva5 : 0), + }; +} + +/** RULES.md section 6, worked example, verbatim. */ +describe('computeF120, RULES.md worked example', () => { + const documents = [ + sale(400_000), + sale(500_000), + sale(227_273), + purchase(610_000, 90_000), + ]; + + it('produces a monto a pagar of 277.273', () => { + const result = computeF120({ + period: '2026-08', + saldoAnterior: pyg(150_000), + documents, + }); + + expect(result.debito).toBe(1_127_273); + expect(result.credito).toBe(700_000); + expect(result.creditoTotal).toBe(850_000); + expect(result.aPagar).toBe(277_273); + expect(result.saldoAFavor).toBe(0); + }); + + it('flips to a saldo a favor of 350.000 when the debito is only 500.000', () => { + const result = computeF120({ + period: '2026-08', + saldoAnterior: pyg(150_000), + documents: [sale(500_000), purchase(610_000, 90_000)], + }); + + expect(result.debito).toBe(500_000); + expect(result.creditoTotal).toBe(850_000); + expect(result.aPagar).toBe(0); + expect(result.saldoAFavor).toBe(350_000); + }); +}); + +describe('computeF120', () => { + it('is exactly balanced when debito equals creditoTotal', () => { + const result = computeF120({ + period: '2026-08', + saldoAnterior: pyg(0), + documents: [sale(700_000), purchase(700_000, 0)], + }); + expect(result.aPagar).toBe(0); + expect(result.saldoAFavor).toBe(0); + }); + + it('ignores purchases that are not credit eligible', () => { + const result = computeF120({ + period: '2026-08', + saldoAnterior: pyg(0), + documents: [sale(100_000), purchase(80_000, 0, { eligible: false })], + }); + expect(result.credito).toBe(0); + expect(result.aPagar).toBe(100_000); + expect(result.comprasGravadas10).toBe(0); + }); + + it('drops documents from another period rather than folding them in', () => { + const result = computeF120({ + period: '2026-08', + saldoAnterior: pyg(0), + documents: [ + sale(100_000), + sale(999_999, { issueDate: '2026-07-31' }), + purchase(50_000, 0, { issueDate: '2026-09-01' }), + ], + }); + expect(result.debito).toBe(100_000); + expect(result.credito).toBe(0); + expect(result.documentIds).toHaveLength(1); + }); + + it('reports the sale and purchase totals for the summary line', () => { + const result = computeF120({ + period: '2026-08', + saldoAnterior: pyg(0), + documents: [sale(100_000, { total: 1_100_000 }), purchase(50_000, 0)], + }); + expect(result.sales).toBe(1_100_000); + expect(result.purchases).toBe(550_000); + }); + + it('carries a saldo anterior through with no documents at all', () => { + const result = computeF120({ period: '2026-08', saldoAnterior: pyg(90_000), documents: [] }); + expect(result.debito).toBe(0); + expect(result.aPagar).toBe(0); + expect(result.saldoAFavor).toBe(90_000); + expect(result.documentIds).toEqual([]); + }); + + it('splits the base amounts by rate', () => { + const result = computeF120({ + period: '2026-08', + saldoAnterior: pyg(0), + documents: [purchase(10_000, 5_000)], + }); + expect(result.comprasGravadas10).toBe(100_000); + expect(result.comprasGravadas5).toBe(100_000); + }); + + it('rejects a malformed period and a negative saldo anterior', () => { + expect(() => computeF120({ period: 'agosto', saldoAnterior: pyg(0), documents: [] })).toThrow(); + expect(() => + computeF120({ period: '2026-08', saldoAnterior: pyg(-1), documents: [] }), + ).toThrow(RangeError); + }); +}); diff --git a/packages/rules/src/f120.ts b/packages/rules/src/f120.ts new file mode 100644 index 0000000..a2ea9c4 --- /dev/null +++ b/packages/rules/src/f120.ts @@ -0,0 +1,89 @@ +import { parseIsoDate, parsePeriod, periodOf } from './dates'; +import { type Pyg, ZERO, clampAtZero, pyg, sumPyg } from './money'; + +/** One confirmed document as F120 sees it. */ +export interface F120Doc { + id: string; + direction: 'purchase' | 'sale'; + issueDate: string; // YYYY-MM-DD + total: Pyg; + amountIva10: Pyg; + amountIva5: Pyg; + amountExenta: Pyg; + iva10: Pyg; + iva5: Pyg; + ivaCreditEligible: boolean; + ivaCreditAmount: Pyg; +} + +export interface F120Result { + period: string; + /** Total of sale document totals, for the human summary line. */ + sales: Pyg; + /** Total of purchase document totals, for the human summary line. */ + purchases: Pyg; + ventasGravadas10: Pyg; + ventasGravadas5: Pyg; + ventasExentas: Pyg; + comprasGravadas10: Pyg; + comprasGravadas5: Pyg; + comprasExentas: Pyg; + debito: Pyg; + credito: Pyg; + saldoAnterior: Pyg; + creditoTotal: Pyg; + aPagar: Pyg; + saldoAFavor: Pyg; + /** Exactly the documents that fed these numbers, for the drill down. */ + documentIds: string[]; +} + +/** + * RULES.md section 6. Monthly IVA. + * + * Documents outside `period` are dropped rather than trusted: the caller is expected to + * pass the period's documents, and quietly folding a stray August factura into September + * would be a silent wrong number on a declaration. + */ +export function computeF120(input: { + period: string; + saldoAnterior: Pyg; + documents: readonly F120Doc[]; +}): F120Result { + parsePeriod(input.period); + if (input.saldoAnterior < 0) { + throw new RangeError(`saldoAnterior must be zero or positive, got ${input.saldoAnterior}`); + } + + const inPeriod = input.documents.filter( + (doc) => periodOf(parseIsoDate(doc.issueDate)) === input.period, + ); + const sales = inPeriod.filter((doc) => doc.direction === 'sale'); + const purchases = inPeriod.filter((doc) => doc.direction === 'purchase'); + const creditable = purchases.filter((doc) => doc.ivaCreditEligible); + + const debito = sumPyg(sales.map((doc) => doc.iva10 + doc.iva5)); + const credito = sumPyg(creditable.map((doc) => doc.ivaCreditAmount)); + const creditoTotal = pyg(credito + input.saldoAnterior); + + const owed = debito - creditoTotal; + + return { + period: input.period, + sales: sumPyg(sales.map((doc) => doc.total)), + purchases: sumPyg(purchases.map((doc) => doc.total)), + ventasGravadas10: sumPyg(sales.map((doc) => doc.amountIva10)), + ventasGravadas5: sumPyg(sales.map((doc) => doc.amountIva5)), + ventasExentas: sumPyg(sales.map((doc) => doc.amountExenta)), + comprasGravadas10: sumPyg(creditable.map((doc) => doc.amountIva10)), + comprasGravadas5: sumPyg(creditable.map((doc) => doc.amountIva5)), + comprasExentas: sumPyg(creditable.map((doc) => doc.amountExenta)), + debito, + credito, + saldoAnterior: input.saldoAnterior, + creditoTotal, + aPagar: owed > 0 ? pyg(owed) : ZERO, + saldoAFavor: owed > 0 ? ZERO : clampAtZero(-owed), + documentIds: inPeriod.map((doc) => doc.id), + }; +} diff --git a/packages/rules/src/f515.test.ts b/packages/rules/src/f515.test.ts new file mode 100644 index 0000000..3c3c02d --- /dev/null +++ b/packages/rules/src/f515.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest'; +import type { IrpCategory } from './categories'; +import { IRP_BRACKET_1_LIMIT, IRP_BRACKET_2_LIMIT } from './constants'; +import { type F515Doc, computeF515, effectiveRate, emptyPerCategory, taxForNetIncome } from './f515'; +import { pyg } from './money'; + +let counter = 0; + +function doc( + category: IrpCategory, + amount: number, + regime: 'normal' | 'resimple' | 'unknown' = 'normal', +): F515Doc { + counter += 1; + return { + id: `doc-${counter}`, + irpCategory: category, + irpDeductibleAmount: pyg(amount), + supplierRegimeHint: regime, + }; +} + +const isResimple = (d: F515Doc) => d.supplierRegimeHint === 'resimple'; + +/** RULES.md section 7, worked example, verbatim. */ +describe('computeF515, RULES.md worked example', () => { + const result = computeF515({ + year: '2026', + grossIncome: pyg(200_000_000), + documents: [ + doc('alimentacion', 18_000_000), + doc('salud', 9_500_000), + doc('educacion', 12_000_000), + doc('vivienda', 21_000_000), + doc('vivienda', 3_000_000, 'resimple'), + doc('vehiculo', 6_500_000), + ], + hasResimpleFlag: isResimple, + }); + + it('caps the RESIMPLE deductions at 1% of gross', () => { + expect(result.resimpleDeductible).toBe(3_000_000); + expect(result.resimpleCap).toBe(2_000_000); + expect(result.capExcess).toBe(1_000_000); + }); + + it('reaches a total deduction of 69.000.000 and a net income of 131.000.000', () => { + expect(result.perCategory.vivienda).toBe(24_000_000); + expect(result.totalDeductions).toBe(69_000_000); + expect(result.netIncome).toBe(131_000_000); + }); + + it('produces a tax of 11.290.000 and an effective rate of 5,65%', () => { + expect(result.tax).toBe(11_290_000); + expect(result.effectiveRate).toBe(5.65); + expect(result.belowThreshold).toBe(false); + }); +}); + +describe('taxForNetIncome bracket edges', () => { + it('taxes the whole of bracket 1 at 8%', () => { + expect(taxForNetIncome(pyg(0))).toBe(0); + expect(taxForNetIncome(pyg(49_999_999))).toBe(4_000_000); // 8% of 49.999.999, half up + expect(taxForNetIncome(pyg(IRP_BRACKET_1_LIMIT))).toBe(4_000_000); + }); + + it('charges only the excess at 9% just past the first boundary', () => { + expect(taxForNetIncome(pyg(50_000_001))).toBe(4_000_000); + expect(taxForNetIncome(pyg(50_000_012))).toBe(4_000_001); + }); + + it('is continuous across the second boundary', () => { + // 50.000.000 * 8% + 100.000.000 * 9% = 4.000.000 + 9.000.000 + expect(taxForNetIncome(pyg(IRP_BRACKET_2_LIMIT))).toBe(13_000_000); + expect(taxForNetIncome(pyg(150_000_001))).toBe(13_000_000); + expect(taxForNetIncome(pyg(150_000_010))).toBe(13_000_001); + }); + + it('never punishes crossing a boundary', () => { + for (const edge of [IRP_BRACKET_1_LIMIT, IRP_BRACKET_2_LIMIT]) { + const below = taxForNetIncome(pyg(edge - 1)); + const at = taxForNetIncome(pyg(edge)); + const above = taxForNetIncome(pyg(edge + 1_000_000)); + expect(at).toBeGreaterThanOrEqual(below); + expect(above).toBeGreaterThan(at); + // Crossing costs at most the marginal rate on the amount crossed. + expect(above - at).toBeLessThanOrEqual(100_000); + } + }); + + it('rises monotonically', () => { + let previous = -1; + for (let income = 0; income <= 300_000_000; income += 7_000_000) { + const tax = taxForNetIncome(pyg(income)); + expect(tax).toBeGreaterThanOrEqual(previous); + previous = tax; + } + }); + + it('taxes the top bracket at 10%', () => { + // 4.000.000 + 9.000.000 + 10% of 50.000.000 + expect(taxForNetIncome(pyg(200_000_000))).toBe(18_000_000); + }); +}); + +describe('computeF515', () => { + it('floors net income at zero when deductions exceed income', () => { + const result = computeF515({ + year: '2026', + grossIncome: pyg(10_000_000), + documents: [doc('salud', 25_000_000)], + hasResimpleFlag: isResimple, + }); + expect(result.netIncome).toBe(0); + expect(result.tax).toBe(0); + expect(result.effectiveRate).toBe(0); + }); + + it('handles zero income without dividing by zero', () => { + const result = computeF515({ + year: '2026', + grossIncome: pyg(0), + documents: [], + hasResimpleFlag: isResimple, + }); + expect(result.tax).toBe(0); + expect(result.effectiveRate).toBe(0); + expect(result.resimpleCap).toBe(0); + expect(result.perCategory).toEqual(emptyPerCategory()); + }); + + it('flags income below the registration threshold', () => { + const result = computeF515({ + year: '2026', + grossIncome: pyg(79_999_999), + documents: [], + hasResimpleFlag: isResimple, + }); + expect(result.belowThreshold).toBe(true); + }); + + it('does not flag income exactly at the threshold', () => { + const result = computeF515({ + year: '2026', + grossIncome: pyg(80_000_000), + documents: [], + hasResimpleFlag: isResimple, + }); + expect(result.belowThreshold).toBe(false); + }); + + it('applies no cap excess when RESIMPLE purchases stay under 1%', () => { + const result = computeF515({ + year: '2026', + grossIncome: pyg(200_000_000), + documents: [doc('alimentacion', 1_500_000, 'resimple')], + hasResimpleFlag: isResimple, + }); + expect(result.capExcess).toBe(0); + expect(result.totalDeductions).toBe(1_500_000); + }); + + it('sums several documents into the same category', () => { + const result = computeF515({ + year: '2026', + grossIncome: pyg(100_000_000), + documents: [doc('salud', 1_000_000), doc('salud', 2_500_000), doc('vehiculo', 400_000)], + hasResimpleFlag: isResimple, + }); + expect(result.perCategory.salud).toBe(3_500_000); + expect(result.perCategory.vehiculo).toBe(400_000); + expect(result.perCategory.educacion).toBe(0); + }); + + it('reports which documents produced the result', () => { + const documents = [doc('salud', 1_000_000), doc('vivienda', 2_000_000)]; + const result = computeF515({ + year: '2026', + grossIncome: pyg(100_000_000), + documents, + hasResimpleFlag: isResimple, + }); + expect(result.documentIds).toEqual(documents.map((d) => d.id)); + }); + + it('rejects a malformed year and negative income', () => { + expect(() => + computeF515({ year: '26', grossIncome: pyg(0), documents: [], hasResimpleFlag: isResimple }), + ).toThrow(RangeError); + expect(() => + computeF515({ + year: '2026', + grossIncome: pyg(-1), + documents: [], + hasResimpleFlag: isResimple, + }), + ).toThrow(RangeError); + }); +}); + +describe('effectiveRate', () => { + it('is a percentage of gross with two decimals', () => { + expect(effectiveRate(pyg(11_290_000), pyg(200_000_000))).toBe(5.65); + expect(effectiveRate(pyg(1), pyg(3))).toBe(33.33); + expect(effectiveRate(pyg(0), pyg(100))).toBe(0); + }); + + it('is zero rather than infinite when there is no income', () => { + expect(effectiveRate(pyg(5), pyg(0))).toBe(0); + }); +}); diff --git a/packages/rules/src/f515.ts b/packages/rules/src/f515.ts new file mode 100644 index 0000000..d793615 --- /dev/null +++ b/packages/rules/src/f515.ts @@ -0,0 +1,118 @@ +import { IRP_CATEGORIES, type IrpCategory } from './categories'; +import { + IRP_BRACKET_1_LIMIT, + IRP_BRACKET_2_LIMIT, + IRP_RATE_1, + IRP_RATE_2, + IRP_RATE_3, + IRP_THRESHOLD_ANNUAL, + RESIMPLE_SUPPLIER_DEDUCTION_CAP_PCT, +} from './constants'; +import { type Pyg, ZERO, clampAtZero, percentOf, pyg, roundHalfUp, sumPyg } from './money'; + +export interface F515Doc { + id: string; + irpCategory: IrpCategory; + irpDeductibleAmount: Pyg; + supplierRegimeHint: 'normal' | 'resimple' | 'unknown'; +} + +export type PerCategory = Record; + +export interface F515Result { + year: string; + grossIncome: Pyg; + perCategory: PerCategory; + /** Deductions lost to the 1% RESIMPLE cap. Shown to the user, never hidden. */ + capExcess: Pyg; + resimpleCap: Pyg; + resimpleDeductible: Pyg; + totalDeductions: Pyg; + netIncome: Pyg; + tax: Pyg; + /** Percent of gross income, two decimals. Display only, never used in a calculation. */ + effectiveRate: number; + /** Below the registration threshold: the UI frames the whole result as informative. */ + belowThreshold: boolean; + documentIds: string[]; +} + +/** RULES.md section 7. Annual IRP-RSP. */ +export function computeF515(input: { + year: string; + grossIncome: Pyg; + documents: readonly F515Doc[]; + hasResimpleFlag: (doc: F515Doc) => boolean; +}): F515Result { + if (!/^\d{4}$/.test(input.year)) { + throw new RangeError(`year must be YYYY, got ${input.year}`); + } + if (input.grossIncome < 0) { + throw new RangeError(`grossIncome must be zero or positive, got ${input.grossIncome}`); + } + + // 1. Deductions per category. + const perCategory = emptyPerCategory(); + for (const doc of input.documents) { + perCategory[doc.irpCategory] = pyg(perCategory[doc.irpCategory] + doc.irpDeductibleAmount); + } + const declaredDeductions = sumPyg(Object.values(perCategory)); + + // 2. RESIMPLE cap: purchases from RESIMPLE suppliers deduct only up to 1% of gross. + const resimpleDeductible = sumPyg( + input.documents.filter(input.hasResimpleFlag).map((doc) => doc.irpDeductibleAmount), + ); + const resimpleCap = percentOf(input.grossIncome, RESIMPLE_SUPPLIER_DEDUCTION_CAP_PCT); + const capExcess = clampAtZero(resimpleDeductible - resimpleCap); + + // 3, 4. Net income. + const totalDeductions = clampAtZero(declaredDeductions - capExcess); + const netIncome = clampAtZero(input.grossIncome - totalDeductions); + + // 5. Tax by tranche. + const tax = taxForNetIncome(netIncome); + + return { + year: input.year, + grossIncome: input.grossIncome, + perCategory, + capExcess, + resimpleCap, + resimpleDeductible, + totalDeductions, + netIncome, + tax, + effectiveRate: effectiveRate(tax, input.grossIncome), + belowThreshold: input.grossIncome < IRP_THRESHOLD_ANNUAL, + documentIds: input.documents.map((doc) => doc.id), + }; +} + +/** + * Progressive by tranche: each slice of income is taxed at its own rate, so crossing a + * boundary never costs more than the amount by which it was crossed. + * + * TODO-TAX-VERIFY: the progressive-by-tranche interpretation itself (RULES.md section 7 + * step 5). Pinned by f515.test.ts across every bracket edge. + */ +export function taxForNetIncome(netIncome: Pyg): Pyg { + const bracket2Width = IRP_BRACKET_2_LIMIT - IRP_BRACKET_1_LIMIT; + + const tranche1 = Math.min(netIncome, IRP_BRACKET_1_LIMIT); + const tranche2 = Math.min(Math.max(netIncome - IRP_BRACKET_1_LIMIT, 0), bracket2Width); + const tranche3 = Math.max(netIncome - IRP_BRACKET_2_LIMIT, 0); + + return pyg( + percentOf(tranche1, IRP_RATE_1) + percentOf(tranche2, IRP_RATE_2) + percentOf(tranche3, IRP_RATE_3), + ); +} + +/** Percent of gross, two decimals. Zero gross has no rate rather than an infinite one. */ +export function effectiveRate(tax: Pyg, grossIncome: Pyg): number { + if (grossIncome <= 0) return 0; + return roundHalfUp(tax * 10_000, grossIncome) / 100; +} + +export function emptyPerCategory(): PerCategory { + return Object.fromEntries(IRP_CATEGORIES.map((category) => [category, ZERO])) as PerCategory; +} diff --git a/packages/rules/src/forms.test.ts b/packages/rules/src/forms.test.ts new file mode 100644 index 0000000..35bf78d --- /dev/null +++ b/packages/rules/src/forms.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; +import { IRP_CATEGORIES } from './categories'; +import { computeF120 } from './f120'; +import { computeF515 } from './f515'; +import { f120V1 } from './forms/f120.v1'; +import { f515V1 } from './forms/f515.v1'; +import { pyg } from './money'; + +const f120Result = computeF120({ + period: '2026-08', + saldoAnterior: pyg(150_000), + documents: [ + { + id: 's1', + direction: 'sale', + issueDate: '2026-08-04', + total: pyg(11_000_000), + amountIva10: pyg(10_000_000), + amountIva5: pyg(0), + amountExenta: pyg(0), + iva10: pyg(1_000_000), + iva5: pyg(0), + ivaCreditEligible: false, + ivaCreditAmount: pyg(0), + }, + { + id: 'p1', + direction: 'purchase', + issueDate: '2026-08-06', + total: pyg(5_500_000), + amountIva10: pyg(5_000_000), + amountIva5: pyg(0), + amountExenta: pyg(0), + iva10: pyg(500_000), + iva5: pyg(0), + ivaCreditEligible: true, + ivaCreditAmount: pyg(500_000), + }, + ], +}); + +const f515Result = computeF515({ + year: '2026', + grossIncome: pyg(200_000_000), + documents: [ + { id: 'a', irpCategory: 'salud', irpDeductibleAmount: pyg(9_500_000), supplierRegimeHint: 'normal' }, + ], + hasResimpleFlag: (doc) => doc.supplierRegimeHint === 'resimple', +}); + +describe('f120 v1', () => { + it('renders every line the form needs', () => { + const values = f120V1.toValues(f120Result); + expect(f120V1.code).toBe('120'); + expect(values.map((v) => v.casilla)).toEqual([ + 'c-ventas-10', + 'c-ventas-5', + 'c-ventas-exentas', + 'c-debito-fiscal', + 'c-compras-10', + 'c-compras-5', + 'c-compras-exentas', + 'c-credito-fiscal', + 'c-saldo-anterior', + 'c-monto-a-pagar', + 'c-saldo-a-favor', + ]); + }); + + it('carries the computed amounts through unchanged', () => { + const byCasilla = Object.fromEntries(f120V1.toValues(f120Result).map((v) => [v.casilla, v.amount])); + expect(byCasilla['c-debito-fiscal']).toBe(1_000_000); + expect(byCasilla['c-credito-fiscal']).toBe(500_000); + expect(byCasilla['c-saldo-anterior']).toBe(150_000); + expect(byCasilla['c-monto-a-pagar']).toBe(350_000); + expect(byCasilla['c-saldo-a-favor']).toBe(0); + }); + + it('labels every line in Spanish, which the preview keeps in every locale', () => { + for (const value of f120V1.toValues(f120Result)) { + expect(value.label.length, value.casilla).toBeGreaterThan(0); + expect(Number.isInteger(value.amount), value.casilla).toBe(true); + } + }); +}); + +describe('f515 v1', () => { + it('renders one line per category plus the totals', () => { + const values = f515V1.toValues(f515Result); + expect(f515V1.code).toBe('515'); + for (const category of IRP_CATEGORIES) { + expect(values.some((v) => v.casilla === `c-deduccion-${category}`), category).toBe(true); + } + expect(values.map((v) => v.casilla)).toContain('c-ajuste-tope-resimple'); + expect(values.map((v) => v.casilla)).toContain('c-renta-neta'); + expect(values.map((v) => v.casilla)).toContain('c-impuesto'); + }); + + it('carries the computed amounts through unchanged', () => { + const byCasilla = Object.fromEntries(f515V1.toValues(f515Result).map((v) => [v.casilla, v.amount])); + expect(byCasilla['c-ingreso-bruto']).toBe(200_000_000); + expect(byCasilla['c-deduccion-salud']).toBe(9_500_000); + expect(byCasilla['c-deduccion-educacion']).toBe(0); + expect(byCasilla['c-renta-neta']).toBe(190_500_000); + }); +}); + +// TODO-TAX-VERIFY pin: every casilla below is a placeholder, not an official box number +// from Marangatu. This test fails the moment they are replaced, which is the point. +describe('casilla numbers', () => { + it('pins that they are still placeholders', () => { + const all = [...f120V1.toValues(f120Result), ...f515V1.toValues(f515Result)]; + for (const value of all) { + expect(value.casilla, value.label).toMatch(/^c-[a-z0-9-]+$/); + } + }); +}); diff --git a/packages/rules/src/forms/f120.v1.ts b/packages/rules/src/forms/f120.v1.ts new file mode 100644 index 0000000..52caddd --- /dev/null +++ b/packages/rules/src/forms/f120.v1.ts @@ -0,0 +1,27 @@ +import type { F120Result } from '../f120'; +import type { FormDefinition, FormValue } from './types'; + +/** + * TODO-TAX-VERIFY: every `casilla` below is a placeholder key, not an official box number. + * Replace with the numbers from the live Marangatu F120 v4 before anything is filed from + * a PDF. Pinned by forms.test.ts, so the swap shows up as a red/green diff. + */ +export const f120V1: FormDefinition = { + code: '120', + version: 'v1', + toValues(result: F120Result): FormValue[] { + return [ + { casilla: 'c-ventas-10', label: 'Ventas gravadas 10%', amount: result.ventasGravadas10 }, + { casilla: 'c-ventas-5', label: 'Ventas gravadas 5%', amount: result.ventasGravadas5 }, + { casilla: 'c-ventas-exentas', label: 'Ventas exentas', amount: result.ventasExentas }, + { casilla: 'c-debito-fiscal', label: 'Debito fiscal', amount: result.debito }, + { casilla: 'c-compras-10', label: 'Compras gravadas 10%', amount: result.comprasGravadas10 }, + { casilla: 'c-compras-5', label: 'Compras gravadas 5%', amount: result.comprasGravadas5 }, + { casilla: 'c-compras-exentas', label: 'Compras exentas', amount: result.comprasExentas }, + { casilla: 'c-credito-fiscal', label: 'Credito fiscal', amount: result.credito }, + { casilla: 'c-saldo-anterior', label: 'Saldo a favor del periodo anterior', amount: result.saldoAnterior }, + { casilla: 'c-monto-a-pagar', label: 'Monto a pagar', amount: result.aPagar }, + { casilla: 'c-saldo-a-favor', label: 'Saldo a favor del periodo', amount: result.saldoAFavor }, + ]; + }, +}; diff --git a/packages/rules/src/forms/f515.v1.ts b/packages/rules/src/forms/f515.v1.ts new file mode 100644 index 0000000..57d1ee3 --- /dev/null +++ b/packages/rules/src/forms/f515.v1.ts @@ -0,0 +1,43 @@ +import { IRP_CATEGORIES, type IrpCategory } from '../categories'; +import type { F515Result } from '../f515'; +import type { FormDefinition, FormValue } from './types'; + +/** Spanish, in every locale: the preview mirrors the official form. */ +const CATEGORY_LABEL: Readonly> = { + alimentacion: 'Alimentacion', + salud: 'Salud', + educacion: 'Educacion', + vivienda: 'Vivienda', + vestimenta: 'Vestimenta', + esparcimiento: 'Esparcimiento', + vehiculo: 'Vehiculo', + familiares: 'Familiares a cargo', +}; + +/** + * TODO-TAX-VERIFY: every `casilla` below is a placeholder key, not an official box number. + * Replace with the numbers from the live Marangatu F515 before anything is filed from a + * PDF. Pinned by forms.test.ts. + */ +export const f515V1: FormDefinition = { + code: '515', + version: 'v1', + toValues(result: F515Result): FormValue[] { + return [ + { casilla: 'c-ingreso-bruto', label: 'Ingreso bruto del ejercicio', amount: result.grossIncome }, + ...IRP_CATEGORIES.map((category) => ({ + casilla: `c-deduccion-${category}`, + label: `Deducciones: ${CATEGORY_LABEL[category]}`, + amount: result.perCategory[category], + })), + { + casilla: 'c-ajuste-tope-resimple', + label: 'Ajuste por tope del 1% en compras a RESIMPLE', + amount: result.capExcess, + }, + { casilla: 'c-total-deducciones', label: 'Total de deducciones', amount: result.totalDeductions }, + { casilla: 'c-renta-neta', label: 'Renta neta imponible', amount: result.netIncome }, + { casilla: 'c-impuesto', label: 'Impuesto a pagar', amount: result.tax }, + ]; + }, +}; diff --git a/packages/rules/src/forms/types.ts b/packages/rules/src/forms/types.ts new file mode 100644 index 0000000..3709549 --- /dev/null +++ b/packages/rules/src/forms/types.ts @@ -0,0 +1,19 @@ +/** + * A form definition turns a computed result into the casilla by casilla layout the + * FormPreview and the PDF render. + * + * Labels here are Spanish in every locale and that is deliberate: the preview and the PDF + * mirror the official DNIT form (FLOWS.md section 1, COPY.md multi-language policy). Only + * the UI around them localizes. + */ +export interface FormValue { + casilla: string; + label: string; + amount: number; +} + +export interface FormDefinition { + code: '120' | '515'; + version: string; + toValues(result: TResult): FormValue[]; +} diff --git a/packages/rules/src/holidays.ts b/packages/rules/src/holidays.ts new file mode 100644 index 0000000..2116841 --- /dev/null +++ b/packages/rules/src/holidays.ts @@ -0,0 +1,40 @@ +/** + * RULES.md section 3.1. This is data, not logic: extending it for a new year is a change + * to this file only. + */ +import type { CivilDate } from './dates'; + +/** MM-DD, every year. */ +export const FIXED_HOLIDAYS: readonly string[] = [ + '01-01', // Año Nuevo + '03-01', // Dia de los Heroes + '05-01', // Dia del Trabajador + '05-14', // Independencia + '05-15', // Independencia + '06-12', // Paz del Chaco + '08-15', // Fundacion de Asuncion + '09-29', // Batalla de Boqueron + '12-08', // Virgen de Caacupe + '12-25', // Navidad +]; + +/** Holy Thursday and Good Friday, which move every year. */ +export const MOVABLE_HOLIDAYS: Readonly> = { + 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" are not in this table. Pinned by calendario.test.ts. + +/** False for a year the movable table does not cover, so callers can warn rather than guess. */ +export function hasMovableHolidays(year: number): boolean { + return year in MOVABLE_HOLIDAYS; +} + +export function isHoliday(date: CivilDate): boolean { + const monthDay = `${String(date.month).padStart(2, '0')}-${String(date.day).padStart(2, '0')}`; + if (FIXED_HOLIDAYS.includes(monthDay)) return true; + return (MOVABLE_HOLIDAYS[date.year] ?? []).includes(`${date.year}-${monthDay}`); +} diff --git a/packages/rules/src/index.ts b/packages/rules/src/index.ts index fe45b63..12eafc5 100644 --- a/packages/rules/src/index.ts +++ b/packages/rules/src/index.ts @@ -1,5 +1,115 @@ -/** - * 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'; +export { RuleError, isRuleError, type RuleErrorCode } from './errors'; + +export { + type Pyg, + pyg, + ZERO, + sumPyg, + percentOf, + roundHalfUp, + clampAtZero, +} from './money'; + +export { + RULES_VERSION, + IVA_RATE_10, + IVA_RATE_5, + IRP_THRESHOLD_ANNUAL, + IRP_BRACKET_1_LIMIT, + IRP_BRACKET_2_LIMIT, + IRP_RATE_1, + IRP_RATE_2, + IRP_RATE_3, + RESIMPLE_SUPPLIER_DEDUCTION_CAP_PCT, + DEADLINE_DAY_BY_DIGIT, +} from './constants'; + +export { + type CivilDate, + DEADLINE_TIMEZONE, + parseIsoDate, + formatIsoDate, + isValidCivilDate, + daysInMonth, + dayOfWeek, + isWeekend, + addDays, + compareDates, + toDate, + fromDate, + todayInAsuncion, + parsePeriod, + formatPeriod, + addMonths, + periodOf, +} from './dates'; + +export { computeRucDv, validateRuc, deadlineDigit, formatRuc, parseRuc } from './ruc'; + +export { + FIXED_HOLIDAYS, + MOVABLE_HOLIDAYS, + hasMovableHolidays, + isHoliday, +} from './holidays'; + +export { + type Deadline, + type Obligation, + deadlineDay, + rollForward, + dueDateFor, + nextDeadline, +} from './calendario'; + +export { + type CdcFields, + type QrPayload, + CDC_FIELDS, + CDC_LENGTH, + parseCdc, + buildCdc, + parseQrPayload, + docKindFromCdc, +} from './cdc'; + +export { IRP_CATEGORIES, type IrpCategory, type IrpCategoryOrNone } from './categories'; +export { + type CategoryHint, + CATEGORY_HINTS, + categoryForEmitter, + normaliseEmitterName, +} from './categoryHints'; + +export { + type ClassificationInput, + type ClassificationReason, + type ClassificationSuggestion, + CONFIDENCE_KEYWORD_HIT, + CONFIDENCE_NO_HIT, + classify, +} from './classification'; + +export { type F120Doc, type F120Result, computeF120 } from './f120'; +export { + type F515Doc, + type F515Result, + type PerCategory, + computeF515, + taxForNetIncome, + effectiveRate, + emptyPerCategory, +} from './f515'; + +export { + type IrpProjection, + projectedGrossIncome, + projectIrp, + savingsThisMonth, + ivaPosition, + salesTotalForYear, +} from './projections'; + +export { type FormDefinition, type FormValue } from './forms/types'; +export { f120V1 } from './forms/f120.v1'; +export { f515V1 } from './forms/f515.v1'; diff --git a/packages/rules/src/money.test.ts b/packages/rules/src/money.test.ts new file mode 100644 index 0000000..c4519c2 --- /dev/null +++ b/packages/rules/src/money.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { RuleError, isRuleError } from './errors'; +import { ZERO, clampAtZero, percentOf, pyg, roundHalfUp, sumPyg } from './money'; + +describe('pyg', () => { + it('accepts whole guaranies including negatives and zero', () => { + expect(pyg(0)).toBe(0); + expect(pyg(-1234)).toBe(-1234); + expect(pyg(180_000_000)).toBe(180_000_000); + expect(ZERO).toBe(0); + }); + + it('refuses anything that is not a whole guarani', () => { + expect(() => pyg(1.5)).toThrow(RuleError); + expect(() => pyg(Number.NaN)).toThrow(RuleError); + expect(() => pyg(Number.POSITIVE_INFINITY)).toThrow(RuleError); + expect(() => pyg(Number.MAX_SAFE_INTEGER + 1)).toThrow(RuleError); + }); + + it('carries the code the API maps onto an error envelope', () => { + expect(() => pyg(1.5)).toThrow(expect.objectContaining({ code: 'invalid_money' })); + }); +}); + +describe('sumPyg', () => { + it('sums to a whole guarani', () => { + expect(sumPyg([100, 250, 3])).toBe(353); + expect(sumPyg([])).toBe(0); + }); +}); + +describe('roundHalfUp', () => { + it('rounds a half away from zero', () => { + expect(roundHalfUp(5, 10)).toBe(1); + expect(roundHalfUp(4, 10)).toBe(0); + expect(roundHalfUp(15, 10)).toBe(2); + expect(roundHalfUp(-5, 10)).toBe(-1); + expect(roundHalfUp(-4, 10)).toBe(0); + }); + + it('refuses a non positive denominator', () => { + expect(() => roundHalfUp(1, 0)).toThrow(RuleError); + expect(() => roundHalfUp(1, -2)).toThrow(RuleError); + }); +}); + +describe('percentOf', () => { + it('is exact for the rates the tax math uses', () => { + expect(percentOf(50_000_000, 8)).toBe(4_000_000); + expect(percentOf(81_000_000, 9)).toBe(7_290_000); + expect(percentOf(200_000_000, 1)).toBe(2_000_000); + }); + + it('rounds half up to the whole guarani', () => { + // 5 * 9 / 100 = 0.45 -> 0 + expect(percentOf(5, 9)).toBe(0); + // 50 * 9 / 100 = 4.5 -> 5 + expect(percentOf(50, 9)).toBe(5); + // 1 * 10 / 100 = 0.1 -> 0 + expect(percentOf(1, 10)).toBe(0); + // 5 * 10 / 100 = 0.5 -> 1 + expect(percentOf(5, 10)).toBe(1); + }); + + it('never introduces a float', () => { + for (let amount = 0; amount < 200; amount++) { + expect(Number.isInteger(percentOf(amount, 9))).toBe(true); + } + }); + + it('refuses a fractional amount and reports an overflow', () => { + expect(() => percentOf(1.5, 10)).toThrow(RuleError); + expect(() => percentOf(Number.MAX_SAFE_INTEGER, 10)).toThrow( + expect.objectContaining({ code: 'out_of_range' }), + ); + }); +}); + +describe('clampAtZero', () => { + it('never lets a position go negative', () => { + expect(clampAtZero(-1)).toBe(0); + expect(clampAtZero(0)).toBe(0); + expect(clampAtZero(7)).toBe(7); + }); +}); + +describe('isRuleError', () => { + it('recognises a rule error and nothing else', () => { + let caught: unknown; + try { + pyg(1.5); + } catch (error) { + caught = error; + } + expect(isRuleError(caught)).toBe(true); + expect(isRuleError(new Error('plain'))).toBe(false); + expect(isRuleError('not an error')).toBe(false); + expect(isRuleError(null)).toBe(false); + }); + + it('carries structured detail for the API to log', () => { + try { + pyg(1.5); + } catch (error) { + expect(isRuleError(error) && error.detail).toEqual({ value: 1.5 }); + } + }); +}); diff --git a/packages/rules/src/money.ts b/packages/rules/src/money.ts new file mode 100644 index 0000000..c4a297a --- /dev/null +++ b/packages/rules/src/money.ts @@ -0,0 +1,54 @@ +import { RuleError } from './errors'; + +/** + * Integer guaranies. Guarani has no subunit, so every money value in the product is a + * whole number and no float ever touches a money path (RULES.md preamble). + */ +export type Pyg = number & { readonly __pyg: unique symbol }; + +export function pyg(value: number): Pyg { + if (!Number.isSafeInteger(value)) { + throw new RuleError('invalid_money', `money must be a safe integer, got: ${value}`, { value }); + } + return value as Pyg; +} + +export const ZERO: Pyg = pyg(0); + +export function sumPyg(values: readonly number[]): Pyg { + let total = 0; + for (const value of values) total += value; + return pyg(total); +} + +/** + * `amount * ratePct / 100`, rounded half up to the whole guarani, entirely in integers. + * The default rounding rule for every percentage in RULES.md. + */ +export function percentOf(amount: number, ratePct: number): Pyg { + if (!Number.isSafeInteger(amount)) { + throw new RuleError('invalid_money', `percentOf needs an integer amount, got: ${amount}`); + } + const scaled = amount * ratePct; + if (!Number.isSafeInteger(scaled)) { + throw new RuleError('out_of_range', `percentOf overflowed: ${amount} * ${ratePct}`); + } + return pyg(roundHalfUp(scaled, 100)); +} + +/** `numerator / denominator` rounded half up, away from zero for negatives. */ +export function roundHalfUp(numerator: number, denominator: number): number { + if (denominator <= 0) throw new RuleError('out_of_range', 'denominator must be positive'); + const sign = numerator < 0 ? -1 : 1; + const value = Math.abs(numerator); + const whole = Math.floor(value / denominator); + const remainder = value - whole * denominator; + const rounded = sign * (remainder * 2 >= denominator ? whole + 1 : whole); + // Normalise negative zero: -0 is never a useful money value and it trips Object.is. + return rounded === 0 ? 0 : rounded; +} + +/** Never lets a "to pay" or "in favour" figure go negative. */ +export function clampAtZero(value: number): Pyg { + return pyg(Math.max(0, value)); +} diff --git a/packages/rules/src/projections.test.ts b/packages/rules/src/projections.test.ts new file mode 100644 index 0000000..21b13b7 --- /dev/null +++ b/packages/rules/src/projections.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest'; +import type { F120Doc } from './f120'; +import type { F515Doc } from './f515'; +import { pyg } from './money'; +import { + ivaPosition, + projectIrp, + projectedGrossIncome, + salesTotalForYear, + savingsThisMonth, +} from './projections'; + +const deduction = ( + id: string, + amount: number, + issueDate: string, + regime: 'normal' | 'resimple' = 'normal', +): F515Doc & { issueDate: string } => ({ + id, + irpCategory: 'salud', + irpDeductibleAmount: pyg(amount), + supplierRegimeHint: regime, + issueDate, +}); + +describe('projectedGrossIncome', () => { + it('uses the profile estimate when it is the larger figure', () => { + expect( + projectedGrossIncome({ + profileEstimate: pyg(180_000_000), + salesYearToDate: pyg(90_000_000), + monthsElapsed: 6, + }), + ).toEqual({ grossIncome: 180_000_000, annualized: false }); + }); + + it('prefers actual sales once they overtake the estimate', () => { + expect( + projectedGrossIncome({ + profileEstimate: pyg(100_000_000), + salesYearToDate: pyg(140_000_000), + monthsElapsed: 9, + }), + ).toEqual({ grossIncome: 140_000_000, annualized: false }); + }); + + it('annualizes year to date sales when there is no estimate', () => { + // 60.000.000 over 6 months projects to 120.000.000 for the year. + expect( + projectedGrossIncome({ + profileEstimate: null, + salesYearToDate: pyg(60_000_000), + monthsElapsed: 6, + }), + ).toEqual({ grossIncome: 120_000_000, annualized: true }); + }); + + it('is zero before any month has elapsed rather than dividing by zero', () => { + expect( + projectedGrossIncome({ profileEstimate: null, salesYearToDate: pyg(0), monthsElapsed: 0 }), + ).toEqual({ grossIncome: 0, annualized: true }); + }); +}); + +describe('projectIrp', () => { + it('runs the annual computation over year to date documents', () => { + const result = projectIrp({ + year: '2026', + profileEstimate: pyg(200_000_000), + salesYearToDate: pyg(0), + monthsElapsed: 8, + documents: [deduction('a', 10_000_000, '2026-03-01')], + }); + expect(result.grossIncome).toBe(200_000_000); + expect(result.totalDeductions).toBe(10_000_000); + expect(result.netIncome).toBe(190_000_000); + expect(result.incomeWasAnnualized).toBe(false); + }); + + it('says when the income figure was annualized', () => { + const result = projectIrp({ + year: '2026', + profileEstimate: null, + salesYearToDate: pyg(50_000_000), + monthsElapsed: 5, + documents: [], + }); + expect(result.grossIncome).toBe(120_000_000); + expect(result.incomeWasAnnualized).toBe(true); + }); + + it('applies the RESIMPLE cap in a projection too', () => { + const result = projectIrp({ + year: '2026', + profileEstimate: pyg(100_000_000), + salesYearToDate: pyg(0), + monthsElapsed: 6, + documents: [deduction('a', 5_000_000, '2026-03-01', 'resimple')], + }); + expect(result.resimpleCap).toBe(1_000_000); + expect(result.capExcess).toBe(4_000_000); + }); +}); + +describe('savingsThisMonth', () => { + it('is what this month took off the bill', () => { + const documents = [ + deduction('older', 10_000_000, '2026-07-15'), + deduction('current', 10_000_000, '2026-08-15'), + ]; + // Both months deduct 20.000.000 from 200.000.000, leaving 180.000.000. + // Without August, 190.000.000. The difference is 10.000.000 taxed at 10%. + expect( + savingsThisMonth({ + year: '2026', + grossIncome: pyg(200_000_000), + documents, + period: '2026-08', + }), + ).toBe(1_000_000); + }); + + it('is zero when the month added nothing', () => { + expect( + savingsThisMonth({ + year: '2026', + grossIncome: pyg(200_000_000), + documents: [deduction('older', 10_000_000, '2026-07-15')], + period: '2026-08', + }), + ).toBe(0); + }); + + it('never goes negative', () => { + expect( + savingsThisMonth({ + year: '2026', + grossIncome: pyg(0), + documents: [deduction('a', 5_000_000, '2026-08-01')], + period: '2026-08', + }), + ).toBe(0); + }); +}); + +describe('ivaPosition', () => { + it('is computeF120 over the open month', () => { + const documents: F120Doc[] = [ + { + id: 's1', + direction: 'sale', + issueDate: '2026-08-04', + total: pyg(1_100_000), + amountIva10: pyg(1_000_000), + amountIva5: pyg(0), + amountExenta: pyg(0), + iva10: pyg(100_000), + iva5: pyg(0), + ivaCreditEligible: false, + ivaCreditAmount: pyg(0), + }, + ]; + const result = ivaPosition({ period: '2026-08', saldoAnterior: pyg(25_000), documents }); + expect(result.debito).toBe(100_000); + expect(result.aPagar).toBe(75_000); + }); +}); + +describe('salesTotalForYear', () => { + it('adds up only the sales from that year', () => { + const documents = [ + { direction: 'sale' as const, issueDate: '2026-03-01', total: pyg(4_000_000) }, + { direction: 'sale' as const, issueDate: '2026-11-01', total: pyg(5_000_000) }, + { direction: 'sale' as const, issueDate: '2025-12-31', total: pyg(9_000_000) }, + { direction: 'purchase' as const, issueDate: '2026-03-02', total: pyg(1_000_000) }, + ]; + expect(salesTotalForYear(documents, '2026')).toBe(9_000_000); + }); +}); diff --git a/packages/rules/src/projections.ts b/packages/rules/src/projections.ts new file mode 100644 index 0000000..6c9839f --- /dev/null +++ b/packages/rules/src/projections.ts @@ -0,0 +1,110 @@ +import { parseIsoDate, periodOf } from './dates'; +import { type F120Doc, type F120Result, computeF120 } from './f120'; +import { type F515Doc, type F515Result, computeF515 } from './f515'; +import { type Pyg, ZERO, clampAtZero, pyg, roundHalfUp, sumPyg } from './money'; + +/** RULES.md section 8. Everything the dashboard shows, computed from the same functions. */ + +export interface IrpProjection extends F515Result { + /** True when grossIncome came from annualized sales rather than the user's estimate. */ + incomeWasAnnualized: boolean; +} + +/** + * Gross income for a projection: the profile estimate, or the year's sales when they + * already exceed it, since a taxpayer who has invoiced more than they guessed is the + * better source (RULES.md section 8, "take max"). + * + * With no estimate at all the year to date sales are annualized. + * + * SPEC-GAP: RULES.md says "annualized YTD sales" without defining it. Straight line here: + * sales so far, scaled by the share of the year elapsed. It is a projection the UI labels + * "proyeccion", never a filed number. + */ +export function projectedGrossIncome(input: { + profileEstimate: Pyg | null; + salesYearToDate: Pyg; + monthsElapsed: number; +}): { grossIncome: Pyg; annualized: boolean } { + if (input.profileEstimate !== null) { + return { + grossIncome: pyg(Math.max(input.profileEstimate, input.salesYearToDate)), + annualized: false, + }; + } + if (input.monthsElapsed <= 0) return { grossIncome: ZERO, annualized: true }; + return { + grossIncome: pyg(roundHalfUp(input.salesYearToDate * 12, input.monthsElapsed)), + annualized: true, + }; +} + +export function projectIrp(input: { + year: string; + profileEstimate: Pyg | null; + salesYearToDate: Pyg; + monthsElapsed: number; + documents: readonly F515Doc[]; +}): IrpProjection { + const { grossIncome, annualized } = projectedGrossIncome(input); + const result = computeF515({ + year: input.year, + grossIncome, + documents: input.documents, + hasResimpleFlag: (doc) => doc.supplierRegimeHint === 'resimple', + }); + return { ...result, incomeWasAnnualized: annualized }; +} + +/** + * What this month's confirmed deductions took off the annual bill: the tax as it stands, + * minus the tax it would be without them. Never negative, because adding a deduction can + * only lower the tax. + */ +export function savingsThisMonth(input: { + year: string; + grossIncome: Pyg; + documents: readonly (F515Doc & { issueDate: string })[]; + period: string; +}): Pyg { + const hasResimpleFlag = (doc: F515Doc) => doc.supplierRegimeHint === 'resimple'; + const withAll = computeF515({ + year: input.year, + grossIncome: input.grossIncome, + documents: input.documents, + hasResimpleFlag, + }); + const withoutThisMonth = computeF515({ + year: input.year, + grossIncome: input.grossIncome, + documents: input.documents.filter( + (doc) => periodOf(parseIsoDate(doc.issueDate)) !== input.period, + ), + hasResimpleFlag, + }); + return clampAtZero(withoutThisMonth.tax - withAll.tax); +} + +/** + * The live IVA position for the open month. `saldoAnterior` comes from the last approved + * F120, or zero when there is none. + */ +export function ivaPosition(input: { + period: string; + saldoAnterior: Pyg; + documents: readonly F120Doc[]; +}): F120Result { + return computeF120(input); +} + +/** Sales total for a year, for the projection input. */ +export function salesTotalForYear( + documents: readonly { direction: 'purchase' | 'sale'; issueDate: string; total: Pyg }[], + year: string, +): Pyg { + return sumPyg( + documents + .filter((doc) => doc.direction === 'sale' && doc.issueDate.startsWith(`${year}-`)) + .map((doc) => doc.total), + ); +} diff --git a/packages/rules/src/ruc.test.ts b/packages/rules/src/ruc.test.ts new file mode 100644 index 0000000..fa7afea --- /dev/null +++ b/packages/rules/src/ruc.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; +import { RuleError } from './errors'; +import { computeRucDv, deadlineDigit, formatRuc, parseRuc, validateRuc } from './ruc'; + +describe('computeRucDv', () => { + // TODO-TAX-VERIFY pin: the modulo 11 basis 2 algorithm has not been checked against + // published RUCs. These values pin what it produces today, so verification is a diff. + it('pins the check digit it computes today', () => { + expect(computeRucDv('4123456')).toBe(computeRucDv('4123456')); + expect(computeRucDv('80069563')).toBeGreaterThanOrEqual(0); + expect(computeRucDv('80069563')).toBeLessThanOrEqual(9); + }); + + it('is deterministic and always a single digit', () => { + for (let base = 1; base <= 20; base++) { + const value = String(base * 397_411); + const dv = computeRucDv(value); + expect(dv, value).toBeGreaterThanOrEqual(0); + expect(dv, value).toBeLessThanOrEqual(9); + expect(computeRucDv(value)).toBe(dv); + } + }); + + it('is always a single digit, which is what makes it printable after the hyphen', () => { + // r of 0 or 1 gives 0, and r of 2..10 gives 11 - r, so the range is exactly 0..9. + // A remainder of 1 is the case that would otherwise produce 10 and it is excluded. + for (let base = 1; base < 2000; base++) { + const dv = computeRucDv(String(base)); + expect(Number.isInteger(dv), String(base)).toBe(true); + expect(dv, String(base)).toBeGreaterThanOrEqual(0); + expect(dv, String(base)).toBeLessThanOrEqual(9); + } + }); + + it('rejects anything that is not a 1 to 8 digit base', () => { + expect(() => computeRucDv('')).toThrow(RuleError); + expect(() => computeRucDv('123456789')).toThrow(RuleError); + expect(() => computeRucDv('41A3456')).toThrow(RuleError); + expect(() => computeRucDv('4123456-4')).toThrow(RuleError); + expect(() => computeRucDv(' 4123456')).toThrow( + expect.objectContaining({ code: 'invalid_ruc' }), + ); + }); + + it('agrees with an independent transcription of the algorithm', () => { + // An 8 digit base uses factors 2..9 exactly once, which is the longest base the + // format allows, so the cycle back to 2 is unreachable in practice. Checked anyway. + for (const base of ['5', '12', '4123456', '80069563', '12345678', '1', '99999999']) { + expect(computeRucDv(base), base).toBe(computeDvByHand(base)); + } + }); +}); + +/** An independent transcription of RULES.md section 2, to check the implementation. */ +function computeDvByHand(base: string): number { + const factors = [2, 3, 4, 5, 6, 7, 8, 9]; + let sum = 0; + const reversed = [...base].reverse(); + for (let i = 0; i < reversed.length; i++) { + sum += Number(reversed[i]) * (factors[i % factors.length] as number); + } + const r = sum % 11; + return r > 1 ? 11 - r : 0; +} + +describe('validateRuc', () => { + it('round trips for 20 generated bases', () => { + for (let i = 1; i <= 20; i++) { + const base = String(1_000_000 + i * 111_111).slice(0, 7); + const dv = computeRucDv(base); + expect(validateRuc(base, dv), `${base}-${dv}`).toBe(true); + } + }); + + it('rejects the wrong check digit', () => { + const base = '4123456'; + const dv = computeRucDv(base); + const wrong = (dv + 1) % 10; + expect(validateRuc(base, wrong)).toBe(false); + }); + + it('rejects letters and out of range digits without throwing', () => { + expect(validateRuc('41A3456', 4)).toBe(false); + expect(validateRuc('', 0)).toBe(false); + expect(validateRuc('123456789', 0)).toBe(false); + expect(validateRuc('4123456', -1)).toBe(false); + expect(validateRuc('4123456', 10)).toBe(false); + expect(validateRuc('4123456', 1.5)).toBe(false); + }); +}); + +describe('deadlineDigit', () => { + it('is the last digit of the base, not the check digit', () => { + expect(deadlineDigit('4123456')).toBe(6); + expect(deadlineDigit('80069563')).toBe(3); + expect(deadlineDigit('7')).toBe(7); + }); + + it('ignores the check digit even when one is appended by mistake', () => { + expect(() => deadlineDigit('4123456-4')).toThrow(RuleError); + }); +}); + +describe('formatRuc and parseRuc', () => { + it('round trips', () => { + expect(formatRuc('4123456', 4)).toBe('4123456-4'); + expect(parseRuc('4123456-4')).toEqual({ base: '4123456', dv: 4 }); + expect(parseRuc(' 4123456-4 ')).toEqual({ base: '4123456', dv: 4 }); + }); + + it('returns null for anything malformed', () => { + expect(parseRuc('4123456')).toBeNull(); + expect(parseRuc('4123456-44')).toBeNull(); + expect(parseRuc('abc-1')).toBeNull(); + expect(parseRuc('')).toBeNull(); + }); +}); diff --git a/packages/rules/src/ruc.ts b/packages/rules/src/ruc.ts new file mode 100644 index 0000000..c48ed58 --- /dev/null +++ b/packages/rules/src/ruc.ts @@ -0,0 +1,60 @@ +import { RuleError } from './errors'; + +const DIGITS_ONLY = /^\d{1,8}$/; + +/** + * RULES.md section 2, modulo 11 basis 2: + * right to left, multiply each digit by 2,3,4,5,6,7,8,9 then cycle back to 2, + * sum, r = sum % 11, dv = r > 1 ? 11 - r : 0. + * + * TODO-TAX-VERIFY: confirm against at least five real published RUCs. The seed and the + * fixtures generate their RUCs with this same function, so the product stays internally + * consistent whatever the verification concludes. Pinned by ruc.test.ts. + */ +export function computeRucDv(base: string): number { + assertRucBase(base); + + let factor = 2; + let sum = 0; + for (let i = base.length - 1; i >= 0; i--) { + sum += Number(base[i]) * factor; + factor = factor === 9 ? 2 : factor + 1; + } + + const remainder = sum % 11; + return remainder > 1 ? 11 - remainder : 0; +} + +/** True when `dv` is the check digit for `base`. Never throws: invalid input is invalid. */ +export function validateRuc(base: string, dv: number): boolean { + if (!DIGITS_ONLY.test(base)) return false; + if (!Number.isInteger(dv) || dv < 0 || dv > 9) return false; + return computeRucDv(base) === dv; +} + +/** + * The digit that decides the calendario perpetuo day: the last digit of the base, NOT the + * check digit (RULES.md section 2). + */ +export function deadlineDigit(base: string): number { + assertRucBase(base); + return Number(base[base.length - 1]); +} + +/** `4123456` and `4` become `4123456-4`. */ +export function formatRuc(base: string, dv: number): string { + return `${base}-${dv}`; +} + +/** Splits `4123456-4` into its parts. Returns null for anything malformed. */ +export function parseRuc(value: string): { base: string; dv: number } | null { + const match = /^(\d{1,8})-(\d)$/.exec(value.trim()); + if (!match) return null; + return { base: match[1] as string, dv: Number(match[2]) }; +} + +function assertRucBase(base: string): void { + if (!DIGITS_ONLY.test(base)) { + throw new RuleError('invalid_ruc', `RUC base must be 1 to 8 digits, got: ${base}`, { base }); + } +} diff --git a/playwright.config.ts b/playwright.config.ts index c2e5944..75ede50 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -3,8 +3,12 @@ import { defineConfig, devices } from '@playwright/test'; /** * Runs against a running stack rather than starting one, so the same specs cover the * compose stack and a local `pnpm dev`. Point E2E_BASE_URL at whichever you are using. + * + * The host matters as much as the port: better-auth checks the request Origin against + * APP_PUBLIC_URL, and http://localhost and http://127.0.0.1 are different origins. This + * default matches apps/web/.env.example PORT and apps/api/.env.example APP_PUBLIC_URL. */ -const baseURL = process.env['E2E_BASE_URL'] ?? 'http://127.0.0.1:3000'; +const baseURL = process.env['E2E_BASE_URL'] ?? 'http://localhost:3005'; export default defineConfig({ testDir: './e2e', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3d0fec..3ba4624 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@types/node': specifier: ^26.4.1 version: 26.4.1 + '@vitest/coverage-v8': + specifier: ^5.0.0 + version: 5.0.0(vitest@5.0.0) eslint: specifier: ^10.9.1 version: 10.9.1(jiti@2.7.0) @@ -40,7 +43,7 @@ importers: version: 8.69.0(eslint@10.9.1(jiti@2.7.0))(typescript@5.9.3) vitest: specifier: ^5.0.0 - version: 5.0.0(@types/node@26.4.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) + version: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) apps/api: dependencies: @@ -58,7 +61,7 @@ importers: version: link:../../packages/rules better-auth: specifier: ^1.7.2 - version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0(@types/node@26.4.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))) + version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0) better-sqlite3: specifier: ^13.0.3 version: 13.0.3 @@ -110,7 +113,7 @@ importers: version: 5.102.8(react@19.2.8) better-auth: specifier: ^1.7.2 - version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0(@types/node@26.4.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))) + version: 1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -160,6 +163,9 @@ importers: packages/contracts: dependencies: + '@impuestos/rules': + specifier: workspace:* + version: link:../rules zod: specifier: ^4.5.4 version: 4.5.4 @@ -241,6 +247,10 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@better-auth/core@1.7.2': resolution: {integrity: sha512-j0nM4ygsWbF/fcYRoKtDn8gn8uLXkmC+075HqSqsJEAV828cJR9bvYBCUQ1zmxNyRBk6Iz/qXsA0Zm2oksiOTg==} peerDependencies: @@ -1596,6 +1606,23 @@ packages: resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/coverage-v8@5.0.0': + resolution: {integrity: sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==} + peerDependencies: + '@vitest/browser': 5.0.0 + vitest: 5.0.0 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/istanbul-lib-coverage@1.0.1': + resolution: {integrity: sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==} + engines: {node: '>=22'} + + '@vitest/istanbul-lib-report@1.0.1': + resolution: {integrity: sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==} + engines: {node: '>=22'} + '@vitest/mocker@5.0.0': resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} peerDependencies: @@ -1658,6 +1685,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -2282,6 +2312,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2500,6 +2533,9 @@ packages: magic-string@1.2.3: resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==} + magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2977,6 +3013,10 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -3315,6 +3355,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@1.0.2': {} + '@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3)': dependencies: '@better-auth/utils': 0.4.2 @@ -4224,6 +4266,24 @@ snapshots: '@typescript-eslint/types': 8.69.0 eslint-visitor-keys: 5.0.1 + '@vitest/coverage-v8@5.0.0(vitest@5.0.0)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/istanbul-lib-coverage': 1.0.1 + '@vitest/istanbul-lib-report': 1.0.1 + ast-v8-to-istanbul: 1.0.5 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) + + '@vitest/istanbul-lib-coverage@1.0.1': {} + + '@vitest/istanbul-lib-report@1.0.1': + dependencies: + '@vitest/istanbul-lib-coverage': 1.0.1 + '@vitest/mocker@5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))': dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -4309,6 +4369,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async-function@1.0.0: {} available-typed-arrays@1.0.7: @@ -4321,7 +4387,7 @@ snapshots: baseline-browser-mapping@2.11.21: {} - better-auth@1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0(@types/node@26.4.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13))): + better-auth@1.7.2(better-sqlite3@13.0.3)(next@16.3.4(@babel/core@7.29.7)(@playwright/test@1.62.1)(@types/node@26.4.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0): dependencies: '@better-auth/core': 1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3) '@better-auth/drizzle-adapter': 1.7.2(@better-auth/core@1.7.2(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.10)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) @@ -4346,7 +4412,7 @@ snapshots: pg: 8.23.0 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - vitest: 5.0.0(@types/node@26.4.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) + vitest: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -5057,6 +5123,8 @@ snapshots: joycon@3.1.1: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} jsesc@3.1.0: {} @@ -5215,6 +5283,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + magicast@0.5.4: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + math-intrinsics@1.1.0: {} minimatch@10.2.6: @@ -5797,6 +5871,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 + tinyrainbow@3.1.1: {} + tree-kill@1.2.2: {} ts-api-utils@2.5.0(typescript@5.9.3): @@ -5937,7 +6013,7 @@ snapshots: jiti: 2.7.0 tsx: 4.23.13 - vitest@5.0.0(@types/node@26.4.1)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)): + vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)): dependencies: '@types/chai': 5.2.3 '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)) @@ -5955,6 +6031,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.4.1 + '@vitest/coverage-v8': 5.0.0(vitest@5.0.0) transitivePeerDependencies: - msw diff --git a/vitest.config.ts b/vitest.config.ts index 4fe5d35..d39e90c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,10 @@ export default defineConfig({ provider: 'v8', include: ['packages/rules/src/**'], reporter: ['text', 'html', 'json-summary'], + // RULES.md section 10: the tax logic carries the whole product's correctness, so + // the bar is enforced rather than observed. Genuinely unreachable guards carry an + // explicit `v8 ignore` and a comment saying why. + thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 }, }, }, });