486 lines
18 KiB
Python
486 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
from odoo import models, fields, api, _
|
|
from odoo.exceptions import UserError, ValidationError
|
|
from datetime import date, timedelta
|
|
|
|
|
|
class PayrollEntry(models.TransientModel):
|
|
"""
|
|
Payroll Entry - Represents a single employee's payroll for a period.
|
|
This is the QuickBooks-like payroll line item.
|
|
Transient because it's linked to the wizard.
|
|
"""
|
|
_name = 'payroll.entry'
|
|
_description = 'Payroll Entry'
|
|
_order = 'employee_id'
|
|
|
|
wizard_id = fields.Many2one(
|
|
'run.payroll.wizard',
|
|
string='Payroll Wizard',
|
|
ondelete='cascade',
|
|
)
|
|
|
|
employee_id = fields.Many2one(
|
|
'hr.employee',
|
|
string='Employee',
|
|
required=True,
|
|
ondelete='cascade',
|
|
)
|
|
|
|
# Display fields for Edit Paycheque dialog
|
|
employee_address = fields.Char(
|
|
string='Employee Address',
|
|
compute='_compute_display_fields',
|
|
)
|
|
pay_date = fields.Date(
|
|
string='Pay Date',
|
|
related='wizard_id.pay_date',
|
|
)
|
|
pay_period_display = fields.Char(
|
|
string='Pay Period',
|
|
compute='_compute_display_fields',
|
|
)
|
|
paid_from = fields.Char(
|
|
string='Paid From',
|
|
compute='_compute_display_fields',
|
|
)
|
|
|
|
# Employee info (computed for display)
|
|
employee_type = fields.Selection([
|
|
('hourly', 'Hourly'),
|
|
('salary', 'Salary'),
|
|
], string='Type', compute='_compute_employee_info')
|
|
|
|
hourly_rate = fields.Monetary(
|
|
string='Hourly Rate',
|
|
currency_field='currency_id',
|
|
compute='_compute_employee_info',
|
|
)
|
|
|
|
# Pay components
|
|
regular_hours = fields.Float(
|
|
string='Regular Hours',
|
|
default=0.0,
|
|
)
|
|
regular_pay = fields.Monetary(
|
|
string='Regular Pay',
|
|
currency_field='currency_id',
|
|
compute='_compute_pay_amounts',
|
|
)
|
|
|
|
vacation_pay_percent = fields.Float(
|
|
string='Vacation %',
|
|
default=4.0,
|
|
help='Vacation pay percentage (default 4%)',
|
|
)
|
|
vacation_pay = fields.Monetary(
|
|
string='Vacation Pay',
|
|
currency_field='currency_id',
|
|
compute='_compute_pay_amounts',
|
|
)
|
|
|
|
stat_holiday_hours = fields.Float(
|
|
string='Stat Holiday Hours',
|
|
default=0.0,
|
|
)
|
|
stat_holiday_pay = fields.Monetary(
|
|
string='Stat Holiday Pay',
|
|
currency_field='currency_id',
|
|
compute='_compute_pay_amounts',
|
|
)
|
|
|
|
stat_pay_avg_daily_wage = fields.Monetary(
|
|
string='Stat Pay - Avg Daily Wage',
|
|
currency_field='currency_id',
|
|
default=0.0,
|
|
help='Additional stat pay based on average daily wage',
|
|
)
|
|
|
|
# Totals
|
|
total_hours = fields.Float(
|
|
string='Total Hrs',
|
|
compute='_compute_totals',
|
|
)
|
|
gross_pay = fields.Monetary(
|
|
string='Gross Pay',
|
|
currency_field='currency_id',
|
|
compute='_compute_totals',
|
|
)
|
|
|
|
# Deductions (Employee Taxes)
|
|
income_tax = fields.Monetary(
|
|
string='Income Tax',
|
|
currency_field='currency_id',
|
|
compute='_compute_taxes',
|
|
)
|
|
employment_insurance = fields.Monetary(
|
|
string='Employment Insurance',
|
|
currency_field='currency_id',
|
|
compute='_compute_taxes',
|
|
)
|
|
cpp = fields.Monetary(
|
|
string='Canada Pension Plan',
|
|
currency_field='currency_id',
|
|
compute='_compute_taxes',
|
|
)
|
|
cpp2 = fields.Monetary(
|
|
string='Second Canada Pension Plan',
|
|
currency_field='currency_id',
|
|
compute='_compute_taxes',
|
|
)
|
|
total_employee_tax = fields.Monetary(
|
|
string='Total Employee Tax',
|
|
currency_field='currency_id',
|
|
compute='_compute_taxes',
|
|
)
|
|
|
|
# Employer Taxes
|
|
employer_ei = fields.Monetary(
|
|
string='EI Employer',
|
|
currency_field='currency_id',
|
|
compute='_compute_employer_taxes',
|
|
)
|
|
employer_cpp = fields.Monetary(
|
|
string='CPP Employer',
|
|
currency_field='currency_id',
|
|
compute='_compute_employer_taxes',
|
|
)
|
|
employer_cpp2 = fields.Monetary(
|
|
string='CPP2 Employer',
|
|
currency_field='currency_id',
|
|
compute='_compute_employer_taxes',
|
|
)
|
|
total_employer_tax = fields.Monetary(
|
|
string='Total Employer Tax',
|
|
currency_field='currency_id',
|
|
compute='_compute_employer_taxes',
|
|
)
|
|
|
|
# Net Pay
|
|
net_pay = fields.Monetary(
|
|
string='Net Pay',
|
|
currency_field='currency_id',
|
|
compute='_compute_net_pay',
|
|
)
|
|
|
|
# Vacation Time Off Tracking
|
|
vacation_hours_accrued = fields.Float(
|
|
string='Vacation Hours Accrued',
|
|
default=0.0,
|
|
)
|
|
vacation_hours_used = fields.Float(
|
|
string='Vacation Hours Used',
|
|
default=0.0,
|
|
)
|
|
vacation_hours_available = fields.Float(
|
|
string='Vacation Hours Available',
|
|
compute='_compute_vacation_balance',
|
|
)
|
|
vacation_amount_accrued = fields.Monetary(
|
|
string='Vacation Amount Accrued',
|
|
currency_field='currency_id',
|
|
compute='_compute_vacation_balance',
|
|
)
|
|
vacation_amount_used = fields.Monetary(
|
|
string='Vacation Amount Used',
|
|
currency_field='currency_id',
|
|
default=0.0,
|
|
)
|
|
vacation_amount_available = fields.Monetary(
|
|
string='Vacation Amount Available',
|
|
currency_field='currency_id',
|
|
compute='_compute_vacation_balance',
|
|
)
|
|
|
|
# Other fields
|
|
memo = fields.Text(
|
|
string='Memo',
|
|
)
|
|
payment_method = fields.Selection([
|
|
('cheque', 'Paper cheque'),
|
|
('direct_deposit', 'Direct Deposit'),
|
|
], string='Pay Method', default='cheque')
|
|
|
|
# Currency
|
|
currency_id = fields.Many2one(
|
|
'res.currency',
|
|
string='Currency',
|
|
default=lambda self: self.env.company.currency_id,
|
|
)
|
|
|
|
# Previous payroll indicator
|
|
previous_payroll_id = fields.Many2one(
|
|
'hr.payslip',
|
|
string='Previous Payroll',
|
|
compute='_compute_previous_payroll',
|
|
)
|
|
previous_payroll_amount = fields.Monetary(
|
|
string='Previous Amount',
|
|
currency_field='currency_id',
|
|
compute='_compute_previous_payroll',
|
|
)
|
|
previous_payroll_date = fields.Date(
|
|
string='Previous Pay Date',
|
|
compute='_compute_previous_payroll',
|
|
)
|
|
has_previous_payroll = fields.Boolean(
|
|
string='Has Previous',
|
|
compute='_compute_previous_payroll',
|
|
)
|
|
change_from_last_payroll = fields.Char(
|
|
string='Change from Last',
|
|
compute='_compute_previous_payroll',
|
|
help='Percentage change from last payroll',
|
|
)
|
|
|
|
@api.depends('employee_id')
|
|
def _compute_employee_info(self):
|
|
for entry in self:
|
|
if entry.employee_id:
|
|
employee = entry.employee_id
|
|
|
|
# Get pay type and rate from Fusion Payroll fields
|
|
pay_type = getattr(employee, 'pay_type', 'hourly')
|
|
|
|
if pay_type == 'hourly':
|
|
entry.employee_type = 'hourly'
|
|
# Use hourly_rate from Fusion Payroll
|
|
hourly_rate = getattr(employee, 'hourly_rate', 0.0)
|
|
entry.hourly_rate = hourly_rate if hourly_rate else 0.0
|
|
elif pay_type == 'salary':
|
|
entry.employee_type = 'salary'
|
|
# Calculate hourly from salary
|
|
salary = getattr(employee, 'salary_amount', 0.0)
|
|
hours_per_week = getattr(employee, 'default_hours_per_week', 40.0) or 40.0
|
|
# Bi-weekly = salary / 2, then divide by hours per pay period
|
|
entry.hourly_rate = (salary / 2) / hours_per_week if salary else 0.0
|
|
else:
|
|
entry.employee_type = 'hourly'
|
|
entry.hourly_rate = 0.0
|
|
|
|
# If still no rate, fallback to contract or hourly_cost
|
|
if entry.hourly_rate == 0.0:
|
|
if hasattr(employee, 'hourly_cost') and employee.hourly_cost:
|
|
entry.hourly_rate = employee.hourly_cost
|
|
elif hasattr(employee, 'contract_id') and employee.contract_id:
|
|
contract = employee.contract_id
|
|
if hasattr(contract, 'wage') and contract.wage:
|
|
entry.hourly_rate = contract.wage / 160
|
|
else:
|
|
entry.employee_type = 'hourly'
|
|
entry.hourly_rate = 0.0
|
|
|
|
@api.depends('regular_hours', 'hourly_rate', 'vacation_pay_percent', 'stat_holiday_hours')
|
|
def _compute_pay_amounts(self):
|
|
for entry in self:
|
|
# Regular pay = hours * rate
|
|
entry.regular_pay = entry.regular_hours * entry.hourly_rate
|
|
|
|
# Vacation pay = vacation_pay_percent% of regular pay
|
|
entry.vacation_pay = entry.regular_pay * (entry.vacation_pay_percent / 100)
|
|
|
|
# Stat holiday pay = hours * rate
|
|
entry.stat_holiday_pay = entry.stat_holiday_hours * entry.hourly_rate
|
|
|
|
@api.depends('regular_hours', 'stat_holiday_hours', 'regular_pay', 'vacation_pay', 'stat_holiday_pay', 'stat_pay_avg_daily_wage')
|
|
def _compute_totals(self):
|
|
for entry in self:
|
|
entry.total_hours = entry.regular_hours + entry.stat_holiday_hours
|
|
entry.gross_pay = entry.regular_pay + entry.vacation_pay + entry.stat_holiday_pay + entry.stat_pay_avg_daily_wage
|
|
|
|
@api.depends('gross_pay', 'employee_id')
|
|
def _compute_taxes(self):
|
|
"""Calculate employee tax deductions."""
|
|
for entry in self:
|
|
if entry.gross_pay <= 0:
|
|
entry.income_tax = 0
|
|
entry.employment_insurance = 0
|
|
entry.cpp = 0
|
|
entry.cpp2 = 0
|
|
entry.total_employee_tax = 0
|
|
continue
|
|
|
|
# Get tax rates from parameters or use defaults
|
|
# These are simplified calculations - actual payroll uses full tax rules
|
|
gross = entry.gross_pay
|
|
|
|
# Simplified tax calculations (bi-weekly)
|
|
# Income tax: ~15-20% average for Canadian employees
|
|
entry.income_tax = round(gross * 0.128, 2) # Approximate federal + provincial
|
|
|
|
# EI: 1.64% of gross (2025 rate) up to maximum
|
|
entry.employment_insurance = round(min(gross * 0.0164, 1049.12 / 26), 2)
|
|
|
|
# CPP: 5.95% of pensionable earnings above basic exemption (2025)
|
|
cpp_exempt = 3500 / 26 # Annual exemption / 26 pay periods
|
|
pensionable = max(0, gross - cpp_exempt)
|
|
entry.cpp = round(min(pensionable * 0.0595, 4034.10 / 26), 2)
|
|
|
|
# CPP2: 4% on earnings above first ceiling (2025)
|
|
entry.cpp2 = 0 # Only applies if earnings exceed $71,300/year
|
|
|
|
entry.total_employee_tax = entry.income_tax + entry.employment_insurance + entry.cpp + entry.cpp2
|
|
|
|
@api.depends('employment_insurance', 'cpp', 'cpp2')
|
|
def _compute_employer_taxes(self):
|
|
"""Calculate employer tax contributions."""
|
|
for entry in self:
|
|
# EI employer: 1.4x employee rate
|
|
entry.employer_ei = round(entry.employment_insurance * 1.4, 2)
|
|
|
|
# CPP employer: same as employee
|
|
entry.employer_cpp = entry.cpp
|
|
|
|
# CPP2 employer: same as employee
|
|
entry.employer_cpp2 = entry.cpp2
|
|
|
|
entry.total_employer_tax = entry.employer_ei + entry.employer_cpp + entry.employer_cpp2
|
|
|
|
@api.depends('gross_pay', 'total_employee_tax')
|
|
def _compute_net_pay(self):
|
|
for entry in self:
|
|
entry.net_pay = entry.gross_pay - entry.total_employee_tax
|
|
|
|
@api.depends('vacation_pay', 'vacation_hours_used')
|
|
def _compute_vacation_balance(self):
|
|
for entry in self:
|
|
entry.vacation_hours_available = entry.vacation_hours_accrued - entry.vacation_hours_used
|
|
entry.vacation_amount_accrued = entry.vacation_pay # Current period accrual
|
|
entry.vacation_amount_available = entry.vacation_amount_accrued - entry.vacation_amount_used
|
|
|
|
@api.depends('employee_id', 'wizard_id.date_start', 'wizard_id.date_end', 'gross_pay')
|
|
def _compute_previous_payroll(self):
|
|
"""Check if employee has been paid in the current period and compute change."""
|
|
for entry in self:
|
|
entry.previous_payroll_id = False
|
|
entry.previous_payroll_amount = 0
|
|
entry.previous_payroll_date = False
|
|
entry.has_previous_payroll = False
|
|
entry.change_from_last_payroll = ''
|
|
|
|
if not entry.employee_id:
|
|
continue
|
|
|
|
# Search for the last payslip for this employee (not in current period)
|
|
payslip = self.env['hr.payslip'].search([
|
|
('employee_id', '=', entry.employee_id.id),
|
|
('state', 'in', ['done', 'paid']),
|
|
], limit=1, order='date_to desc')
|
|
|
|
if payslip:
|
|
entry.previous_payroll_id = payslip
|
|
entry.previous_payroll_amount = payslip.net_wage
|
|
entry.previous_payroll_date = payslip.date_to
|
|
entry.has_previous_payroll = True
|
|
|
|
# Calculate change percentage
|
|
if payslip.basic_wage and payslip.basic_wage > 0 and entry.gross_pay > 0:
|
|
change = ((entry.gross_pay - payslip.basic_wage) / payslip.basic_wage) * 100
|
|
if change > 0:
|
|
entry.change_from_last_payroll = f"↑ Up {abs(change):.0f}%"
|
|
elif change < 0:
|
|
entry.change_from_last_payroll = f"↓ Down {abs(change):.0f}%"
|
|
else:
|
|
entry.change_from_last_payroll = "No change"
|
|
|
|
def action_load_attendance_hours(self):
|
|
"""Load hours from hr_attendance for the pay period."""
|
|
self.ensure_one()
|
|
|
|
if not self.wizard_id:
|
|
return
|
|
|
|
# Search for attendance records in the period
|
|
attendances = self.env['hr.attendance'].search([
|
|
('employee_id', '=', self.employee_id.id),
|
|
('check_in', '>=', self.wizard_id.date_start),
|
|
('check_in', '<=', self.wizard_id.date_end),
|
|
('check_out', '!=', False),
|
|
])
|
|
|
|
total_hours = 0
|
|
for att in attendances:
|
|
if att.check_out:
|
|
delta = att.check_out - att.check_in
|
|
total_hours += delta.total_seconds() / 3600
|
|
|
|
self.regular_hours = round(total_hours, 2)
|
|
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'title': _('Attendance Hours Loaded'),
|
|
'message': _('Loaded %.2f hours from attendance records.') % total_hours,
|
|
'type': 'success',
|
|
},
|
|
}
|
|
|
|
def action_view_previous_payroll(self):
|
|
"""Open the previous payroll record."""
|
|
self.ensure_one()
|
|
if self.previous_payroll_id:
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Previous Payslip'),
|
|
'res_model': 'hr.payslip',
|
|
'res_id': self.previous_payroll_id.id,
|
|
'view_mode': 'form',
|
|
'target': 'new',
|
|
}
|
|
|
|
def action_open_edit_paycheque(self):
|
|
"""Open the detailed paycheque editor dialog."""
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Edit Paycheque - %s') % self.employee_id.name,
|
|
'res_model': 'payroll.entry',
|
|
'res_id': self.id,
|
|
'view_mode': 'form',
|
|
'target': 'new',
|
|
'context': {'form_view_ref': 'fusion_payroll.payroll_entry_edit_form'},
|
|
}
|
|
|
|
@api.depends('employee_id', 'wizard_id')
|
|
def _compute_display_fields(self):
|
|
for entry in self:
|
|
# Employee address
|
|
if entry.employee_id:
|
|
emp = entry.employee_id
|
|
parts = []
|
|
if hasattr(emp, 'home_street') and emp.home_street:
|
|
parts.append(emp.home_street)
|
|
if hasattr(emp, 'home_city') and emp.home_city:
|
|
city_part = emp.home_city
|
|
if hasattr(emp, 'home_province') and emp.home_province:
|
|
city_part += f", {emp.home_province}"
|
|
if hasattr(emp, 'home_postal_code') and emp.home_postal_code:
|
|
city_part += f" {emp.home_postal_code}"
|
|
parts.append(city_part)
|
|
entry.employee_address = '\n'.join(parts) if parts else ''
|
|
else:
|
|
entry.employee_address = ''
|
|
|
|
# Pay period display
|
|
if entry.wizard_id and entry.wizard_id.date_start and entry.wizard_id.date_end:
|
|
entry.pay_period_display = f"{entry.wizard_id.date_start.strftime('%m.%d.%Y')} to {entry.wizard_id.date_end.strftime('%m.%d.%Y')}"
|
|
else:
|
|
entry.pay_period_display = ''
|
|
|
|
# Paid from (company bank or cheque info)
|
|
if entry.wizard_id and entry.wizard_id.company_id:
|
|
company = entry.wizard_id.company_id
|
|
if entry.payment_method == 'cheque':
|
|
entry.paid_from = f"Cheque ({company.name})"
|
|
else:
|
|
entry.paid_from = f"Direct Deposit ({company.name})"
|
|
else:
|
|
entry.paid_from = ''
|
|
|
|
def action_save_entry(self):
|
|
"""Save the entry and close the dialog."""
|
|
self.ensure_one()
|
|
return {'type': 'ir.actions.act_window_close'}
|