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:
Michilis
2026-09-03 22:15:28 +00:00
co-authored by Claude Opus 5
parent ae2ea20b7e
commit 80b10c958e
45 changed files with 3317 additions and 48 deletions
+182
View File
@@ -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);
});
});
+112
View File
@@ -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));
}
+19
View File
@@ -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';
+95
View File
@@ -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;
}
+192
View File
@@ -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();
});
});
+185
View File
@@ -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;
}
+192
View File
@@ -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);
});
});
+103
View File
@@ -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,
};
}
+48
View File
@@ -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);
}
});
});
+39
View File
@@ -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,
};
+144
View File
@@ -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');
});
});
+122
View File
@@ -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);
}
+28
View File
@@ -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;
}
+154
View File
@@ -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);
});
});
+89
View File
@@ -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),
};
}
+211
View File
@@ -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);
});
});
+118
View File
@@ -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;
}
+117
View File
@@ -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-]+$/);
}
});
});
+27
View File
@@ -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 },
];
},
};
+43
View File
@@ -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 },
];
},
};
+19
View File
@@ -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[];
}
+40
View File
@@ -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
View File
@@ -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';
+108
View File
@@ -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 });
}
});
});
+54
View File
@@ -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));
}
+179
View File
@@ -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);
});
});
+110
View File
@@ -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),
);
}
+117
View File
@@ -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();
});
});
+60
View File
@@ -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 });
}
}