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
+92 -12
View File
@@ -10,18 +10,11 @@ Markers used in the code:
## Blocking gaps in the source material
### RULES.md is missing
`docs/` ships SPEC.md, FLOWS.md, COPY.md and CONTRACTS.md. RULES.md, which the prompt
names as authoritative for every tax rule, is not present. Phase 1 is entirely RULES.md
and phases 4 and 5 depend on it.
Consequence for phase 0: `packages/rules` exists and exports only `RULES_VERSION`.
Nothing in this phase computes a tax number, a check digit or a deadline, so nothing was
invented. The seed deliberately stops short of profiles for the same reason: CONTRACTS.md
section 4 asks for RUC base `4123456` "with computed DV" and a `deadlineDigit`, both of
which are RULES.md algorithms. Seeding accounts only keeps phase 0 honest.
**Needed before phase 1 starts.**
### RULES.md was missing during phase 0, supplied for phase 1
`packages/rules` shipped phase 0 with only `RULES_VERSION` and the phase 0 seed stopped
short of profiles, because the RUC check digit and the deadline digit are RULES.md
algorithms. Both are implemented in phase 1 and the seed can now be completed.
**Resolved.**
### `boneyard` and `canvas-ui` are not the packages the prompt means
Both names resolve on npm to unrelated projects: `boneyard@0.1.4` is a 2015 Backbone
@@ -129,3 +122,90 @@ SQLite stays single process. Worth reconciling in SPEC.md.
`/legal/privacidad` and `/legal/terminos` do not exist yet (they belong to phase 2's
marketing routes). Per COPY.md section 13 they will ship with clearly marked placeholder
content and no generated legal text. **TODO: human written before launch.**
---
## Phase 1
### TODO-TAX-VERIFY register
Every item RULES.md flags, where it is implemented, and the test that pins today's
behaviour so verification is a red/green diff rather than archaeology.
| # | What needs verifying | Implemented in | Pinned by |
|---|---|---|---|
| 1 | IRP tranche boundaries (50M, 150M) and the Gs. 80.000.000 registration threshold, against the live DNIT tables for the current fiscal year | `constants.ts` | `constants.test.ts` "pins the IRP tranche boundaries and threshold pending verification" |
| 2 | The RUC check digit algorithm (modulo 11, basis 2) against at least five real published RUCs | `ruc.ts` | `ruc.test.ts` "pins the check digit it computes today" and "agrees with an independent transcription" |
| 3 | Government decreed one off holidays and "dias no laborables trasladables", and movable holidays past 2028 | `holidays.ts` | `calendario.test.ts` "pins that movable holidays are only known for 2026 to 2028" |
| 4 | F120 casilla numbers, currently placeholder keys, against the live Marangatu F120 v4 | `forms/f120.v1.ts` | `forms.test.ts` "pins that they are still placeholders" |
| 5 | F515 casilla numbers, same | `forms/f515.v1.ts` | same test |
| 6 | The progressive-by-tranche reading of the IRP rates | `f515.ts` `taxForNetIncome` | `f515.test.ts` bracket edge suite, including the worked example |
Nothing outside this list was invented. Where RULES.md was silent the gap is marked
`SPEC-GAP` in the code and listed below.
### Both worked examples reproduce exactly
RULES.md section 6 gives F120 a Gs. 277.273 monto a pagar and a Gs. 350.000 flip case;
section 7 gives F515 a Gs. 11.290.000 tax at a 5,65% effective rate. Both are transcribed
verbatim as tests and both pass on the first implementation, which is the main evidence
that the integer money math and the rounding rule are right.
### Coverage is enforced, not observed
`vitest.config.ts` fails the run below 100% statements, branches, functions and lines for
`packages/rules`. Three defensive throws carry an explicit `v8 ignore` and a comment
saying why they are unreachable: the bounded loops in `rollForward` and `nextDeadline`
guard against an edit to `holidays.ts` that would otherwise spin forever, and no input can
reach them with any sane holiday table.
### Classification emits reason codes, not sentences
CONTRACTS.md types `reasons` as `string[]` and RULES.md gives one of them as Spanish text
("Sin categoria sugerida"). A pure, locale free package cannot emit user facing copy in
one language without breaking the en locale, which is a hard constraint. `classify`
therefore returns stable codes and `packages/i18n` carries a `classification.reason.*`
string per code, with RULES.md's wording used verbatim for the Spanish of that one.
### SPEC-GAPs in the tax logic
**`taxpayer.hasIrp` gates the deduction amount.** RULES.md states the IVA eligibility test
in full but never says whether being registered for IRP is required for a deduction. It is
the only field of the documented `ClassificationInput` that would otherwise go unused, so
it gates `irpDeductibleAmount`. The category is still suggested either way, so the data is
already correct if the user registers for IRP later. Pinned in `classification.test.ts`.
**A sale reads as "no keyword hit", so its confidence is 0,4.** RULES.md defines confidence
only for the keyword map, and a sale can never hit it. Taking that literally rather than
inventing a number means sales never reach the auto confirm threshold. Worth a decision
before the auto confirm sweep ships in phase 4. Pinned in `classification.test.ts`.
**"Annualized YTD sales" is straight line.** RULES.md section 8 names the fallback without
defining it: sales so far, scaled by the share of the year elapsed. It is only ever shown
as a "proyeccion" and never filed.
**Ñ folds to N when normalising an emitter name.** The tilde is a diacritic to Unicode, so
"diacritic-insensitive" folds it. No keyword contains Ñ, so matching is unaffected, and
folding is the forgiving choice when a printed name spells it either way.
**CDC type `07` maps to DocKind `otro`.** `nota de remision` is a known CDC document type
but has no DocKind of its own in CONTRACTS.md section 2. The code and its Spanish label
survive on the parsed fields, so nothing is lost.
**`nextDeadline` returns a `Date`, as RULES.md types it.** Every calculation inside the
package works on civil year/month/day values, and the returned `Date` is midnight UTC of
that civil date, so it cannot drift a day with the host timezone. `America/Asuncion` is
consulted in exactly one place, `todayInAsuncion`.
### The IRP category vocabulary lives in packages/rules
`packages/contracts` now imports `IRP_CATEGORIES` from `packages/rules` and builds its Zod
enum from it, rather than declaring the eight strings a second time. The vocabulary sits
next to the tax logic that uses it and the two cannot drift.
### Local ports: web 3005, and the API must agree
`apps/web/.env.example` ships `PORT=3005` and `apps/api/.env.example` names the same port
in `APP_PUBLIC_URL` and `BETTER_AUTH_URL`. They have to agree: better-auth checks the
request Origin, so a mismatch makes sign in fail with a 403 that looks nothing like a port
problem. `http://localhost` and `http://127.0.0.1` are different origins too, which is why
the Playwright default base URL uses localhost.
This deviates from the literal example values in SPEC.md section 15, which use port 3000.
The compose stack still publishes 3000 and is unaffected.