Back
Tech 8 min read - 9 Jul. 26 - Update 10 Jul. 26 - Mayeul Le Monies de Sagazan

Publicodes: how Puncto coded its payroll engine

A payslip is undoubtedly one of the most read and least understood documents in an employee's life. We receive it every month, keep it until retirement, yet almost no one can explain how one goes from gross salary to the actual amount paid. This opacity is no accident: behind each line lies a pile of rules, exceptions, and specific cases accumulated over decades.
For an engineer, this observation hides another, more interesting one. A payslip is nothing other than the deterministic execution of a rule tree : a function that takes a hundred variables as input (salary, status, seniority, health insurance, absences, year-to-date totals…) and produces a set of amounts. The real subject is therefore not the calculation, it is perfectly deterministic, but how to encode thousands of interconnected rules without the codebase, in turn, becoming black magic.
This is the gamble Puncto took, a payroll software French SaaS: rather than translating the Labour Code into imperative TypeScript, describing the regulations in a declarative DSL, Publicodes, then executing it from a typed backend. Here's how, and the engineering problems it genuinely raises.

The Labour Code is a legacy codebase

Payroll regulations have exactly the properties one fears in an old monolith:
  • Stacked rules : each contribution (health, pension, unemployment, social security contributions and levies, occupational accident and illness contributions…) has its rate, its basis, its ceilings, its allowances.
  • Exceptions everywhere : apprentice, executive, intern, fixed-day contract… the same contribution is calculated differently depending on the status.
  • An evolving social model : rates, minimum wage and social security ceiling change at least once a year, sometimes during the year.
  • Circular dependencies : some values are defined in terms of themselves.
Encoding this imperatively is feasible, but you quickly get thousands of lines of nested conditions: evaluation order to be managed manually, implicit dependencies, and a codebase unreadable for the only people able to validate it : the payroll experts, who are not developers. The problem is not writing the calculation, it's keeping it verifiable.

Publicodes: making a regulation executable

Publicodes is an open-source declarative language, originally developed by the team at mon-entreprise.urssaf.fr to model French social law. The principle: we describe rules and their dependencies, not an algorithm. The engine builds the graph and evaluates it itself, just as a spreadsheet recalculates its cells.
A 'leaf' rule reduces to a question:
salaire de base:
  question: Salaire de base ce mois ?
  unité: €
A calculated rule composes other rules by their name:
rémunération brute:
  unité: €
  somme:
    - salaire de base
    - total primes
    - indemnisations d'absences
Three points follow directly. Firstly, the rule's name is its identifier : we reference basic salary by its French label, the model is readable as is. Secondly, we never code the calculation order : the engine deduces the dependency graph and evaluates it on demand. Finally, this file is the specification: a payroll expert rereads and validates it line by line, which would be impossible with the imperative equivalent. At Puncto, this model now represents a few hundred rules, spread across several files and organised into large modules.

The real challenges

A Publicodes 'hello world' is deceptively simple. The difficulty arises when rules intertwine. A few examples, taken from the real codebase, illustrate this.

1. Circular dependencies

In case of sick leave, the employer often 'maintains' the salary: they top up the daily allowances (IJSS) paid by social security. These IJSS must be subtracted from the gross to avoid double counting... but the gross is precisely used to calculate part of what depends on it. Circular reference. Imperatively, it would be handled with a two-pass calculation. Publicodes exposes it as a declarative property:
rémunération brute:
  résoudre la référence circulaire: oui
  unité: €
  somme:
    - salaire de base
    - total primes
    - indemnisations d'absences
    - IJSS brutes à soustraire
  avec:
    IJSS brutes à soustraire:
      applicable si: maintien du salaire = oui
      valeur: total IJSS ce mois * -1
With resolve circular reference, the engine detects the cycle and iterates until the value stabilises (fixed point). The complexity is declared, not cobbled together in a homemade orchestration that would need re-testing with each evolution.

2. Numerical inversion: finding the gross from the net

In France, a salary is almost always quoted as gross. But sometimes a precise net needs to be guaranteed: a guaranteed net negotiated at hiring, or a year-end net adjustment. However, the entire payroll machinery works the other way: starting from gross, deductions for contributions then tax are made, and the net is obtained. How can one find the gross that will produce a given exact net?
The immediate reaction would be to write the inverse formula. Except there isn't one: net as a function of gross is a step function, non-linear (bands, caps, exemption thresholds). It cannot be inverted algebraically. The only reliable way is numerical: try a gross amount, calculate the corresponding net, compare to the target, adjust, repeat until convergence. Here again, Publicodes provides the mechanism, thenumerical inversion : one declares that a rule usually calculated 'forwards' can be found from one of its outputs.
rémunération brute calculée depuis le net:
  unité: €
  applicable si: option de régularisation du net
  inversion numérique:
    - net à payer avant impôt sur le revenu
  remplace:
    références à:
      rémunération brute
The engine then runs the entire gross → net calculation in a loop, adjusting the gross at each iteration until the calculated net matches the target (a zero-finding search). Two elegant details: replaces means that this 'inverted' gross automatically substitutes the gross everywhere in the model as soon as a net is provided, and applicable if keeps the mechanism dormant the rest of the time. Crucially, a second 'reverse' model is not rewritten: it's the same rule tree, executed in the other direction. Akin to the circular reference seen above, numerical inversion truly shows what this engine is: an iterative solver, not a simple cascade of formulas.

The bridge between Publicodes and TypeScript

This is where the most interesting part of the engineering comes into play: making a model written in French communicate with a typed back-end, without losing the guarantees of either.
On compilation, the files .publicodes are transformed into a JSON graph and into generated TypeScript types: the union of all rule names now exists as a type. Concretely, calling a rule is autocompleted, and asking for a non-existent rule becomes a compilation error, not a runtime exception during payslip generation. An engine is instantiated per payslip, then evaluated on demand:
const { payslipCalculator } = getPayslipCalculator(payslipVariables)

const net       = payslipCalculator('net payé')       // typé, autocomplété
const superBrut = payslipCalculator('super brut')
// payslipCalculator('net paye')  ← faute de frappe : erreur de compilation
What remains is the impedance: Publicodes has its conventions, inherited from law (a boolean is written yes/no, a date JJ/MM/AAAA, an enumeration is quoted: 'CDI'). A typed wrapper bridges the gap in both directions, and crucially, it refuses to return a result if a required variable is missing:
calculateValue<T extends RuleNames>(value: T) {
  const res = super.evaluate(value)
  const missingVariables = Object.keys(res.missingVariables)
    .filter((key) => key.includes('régularisation') === false)

  if (missingVariables.length > 0) {
    throw new Error(`Missing publicodes variables ${JSON.stringify(missingVariables)}`)
  }
  return res.nodeValue as RuleValue[T]
}
This choice of fail-fast is a design decision in its own right. In a domain where a forgotten variable doesn't crash the system but produces an amount that is false, it's better to have an explicit exception than a silently erroneous payslip.

Testing a calculation where an error is costly

Once this foundation is in place, a mental shift occurs, and it is counter-intuitive: the calculation is practically never wrong. A validated rule tree always gives the correct result for correct inputs. In practice, payroll errors almost exclusively come from a configuration or an entry : incorrectly affiliated mutual health insurance, wrong seniority, forgotten bonus, incorrectly reported leave.
The testing strategy follows this logic: we freeze complete situations and check each line to the nearest penny. A manager's payslip for 64 k€/an from April 2025 thus produces about a hundred assertions of the type:
test('CSG déductible', () => {
  assert.strictEqual(
    payslipCalculator("CSG-CRDS . CSG déductible de l'impôt sur le revenu"),
    360.7,
  )
})
These tests play a dual role: an anti-regression net, and executable specification. When a rate changes on 1st January, we add a dated situation with the new expected amounts; non-regression on older vintages is guaranteed by design thanks to date-based versioning at the publicodes level. Result: modifying a contribution rule, usually dreaded in payroll software, becomes a controlled operation: if a penny moves somewhere, a test immediately flags it.

What the declarative approach brings, and what it costs

The outcome is clear. In favour of Publicodes: a model auditable by business experts themselves, readable Git diffs when a rule changes, an automatic dependency resolution (no manual topological sort), a regulatory versioning almost free via date variations - all wrapped in a type layer that brings back TypeScript's static safety.
The cost exists: learning the language conventions, writing and maintaining the impedance layer, and accepting that an engine solving cycles by iteration is a little less transparent than a manually unrolled function. Compared to a living social law, retroactive and full of exceptions, the exchange is very favourable.
The lesson goes beyond payroll: when a domain resembles a legacy codebase that no one fully masters, the right answer isn't always to write more code: sometimes it's about finding the right language to describe it, and letting a machine execute it. This is exactly what powers the payroll engine of Puncto on a daily basis.

Do you want support to launch your digital project?

Submit your project now