When You Need Code
The No-Code token system handles straightforward arithmetic beautifully. Rate times base, cap at ceiling, round to two decimals — the ^^, ^$, ^# tokens express these calculations in a readable, auditable form that payroll consultants can verify against statutory text. But tokens have limits, and those limits surface in predictable patterns.
The first signal is the ^# lookup token failing as an arithmetic operand. A token expression like ^$1000 * ^#ContributionParameter(PeriodStartYear, 'Rate') fails silently — the lookup value doesn't resolve into a numeric operand that can be multiplied. The result is zero or undefined, with no error message. The wage type simply produces no output, and you spend hours tracing why.
The silent failure pattern: ^# is only reliable as a standalone expression or when the lookup returns a single value used directly. The moment you need a lookup value as one factor in a multiplication, the token system can't help you. This is by design — the token evaluator doesn't compose lookup resolution with arithmetic in a single pass.
The second signal is time-segmented multi-field calculations. When an employee's salary changes mid-month and you need to multiply the pro-rated salary by a percentage rate that also changed mid-month, GetCaseValue<T> collapses all sub-periods to a single scalar before any arithmetic happens. The result is a weighted average that doesn't match the per-sub-period formula the law requires.
The third signal is complex conditional logic. A garnishment calculation that must evaluate creditor priority, protected amounts based on dependent count, multiple income sources, and statutory exemptions — this is a decision tree, not a linear formula. Expressing it in conditional tokens (? condition : value) produces an unreadable wall of nested expressions that no one can audit or maintain.
These three signals — lookup arithmetic, time-segmented calculations, and conditional branching — are the boundary markers. When you hit one, you need a Custom Action.
| Signal | What happens with tokens | What you need |
|---|---|---|
^# as arithmetic operand |
Silent failure — result is zero or missing | GetLookup<T> in C# with explicit multiplication |
| Multi-field time segmentation | GetCaseValue collapses sub-periods to scalar |
GetCaseValues preserving per-segment arithmetic |
| Multi-branch conditional logic | Nested ? tokens become unreadable |
C# if/switch with named variables |
| Iterative algorithms | Not expressible in tokens at all | C# loops with convergence logic |
Anatomy of a Custom Action
A Custom Action is a C# method that the Payroll Engine compiles at runtime via Roslyn and invokes by name from the valueActions array. It lives in a .Action.cs file alongside the regulation JSON. Here's a complete example — an employer pension contribution that reads a grondslag (base) from another wage type and applies a rate from a versioned lookup table:
using System;
#pragma warning disable IDE0130
namespace PayrollEngine.Client.Scripting.Function;
#pragma warning restore IDE0130
public partial class WageTypeValueFunction
{
[WageTypeValueAction("ACMECalculatePension")]
public ActionValue ACMECalculatePension()
{
var grondslag = (decimal)WageType[2990];
var rates = GetLookup<PensionRates>("PensionConfig", PeriodStart.Year);
if (rates == null) return ActionValue.None;
var result = Math.Round(grondslag * rates.EmployeeRate, 2);
return new(result);
}
private sealed class PensionRates
{
public decimal EmployeeRate { get; set; }
public decimal EmployerRate { get; set; }
}
}
Every line in this file follows a mandatory pattern. Let's break it down.
The namespace is non-negotiable. namespace PayrollEngine.Client.Scripting.Function; must appear exactly as shown. Without it, every PE runtime member — WageType[], GetCaseValue, PeriodStart, GetLookup, LogWarning — is unknown to the compiler, producing CS0103 errors. The #pragma warning disable IDE0130 suppresses the IDE's namespace-doesn't-match-folder warning, which is irrelevant for runtime-compiled scripts.
The partial class declaration. public partial class WageTypeValueFunction merges your method into the engine's existing function class. This gives you access to all scripting API members as instance members — no imports, no dependency injection, no constructor. The class must be partial; the engine provides the other half.
The attribute. [WageTypeValueAction("ACMECalculatePension")] registers the method as a callable action. The string name is what appears in valueActions in the regulation JSON. Convention: prefix with a company/country code (ACME, DE, NL) to avoid cross-regulation name collisions.
The return type. ActionValue — not decimal, not void. Return new ActionValue(result) for a computed value, or ActionValue.None to signal "this wage type produces no result for this period" (the wage type row won't appear on the payslip).
Helper classes as private sealed. The PensionRates class is declared private sealed inside the partial class. Not file class (which produces CS9068 in Roslyn scripting), not a top-level class (which conflicts with the namespace). Private sealed, nested inside the partial class — always.
The WageType[] indexer returns object. Always cast: (decimal)WageType[2990] for a known value, (decimal?)WageType[10] ?? 1.0m for a nullable defensive pattern. Without the cast, you get a runtime InvalidCastException buried in the payrun log.
The Scripting API
Inside a Custom Action, you have access to the complete Payroll Engine scripting environment. These are instance members — no using statements, no service resolution, no DI container. They're available because your method is part of the WageTypeValueFunction partial class that the engine provides.
| Method / Property | Returns | Usage |
|---|---|---|
GetCaseValue<T>(name) |
T (scalar) | Read a case field value — collapses time segments to weighted scalar |
GetCaseValues(f1, f2, ...) |
CasePayrollValue dictionary | Multi-field read preserving time-segmented sub-periods |
GetLookup<T>(name, key) |
T or null | Deserialize full lookup object by name and key (year, code) |
GetLookupField<T>(name, key, field) |
T or default | Read a single field from a lookup entry |
WageType[number] |
object | Result of another wage type in current payrun — always cast |
PeriodStart |
DateTime | Start of the current payroll period (use .Year, .Month) |
PeriodEnd |
DateTime | End of the current payroll period |
SetEmployeeRuntimeValue(k, v) |
void | Store a value for consumption by downstream wage types |
LogWarning(msg) |
void | Emit a warning to the payrun log (visible in WebApp) |
A few critical notes that trip up every first-time author:
PeriodStartYear does not exist. Use PeriodStart.Year. The property is PeriodStart (a DateTime), and you access the year component via the standard .NET .Year property. Writing PeriodStartYear produces CS0103 — "name does not exist in the current context."
GetLookup can return null. If the lookup name doesn't match any registered lookup in the regulation's visible layers, or if the key doesn't exist, the result is null. Always null-check before accessing properties. A NullReferenceException inside a Custom Action kills the entire wage type with no diagnostic beyond "script error."
Monetary results must be rounded. Math.Round(result, 2) prevents accumulation errors in downstream wage types that read your result via WageType[number]. An unrounded 0.005 residual propagates through a 30-step wage type chain and produces a one-cent discrepancy on the payslip — which fails the integration test.
// Defensive pattern for lookup + calculation
var param = GetLookup<ContributionParam>("SvParameter", PeriodStart.Year);
if (param == null)
{
LogWarning($"SvParameter not found for year {PeriodStart.Year}");
return ActionValue.None;
}
var result = Math.Round((decimal)WageType[5010] * param.EmployerRate, 2);
return new(result);
The Time-Segmentation Trap
This is the most common source of silently wrong results in Custom Actions. The issue is subtle: GetCaseValue<T> works perfectly when you need a single value. But when you need to multiply two values that can both change mid-period, it collapses time segments before your arithmetic runs.
Consider an employee whose monthly salary changes from 3,000 EUR to 3,500 EUR on the 16th of the month. The regulation needs to calculate their pension contribution at 10% of salary. The correct result is:
Days 1-15: 3,000 * 10% * (15/30) = 150.00
Days 16-30: 3,500 * 10% * (15/30) = 175.00
Total: 325.00
But if you write this:
// WRONG — collapses segments before multiplication
var salary = GetCaseValue<decimal>("XX.Salary");
var rate = GetCaseValue<decimal>("XX.PensionRate");
return new(Math.Round(salary * rate, 2));
GetCaseValue<decimal> returns a weighted average: (3000 * 15/30) + (3500 * 15/30) = 3250. Then 3250 * 0.10 = 325.00. In this simple case, the result happens to be correct because only one operand changes. But if both salary AND rate change mid-month:
Days 1-15: 3,000 * 10% * (15/30) = 150.00
Days 16-30: 3,500 * 12% * (15/30) = 210.00
Correct total: 360.00
GetCaseValue gives: 3,250 * 11% = 357.50 // WRONG
The correct pattern uses GetCaseValues, which preserves the time-segmented sub-periods and applies arithmetic per segment:
// CORRECT — preserves per-segment arithmetic
var values = GetCaseValues("XX.Salary", "XX.PensionRate");
var result = (decimal)(values["XX.Salary"] * values["XX.PensionRate"]);
return new(Math.Round(result, 2));
The CasePayrollValue objects returned by GetCaseValues support operator overloading — multiplication, addition, subtraction — that automatically distributes the operation across aligned time segments. The engine handles the sub-period day-fraction weighting internally.
When does this matter? Any time two or more operands in a multiplication are Period or CalendarPeriod case fields that can change independently mid-month. Salary changes, rate changes, working-hours changes, mid-month hires, mid-month contract amendments. If only ONE operand can change mid-period and the other is a static lookup value or a Timeless field, GetCaseValue is safe. The moment both operands are period-sensitive, switch to GetCaseValues.
There's one additional trap: the CalendarPeriod times CalendarPeriod scaling issue. The backend scales every CalendarPeriod field at read time by value * subDays / periodDays. If both operands are CalendarPeriod, the scale is applied twice and the product is silently too small. At most one operand per multiplication should be CalendarPeriod; the other must be Period (factor from a date) or Timeless (fixed rate).
Calling Custom Actions from valueActions
The JSON side is deliberately simple. A Custom Action is called by name inside the valueActions array, exactly like a No-Code token expression:
{
"wageTypeNumber": 6500,
"name": "PensionEmployer",
"valueActions": [
"ACMECalculatePension()"
]
}
The parentheses are required even when there are no arguments. The engine parses the action name, finds the method with the matching [WageTypeValueAction] attribute, compiles and caches the script, and invokes it.
You can pass CaseField names as string arguments to Custom Actions. The method receives them as parameters and resolves the values internally:
{
"wageTypeNumber": 5100,
"name": "IncomeTax",
"valueActions": [
"DEGesLohnsteuer('DE.SteuerKlasse', 'DE.KiStStaat')"
]
}
Inside the C# method, these arrive as string parameters that you pass to GetCaseValue:
[WageTypeValueAction("DEGesLohnsteuer")]
public ActionValue DEGesLohnsteuer(string taxClassField, string churchTaxStateField)
{
var taxClass = GetCaseValue<int>(taxClassField);
var churchState = GetCaseValue<string>(churchTaxStateField);
// ... algorithm ...
}
Action call parameter literals are passed verbatim to GetCaseValue. The engine does NOT resolve the namespace for them. They must be fully qualified ('XX.Salary'), even when calling an action defined in the same namespace. This is different from No-Code tokens where ^^Salary resolves within the current namespace automatically.
Custom Actions can also appear alongside No-Code tokens in the same valueActions array. The engine executes them in sequence — each action can read the wage type's current intermediate value via ^| (in token expressions) or the return value of the previous action:
"valueActions": [
"^^MonthlySalary",
"ACMEApplyProRata()",
"Round(^|, 2)"
]
Line one sets the base value from a case field (No-Code token). Line two applies a Custom Action that adjusts for pro-rata factors. Line three rounds the result (No-Code built-in function). This mixing is the 80/20 split in practice: most of the regulation is token expressions, with Custom Actions handling the complex steps.
The architectural benefit is that the regulation JSON remains the authoritative source of truth for what each wage type does. A payroll consultant reading the JSON sees: "Wage type 5100 calls DEGesLohnsteuer with tax class and church tax state." They know what the wage type computes and which inputs it uses. The how — the algorithm inside the C# method — is a separate concern, maintained by developers but called from the declarative layer.
File Naming and Script Registration
Custom Action scripts follow a strict naming convention that distinguishes them from Low-Code scripts (which use valueExpression instead of valueActions):
WageTypeValueFunction.ACMECalculatePension.Action.cs
The pattern is: <FunctionClass>.<ActionName>.Action.cs. The .Action.cs suffix is the distinguishing marker. Without it, the script is assumed to be a Low-Code valueExpression script — a completely different invocation path.
| Script type | File suffix | Called via | Example |
|---|---|---|---|
| Custom Action (No-Code) | .Action.cs |
valueActions |
WageTypeValueFunction.ACMECalculatePension.Action.cs |
| Low-Code expression | .cs |
valueExpression |
WageTypeValueFunction.CalculateGross.cs |
Scripts must be registered in Scripts.YYYY.json (where YYYY is the regulation year). This file maps script names to their file paths so the engine knows where to find them at import time:
{
"scripts": [
{
"name": "ACMECalculatePension",
"valueFile": "Scripts/WageTypeValueFunction.ACMECalculatePension.Action.cs"
}
]
}
The script file must also be listed in regulation-package.json under the installFiles array. If it's in regulation-package.json but not in Scripts.YYYY.json, it ships with the package but doesn't get registered. If it's in Scripts.YYYY.json but not in regulation-package.json, it's registered but the file doesn't ship. Both must reference the same path.
The four-file sync rule applies to every new Custom Action:
1. Scripts/WageTypeValueFunction.ACMECalculatePension.Action.cs (the code)
2. Scripts.YYYY.json (script registration)
3. regulation-package.json (NuGet packaging)
4. Setup.pecmd (runtime import)
Missing any one of these four produces a different failure mode: missing from regulation-package.json means the file doesn't deploy. Missing from Scripts.YYYY.json means the engine doesn't compile it. Missing from Setup.pecmd means it doesn't import into the running system. All four must be updated in the same commit.
The validation check: After adding a new Custom Action, run the regulation's integration test suite. If the action isn't properly registered, the wage type will produce no result (not an error — just an empty result). The integration test catches this because it asserts a specific expected value. No result means the assertion fails, which tells you the registration chain is broken somewhere.
Write your first Custom Action
See how C# Custom Actions extend No-Code regulations when token arithmetic isn't enough.
Get in Touch →