Salarisengine: loonhiërarchie en toeslagen

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.