Beyond the Payslip

A completed payrun produces wage type results, collector balances, and case values for every employee in a period. But the raw result set is just the starting point. Employers need employer cost reports that aggregate contributions and taxes across the workforce. Finance departments need YTD summaries for budgeting. Tax authorities need quarterly declarations formatted to specification. And every provider has bespoke analytics that no country regulation can anticipate.

In the Payroll Engine, reports live inside the regulation — they follow the same four-layer model as wage types and cases. A country regulation ships with standard reports (payslips, employer cost overviews, statutory declarations). A provider regulation can add its own reports in the overlay layer without modifying the country regulation. Namespace separation keeps them cleanly isolated.

This design means the reporting layer benefits from everything the regulation model provides: versioning, layer overrides, tenant isolation, and test-driven validation. A report is not an afterthought bolted onto the side — it is a first-class regulation object with its own lifecycle, parameters, and server-side logic.

Key distinction: Reports query completed payrun results — they never compute values during payrun execution. YTD aggregations, retro-period comparisons, and cross-employee summaries belong exclusively in the report layer. Computing them inside wage types would create circular dependencies and break the sequential execution model.

Report Folder Structure

Every report follows a standardized folder convention. Each report gets its own directory inside the Reports/ folder, with a fixed set of files that separate definition, logic, template, and tooling.

Reports/
  Setup.Reports.pecmd          
  Report.Data.json             
  EmployerCost/
    Report.json                
    ReportEndFunction.cs       
    EmployerCost.frx           
    parameters.json            
    Import.pecmd               
    Script.pecmd               
    Report.Build.pecmd         
    Report.Pdf.pecmd           

Here is what each file does:

File Purpose
Report.json Report definition: name, description, endExpressionFile reference, template declarations. Uses updateMode: "Update".
ReportEndFunction.cs Server-side C# script that builds the DataSet — queries payrun results, transforms data, populates tables for the template.
EmployerCost.frx FastReport template (FRX XML) that renders the DataSet into formatted output (PDF, HTML, Excel).
parameters.json Runtime parameters for pecmd commands — tenant, payroll, employee IDs used during local testing.
Import.pecmd Imports Report.json via PayrollImport.
Script.pecmd Publishes ReportEndFunction.cs via ScriptPublish.
Report.Build.pecmd Runs ReportBuild to generate the FRX skeleton from the DataSet structure.
Report.Pdf.pecmd Executes the report and opens the result: Report /pdf /shellopen.

The top-level Setup.Reports.pecmd orchestrates the full import: it calls each report’s Import.pecmd and Script.pecmd, then imports Report.Data.json (which provides test payrun data for local report execution). This is a separate pipeline from the regulation package — reports are not listed in regulation-package.json and not registered in Scripts.YYYY.json.

The Report.json definition uses endExpressionFile to reference the script — not a scripts[] array. This is the critical difference from wage type scripting:

{
  "name": "EmployerCost",
  "description": "Employer cost overview per employee and period",
  "updateMode": "Update",
  "endExpressionFile": "ReportEndFunction.cs",
  "templates": [
    {
      "name": "EmployerCost",
      "culture": "de-DE",
      "contentFile": "EmployerCost.frx"
    }
  ]
}
Tip: The templates array supports multiple entries for different cultures. A report can ship with a German and an English template — the engine selects the matching culture at runtime.

The ReportEndFunction Lifecycle

The ReportEndFunction is a C# script that runs server-side when a report is requested. Its job is to build a DataSet by querying payrun results through the Payroll Engine API and shaping the data for the FRX template. The lifecycle follows a strict sequence that must be respected — skipping steps produces silent failures.

Here is the core pattern, extracted from a real employer cost report:

[ReportEndScript(reportName: "EmployerCost")]
public partial class ReportEndFunction
{
    public object Execute()
    {
        var payrollId = int.Parse(GetParameter("PayrollId"));
        var employeeId = int.Parse(GetParameter("EmployeeId"));
        var periodStart = GetParameter("PeriodStart");

        AddTable("CostSlice");

        var query = $"Status eq 0 and PeriodStart eq {periodStart}";
        var slice = ExecuteQuery("CostSlice",
            "PayrunResults",
            new { PayrollId = payrollId, EmployeeId = employeeId },
            query);

        foreach (DataColumn col in slice.Columns)
        {
            var shortName = col.ColumnName;
            var dotIndex = shortName.IndexOf('.');
            if (dotIndex >= 0)
                shortName = shortName.Substring(dotIndex + 1);
            col.ColumnName = shortName;
        }

        return slice.DataSet;
    }
}

The critical sequence:

  1. AddTable("CostSlice") — Creates an empty table in the DataSet. This must happen before any query. Without it, ExecuteQuery has no target table and the query result will not belong to the DataSet.
  2. ExecuteQuery("CostSlice", ...) — Runs the API query and writes results into the named table. The table must already exist in the DataSet from step 1.
  3. Process the data — Strip namespace prefixes, compute derived columns, filter rows.
  4. Return DataSet — The engine passes the DataSet to the FRX template for rendering.

When iterating over multiple slices (e.g., querying results for each employee in a multi-employee report), the lifecycle extends with cleanup and re-creation:

AddTable("Slice");

foreach (var empId in employeeIds)
{
    var slice = ExecuteQuery("Slice",
        "PayrunResults",
        new { PayrollId = payrollId, EmployeeId = empId },
        query);

    // ... process slice, copy rows to summary table ...

    RemoveTables("Slice");
    AddTable("Slice");
}

RemoveTables("Slice");
Never call ExecuteQuery without a prior AddTable. If the table does not belong to the DataSet, RemoveTables will throw "Table X does not belong to this DataSet". This is the single most common report scripting error.

The script attribute [ReportEndScript(reportName: "EmployerCost")] is required for import — the ScriptParser matches the reportName against the short name in Report.json. If the name does not match, the import fails with "Missing or invalid script file". Note: [ReportEndFunction(...)] (without “Script”) is for client-side local debugging only and is ignored by the backend.

Parameters and Available Properties

Report scripts receive runtime context through two mechanisms: built-in properties and explicit parameters. The distinction matters because using the wrong access pattern produces null values without any error.

TenantId is available as a built-in property — you access it directly in the script body as TenantId. No parameter declaration is needed.

Every other contextual value — including EmployeeId and PayrollId — must be declared as report parameters and accessed through GetParameter():

var payrollId = int.Parse(GetParameter("PayrollId"));
var employeeId = int.Parse(GetParameter("EmployeeId"));

Parameters are declared in Report.json with a parameterType that tells the engine how to resolve the value at runtime:

"parameters": [
  {
    "name": "EmployeeId",
    "parameterType": "EmployeeId",
    "mandatory": true
  },
  {
    "name": "PayrollId",
    "parameterType": "PayrollId",
    "mandatory": true
  },
  {
    "name": "PeriodStart",
    "parameterType": "Value",
    "mandatory": true,
    "description": "Start date of the reporting period"
  }
]

The full set of available parameterType values:

parameterType Resolved to
Value Custom value supplied at runtime (default)
Now Current date and time
Today Current date (time portion zeroed)
TenantId Current tenant identifier
UserId Current user identifier
EmployeeId Target employee identifier
RegulationId Active regulation identifier
PayrollId Active payroll identifier
PayrunId Active payrun identifier
ReportId Current report identifier
WebhookId Webhook identifier
Watch out: PayrunJobId does not exist as a parameterType. If you need a payrun job ID in a report, declare it as a plain parameter with parameterType: "Value" and pass the ID explicitly at invocation time.

DataSet Tables and Column Names

When ExecuteQuery returns payrun results, every column name is fully qualified with the regulation namespace. A wage type named GrossIncome in the DE namespace arrives as DE.GrossIncome. A collector named SvBrutto arrives as DE.SvBrutto.

This is by design — when a payroll spans multiple regulation layers, namespace-qualified names prevent collisions. But FastReport FRX templates (the open-source edition used by Payroll Engine) have no script block to transform column names. The FRX binds directly to DataSet column names. If the template references [GrossIncome] but the column is named DE.GrossIncome, the binding fails silently and the field renders empty.

The solution is to strip the namespace prefix in the ReportEndFunction before returning the DataSet:

foreach (DataColumn col in resultTable.Columns)
{
    var name = col.ColumnName;
    var dotIndex = name.IndexOf('.');
    if (dotIndex >= 0)
        col.ColumnName = name.Substring(dotIndex + 1);
}

This pattern iterates every column, finds the first dot (the namespace separator), and strips everything before it. After this transformation, the FRX template can bind to [GrossIncome], [SvBrutto], and [Lohnsteuer] directly.

Tip: Apply the stripping loop immediately after ExecuteQuery, before any processing logic. This way, all subsequent code in the script also works with short names — making the script more readable and less error-prone.

For multi-layer reports (where the same wage type number exists in both the country and provider regulation), the fully qualified prefix is the only way to distinguish them. In such cases, strip selectively or rename to explicit short names:

// Rename specific columns for clarity
foreach (DataColumn col in resultTable.Columns)
{
    if (col.ColumnName == "DE.GrossIncome")
        col.ColumnName = "CountryGross";
    else if (col.ColumnName == "ACME.GrossIncome")
        col.ColumnName = "ProviderGross";
}

Provider Reports in the Overlay

Provider reports follow the same layering mechanism as provider wage types. A provider regulation in the ACME.DE.Base namespace can ship its own reports that query country-level wage type results, combine them with provider-specific values, and present the output in a branded format.

The folder structure is identical to country reports — the only difference is the namespace and the baseRegulations context. A provider report in ACME.DE.Base can query results from DE wage types because the provider’s payroll stack includes the country regulation as a base layer.

// Provider report querying country + provider wage type results
var query = $"Status eq 0 and PeriodStart eq {periodStart}";
var results = ExecuteQuery("Results",
    "PayrunResults",
    new { PayrollId = payrollId, EmployeeId = employeeId },
    query);

// Results contain both DE.* and ACME.* columns
// Strip or rename as needed for the template

Provider reports are imported through a separate Setup.Reports.pecmd in the provider regulation — not through the country regulation’s setup pipeline. They are never listed in regulation-package.json and never registered in Scripts.YYYY.json. This separation means provider reports can be developed, tested, and deployed independently of country regulation releases.

The import sequence for a provider report looks like this:

# Setup.Reports.pecmd (provider regulation)

# Import report definition and publish script
EmployerCostACME/Import.pecmd
EmployerCostACME/Script.pecmd

# Import test data for local report execution
PayrollImport Report.Data.json /update

Because reports are regulation objects with namespace isolation, a provider can even override a country report by declaring a report with the same name in the provider layer. The layer resolution rules apply: the provider-layer report wins. This allows customizing the standard payslip format for specific clients without forking the country regulation.

Tip: Use the Report.Build.pecmd command during development to generate an FRX skeleton from your DataSet structure. This creates the template scaffolding with all column bindings pre-configured — then customize the layout in the FastReport designer.

For a concrete example of how reports integrate with the full provider regulation lifecycle, including the interaction between wage type calculations, collector aggregation, and report queries, see the Case Study: Building a Pension Fund Overlay. For the architectural foundation that makes cross-layer reporting possible, see The Four-Layer Regulation Model.

XML Reports: Structured Output for Authorities

Not every report produces a PDF. Tax authorities and social security agencies require machine-readable XML declarations: Germany’s LStAnmeldung (income tax declaration), BeitragsNachweis (contribution proof), and SV-Meldungen (social security notifications) are all structured XML documents with precise schemas. The same ReportEndFunction mechanism handles these — the only difference is the output format.

An FRX-based report builds a DataSet and passes it to a FastReport template for visual rendering. An XML report builds the same DataSet but constructs structured XML output directly in the script, typically using System.Xml.Linq:

[ReportEndScript(reportName: "LStAnmeldung")]
public partial class ReportEndFunction
{
    public object Execute()
    {
        var payrollId = int.Parse(GetParameter("PayrollId"));
        var periodStart = GetParameter("PeriodStart");

        AddTable("TaxResults");
        var results = ExecuteQuery("TaxResults",
            "PayrunResults",
            new { PayrollId = payrollId },
            $"Status eq 0 and PeriodStart eq {periodStart}");

        var xml = new XDocument(
            new XElement("LStAnmeldung",
                new XAttribute("version", "2026"),
                new XElement("Zeitraum", periodStart),
                new XElement("Kennzahlen",
                    BuildKennzahl(results, "DE.Lohnsteuer", "41"),
                    BuildKennzahl(results, "DE.SolZ", "42"),
                    BuildKennzahl(results, "DE.Kirchensteuer", "46")
                )
            )
        );

        RemoveTables("TaxResults");

        AddTable("XmlOutput");
        var outputTable = new DataTable("XmlOutput");
        outputTable.Columns.Add("Content", typeof(string));
        outputTable.Rows.Add(xml.ToString());

        return outputTable.DataSet;
    }
}

The pattern is identical to visual reports: AddTable, ExecuteQuery, process results, return. The difference is that instead of formatting columns for an FRX template, the script assembles an XML document from the query results. The FRX template for an XML report is minimal — it simply outputs the Content column as raw text.

Report Type Output Template Example
Visual report PDF, HTML, Excel Full FRX with layout, bands, formatting Payslip, employer cost overview
XML report Structured XML Minimal FRX (raw content output) LStAnmeldung, BeitragsNachweis, SvMeldung

XML reports are particularly important for electronic filing scenarios. The German regulation ships three XML reports: the LStAnmeldung (monthly/quarterly income tax declaration to the tax authority), the BeitragsNachweis (monthly contribution proof to the health insurance fund), and the SV-Meldungen (social security notifications for employment events). Each follows a different XML schema, but all use the same ReportEndFunction lifecycle.

Provider regulations can add their own XML reports for sector-specific declarations or internal data exchange formats. The mechanism is the same: define a Report.json, implement the ReportEndFunction that queries payrun results and constructs XML, and ship a minimal FRX template. The regulation layer model ensures that provider XML reports have full access to country-level wage type results through the standard query API.

Tip: XML reports benefit from the same test-driven approach as calculations. Run the report against known payrun data and validate the XML output against the authority’s XSD schema. Schema validation catches structural errors before submission — wrong element names, missing attributes, incorrect nesting — that would otherwise result in rejected filings.

Build your first report

See how the reporting framework turns payrun results into actionable output for your clients.

Get in Touch →
← Back
All Articles