381 lines
14 KiB
Python
381 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import UserError
|
|
|
|
|
|
class HrPayslip(models.Model):
|
|
_inherit = 'hr.payslip'
|
|
|
|
# === Additional Fields for QuickBooks-style Paycheque Entry ===
|
|
cheque_id = fields.Many2one(
|
|
'payroll.cheque',
|
|
string='Cheque',
|
|
copy=False,
|
|
help='Linked cheque record for paper cheque payments',
|
|
)
|
|
cheque_number = fields.Char(
|
|
string='Cheque Number',
|
|
related='cheque_id.cheque_number',
|
|
store=True,
|
|
copy=False,
|
|
help='Cheque number for paper cheque payments',
|
|
)
|
|
cheque_state = fields.Selection(
|
|
related='cheque_id.state',
|
|
string='Cheque Status',
|
|
store=True,
|
|
)
|
|
memo = fields.Text(
|
|
string='Memo',
|
|
help='Internal notes for this paycheque',
|
|
)
|
|
paid_by = fields.Selection([
|
|
('cheque', 'Paper Cheque'),
|
|
('direct_deposit', 'Direct Deposit'),
|
|
], string='Payment Method', compute='_compute_paid_by', store=True, readonly=False)
|
|
|
|
@api.depends('employee_id', 'employee_id.payment_method')
|
|
def _compute_paid_by(self):
|
|
"""Set payment method from employee's default payment method."""
|
|
for payslip in self:
|
|
if payslip.employee_id and hasattr(payslip.employee_id, 'payment_method'):
|
|
payslip.paid_by = payslip.employee_id.payment_method or 'direct_deposit'
|
|
else:
|
|
payslip.paid_by = 'direct_deposit'
|
|
|
|
def action_print_cheque(self):
|
|
"""Print cheque for this payslip - always opens wizard to set/change cheque number."""
|
|
self.ensure_one()
|
|
|
|
if self.paid_by != 'cheque':
|
|
raise UserError(_("This payslip is not set to be paid by cheque."))
|
|
|
|
# Create cheque if not exists
|
|
if not self.cheque_id:
|
|
cheque = self.env['payroll.cheque'].create_from_payslip(self)
|
|
if cheque:
|
|
self.cheque_id = cheque.id
|
|
else:
|
|
raise UserError(_("Failed to create cheque. Check employee payment method."))
|
|
|
|
# Always open the cheque number wizard to allow changing the number
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Set Cheque Number'),
|
|
'res_model': 'payroll.cheque.number.wizard',
|
|
'view_mode': 'form',
|
|
'target': 'new',
|
|
'context': {
|
|
'default_cheque_id': self.cheque_id.id,
|
|
},
|
|
}
|
|
|
|
def action_create_cheque(self):
|
|
"""Create a cheque for this payslip without printing."""
|
|
self.ensure_one()
|
|
|
|
if self.paid_by != 'cheque':
|
|
raise UserError(_("This payslip is not set to be paid by cheque."))
|
|
|
|
if self.cheque_id:
|
|
raise UserError(_("A cheque already exists for this payslip."))
|
|
|
|
cheque = self.env['payroll.cheque'].create_from_payslip(self)
|
|
if cheque:
|
|
self.cheque_id = cheque.id
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'title': _('Cheque Created'),
|
|
'message': _('Cheque created for %s.') % self.employee_id.name,
|
|
'type': 'success',
|
|
'sticky': False,
|
|
}
|
|
}
|
|
else:
|
|
raise UserError(_("Failed to create cheque."))
|
|
|
|
# === YTD Computed Fields ===
|
|
ytd_gross = fields.Monetary(
|
|
string='YTD Gross',
|
|
compute='_compute_ytd_amounts',
|
|
currency_field='currency_id',
|
|
help='Year-to-date gross earnings',
|
|
)
|
|
ytd_cpp = fields.Monetary(
|
|
string='YTD CPP',
|
|
compute='_compute_ytd_amounts',
|
|
currency_field='currency_id',
|
|
help='Year-to-date CPP contributions (employee)',
|
|
)
|
|
ytd_cpp2 = fields.Monetary(
|
|
string='YTD CPP2',
|
|
compute='_compute_ytd_amounts',
|
|
currency_field='currency_id',
|
|
help='Year-to-date CPP2 contributions (employee)',
|
|
)
|
|
ytd_ei = fields.Monetary(
|
|
string='YTD EI',
|
|
compute='_compute_ytd_amounts',
|
|
currency_field='currency_id',
|
|
help='Year-to-date EI contributions (employee)',
|
|
)
|
|
ytd_income_tax = fields.Monetary(
|
|
string='YTD Income Tax',
|
|
compute='_compute_ytd_amounts',
|
|
currency_field='currency_id',
|
|
help='Year-to-date income tax withheld',
|
|
)
|
|
ytd_net = fields.Monetary(
|
|
string='YTD Net',
|
|
compute='_compute_ytd_amounts',
|
|
currency_field='currency_id',
|
|
help='Year-to-date net pay',
|
|
)
|
|
|
|
# === Employer Tax Totals (for display) ===
|
|
employer_cpp = fields.Monetary(
|
|
string='Employer CPP',
|
|
compute='_compute_employer_contributions',
|
|
currency_field='currency_id',
|
|
)
|
|
employer_cpp2 = fields.Monetary(
|
|
string='Employer CPP2',
|
|
compute='_compute_employer_contributions',
|
|
currency_field='currency_id',
|
|
)
|
|
employer_ei = fields.Monetary(
|
|
string='Employer EI',
|
|
compute='_compute_employer_contributions',
|
|
currency_field='currency_id',
|
|
)
|
|
total_employer_cost = fields.Monetary(
|
|
string='Total Employer Cost',
|
|
compute='_compute_employer_contributions',
|
|
currency_field='currency_id',
|
|
help='Total employer contributions (CPP + CPP2 + EI)',
|
|
)
|
|
|
|
# === Employee Tax Totals (for summary) ===
|
|
employee_cpp = fields.Monetary(
|
|
string='Employee CPP',
|
|
compute='_compute_employee_deductions',
|
|
currency_field='currency_id',
|
|
)
|
|
employee_cpp2 = fields.Monetary(
|
|
string='Employee CPP2',
|
|
compute='_compute_employee_deductions',
|
|
currency_field='currency_id',
|
|
)
|
|
employee_ei = fields.Monetary(
|
|
string='Employee EI',
|
|
compute='_compute_employee_deductions',
|
|
currency_field='currency_id',
|
|
)
|
|
employee_income_tax = fields.Monetary(
|
|
string='Income Tax',
|
|
compute='_compute_employee_deductions',
|
|
currency_field='currency_id',
|
|
)
|
|
total_employee_deductions = fields.Monetary(
|
|
string='Total Employee Deductions',
|
|
compute='_compute_employee_deductions',
|
|
currency_field='currency_id',
|
|
)
|
|
|
|
@api.depends('employee_id', 'date_from', 'line_ids', 'line_ids.total')
|
|
def _compute_ytd_amounts(self):
|
|
"""Calculate year-to-date amounts for each payslip"""
|
|
for payslip in self:
|
|
if not payslip.employee_id or not payslip.date_from:
|
|
payslip.ytd_gross = 0
|
|
payslip.ytd_cpp = 0
|
|
payslip.ytd_cpp2 = 0
|
|
payslip.ytd_ei = 0
|
|
payslip.ytd_income_tax = 0
|
|
payslip.ytd_net = 0
|
|
continue
|
|
|
|
# Get the start of the year
|
|
year_start = payslip.date_from.replace(month=1, day=1)
|
|
|
|
# Find all payslips for this employee in the same year, up to and including this one
|
|
domain = [
|
|
('employee_id', '=', payslip.employee_id.id),
|
|
('date_from', '>=', year_start),
|
|
('date_to', '<=', payslip.date_to),
|
|
('state', 'in', ['done', 'paid']),
|
|
]
|
|
# Include current payslip if it's in draft/verify state
|
|
if payslip.state in ['draft', 'verify']:
|
|
domain = ['|', ('id', '=', payslip.id)] + domain
|
|
|
|
ytd_payslips = self.search(domain)
|
|
|
|
# Calculate YTD totals
|
|
ytd_gross = 0
|
|
ytd_cpp = 0
|
|
ytd_cpp2 = 0
|
|
ytd_ei = 0
|
|
ytd_income_tax = 0
|
|
ytd_net = 0
|
|
|
|
for slip in ytd_payslips:
|
|
ytd_gross += slip.gross_wage or 0
|
|
ytd_net += slip.net_wage or 0
|
|
|
|
# Sum up specific rule amounts
|
|
for line in slip.line_ids:
|
|
code = line.code or ''
|
|
if code == 'CPP':
|
|
ytd_cpp += abs(line.total or 0)
|
|
elif code == 'CPP2':
|
|
ytd_cpp2 += abs(line.total or 0)
|
|
elif code == 'EI':
|
|
ytd_ei += abs(line.total or 0)
|
|
elif code in ['FED_TAX', 'PROV_TAX', 'INCOME_TAX']:
|
|
ytd_income_tax += abs(line.total or 0)
|
|
|
|
payslip.ytd_gross = ytd_gross
|
|
payslip.ytd_cpp = ytd_cpp
|
|
payslip.ytd_cpp2 = ytd_cpp2
|
|
payslip.ytd_ei = ytd_ei
|
|
payslip.ytd_income_tax = ytd_income_tax
|
|
payslip.ytd_net = ytd_net
|
|
|
|
@api.depends('line_ids', 'line_ids.total', 'line_ids.code')
|
|
def _compute_employer_contributions(self):
|
|
"""Calculate employer contribution totals from payslip lines"""
|
|
for payslip in self:
|
|
employer_cpp = 0
|
|
employer_cpp2 = 0
|
|
employer_ei = 0
|
|
|
|
for line in payslip.line_ids:
|
|
code = line.code or ''
|
|
if code == 'CPP_ER':
|
|
employer_cpp = abs(line.total or 0)
|
|
elif code == 'CPP2_ER':
|
|
employer_cpp2 = abs(line.total or 0)
|
|
elif code == 'EI_ER':
|
|
employer_ei = abs(line.total or 0)
|
|
|
|
payslip.employer_cpp = employer_cpp
|
|
payslip.employer_cpp2 = employer_cpp2
|
|
payslip.employer_ei = employer_ei
|
|
payslip.total_employer_cost = employer_cpp + employer_cpp2 + employer_ei
|
|
|
|
@api.depends('line_ids', 'line_ids.total', 'line_ids.code')
|
|
def _compute_employee_deductions(self):
|
|
"""Calculate employee deduction totals from payslip lines"""
|
|
for payslip in self:
|
|
employee_cpp = 0
|
|
employee_cpp2 = 0
|
|
employee_ei = 0
|
|
employee_income_tax = 0
|
|
|
|
for line in payslip.line_ids:
|
|
code = line.code or ''
|
|
if code == 'CPP':
|
|
employee_cpp = abs(line.total or 0)
|
|
elif code == 'CPP2':
|
|
employee_cpp2 = abs(line.total or 0)
|
|
elif code == 'EI':
|
|
employee_ei = abs(line.total or 0)
|
|
elif code in ['FED_TAX', 'PROV_TAX', 'INCOME_TAX']:
|
|
employee_income_tax += abs(line.total or 0)
|
|
|
|
payslip.employee_cpp = employee_cpp
|
|
payslip.employee_cpp2 = employee_cpp2
|
|
payslip.employee_ei = employee_ei
|
|
payslip.employee_income_tax = employee_income_tax
|
|
payslip.total_employee_deductions = (
|
|
employee_cpp + employee_cpp2 + employee_ei + employee_income_tax
|
|
)
|
|
|
|
# =========================================================================
|
|
# PAY TYPE IDENTIFICATION HELPERS (for T4 and ROE reporting)
|
|
# =========================================================================
|
|
|
|
@api.model
|
|
def _get_pay_type_from_code(self, code, category_code=None):
|
|
"""
|
|
Map salary rule code to pay type for ROE/T4 reporting.
|
|
|
|
Returns one of: 'salary', 'hourly', 'overtime', 'bonus', 'stat_holiday',
|
|
'commission', 'allowance', 'reimbursement', 'union_dues', 'other'
|
|
|
|
:param code: Salary rule code (e.g., 'OT_PAY', 'BONUS_PAY')
|
|
:param category_code: Category code (e.g., 'BASIC', 'ALW', 'DED')
|
|
:return: Pay type string
|
|
"""
|
|
if not code:
|
|
return 'other'
|
|
|
|
code_upper = code.upper()
|
|
|
|
# Direct code matches
|
|
if code_upper == 'OT_PAY':
|
|
return 'overtime'
|
|
elif code_upper == 'BONUS_PAY' or code_upper == 'BONUS':
|
|
return 'bonus'
|
|
elif code_upper == 'STAT_PAY' or code_upper == 'STAT_HOLIDAY':
|
|
return 'stat_holiday'
|
|
|
|
# Pattern matching for commissions
|
|
if 'COMMISSION' in code_upper or 'COMM' in code_upper:
|
|
return 'commission'
|
|
|
|
# Pattern matching for union dues
|
|
if 'UNION' in code_upper or 'DUES' in code_upper:
|
|
return 'union_dues'
|
|
|
|
# Pattern matching for reimbursements
|
|
if 'REIMBURSEMENT' in code_upper or 'REIMB' in code_upper:
|
|
return 'reimbursement'
|
|
|
|
# Pattern matching for allowances
|
|
if 'ALLOWANCE' in code_upper or 'ALW' in code_upper:
|
|
# Check if it's a reimbursement first
|
|
if 'REIMBURSEMENT' in code_upper or 'REIMB' in code_upper:
|
|
return 'reimbursement'
|
|
return 'allowance'
|
|
|
|
# Category-based identification
|
|
if category_code:
|
|
category_upper = category_code.upper()
|
|
if category_upper == 'BASIC':
|
|
return 'salary' # Could be salary or hourly, default to salary
|
|
elif category_upper == 'ALW':
|
|
# Already checked for allowance patterns above
|
|
return 'allowance'
|
|
elif category_upper == 'DED':
|
|
# Deductions - check if union dues
|
|
if 'UNION' in code_upper or 'DUES' in code_upper:
|
|
return 'union_dues'
|
|
return 'other'
|
|
|
|
return 'other'
|
|
|
|
@api.model
|
|
def _is_reimbursement(self, code, category_code=None):
|
|
"""
|
|
Check if salary rule code represents a reimbursement (non-taxable).
|
|
|
|
:param code: Salary rule code
|
|
:param category_code: Category code
|
|
:return: True if reimbursement, False otherwise
|
|
"""
|
|
if not code:
|
|
return False
|
|
|
|
code_upper = code.upper()
|
|
|
|
# Direct pattern matching
|
|
if 'REIMBURSEMENT' in code_upper or 'REIMB' in code_upper:
|
|
return True
|
|
|
|
return False
|