Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View 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'}