# -*- coding: utf-8 -*- import base64 import os import io from datetime import date from odoo import models, fields, api from odoo.exceptions import UserError from odoo import tools class HrT4ASummary(models.Model): """T4A Summary - One per company per tax year""" _name = 'hr.t4a.summary' _description = 'T4A Summary' _order = 'tax_year desc' _inherit = ['mail.thread', 'mail.activity.mixin'] def _get_pdf_text_coordinates(self): """Get text overlay coordinates for flattened PDF Returns dict mapping field names to (x, y, font_size, font_name) tuples Coordinates are in points (1/72 inch), origin at bottom-left Reads from pdf.field.position model based on template type """ # Query configured positions from database for T4A Summary position_model = self.env['pdf.field.position'] return position_model.get_coordinates_dict('T4A Summary') STATE_SELECTION = [ ('draft', 'Draft'), ('generated', 'Generated'), ('filed', 'Filed'), ] name = fields.Char( string='Reference', compute='_compute_name', store=True, ) company_id = fields.Many2one( 'res.company', string='Company', required=True, default=lambda self: self.env.company, ) currency_id = fields.Many2one( related='company_id.currency_id', ) tax_year = fields.Integer( string='Tax Year', required=True, default=lambda self: date.today().year - 1, ) state = fields.Selection( selection=STATE_SELECTION, string='Status', default='draft', tracking=True, ) # === CRA Information === cra_business_number = fields.Char( string='CRA Business Number', compute='_compute_cra_business_number', readonly=True, ) @api.depends('company_id') def _compute_cra_business_number(self): """Get CRA business number from payroll settings.""" for summary in self: if summary.company_id: settings = self.env['payroll.config.settings'].get_settings(summary.company_id.id) summary.cra_business_number = settings.get_cra_payroll_account_number() or summary.company_id.vat or '' else: summary.cra_business_number = '' # === Slip Count === slip_count = fields.Integer( string='Total T4A Slips', compute='_compute_totals', store=True, ) slip_ids = fields.One2many( 'hr.t4a.slip', 'summary_id', string='T4A Slips', ) # === Summary Totals === total_box_016 = fields.Monetary( string='Total Box 016 (Pension)', currency_field='currency_id', compute='_compute_totals', store=True, ) total_box_018 = fields.Monetary( string='Total Box 018 (Lump-Sum)', currency_field='currency_id', compute='_compute_totals', store=True, ) total_box_020 = fields.Monetary( string='Total Box 020 (Commissions)', currency_field='currency_id', compute='_compute_totals', store=True, ) total_box_024 = fields.Monetary( string='Total Box 024 (Annuities)', currency_field='currency_id', compute='_compute_totals', store=True, ) total_box_048 = fields.Monetary( string='Total Box 048 (Fees)', currency_field='currency_id', compute='_compute_totals', store=True, ) # === Contact Information === contact_name = fields.Char( string='Contact Person', default=lambda self: self.env.user.name, ) contact_phone = fields.Char( string='Telephone', ) # === Filing Information === filing_date = fields.Date( string='Filing Date', tracking=True, ) @api.depends('tax_year', 'company_id') def _compute_name(self): for rec in self: rec.name = f"T4A Summary {rec.tax_year} - {rec.company_id.name}" @api.depends('slip_ids') def _compute_totals(self): for rec in self: slips = rec.slip_ids rec.slip_count = len(slips) rec.total_box_016 = sum(slips.mapped('box_016_pension')) rec.total_box_018 = sum(slips.mapped('box_018_lump_sum')) rec.total_box_020 = sum(slips.mapped('box_020_commissions')) rec.total_box_024 = sum(slips.mapped('box_024_annuities')) rec.total_box_048 = sum(slips.mapped('box_048_fees')) def action_mark_filed(self): """Mark T4A Summary as filed""" self.ensure_one() self.write({ 'state': 'filed', 'filing_date': date.today(), }) class HrT4ASlip(models.Model): """T4A Slip - One per recipient per tax year""" _name = 'hr.t4a.slip' _description = 'T4A Slip' _order = 'recipient_name' def _get_pdf_text_coordinates(self): """Get text overlay coordinates for flattened PDF Returns dict mapping field names to (x, y, font_size, font_name) tuples Coordinates are in points (1/72 inch), origin at bottom-left Reads from pdf.field.position model based on template type """ # Query configured positions from database for T4A position_model = self.env['pdf.field.position'] return position_model.get_coordinates_dict('T4A') def _overlay_text_on_pdf(self, template_path, field_mapping): """Overlay text on a flattened PDF using reportlab Returns base64-encoded PDF data """ try: from reportlab.pdfgen import canvas from PyPDF2 import PdfReader, PdfWriter except ImportError as e: raise UserError(f'Required library not available: {str(e)}\nPlease install reportlab and PyPDF2.') # Get text coordinates text_coords = self._get_pdf_text_coordinates() if not text_coords: raise UserError( 'Text coordinates not configured for T4A template. ' 'Please configure PDF field positions in Payroll → Configuration → PDF Field Positions.' ) # Read the template PDF with open(template_path, 'rb') as template_file: template_reader = PdfReader(template_file) if not template_reader.pages: raise UserError('Template PDF has no pages') # Get first page dimensions first_page = template_reader.pages[0] page_width = float(first_page.mediabox.width) page_height = float(first_page.mediabox.height) # Create overlay PDF with text overlay_buffer = io.BytesIO() can = canvas.Canvas(overlay_buffer, pagesize=(page_width, page_height)) # Draw text for each field for field_name, value in field_mapping.items(): if field_name in text_coords and value: coord_data = text_coords[field_name] # Handle both old format (x, y, font_size) and new format (x, y, font_size, font_name) if len(coord_data) == 4: x, y, font_size, font_name = coord_data elif len(coord_data) == 3: x, y, font_size = coord_data font_name = 'Helvetica' # Default font else: continue # Skip invalid coordinate data can.setFont(font_name, font_size) can.drawString(x, y, str(value)) can.save() overlay_buffer.seek(0) # Merge overlay with template with open(template_path, 'rb') as template_file: template_reader = PdfReader(template_file) overlay_reader = PdfReader(overlay_buffer) writer = PdfWriter() for i, page in enumerate(template_reader.pages): if i < len(overlay_reader.pages): page.merge_page(overlay_reader.pages[i]) writer.add_page(page) # Write to bytes output_buffer = io.BytesIO() writer.write(output_buffer) return base64.b64encode(output_buffer.getvalue()) summary_id = fields.Many2one( 'hr.t4a.summary', string='T4A Summary', required=True, ondelete='cascade', ) company_id = fields.Many2one( related='summary_id.company_id', ) currency_id = fields.Many2one( related='summary_id.currency_id', ) tax_year = fields.Integer( related='summary_id.tax_year', store=True, ) # === Recipient Information === recipient_id = fields.Many2one( 'res.partner', string='Recipient', help='Recipient partner (individual or business)', ) recipient_name = fields.Char( string='Recipient Name', required=True, help='Last name, first name and initials', ) recipient_address = fields.Text( string='Recipient Address', help='Full address including province and postal code', ) recipient_sin = fields.Char( string='SIN (Box 12)', help='Social Insurance Number (9 digits)', ) recipient_account_number = fields.Char( string='Account Number (Box 13)', help='Business Number if recipient is a business', ) # === Income Boxes === box_016_pension = fields.Monetary( string='Box 016: Pension or Superannuation', currency_field='currency_id', ) box_018_lump_sum = fields.Monetary( string='Box 018: Lump-Sum Payments', currency_field='currency_id', ) box_020_commissions = fields.Monetary( string='Box 020: Self-Employed Commissions', currency_field='currency_id', ) box_024_annuities = fields.Monetary( string='Box 024: Annuities', currency_field='currency_id', ) box_048_fees = fields.Monetary( string='Box 048: Fees for Services', currency_field='currency_id', ) # === Other Information (028-197) === other_info_ids = fields.One2many( 'hr.t4a.other.info', 'slip_id', string='Other Information', help='Other information boxes (028-197)', ) # === PDF Generation === filled_pdf = fields.Binary( string='Filled PDF', attachment=True, ) filled_pdf_filename = fields.Char( string='PDF Filename', ) @api.onchange('recipient_id') def _onchange_recipient_id(self): """Auto-fill recipient information from partner""" if self.recipient_id: # Format name: Last name, First name name_parts = self.recipient_id.name.split(',') if ',' in self.recipient_id.name else self.recipient_id.name.split() if len(name_parts) >= 2: self.recipient_name = f"{name_parts[-1].strip()}, {' '.join(name_parts[:-1]).strip()}" else: self.recipient_name = self.recipient_id.name # Build address address_parts = [] if self.recipient_id.street: address_parts.append(self.recipient_id.street) if self.recipient_id.street2: address_parts.append(self.recipient_id.street2) if self.recipient_id.city: city_line = self.recipient_id.city if self.recipient_id.state_id: city_line += f", {self.recipient_id.state_id.code}" if self.recipient_id.zip: city_line += f" {self.recipient_id.zip}" address_parts.append(city_line) self.recipient_address = '\n'.join(address_parts) # Get SIN if available (might be stored in a custom field) if hasattr(self.recipient_id, 'sin_number'): self.recipient_sin = self.recipient_id.sin_number def action_fill_pdf(self): """Fill the T4A PDF form with data from this slip""" self.ensure_one() try: # Try to import pdfrw (preferred) or PyPDF2 try: from pdfrw import PdfReader, PdfWriter use_pdfrw = True except ImportError: try: import PyPDF2 use_pdfrw = False except ImportError: raise UserError( 'PDF library not found. Please install pdfrw or PyPDF2:\n' 'pip install pdfrw\n' 'or\n' 'pip install PyPDF2' ) # Get PDF template path - try multiple locations # 1. Try in static/pdf/ folder (recommended location) module_path = os.path.dirname(os.path.dirname(__file__)) template_path = os.path.join(module_path, 'static', 'pdf', 't4a-fill-25e.pdf') # 2. Try in module root directory (fallback) if not os.path.exists(template_path): template_path = os.path.join(module_path, 't4a-fill-25e.pdf') # 3. Try using tools.file_path (Odoo 19) if not os.path.exists(template_path): try: template_path = tools.file_path('fusion_payroll/static/pdf/t4a-fill-25e.pdf') except: pass # 4. Final fallback - root directory if not os.path.exists(template_path): try: template_path = tools.file_path('fusion_payroll/t4a-fill-25e.pdf') except: pass if not os.path.exists(template_path): raise UserError( 'T4A PDF template not found. Please ensure t4a-fill-25e.pdf is in one of these locations:\n' f'1. {os.path.join(module_path, "static", "pdf", "t4a-fill-25e.pdf")} (recommended)\n' f'2. {os.path.join(module_path, "t4a-fill-25e.pdf")} (module root)\n\n' 'The system will automatically fill the PDF with data from this T4A slip when you click "Fill PDF".' ) # Get field mapping field_mapping = self._get_pdf_field_mapping() # Check if we should use text overlay (for flattened PDFs) text_coords = self._get_pdf_text_coordinates() if text_coords: # Use text overlay method for flattened PDF pdf_data = self._overlay_text_on_pdf(template_path, field_mapping) elif use_pdfrw: # Use pdfrw to fill PDF from pdfrw import PdfDict template = PdfReader(template_path) # Fill form fields if hasattr(template.Root, 'AcroForm') and template.Root.AcroForm: if hasattr(template.Root.AcroForm, 'Fields') and template.Root.AcroForm.Fields: for field in template.Root.AcroForm.Fields: # Get field name (can be in /T or /TU) field_name = None if hasattr(field, 'T'): field_name = str(field.T).strip('()') elif hasattr(field, 'TU'): field_name = str(field.TU).strip('()') if field_name and field_name in field_mapping: value = field_mapping[field_name] if value is not None and value != '': # Set field value field.V = str(value) # Make sure field is not read-only if hasattr(field, 'Ff'): field.Ff = 0 # Remove read-only flag # Write filled PDF to temporary file import tempfile with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp_file: tmp_path = tmp_file.name writer = PdfWriter() writer.write(template, tmp_path) # Read filled PDF with open(tmp_path, 'rb') as f: pdf_data = base64.b64encode(f.read()) # Clean up temp file try: os.remove(tmp_path) except: pass else: # Use PyPDF2 (fallback) with open(template_path, 'rb') as template_file: reader = PyPDF2.PdfReader(template_file) writer = PyPDF2.PdfWriter() # Copy pages for page in reader.pages: writer.add_page(page) # Fill form fields field_mapping = self._get_pdf_field_mapping() if reader.get_form_text_fields(): writer.update_page_form_field_values(writer.pages[0], field_mapping) # Write to bytes output_buffer = io.BytesIO() writer.write(output_buffer) pdf_data = base64.b64encode(output_buffer.getvalue()) # Generate filename recipient_safe = self.recipient_name.replace(' ', '_').replace(',', '')[:30] filename = f'T4A_{self.tax_year}_{recipient_safe}.pdf' # Save filled PDF self.write({ 'filled_pdf': pdf_data, 'filled_pdf_filename': filename, }) # Post to chatter self.message_post( body=f'T4A PDF generated: {filename}', attachment_ids=[(0, 0, { 'name': filename, 'type': 'binary', 'datas': pdf_data, 'res_model': self._name, 'res_id': self.id, 'mimetype': 'application/pdf', })], ) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'PDF Generated', 'message': f'T4A PDF filled and saved: {filename}', 'type': 'success', } } except Exception as e: raise UserError(f'Error filling PDF: {str(e)}') def action_extract_pdf_fields(self): """Helper method to extract PDF form field names (for debugging)""" self.ensure_one() try: from pdfrw import PdfReader except ImportError: raise UserError('pdfrw library not installed. Install with: pip install pdfrw') # Get PDF template path - try multiple locations module_path = os.path.dirname(os.path.dirname(__file__)) template_path = os.path.join(module_path, 'static', 'pdf', 't4a-fill-25e.pdf') if not os.path.exists(template_path): template_path = os.path.join(module_path, 't4a-fill-25e.pdf') if not os.path.exists(template_path): try: template_path = tools.file_path('fusion_payroll/static/pdf/t4a-fill-25e.pdf') except: template_path = None if not template_path or not os.path.exists(template_path): raise UserError('T4A PDF template not found. Please ensure t4a-fill-25e.pdf is in static/pdf/ or module root.') template = PdfReader(template_path) field_names = [] # Extract field names from all pages for page_num, page in enumerate(template.pages, 1): if hasattr(page, 'Annots') and page.Annots: for annot in page.Annots: if hasattr(annot, 'Subtype') and str(annot.Subtype) == '/Widget': if hasattr(annot, 'T'): field_name = str(annot.T).strip('()') field_names.append(f'Page {page_num}: {field_name}') # Return as message if field_names: message = 'PDF Form Fields Found:\n\n' + '\n'.join(field_names[:50]) if len(field_names) > 50: message += f'\n\n... and {len(field_names) - 50} more fields' else: message = 'No form fields found in PDF. The PDF may not be a fillable form, or field names are stored differently.' return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'PDF Fields', 'message': message, 'type': 'info', 'sticky': True, } } def _get_pdf_field_mapping(self): """Map model fields to PDF form field names""" # This mapping may need to be adjusted based on actual PDF form field names # Common field name patterns for T4A forms: # You can use action_extract_pdf_fields() to see actual field names in the PDF mapping = {} # Year mapping['Year'] = str(self.tax_year) mapping['year'] = str(self.tax_year) mapping['YEAR'] = str(self.tax_year) # Payer information (from company) company = self.company_id if company: mapping['PayerName'] = company.name or '' mapping['PayerName1'] = company.name or '' if company.street: mapping['PayerAddress1'] = company.street if company.street2: mapping['PayerAddress2'] = company.street2 if company.city: city_line = company.city if company.state_id: city_line += f", {company.state_id.code}" if company.zip: city_line += f" {company.zip}" mapping['PayerCity'] = city_line # Payer account number settings = self.env['payroll.config.settings'].get_settings(company.id) account_num = settings.get_cra_payroll_account_number() or company.vat or '' mapping['PayerAccount'] = account_num mapping['Box54'] = account_num # Recipient information if self.recipient_name: # Split name into last, first name_parts = self.recipient_name.split(',') if len(name_parts) >= 2: mapping['LastName'] = name_parts[0].strip() mapping['FirstName'] = name_parts[1].strip() else: # Try to split by space name_parts = self.recipient_name.split() if len(name_parts) >= 2: mapping['LastName'] = name_parts[-1] mapping['FirstName'] = ' '.join(name_parts[:-1]) else: mapping['LastName'] = self.recipient_name if self.recipient_address: addr_lines = self.recipient_address.split('\n') for i, line in enumerate(addr_lines[:3], 1): mapping[f'RecipientAddress{i}'] = line if self.recipient_sin: mapping['SIN'] = self.recipient_sin.replace('-', '').replace(' ', '') mapping['Box12'] = self.recipient_sin.replace('-', '').replace(' ', '') if self.recipient_account_number: mapping['Box13'] = self.recipient_account_number # Income boxes if self.box_016_pension: mapping['Box016'] = f"{self.box_016_pension:.2f}" mapping['016'] = f"{self.box_016_pension:.2f}" if self.box_018_lump_sum: mapping['Box018'] = f"{self.box_018_lump_sum:.2f}" mapping['018'] = f"{self.box_018_lump_sum:.2f}" if self.box_020_commissions: mapping['Box020'] = f"{self.box_020_commissions:.2f}" mapping['020'] = f"{self.box_020_commissions:.2f}" if self.box_024_annuities: mapping['Box024'] = f"{self.box_024_annuities:.2f}" mapping['024'] = f"{self.box_024_annuities:.2f}" if self.box_048_fees: mapping['Box048'] = f"{self.box_048_fees:.2f}" mapping['048'] = f"{self.box_048_fees:.2f}" # Other information boxes for other_info in self.other_info_ids: box_num = str(other_info.box_number).zfill(3) mapping[f'Box{box_num}'] = f"{other_info.amount:.2f}" mapping[box_num] = f"{other_info.amount:.2f}" return mapping def action_download_pdf(self): """Download the filled PDF""" self.ensure_one() if not self.filled_pdf: raise UserError('No PDF has been generated yet. Please click "Fill PDF" first.') return { 'type': 'ir.actions.act_url', 'url': f'/web/content/hr.t4a.slip/{self.id}/filled_pdf/{self.filled_pdf_filename}?download=true', 'target': 'self', } class HrT4AOtherInfo(models.Model): """T4A Other Information (Boxes 028-197)""" _name = 'hr.t4a.other.info' _description = 'T4A Other Information' _order = 'box_number' slip_id = fields.Many2one( 'hr.t4a.slip', string='T4A Slip', required=True, ondelete='cascade', ) box_number = fields.Integer( string='Box Number', required=True, help='Box number (028-197)', ) currency_id_slip = fields.Many2one( related='slip_id.currency_id', string='Currency', ) amount = fields.Monetary( string='Amount', currency_field='currency_id_slip', required=True, ) description = fields.Char( string='Description', help='Description of this income type', )