phase-1: packages/rules complete, 100% covered
Every algorithm in docs/RULES.md, implemented exactly as written: the RUC check digit, the calendario perpetuo with weekend and holiday roll forward, CDC and KUDE QR parsing, keyword classification, computeF120, computeF515 and the dashboard projections, plus the two form definitions. Both worked examples reproduce verbatim on the first implementation: F120 at Gs. 277.273 to pay with the Gs. 350.000 flip case, F515 at Gs. 11.290.000 on a 5,65% effective rate. 162 tests over the package, coverage enforced at 100% statements, branches, functions and lines; the only exclusions are three bounded-loop guards marked v8 ignore with a comment saying why. No tax rule was invented. All six TODO-TAX-VERIFY items from RULES.md have a test pinning today's behaviour and a row in the DECISIONS.md register, so verification later is a red/green diff. Where RULES.md was silent the choice is marked SPEC-GAP in the code and listed too, the notable one being that taxpayer.hasIrp gates the deduction amount. No floats anywhere in a money path: money is a branded Pyg of whole guaranies and percentages go through integer arithmetic rounded half up. Also: contracts now takes IRP_CATEGORIES from rules rather than declaring the eight strings twice; classification returns reason codes with catalog strings in both locales, so the detail sheet localizes; apps/api/.env.example now names the same web port as apps/web/.env.example, without which a fresh checkout fails sign in on the origin check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ae2ea20b7e
commit
80b10c958e
+92
-12
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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');
|
||||
@@ -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'],
|
||||
|
||||
+4
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@impuestos/rules": "workspace:*",
|
||||
"zod": "^4.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof IrpCategory>;
|
||||
|
||||
@@ -219,6 +219,17 @@ export const en: Record<MessageKey, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
'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();
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, string>> = {
|
||||
'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<Record<string, string>> = {
|
||||
'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<string, string> = {};
|
||||
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;
|
||||
}
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<Record<number, number>> = {
|
||||
0: 7,
|
||||
1: 9,
|
||||
2: 11,
|
||||
3: 13,
|
||||
4: 15,
|
||||
5: 17,
|
||||
6: 19,
|
||||
7: 21,
|
||||
8: 23,
|
||||
9: 25,
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<Record<string, unknown>> | undefined;
|
||||
|
||||
constructor(code: RuleErrorCode, message: string, detail?: Record<string, unknown>) {
|
||||
super(message);
|
||||
this.name = 'RuleError';
|
||||
this.code = code;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
export function isRuleError(value: unknown): value is RuleError {
|
||||
return value instanceof RuleError;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<IrpCategory, Pyg>;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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-]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<F120Result> = {
|
||||
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 },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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<Record<IrpCategory, string>> = {
|
||||
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<F515Result> = {
|
||||
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 },
|
||||
];
|
||||
},
|
||||
};
|
||||
@@ -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<TResult> {
|
||||
code: '120' | '515';
|
||||
version: string;
|
||||
toValues(result: TResult): FormValue[];
|
||||
}
|
||||
@@ -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<Record<number, readonly string[]>> = {
|
||||
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}`);
|
||||
}
|
||||
+115
-5
@@ -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';
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Generated
+83
-6
@@ -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
|
||||
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user