← Terug naar blog
AI Engineering
8 mei 2026

Salarisengine: loonhiërarchie en toeslagen

Hoe je een flexibel uurloon-systeem bouwt dat rekening houdt met leeftijd, functieschaal, inkoopcontracten en overuren — zonder hardcoded tarieven
Tijn van Geel
Tijn van GeelFullstack Developer & AI Engineer

Een uurloon is zelden één getal. In de horeca, zorg en detailhandel spelen leeftijdstabellen, functieschalen, cao-toeslagen, en inkoopafspraken allemaal mee. Hardcode je één tarief per medewerker, dan klopt het zodra iemand een verjaardag heeft of een nieuwe cao van kracht wordt.

De oplossing is een hiërarchische loonengine: meerdere niveaus van loonbepaling met een expliciete prioriteitsvolgorde, waarbij het hoogste niveau dat een tarief oplevert, wint.

De uurloon-hiërarchie

De BillingCalculatorService lost het uurloon op via resolveHourlyWage:

async resolveHourlyWage(params: {
  relationId: string       // de klant/opdrachtgever
  userFunctionId: string   // de functie van de medewerker
  wageScaleId: string      // de loonschaal (cao-trede)
  organizationId: string
  employeeAge: number
}): Promise<ResolvedWage> {
  // Prioriteit 1: klant-specifiek inkooptarief
  const procurementRate = await this.findProcurementRate(
    params.relationId,
    params.userFunctionId,
  )
  if (procurementRate) {
    return { hourlyWage: procurementRate, source: 'procurement' }
  }

  // Prioriteit 2: leeftijdsafhankelijk cao-tarief
  return this.calculateProcurementRateByAge(params)
}

Als er een inkoopcontract is tussen de organisatie en de opdrachtgever voor die specifieke functie, wint dat altijd. Inkooptarieven zijn vaak vaste afspraken die afwijken van de standaard-cao — een uitzendbureau dat op marge werkt, heeft andere tarieven per klant.

Pas als er geen inkoopcontract is, valt de engine terug op de leeftijdsafhankelijke berekening.

Leeftijdsafhankelijk loon

De Nederlandse wet kent jeugdloon: medewerkers onder de 21 krijgen een percentage van het minimumloon. Cao's bouwen hier vaak op voort met eigen schalen. De calculateProcurementRateByAge-functie zoekt de juiste rij op uit de loonschaal-tabel:

async calculateProcurementRateByAge(params: {
  wageScaleId: string
  userFunctionId: string
  employeeAge: number
}): Promise<ResolvedWage> {
  const scaleRows = await this.db.wageScaleRow.findMany({
    where: { wageScaleId: params.wageScaleId },
    orderBy: { minAge: 'desc' },  // hoogste leeftijdsdrempel eerst
  })

  const applicable = scaleRows.find(
    (row) => params.employeeAge >= row.minAge
  )

  if (!applicable) throw new Error(`Geen loonschaal gevonden voor leeftijd ${params.employeeAge}`)

  return {
    hourlyWage: applicable.hourlyWage,
    source: 'wage_scale',
    wageScaleRowId: applicable.id,
  }
}

orderBy: { minAge: 'desc' } gevolgd door find geeft de meest specifieke toepasselijke rij. Een 19-jarige matcht op de rij met minAge: 18, niet op de basisrij met minAge: 0, als die allebei bestaan.

De source-veld in ResolvedWage is cruciaal voor auditing: je kunt achteraf altijd reconstrueren op basis waarvan het uurloon is berekend.

Uren berekenen: bruto naar netto

Het berekenen van het aantal factureerbare uren is niet simpel optellen:

calculateHours(params: {
  startTime: Date
  endTime: Date
  breakMinutes: number
  bonusHours: number
}): number {
  const diffMinutes = differenceInMinutes(params.endTime, params.startTime)
  const netMinutes = diffMinutes - params.breakMinutes + params.bonusHours * 60
  return Math.round((netMinutes / 60) * 100) / 100  // afronden op 2 decimalen
}

bonusHours is een correctiemechanisme: als een medewerker langer heeft gewerkt dan geregistreerd (bijv. bij handmatige correctie door een manager), kunnen extra uren worden toegevoegd zonder de start- of eindtijd te wijzigen. Het resultaat wordt afgerond op 2 decimalen om drijvende-komma-artefacten te voorkomen.

Toeslagen

Toeslagen (avond, nacht, weekend) zijn procentuele opslag op het basisuurloon voor specifieke tijdvakken. De engine berekent toeslagen per tijdvak:

calculateSurcharges(
  shift: Shift,
  baseWage: number,
  surchargeRules: SurchargeRule[],
): SurchargeResult[] {
  return surchargeRules
    .filter((rule) => overlaps(shift, rule.timeWindow))
    .map((rule) => {
      const overlapMinutes = calculateOverlapMinutes(shift, rule.timeWindow)
      const surchargeHours = overlapMinutes / 60
      return {
        ruleId: rule.id,
        description: rule.description,
        percentage: rule.percentage,
        hours: surchargeHours,
        amount: surchargeHours * baseWage * (rule.percentage / 100),
      }
    })
}

Een shift van 22:00 tot 02:00 overlapt met zowel het avondtoeslag-venster (21:00–00:00) als het nachttoeslag-venster (00:00–06:00). De engine berekent de overlap voor elk venster apart en produceert twee SurchargeResult-rijen — zodat de factuur per toeslag uitgesplitst kan worden.

Audittrail

Elke loonberekening slaat zijn inputs en beslissingen op:

type BillingCalculation = {
  shiftId: string;
  employeeId: string;
  hours: number;
  baseWagePerHour: number;
  wageSource: "procurement" | "wage_scale";
  procurementRateId: string | null;
  wageScaleRowId: string | null;
  surcharges: SurchargeResult[];
  totalAmount: number;
  calculatedAt: Date;
};

Als een medewerker of opdrachtgever de loonberekening betwist, kun je exact reconstrueren: welke loonschaal, welk inkooptarief, welke toeslagregels waren van kracht op dat moment. De calculatedAt-timestamp is hierbij essentieel — tarieven kunnen achteraf wijzigen, de berekening op het moment van de shift moet onveranderd blijven.

Waarom geen hardcoded cao-tabellen?

De eerste versie van veel planningssystemen bevat een hardcoded cao-tabel als JSON-bestand. Dat werkt totdat:

  • De cao van kracht wordt per 1 januari
  • Een klant een afwijkend inkooptarief onderhandelt
  • Een medewerker een verjaardag heeft en een hogere leeftijdstrede bereikt

Een database-gestuurde hiërarchie met source-tracking maakt al deze scenario's beheerbaar. De loonschaal-tabel wordt bijgewerkt via een admin-interface; de engine pikt het automatisch op bij de volgende berekening.


Project bespreken?

Heb je een use case waar je over twijfelt?

We praten graag over of een AI-aanpak past — en zeggen het eerlijk als het beter zonder kan. Vaste prijs, afgesproken scope.
Plan een gesprek
Probeer AI