17 — The pure core: a calculator and a planner with no dependencies
Read this first: inside the backend's densest service sit two small files that import
nothing from Spring — no annotations, no repositories, no clock reads. DailyPayCalculator
(about 240 lines) and PayPeriodPlanner (about 220 lines) hold the arithmetic that decides what
an employee is paid and which dates they are paid for. This lesson decodes the decision — why
those two are pure — and deliberately never states a multiplier, a premium, a night-window
boundary or a rounding rule. Those numbers are statutory or configured; they live in
the business-rules reference, and the two test files named at the end pin
every one of them.
Time: about 30 minutes. Assumes lesson 16.
Purity is the design decision
Both classes are declared final with no access modifier — package-private, visible only inside
com.motorph.payroll.service.impl. Neither carries @Component or @Service. The Spring context
does not know they exist.
Everything that could vary is handed in. The calculator takes the settings singleton, the period's
holiday map and the employee's rest days at construction, and hours and an hourly rate per call.
The planner is all-static behind a private constructor, and takes today as a parameter — its
javadoc says the reference date is injected precisely so the logic stays testable. The service reads
the clock; the planner never does.
So both files satisfy the same contract: same inputs, same outputs, forever. No row is loaded, no row is written, nothing is timed, nothing is logged, and running the code twice cannot disagree with itself.
Predict: what does package-private-with-no-annotation buy that a @Component on the same class
would take away?
Two things. First, nothing outside this package can inject it, so a controller or a report job
cannot start calling the pay math directly and drift into a second interpretation of the same
statutory rules — there is exactly one caller, PayrollServiceImpl, and lesson
16 is the story of the gates it puts in front. Second, a class with no
injection points is constructed, not wired: a test writes new DailyPayCalculator(...) and gets a
fully configured object without a Spring context, a database, or a fixed-clock fixture.
The calculator, by shape
A DailyPayCalculator is built once per employee per run — its rest days come from that employee's
configured rest day and fall back to the settings default — and then answers one question per
worked day. The pipeline is five stages, and their signatures are the whole map:
DayType classify(LocalDate date);
boolean isRestDay(LocalDate date);
BigDecimal dayMultiplier(DayType type, boolean restDay);
BigDecimal overtimeFactor(DayType type, boolean restDay);
BigDecimal nightHours(LocalTime timeIn, LocalTime timeOut);
DayPay computeDay(LocalDate date, BigDecimal dayHours, BigDecimal otHours,
BigDecimal nightHours, BigDecimal hourlyRate);
Read it as a funnel. classify turns a date into a day type by looking it up in the holiday map —
ordinary, special day, regular holiday, or special working day. isRestDay asks whether that
weekday is a rest day for this employee. Those two answers, and only those two, select a day
multiplier and an overtime factor out of settings; the premium matrix they index is statutory
(Labor Code Arts. 86–94), which is exactly why it is configuration read from a row rather than
constants compiled into this file. nightHours measures how much of a shift overlaps the
configured night window, treating a time-out at or before the time-in as a shift that crossed
midnight. computeDay combines them and returns a record:
record DayPay(BigDecimal basePay, BigDecimal overtimePay, BigDecimal nightDiffPay,
BigDecimal holidayPremium, BigDecimal restDayPremium) {
}
Five named components, and the last two are not extra money. An inline comment marks them as a
reporting-only split of the premium over the base rate, for the payslip breakdown — the premium is
already inside basePay; these fields exist so a payslip can show why a day paid more than a
plain day did. Miss that and you will double-count them the first time you write a report.
MoneyRounder is the third file in this set: a five-mode rounder built from settings in the
calculator's constructor, applied to every DayPay component on the way out, and exposed through
rounder() so the service rounds the payslip's other lines the same way.
One comment that draws a boundary
The single most instructive line in either file is not arithmetic. It is inside the lateness adjustment, explaining a bound that looks arbitrary until you read it:
// A clock-in this far past the scheduled start belongs to a different shift
// entirely (e.g. a night-differential shift starting in the evening against a
// morning workStartTime) rather than tardiness against it — this settings model
// has no separate per-shift schedule, so bound lateness to one shift's length.
The settings singleton holds one work start time for the company. An employee on an evening shift clocks in many hours after it, every single day. Without the bound, the honest reading of that data is enormous tardiness, and the lateness deduction would quietly eat a night worker's pay for the crime of working nights. With it, an arrival a full shift-length past the scheduled start is not late — it is a different shift, and lateness does not apply.
This is what an incident-justified boundary looks like: it names the missing capability (no
per-shift schedule), names the case that exposed it (a night-differential shift against a morning
start), and states the rule that holds until the model grows. RecognitionCalculator defers to the
same guard rather than re-deriving it, which is how a boundary stays one boundary.
The planner and the grid
PayPeriodPlanner has two public jobs — suggest proposes the next period so operators do not
hand-compute cutoff dates, and mismatches reports how a proposed period departs from the schedule.
Its class doc explains why the second one is not a nicety:
* <p>The second matters more than it looks: weekly payroll decides which cutoff closes
* the month by walking the anchor grid, and the month's statutory contribution true-up
* rides on that decision. A period that is off-grid or the wrong length can land a month
* with no final cutoff at all, under-deducting SSS/PhilHealth/Pag-IBIG for every
* employee in it.
You met the consequence in lesson 16 from the service's side: the
schedule gate, the confirmation flag, the 422. This is the file that computes the problems that gate
reports. And note the shape of the reporting — mismatches returns a list of sentences, each
naming what is wrong, including the nearest scheduled period end when a period lands off the grid.
A boolean would have let the operator guess.
The grid itself is deliberately unanchored in time: it extends infinitely in both directions from the anchor date, so an anchor dated in the future is as usable as one in the past. That is a purity consequence too — nothing in the derivation asks what today is, so nothing in it can be stale.
The pay date is derived, not observed
The last piece of the planner is the one operators get wrong most often, and its javadoc says why:
/**
* The wage-release date the configured schedule implies for a period ending on
* {@code periodEnd}. Semi-monthly releases on the cutoff's day-of-month pay date —
* a period ending inside the first cutoff window pays on that month's first pay
* date, anything ending later (including free-form full-month periods) pays like
* the second cutoff, in the FOLLOWING month. Weekly and monthly release a fixed
* lag after the period ends.
*/
Predict: an operator falls behind and processes a June period in late August. What payment date lands on the payslip — August, or June's schedule?
June's schedule. payDateFor is a function of the settings and the period end, and nothing else;
the day the button was pressed is not one of its arguments and could not influence the answer if it
wanted to. That is the point of putting the derivation in a pure function: an employee's payslip
says when their wages were due under the configured schedule, and a late run cannot rewrite that
into a story about the operator's calendar.
What purity buys the tests
Two plain JUnit files — no @SpringBootTest, no mocks, no fixtures beyond a settings object:
DailyPayCalculatorTest.java
(about 296 lines) and
PayPeriodPlannerTest.java
(about 281 lines). These are the answer key for everything this lesson refuses to state.
The calculator test picks a round hourly rate on purpose, so every expected value is checkable in your head, and each assertion carries its derivation in a trailing comment. The shape is always the same — one component of the record, one pinned number:
assertThat(pay.overtimePay()).isEqualByComparingTo(/* the value pinned in the test */);
Read them in order and the premium matrix reads back out of the assertions: ordinary day, rest day, special day, special working day, regular holiday, regular holiday falling on a rest day, and overtime and night hours layered on each. One case is worth reading for its javadoc alone — the test that overtime and night hours are capped at hours actually worked explains that without the clamp, a day claiming more overtime than hours drives regular hours negative, and a negative base pay silently nets off the rest of the payslip. That is a test documenting a failure mode, not a formula.
The planner test does the same for dates, and spends its length on the boundaries where date math actually breaks: a month whose cutoffs run five deep, the year boundary, a short February, a leap February, an anchor dated in the future, and a ragged run that has to be caught up to the grid without re-paying days already paid.
Neither suite needs a container to start. That is what purity is worth in practice — change a multiplier in settings and you learn in under a second which day types moved.
Where this shows up in MotorPH
- DailyPayCalculator.java
— the per-day pipeline, the
DayPayrecord, and the shift-span comment quoted above. - PayPeriodPlanner.java
—
suggest,mismatches, the anchor-grid helpers, andpayDateFor. - MoneyRounder.java — five configured modes, one place.
- DailyPayCalculatorTest.java and PayPeriodPlannerTest.java — the answer keys.
- RecognitionCalculator.java — a third pure calculator, written to the same rules and reusing the shift-span guard.
- PayrollServiceImpl.java — the only caller; lesson 16 reads it.
- ../business-rules.md — every multiplier, premium, window and rounding rule this lesson points at without stating.
Recap
- The two computational hearts have no dependencies. Package-private, no Spring annotations,
nothing loaded or written, and even
todayarrives as a parameter — so the same inputs always produce the same outputs. - Package-private is a design boundary, not tidiness. Nothing outside the package can inject the pay math and start a second interpretation of the same statutory rules.
- The premium matrix is configuration, not code.
classifyandisRestDayselect a multiplier and an overtime factor from settings; the numbers belong to the business-rules reference. - Two of
DayPay's five components are reporting-only. The holiday and rest-day premiums are a split of whatbasePayalready contains — adding them again double-counts. - The tests are the answer key. A round rate, one pinned value per assertion, and boundary cases for the date grid; when you want the arithmetic, open the test rather than the lesson.
Next: 18 — Compliance is data: cohorts, effective dates, and a validator that names names.