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