# 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 = { 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).