Skip to content

Get started

Installation

pip install ccnl-engine

Requires Python 3.12+. Every pull request and every release run the API and knowledge tests on Python 3.12, 3.13 and 3.14, and smoke-test the built wheel on each.

Find a CCNL and its level

Employment takes the slug of a bundled CCNL and one of its level codes. Search the catalog by name or slug, pick the contract by slug or CNEL code and list its levels:

"""Find a CCNL and its level before the first run.

A caller does not need to know a file name: search the bundled contracts by
name or slug, pick one by slug or CNEL code, list its levels, then pass the
slug and a level code to :class:`Employment`.
"""

from ccnl_engine import Employment, PayrollEngine
from ccnl_engine.catalog import get_ccnl, search_ccnls
from ccnl_engine.inputs import Permanent

engine = PayrollEngine.bundled()

# Every bundled contract, or a search on name and slug.
print(f"Bundled CCNLs: {len(engine.list_contracts())}")
for candidate in search_ccnls("commercio"):
    print(candidate.ccnl_id, candidate.cnel_code, candidate.name)

# By slug or by CNEL code.  A CNEL code can cover more than one bundled
# contract (H052: Turismo Confcommercio, Federalberghi and Agenzie di viaggio
# Fiavet); a shared code raises UnknownCcnlError with the slugs to choose from.
contract = get_ccnl("H011")
print(f"\n{contract.name}: {contract.readiness}")

for level in engine.list_levels(contract.ccnl_id):
    print(level.code, level.description)

employment = Employment(
    ccnl_slug=contract.ccnl_id,
    level_code="4",
    contract_type=Permanent(),
)
print(f"\nEmployment on {employment.ccnl_slug}, level {employment.level_code}")

PayrollEngine.list_contracts() returns every contract; search_ccnls() matches name and slug; get_ccnl() and engine.list_levels() take a slug or a CNEL code. A CNEL code shared by several bundled contracts raises UnknownCcnlError with their slugs as suggestions.

Quickstart

The minimum working example: load a CCNL, describe the employee, compute the payroll.

"""Quickstart: compute one payroll period with PayrollEngine."""

from datetime import date

from ccnl_engine import (
    EmployerProfile,
    Employment,
    Headcount,
    PayrollEngine,
    PayrollRun,
    PeriodInput,
)
from ccnl_engine.inputs import Permanent

engine = PayrollEngine.bundled()

result = engine.calculate_period(
    PeriodInput(
        run=PayrollRun.regular(year=2026, month=1),
        payment_date=date(2026, 1, 28),
        employment=Employment(
            ccnl_slug="commercio-confcommercio.json",
            level_code="4",
            contract_type=Permanent(),
        ),
        employer=EmployerProfile(headcount=Headcount(50)),
    )
)

print(f"Gross:   {result.period_gross} EUR")
print(f"Net:     {result.period_net} EUR")
print(f"Cost:    {result.period_employer_cost} EUR")
print(f"Payable: {result.is_payable} ({len(result.blockers)} blockers)")

# Readiness of the CCNL ruleset: the same before a run and in the result.
ruleset = engine.inspect_ruleset("commercio-confcommercio")
print(f"CCNL:    {ruleset.id} is {ruleset.readiness}")

Every result says whether its amounts can be paid as computed (result.is_payable, result.blockers) and which rulesets it read, with their readiness (result.rulesets). engine.list_contracts() and engine.inspect_ruleset(ccnl_id) report readiness before any run. PayrollEngine.bundled() simulates; PayrollEngine.bundled(mode="operational") computes the same amounts but blocks payment unless the CCNL ruleset is production, which only Metalmeccanico Federmeccanica is. See Readiness.

A full year

A full year derives its calendar from the CCNL: Commercio grants tredicesima and quattordicesima, so the year has 14 runs. A different calendar needs a CalendarOverride with a reason, and an override that drops a CCNL extra month raises InvalidInputError. Employment.employment_period selects the runs: a worker employed from July to September gets three runs, and the September run also pays the 3/12 of tredicesima and quattordicesima accrued until the termination. A month employed only in part pays the CCNL daily quotas of its employed days (one twenty-sixth per Monday to Saturday for most CCNLs); a CCNL whose data define no daily quota leaves that month undetermined and not payable, never paid in full.

from ccnl_engine import CompetenceYearPlan, PeriodFacts

year = engine.calculate_competence_year(
    CompetenceYearPlan(
        year=2026,
        employment=employment,
        employer=employer,
        default_facts=PeriodFacts(regione="IT-45"),
    )
)
print(len(year.period_results))  # → 14
next_year = year.next_opening_state  # close_tax_year of the closing state

The state keeps competence and cash apart: closing_state.accrual lists the runs closed over the employment and the INPS base of each competence year, closing_state.cash the payments and year-to-date totals of the tax year. A run is paid on payment_day of its month unless payment_dates names its date, or the CCNL fixes the day of an extra month (Commercio: the tredicesima on Christmas Eve, the quattordicesima on 1 July). A December paid after 12 January is a payment of the next tax year (TUIR art. 51 c. 1): calculate_competence_year settles the conguaglio of the year on its last payment actually made in it and opens the next tax year with the late December, and calculate_tax_year(TaxYearPlan(...)) computes every payment cashed in one tax year, late payments of an earlier competence year included. The bundle ships the tax tables of 2026 and provisional tables of 2027 (ccnl_engine.catalog.supported_tax_years()). The 2027 tables are provisional: the 2027 sources are not published yet, so they carry the 2026 values over (RulesetIdentity.provisional). Every run that reads them, a December 2026 paid after 12 January included, is computed and not payable, with the open limitation provisional_ruleset (tax) or provisional_inps_ruleset (INPS). A payment of any other tax year raises UnsupportedTaxYearError. A run opens with the history of the employment: PeriodState.zero() is the fact only for the first run of an employment whose start is stated, and a run without its history is not payable. Totals of another provider enter through engine.import_opening_balances(OpeningBalances(...)), and the INPS base of the worker's other employments of the year through CurrentYearTaxFacts.other_employment_inps_base or the imported inps_bases; see Opening state and imported balances.

CompetenceYearPlan.periods maps a month (1-12) or a run id such as "2026-12-thirteenth" to the PeriodFacts of that run; runs without an entry take default_facts.

Supported CCNLs

Every bundled CCNL is listed in the contracts index with its CNEL code, sector, estimated workforce, readiness and coverage; the list and its counts are generated from the data.

Full list with sources and per-contract examples: Contracts →

To understand the terminology used in each contract page, read Domain: What is a CCNL first.

Disclaimer

Not legal or tax advice. Always verify results against official sources or a qualified payroll professional.