Initial commit
This commit is contained in:
485
fusion_payroll/models/payroll_entry.py
Normal file
485
fusion_payroll/models/payroll_entry.py
Normal file
@@ -0,0 +1,485 @@
|
||||
# -*- 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'}
|
||||
Reference in New Issue
Block a user