Pay runs and warnings¶
A pay run (hr.payslip.run) groups the payslips of a period and a salary structure, so that
they are generated, computed, validated and paid together. Payroll warnings
(hr.payroll.warning) are configurable checks, written as a domain or as Python code, displayed
on the payroll dashboard and on the payslips, where they can block the validation.
Ver también
Payslips for the computation and the states of the payslips.
Pay runs¶
A pay run has a period (date_start, date_end), a structure (structure_id), a company and
optionally employee types (employee_type_ids). Its versions (version_ids) are the employee
versions to pay, and its payslips (slip_ids) point to it with hr.payslip.payslip_run_id. A
payslip without pay run is an off-cycle payslip.
When the structure has a pay schedule, the period defaults to the period following the last done
or paid pay run of the structure (_compute_date_start(),
_compute_date_end()), and the name to the period, e.g.
Nov 2026.
Pay run states¶
state is computed from the versions and the payslips
(_compute_state()), so writing it has no lasting effect:
00_draft(Draft)Some versions have no payslip yet.
01_ready(Ready)At least one payslip is draft.
02_close(Done)No draft payslip, at least one validated payslip.
03_paid(Paid)No draft or validated payslip, at least one paid payslip.
04_cancel(Cancelled)Otherwise: all the payslips are cancelled (or the pay run has neither version nor payslip).
Generating the payslips¶
The versions of a new pay run are computed at its creation
(_compute_version_ids()) by
_get_valid_versions(): one version per employee contract
running in the period, among the versions of active employees of the company with the structure
type of the structure, started before the end of the period, and of the employee types of the
pay run (and of the structure, if it restricts them). See
_get_valid_versions_domain_payrun().
Then:
_generate_payslips()creates one payslip per version without payslip, for the period of the pay run and its structure, and computes them;action_open_payslips()(the button) generates the missing payslips of a draft pay run, then opens them;action_start_payrun()creates a pay run from values, generates its payslips and opens them.action_start_payrun_with_warnings(), used by the user interface, first checks for common issues (_get_start_payrun_warnings(): employees to review, time off to approve, no time off at all in the period) and displays them before starting.
Versions can be added to an existing pay run with
action_add_versions() or replaced with
action_assign_versions() (both take employee ids).
Nota
Employee types with payroll_auto_post get their monthly pay run created, computed and
validated by a cron
(_cron_generate_payrun_for_employee_types_with_auto_post()).
Pay run actions¶
The actions of the pay run apply the payslip actions to its payslips:
action_confirm()Computes the draft payslips.
action_re_compute_payslips()Recomputes the draft payslips of a draft or ready pay run.
action_validate()Validates the computed, non-cancelled payslips (
hr.payslip.action_payslip_done()). Users who are not payroll officers cannot validate a pay run with warnings or errors.action_paid()Marks the payslips as paid.
action_unpaid()Sets the paid payslips back to validated.
action_draft()Sets the payslips back to draft, which regenerates their worked days and lines. Refused if a payslip is paid.
action_correct()For a done or paid pay run: creates a correction pay run holding a refund and a correction of each payslip (see Refunds and corrections), and opens it.
off_cycle_version()Removes a version from the pay run; its payslips become off-cycle payslips.
Example
from datetime import date
struct = env.ref('hr_payroll.structure_002') # Regular Pay
run = env['hr.payslip.run'].create({
'date_start': date(2026, 11, 1),
'date_end': date(2026, 11, 30),
'structure_id': struct.id,
})
run.name, run.state, len(run.version_ids) # ('Nov 2026', '00_draft', 13)
slips = run._generate_payslips() # created and computed
len(slips), run.state, set(slips.mapped('state')) # (13, '01_ready', {'draft'})
run.gross_sum, run.net_sum # (98896.0, 81828.5)
run.action_validate()
run.state # '02_close'
run.action_paid()
run.state # '03_paid'
run.action_correct() # returns the action opening the correction pay run
correction_run = env['hr.payslip.run'].search([('name', '=', 'Nov 2026 (Correction)')])
correction_run.state, len(correction_run.slip_ids) # ('01_ready', 26)
set(correction_run.slip_ids.mapped(lambda slip: (slip.is_refund_payslip, slip.state)))
# {(True, 'validated'), (False, 'draft')}: validated refunds, draft corrections
Payroll warnings¶
A payroll warning finds the records needing attention: employees without bank account,
payslips without running contract, time off to approve, etc. Its records belong to model_id
(hr.employee, hr.version, hr.payslip or hr.leave, see
_get_allowed_models()), and it is displayed:
on the payroll dashboard (
display_on_dashboard): one card per warning with the number of records, a due date and a button opening them;on the records (
display_on_model): only payslips are supported by the computation of the issues (see below).
A warning applies to the companies of its country_id, or to all companies without country.
warning_color_class gives its color and, on payslips, its level: danger is an error,
anything else a warning. block_payslips prevents the validation of the payslips concerned
(the payslips themselves, or the payslips of the employees matched).
Domain and Python warnings¶
warning_type decides how the records are found:
domainwarning_domainis a domain on the model, restricted to the allowed companies (and the country of the warning) by_get_warning_domain_records(). Withis_in_contract, the dashboard only keeps the records with a running contract (for employees and versions).pythonevaluation_codeis executed withsafe_evalby_get_warning_python_records(). It must setwarning_records(a recordset), and may set:warning_details:{record: [{'message': str, 'level': 'danger' or 'warning', 'action_text': str, 'action': dict}]}, the issues to display on each record instead of the description of the warning;warning_title,warning_description,warning_count,warning_button,warning_action,warning_deadline: overrides of the dashboard card;additional_context,warning_multi_results(several cards from one warning).
The code can use
self(the warning, henceself.env),model(empty recordset of the model),records,date,datetime,relativedelta,dateutil,float_compare,defaultdict,jsonand_get_payroll_translation. When computing the payslip issues,recordsandactive_idsare the payslips to check; on the dashboard, the code also getscompany,closing_date,Domainandliteral_eval, and searches the records itself.
Payslip issues¶
_compute_issues() evaluates the active
warnings displayed on hr.payslip records on the draft and validated payslips (and the paid
payslips with outdated data). It stores:
issues: a JSON dict{index: {'message', 'level', ...}};error_count(dangerissues) andwarning_count.
Payslips with errors cannot be validated nor paid. The issues are recomputed when one of the
fields of _issues_dependencies()
changes; localizations adding payslip warnings on other fields extend it.
Example
A blocking domain warning and a Python warning giving details per payslip:
warning = env['hr.payroll.warning'].create({
'name': 'High net salary',
'description': 'Net salary above 6000.',
'warning_type': 'domain',
'model_id': env['ir.model']._get_id('hr.payslip'),
'warning_domain': "[('net_wage', '>', 6000)]",
'warning_color_class': 'danger', # error: blocks the validation
'display_on_dashboard': False,
'display_on_model': True,
'country_id': False,
})
python_warning = env['hr.payroll.warning'].create({
'name': 'Unpaid days',
'warning_type': 'python',
'model_id': env['ir.model']._get_id('hr.payslip'),
'display_on_dashboard': False,
'display_on_model': True,
'country_id': False,
'evaluation_code': """
warning_details = defaultdict(list)
for payslip in records.filtered(lambda p: p.state == 'draft'):
unpaid_wds = payslip.worked_days_line_ids.filtered(lambda wd: not wd.is_paid)
unpaid = sum(unpaid_wds.mapped('number_of_days'))
if unpaid:
warning_details[payslip].append({
'message': '%s unpaid days' % unpaid, 'level': 'warning',
})
warning_records = records.filtered(lambda p: p in warning_details)
""",
})
# the employee of the payslip examples, with 2 days of unpaid time off in November
employee = env['hr.employee'].search([('name', '=', 'Ronnie Hart')])
slip = env['hr.payslip'].create({'employee_id': employee.id, 'date_from': date(2026, 11, 1)})
slip.compute_sheet()
slip.net_wage, slip.error_count, slip.state_display # (6079.05, 1, '01_error')
[issue for issue in slip.issues.values()
if issue['message'] in ('Net salary above 6000.', '2.0 unpaid days')]
# [{'message': 'Net salary above 6000.', 'level': 'danger'},
# {'message': '2.0 unpaid days', 'level': 'warning'}]
slip.action_payslip_done()
# ValidationError: • Net salary above 6000.
records, details, *_ = python_warning._get_warning_python_records({'active_ids': slip.ids})
dict(details)
# {hr.payslip(...): [{'message': '2.0 unpaid days', 'level': 'warning'}]}
(The payslip may also show the issues of the warnings installed by the modules.)
Dashboard¶
The dashboard loads progressively:
get_payroll_dashboard_data() returns the pay run
closing dates of the company and the ids of the applicable warnings (company country or no
country, not snoozed), then
get_payroll_dashboard_warning_cards() evaluates them
and returns one card per warning, pay schedule and due date
(_serialize_card(): name, description, count,
color_class, warning_date, warning_records, button_name, button_action, …). A warning
that fails gives an error card instead of breaking the dashboard.
The due date of a card (_get_warning_date()) is the
reference date of closing_on (next pay run closing date, contract start or end, end of the
month, quarter or year, …) shifted by warning_offset_days. A warning with an email_message
sends an email email_alert_days after its due date
(_cron_payroll_warning_email_alert()).
snooze_date hides a warning until that date.
action_payslip_done() also uses
get_payroll_dashboard_warning_cards(), with
block_payslips=True, to find the blocking warnings.
Pitfalls¶
The state of a pay run is computed: change the state of its payslips instead.
_generate_payslips()raises aUserErrorwhen the pay run has no version, and skips the versions of another structure type than the structure of the pay run.A Python warning must set
warning_records; an exception in its code raises aUserErrornaming the warning (on the dashboard, an error card).warning_color_class = 'danger'blocks the payslips through their issues, whileblock_payslipsblocks them through the dashboard warnings: both prevent the validation.Payslip issues are not computed in tests unless the context has
hr_payroll_force_compute_issue.