Monorepo (pnpm workspaces) with two deployable apps and three pure packages. apps/api (Hono on Node): Zod validated env that fails fast and names the problem, Kysely factories for SQLite and Postgres chosen by DATABASE_URL scheme, portable migrations covering the whole SPEC section 5 schema, Better Auth with the four roles and seeded demo accounts, localized error envelope, /healthz and /readyz, graceful SIGTERM drain. Dialect specific SQL is confined to the two factories. apps/web (Next.js App Router): locale routed shell in es and en with a language switcher, sign in screen, and a runtime /api proxy so the browser only ever sees one origin and cookies stay first party. packages/i18n ships both catalogs complete; es is generated from COPY.md and a test re-derives it from the document on every run so it cannot drift. packages/contracts holds the Zod schemas and the typed client the web app uses. Verified: 43 vitest tests, 14 Playwright tests on mobile and desktop, typecheck and lint clean, migrate and seed from a clean database, sign in through the proxy with CSRF rejection of foreign origins. Not verified here: docker compose. This user has no access to the docker socket. RULES.md is absent from docs/, so packages/rules exports only RULES_VERSION and no tax rule, check digit or deadline was invented. See DECISIONS.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
242 lines
13 KiB
Markdown
242 lines
13 KiB
Markdown
# RULES.md: Exact Tax Logic, Algorithms, Worked Examples and Test Vectors
|
|
|
|
Authoritative for `packages/rules`. Every algorithm here must be implemented exactly as written and covered by the tests listed. Anything marked `TODO-TAX-VERIFY` is implemented as written now, flagged in code, and listed in DECISIONS.md for human verification before production.
|
|
|
|
All money values are integer guaranies (branded type `Pyg`). No floats in any money path. Percentages are computed as integer math with explicit rounding rules (round half up to whole guarani unless stated).
|
|
|
|
---
|
|
|
|
## 1. Constants
|
|
|
|
```ts
|
|
export const RULES_VERSION = "1.0.0";
|
|
|
|
export const IVA_RATE_10 = 10;
|
|
export const IVA_RATE_5 = 5;
|
|
|
|
export const IRP_THRESHOLD_ANNUAL = 80_000_000; // Gs., registration threshold
|
|
export const IRP_BRACKET_1_LIMIT = 50_000_000; // 8% up to here
|
|
export const IRP_BRACKET_2_LIMIT = 150_000_000; // 9% for the tranche above 1 up to here, 10% above
|
|
export const IRP_RATE_1 = 8;
|
|
export const IRP_RATE_2 = 9;
|
|
export const IRP_RATE_3 = 10;
|
|
|
|
export const RESIMPLE_SUPPLIER_DEDUCTION_CAP_PCT = 1; // 1% of gross annual income
|
|
|
|
export const DEADLINE_DAY_BY_DIGIT: Record<number, number> = {
|
|
0: 7, 1: 9, 2: 11, 3: 13, 4: 15, 5: 17, 6: 19, 7: 21, 8: 23, 9: 25,
|
|
};
|
|
```
|
|
|
|
`TODO-TAX-VERIFY`: IRP tranche boundaries and the 80M threshold against the live DNIT tables for the current fiscal year.
|
|
|
|
---
|
|
|
|
## 2. RUC and check digit
|
|
|
|
Paraguayan RUC: base number (up to 8 digits) + hyphen + verification digit (DV). CI holders use their CI as RUC base.
|
|
|
|
**DV algorithm (modulo 11, basis 2):**
|
|
1. Take the base digits, process right to left.
|
|
2. Multiply each digit by factors 2, 3, 4, 5, 6, 7, 8, 9, then cycle back to 2.
|
|
3. Sum the products. `r = sum % 11`.
|
|
4. `dv = r > 1 ? 11 - r : 0`.
|
|
|
|
```ts
|
|
export function computeRucDv(base: string): number;
|
|
export function validateRuc(base: string, dv: number): boolean; // also rejects non-digits, length 1..8
|
|
export function deadlineDigit(base: string): number; // last digit of base (NOT the dv)
|
|
```
|
|
|
|
`TODO-TAX-VERIFY`: confirm the algorithm against at least 5 real published RUCs (DNIT publishes RUC lists; the seed uses synthetic RUCs generated WITH this algorithm so internal consistency holds regardless).
|
|
|
|
**Tests:** compute and validate round-trip for 20 generated bases; reject wrong dv; reject letters; digit extraction ignores dv.
|
|
|
|
---
|
|
|
|
## 3. Calendario perpetuo
|
|
|
|
```ts
|
|
export function deadlineDay(digit: number): number; // table above, throws on out of range
|
|
export function nextDeadline(opts: {
|
|
digit: number;
|
|
obligation: "iva_120" | "irp_515";
|
|
from: Date; // "now"
|
|
}): { period: string; dueDate: Date };
|
|
```
|
|
|
|
Rules:
|
|
- `iva_120`: declares month M, due in month M+1 on the digit day. Example: August 2026 IVA, digit 6 → due 2026-09-19 (before roll rules).
|
|
- `irp_515`: declares year Y, due in March of Y+1 on the digit day.
|
|
- **Roll-forward:** if the computed date is a Saturday, Sunday, or a holiday (section 3.1), advance day by day until a business day.
|
|
- Timezone: all deadline math in `America/Asuncion`, dates stored as `YYYY-MM-DD`.
|
|
|
|
### 3.1 Holidays (hardcoded table, extend yearly)
|
|
|
|
Fixed every year: `01-01, 03-01, 05-01, 05-14, 05-15, 06-12, 08-15, 09-29, 12-08, 12-25`.
|
|
|
|
Movable (Holy Thursday and Good Friday), by year:
|
|
- 2026: `2026-04-02`, `2026-04-03`
|
|
- 2027: `2027-03-25`, `2027-03-26`
|
|
- 2028: `2028-04-13`, `2028-04-14`
|
|
|
|
`TODO-TAX-VERIFY`: government-decreed one-off holidays and "dias no laborables trasladables" per year; the table is data (`holidays.ts`), updating it is a data change, not a code change.
|
|
|
|
**Tests:** digit 0 vs digit 9 spread; due date landing on Saturday rolls to Monday; due date landing on 2026-04-02 rolls past both holidays to 2026-04-06 (Monday); December IVA due in January of next year; year boundary for IRP.
|
|
|
|
---
|
|
|
|
## 4. CDC parsing
|
|
|
|
CDC = exactly 44 digits:
|
|
|
|
| Field | Length | Offset |
|
|
|---|---|---|
|
|
| tipoDocumento | 2 | 0 |
|
|
| rucEmisor | 8 | 2 |
|
|
| dvEmisor | 1 | 10 |
|
|
| establecimiento | 3 | 11 |
|
|
| puntoExpedicion | 3 | 14 |
|
|
| numeroDocumento | 7 | 17 |
|
|
| tipoContribuyente | 1 | 24 |
|
|
| fechaEmision (YYYYMMDD) | 8 | 25 |
|
|
| tipoEmision | 1 | 33 |
|
|
| codigoSeguridad | 9 | 34 |
|
|
| digitoVerificador | 1 | 43 |
|
|
|
|
```ts
|
|
export function parseCdc(cdc: string): CdcFields; // throws TypedError on length/charset/date validity
|
|
```
|
|
|
|
tipoDocumento map (store label): `01` factura electronica, `04` autofactura, `05` nota de credito, `06` nota de debito, `07` nota de remision. Unknown codes: keep code, label "otro".
|
|
|
|
**Canonical test vector (synthetic, used by fixtures too):**
|
|
`01 80069563 1 001 001 0001234 1 20260815 1 123456789 4` → concatenated: `"01800695631001001000123412026081511234567894"`. Wait: build programmatically in tests from the field table (concatenate parts) rather than hardcoding a string, then assert round-trip parse. The fixtures script (`scripts/fixtures.ts`) must generate CDCs the same way.
|
|
|
|
**QR payload:** the KUDE QR is a URL. Treat it as: parse as URL, read query param `Id` = CDC (44 digits). If present, also read `dTotGralOpe` (total) and `dTotIVA` (total IVA) as integers when parseable. All other params are stored opaque in `documents.qr_url`. Any URL whose host is not recognizable is still accepted if `Id` parses as a valid CDC (offline QRs from test fixtures). If no `Id` param, try: the raw string IS a 44-digit CDC.
|
|
|
|
**Tests:** round-trip; invalid length; invalid date (20261340); QR URL with and without `Id`; raw-CDC QR.
|
|
|
|
---
|
|
|
|
## 5. Classification
|
|
|
|
```ts
|
|
export interface ClassificationInput {
|
|
direction: "purchase" | "sale";
|
|
docKind: string;
|
|
emitterName: string; // uppercase-normalized
|
|
emitterRuc: string;
|
|
supplierRegimeHint: "normal" | "resimple" | "unknown";
|
|
taxpayer: { kind: "individual" | "company"; hasIva: boolean; hasIrp: boolean };
|
|
amounts: { total: Pyg; iva10: Pyg; iva5: Pyg };
|
|
}
|
|
export interface ClassificationSuggestion {
|
|
ivaCreditEligible: boolean;
|
|
ivaCreditAmount: Pyg; // iva10 + iva5 when eligible, else 0
|
|
irpCategory: IrpCategory | "none";
|
|
irpDeductibleAmount: Pyg; // total when deductible, else 0
|
|
confidence: number; // 0..1
|
|
reasons: string[]; // human-readable, for the UI detail sheet
|
|
}
|
|
```
|
|
|
|
**IVA credit logic (purchases only):**
|
|
- eligible = `taxpayer.hasIva && direction === "purchase" && supplierRegimeHint !== "resimple" && (iva10 + iva5) > 0 && docKind !== "boleta_resimple"`.
|
|
- v1 assumes business purpose = true for IVA taxpayers; the user can toggle it off per document in the UI (that toggle overrides, `decided_by='user'`).
|
|
|
|
**IRP category by emitter keyword map** (`categoryHints.ts`, data not code; match on normalized emitter name, first hit wins, order as listed):
|
|
|
|
| Category | Keywords (contains, case/diacritic-insensitive) |
|
|
|---|---|
|
|
| salud | FARMACIA, FARMA, CLINICA, SANATORIO, HOSPITAL, LABORATORIO, ODONTO, OPTICA |
|
|
| educacion | COLEGIO, ESCUELA, UNIVERSIDAD, INSTITUTO, ACADEMIA, LIBRERIA |
|
|
| alimentacion | SUPERMERCADO, SUPER, DESPENSA, ALMACEN, MINIMARKET, CARNICERIA, PANADERIA, RESTAURANT, RESTAURANTE, PIZZERIA, COMIDAS |
|
|
| vehiculo | PETROBRAS, SHELL, PUMA, ESTACION, COMBUSTIBLE, TALLER, GOMERIA, REPUESTOS, LUBRICANTES |
|
|
| vivienda | INMOBILIARIA, ALQUILER, CONDOMINIO, FERRETERIA, ELECTRICIDAD, SANITARIOS, ANDE, ESSAP |
|
|
| vestimenta | BOUTIQUE, TIENDA, CALZADOS, MODAS, CONFECCIONES |
|
|
| esparcimiento | CINE, TEATRO, CLUB, GIMNASIO, GYM, TURISMO, HOTEL |
|
|
| familiares | (never keyword-assigned; only user-assigned with a dependent) |
|
|
|
|
- Confidence: keyword hit = 0.85; no hit = 0.4 with `irpCategory` set to the amount-weighted default `"alimentacion"`? NO: no hit → `irpCategory: "none"`, confidence 0.4, reasons include "Sin categoria sugerida". Never guess a category without a keyword hit.
|
|
- Sales documents: `irpCategory: "none"`, they count as income, not deductions.
|
|
- `esparcimiento` Paraguay-only rule: v1 treats all ingested comprobantes as Paraguayan (they have RUCs); rule noted for future foreign-expense support.
|
|
|
|
**Tests:** every keyword row; resimple supplier blocks IVA credit but NOT IRP deduction; sale never deductible; no-hit yields none/0.4.
|
|
|
|
---
|
|
|
|
## 6. Formulario 120 computation (monthly IVA)
|
|
|
|
```ts
|
|
export function computeF120(input: {
|
|
period: string; // "2026-08"
|
|
saldoAnterior: Pyg; // credit carried from previous period, >= 0
|
|
documents: F120Doc[]; // confirmed docs whose issue_date is in period
|
|
}): F120Result;
|
|
```
|
|
|
|
- `debito = sum(iva10 + iva5)` over confirmed SALE documents in the period.
|
|
- `credito = sum(ivaCreditAmount)` over confirmed PURCHASE documents with `ivaCreditEligible` in the period.
|
|
- `creditoTotal = credito + saldoAnterior`.
|
|
- If `debito > creditoTotal`: `aPagar = debito - creditoTotal`, `saldoAFavor = 0`.
|
|
- Else: `aPagar = 0`, `saldoAFavor = creditoTotal - debito` (becomes next period's `saldoAnterior`).
|
|
|
|
**Worked example (used verbatim as a test):**
|
|
Sales: 3 facturas with iva10 = 400,000 / 500,000 / 227,273 → debito 1,127,273.
|
|
Purchases eligible: iva10 total 610,000, iva5 total 90,000 → credito 700,000. saldoAnterior 150,000 → creditoTotal 850,000.
|
|
Result: aPagar = 277,273, saldoAFavor = 0.
|
|
Flip test: same purchases, sales debito only 500,000 → aPagar 0, saldoAFavor 350,000.
|
|
|
|
Form definition `forms/f120.v1.ts` maps: ventas gravadas 10/5 (base amounts), debito fiscal, compras gravadas 10/5, credito fiscal, saldo anterior, monto a pagar, saldo a favor. Casilla numbers are placeholder strings `"c-ventas-10"` etc. with `TODO-TAX-VERIFY: replace with official casilla numbers from live Marangatu F120 v4`.
|
|
|
|
---
|
|
|
|
## 7. Formulario 515 computation (annual IRP-RSP)
|
|
|
|
```ts
|
|
export function computeF515(input: {
|
|
year: string;
|
|
grossIncome: Pyg; // from profile estimate in v1 (sales docs when present add to it, take max)
|
|
documents: F515Doc[]; // confirmed purchase docs of the year with irpCategory != "none"
|
|
hasResimpleFlag: (doc) => boolean; // supplierRegimeHint === "resimple"
|
|
}): F515Result;
|
|
```
|
|
|
|
Steps:
|
|
1. Sum deductions per category from `irpDeductibleAmount`.
|
|
2. **RESIMPLE cap:** sum deductible amounts whose supplier is resimple; cap that subtotal at `floor(grossIncome * 1 / 100)`; excess is reported as `capExcess` (UI shows "Gs. X no deducible por tope del 1%").
|
|
3. `totalDeductions = sum(categories) - capExcess`.
|
|
4. `netIncome = max(0, grossIncome - totalDeductions)`.
|
|
5. **Tax by tranches** (`TODO-TAX-VERIFY`: progressive-by-tranche interpretation):
|
|
- tranche1 = min(netIncome, 50,000,000) * 8%
|
|
- tranche2 = min(max(netIncome - 50,000,000, 0), 100,000,000) * 9%
|
|
- tranche3 = max(netIncome - 150,000,000, 0) * 10%
|
|
- `tax = round(t1 + t2 + t3)`.
|
|
6. `effectiveRate` (2 decimals, display only). If `grossIncome < IRP_THRESHOLD_ANNUAL`, result includes `belowThreshold: true` and the UI frames the output as informative.
|
|
|
|
**Worked example (verbatim test):**
|
|
grossIncome 200,000,000. Deductions: alimentacion 18,000,000; salud 9,500,000; educacion 12,000,000; vivienda 24,000,000; vehiculo 6,500,000; of which 3,000,000 came from RESIMPLE suppliers. Cap = 2,000,000 → capExcess 1,000,000. totalDeductions = 70,000,000 - 1,000,000 = 69,000,000. netIncome = 131,000,000. Tax = 50,000,000*8% + 81,000,000*9% = 4,000,000 + 7,290,000 = **11,290,000**. effectiveRate on gross = 5.65%.
|
|
|
|
**Bracket edge tests:** netIncome 49,999,999 / 50,000,000 / 50,000,001 / 150,000,000 / 150,000,001; zero income; deductions exceeding income floor at 0.
|
|
|
|
Form definition `forms/f515.v1.ts`: income, one line per category, cap adjustment line, net, tax, with placeholder casillas and the same TODO.
|
|
|
|
---
|
|
|
|
## 8. Projections (dashboard)
|
|
|
|
- IRP projection for year Y at date D: `computeF515` with year-to-date documents, grossIncome = profile estimate (fallback: annualized YTD sales when no estimate). Label clearly "proyeccion".
|
|
- "Savings this month" delta = tax with current deductions minus tax with deductions excluding the current month's confirmed docs.
|
|
- IVA month position = `computeF120` over the open month with live (confirmed) docs, `saldoAnterior` from the last approved F120 declaration (0 when none).
|
|
|
|
## 9. OCR extraction schema (documents module, not rules, listed here for completeness)
|
|
|
|
Zod schema the Anthropic call must return (strict JSON, temperature 0):
|
|
`{ emitter_ruc: string|null, emitter_dv: string|null, emitter_name: string|null, receiver_doc: string|null, doc_number: string|null, issue_date: "YYYY-MM-DD"|null, total: int|null, amount_iva10: int|null, amount_iva5: int|null, amount_exenta: int|null, iva10: int|null, iva5: int|null, confidence: { [field]: number } }`
|
|
Prompt requirements: instruct that Paraguayan facturas print IVA columns as "10%", "5%", "Exentas"; amounts use dots as thousand separators; return integers without separators; null when unreadable, never guess. Validate: if `total` present and components present, assert `|total - (base10+base5+exenta)| tolerance 1 Gs` where derivable; on mismatch lower confidence of money fields to 0.5.
|
|
|
|
## 10. Test coverage bar
|
|
|
|
`packages/rules`: 100% line coverage target, every worked example verbatim, every `TODO-TAX-VERIFY` has a test pinning current behavior (so verification later is a red/green diff, not archaeology).
|