The Requirement: BPV Sector Pension
The Dutch transport sector (Beroepsvervoer) mandates participation in the BPV sector pension fund. Every employer in this sector must withhold employee pension contributions and pay employer contributions on top of gross salary. The contribution formula depends on the employee’s pensionable earnings (pensioengrondslag), a franchise amount that is deducted before applying the rate, and an annual ceiling.
The country regulation (NL.Loonheffing) ships with pension wage types that act as stubs: WT 5200 (employee pension contribution) and WT 6799 (employer pension contribution) both return zero. They exist in the country regulation because pension wage types must participate in the correct collector chain — employee pension deductions reduce taxable income, and employer contributions must appear in the employer cost overview. But the actual formula varies by sector, so the country regulation cannot implement it.
A provider serving transport companies needs to override these stubs with the BPV pension formula. This is exactly the use case the four-layer regulation model was designed for — provider-specific logic overriding country-level stubs without modifying the country regulation itself.
What follows is a step-by-step implementation that touches every concept from the series: data satellites for rate lookups, namespace-isolated provider regulations, stub overrides via the layer model, Custom Actions for the calculation logic, and integration tests that prove the numbers.
Step 1: The Data Satellite
BPV pension rates change annually. The franchise amount, the ceiling, and the employee/employer contribution rates are published each year by the pension fund. Hardcoding these values into the calculation logic would require a code change every January. Instead, we store them in a data satellite — a separate regulation that contains only lookup tables with rate data, keyed by year.
The data satellite is named NL.Loonheffing.Data.BPV. Its baseRegulations points to the country regulation, placing it in the same inheritance chain:
{
"name": "NL.Loonheffing.Data.BPV",
"namespace": "NL",
"description": "BPV sector pension fund rate data",
"baseRegulations": ["NL.Loonheffing"],
"validFrom": "2026-01-01",
"lookups": [
{
"name": "BPVPensionRates",
"description": "Annual BPV pension parameters by year",
"lookupValues": [
{
"key": "2026",
"value": {
"Franchise": 17545,
"Ceiling": 71628,
"EmployeeRate": 0.0482,
"EmployerRate": 0.1157
}
},
{
"key": "2025",
"value": {
"Franchise": 16711,
"Ceiling": 68571,
"EmployeeRate": 0.0467,
"EmployerRate": 0.1122
}
}
]
}
]
}
The lookup is structured as a single entry per year. Each entry contains all the parameters the pension calculation needs. The key is the year as a string; the value is a JSON object with named fields. This structure allows the Custom Action to load all parameters in one GetLookup call and access individual fields by name.
validFrom for versioning. When 2027 rates are published, you create a new data satellite with validFrom: "2027-01-01" and add the 2027 entry. The engine resolves the correct satellite based on the payrun period. The calculation logic in the provider regulation does not change — only the data does.
The satellite must be imported before the provider regulation and registered in the payroll’s layer stack. Both Setup.Test.pecmd (for test import) and the payroll’s Test.Setup.json (for layer resolution) must include it. Missing either step is a common cause of GetLookup returning null at runtime — as described in Lookups and Data Satellites.
Step 2: The Provider Regulation
The provider regulation is named NL.Loonheffing.Demo.BPV. It sits in layer 2 (the provider layer) and declares both the country regulation and the data satellite as base regulations:
{
"name": "NL.Loonheffing.Demo.BPV",
"namespace": "NL",
"description": "BPV sector pension provider overlay",
"baseRegulations": [
"NL.Loonheffing",
"NL.Loonheffing.Data.BPV"
]
}
The baseRegulations array establishes the inheritance chain. The provider regulation can read all case fields, wage type results, collectors, and lookups from both the country regulation and the data satellite. It can also override any object from the country regulation by declaring an object with the same name or number in its own layer.
Note that the namespace is still NL. The provider regulation operates within the same namespace as the country regulation because it overrides country-level wage types. If it used a different namespace (e.g., ACME), the wage type numbers would not collide and no override would occur. Namespace identity is what makes stub activation work.
The regulation defines three wage types in its overlay: WT 5200 (employee pension contribution), WT 6799 (employer pension contribution), and WT 50 (fiscal wage adjustment for pension deduction). All three use the same number as the country stubs they override.
Step 3: Overriding the Pension Stubs
The country regulation’s WT 5200 is a stub — it exists to hold the collector wiring (feeding the pension deduction into the net pay chain) but returns zero. The provider override replaces the calculation while inheriting the collector assignments from the country layer.
Here is the provider’s WT 5200:
{
"wageTypeNumber": 5200,
"name": "PensioenWerknemer",
"description": "BPV employee pension contribution",
"valueActions": [
"NLCalculatePensioenWerknemer('NL.PensioenGrondslag')"
]
}
The valueActions array contains a single line: a call to the Custom Action NLCalculatePensioenWerknemer. This is the pattern described in Custom Actions — when the calculation requires lookup access and arithmetic that exceeds what No-Code tokens can express, you delegate to a C# method.
The employer contribution follows the same pattern:
{
"wageTypeNumber": 6799,
"name": "PensioenWerkgever",
"description": "BPV employer pension contribution",
"valueActions": [
"NLCalculatePensioenWerkgever('NL.PensioenGrondslag')"
]
}
And the fiscal wage adjustment — the employee pension deduction reduces the taxable income (fiscaal loon). WT 50 applies this reduction:
{
"wageTypeNumber": 50,
"name": "FiscaalLoonCorrectie",
"description": "Pension deduction reduces taxable income",
"valueActions": [
"? ^$5200 > ^|0",
"^$5200 * ^|-1"
],
"collectors": [
"NL.FiscaalLoon"
]
}
WT 50 uses pure No-Code: it checks whether the employee pension contribution (WT 5200) produced a positive result, and if so, negates it and feeds the negative value into the NL.FiscaalLoon collector. This reduces the tax base by the pension deduction amount. No Custom Action needed — it is a simple arithmetic transformation of another wage type’s result.
NL), the engine’s layer resolution picks the provider version. The country stub is completely replaced. The collector wiring from the country layer is preserved unless the provider explicitly overrides it.
Step 4: The Pension Calculation
The Custom Action implements the actual BPV pension formula. It loads the rate data from the data satellite, reads the pensionable earnings from a preceding wage type, applies the franchise deduction, caps at the ceiling, and computes the employee share.
using System;
#pragma warning disable IDE0130
namespace PayrollEngine.Client.Scripting.Function;
#pragma warning restore IDE0130
public partial class WageTypeValueFunction
{
[WageTypeValueAction("NLCalculatePensioenWerknemer")]
public ActionValue NLCalculatePensioenWerknemer()
{
var grondslag = (decimal)WageType[2990];
if (grondslag <= 0m)
return ActionValue.Stop(0m);
var year = PeriodStart.Year.ToString();
var rates = GetLookup<BPVRates>("BPVPensionRates", year);
if (rates == null)
{
LogWarning($"BPVPensionRates not found for {year}");
return ActionValue.Stop(0m);
}
var annualGrondslag = grondslag * 12m;
var cappedGrondslag = Math.Min(annualGrondslag, rates.Ceiling);
var pensioenBasis = Math.Max(cappedGrondslag - rates.Franchise, 0m);
var annualContribution = pensioenBasis * rates.EmployeeRate;
var monthlyContribution = Math.Round(annualContribution / 12m, 2);
return new ActionValue(monthlyContribution);
}
private sealed class BPVRates
{
public decimal Franchise { get; set; }
public decimal Ceiling { get; set; }
public decimal EmployeeRate { get; set; }
public decimal EmployerRate { get; set; }
}
}
Let’s walk through the key elements:
WageType[2990] reads the result of WT 2990 (PensioenGrondslag), which calculates the pensionable earnings base. Since wage types execute in numeric order, WT 2990 has already completed by the time WT 5200 runs. The indexer returns object, so a cast to decimal is required.
GetLookup<BPVRates>("BPVPensionRates", year) loads the full lookup entry for the current year and deserializes it into the BPVRates helper class. This is the typed lookup pattern — the JSON value object maps directly to C# properties. The null check is mandatory: if the data satellite is not in the payroll’s layer stack or the year key does not exist, GetLookup returns null.
The formula: annual pensionable earnings capped at the ceiling, minus the franchise (tax-free threshold), multiplied by the employee rate, divided by 12 for a monthly amount. Math.Round(..., 2) ensures cent-precision — without rounding, floating-point accumulation errors propagate into downstream wage types.
private sealed class BPVRates is declared inside the partial class, not at the top level. This is required by the Payroll Engine script compiler — file class and top-level classes trigger CS9068. The sealed modifier is a best practice to prevent accidental inheritance.
NLCalculatePensioenWerkgever) follows the identical pattern but uses rates.EmployerRate instead of rates.EmployeeRate. In practice, both actions can share the helper class by defining them in the same partial class file.
Step 5: Proving the Numbers
Every regulation change requires a passing integration test before it ships. The BPV overlay is no exception. The test proves that the full payroll stack — country regulation, data satellite, and provider overlay — produces the correct pension amounts for a given employee scenario.
The test is named WT-TC5200-NL-BPV-PensioenWerknemer. It follows the standard test structure: an .et.json file with the test employee, case values, payrun invocation, and expected results.
The employee scenario:
| Parameter | Value |
|---|---|
| Monthly gross salary | 4,000 EUR |
| PensioenGrondslag (WT 2990) | 4,000 EUR |
| BPV rates (2026) | Franchise 17,545 / Ceiling 71,628 / Employee 4.82% |
| Annual gross | 48,000 EUR |
| Capped at ceiling | 48,000 EUR (below 71,628) |
| Minus franchise | 48,000 - 17,545 = 30,455 EUR |
| Annual contribution | 30,455 * 0.0482 = 1,467.93 EUR |
| Monthly contribution | 1,467.93 / 12 = 122.33 EUR |
The expected result for WT 5200 is 122.33. Here is the core of the .et.json test file:
{
"createdObjectDate": "2025-01-01",
"employees": [
{
"identifier": "BPV-Test-01",
"firstName": "Jan",
"lastName": "Transport",
"caseChanges": [
{
"caseName": "NL.Employment",
"values": [
{
"caseFieldName": "NL.EntryDate",
"value": "2025-06-01",
"start": "2025-06-01",
"created": "2025-05-01"
}
]
},
{
"caseName": "NL.Salary",
"values": [
{
"caseFieldName": "NL.MonthlySalary",
"value": "4000",
"start": "2026-01-01",
"created": "2025-12-01"
}
]
}
]
}
],
"payrunJobInvocations": [
{
"name": "BPV-Jan2026",
"payrunId": "NL.Payrun",
"evaluationDate": "2026-01-15",
"payrollResults": [
{
"employeeIdentifier": "BPV-Test-01",
"wageTypeResults": [
{
"wageTypeNumber": 5200,
"value": 122.33
}
]
}
]
}
]
}
The payroll stack for the test is defined in Test.Setup.json. It declares all three layers that the payroll engine must resolve during the test run:
{
"payrolls": [
{
"name": "NL.Payrun",
"layers": [
{ "level": 1, "regulationName": "NL.Loonheffing" },
{ "level": 1, "regulationName": "NL.Loonheffing.Data.BPV" },
{ "level": 2, "regulationName": "NL.Loonheffing.Demo.BPV" }
]
}
]
}
Note the layer levels: the country regulation and the data satellite share level 1 (they are peers in the base layer), while the provider overlay sits at level 2. This is how the engine knows to resolve WT 5200 from the provider layer rather than the country stub.
Setup.Test.pecmd but not listed in the payroll layers array, the GetLookup call in the Custom Action returns null. The wage type produces zero, the test fails, and the error message gives no hint about the missing layer. Always verify both the import pipeline and the layer stack.
The Complete Picture
Stepping back, here is the full architecture of the BPV pension overlay:
Layer 1 (Country)
NL.Loonheffing
WT 2990 PensioenGrondslag (pensionable earnings base)
WT 5200 PensioenWerknemer (stub - returns 0)
WT 6799 PensioenWerkgever (stub - returns 0)
Collectors: NL.FiscaalLoon, NL.NettoLoon, ...
Layer 1 (Data Satellite)
NL.Loonheffing.Data.BPV
Lookup: BPVPensionRates (franchise, ceiling, rates by year)
Layer 2 (Provider Overlay)
NL.Loonheffing.Demo.BPV
WT 50 FiscaalLoonCorrectie (pension deduction from taxable income)
WT 5200 PensioenWerknemer (overrides stub - BPV formula)
WT 6799 PensioenWerkgever (overrides stub - BPV formula)
Scripts: NLCalculatePensioenWerknemer, NLCalculatePensioenWerkgever
The execution flow during a payrun: the engine processes wage types in numeric order across all layers. WT 50 runs early (fiscal wage correction). WT 2990 computes the pensionable earnings base. WT 5200 resolves to the provider override (layer 2 wins over layer 1), calls the Custom Action, loads BPV rates from the data satellite lookup, and returns the employee contribution. WT 6799 follows the same pattern for the employer share. Downstream wage types (net pay, employer cost totals) automatically incorporate the pension amounts through the collector chain.
What the provider ships is a NuGet package containing three files: the regulation JSON (wage types + scripts references), the Custom Action C# files, and the integration tests. The data satellite ships separately — either as its own NuGet package or bundled with the provider regulation, depending on the update cycle.
The annual update process is minimal: when BPV publishes new rates for 2027, the provider updates the data satellite with a new lookup entry. The provider regulation, the Custom Action code, and the tests remain unchanged. Only the expected test values need updating to reflect the new rates. This separation of data from logic is the core advantage of the data satellite pattern.
This case study has touched every concept in the Regulation Development series:
- The Four-Layer Model — country stubs, provider overrides, layer resolution
- No-Code Wage Types — WT 50 fiscal correction with pure token arithmetic
- Cases and CaseFields — employee case data feeding wage type inputs
- Lookups and Data Satellites — annual rate data in a separate regulation
- Collectors — pension values flowing into fiscal and net pay chains
- Stub Activation — overriding country zero-stubs with provider logic
- Custom Actions — C# calculation with typed lookup access
- Testing — integration test proving the end-to-end calculation
- Reporting — pension values accessible in provider reports via the DataSet
The BPV overlay is a representative example, but the pattern applies universally. Industry pension funds in Germany (VBL, ZVK), supplementary social security in Belgium (sectorfonds), and company-specific benefit schemes all follow the same architecture: data satellite for rates, provider overlay for logic, stub activation for integration, tests for proof.
For more on how providers manage compliance across the full regulation lifecycle, see Provider Compliance. To explore the available country regulations and their stub inventories, visit the Country Regulations product page.
Build your first overlay
From stub to payslip in one afternoon. See how the layer model, Custom Actions, and test-driven development come together — or get in touch to plan your provider regulation.
Get in Touch →