615 lines
23 KiB
Python
615 lines
23 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
Payroll Cheque Management
|
|
=========================
|
|
Custom cheque printing for payroll with configurable layouts.
|
|
"""
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import UserError
|
|
from num2words import num2words
|
|
|
|
|
|
class PayrollCheque(models.Model):
|
|
"""
|
|
Payroll Cheque Record
|
|
Tracks cheques issued for payroll payments.
|
|
"""
|
|
_name = 'payroll.cheque'
|
|
_description = 'Payroll Cheque'
|
|
_order = 'cheque_date desc, cheque_number desc'
|
|
_inherit = ['mail.thread', 'mail.activity.mixin']
|
|
|
|
name = fields.Char(
|
|
string='Reference',
|
|
compute='_compute_name',
|
|
store=True,
|
|
)
|
|
cheque_number = fields.Char(
|
|
string='Cheque Number',
|
|
readonly=True,
|
|
copy=False,
|
|
tracking=True,
|
|
)
|
|
cheque_date = fields.Date(
|
|
string='Cheque Date',
|
|
required=True,
|
|
default=fields.Date.context_today,
|
|
tracking=True,
|
|
)
|
|
|
|
# Payee Information
|
|
employee_id = fields.Many2one(
|
|
'hr.employee',
|
|
string='Employee',
|
|
required=True,
|
|
tracking=True,
|
|
)
|
|
payment_method_display = fields.Char(
|
|
string='Payment Method',
|
|
compute='_compute_payment_method_display',
|
|
)
|
|
payee_name = fields.Char(
|
|
string='Payee Name',
|
|
compute='_compute_payee_info',
|
|
store=True,
|
|
)
|
|
payee_address = fields.Text(
|
|
string='Payee Address',
|
|
compute='_compute_payee_info',
|
|
store=True,
|
|
)
|
|
|
|
# Amount
|
|
amount = fields.Monetary(
|
|
string='Amount',
|
|
currency_field='currency_id',
|
|
required=True,
|
|
tracking=True,
|
|
)
|
|
amount_in_words = fields.Char(
|
|
string='Amount in Words',
|
|
compute='_compute_amount_in_words',
|
|
)
|
|
currency_id = fields.Many2one(
|
|
'res.currency',
|
|
string='Currency',
|
|
default=lambda self: self.env.company.currency_id,
|
|
)
|
|
|
|
# Related Records
|
|
payslip_id = fields.Many2one(
|
|
'hr.payslip',
|
|
string='Payslip',
|
|
ondelete='set null',
|
|
)
|
|
payslip_run_id = fields.Many2one(
|
|
'hr.payslip.run',
|
|
string='Payslip Batch',
|
|
ondelete='set null',
|
|
)
|
|
|
|
# Bank Account
|
|
bank_account_id = fields.Many2one(
|
|
'res.partner.bank',
|
|
string='Bank Account',
|
|
domain="[('company_id', '=', company_id)]",
|
|
)
|
|
company_id = fields.Many2one(
|
|
'res.company',
|
|
string='Company',
|
|
required=True,
|
|
default=lambda self: self.env.company,
|
|
)
|
|
|
|
# Pay Period Info
|
|
pay_period_start = fields.Date(string='Pay Period Start')
|
|
pay_period_end = fields.Date(string='Pay Period End')
|
|
pay_period_display = fields.Char(
|
|
string='Pay Period',
|
|
compute='_compute_pay_period_display',
|
|
)
|
|
|
|
# Memo
|
|
memo = fields.Text(string='Memo')
|
|
|
|
# Status
|
|
state = fields.Selection([
|
|
('draft', 'Draft'),
|
|
('printed', 'Printed'),
|
|
('voided', 'Voided'),
|
|
('cashed', 'Cashed'),
|
|
], string='Status', default='draft', tracking=True)
|
|
|
|
printed_date = fields.Datetime(string='Printed Date', readonly=True)
|
|
voided_date = fields.Datetime(string='Voided Date', readonly=True)
|
|
void_reason = fields.Text(string='Void Reason')
|
|
|
|
# ==================== COMPUTED FIELDS ====================
|
|
|
|
@api.depends('cheque_number', 'employee_id')
|
|
def _compute_name(self):
|
|
for cheque in self:
|
|
if cheque.cheque_number and cheque.employee_id:
|
|
cheque.name = f"CHQ-{cheque.cheque_number} - {cheque.employee_id.name}"
|
|
elif cheque.cheque_number:
|
|
cheque.name = f"CHQ-{cheque.cheque_number}"
|
|
else:
|
|
cheque.name = _('New Cheque')
|
|
|
|
@api.depends('employee_id')
|
|
def _compute_payee_info(self):
|
|
for cheque in self:
|
|
if cheque.employee_id:
|
|
emp = cheque.employee_id
|
|
cheque.payee_name = emp.name
|
|
|
|
# Build address from Fusion Payroll fields or fallback
|
|
address_parts = []
|
|
|
|
# Try Fusion Payroll home address fields
|
|
if hasattr(emp, 'home_street') and emp.home_street:
|
|
address_parts.append(emp.home_street)
|
|
if hasattr(emp, 'home_street2') and emp.home_street2:
|
|
address_parts.append(emp.home_street2)
|
|
|
|
city_line = []
|
|
if hasattr(emp, 'home_city') and emp.home_city:
|
|
city_line.append(emp.home_city)
|
|
if hasattr(emp, 'home_province') and emp.home_province:
|
|
city_line.append(emp.home_province)
|
|
if hasattr(emp, 'home_postal_code') and emp.home_postal_code:
|
|
city_line.append(emp.home_postal_code)
|
|
|
|
if city_line:
|
|
address_parts.append(' '.join(city_line))
|
|
|
|
# Fallback to private address
|
|
if not address_parts and emp.private_street:
|
|
address_parts.append(emp.private_street)
|
|
if emp.private_city:
|
|
address_parts.append(f"{emp.private_city}, {emp.private_state_id.code or ''} {emp.private_zip or ''}")
|
|
|
|
cheque.payee_address = '\n'.join(address_parts) if address_parts else ''
|
|
else:
|
|
cheque.payee_name = ''
|
|
cheque.payee_address = ''
|
|
|
|
@api.depends('amount', 'currency_id')
|
|
def _compute_amount_in_words(self):
|
|
for cheque in self:
|
|
if cheque.amount:
|
|
# Split into dollars and cents
|
|
dollars = int(cheque.amount)
|
|
cents = int(round((cheque.amount - dollars) * 100))
|
|
|
|
# Convert to words
|
|
dollars_words = num2words(dollars, lang='en').title()
|
|
|
|
# Format: "One Thousand Three Hundred Fifty-Three and 47/100"
|
|
cheque.amount_in_words = f"*****{dollars_words} and {cents:02d}/100"
|
|
else:
|
|
cheque.amount_in_words = ''
|
|
|
|
@api.depends('pay_period_start', 'pay_period_end')
|
|
def _compute_pay_period_display(self):
|
|
for cheque in self:
|
|
if cheque.pay_period_start and cheque.pay_period_end:
|
|
cheque.pay_period_display = f"{cheque.pay_period_start.strftime('%m.%d.%Y')} - {cheque.pay_period_end.strftime('%m.%d.%Y')}"
|
|
else:
|
|
cheque.pay_period_display = ''
|
|
|
|
@api.depends('employee_id', 'employee_id.payment_method')
|
|
def _compute_payment_method_display(self):
|
|
for cheque in self:
|
|
if cheque.employee_id and hasattr(cheque.employee_id, 'payment_method'):
|
|
method = cheque.employee_id.payment_method
|
|
if method == 'cheque':
|
|
cheque.payment_method_display = 'Cheque'
|
|
elif method == 'direct_deposit':
|
|
cheque.payment_method_display = 'Direct Deposit'
|
|
else:
|
|
cheque.payment_method_display = method or 'N/A'
|
|
else:
|
|
cheque.payment_method_display = 'N/A'
|
|
|
|
# ==================== ACTIONS ====================
|
|
|
|
def action_assign_number(self):
|
|
"""Assign cheque number from sequence, checking for highest existing number."""
|
|
for cheque in self:
|
|
if not cheque.cheque_number:
|
|
# Get the highest cheque number from payroll cheques
|
|
max_payroll = self.env['payroll.cheque'].search([
|
|
('cheque_number', '!=', False),
|
|
('cheque_number', '!=', ''),
|
|
('company_id', '=', cheque.company_id.id),
|
|
], order='cheque_number desc', limit=1)
|
|
|
|
# Get the highest cheque number from account.payment (vendor payments)
|
|
max_payment = 0
|
|
if 'account.payment' in self.env:
|
|
payments = self.env['account.payment'].search([
|
|
('check_number', '!=', False),
|
|
('check_number', '!=', ''),
|
|
('company_id', '=', cheque.company_id.id),
|
|
])
|
|
for payment in payments:
|
|
try:
|
|
num = int(payment.check_number)
|
|
if num > max_payment:
|
|
max_payment = num
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Get highest from payroll cheques
|
|
max_payroll_num = 0
|
|
if max_payroll and max_payroll.cheque_number:
|
|
try:
|
|
max_payroll_num = int(max_payroll.cheque_number)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Use the higher of the two, or sequence if both are 0
|
|
next_num = max(max_payroll_num, max_payment) + 1
|
|
|
|
if next_num > 1:
|
|
# Set sequence to next number
|
|
sequence = self.env['ir.sequence'].search([
|
|
('code', '=', 'payroll.cheque'),
|
|
('company_id', '=', cheque.company_id.id),
|
|
], limit=1)
|
|
if sequence:
|
|
sequence.write({'number_next': next_num})
|
|
|
|
cheque.cheque_number = str(next_num).zfill(6)
|
|
else:
|
|
# Use sequence normally
|
|
cheque.cheque_number = self.env['ir.sequence'].next_by_code('payroll.cheque') or '/'
|
|
|
|
def action_print_cheque(self):
|
|
"""Always open wizard to set/change cheque number before printing."""
|
|
self.ensure_one()
|
|
|
|
# Always open wizard to allow changing the cheque 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.id,
|
|
},
|
|
}
|
|
|
|
def action_void(self):
|
|
"""Void the cheque."""
|
|
return {
|
|
'type': 'ir.actions.act_window',
|
|
'name': _('Void Cheque'),
|
|
'res_model': 'payroll.cheque.void.wizard',
|
|
'view_mode': 'form',
|
|
'target': 'new',
|
|
'context': {'default_cheque_id': self.id},
|
|
}
|
|
|
|
def action_mark_cashed(self):
|
|
"""Mark cheque as cashed."""
|
|
self.write({'state': 'cashed'})
|
|
|
|
def action_reset_to_draft(self):
|
|
"""Reset to draft (only for voided cheques)."""
|
|
for cheque in self:
|
|
if cheque.state == 'voided':
|
|
cheque.write({
|
|
'state': 'draft',
|
|
'voided_date': False,
|
|
'void_reason': False,
|
|
})
|
|
|
|
# ==================== HELPER METHODS ====================
|
|
|
|
def get_pay_stub_data(self):
|
|
"""Get pay stub data for the cheque report."""
|
|
self.ensure_one()
|
|
|
|
payslip = self.payslip_id
|
|
if not payslip:
|
|
return {}
|
|
|
|
# Get payslip lines by category
|
|
def get_line_amount(code):
|
|
line = payslip.line_ids.filtered(lambda l: l.code == code)
|
|
return line.total if line else 0
|
|
|
|
# Calculate YTD values
|
|
year_start = self.cheque_date.replace(month=1, day=1)
|
|
ytd_payslips = self.env['hr.payslip'].search([
|
|
('employee_id', '=', self.employee_id.id),
|
|
('date_from', '>=', year_start),
|
|
('date_to', '<=', self.cheque_date),
|
|
('state', 'in', ['done', 'paid']),
|
|
])
|
|
|
|
def get_ytd_amount(code):
|
|
total = 0
|
|
for slip in ytd_payslips:
|
|
line = slip.line_ids.filtered(lambda l: l.code == code)
|
|
total += line.total if line else 0
|
|
return total
|
|
|
|
# Get hourly rate
|
|
hourly_rate = 0
|
|
if hasattr(self.employee_id, 'hourly_rate'):
|
|
hourly_rate = self.employee_id.hourly_rate or 0
|
|
|
|
# Calculate hours from payslip inputs
|
|
regular_hours = 0
|
|
for input_line in payslip.input_line_ids:
|
|
if 'hour' in (input_line.code or '').lower():
|
|
regular_hours = input_line.amount
|
|
break
|
|
|
|
# Get regular pay - look for REGPAY first, then BASIC, then GROSS
|
|
regular_pay_current = (get_line_amount('REGPAY') or get_line_amount('BASIC') or
|
|
get_line_amount('GROSS') or payslip.basic_wage or 0)
|
|
regular_pay_ytd = (get_ytd_amount('REGPAY') or get_ytd_amount('BASIC') or
|
|
get_ytd_amount('GROSS') or 0)
|
|
|
|
# Get vacation pay
|
|
vacation_pay_current = get_line_amount('VAC') or get_line_amount('VACATION') or 0
|
|
vacation_pay_ytd = get_ytd_amount('VAC') or get_ytd_amount('VACATION') or 0
|
|
|
|
# Get stat holiday pay
|
|
stat_pay_current = get_line_amount('STAT') or get_line_amount('STATHOLIDAY') or 0
|
|
stat_pay_ytd = get_ytd_amount('STAT') or get_ytd_amount('STATHOLIDAY') or 0
|
|
|
|
# Get taxes - these are negative in payslip, so use abs()
|
|
# First try to get from payslip lines
|
|
income_tax_current = abs(get_line_amount('FIT') or get_line_amount('INCOMETAX') or 0)
|
|
ei_current = abs(get_line_amount('EI_EMP') or get_line_amount('EI') or 0)
|
|
cpp_current = abs(get_line_amount('CPP_EMP') or get_line_amount('CPP') or 0)
|
|
cpp2_current = abs(get_line_amount('CPP2_EMP') or get_line_amount('CPP2') or 0)
|
|
|
|
# If individual line values are 0, calculate from payslip totals
|
|
total_taxes_from_lines = income_tax_current + ei_current + cpp_current + cpp2_current
|
|
if total_taxes_from_lines == 0 and payslip.basic_wage > 0 and payslip.net_wage > 0:
|
|
# Calculate total taxes as difference between basic and net
|
|
total_taxes_calculated = payslip.basic_wage - payslip.net_wage
|
|
if total_taxes_calculated > 0:
|
|
# Approximate breakdown based on typical Canadian tax rates
|
|
# CPP ~5.95%, EI ~1.63%, Income Tax = remainder
|
|
gross = payslip.basic_wage
|
|
cpp_current = min(gross * 0.0595, 3867.50) # 2025 CPP max
|
|
ei_current = min(gross * 0.0163, 1049.12) # 2025 EI max
|
|
income_tax_current = max(0, total_taxes_calculated - cpp_current - ei_current)
|
|
cpp2_current = 0 # Usually 0 unless over threshold
|
|
|
|
income_tax_ytd = abs(get_ytd_amount('FIT') or get_ytd_amount('INCOMETAX') or 0)
|
|
ei_ytd = abs(get_ytd_amount('EI_EMP') or get_ytd_amount('EI') or 0)
|
|
cpp_ytd = abs(get_ytd_amount('CPP_EMP') or get_ytd_amount('CPP') or 0)
|
|
cpp2_ytd = abs(get_ytd_amount('CPP2_EMP') or get_ytd_amount('CPP2') or 0)
|
|
|
|
# Calculate totals
|
|
total_taxes_current = income_tax_current + ei_current + cpp_current + cpp2_current
|
|
total_taxes_ytd = income_tax_ytd + ei_ytd + cpp_ytd + cpp2_ytd
|
|
|
|
total_pay_current = regular_pay_current + vacation_pay_current + stat_pay_current
|
|
total_pay_ytd = regular_pay_ytd + vacation_pay_ytd + stat_pay_ytd
|
|
|
|
# Get employer contributions
|
|
employer_ei_current = abs(get_line_amount('EI_ER') or 0)
|
|
employer_cpp_current = abs(get_line_amount('CPP_ER') or 0)
|
|
employer_cpp2_current = abs(get_line_amount('CPP2_ER') or 0)
|
|
|
|
# If no employer lines, calculate from employee amounts
|
|
if employer_ei_current == 0 and ei_current > 0:
|
|
employer_ei_current = ei_current * 1.4 # EI employer is 1.4x employee
|
|
if employer_cpp_current == 0 and cpp_current > 0:
|
|
employer_cpp_current = cpp_current # CPP employer matches employee
|
|
if employer_cpp2_current == 0 and cpp2_current > 0:
|
|
employer_cpp2_current = cpp2_current # CPP2 employer matches employee
|
|
|
|
return {
|
|
'pay': {
|
|
'regular_pay': {
|
|
'hours': regular_hours,
|
|
'rate': hourly_rate,
|
|
'current': regular_pay_current,
|
|
'ytd': regular_pay_ytd,
|
|
},
|
|
'vacation_pay': {
|
|
'hours': '-',
|
|
'rate': '-',
|
|
'current': vacation_pay_current,
|
|
'ytd': vacation_pay_ytd,
|
|
},
|
|
'stat_holiday_pay': {
|
|
'hours': '-',
|
|
'rate': hourly_rate,
|
|
'current': stat_pay_current,
|
|
'ytd': stat_pay_ytd,
|
|
},
|
|
},
|
|
'taxes': {
|
|
'income_tax': {
|
|
'current': income_tax_current,
|
|
'ytd': income_tax_ytd,
|
|
},
|
|
'ei': {
|
|
'current': ei_current,
|
|
'ytd': ei_ytd,
|
|
},
|
|
'cpp': {
|
|
'current': cpp_current,
|
|
'ytd': cpp_ytd,
|
|
},
|
|
'cpp2': {
|
|
'current': cpp2_current,
|
|
'ytd': cpp2_ytd,
|
|
},
|
|
},
|
|
'summary': {
|
|
'total_pay': {
|
|
'current': total_pay_current,
|
|
'ytd': total_pay_ytd,
|
|
},
|
|
'taxes': {
|
|
'current': total_taxes_current,
|
|
'ytd': total_taxes_ytd,
|
|
},
|
|
'deductions': {
|
|
'current': 0,
|
|
'ytd': 0,
|
|
},
|
|
'net_pay': {
|
|
'current': self.amount,
|
|
'ytd': sum(s.net_wage for s in ytd_payslips) + self.amount,
|
|
},
|
|
},
|
|
'benefits': {
|
|
'vacation': {
|
|
'accrued': 0,
|
|
'used': 0,
|
|
'available': 0,
|
|
},
|
|
},
|
|
'employer': {
|
|
'ei': {
|
|
'current': employer_ei_current,
|
|
},
|
|
'cpp': {
|
|
'current': employer_cpp_current,
|
|
},
|
|
'cpp2': {
|
|
'current': employer_cpp2_current,
|
|
},
|
|
},
|
|
}
|
|
|
|
@api.model
|
|
def create_from_payslip(self, payslip):
|
|
"""Create a cheque from a payslip."""
|
|
# Check if employee payment method is cheque
|
|
if hasattr(payslip.employee_id, 'payment_method') and payslip.employee_id.payment_method != 'cheque':
|
|
return False
|
|
|
|
# Check if cheque already exists for this payslip
|
|
existing = self.search([('payslip_id', '=', payslip.id)], limit=1)
|
|
if existing:
|
|
return existing
|
|
|
|
cheque = self.create({
|
|
'employee_id': payslip.employee_id.id,
|
|
'payslip_id': payslip.id,
|
|
'payslip_run_id': payslip.payslip_run_id.id if payslip.payslip_run_id else False,
|
|
'amount': payslip.net_wage,
|
|
'cheque_date': payslip.date_to,
|
|
'pay_period_start': payslip.date_from,
|
|
'pay_period_end': payslip.date_to,
|
|
'company_id': payslip.company_id.id,
|
|
})
|
|
|
|
# Link cheque back to payslip
|
|
if cheque and hasattr(payslip, 'cheque_id'):
|
|
payslip.cheque_id = cheque.id
|
|
|
|
return cheque
|
|
|
|
def action_mark_printed(self):
|
|
"""Mark cheque as printed and assign number if not assigned."""
|
|
for cheque in self:
|
|
if not cheque.cheque_number:
|
|
cheque.action_assign_number()
|
|
cheque.state = 'printed'
|
|
|
|
|
|
class PayrollChequeVoidWizard(models.TransientModel):
|
|
"""Wizard to void a cheque with reason."""
|
|
_name = 'payroll.cheque.void.wizard'
|
|
_description = 'Void Cheque Wizard'
|
|
|
|
cheque_id = fields.Many2one('payroll.cheque', required=True)
|
|
void_reason = fields.Text(string='Void Reason', required=True)
|
|
|
|
def action_void(self):
|
|
"""Void the cheque."""
|
|
self.cheque_id.write({
|
|
'state': 'voided',
|
|
'voided_date': fields.Datetime.now(),
|
|
'void_reason': self.void_reason,
|
|
})
|
|
return {'type': 'ir.actions.act_window_close'}
|
|
|
|
|
|
class PayrollChequeLayout(models.Model):
|
|
"""
|
|
Cheque Layout Configuration
|
|
Allows customizing field positions on the cheque.
|
|
"""
|
|
_name = 'payroll.cheque.layout'
|
|
_description = 'Cheque Layout'
|
|
|
|
name = fields.Char(string='Layout Name', required=True)
|
|
active = fields.Boolean(default=True)
|
|
company_id = fields.Many2one(
|
|
'res.company',
|
|
default=lambda self: self.env.company,
|
|
)
|
|
|
|
# Page Settings
|
|
page_width = fields.Float(string='Page Width (inches)', default=8.5)
|
|
page_height = fields.Float(string='Page Height (inches)', default=11)
|
|
|
|
# Cheque Section (Top)
|
|
cheque_height = fields.Float(string='Cheque Height (inches)', default=3.5)
|
|
|
|
# Date Position
|
|
date_x = fields.Float(string='Date X Position', default=6.5)
|
|
date_y = fields.Float(string='Date Y Position', default=0.5)
|
|
date_format = fields.Selection([
|
|
('mmddyyyy', 'MMDDYYYY'),
|
|
('mm/dd/yyyy', 'MM/DD/YYYY'),
|
|
('yyyy-mm-dd', 'YYYY-MM-DD'),
|
|
], string='Date Format', default='mmddyyyy')
|
|
|
|
# Amount Position
|
|
amount_x = fields.Float(string='Amount X Position', default=6.5)
|
|
amount_y = fields.Float(string='Amount Y Position', default=1.5)
|
|
|
|
# Amount in Words Position
|
|
amount_words_x = fields.Float(string='Amount Words X', default=0.5)
|
|
amount_words_y = fields.Float(string='Amount Words Y', default=1.0)
|
|
|
|
# Payee Position
|
|
payee_x = fields.Float(string='Payee X Position', default=0.5)
|
|
payee_y = fields.Float(string='Payee Y Position', default=1.5)
|
|
|
|
# Memo Position
|
|
memo_x = fields.Float(string='Memo X Position', default=0.5)
|
|
memo_y = fields.Float(string='Memo Y Position', default=2.5)
|
|
|
|
# Pay Period Position
|
|
pay_period_x = fields.Float(string='Pay Period X', default=0.5)
|
|
pay_period_y = fields.Float(string='Pay Period Y', default=2.0)
|
|
|
|
# Stub Settings
|
|
stub_height = fields.Float(string='Stub Height (inches)', default=3.75)
|
|
show_employer_copy = fields.Boolean(string='Show Employer Copy', default=True)
|
|
|
|
@api.model
|
|
def get_default_layout(self):
|
|
"""Get or create the default layout."""
|
|
layout = self.search([
|
|
('company_id', '=', self.env.company.id),
|
|
], limit=1)
|
|
|
|
if not layout:
|
|
layout = self.create({
|
|
'name': 'Default Cheque Layout',
|
|
'company_id': self.env.company.id,
|
|
})
|
|
|
|
return layout
|