# -*- coding: utf-8 -*- import base64 from datetime import date, timedelta from odoo import models, fields, api from odoo.exceptions import UserError, ValidationError class HrROE(models.Model): _name = 'hr.roe' _description = 'Record of Employment' _order = 'create_date desc' _inherit = ['mail.thread', 'mail.activity.mixin'] # === ROE Reason Codes (Service Canada) === ROE_REASON_CODES = [ ('A', 'A - Shortage of work'), ('B', 'B - Strike or lockout'), ('D', 'D - Illness or injury'), ('E', 'E - Quit'), ('F', 'F - Maternity'), ('G', 'G - Retirement'), ('H', 'H - Work-Sharing'), ('J', 'J - Apprentice training'), ('K', 'K - Other'), ('M', 'M - Dismissal'), ('N', 'N - Leave of absence'), ('P', 'P - Parental'), ('Z', 'Z - Compassionate Care/Family Caregiver'), ] PAY_PERIOD_TYPES = [ ('W', 'Weekly'), ('B', 'Bi-Weekly'), ('S', 'Semi-Monthly'), ('M', 'Monthly'), ] STATE_SELECTION = [ ('draft', 'Draft'), ('ready', 'Ready to Submit'), ('submitted', 'Submitted'), ('archived', 'Archived'), ] name = fields.Char( string='Reference', required=True, copy=False, default=lambda self: self.env['ir.sequence'].next_by_code('hr.roe') or 'New', ) state = fields.Selection( selection=STATE_SELECTION, string='Status', default='draft', tracking=True, ) employee_id = fields.Many2one( 'hr.employee', string='Employee', required=True, tracking=True, ) company_id = fields.Many2one( 'res.company', string='Company', required=True, default=lambda self: self.env.company, ) # === Box 5: CRA Business Number === cra_business_number = fields.Char( string='CRA Business Number (BN)', compute='_compute_cra_business_number', readonly=True, help='15-character format: 123456789RP0001', ) @api.depends('company_id') def _compute_cra_business_number(self): """Get CRA business number from payroll settings.""" for roe in self: if roe.company_id: settings = self.env['payroll.config.settings'].get_settings(roe.company_id.id) roe.cra_business_number = settings.get_cra_payroll_account_number() or roe.company_id.vat or '' else: roe.cra_business_number = '' # === Box 6: Pay Period Type === pay_period_type = fields.Selection( selection=PAY_PERIOD_TYPES, string='Pay Period Type', compute='_compute_pay_period_type', store=True, ) # === Box 8: Social Insurance Number === sin_number = fields.Char( string='Social Insurance Number', related='employee_id.sin_number', readonly=True, ) # === Box 10: First Day Worked === first_day_worked = fields.Date( string='First Day Worked', related='employee_id.hire_date', readonly=True, ) # === Box 11: Last Day for Which Paid === last_day_paid = fields.Date( string='Last Day for Which Paid', required=True, tracking=True, ) # === Box 12: Final Pay Period Ending Date === final_pay_period_end = fields.Date( string='Final Pay Period Ending Date', required=True, ) # === Box 13: Occupation === occupation = fields.Char( string='Occupation', related='employee_id.job_title', readonly=True, ) # === Box 14: Expected Date of Recall === expected_recall_date = fields.Date( string='Expected Date of Recall', help='If temporary layoff, when employee is expected to return', ) # === Box 15A: Total Insurable Hours === total_insurable_hours = fields.Float( string='Total Insurable Hours', digits=(10, 2), help='Total hours worked during the insurable period', ) # === Box 15B: Total Insurable Earnings === total_insurable_earnings = fields.Float( string='Total Insurable Earnings', digits=(10, 2), help='Total earnings during the insurable period', ) # === Box 15C: Insurable Earnings by Pay Period === pay_period_earnings_ids = fields.One2many( 'hr.roe.pay.period', 'roe_id', string='Pay Period Earnings', ) # === Box 16: Reason for Issuing ROE === reason_code = fields.Selection( selection=ROE_REASON_CODES, string='Reason for Issuing ROE', required=True, tracking=True, ) # === Box 17: Other Payments === other_payments = fields.Text( string='Other Payments/Benefits', help='Other than regular pay, paid or payable at a later date', ) # === Box 18: Comments === comments = fields.Text( string='Comments', ) # === Box 20: Communication Preference === communication_language = fields.Selection([ ('E', 'English'), ('F', 'French'), ], string='Communication Preference', default='E') # === Contact Information === contact_name = fields.Char( string='Contact Person', default=lambda self: self.env.user.name, ) contact_phone = fields.Char( string='Contact Phone', ) # === File Attachments === blk_file = fields.Binary( string='BLK File', attachment=True, ) blk_filename = fields.Char( string='BLK Filename', ) pdf_file = fields.Binary( string='PDF File', attachment=True, ) pdf_filename = fields.Char( string='PDF Filename', ) # === Submission Tracking === submission_date = fields.Date( string='Submission Date', tracking=True, ) submission_deadline = fields.Date( string='Submission Deadline', compute='_compute_submission_deadline', store=True, ) service_canada_serial = fields.Char( string='Service Canada Serial Number', help='Serial number assigned after submission', ) @api.depends('employee_id', 'employee_id.pay_schedule') def _compute_pay_period_type(self): mapping = { 'weekly': 'W', 'biweekly': 'B', 'semi_monthly': 'S', 'monthly': 'M', } for roe in self: schedule = roe.employee_id.pay_schedule if roe.employee_id else 'biweekly' roe.pay_period_type = mapping.get(schedule, 'B') @api.depends('last_day_paid') def _compute_submission_deadline(self): for roe in self: if roe.last_day_paid: # ROE must be submitted within 5 calendar days roe.submission_deadline = roe.last_day_paid + timedelta(days=5) else: roe.submission_deadline = False def action_calculate_earnings(self): """Calculate insurable earnings from payslips with proper period allocation""" self.ensure_one() if not self.employee_id: raise UserError('Please select an employee first.') # Find all payslips for this employee in the last year year_ago = self.last_day_paid - timedelta(days=365) if self.last_day_paid else date.today() - timedelta(days=365) payslips = self.env['hr.payslip'].search([ ('employee_id', '=', self.employee_id.id), ('state', '=', 'done'), ('date_from', '>=', year_ago), ('date_to', '<=', self.last_day_paid or date.today()), ], order='date_from asc', limit=53) # Max 53 pay periods, order ascending for period allocation if not payslips: raise UserError('No payslips found for this employee in the specified period.') Payslip = self.env['hr.payslip'] # Track earnings by period # Key: period index (0-based), Value: total insurable earnings for that period period_earnings = {} total_hours = 0 total_earnings = 0 # Process each payslip for idx, payslip in enumerate(payslips): # Get worked hours for this payslip worked_days = payslip.worked_days_line_ids hours = sum(wd.number_of_hours for wd in worked_days) if worked_days else 0 total_hours += hours # Break down earnings by pay type period_earnings_for_which = 0 # Earnings for this period (work period) period_earnings_in_which = 0 # Earnings for next period (pay date) for line in payslip.line_ids: code = line.code or '' category_code = line.category_id.code if line.category_id else None amount = abs(line.total or 0) # Use pay type helpers pay_type = Payslip._get_pay_type_from_code(code, category_code) is_reimbursement = Payslip._is_reimbursement(code, category_code) # Skip reimbursements - they are non-insurable if is_reimbursement: continue # Skip union dues - they are deductions, not earnings if pay_type == 'union_dues': continue # "For which period" allocation (work period) # Salary, Hourly, Overtime, Stat Holiday, Commission if pay_type in ['salary', 'hourly', 'overtime', 'stat_holiday', 'commission', 'other']: period_earnings_for_which += amount # "In which period" allocation (pay date) # Bonus, Allowance, Vacation (paid as %) elif pay_type in ['bonus', 'allowance']: period_earnings_in_which += amount # Allocate earnings to periods # "For which" earnings go to current period (idx) if idx not in period_earnings: period_earnings[idx] = 0 period_earnings[idx] += period_earnings_for_which # "In which" earnings go to next period (idx + 1) # If it's the last payslip, allocate to current period next_period_idx = idx + 1 if idx < len(payslips) - 1 else idx if next_period_idx not in period_earnings: period_earnings[next_period_idx] = 0 period_earnings[next_period_idx] += period_earnings_in_which # Add to total (both types are insurable) total_earnings += period_earnings_for_which + period_earnings_in_which # Clear existing pay period lines self.pay_period_earnings_ids.unlink() # Create pay period lines (ROE uses reverse order - most recent first) pay_period_data = [] for period_idx in sorted(period_earnings.keys(), reverse=True): if period_idx < len(payslips): payslip = payslips[period_idx] earnings = period_earnings[period_idx] # ROE sequence numbers start from 1, most recent period is sequence 1 sequence = len(payslips) - period_idx pay_period_data.append({ 'roe_id': self.id, 'sequence': sequence, 'amount': earnings, 'payslip_id': payslip.id, }) # Create new pay period lines if pay_period_data: self.env['hr.roe.pay.period'].create(pay_period_data) self.write({ 'total_insurable_hours': total_hours, 'total_insurable_earnings': total_earnings, }) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'Earnings Calculated', 'message': f'Found {len(payslips)} pay periods. Total: ${total_earnings:,.2f}', 'type': 'success', } } def action_generate_blk(self): """Generate BLK file for ROE Web submission""" self.ensure_one() blk_content = self._generate_blk_xml() # Encode to base64 blk_data = base64.b64encode(blk_content.encode('utf-8')) # Generate filename employee_name = self.employee_id.name.replace(' ', '_') today = date.today().strftime('%Y-%m-%d') filename = f'ROEForm_{employee_name}_{today}.blk' self.write({ 'blk_file': blk_data, 'blk_filename': filename, 'state': 'ready', }) # Post the file to chatter as attachment attachment = self.env['ir.attachment'].create({ 'name': filename, 'type': 'binary', 'datas': blk_data, 'res_model': self._name, 'res_id': self.id, 'mimetype': 'application/xml', }) # Post message with attachment self.message_post( body=f'BLK file generated: {filename}
Ready for submission to Service Canada ROE Web.', attachment_ids=[attachment.id], message_type='notification', ) # Return download action return { 'type': 'ir.actions.act_url', 'url': f'/web/content/{attachment.id}?download=true', 'target': 'self', } def _generate_blk_xml(self): """Generate the XML content for BLK file in CRA-compliant format""" self.ensure_one() # Format SIN (remove dashes/spaces) sin = (self.sin_number or '').replace('-', '').replace(' ', '') # Employee address emp = self.employee_id # Build pay period earnings XML with proper indentation pp_lines = [] for pp in self.pay_period_earnings_ids: pp_lines.append(f''' {pp.amount:.2f} ''') pp_xml = '\n'.join(pp_lines) # Contact phone parts phone = (self.contact_phone or '').replace('-', '').replace(' ', '').replace('(', '').replace(')', '') area_code = phone[:3] if len(phone) >= 10 else '' phone_number = phone[3:10] if len(phone) >= 10 else phone # Get first name and last name name_parts = (emp.name or '').split() if emp.name else ['', ''] first_name = name_parts[0] if name_parts else '' last_name = ' '.join(name_parts[1:]) if len(name_parts) > 1 else '' # Contact name parts contact_parts = (self.contact_name or '').split() if self.contact_name else ['', ''] contact_first = contact_parts[0] if contact_parts else '' contact_last = ' '.join(contact_parts[1:]) if len(contact_parts) > 1 else '' # Build XML with proper CRA formatting xml = f''' {self.cra_business_number or ''} {self.pay_period_type} {sin} {first_name} {last_name} {emp.home_street or ''} {emp.home_city or ''} {emp.home_province or 'ON'}, CA {(emp.home_postal_code or '').replace(' ', '')} {self.first_day_worked.strftime('%Y-%m-%d') if self.first_day_worked else ''} {self.last_day_paid.strftime('%Y-%m-%d') if self.last_day_paid else ''} {self.final_pay_period_end.strftime('%Y-%m-%d') if self.final_pay_period_end else ''} {'R' if self.expected_recall_date else 'U'} {self.total_insurable_hours:.0f} {pp_xml} {self.reason_code} {contact_first} {contact_last} {area_code} {phone_number} {self.communication_language} ''' return xml def action_print_roe(self): """Print ROE as PDF""" self.ensure_one() return self.env.ref('fusion_payroll.action_report_roe').report_action(self) def action_mark_submitted(self): """Mark ROE as submitted to Service Canada""" self.ensure_one() self.write({ 'state': 'submitted', 'submission_date': date.today(), }) # Update employee ROE tracking self.employee_id.write({ 'roe_issued': True, 'roe_issued_date': date.today(), }) def action_archive(self): """Archive the ROE""" self.ensure_one() self.write({'state': 'archived'}) class HrROEPayPeriod(models.Model): _name = 'hr.roe.pay.period' _description = 'ROE Pay Period Earnings' _order = 'sequence' roe_id = fields.Many2one( 'hr.roe', string='ROE', required=True, ondelete='cascade', ) sequence = fields.Integer( string='Pay Period #', required=True, ) amount = fields.Float( string='Insurable Earnings', digits=(10, 2), ) payslip_id = fields.Many2one( 'hr.payslip', string='Payslip', )