Payslips¶
A payslip (hr.payslip) is the salary computation of one employee for one period: an employee,
a version (hr.version, which holds the contract data such as the wage and the schedule), a
salary structure (hr.payroll.structure, which holds the salary rules) and the dates of the
period. Its computation goes through three kinds of lines:
worked days (
hr.payslip.worked_days): the time of the period by work entry type, built from the generated work entries;inputs (
hr.payslip.input): values entered by the user or coming from the employee (salary attachments, version properties), each one read by a salary rule;payslip lines (
hr.payslip.line): the results of the salary rules (basic salary, gross, taxes, net, ...).
![digraph payslip {
rankdir=LR;
node [shape=box, style=rounded, fontname="sans-serif", fontsize=10];
edge [fontname="sans-serif", fontsize=9];
version [label="hr.version\n(wage, schedule)"];
we [label="work entry values\n(generated)", style="rounded,dashed"];
wd [label="hr.payslip.worked_days"];
input [label="hr.payslip.input"];
slip [label="hr.payslip"];
rules [label="hr.salary.rule\n(of the structure)"];
line [label="hr.payslip.line"];
version -> we [label="generate_work_entries"];
we -> wd;
wd -> slip [label="worked_days_line_ids"];
input -> slip [label="input_line_ids"];
slip -> rules [label="compute_sheet"];
rules -> line [label="line_ids"];
}](../../../../_images/graphviz-40748b32b92d1f629383e5f94e1eebc6e73490ed.png)
Xem thêm
Work entries for the generation of the work entries;
Salary structures and rules for the structures and the salary rules, including the fields of a rule and the meaning of
result,result_qty,result_rate;Pay runs and warnings for the pay runs, which create payslips in batch, and the payroll warnings.
Creating a payslip¶
Only the employee and the start date are needed; the other values are computed (stored computed fields, so they can also be given):
version_id(_compute_version_id()): the first version of a contract overlapping the period, or else the version valid atdate_from;struct_id(_compute_struct_id()): the default structure of the structure type of the version;date_to:date_fromplus the length of the pay schedule of the version (_schedule_timedelta(), e.g. the end of the month for a monthly schedule), or the context keydefault_date_to;worked_days_line_idsandinput_line_ids, see Worked days and Inputs;issues,error_countandwarning_count, see Payroll warnings.
A new payslip has no lines: call compute_sheet() to compute them.
Payslip states¶
state is draft, validated, paid or cancel. state_display adds the 01_error
(Blocked) and 02_warning states computed from the issues, for the views.
Transition |
Method |
Checks and effects |
|---|---|---|
compute |
|
Draft payslips only: the lines are deleted and computed again, |
draft → validated |
|
Payroll officer only. Refused for cancelled payslips, payslips with errors
( |
compute and validate |
|
Computes the draft payslips without lines, then validates the draft payslips. |
validated → paid |
|
Sets |
paid → validated |
|
|
→ draft |
|
Removes the payment report and |
→ cancel |
|
Payroll manager only for validated payslips. The time off get back
|
delete |
|
Draft and cancelled payslips only. |
Validated and paid payslips are legal documents: their wages are not recomputed anymore. Most
localizations extend action_payslip_done() (accounting entries,
declarations, ...).
Side effects of write¶
write() triggers part of the business logic, so writing on a payslip
is not neutral:
changing the input lines recomputes the computed draft payslips (refunds excepted);
editing the
name,amount,quantityorrateof a line (line_idsupdate commands) recomputes the lines following it with the edited values forced (_recompute_with_forced_lines()), flags the linesmanually_modifiedand the payslipedited, and logs the changes in the chatter;setting
statetodrafton a non-draft payslip callsaction_refresh_from_work_entries();setting
statetopaidrecords the payments of the salary attachments;changing
struct_idsetsignore_worked_day_lines(structure without worked days), and changingignore_worked_day_linesrecomputes the payslip.
An edited payslip is never refreshed from the work entries anymore: manual changes are kept.
Example
from datetime import date
from odoo.fields import Command
employee = env['hr.employee'].search([('name', '=', 'Ronnie Hart')]) # wage 4000/month
unpaid = env['hr.work.entry.type'].search([
('code', '=', '158.00'), ('country_id.code', '=', 'US'),
])
env['hr.leave'].create({
'employee_id': employee.id,
'work_entry_type_id': unpaid.id,
'request_date_from': date(2026, 11, 16),
'request_date_to': date(2026, 11, 17),
})
slip = env['hr.payslip'].create({'employee_id': employee.id, 'date_from': date(2026, 11, 1)})
slip.name, slip.date_to, slip.struct_id.name, slip.state
# ('Payslip - Ronnie Hart - November 2026', datetime.date(2026, 11, 30), 'Regular Pay',
# 'draft')
[(wd.code, wd.number_of_days, wd.number_of_hours, wd.is_paid, round(wd.amount, 2))
for wd in slip.worked_days_line_ids]
# [('013.00', 3.0, 24.0, True, 571.43), ('158.00', 2.0, 16.0, False, 0.0),
# ('002.00', 16.0, 128.0, True, 3047.62)]
slip.line_ids # hr.payslip.line(): not computed yet
slip.compute_sheet()
[(line.code, line.amount, line.quantity, line.rate, line.total) for line in slip.line_ids]
# [('BASIC', 3619.05, 1.0, 100.0, 3619.05), ('HRA', 4000.0, 1.0, 40.0, 1600.0),
# ('CA', 800.0, 1.0, 100.0, 800.0), ('CAGG', 600.0, 1.0, 100.0, 600.0),
# ('MA', 10.0, 16.0, 100.0, 160.0), ('SUMALW', 0.0, 1.0, 100.0, 0.0),
# ('GROSS', 6779.05, 1.0, 100.0, 6779.05), ('PF', 4000.0, 1.0, -12.5, -500.0),
# ('PT', -200.0, 1.0, 100.0, -200.0), ('NET', 6079.05, 1.0, 100.0, 6079.05)]
slip.basic_wage, slip.gross_wage, slip.net_wage # (3619.05, 6779.05, 6079.05)
The employee earns 4000 for the 168 working hours of November 2026: the 128 hours of
attendance are worth 4000 × 128 / 168 = 3047.62, the 24 hours of sick leave 571.43 and the 16
hours of unpaid leave 0. BASIC sums the worked days amounts.
Worked days¶
Worked days lines summarize the time of the period by work entry type. They are computed by
_compute_worked_days_line_ids() when the employee, version,
structure or dates change, for the structures with use_worked_day_lines (and payslips without
ignore_worked_day_lines):
The versions taken into account are the version of the payslip and the other versions of the same contract (same employee and contract dates) starting during the period: a wage change in the middle of the month gives worked days lines for each version.
The work entries of these versions are generated (see Work entries), from the day before to the day after the period.
_get_worked_day_lines()calls, for each version,_get_worked_day_lines_values(), which sums the hours of the period by work entry type (and category options) withget_work_hours(), and converts them into days with the hours per day of the schedule (_get_worked_day_lines_hours_per_day()). Days are rounded by_round_days(), to the half or whole day for the types requested by day or half day, with the typeround_days_type; the rounding difference is carried over to the type with the most hours, so the total is kept.The parts of the period outside the contract (before its start, after its end, or the whole period without contract) give an out-of-contract line, of type
000.00of the structure country (orhr_work_entry.generic_hr_work_entry_type_out_of_contract), measured on the schedule (_get_out_of_period_duration()). Its amount is 0, which prorates the wage.
The amount of a line (_compute_amount()) is
hourly rate × number_of_hours × amount_rate of its type when the line is paid (is_paid: the
amount_rate of the type is not 0). For an hourly wage, the hourly rate is the wage; for a
monthly wage, it is the wage divided by the working hours of the schedule over the payslip
period. The amount is editable; it is not recomputed on edited or non-draft payslips.
Worked days are not regenerated by compute_sheet(). After a change in
the time off, attendances, schedule or version of the period, call
action_refresh_from_work_entries() (draft payslips only; edited
payslips are skipped), which regenerates the worked days and recomputes the lines. Changing the
wage, hourly wage or payroll properties of a version refreshes its draft payslips.
Inputs¶
An input line holds a value read by a salary rule through inputs['<CODE>']. It points to the
rule itself (salary_rule_id); its code is the rule code. (There is no
hr.payslip.input.type model anymore.) Only the rules of the structure reading an input can be
chosen: rules whose condition or amount is property_input, or with input_usage_payslip. See
Rules as inputs.
Input lines come from:
the salary attachments of the employee open during the period: one line per rule code, rebuilt at each recomputation (see Payslip adjustments);
the version properties (
hr.version.payroll_properties) of the rules used as employee inputs, or the default value of the rules selected by default (_compute_input_line_ids()). Existing lines are never overwritten;the user, or the code, with
_set_input_value()and_set_input_values(): a falsy value removes the line. As the lines change, a computed draft payslip is recomputed.
Example
With the draft payslip of the previous example:
slip._set_input_value('DEDUCTION', 150)
[(line.code, line.name, line.amount) for line in slip.input_line_ids]
# [('DEDUCTION', 'Deduction', 150.0)]
[(line.code, line.total) for line in slip.line_ids if line.code in ('DEDUCTION', 'NET')]
# [('DEDUCTION', -150.0), ('NET', 5929.05)]: recomputed
Computation¶
compute_sheet() computes the lines of the draft payslips of
self (the others are ignored):
The existing lines are deleted and
compute_dateis set.Refunds get the negated lines of their origin payslip.
The other payslips are computed by employee, in chronological layers: the first payslip of every employee, then the second one, etc. This way, the cumulative rules of the second payslip of an employee (e.g. after a version change) see the first one. For each layer, the line values returned by
_get_payslip_lines()are created.The year-to-date amounts of the worked days are set.
_get_payslip_lines() evaluates the rules and returns the line values
without writing anything, which makes it the method to call to simulate a payslip:
It fetches the last payslip of each employee since the year-to-date reset date (
_get_last_ytd_payslips()) and generates the work entries of the versions of the worked days.It builds the common values of the batch (
_get_common_localdict()).For each structure, it evaluates the rules by round (
round_of_computation, ascending), for all the payslips of the structure, so that a rule of round 2 can use the results of round 1 of all the payslips. Before each round,_get_round_common_localdict()may add values.Each payslip gets one evaluation context, the localdict (
_get_localdict()), built once and kept across the rounds.The rules of the round are evaluated by
sequence. For each rule:result,result_qty,result_rate,result_nameandexplanation_infoare reset toNone,1.0,100,Falseand{};if the condition is satisfied, the rule computes its amount, quantity and rate;
the total is given by
_get_payslip_line_total()(amount × quantity × rate / 100);the total becomes a variable named after the rule code,
result_rulesis updated, and the total is added tocategoriesfor each category of the rule and its parent categories;the line explanation is the
explanation_templateof the rule, formatted withexplanation_info.
One line is returned per rule code and payslip. A rule whose code has several input lines is computed once per input line and gives one line each.
A line value contains sequence, code, name, salary_rule_id, version_id,
employee_id, slip_id, explanation, amount, quantity, rate, total, ytd and
manually_modified:
slip._get_payslip_lines()[0]
# {'sequence': 1, 'code': 'BASIC', 'name': 'Basic Salary', 'salary_rule_id': 9,
# 'version_id': 5, 'employee_id': 5, 'explanation': False, 'amount': 3619.0499999999997,
# 'quantity': 1.0, 'rate': 100, 'total': 3619.05, 'slip_id': 86, 'ytd': 3619.05,
# 'manually_modified': False}
Ghi chú
The YTD (year to date) of a line is the YTD of the line of the same code in the last
validated or paid payslip of the employee and structure since the YTD reset date of the
company, plus the total of the line. The reference date of a payslip is its date_to
(_get_ytd_reference_date(), which some localizations replace by
the payment date).
The localdict¶
The localdict is the dict in which the code of the salary rules is evaluated: every key is a
variable of the rule code, and localdict itself is also available. It is shared by all the
rules of the payslip: every variable assigned by a rule (result, but also any local variable)
stays in it for the next rules.
Key |
Type |
Usage |
|---|---|---|
|
|
The payslip record: |
|
|
|
|
|
The version of the payslip: |
|
|
|
|
|
The payroll configuration of the company valid for the payslip. |
|
|
Totals by category so far, |
|
|
|
|
|
The total of a rule already computed: |
|
|
Plain dict, missing codes raise |
|
|
Plain dict, missing codes raise |
|
|
The codes having several input lines; used by the engine. |
|
|
Hours of the worked days by category option (premium pays) and their parents:
|
|
|
The work entry values of the period, for the versions of the worked days (see Work entry values). |
|
legacy |
Never filled: use |
|
|
The |
|
outputs |
|
helpers |
|
The keys come from several methods, which are also the ways to add keys:
_get_base_local_dict(): the helpers (localizations add functions, e.g.l10n_in_hr_payrolladds_);_get_localdict(): the records, the dicts andwork_entries, for one payslip (e.g.l10n_vn_hr_payrolladds a gross-up flag);_get_common_localdict(): values computed once for the whole batch, copied in each localdict (global_line_values);_get_round_common_localdict(): values added before each round (e.g.l10n_be_hr_payrollcomputes tax exemptions shared between payslips in round 2).
categories starts with the worked days: the amount of each worked days line is added to the
categories of its work entry type (hr.work.entry.type.category_ids) and their parents. This is
why a BASIC rule can simply be result = categories['BASIC']. Structures without worked days
(or payslips with ignore_worked_day_lines) start with empty categories.
Example
Inspecting the context of a payslip in a shell, to try expressions before writing a rule:
work_entries = slip.version_id.generate_work_entries(slip.date_from, slip.date_to)
localdict = slip._get_localdict(work_entries)
worked_days, inputs = localdict['worked_days'], localdict['inputs']
categories = localdict['categories']
sorted(localdict)
# ['UserError', '_get_payroll_translation', 'categories', 'category_options', 'ceil',
# 'company', 'date', 'datetime', 'defaultdict', 'employee', 'float_compare',
# 'float_round', 'floor', 'inputs', 'payroll_config', 'payslip', 'relativedelta',
# 'result_rules', 'rules', 'same_type_input_lines', 'version', 'work_entries',
# 'worked_days']
sum(worked_days['002.00'].mapped('number_of_hours')) # 128.0
worked_days['LEAVE110'] # KeyError: 'LEAVE110'
inputs.get('DEDUCTION') # hr.payslip.input(19,)
categories['BASIC'], categories['GROSS'] # (3619.0499999999997, 0): worked days only
localdict['result_rules']['GROSS']
# {'total': 0, 'amount': 0, 'quantity': 0, 'rate': 0, 'ytd': 0}
Without argument, _get_localdict() gives an empty work_entries. The lines are not part
of the context: rules are evaluated from scratch.
Example
Two rules added to the structure of the payslip: a transport allowance per day of attendance,
read from work_entries and overridable by an input, and a contribution reading the total of
GROSS:
struct = slip.struct_id
env['hr.salary.rule'].create([{
'name': 'Transport allowance',
'code': 'TRANSPORT',
'struct_ids': [Command.link(struct.id)],
'category_ids': [Command.link(env.ref('hr_payroll.ALW').id)],
'sequence': 95, # before GROSS (100), which sums the ALW category
'input_usage_payslip': True, # the amount per day can be given on the payslip
'condition_select': 'none',
'amount_select': 'code',
'amount_python_compute': """
office_days = {
vals['date'] for vals in work_entries
if vals['work_entry_type_id'].code == '002.00'
}
result = inputs['TRANSPORT'].amount if 'TRANSPORT' in inputs else 5.0
result_qty = len(office_days)
""",
}, {
'name': 'Solidarity contribution',
'code': 'SOLIDARITY',
'struct_ids': [Command.link(struct.id)],
'category_ids': [Command.link(env.ref('hr_payroll.DED').id)],
'sequence': 160, # after GROSS
'condition_select': 'python',
'condition_python': "result = GROSS > 5000",
'amount_select': 'code',
'amount_python_compute': """
result = -GROSS
result_rate = 1
explanation_info = {'gross': GROSS, 'transport': result_rules['TRANSPORT']['total']}
""",
'explanation_template': '1% of {gross:.2f} (incl. {transport:.2f} transport)',
}])
slip.compute_sheet()
[(line.code, line.amount, line.quantity, line.rate, line.total, line.explanation)
for line in slip.line_ids if line.code in ('TRANSPORT', 'GROSS', 'SOLIDARITY', 'NET')]
# [('TRANSPORT', 5.0, 16.0, 100.0, 80.0, False),
# ('GROSS', 6859.05, 1.0, 100.0, 6859.05, False),
# ('SOLIDARITY', -6859.05, 1.0, 1.0, -68.59, '1% of 6859.05 (incl. 80.00 transport)'),
# ('NET', 5940.46, 1.0, 100.0, 5940.46, False)]
slip._set_input_value('TRANSPORT', 8)
[(line.code, line.total) for line in slip.line_ids
if line.code in ('TRANSPORT', 'SOLIDARITY', 'NET')]
# [('TRANSPORT', 128.0), ('SOLIDARITY', -69.07), ('NET', 5987.98)]
Reading the results¶
The lines:
slip.line_ids.filtered(lambda line: line.code == 'NET').total.The stored totals
basic_wage,gross_wageandnet_wage, sums of the lines whose codes are given by_get_basic_wage_line_codes()(BASIC),_get_gross_wage_line_codes()(GROSS) and_get_net_wage_line_codes()(NET), andemployer_cost. They are only recomputed on draft payslips.For many payslips,
_get_line_values()sums the lines by code and payslip in one query (totalby default, alsoamount,quantity,ytd); missing codes give 0._get_worked_days_line_values()does the same for the worked days.Over a period, on the validated and paid payslips of the employee:
_sum()(rule code),_sum_category()(category) and_sum_worked_days()(work entry type). The end date defaults to today. These are usable in rule code, e.g. to cap a yearly amount.
slip.line_ids.filtered(lambda line: line.code == 'NET').total # 5987.98
slip._get_line_values(['NET'])['NET'][slip.id]['total'] # 5987.98
# with several payslips: the sum over all the payslips
slip._get_line_values(['GROSS', 'NET'], compute_sum=True)['NET']['sum']['total'] # 5987.98
Refunds and corrections¶
Validated and paid payslips are never changed. To cancel or fix them:
_action_refund_payslips()Creates, for each payslip, a refund: a copy with
credit_note,is_refund_payslipandeditedset andorigin_payslip_idpointing to the original, whose worked days, inputs and lines are negated. Refunds are validated at once. The original is flaggedis_refundedand its time off get backpayslip_state = 'normal'.refund_sheet()is the button calling it._action_correct_payslips()Creates and computes, for each payslip, a correction: a new draft payslip of the same employee, version, structure and period, with
origin_payslip_idand a copy of the input lines. The original is flaggedis_corrected;correction_net_deltais the difference of net with the original.
The correction wizard (hr.payslip.correction.wizard) refunds, or refunds and corrects, the
selected payslips; the pay run correction (see Pay run actions)
refunds and corrects all the payslips of a pay run.
_get_payslip_family() returns the payslips linked through
origin_payslip_id.
Example
The payslip of the previous examples is validated, paid, then refunded and corrected:
slip.action_payslip_done()
slip.state # 'validated'
slip.paid_date = date(2026, 12, 1)
slip.action_payslip_paid()
slip.state, slip.paid_date # ('paid', datetime.date(2026, 12, 1))
refund = slip._action_refund_payslips()
refund.name, refund.state, refund.net_wage
# ('Refund: Payslip - Ronnie Hart - November 2026', 'validated', -5987.98)
[(wd.code, wd.number_of_days) for wd in refund.worked_days_line_ids]
# [('013.00', -3.0), ('158.00', -2.0), ('002.00', -16.0)]
correction = slip._action_correct_payslips()
correction.name, correction.state, correction.correction_net_delta
# ('Correction: Payslip - Ronnie Hart - November 2026', 'draft', 0.0)
[(line.code, line.amount) for line in correction.input_line_ids]
# [('DEDUCTION', 150.0), ('TRANSPORT', 8.0)]
Pitfalls¶
Creating a payslip does not compute it, and
compute_sheet()does not regenerate the worked days: useaction_refresh_from_work_entries()after changing time data.action_payslip_done()does not compute the payslips; useaction_validate()to compute the payslips without lines first.Edited payslips (manual line or worked days changes, refunds) are never refreshed from the work entries.
In rules,
worked_days[code]andinputs[code]raiseKeyErrorfor missing codes, whilecategoriesandresult_rulessilently return 0.worked_days[code]may hold several lines:worked_days[code].number_of_daysthen fails, usemapped().Variables assigned in a rule leak into the next rules of the payslip; initialize the variables you use.
Payslip issues are not computed when running tests, unless the context has
hr_payroll_force_compute_issue.The rule parameters are read at the YTD reference date (
date_toby default), not today.
Extending¶
The methods below are designed to be overridden; localization modules give many examples.
- Context of the rules
_get_base_local_dict(),_get_localdict(),_get_common_localdict()and_get_round_common_localdict()(see above).- Computation
_get_payslip_lines()(e.g.l10n_vn_hr_payrollcomputes the net to gross salaries by calling it in a loop),_get_payslip_line_total()(rounding of the totals, e.g.l10n_be_hr_payroll,l10n_ch_hr_payroll) andcompute_sheet()(e.g. to ask for missing data with a wizard: the action returned bycompute_sheetis returned byaction_validate).- Worked days
_get_worked_day_lines_values(),_get_worked_day_lines(),_get_worked_day_lines_hours_per_day(),_round_days(),_get_out_of_contract_calendar()and_get_out_of_period_duration().- Inputs
_compute_input_line_ids(), to add input lines computed from other data (e.g.hr_payroll_expenseadds the expenses to reimburse).- Validation
action_payslip_done()(accounting entries, declarations).- Periods and YTD
_schedule_timedelta(),_get_ytd_reference_date()and_get_ytd_date_field()(e.g.l10n_us_hr_payrolluses the payment date).- Issues
_issues_dependencies(), to recompute the issues when the fields used by the payroll warnings of a localization change.- Reports and data
_get_basic_wage_line_codes()(and gross, net),_get_pdf_reports(),_get_data_files_to_update()and_get_payroll_translation(), see Writing a payroll localization.