Files
impuestospy/packages/rules/src/ruc.ts
T
MichilisandClaude Opus 5 80b10c958e 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>
2026-09-03 22:15:28 +00:00

61 lines
2.0 KiB
TypeScript

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 });
}
}