Engine¶
The engine takes a payroll scenario (the employment, the employer, the facts
of the run and the applicable rules) and returns a fully itemised
PeriodResult. It is a pure
function: given the same inputs and the same knowledge base version, it always
produces the same output.
Entry point: PayrollEngine¶
Construct the engine with PayrollEngine.bundled() and call
calculate_period() for a single pay run or calculate_competence_year() for a full
year:
from datetime import date
from ccnl_engine import (
EmployerProfile,
Employment,
Headcount,
PayrollEngine,
PayrollRun,
PeriodInput,
)
from ccnl_engine.inputs import Permanent
engine = PayrollEngine.bundled()
employment = Employment(
ccnl_slug="metalmeccanico-federmeccanica.json",
level_code="C3",
contract_type=Permanent(),
)
employer = EmployerProfile(headcount=Headcount(50))
result = engine.calculate_period(
PeriodInput(
run=PayrollRun.regular(year=2026, month=1),
payment_date=date(2026, 1, 28),
employment=employment,
employer=employer,
)
)
print(result.is_payable, [blocker.code.value for blocker in result.blockers])
print(result.period_gross)
print(result.period_net)
To add period-specific events (overtime, absences, benefits), pass them in
the PeriodFacts of the run:
from decimal import Decimal
from ccnl_engine import PeriodFacts
from ccnl_engine.events import OvertimeEvent
result = engine.calculate_period(
PeriodInput(
run=PayrollRun.regular(year=2026, month=1),
payment_date=date(2026, 1, 28),
employment=employment,
employer=employer,
facts=PeriodFacts(
events=(
OvertimeEvent(
event_date=date(2026, 1, 10),
hours=Decimal(8),
hourly_rate=...,
# multiplier omitted: 1 + the CCNL weekday band
),
),
),
)
)
calculate_period() returns a PeriodResult with its assurance, issues, decisions,
gross, net, pay items, the closing state and a full ledger of every
accounting entry. See API: ccnl_engine.
Tax year and payment date¶
payment_date is required on every PeriodInput. It selects the tax year
of the run (TUIR art. 51 c. 1, TaxYearPolicy), while the run year and month
keep selecting the contractual values (salary table, seniority, allowances):
| Payment of a December 2026 run | Tax year | Rule |
|---|---|---|
| any day of December 2026 | 2026 | cash |
| 1 to 12 January 2027 | 2026 | cassa allargata |
| 13 January 2027 or later in 2027 | 2027 | cash |
| June 2028 | 2028 | cash |
- The 12 January extension covers only runs of the previous year: December 2025 paid on 10 January 2027 belongs to 2027.
- A payment before the first day of the run month raises
InvalidInputError. - There is no upper bound on the payment date. Separate taxation of arrears (TUIR art. 17) is not modelled.
- The tax tables, INPS rates, surtax tables and year-to-date state are those
of the attributed tax year. A year the bundle does not ship (2027 today)
raises
UnsupportedTaxYearErrorwith the year, instead of computing the run with the rules of another year. - A CCNL rule the run needs with no value on the competence date (the date
precedes the first tranche of the bundle, or falls in a gap the data
declares, such as the seniority amounts of
grafica-editoria-aiegbefore July 2026) raisesMissingRuleErrorwith the CCNL (ruleset), thefeature, the date (as_of), the declaredgap_kindand aremediation. A rule is read only when it pays something: a worker with no seniority increment due is computed even where the increment amounts are missing. opening_state.tax_year, when set, must match the attributed tax year: a December 2026 run paid on 13 January 2027 does not close into the 2026 state and raisesInvalidInputError. Open the new year withPayrollEngine.close_tax_year()on the closing state of the last run of the previous year: it resets the tax cash state and carries the obligations, such as an installment recovery, and the competence runs already closed. See Payroll state and the year change.opening_stateholds the history of the employment: the zero state is the fact only for the first run of an employment whose start is stated, and the INPS base of the worker's other employments is a fact too; a run without them is not payable. See Opening state and imported balances.
Full year: calculate_competence_year()¶
calculate_competence_year() runs every payslip of a competence year. The
calendar is derived from the CCNL additional_months: tredicesima in
December and, when granted, quattordicesima in June. The runs follow from
that calendar; the IRPEF withholding schedule follows from the payments
actually made in each tax year, so a December paid after 12 January settles
the conguaglio of the year on its tredicesima and opens the next tax year
(see Payroll state).
calculate_tax_year(TaxYearPlan(...)) computes every payment of one tax
year instead, late payments of an earlier competence year included.
from ccnl_engine import CompetenceYearPlan
commercio = Employment(
ccnl_slug="commercio-confcommercio.json",
level_code="4",
contract_type=Permanent(),
)
year = engine.calculate_competence_year(
CompetenceYearPlan(year=2026, employment=commercio, employer=employer)
)
print(len(year.period_results)) # 14: 12 regular runs, tredicesima, quattordicesima
Each run takes its PeriodFacts from CompetenceYearPlan.periods, keyed by month
(1-12, the regular run of that month) or by run id (for example
"2026-12-thirteenth"), and otherwise from default_facts. An entry replaces
default_facts for its run, so repeat the region and the family in it, for
example with dataclasses.replace(default_facts, events=...).
default_facts must carry no event. Naming a run twice (by month and by run
id) raises InvalidInputError. year.closing_state is the state after the
last run; year.next_opening_state opens the next competence year.
A different calendar is accepted only as a CalendarOverride with a
CalendarOverrideReason and a non-blank note. The override is checked
against the CCNL calendar and raises InvalidInputError when a rule fails:
| Reason | Allowed | Rejected |
|---|---|---|
PAYMENT_MONTH |
another payment month, e.g. quattordicesima in July | any change of the extra months or their fractions |
MORE_FAVOURABLE_TREATMENT (art. 2077 c.c.) |
adding an extra month or raising a fraction | an override that grants nothing more |
No reason allows dropping or lowering an extra month the CCNL grants. Every
extra month must accrue over the 12 months ending in its payment month
(accrual_window_start_month == payment_month % 12 + 1, which
WorkCalendar.from_additional_months sets): any other window pays a
full-year worker less than the fraction, so a tredicesima stays in December. Paying
the ratei monthly (mensilizzazione) is not supported: the engine does not
pay ratei inside regular runs. The effective calendar and the override are
returned on the year result as calendar and calendar_override.
Every run is paid on payment_day of its own month, 28 by default; any day
from 1 to 28 is accepted, another raises InvalidInputError. A
quattordicesima whose day the CCNL fixes (parameters.fourteenth_payment_day)
is paid on that day unless the plan overrides the calendar: the Commercio
quattordicesima of June is paid on 1 July (CCNL Terziario art. 221).
payment_dates names the date of a run, keyed like periods: an employer
that pays in arrears pays each month on the 10th of the next one and may pay
the extra months before the salary of their month. A date before the first
day of its run month, or of a run the year does not compute, is rejected.
tenth = engine.calculate_competence_year(
CompetenceYearPlan(year=2026, employment=commercio, employer=employer, payment_day=10)
)
print(tenth.period_results[0].payment_date) # 2026-01-10
Employment period¶
Employment.employment_period selects the runs of the year:
- a regular run for every month with at least one employed day;
- an extra-month run only when its payment month is such a month, so a worker employed from July to September has no tredicesima or quattordicesima run;
- one IRPEF withholding slot per selected run, so the conguaglio settles on the last run of the employment;
- an employment with no day in the year raises
InvalidInputError.
A month the employment covers only in part (hire on the 15th, end before the
last day) keeps its run and pays the daily quotas of its employed days, in
calculate_period and in the year plans alike. The quota is the one the CCNL
sets for unpaid absences, work_rules.absence_rules.daily_divisor_method,
with its provenance:
by_22: one twenty-second per employed Monday to Friday (a five-day week);by_26: one twenty-sixth per employed Monday to Saturday (Sundays are not paid days, a public holiday on a weekday is);by_30: one thirtieth per day of a 30-day commercial month (a span to the month end runs to day 30, day 31 counts as day 30);by_hourly:daily_hours / hourly_divisorper employed Monday to Friday;by_month_hours: one share per employed Monday to Friday out of the Mondays to Fridays of the month (weekday holidays included), for a CCNL that deducts unworked hours in proportion to the working hours of the month. Sick days use the same divisor.
Each pay component is prorated and rounded on its own, after the part-time
ratio, and never above one monthly pay (2 March 2026, a Monday after a
Sunday 1st, pays the whole of March). The base_salary decision has reason
pay_chain_prorated and records employed_from, employed_until,
divisor_method, payable_days and divisor; the absence rule and, for
by_hourly, the hourly divisor join the rules of base_salary.
The monthly pay of a competence month is posted once, by the run that
closes the month first. The regular run posts it. A termination run posts
it, prorated the same way, only when the regular run of its month is not
closed in the opening state (a termination closes the competence year, so
no regular run of the month can follow it); after the regular run it posts
no monthly pay, only its own items (events, TFR, the second conguaglio). An
adjustment run corrects a run already closed and never posts the monthly
pay. A run that posts none has a base_salary decision with reason
monthly_pay_posted_by_another_run, amount 0.00 and input
monthly_pay_posted_by (the regular run id, or corrected_run).
Extra-month runs follow the accrual rule below.
A CCNL whose data define no daily quota (no absence_rules, or by_hourly
without daily_hours or an hourly divisor in force) never pays the month in
full: the run posts no pay, the base_salary decision is provisional with
reason partial_month_rule_missing and no amount, and the
partial_month_rule_missing issue (incomplete) makes it not payable.
Extra months accrue per qualifying month of their window, counted from the employment dates and never from the runs already closed:
- the 12-month window ends in the payment month and starts at the hire date when the worker was hired inside it (hire on 15 March: the June quattordicesima accrues March to June, 4/12);
- a month qualifies when its accruing calendar days pass the CCNL accrual
rule,
parameters.accrual_rule: a threshold (min_days) and a comparison,more_than("la frazione di mese superiore a 15 giorni va considerata come mese intero", Metalmeccanico Federmeccanica, sez. quarta, titolo IV, art. 7) orat_least("superiori o uguali a 15 giorni", Terziario Confcommercio, art. 204). Some CCNLs accrue days instead, with no threshold (min_daysleft out):working_days_26, a month not worked in full accrues its days from Monday to Saturday in twenty-sixths of a rateo (Turismo 2024, art. 160 c. 2);days_365anddays_360, a twelfth per whole month and a 365th or 360th of the year per day of a month served in part (Sanità 2022-2024 art. 60 c. 4; Area I dirigenza 2006 art. 63 c. 4);service_days_365, a 365th of the year for every day served (Funzioni Locali 2022 art. 75 c. 3);cumulated_fractions, a rateo per whole month, the days of the months worked in part summed into a rateo for each 30 and one for a remainder of 15 or more (Pubblici Esercizi art. 183 c. 2). A clause a renewal changed carries itsvalid_fromand thepreviousclause: each month of the window is counted with the clause in force on its first day (Turismo: cumulated fractions until 30 June 2024, twenty-sixths from 1 July). The decision then records theday_unitsaccrued beyond the whole months. A threshold can count working days instead of calendar days (working_days: Monday to Saturday, Cooperative sociali art. 79, "superiore ai 15 giorni lavorativi"). The rule is stored only for the 66 CCNLs whose signed text was read, with the article and the quote (statusassumeduntil a named reviewer checks it). The other CCNLs use the engine default, at least 15 days, recorded as amissingrule: a run whose window has a partly accrued month then addsrule_source_missingand isincomplete; a window of whole months does not read the rule. Each rateo paid records abase_salarydecisionextra_month_ratei_countedwith the months, the partly accrued months, the threshold, the comparison and whether the rule is the CCNL clause (ccnl), the default (engine_default) or one the caller built (request). A caller building theExtraMonthAccrualof acalculate_periodrequest can pass its ownMonthAccrualRule(min_days=15, comparison=AccrualComparison.MORE_THAN)(fromccnl_engine.extra_month.modelsandccnl_engine.compensation.models); - an
AbsenceEventwithsuspends_accrual=True(for example aspettativa non retribuita) removes its calendar days from every window. The caller says which absences suspend accrual:Falsefor an ordinary unpaid absence, which reduces pay, not the ratei. LeftNone, the days count as accruing and a rateo they could change isprovisional, with amissing_factblocker forsuspends_accrual. Absences of the previous year are not known, and a singlecalculate_period()call on an extra-month run counts from the employment dates only; - when the employment ends before an extra month's payment month, the ratei
accrued up to the termination are paid on the last regular run as
extra_month_earningitems (ordinary IRPEF, INPS and TFR base). A quattordicesima whose June run was already paid restarts in July, so an end in September liquidates 3/12 of the next one; - on a CCNL with a
raccordo_element(the ERC of the CCNL grafici editoriali), the run that pays or liquidates the tredicesima also paysEmployment.erc_amountx its months / 12 as araccordo_element_earning(ordinary IRPEF and INPS, outside the TFR base). An unstated ERC leaves it out with amissing_factblockererc_unknown; - on a CCNL with an
hourly_pay(the operai of the CCNL edilizia industria), the run that posts the monthly pay of a worker of one of itscategoriespays the pay chain over the hourly divisor timesPeriodFacts.ordinary_hours_worked(a part-time worker the full hourly rate on the hours worked), and each of itspercentagesof the base salary and the named allowances as anhourly_percentage_earning(ordinary IRPEF, INPS and TFR base): settlementcashis paid with the pay (riposi annui 4.95%), settlementcassa_edileis accrued with the Cassa Edile net of the withholdings (18.5%, Allegato D), so itsnet_rateof the base (14.2%) is withheld from the net pay as anemployee_withholding_itemonbilateral_fund_employee. Ferie and permit hours are not paid as days, sick days deduct nothing, anAbsenceEventis rejected withInvalidInputError(its hours are already left out of the hours worked), the IRPEF projection adds the percentages of a full month to each regular payment to come, and thebase_salarydecision has reasonpay_chain_paid_by_the_hour. Unstated hours pay nothing, with amissing_factblockerhourly_pay_hours_unknown.
The work deduction (art. 13 TUIR), the ulteriore detrazione (L. 207/2024 art. 1 c. 6) and the trattamento integrativo are proportioned to the days of employment in the tax year, at most 365.
CCNL and level validity does not drop runs: a month the salary table does not cover fails in the salary lookup.
from datetime import date
from ccnl_engine.inputs import EmploymentPeriod
short = engine.calculate_competence_year(
CompetenceYearPlan(
year=2026,
employment=Employment(
ccnl_slug="commercio-confcommercio.json",
level_code="4",
contract_type=Permanent(),
employment_period=EmploymentPeriod(date(2026, 7, 1), date(2026, 9, 30)),
),
employer=employer,
)
)
print(len(short.period_results)) # 3: July, August, September
print(short.annual_gross) # 6243.15: three months plus 3/12 of each extra month
from ccnl_engine.inputs import CalendarOverride, CalendarOverrideReason, WorkCalendar
july = CalendarOverride(
calendar=WorkCalendar.from_additional_months(
2026, 14, fourteenth_payment_month=7
),
reason=CalendarOverrideReason.PAYMENT_MONTH,
note="quattordicesima paid with the July salary",
)
Computation chain¶
The engine applies rules in a fixed sequence:
1. Resolve time-series values (base salary, seniority amounts, hourly divisor)
↓
2. Apply apprenticeship percentage (base and allowances flagged
apprenticeship_pct_relevant; the apprentice seniority amount is paid in
full), then part-time coefficient
↓
3. Add fixed allowances
↓
4. Compute INPS contributions (employee + employer, NASpI surcharge of a fixed term)
↓
5. Compute TFR accrual (Art. 2120 c.c.), less the 0.50% additional IVS
(L. 297/1982 art. 3 c. 16), to the company, the Fondo Tesoreria or
the pension fund; a TFR conferred to a fund cuts the employer
contributions (D.Lgs. 252/2005 art. 10); on the December run, decide
the revaluation of the fund at 31 December (art. 2120 c. 4 c.c.)
↓
6. Compute employer contractual funds
↓
7. Compute IRPEF: bracket tax → work-income deduction → trattamento integrativo
↓
8. Apply regional + municipal surtax (when jurisdiction is provided)
↓
9. Apply Art. 12 family deductions and Art. 15 mortgage interest deduction
(reduce irpef_net / net_annual; only when inputs are provided)
↓
10. Compute L3 work-rules supplements (informational, do not mutate gross/net):
overtime pay, absence deduction, leave accrual, sick-pay integration,
fringe benefits, welfare, PdR bonus
↓
11. Assemble PeriodResult: gross, net, employer cost, issues, decisions,
capability report, rulesets; the assurance is derived from them
Steps 7–9 are fiscal and can be parameterised heavily. See Fiscal for the full reference. Step 10 is optional; see Work rules.
Input types¶
| Type | What it describes |
|---|---|
PeriodInput |
One run: PayrollRun, payment date, employment, employer, facts of the run, prior-year facts, opening state |
CompetenceYearPlan |
Every run of a competence year: employment, employer, prior-year facts, periods and default_facts, calendar override, payment day and payment_dates, opening state |
TaxYearPlan |
Every payment of a tax year: the competence years whose runs are paid in it and the opening state |
OpeningBalances |
Totals, payments, competence runs and INPS bases of another provider, imported with engine.import_opening_balances() |
Employment |
CCNL slug, level, contract type, category, EmploymentPeriod, WeeklyHours, SeniorityFact, roles, ContributionHistory and sector; impossible values are rejected on construction |
EmployerProfile |
The employer: its Headcount (required, at least 1) selects the INPS rate tier; activity feeds the L. 199/2025 c. 18 exclusion |
PriorYearTaxFacts |
Prior-year employment income and written waivers, declared once and read by every substitute-tax regime |
CurrentYearTaxFacts |
Income of the tax year beyond this employment (other employers, other income, main dwelling excluded), its date and quality; read by the Art. 12 family deductions on PeriodInput.current_year, CompetenceYearPlan.current_year or TaxYearPlan.current_year |
PeriodFacts |
Events, contributable hours, region and Belfiore code, family composition of one run |
PayrollRun |
The pay run: year, month, and run kind (regular / thirteenth / fourteenth) |
FamilyComposition |
Dependent spouse, children and ascendants (Art. 12 TUIR), each with its dependency interval and its conditions (None unknown, never met); sole_parent for the first-child rule |
OvertimeEvent |
Overtime hours for a specific date |
AbsenceEvent |
Unpaid absence in the period; suspends_accrual also stops the extra-month ratei |
SicknessEpisode |
Sickness episode; the engine pays its days in each month it touches |
SickLeaveEvent |
Sick pay computed by the caller: an override, never payable |
FringeEvent |
Fringe-benefit value; the annual threshold follows the children of PeriodFacts.family_composition |
WelfareEvent |
Welfare benefit annual amount |
BonusEvent |
Bonus amount and kind (ordinary, PdR, contract renewal with its signing date) |
Full type reference: API: ccnl_engine.
Output: PeriodResult¶
The result contains every gross, net, and cost component. Key fields:
result.is_payable # whether the amounts can be paid as computed
result.blockers # every reason they cannot: code, feature, detail
result.assurance # calculation, coverage, evidence, rulesets, mode, payability
result.rulesets # rulesets read, with id, hash, kind and readiness
result.mode # simulation (default) or operational
result.issues # conditions that lowered the calculation axis
result.decisions # what each capability decided, with its inputs
result.period_gross # gross entitlement for the period (before absence deductions)
result.period_net # net pay for this period
result.period_employer_cost # total employer cost (gross + contributions + TFR accrual)
result.unpaid_absence_deduction # wages withheld for unpaid absences
result.closing_state # accrual and tax cash state: opening_state of the next run
result.pay_items # all pay items produced
result.ledger_entries # full accounting ledger
result.capability_report # what the run executed against the capability registry
CompetenceYearResult and TaxYearResult sum the runs (annual_gross,
annual_net, annual_employer_cost), keep every PeriodResult in
period_results and expose the combined assurance (and is_payable,
blockers, rulesets), the issues and decisions of their runs, the
closing_state of the last run, the payments that settled a conguaglio
(conguagli) and the next_opening_state. TaxYearResult also lists the
payments of the tax year and its conguaglio. See Trust: Assurance for how the assurance is
derived.
Guides¶
| Page | Contents |
|---|---|
| Pay components | Part-time, seniority, RAL overrides |
| Second level | Second-level agreements: what the engine does not take as input |
| Fiscal | IRPEF, family and Art. 15 deductions |
| Surtax | Regional and municipal surcharges, their tables and decisions |
| Tax credits | Ulteriore detrazione and trattamento integrativo decisions |
| Domestic work | Flat per-hour contributions, non-withholding employer |
| Work rules | L3: overtime, absence, leave, sickness, bonus, welfare |