# -*- 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'}