18 — Compliance is data: cohorts, effective dates, and a validator that names names
Read this first: the last lesson showed you a calculator with no dependencies. This one shows where its numbers come from — and the answer is rows, not Java. Statutory contribution and tax tables live in the database, versioned by effective date, and the interesting code is not the arithmetic but the machinery deciding which version applies to a given period, and what happens when a table has a hole in it. As in lesson 16, you read comments, javadoc, and signatures only. Every rate and bracket boundary lives in the business-rules reference; this lesson states none of them on purpose, so that a number changing next January never makes it wrong.
Time: about 30 minutes. Assumes lesson 17.
The rules are rows, not code
There is no if (salary > X) return Y; anywhere in the contribution path. There are four entities,
and they are all the same shape:
SssContributionRate salary bracket from/to + effective_date
PhilhealthContributionRate salary bracket from/to + effective_date
PagibigContributionRate salary bracket from/to + effective_date
WithholdingTaxBracket taxable income from/to + effective_date + pay_period
A range, an effective date, and the amounts that apply inside it. What each one contains is agency policy and belongs to the business-rules reference; this lesson is about the two extra columns, because those are the entire versioning design.
pay_period exists because BIR publishes a separate prescribed table per pay period. The
TaxPayPeriod enum spells that out: daily, weekly, semi-monthly, monthly, plus the annual schedule
used for the year-end adjustment, with every supported pay frequency mapping 1:1 onto its own
table. DAILY rows are seeded as reference data but unreachable, since no daily pay frequency
exists. Picking the wrong table is not a rounding error — taxing a semi-monthly payslip against the
monthly table systematically under-withholds, which is the bug migration V63 fixed.
A table is a stack of dated versions
When the agencies publish new rates, nothing is overwritten. A new cohort — a set of rows sharing
one effective_date — is inserted alongside the old one, and lookups choose between them. The
convention is stated once per repository, in the same words. From SssContributionRateRepository:
// Rate tables are versioned by effective_date: the cohort in force on a given
// date is the one with the greatest effective_date not after it. Ordered and
// wrapped in a default method so an accidental overlapping-bracket edit picks a
// deterministic match instead of Spring Data throwing on a non-unique result.
Two decisions in four lines. The first sentence is the versioning rule: greatest effective date
not after the as-of date. Not "the newest cohort" — that one may not have taken effect yet when
the period you are computing ended. The subquery expressing this is why these lookups are
hand-written @Query methods rather than derived query names
(lesson 08).
The second sentence is defensive. The query returns a List ordered by bracket floor, and a
default method on the interface takes the first element. If it returned a single entity instead,
one overlapping-bracket edit anywhere in the table would make Spring Data throw a non-unique-result
exception on some salaries and not others — a failure that depends on who is being paid. Ordering
plus first-match turns that into a deterministic, if slightly wrong, answer, while the validator
later in this lesson stops the overlap being saved at all.
A second query sits underneath, with a one-line comment that is easy to skim past:
// Deterministic fallback when the as-of date predates every cohort.
If you back-date a run before the earliest seeded cohort, the primary lookup finds nothing. Rather than fail, the fallback resolves against the earliest cohort — the oldest rates the system knows. Deterministic, documented, and clearly labelled as a fallback rather than a rule.
Two boundary conventions, and why they differ
Contribution lookups match with SQL between, which is inclusive at both ends. Tax brackets do
not, and WithholdingTaxBracketRepository explains why in the only comment in the file:
// Lower bound exclusive, upper bound inclusive: adjacent brackets share a boundary value
// […] so a value exactly on that boundary
// must match exactly one bracket — the lower one. Brackets are versioned by
// effective_date PER PAY PERIOD: the cohort in force for a table is the one with the
// greatest effective_date not after the as-of date among rows of that pay period, so a
// newer cohort that only updates one table never hides the others.
BIR's published tables share boundary values by design: one bracket's ceiling is literally the next
bracket's floor. Match both ends inclusively and an income landing exactly on a boundary matches
two brackets, and which one wins depends on row order. So the tax query is from < income <= to —
strictly above the floor, at most the ceiling — and exactly one bracket can ever match.
The second half of that comment is why the cohort subquery carries pay_period as well as the
date. If a new cohort revises only, say, the monthly table, a global "latest cohort" rule would
make every other pay period's table vanish — their newest rows would no longer be in the newest
cohort. Scoping per pay period lets each table advance on its own timeline.
Predict: a payroll period ends on the last day of the month, and a new contribution cohort's effective date falls three days before that. Two cohorts now straddle the period. Which one is used for the run — and if the new cohort's effective date were exactly the period end date, would it apply?
The new one, and yes. The as-of date is the period end, and the rule is "greatest effective date
not after the as-of date", so a cohort that took effect any time on or before the period end is in
force for the whole run. There is no proration inside a period: a run resolves its cohort once,
from one date, and every payslip in it uses that table. And because the comparison is <=, a
cohort effective exactly on the period end date does apply — the date boundary is inclusive even
though the tax bracket boundary is exclusive at the bottom.
The pin, and what it cannot do
Settings carry four optional version pins — one each for tax, SSS, PhilHealth, and Pag-IBIG. The comment on those columns is unusually careful about their status: date-based effective-date selection is authoritative, and a pin merely overrides the as-of date. One static method implements the whole feature:
/**
* As-of date for a rate lookup: a table-version pin that parses as a 4-digit year
* pins the cohort to Dec 31 of that year; otherwise the payroll period end governs.
*/
static LocalDate resolveAsOf(String versionPin, LocalDate periodEnd) {
Read what the pin is not. It is not a foreign key to a cohort, and it cannot name a cohort that
does not exist. It resolves to a date, and that date goes through the same
greatest-effective-date-not-after rule as any other lookup — so a pin naming a year with no cohort
of its own resolves to whatever was in force at the end of it. Null, blank, or non-numeric means
automatic. And resolveAsOf is package-private and static: a pure function of two inputs,
unit-testable without a database, exactly the shape lesson 17 argued for.
The incident that produced a validator
ContributionRateCoverageValidator is a final class with a private constructor and one entry
point, and its javadoc opens with a war story instead of a description. A prior incident left the
live PhilHealth table with a single bracket covering one narrow band of salaries; every other
salary fell through the lookup, and the javadoc's phrase for what happened next is worth
memorising — "silently sending every other salary through a wrong hardcoded default." Not a
crash. Correct-looking payslips, wrong money, for everyone outside one band.
The class exists so no create, update, or delete on any of the four tables can leave that state behind. Its second paragraph is the design:
* Tables are versioned by effective date, so validation runs per cohort: every row set
* sharing one effective date must independently cover the full salary range. Deleting a
* cohort's last row removes the whole cohort, which is allowed as long as at least one
* cohort remains.
Per cohort, not per table, and that follows directly from the lookup rule. A run resolves exactly one cohort and never mixes rows across effective dates, so a cohort covering only part of the salary range is a hole for every run that lands on it — even if a neighbouring cohort would have covered it. The validator groups rows by effective date and checks each group independently for overlaps, for gaps between adjacent brackets, for a floor above zero, and for a ceiling below the practical maximum.
The delete rule is the pragmatic exception. Removing a cohort's last remaining row does not create a gap; it retires the cohort entirely, which is allowed as long as one cohort survives. Delete the last row of the last cohort and the validator refuses: a table with no rows covers nothing.
One implementation detail matters more than it looks. The rate services do not validate the current table; they build the ranges the table would have after the change — existing rows, minus the row being edited or deleted, plus the proposed row — and validate that. Validation runs before the write, on the hypothetical result, so an invalid edit is rejected rather than rolled back.
The gate at generation time
Editing tables is one risk. Running payroll against a table that was already wrong is the other,
and the run path has its own check. From PayrollServiceImpl:
/**
* Fails fast, naming every affected employee, instead of aborting mid-run on
* whichever employee happens to iterate first when a salary falls outside every
* seeded contribution bracket.
*/
validateContributionBracketCoverage resolves the as-of date for each of the three contribution
tables through resolveAsOf, walks every active employee trying the primary lookup and the
earliest-cohort fallback, and collects a line per miss. Only after the whole sweep does it throw —
one exception carrying every failure:
Cannot generate payslips: <employee number> (<name>): no SSS contribution bracket
covers monthly salary <amount>; <employee number> (<name>): no PhilHealth ...
It runs before the payslip-creation loop, not inside it, and that ordering is the point of the javadoc: a check inside the loop aborts on whichever employee iterates first, so an operator fixing one bracket reruns and discovers the next victim, one at a time.
Predict: a gap opens in the PhilHealth table and three employees' salaries fall into it. The run is generated. Do those three get wrong payslips, or does nobody get a payslip?
Nobody gets a payslip. The gate runs ahead of generation inside the transactional generate method,
so the throw rolls back everything and the run stays in Draft — never a partial batch where most
of the company is paid and three people are missing. The message names all three by employee number
and name, so the operator sees the full extent of the problem in one read. Behind that gate, the
calculators keep their own orElseThrow naming the salary and the as-of date, as a backstop.
Fail loud, by name
Every decision in this lesson leans the same way. The lookup falls back deterministically rather than returning null. The overlap wrapper picks a documented row rather than throwing at random. The validator rejects an edit before it lands. The run refuses to generate rather than generating something plausible. And the failure message names people, not row IDs — because for payroll a wrong answer is a wrong bank transfer, a wrong statutory remittance, and a correction run three weeks later. The original incident did not crash, which is precisely why it survived long enough to become a war story in a javadoc. Loud failure is cheap; silent wrongness is not.
Where this shows up in MotorPH
- SssContributionRate.java and WithholdingTaxBracket.java — the two entity shapes; the PhilHealth and Pag-IBIG entities mirror the first, and TaxPayPeriod.java explains the per-pay-period tax tables.
- SssContributionRateRepository.java
— the cohort comment and the first-match
defaultwrapper; the PhilHealth and Pag-IBIG repositories repeat it verbatim. - WithholdingTaxBracketRepository.java — the boundary convention and per-pay-period cohorts.
- ContributionRateCoverageValidator.java — the war story and the per-cohort coverage rules; SssContributionRateServiceImpl.java is one of its four callers.
- PayrollServiceImpl.java
—
resolveAsOfandvalidateContributionBracketCoverage. - ../business-rules.md — every rate, bracket, and amount this lesson
deliberately never states; ../backend/entities-and-migrations.md
covers the migrations,
V63among them, that shaped these tables. - ../api/payroll.md — the endpoints that edit rate tables and start runs.
Recap
- Statutory rules are rows, not code. Four entities share one shape — a range plus an effective date — and the values belong to the business-rules reference.
- Cohorts resolve by greatest effective date not after the as-of date. A run picks one cohort from its period end and uses it for every payslip; tax cohorts resolve per pay period so a partial republication never hides the other tables.
- Boundaries are decided, not assumed. Tax brackets match
from < income <= tobecause adjacent brackets share a value; contribution lookups use inclusivebetweenplus an ordered first-match wrapper so an accidental overlap degrades deterministically instead of throwing. - Coverage is validated per cohort, before the write. The rate services validate the table the change would produce; each cohort must independently cover the whole range.
- Generation fails loud and by name. The pre-loop gate collects every uncovered employee and throws once, naming all of them, so nobody gets a wrong payslip and one pass fixes the table.
Next: 19 — Withholding and the true-up: why the final cutoff is different.