Files
Odoo-Modules/fusion_payroll/models/hr_payroll_t4a.py
2026-02-22 01:22:18 -05:00

705 lines
26 KiB
Python

# -*- 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: <strong>{filename}</strong>',
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',
)