Work rules (L3)¶
Variable work data for a payroll run (overtime, absences, sick leave,
supplements, benefits, bonuses) is passed as work events in
PeriodFacts.events: on PeriodInput.facts for one run, or in
CompetenceYearPlan.periods keyed by month or by run id for a year. Events are part
of the run and change the
result according to the treatment in the table below. For example, overtime
raises period_gross; an unpaid absence is reported in
unpaid_absence_deduction; a bilateral fund contribution changes only
period_net and period_employer_cost. Each event appears as pay items in
result.pay_items.
Import event types from the ccnl_engine package, like every other public name.
"""Work-rule events: overtime, absence, sickness, fringe benefit, welfare, bonus."""
from datetime import date
from decimal import Decimal
from ccnl_engine import (
EmployerProfile,
Employment,
Headcount,
PayrollEngine,
PayrollRun,
PeriodFacts,
PeriodInput,
)
from ccnl_engine.events import (
AbsenceEvent,
BonusEvent,
FringeEvent,
OvertimeEvent,
SicknessEpisode,
WelfareEvent,
)
from ccnl_engine.inputs import PriorYearTaxFacts
from ccnl_engine.inputs import Permanent
engine = PayrollEngine.bundled()
events = (
# No multiplier: the CCNL weekday band gives it (metalmeccanico
# OT_DIURNO 25% for the first two hours, so 4 h x 16.50 EUR x 1.25 =
# 82.50 EUR; the run is provisional because the 30% tier beyond two
# hours needs an explicit multiplier).
OvertimeEvent(
event_date=date(2026, 3, 5),
hours=Decimal(4),
hourly_rate=Decimal("16.50"),
),
AbsenceEvent(
event_date=date(2026, 3, 10),
hours=Decimal(8),
hourly_rate=Decimal("16.50"),
),
# The engine pays the sick days from the CCNL and INPS rules: three
# days of carenza, here integrated at 100% by the CCNL. An episode
# that continues into April is passed again, same id, to the April run.
SicknessEpisode(
episode_id="cert-2026-0316",
started_on=date(2026, 3, 16),
ended_on=date(2026, 3, 18),
),
FringeEvent(
event_date=date(2026, 3, 1),
amount=Decimal(200),
),
WelfareEvent(
event_date=date(2026, 3, 1),
amount=Decimal(500),
),
BonusEvent(
event_date=date(2026, 3, 1),
amount=Decimal(1000),
kind="productivity_bonus",
),
)
result = engine.calculate_period(
PeriodInput(
run=PayrollRun.regular(year=2026, month=3),
payment_date=date(2026, 3, 27),
employment=Employment(
ccnl_slug="metalmeccanico-federmeccanica.json",
level_code="C3",
contract_type=Permanent(),
),
employer=EmployerProfile(headcount=Headcount(100)),
facts=PeriodFacts(events=events),
# The PdR substitute tax needs the prior-year employment income.
prior_year=PriorYearTaxFacts(employment_income=Decimal(0)),
)
)
print(f"Period gross: {result.period_gross}")
print(f"Period net: {result.period_net}")
print(f"Absence deduction: {result.unpaid_absence_deduction}")
print("\nWork-rule pay items:")
for item in result.pay_items:
print(f" {item.kind:40s} {item.amount}")
Event types¶
All events are frozen dataclasses. Every event carries an event_date;
amounts are Decimal values in EUR, validated on construction.
| Event | Fields | Treatment |
|---|---|---|
OvertimeEvent |
hours, hourly_rate, multiplier (default None: from the CCNL band), kind (OvertimeKind, default WEEKDAY) |
Pay hours × hourly_rate × multiplier; subject to INPS, IRPEF and TFR |
NightShiftEvent |
supplement_amount |
Night-work supplement; INPS and IRPEF |
HolidayWorkEvent |
supplement_amount |
Public holiday or weekly rest-day supplement; INPS and IRPEF, no TFR |
ShiftWorkEvent |
supplement_amount |
Shift allowance; INPS and IRPEF, no TFR |
AbsenceEvent |
hours, hourly_rate, end_date, suspends_accrual |
Unpaid absence deducted from pay; reduces the INPS and TFR base |
SicknessEpisode |
episode_id, started_on, ended_on, relapse_of, prior_worked_days (fixed-term), hospital_stays, serious_illness |
Sickness episode; the engine derives the deduction, INPS indemnity, CCNL integration and carenza pay of the days in the run's month; see Sickness |
SickLeaveEvent |
amount, sick_days, waiting_period_days |
Sick pay the caller computed: an override of the engine, never payable; INPS and IRPEF, no TFR |
FringeEvent |
amount |
Fringe benefit (art. 51 c. 3 TUIR); exempt while the year total stays within the threshold, then the whole year total is taxable; see Fringe benefits |
WelfareEvent |
amount |
Welfare benefit; exempt from INPS and IRPEF, no TFR |
TravelAllowanceEvent |
days, outside_municipality, meals_lodging (required), daily_amount or hours_away, tfr_base, abroad |
Daily travel allowance (indennità di trasferta), paid in cash; outside the comune exempt from IRPEF and INPS up to 46.48 a day (77.47 abroad), reduced by one third or two thirds when meals or lodging are reimbursed or provided (art. 51 c. 5 TUIR, art. 12 c. 2 L. 153/1969); within the comune income in full. Without daily_amount the CCNL work_rules.travel_allowance amount of the span of hours_away is paid (Logistica travelling staff, art. 62). The TFR base takes nothing, the whole, or the part above the exemption, as tfr_base or the CCNL says; a trasfertista (art. 51 c. 6) is rejected |
StandbyEvent |
kind (StandbyKind: hours, worked day, rest day, holiday, week, week with holiday, week with holiday and rest day), quantity, calls |
Standby pay (reperibilità) at the CCNL work_rules.standby_rules rates of the worker's level band in force on the event date, plus the CCNL fee per call with an intervention; income for IRPEF and INPS; TFR as the CCNL rules say (Federmeccanica: excluded). A CCNL or a date without rates rejects the event (MissingRuleError) |
BonusEvent |
amount, kind, agreement_signed_on |
One-off bonus; kind selects ordinary IRPEF, the PdR regime or the renewal regime |
BilateralFundEvent |
employee_amount, employer_amount |
Bilateral or health fund contribution; see Pay components |
ArrearsEvent |
amount, separate_tax_rate, reference_period |
Renewal arrears; see Renewal arrears |
TerminationTFREvent |
amount, separate_tax_rate |
TFR settlement at cessazione (art. 19 TUIR) |
Overtime multiplier¶
D.Lgs. 66/2003 art. 5 c. 5 leaves the overtime supplement to the CCNL and sets no statutory rate, so the engine has no default multiplier.
multiplier |
CCNL band of kind |
Paid with | Decision and status |
|---|---|---|---|
None |
present | 1 + band |
overtime decision ccnl_overtime_band_applied, origin engine, citing the band and its source |
None |
absent | nothing: InvalidInputError |
the run is rejected; pass an explicit multiplier |
| explicit | equal to a band | the caller's value | caller_supplied decision only |
| explicit | different | the caller's value | caller_supplied decision and a provisional issue caller_multiplier_differs_from_ccnl with both values |
| explicit | absent | the caller's value | caller_supplied decision only |
A caller-supplied multiplier, matching a band or not, is a
caller_supplied_rule blocker: the result is not payable until the value is
validated outside the engine.
kind selects the band: WEEKDAY (straordinario diurno), NIGHT,
HOLIDAY, REST_DAY or NIGHT_HOLIDAY, matched against the
applies_to_kinds of the CCNL work_rules.time_supplements.overtime_bands.
REST_DAY is overtime on the weekly rest day for the CCNLs that pay it apart
from the public holidays (Logistica travelling staff, art. 13 c. 6: 30% on
Saturday and Sunday, 65% on the festività); a CCNL without a rest_day band
pays it with its HOLIDAY band. The band used is the
first tier: the percentage band with code OT_* that has no hour threshold
and no context condition. When the CCNL also has bands that start beyond a
daily or weekly hour threshold (commercio: 15% up to 48 weekly hours, 20%
beyond; metalmeccanico: 25% for the first two hours, 30% beyond), the event does not carry the hours of the week, so the first tier
is applied to every hour and the run is provisional with issue
overtime_tier_not_applied: pass an explicit multiplier for the hours
beyond the threshold (a multiplier equal to a higher tier raises no
difference issue). A CCNL whose bands for the kind all start beyond a
threshold, or are not in force on the event date gives no multiplier. A
kind with no percentage band but a band in EUR per hour (indennita_per_hour:
the PA tables of fixed hourly amounts, e.g. the scuola ATA Tabella E1.7) is
paid that amount for each hour in place of hourly_rate, with multiplier 1.
A band that lists levels applies only to those levels. The derived band is a payable rule of overtime: its
provenance status is reported in capability_report.rule_sources.
from datetime import date
from decimal import Decimal
from ccnl_engine.events import OvertimeEvent, OvertimeKind
# Metalmeccanico OT_NOTTURNO 50%: 2 h x 15.00 EUR x 1.50 = 45.00 EUR.
night = OvertimeEvent(
date(2026, 3, 10), Decimal(2), Decimal("15.00"), kind=OvertimeKind.NIGHT
)
Other rates and amounts are caller inputs¶
OvertimeEvent.hourly_rate, the supplement_amount of night, holiday and
shift events, and the separate_tax_rate of arrears and TFR settlements come
from the caller.
Sickness¶
A SicknessEpisode is one illness: a stable episode_id, the first and
last day on the certificates and, for a relapse (ricaduta), the
relapse_of id of the episode it continues. Pass the same episode, same id
and start, to every regular run whose month it touches; a later run may
extend ended_on. Only the run that posts the monthly pay of its month
(a regular run, or a termination run when the regular run is not closed)
accepts an episode, so no day is paid twice.
Each calendar day has an index in the episode, counted on from the episode it continues for a relapse. The index sets:
| Days | INPS (INPS page on the sickness indemnity) | Worker receives |
|---|---|---|
| 1-3 (carenza) | nothing | CCNL carenza_integration_rate, only past carenza_min_days of sickness when the CCNL sets it |
| 4-20 | 50% | the higher of the CCNL tier rate and the INPS rate |
| 21-180 | 66.66% | the same |
| past the comporto | left out, incomplete issue |
left out |
A fixed-term contract pays sickness for no more days than those of work in
the 12 months before the episode, every employer counted
(SicknessEpisode.prior_worked_days, D.L. 463/1983 art. 5 c. 1); with 30
days or fewer, for at most 30 days in the calendar year, the INPS share
paid by INPS directly (c. 3). The employer advances the INPS share only for
the calendar days of the employment in those 12 months; INPS pays the later
days directly (c. 5), so their share is not on the payslip. The days past
the limit are unpaid sickness (fixed_term_limit, deducted). Without
prior_worked_days the limit is not applied and the run has a provisional
sickness_fixed_term_prior_work_unknown issue. The 30 days of c. 3 and
the source are the fixed_term record of sickness/indemnity/rates.json,
cited in the rule sources of a fixed-term run.
A CCNL whose workers INPS indemnifies at other rates names the regime in
work_rules.sickness_rules.inps_regime; its bands replace the ones above,
while the carenza, the 180-day maximum and the cover stay. The bundle holds
one regime, pubblici_esercizi: 80% from day 4 to day 180 for the
employees of pubblici esercizi and laboratori di pasticceria, whose
employers pay the additional sickness contribution of 0.77% (INPS page and
circ. 71/1985). A regime the table lacks is a data error. The employers of
a regime also pay its additional contribution: the INPS table of the year
lists it under inps.sick_pay_supplements (0.77% of the INPS taxable pay
for pubblici_esercizi, in the terziario tables), and every run adds it to
the employer contributions as the sick_pay_supplement_employer component.
A year whose table has no supplement for the regime is a data error.
INPS pays at most 180 days a calendar year, counted over every recorded
episode. INPS covers the worker by the rules of
knowledge/sickness/indemnity/rates.json: operai of industry and the
terziario and impiegati of the terziario (secondary source), operai of the
building and artisan sectors, quadri of the terziario and apprentices
(unverified); not impiegati and quadri of industry (secondary source),
dirigenti, public employees or domestic workers (unverified). Each rule
names its source in the file. Without a rule the cover is unknown: the days
are paid at the CCNL rate only and a provisional issue
sickness_inps_cover_unknown names the fact category when the level does
not fix it. For most CCNLs the tier is the one of the month of sickness
(30 days) the day falls in, so a month that crosses a tier threshold pays
each day its own rate, and the comporto ends past max_duration_days of
the episode and its relapses. A CCNL that counts several episodes sets a
cumulation instead (below).
The payable days of each class are counted with the CCNL daily quota of an
unpaid absence (work_rules.absence_rules.daily_divisor_method), the same
count as a hire or termination month, unless the sickness rules set their
own (work_rules.sickness_rules.daily_divisor_method: the CCNL lavoro
domestico pays sickness in thirtieths and absences in twenty-sixths), and
never exceed one monthly pay,
however many episodes the month holds: the episodes of a run are taken by
first day, whatever their order in the facts, and the days past the pay
left by the earlier ones are dropped. Their pay is rounded once on the days
counted so far, not band by band, so the sick days of a month never deduct
more than the monthly pay. The run deducts the daily pay of the sick days (absence_deduction) and pays
back the INPS share as sickness_inps_item (outside the contribution base,
the days are covered by figurative contributions) and the employer share
and carenza pay as sickness_item. TFR keeps the full monthly pay (art.
2120 c. 3 c.c.).
The closing state records each episode up to its last processed day
(EmploymentAccrualState.sickness_episodes); OpeningBalances takes the
same records for a worker taken over from another provider. Each episode
records one sickness decision with the classified days in its inputs.
| Missing | Effect |
|---|---|
CCNL work_rules.sickness_rules |
Nothing posted; incomplete issue sickness_rule_missing |
| CCNL daily quota | Nothing posted; incomplete issue sickness_daily_quota_missing |
Two engine limitations stay open: the INPS share uses the CCNL daily quota
of the month instead of the INPS daily base of the month before
(sickness_inps_daily_base), and, for a CCNL without a cumulation, tiers
and comporto count one relapse chain, not the CCNL window across episodes
(sickness_cumulation_window).
Sickness counted over several episodes¶
work_rules.sickness_rules.cumulation holds the rules of a CCNL that
counts the sickness of several episodes. Metalmeccanici Federmeccanica
(Sez. Quarta Titolo VI Art. 2) is the first:
| Seniority | Full pay | Then | Comporto breve |
|---|---|---|---|
| up to 3 years | first 122 days of the chain | 80% | 183 days |
| 3 to 6 years | first 153 days | 80% | 274 days |
| over 6 years | first 214 days | 80% | 365 days |
- The chain sums the days of consecutive episodes; it restarts for an episode that starts after at least 61 calendar days of work.
- The comporto counts the sick days of the three years that end on the
day; a day past it is left out with an
incompleteissue (the comporto prolungato and the days added for a certified disability are not modelled). - From the fourth short absence (at most 5 days) of a calendar year, the
first three days are paid 66%, from the fifth 50%, unless the CCNL
exempts the absence (
SicknessEpisode.short_absence_exempt). - The band is the one of the seniority on the first day of the episode.
A seniority band can count over its own window (window_months: Logistica
Confetra art. 63, 24 months up to 5 years of seniority and 30 above) and pay
the days past its full-pay days by steps (steps, by day of the count: the
PA comparti pay 90% for 3 months and 50% for the next 6); a day in no step
takes reduced_integration_rate.
Without reset_after_days there is no chain: the full-pay days are counted
over the same window as the comporto. The CCNL lavoro domestico (Art. 27
cc. 4-7) pays the sickness within the comporto, 50% up to the third day
and 100% from the fourth, for at most 8, 10 or 15 days "complessivi
nell'anno" by seniority, and keeps the job 10, 45 or 180 days in the 365
days from the event.
A fact the engine does not know raises a provisional issue, a blocker,
only when it could change a day the run pays:
| Issue | When | Settled by |
|---|---|---|
sickness_history_unknown |
days before the recorded history could pass a threshold | OpeningBalances.sickness_known_from (the hire date when the imported episodes are complete), or Employment.employment_period |
sickness_seniority_unknown |
the counts pass the days of the first band | Employment.seniority |
sickness_short_absence_exemption_unknown |
a short absence could be reduced | SicknessEpisode.short_absence_exempt |
sickness_fixed_term_prior_work_unknown |
a fixed-term episode without its days of work before it | SicknessEpisode.prior_worked_days |
sickness_seniority_band_changes |
the band changes within the days paid | not modelled |
sickness_hospital_stays_unknown |
a reduced day of a CCNL that pays long hospital stays in full (Federmeccanica: over 10 consecutive days, up to 61 days) | SicknessEpisode.hospital_stays |
sickness_hospital_stay_not_modelled |
a reduced day of a CCNL whose hospital rule is not in the bundle | not modelled |
sickness_serious_illness_unknown |
a reduced day of a CCNL that treats a particularly serious illness apart (Logistica art. 63: 18 months in full, outside the comporto; Concia art. 60 B: comporto 2, 3, 4 months longer) | SicknessEpisode.serious_illness |
sickness_fixed_term_proportion |
a fixed-term contract (periods scaled to its length) | not modelled |
An import without sickness_known_from lists the sickness of its tax year
only, from 1 January; the engine's own chained state lists every sick day
of the employment.
A month whose days differ from the divisor¶
No CCNL text in the bundle says how a month of sickness is deducted when
its payable days differ from the divisor, so the run raises the
provisional issue sickness_month_quota_mismatch when every payable day
of a fully posted month is sick yet part of the pay is left (24/26 of a
February by 26), or a payable day is worked yet the sick days deduct the
whole pay (sick 1 to 30 of a 31-day month by 30).
SickLeaveEvent remains as an explicit override: an amount the caller
computed. It records a caller-supplied sickness decision, so the result is
never payable.
Absences are bounded by the pay of the run¶
Unpaid absences that deduct more than the monthly pay of the run raise
InvalidInputError before any amount is computed: check the hours and the
hourly rate. The sick days of SicknessEpisode are capped at the monthly
pay by construction (Sickness). An AbsenceEvent on a day of
an episode raises InvalidInputError; on another day of the month it is
deducted at the caller's rate beside the sick days at the CCNL quota, with
the provisional issue sickness_with_unpaid_absence, and when the two
exceed the pay the run raises OutOfScopeError with reason
sickness_with_unpaid_absence. Absences below the pay can still leave less than the IRPEF and
surtax due on the run (the withholding follows the projected annual income).
The taxes are then withheld up to the pay left and the rest is carried to
the next runs of the tax year
(Fiscal). A run whose other
deductions (INPS, substitute tax, recovery installments) exceed the pay
left still raises OutOfScopeError with reason withholding_shortfall.
Fringe benefits¶
For tax years 2025 to 2027 goods and services granted to the worker are
exempt from IRPEF and INPS up to 1,000 EUR in the year, or 2,000 EUR when the
worker has children "che si trovano nelle condizioni previste dall'articolo
12, comma 2" TUIR and declares them to the employer with their tax codes
(L. 207/2024 art. 1 cc. 390-391, derogating TUIR art. 51 c. 3). The engine
derives the condition from PeriodFacts.family_composition: declare every
child, a minor too, as a Dependent once the worker has made the
declaration. A child counts when its dependency interval touches the tax
year and its own_income is within the limit of art. 12 c. 2 (2,840.51
EUR, 4,000 EUR for a child who turns at most 24 in the year); the age band
and the residency condition of the art. 12 deduction do not apply. When the
condition is unknown (no family composition, or a child's own_income left
None and no other child within the limit) the 1,000 EUR threshold is
applied and, if the 2,000 EUR one would tax another amount, the decision is
provisional with an incomplete issue fringe_threshold_undetermined
(fact="own_income" when a composition is given), so the run is not
payable.
The threshold is all or nothing (AdE circ. 4/E of 16 May 2025, par. 2.7): an
amount equal to the threshold is still exempt, but once the year total
exceeds it the whole amount of the year is taxable, not only the excess. The
year total comes from opening_state.cash.fringe, so chain the closing state
of each run into the next. The benefit that crosses the threshold makes the
earlier exempt amounts of the year taxable in its run. For example, 600 EUR
in February is exempt; another 600 EUR in March brings the year to 1,200 EUR
and makes 1,200 EUR taxable in March; a further 300 EUR in April is taxable
on its own.
Each FringeEvent reports:
- on its
FringeBenefitItem:threshold_annual,ytd_total(year total including the benefit) andtaxable_amount(which can exceedamounton the crossing benefit); - a
fringe_benefitdecision inresult.decisions, with reasonwithin_threshold,above_thresholdorabove_threshold_retroactive, the threshold and the children condition, the year totals, theretroactive_amountand the taxable amount asamount.
Substitute-tax regimes¶
The prior-year employment income and the written waivers that decide
eligibility for the substitute-tax regimes are declared once, in
PriorYearTaxFacts on the input, not on each event. The sector is declared on
Employment and the employer activity on EmployerProfile. An unknown fact
means ordinary taxation; for night, holiday and shift supplements and for
renewal increments the result is then provisional. A renewal increment
(BonusEvent with kind="contract_renewal") carries the signing date of its
renewal in agreement_signed_on. See
Substitute tax regimes.
Renewal arrears¶
Art. 17 c. 1 lett. b TUIR taxes separately the "emolumenti arretrati per
prestazioni di lavoro dipendente riferibili ad anni precedenti" received
by effect of a collective agreement; art. 21 c. 1 sets the rate on half
the income of the two years before the year of receipt, which the engine
does not know: the caller supplies it as separate_tax_rate.
reference_period is compared with the tax year of the run (a December
paid by 12 January belongs to its year):
reference_period |
Taxation | contract_renewal_arrears decision |
|---|---|---|
| An earlier tax year | Separate, amount x separate_tax_rate on separate_tax (code 1002) |
separate_taxation, final |
| The tax year of the run | Ordinary IRPEF with the run; the rate is not used | ordinary_taxation, final |
None |
Separate, as a simulation, with an arrears_reference_period_unknown issue and a missing_fact reference_period blocker |
reference_period_unknown, incomplete |
| A later year | InvalidInputError |
The arrears enter the INPS base of the run in every case.
Reading the result¶
Each event produces pay items and ledger entries on the period result. A
condition that lowers the reliability of the result (for example an unknown
prior-year income) is reported in result.issues and blocks
result.is_payable. See Assurance and payability.