Skip to main content

20 — Settings, reports, and the edges of the engine

Read this first: the last five lessons walked the engine down the middle — runs, payslips, the pure core, the cohorts, the true-up. This one walks its edges: where a configured value comes from when nobody configured it, which payroll-adjacent features are deliberately not runs, and what this codebase does when two of its own outputs legitimately disagree.

Time: about 30 minutes. Assumes lesson 19.

One row, and what lives in it​

PayrollSettings is a single-row table per tenant. Its class javadoc is two sentences, and the second one is the reason this lesson exists:

/**
* Single-row table of configurable payroll computation parameters.
* Field initializers double as the statutory defaults used when no row exists yet.
*/

The entity is long and this lesson will not enumerate it — the values are business-rules.md's job, the field roster the source's. What you need is the shape, which is four groups:

  • Premium multipliers — the factors for ordinary overtime, the night window, rest days, and the two classes of holiday, plus their combinations.
  • Schedule anchors — pay frequency, the cutoff windows for the calendar-aligned frequencies, the recurring pay days, the anchor date weekly pay needs because it is not calendar-aligned, and the lag between a period ending and wages being released.
  • Policy switches — attendance and rounding, holiday eligibility, leave-and-holiday interaction, the clamps on bracket-derived contributions, the table-version pins from lesson 18, and presentation flags down to the company logo.
  • Master feature switches, and a small governance block: version auto-increments on save, isLocked rejects edits until released, approvedBy names who signed off, effectiveDate records when the configuration takes effect. Nothing here computes; every field is an input that some other file reads.

Defaults at read time, defaults at compute time​

Now the paragraph. PayrollSettingsServiceImpl opens with a javadoc that is not describing what the method does — it is defending a deliberate inconsistency with the rest of the backend:

/**
* The read paths in this service deliberately keep falling back to a defaults object when a
* tenant has no settings row, unlike the payroll and reporting services, which now refuse.
*
* <p>The distinction is what the value is used for. Computing a payslip from defaults produces
* a wrong number that looks right, so it must fail loudly. Rendering the settings screen from
* defaults produces the form an administrator needs in order to save real settings -- making it
* throw would leave a half-provisioned tenant locked out of the one page that could fix them.
*/

In effect the halves are one line of code apart. The settings read does .orElseGet(PayrollSettings::new) — no row, so build a transient entity, let the initializers fill it, map it to a DTO. Reporting, needing the same singleton for a statutory figure, does .orElseThrow(...) with MissingTenantSettingsException.

The asymmetry is the point. A default that becomes a form field is a suggestion; a default that becomes an amount on a filed form is a lie with a decimal point. A blank settings screen is recoverable in ten seconds by the person looking at it. A payslip computed from a multiplier nobody chose looks exactly like one computed from a multiplier somebody did — and keeps looking like it until an employee or an auditor finds it.

Predict: you edit one of the multiplier initializers in PayrollSettings.java, redeploy, and the tenants have been running payroll for a year. Which historical numbers move?

None of them, for three independent reasons worth being able to state. Any tenant that has ever saved settings has a row, and the row is what every compute path loads — the initializer is unreachable for them. Generated payslips carry the amounts they were generated with; a regeneration would recompute, but from that saved row, not from your edit. And for a tenant with no row, the compute paths do not quietly substitute your new value — they throw. The initializer you changed is reachable by one kind of caller: the one drawing a form somebody is about to overwrite anyway.

That is why the two treatments of a missing row are not an inconsistency to tidy up. They are the same rule applied to two different consequences: fail loudly where a wrong value would be believed, fall back where a wrong value will immediately be replaced.

A switch that gates issuing, not possession​

Among the booleans are a few master switches — flags that do not adjust a computation but decide whether a whole subsystem participates. One turns custom deductions off for every run at once, whatever the individual assignments say. One controls whether the year-end true-up from lesson 19 applies at all. One carries the comment worth copying whenever you add a feature flag:

// Master switch for Recognition & Awards: when off, awards cannot be generated or
// deleted and the feature's UI stays hidden. Already-issued certificates remain
// downloadable — the switch gates issuing, not possession.

Read the last clause as a design rule. A flag that revokes what was already handed out is not a toggle but a retraction: an employee holding a certificate link gets a 404 because an administrator changed an unrelated setting. So the switch sits on the verbs — generate, delete, show the entry point — never on the artefact.

The same caution appears one layer down, in the migration that introduces the column: added NOT NULL with a default of false, so every existing tenant lands with the feature off. That is the choice for a brand-new feature with external side effects — this one mails winners. Defaulting to true would mean the first deploy after the migration could send mail on behalf of tenants who never asked for the feature. Tenants opt in.

Three things that are deliberately not runs​

The word "payroll" attaches itself to features with nothing to do with the run lifecycle from lesson 16. Knowing these three are not runs saves a hunt for a run type that does not exist.

Thirteenth-month pay is a report. GovernmentFormsService exposes it as a year query that reads each employee's basic-pay history out of the payslip table and derives the figure. There is no thirteenth-month run, no thirteenth-month payslip, and no run-type enum anywhere in the engine — a run is a Payroll row with a period, and that is the only kind. A second method wraps the same rows with the establishment's details for the DOLE report. Both read history that already exists.

Expanded withholding tax is a parallel ledger. EwtPayee is a supplier, contractor, or professional the company withholds creditable tax from; EwtTransaction is one month's withholding against one payee under one ATC code, its covered month normalised to the first day. Together they feed the BIR forms named in the entity javadoc. Search the EWT services for Payroll or Payslip and you get nothing — hand-entered, no employee, no run, no payslip touchpoint. It shares the payroll module because it shares the filing calendar, not the engine.

The compliance checklist is a read that judges. getComplianceChecklist loads the settings singleton and each rate repository, then builds rows through PayrollComplianceChecks — one per rule, each a key, a label, an OK-or-warning status, and a detail sentence. The detail is what makes it useful: a failing row does not say "invalid", it says what will go wrong in the operator's terms — a missing anchor means a month's final cutoff may be inferred wrongly; an empty holiday calendar means holiday premiums will not apply. It changes nothing, and it is the cheapest way to ask a running system whether it is configured at all.

When two outputs legitimately disagree​

Now the passage this lesson is really for. In GovernmentFormsServiceImpl, the method building the monthly 1601-C figures carries a sixteen-line comment. It opens by admitting what it cannot do:

// 17 is capped per employee per year at PHP 90,000; monthly cap tracking is not
// possible from a single month's view, so the full bonus amount is reported
// non-taxable here, consistent with how payroll runs never taxed bonuses.

Then, before you can conclude the cap is unimplemented, it says loudly that it is not:

// The annual cap DOES exist and IS enforced - see ReportingServiceImpl#getBirAlphalist,
// which sources the same BonusRepository.findYearToDateBonusForAllEmployees(year) and
// splits it at PHP 90,000 into columns 7b (non-taxable) / 7h (taxable excess). This
// method intentionally does not reuse that logic: it only has this month's aggregate,
// not the employee's running YTD bonus total needed to know how much of the cap is
// already used up. Consequence: for an employee who crosses PHP 90,000 in bonuses

And it closes by naming the authority and the price of changing it:

// during the year, this month's 1601-C and the year-end 1604-C alphalist / 2316 for
// the same employee will legitimately disagree on the non-taxable bonus figure - the
// annual forms are authoritative. If strict monthly-vs-annual consistency is ever
// required, this method would need to compute each employee's prior-YTD bonus (as of
// the month before `month`) and only treat the remaining headroom under 90,000 as
// non-taxable, rather than aggregating a flat monthly total across all employees.

Count what that comment does, because it is the standard this codebase holds itself to. It states the divergence rather than hiding it. It pre-empts the wrong fix — a reader who spots the missing cap and "fixes" it by calling the annual method would wire a year's aggregate into a month's form. It gives the reason, structural and not a shortcut: a single month's view cannot know how much of an employee's annual headroom is spent. It says which output wins, so support has an answer instead of a bug report. And it scopes the work to converge.

When two outputs legitimately disagree, the code says so at the site of the divergence — not in a ticket, not in a wiki, but at the line where the reader will first be confused. The rules behind the figures stay in business-rules.md and bir-1601c.md; the reasoning belongs in the source.

The PDF the backend refuses to draw​

Two tables hold payslip designs, and their split is the whole design:

/**
* A named, tenant-owned payslip template saved from the Designer. The template payload is an
* opaque pdfme JSON string owned by the frontend — the backend stores and returns it verbatim.
* This is a library of reusable designs; the currently-active template is still the single
* {@link PayslipTemplateSettings} row, which "apply" overwrites with a library row's payload.
*/

PayslipTemplate is the library — many named rows per tenant, unique by name. PayslipTemplateSettings is the singleton the renderer reads, and "apply" copies a library row's payload into it, so adding a library did not change what generation reads: one active design, in one place, older clients unaffected. That singleton carries its own version and isLocked — the governance shape again — and a null payload means the frontend falls back to a bundled design.

Predict: the backend has a PDF library on the classpath — it renders award certificates with it. Why does it not render the payslip PDF too?

Because the designer and the renderer are the same library, and the designer runs in a browser. Rendering a drag-and-drop payload faithfully means running the library that produced it, against the same field names. Put a second renderer on the server and you own two implementations of one layout that must agree pixel for pixel forever. Instead the backend treats the payload as opaque: stores the string, returns the string, never parses it. The column is a plain TEXT blob and the javadoc says verbatim. Frontend 101 has the client half, including how a payslip's rows map into the template's fields: 12 — Employees: drawers and exports.

Where this shows up in MotorPH​

Recap​

  • A default is a read-time convenience, never a compute-time input. The settings read falls back to the entity's initializers so an administrator gets a form; the payroll and reporting paths throw instead, because a wrong number that looks right is worse than an error.
  • Changing a default in code moves nothing historical. Saved rows win, generated payslips keep their amounts, and tenants with no row get an exception rather than your new value.
  • A master switch gates issuing, not possession — and a brand-new feature with external side effects ships default-off in its migration, so tenants opt in rather than get opted in.
  • Not everything payroll-adjacent is a run. Thirteenth-month pay is a report over payslip history, EWT is a hand-entered ledger with no payslip touchpoint, and the compliance checklist is a read that judges configuration without changing it.
  • When two outputs legitimately disagree, say so at the site: name the reason, name which one is authoritative, and name what convergence would cost — the 1601-C comment does all three.

Next: 21 — The test harness: containers, tenants, and honest exclusions.