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,614 @@
# -*- 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