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

Binary file not shown.

BIN
fusion_payroll/T4-Empty.pdf Normal file

Binary file not shown.

View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import models
from . import wizards
from . import controllers

View File

@@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
{
'name': 'Fusion Payroll - Canada',
'version': '19.0.2.4.6',
'category': 'Human Resources/Payroll',
'summary': 'Canadian Payroll - QuickBooks-like features with CPP, CPP2, EI, ROE',
'description': """
Fusion Payroll - Canadian Payroll Module
=========================================
Comprehensive Canadian payroll functionality inspired by QuickBooks Online Payroll.
**Tax Calculations:**
- Federal Income Tax (5 brackets)
- Provincial Income Tax (Ontario - 5 brackets)
- Support for all Canadian provinces and territories
- Dynamic pay period support (Weekly, Bi-Weekly, Semi-Monthly, Monthly)
**Deductions:**
- CPP (Canada Pension Plan) - Employee and Employer portions
- CPP2 (Second Canada Pension Plan - 2024+) - 4% on earnings above first ceiling
- EI (Employment Insurance) - Employee portion + Employer 1.4x multiplier
- Year-to-date tracking with annual maximums
- Tax exemption flags per employee
**Additional Pay Types:**
- Vacation Pay (% of earnings)
- Stat Holiday Pay
- Overtime Pay (1.5x)
**Employee Management:**
- Employment Status (Active/On Leave/Terminated)
- Full Canadian address with province selection
- SIN validation
- T4 Dental Benefits Code
- Emergency contact information
- Pay schedule per employee
**Record of Employment (ROE):**
- Full ROE generation with all Service Canada fields
- BLK file export for ROE Web submission
- Automatic calculation of insurable earnings
- 5-day submission deadline tracking
- All official ROE reason codes (A-Z)
**Configuration:**
- Payroll → Configuration → Yearly Rates
- Payroll → ROE → Records of Employment
Built for Odoo Enterprise Payroll (hr_payroll).
""",
'author': 'Your Company',
'website': '',
'license': 'LGPL-3',
'depends': [
'hr_payroll', # Core payroll functionality
'hr_work_entry_enterprise', # For payroll menu structure (Odoo 19)
'hr_holidays', # For vacation/leave reports
'hr_attendance', # For punch-in/out time tracking
'mail', # For ROE chatter/tracking
],
'data': [
# Views
'views/pay_period_views.xml',
'views/tax_yearly_rates_views.xml',
'views/hr_employee_views.xml',
'views/hr_roe_views.xml',
'views/hr_payslip_views.xml',
'views/hr_tax_centre_views.xml',
'views/hr_t4_views.xml',
'views/hr_t4a_views.xml',
'views/pdf_field_position_views.xml',
'views/run_payroll_wizard_views.xml',
'views/payroll_report_views.xml',
'views/payroll_tax_payment_schedule_views.xml',
'views/payroll_config_settings_views.xml',
'views/payroll_work_location_views.xml',
'views/payroll_tax_payment_schedule_views.xml',
'views/payroll_dashboard_views.xml',
'views/payroll_cheque_views.xml',
'views/payroll_cheque_print_wizard_views.xml',
'views/cheque_number_wizard_views.xml',
'views/cheque_layout_settings_views.xml',
# Central Menu Structure (must be last - references other actions)
'views/fusion_payroll_menus.xml',
# Reports
'reports/report_paystub_canada.xml',
'reports/payroll_cheque_report.xml',
'reports/payroll_reports.xml',
'reports/payroll_report_pdf.xml',
# Data (order matters!)
# 1. Rule parameters (CPP, EI, Federal, Provincial rates - Odoo native approach)
'data/hr_rule_parameter_data.xml',
# 2. Input types for additional pay (OT, Stat, Bonus)
'data/hr_payslip_input_type_data.xml',
# 3. Legacy tax rates data
'data/tax_yearly_rates_data.xml',
# 4. Payroll structure (creates structure and category)
'data/hr_payroll_structure.xml',
# 5. Canadian salary rules (references structure and parameters)
'data/hr_salary_rules.xml',
# 6. Demo/Sample data (loads on install)
'demo/demo_data.xml',
# Security (load last to ensure all models are registered)
'security/ir.model.access.csv',
],
'demo': [],
'images': ['static/description/icon.png'],
'assets': {
'web.assets_backend': [
'fusion_payroll/static/src/scss/payroll_report.scss',
'fusion_payroll/static/src/js/report_hub.js',
'fusion_payroll/static/src/js/payroll_report_action.js',
'fusion_payroll/static/src/xml/report_hub.xml',
'fusion_payroll/static/src/xml/payroll_report_templates.xml',
],
'web.report_assets_common': [
'fusion_payroll/static/src/css/roe_report.css',
],
},
'installable': True,
'application': True,
'auto_install': False,
}

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import payroll_report

View File

@@ -0,0 +1,249 @@
# -*- coding: utf-8 -*-
"""
Payroll Report Controller
=========================
Handles AJAX requests for payroll reports:
- Loading report data
- Exporting PDF/Excel
- Updating filters
"""
import base64
import json
from datetime import date
from odoo import http
from odoo.http import request, content_disposition
class PayrollReportController(http.Controller):
"""Controller for payroll report endpoints."""
@http.route('/payroll/report/get_report_data', type='jsonrpc', auth='user')
def get_report_data(self, report_model, options=None):
"""
Get report data for the given report model.
Args:
report_model: The report model name (e.g., 'payroll.report.paycheque.history')
options: Filter options dictionary
Returns:
dict: Report data with columns, lines, and options
"""
try:
# Check if model exists
if report_model not in request.env.registry:
return {'error': f'Report model {report_model} not found in registry'}
# Get the model class (works for abstract models)
report = request.env[report_model]
# Build options
if options:
if isinstance(options, str):
options = json.loads(options)
final_options = report._get_options(options)
# Get report data
columns = report._get_columns()
lines = report._get_lines(final_options)
return {
'report_name': report.report_name,
'report_code': report.report_code,
'options': final_options,
'columns': columns,
'lines': lines,
'date_filter_options': report.DATE_FILTER_OPTIONS,
'filter_date_range': report.filter_date_range,
'filter_employee': report.filter_employee,
'filter_department': report.filter_department,
}
except KeyError as e:
return {'error': f'Report model {report_model} not found: {str(e)}'}
except AttributeError as e:
return {'error': f'Report model {report_model} missing required attribute: {str(e)}'}
except Exception as e:
import traceback
import logging
_logger = logging.getLogger(__name__)
_logger.error(f"Error in get_report_data: {str(e)}\n{traceback.format_exc()}")
return {'error': str(e), 'traceback': traceback.format_exc()}
@http.route('/payroll/report/get_lines', type='jsonrpc', auth='user')
def get_lines(self, report_model, options):
"""
Get report lines with current options.
"""
try:
if report_model not in request.env:
return {'error': f'Report model {report_model} not found'}
report = request.env[report_model]
if isinstance(options, str):
options = json.loads(options)
final_options = report._get_options(options)
return {
'lines': report._get_lines(final_options),
'options': final_options,
}
except Exception as e:
import traceback
return {'error': str(e), 'traceback': traceback.format_exc()}
@http.route('/payroll/report/get_detail_lines', type='jsonrpc', auth='user')
def get_detail_lines(self, report_model, line_id, options):
"""
Get expanded detail lines for a specific line.
Used for drill-down functionality.
"""
try:
if report_model not in request.env:
return {'error': f'Report model {report_model} not found'}
report = request.env[report_model]
if isinstance(options, str):
options = json.loads(options)
# Parse line_id to get record id
if '_' in str(line_id):
parts = line_id.split('_')
record_id = int(parts[-1])
else:
record_id = int(line_id)
if hasattr(report, '_get_detail_lines'):
return {
'detail_lines': report._get_detail_lines(record_id, options)
}
return {'detail_lines': []}
except Exception as e:
import traceback
return {'error': str(e), 'traceback': traceback.format_exc()}
@http.route('/payroll/report/export_xlsx', type='http', auth='user')
def export_xlsx(self, report_model, options, **kw):
"""
Export report to Excel format.
"""
report = request.env[report_model]
if isinstance(options, str):
options = json.loads(options)
final_options = report._get_options(options)
try:
xlsx_data = report.get_xlsx(final_options)
filename = f"{report.report_code}_{date.today().strftime('%Y%m%d')}.xlsx"
return request.make_response(
xlsx_data,
headers=[
('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'),
('Content-Disposition', content_disposition(filename)),
('Content-Length', len(xlsx_data)),
],
)
except Exception as e:
return request.make_response(
json.dumps({'error': str(e)}),
headers=[('Content-Type', 'application/json')],
status=500,
)
@http.route('/payroll/report/export_pdf', type='http', auth='user')
def export_pdf(self, report_model, options, **kw):
"""
Export report to PDF format.
"""
report = request.env[report_model]
if isinstance(options, str):
options = json.loads(options)
final_options = report._get_options(options)
try:
pdf_data = report.get_pdf(final_options)
filename = f"{report.report_code}_{date.today().strftime('%Y%m%d')}.pdf"
return request.make_response(
pdf_data,
headers=[
('Content-Type', 'application/pdf'),
('Content-Disposition', content_disposition(filename)),
('Content-Length', len(pdf_data)),
],
)
except Exception as e:
return request.make_response(
json.dumps({'error': str(e)}),
headers=[('Content-Type', 'application/json')],
status=500,
)
@http.route('/payroll/report/get_employees', type='jsonrpc', auth='user')
def get_employees(self, search_term=''):
"""
Get employees for filter dropdown.
"""
try:
domain = [('company_id', '=', request.env.company.id)]
if search_term:
domain.append(('name', 'ilike', search_term))
employees = request.env['hr.employee'].search(domain, limit=50, order='name')
return [
{'id': emp.id, 'name': emp.name, 'department': emp.department_id.name or ''}
for emp in employees
]
except Exception as e:
import traceback
return {'error': str(e), 'traceback': traceback.format_exc()}
@http.route('/payroll/report/get_departments', type='jsonrpc', auth='user')
def get_departments(self):
"""
Get departments for filter dropdown.
"""
try:
departments = request.env['hr.department'].search([
('company_id', '=', request.env.company.id),
], order='name')
return [
{'id': dept.id, 'name': dept.name}
for dept in departments
]
except Exception as e:
import traceback
return {'error': str(e), 'traceback': traceback.format_exc()}
@http.route('/payroll/report/action_open_record', type='jsonrpc', auth='user')
def action_open_record(self, model, res_id):
"""
Get action to open a specific record.
"""
try:
return {
'type': 'ir.actions.act_window',
'res_model': model,
'res_id': res_id,
'view_mode': 'form',
'target': 'current',
}
except Exception as e:
import traceback
return {'error': str(e), 'traceback': traceback.format_exc()}

View File

@@ -0,0 +1,135 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- ============================================================ -->
<!-- CANADA SALARY STRUCTURE -->
<!-- ============================================================ -->
<!-- Salary Structure Type (if needed) -->
<record id="structure_type_canada" model="hr.payroll.structure.type">
<field name="name">Canada</field>
<field name="country_id" ref="base.ca"/>
</record>
<!-- Canada Salary Structure -->
<record id="hr_payroll_structure_canada" model="hr.payroll.structure">
<field name="name">Canada salary structure</field>
<field name="code">Canada</field>
<field name="type_id" ref="structure_type_canada"/>
</record>
<!-- ============================================================ -->
<!-- SALARY RULE CATEGORY - CANADA (Deduction) -->
<!-- This category links to CPP, EI, Federal and Provincial Tax -->
<!-- ============================================================ -->
<record id="hr_payroll_category_canada" model="hr.salary.rule.category">
<field name="name">Deduction</field>
<field name="code">CANADA</field>
<field name="parent_id" ref="hr_payroll.DED"/>
<!-- These links are set via the UI after tax rates are created -->
<!-- cpp_deduction_id, ei_deduction_id, fed_tax_id, provincial_tax_id -->
</record>
<!-- ============================================================ -->
<!-- LINK SALARY RULES TO CANADA STRUCTURE -->
<!-- ============================================================ -->
<!-- Basic Salary -->
<record id="hr_rule_basic" model="hr.salary.rule">
<field name="name">Basic Salary</field>
<field name="code">BASIC</field>
<field name="sequence">1</field>
<field name="category_id" ref="hr_payroll.BASIC"/>
<field name="struct_id" ref="hr_payroll_structure_canada"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">result = payslip.paid_amount</field>
</record>
<!-- House Rent Allowance -->
<record id="hr_rule_hra" model="hr.salary.rule">
<field name="name">House Rent Allowance</field>
<field name="code">HRA</field>
<field name="sequence">5</field>
<field name="category_id" ref="hr_payroll.ALW"/>
<field name="struct_id" ref="hr_payroll_structure_canada"/>
<field name="condition_select">none</field>
<field name="amount_select">fix</field>
<field name="amount_fix">0.0</field>
</record>
<!-- Dearness Allowance -->
<record id="hr_rule_da" model="hr.salary.rule">
<field name="name">Dearness Allowance</field>
<field name="code">DA</field>
<field name="sequence">6</field>
<field name="category_id" ref="hr_payroll.ALW"/>
<field name="struct_id" ref="hr_payroll_structure_canada"/>
<field name="condition_select">none</field>
<field name="amount_select">fix</field>
<field name="amount_fix">0.0</field>
</record>
<!-- Travel Allowance -->
<record id="hr_rule_travel" model="hr.salary.rule">
<field name="name">Travel Allowance</field>
<field name="code">Travel</field>
<field name="sequence">7</field>
<field name="category_id" ref="hr_payroll.ALW"/>
<field name="struct_id" ref="hr_payroll_structure_canada"/>
<field name="condition_select">none</field>
<field name="amount_select">fix</field>
<field name="amount_fix">0.0</field>
</record>
<!-- Meal Allowance -->
<record id="hr_rule_meal" model="hr.salary.rule">
<field name="name">Meal Allowance</field>
<field name="code">Meal</field>
<field name="sequence">8</field>
<field name="category_id" ref="hr_payroll.ALW"/>
<field name="struct_id" ref="hr_payroll_structure_canada"/>
<field name="condition_select">none</field>
<field name="amount_select">fix</field>
<field name="amount_fix">0.0</field>
</record>
<!-- Medical Allowance -->
<record id="hr_rule_medical" model="hr.salary.rule">
<field name="name">Medical Allowance</field>
<field name="code">Medical</field>
<field name="sequence">9</field>
<field name="category_id" ref="hr_payroll.ALW"/>
<field name="struct_id" ref="hr_payroll_structure_canada"/>
<field name="condition_select">none</field>
<field name="amount_select">fix</field>
<field name="amount_fix">0.0</field>
</record>
<!-- Gross Salary -->
<record id="hr_rule_gross" model="hr.salary.rule">
<field name="name">Gross</field>
<field name="code">GROSS</field>
<field name="sequence">100</field>
<field name="category_id" ref="hr_payroll.GROSS"/>
<field name="struct_id" ref="hr_payroll_structure_canada"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">result = categories.BASIC + categories.ALW</field>
</record>
<!-- Net Salary -->
<record id="hr_rule_net" model="hr.salary.rule">
<field name="name">Net Salary</field>
<field name="code">NET</field>
<field name="sequence">200</field>
<field name="category_id" ref="hr_payroll.NET"/>
<field name="struct_id" ref="hr_payroll_structure_canada"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="amount_python_compute">result = categories.GROSS + categories.DED</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- ============================================================ -->
<!-- CANADIAN PAYSLIP INPUT TYPES -->
<!-- These are used to pass additional pay inputs to salary rules -->
<!-- ============================================================ -->
<!-- Overtime Hours -->
<record id="input_type_ot_hours" model="hr.payslip.input.type">
<field name="name">Overtime Hours</field>
<field name="code">OT_HOURS</field>
<field name="country_id" ref="base.ca"/>
</record>
<!-- Stat Holiday Hours -->
<record id="input_type_stat_hours" model="hr.payslip.input.type">
<field name="name">Stat Holiday Hours</field>
<field name="code">STAT_HOURS</field>
<field name="country_id" ref="base.ca"/>
</record>
<!-- Bonus Amount -->
<record id="input_type_bonus" model="hr.payslip.input.type">
<field name="name">Bonus</field>
<field name="code">BONUS</field>
<field name="country_id" ref="base.ca"/>
</record>
<!-- Vacation Payout -->
<record id="input_type_vacation_payout" model="hr.payslip.input.type">
<field name="name">Vacation Payout</field>
<field name="code">VACATION_PAYOUT</field>
<field name="country_id" ref="base.ca"/>
</record>
<!-- Commission -->
<record id="input_type_commission" model="hr.payslip.input.type">
<field name="name">Commission</field>
<field name="code">COMMISSION</field>
<field name="country_id" ref="base.ca"/>
</record>
<!-- Retroactive Pay -->
<record id="input_type_retro_pay" model="hr.payslip.input.type">
<field name="name">Retroactive Pay</field>
<field name="code">RETRO_PAY</field>
<field name="country_id" ref="base.ca"/>
</record>
<!-- Shift Premium -->
<record id="input_type_shift_premium" model="hr.payslip.input.type">
<field name="name">Shift Premium</field>
<field name="code">SHIFT_PREMIUM</field>
<field name="country_id" ref="base.ca"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,222 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- ============================================================ -->
<!-- CANADA PENSION PLAN (CPP) PARAMETERS - 2025 -->
<!-- ============================================================ -->
<record id="rule_parameter_ca_cpp_rate" model="hr.rule.parameter">
<field name="name">Canada - CPP Rate</field>
<field name="code">ca_cpp_rate</field>
<field name="country_id" ref="base.ca"/>
<field name="description">CPP employee/employer contribution rate</field>
</record>
<record id="rule_parameter_ca_cpp_rate_2025" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="rule_parameter_ca_cpp_rate"/>
<field name="date_from">2025-01-01</field>
<field name="parameter_value">0.0595</field>
</record>
<record id="rule_parameter_ca_cpp_exemption" model="hr.rule.parameter">
<field name="name">Canada - CPP Annual Exemption</field>
<field name="code">ca_cpp_exemption</field>
<field name="country_id" ref="base.ca"/>
<field name="description">CPP basic exemption amount per year</field>
</record>
<record id="rule_parameter_ca_cpp_exemption_2025" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="rule_parameter_ca_cpp_exemption"/>
<field name="date_from">2025-01-01</field>
<field name="parameter_value">3500.00</field>
</record>
<record id="rule_parameter_ca_cpp_max" model="hr.rule.parameter">
<field name="name">Canada - CPP Maximum Annual Contribution</field>
<field name="code">ca_cpp_max</field>
<field name="country_id" ref="base.ca"/>
<field name="description">Maximum CPP employee contribution per year</field>
</record>
<record id="rule_parameter_ca_cpp_max_2025" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="rule_parameter_ca_cpp_max"/>
<field name="date_from">2025-01-01</field>
<field name="parameter_value">4034.10</field>
</record>
<!-- ============================================================ -->
<!-- SECOND CANADA PENSION PLAN (CPP2) PARAMETERS - 2025 -->
<!-- ============================================================ -->
<record id="rule_parameter_ca_cpp2_rate" model="hr.rule.parameter">
<field name="name">Canada - CPP2 Rate</field>
<field name="code">ca_cpp2_rate</field>
<field name="country_id" ref="base.ca"/>
<field name="description">CPP2 contribution rate (on earnings above YMPE)</field>
</record>
<record id="rule_parameter_ca_cpp2_rate_2025" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="rule_parameter_ca_cpp2_rate"/>
<field name="date_from">2025-01-01</field>
<field name="parameter_value">0.04</field>
</record>
<record id="rule_parameter_ca_cpp2_max" model="hr.rule.parameter">
<field name="name">Canada - CPP2 Maximum Annual Contribution</field>
<field name="code">ca_cpp2_max</field>
<field name="country_id" ref="base.ca"/>
<field name="description">Maximum CPP2 employee contribution per year</field>
</record>
<record id="rule_parameter_ca_cpp2_max_2025" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="rule_parameter_ca_cpp2_max"/>
<field name="date_from">2025-01-01</field>
<field name="parameter_value">396.00</field>
</record>
<record id="rule_parameter_ca_ympe" model="hr.rule.parameter">
<field name="name">Canada - YMPE (CPP Ceiling 1)</field>
<field name="code">ca_ympe</field>
<field name="country_id" ref="base.ca"/>
<field name="description">Year's Maximum Pensionable Earnings - CPP first ceiling</field>
</record>
<record id="rule_parameter_ca_ympe_2025" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="rule_parameter_ca_ympe"/>
<field name="date_from">2025-01-01</field>
<field name="parameter_value">71300.00</field>
</record>
<record id="rule_parameter_ca_yampe" model="hr.rule.parameter">
<field name="name">Canada - YAMPE (CPP Ceiling 2)</field>
<field name="code">ca_yampe</field>
<field name="country_id" ref="base.ca"/>
<field name="description">Year's Additional Maximum Pensionable Earnings - CPP second ceiling</field>
</record>
<record id="rule_parameter_ca_yampe_2025" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="rule_parameter_ca_yampe"/>
<field name="date_from">2025-01-01</field>
<field name="parameter_value">81200.00</field>
</record>
<!-- ============================================================ -->
<!-- EMPLOYMENT INSURANCE (EI) PARAMETERS - 2025 -->
<!-- ============================================================ -->
<record id="rule_parameter_ca_ei_rate" model="hr.rule.parameter">
<field name="name">Canada - EI Rate</field>
<field name="code">ca_ei_rate</field>
<field name="country_id" ref="base.ca"/>
<field name="description">EI employee contribution rate</field>
</record>
<record id="rule_parameter_ca_ei_rate_2025" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="rule_parameter_ca_ei_rate"/>
<field name="date_from">2025-01-01</field>
<field name="parameter_value">0.0164</field>
</record>
<record id="rule_parameter_ca_ei_max" model="hr.rule.parameter">
<field name="name">Canada - EI Maximum Annual Premium</field>
<field name="code">ca_ei_max</field>
<field name="country_id" ref="base.ca"/>
<field name="description">Maximum EI employee premium per year</field>
</record>
<record id="rule_parameter_ca_ei_max_2025" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="rule_parameter_ca_ei_max"/>
<field name="date_from">2025-01-01</field>
<field name="parameter_value">1077.48</field>
</record>
<record id="rule_parameter_ca_ei_employer_mult" model="hr.rule.parameter">
<field name="name">Canada - EI Employer Multiplier</field>
<field name="code">ca_ei_employer_mult</field>
<field name="country_id" ref="base.ca"/>
<field name="description">EI employer contribution multiplier (1.4x employee)</field>
</record>
<record id="rule_parameter_ca_ei_employer_mult_2025" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="rule_parameter_ca_ei_employer_mult"/>
<field name="date_from">2025-01-01</field>
<field name="parameter_value">1.4</field>
</record>
<!-- ============================================================ -->
<!-- FEDERAL TAX PARAMETERS - 2025 -->
<!-- ============================================================ -->
<record id="rule_parameter_ca_fed_bpa" model="hr.rule.parameter">
<field name="name">Canada - Federal Basic Personal Amount</field>
<field name="code">ca_fed_bpa</field>
<field name="country_id" ref="base.ca"/>
<field name="description">Federal basic personal amount (TD1 default)</field>
</record>
<record id="rule_parameter_ca_fed_bpa_2025" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="rule_parameter_ca_fed_bpa"/>
<field name="date_from">2025-01-01</field>
<field name="parameter_value">16129</field>
</record>
<record id="rule_parameter_ca_fed_cea" model="hr.rule.parameter">
<field name="name">Canada - Canada Employment Amount</field>
<field name="code">ca_fed_cea</field>
<field name="country_id" ref="base.ca"/>
<field name="description">Canada Employment Amount for federal tax credit</field>
</record>
<record id="rule_parameter_ca_fed_cea_2025" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="rule_parameter_ca_fed_cea"/>
<field name="date_from">2025-01-01</field>
<field name="parameter_value">1433</field>
</record>
<record id="rule_parameter_ca_fed_brackets" model="hr.rule.parameter">
<field name="name">Canada - Federal Tax Brackets</field>
<field name="code">ca_fed_brackets</field>
<field name="country_id" ref="base.ca"/>
<field name="description">Federal income tax brackets: [(threshold, rate), ...]</field>
</record>
<record id="rule_parameter_ca_fed_brackets_2025" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="rule_parameter_ca_fed_brackets"/>
<field name="date_from">2025-01-01</field>
<field name="parameter_value">[(57375, 0.15), (114750, 0.205), (177882, 0.26), (253414, 0.29), (float('inf'), 0.33)]</field>
</record>
<!-- ============================================================ -->
<!-- ONTARIO PROVINCIAL TAX PARAMETERS - 2025 -->
<!-- ============================================================ -->
<record id="rule_parameter_ca_on_bpa" model="hr.rule.parameter">
<field name="name">Canada Ontario - Basic Personal Amount</field>
<field name="code">ca_on_bpa</field>
<field name="country_id" ref="base.ca"/>
<field name="description">Ontario basic personal amount</field>
</record>
<record id="rule_parameter_ca_on_bpa_2025" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="rule_parameter_ca_on_bpa"/>
<field name="date_from">2025-01-01</field>
<field name="parameter_value">12399</field>
</record>
<record id="rule_parameter_ca_on_brackets" model="hr.rule.parameter">
<field name="name">Canada Ontario - Tax Brackets</field>
<field name="code">ca_on_brackets</field>
<field name="country_id" ref="base.ca"/>
<field name="description">Ontario income tax brackets: [(threshold, rate), ...]</field>
</record>
<record id="rule_parameter_ca_on_brackets_2025" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="rule_parameter_ca_on_brackets"/>
<field name="date_from">2025-01-01</field>
<field name="parameter_value">[(52886, 0.0505), (105775, 0.0915), (150000, 0.1116), (220000, 0.1216), (float('inf'), 0.1316)]</field>
</record>
<!-- ============================================================ -->
<!-- VACATION PAY RATE -->
<!-- ============================================================ -->
<record id="rule_parameter_ca_vacation_rate" model="hr.rule.parameter">
<field name="name">Canada - Vacation Pay Rate</field>
<field name="code">ca_vacation_rate</field>
<field name="country_id" ref="base.ca"/>
<field name="description">Vacation pay percentage (Ontario minimum 4%)</field>
</record>
<record id="rule_parameter_ca_vacation_rate_2025" model="hr.rule.parameter.value">
<field name="rule_parameter_id" ref="rule_parameter_ca_vacation_rate"/>
<field name="date_from">2025-01-01</field>
<field name="parameter_value">0.04</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,358 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- ============================================================ -->
<!-- OVERTIME PAY - 1.5x Regular Rate -->
<!-- ============================================================ -->
<record id="hr_overtime_pay" model="hr.salary.rule">
<field name="name">Overtime Pay</field>
<field name="code">OT_PAY</field>
<field name="sequence">101</field>
<field name="struct_id" ref="hr_payroll_structure_canada"/>
<field name="category_id" ref="hr_payroll.ALW"/>
<field name="condition_select">python</field>
<field name="condition_python">result = 'OT_HOURS' in inputs</field>
<field name="amount_select">code</field>
<field name="appears_on_payslip">True</field>
<field name="amount_python_compute">
# Overtime Pay - 1.5x regular hourly rate
OT_MULTIPLIER = 1.5
ot_hours = inputs['OT_HOURS'].amount if 'OT_HOURS' in inputs else 0
# Calculate hourly rate from paid amount (assuming semi-monthly ~86.67 hours)
hourly_rate = payslip.paid_amount / 86.67 if payslip.paid_amount else 0
result = ot_hours * hourly_rate * OT_MULTIPLIER
</field>
</record>
<!-- ============================================================ -->
<!-- STAT HOLIDAY PAY -->
<!-- ============================================================ -->
<record id="hr_stat_holiday_pay" model="hr.salary.rule">
<field name="name">Stat Holiday Pay</field>
<field name="code">STAT_PAY</field>
<field name="sequence">102</field>
<field name="struct_id" ref="hr_payroll_structure_canada"/>
<field name="category_id" ref="hr_payroll.ALW"/>
<field name="condition_select">python</field>
<field name="condition_python">result = 'STAT_HOURS' in inputs</field>
<field name="amount_select">code</field>
<field name="appears_on_payslip">True</field>
<field name="amount_python_compute">
# Stat Holiday Pay
stat_hours = inputs['STAT_HOURS'].amount if 'STAT_HOURS' in inputs else 0
hourly_rate = payslip.paid_amount / 86.67 if payslip.paid_amount else 0
result = stat_hours * hourly_rate
</field>
</record>
<!-- ============================================================ -->
<!-- BONUS PAY -->
<!-- ============================================================ -->
<record id="hr_bonus_pay" model="hr.salary.rule">
<field name="name">Bonus</field>
<field name="code">BONUS_PAY</field>
<field name="sequence">103</field>
<field name="struct_id" ref="hr_payroll_structure_canada"/>
<field name="category_id" ref="hr_payroll.ALW"/>
<field name="condition_select">python</field>
<field name="condition_python">result = 'BONUS' in inputs</field>
<field name="amount_select">code</field>
<field name="appears_on_payslip">True</field>
<field name="amount_python_compute">
# Bonus Pay - direct amount from input
result = inputs['BONUS'].amount if 'BONUS' in inputs else 0
</field>
</record>
<!-- ============================================================ -->
<!-- CPP EMPLOYEE - Canada Pension Plan (Employee Portion) -->
<!-- Uses rule parameters for rates and limits -->
<!-- ============================================================ -->
<record id="hr_cpp_employee" model="hr.salary.rule">
<field name="name">CPP Employee</field>
<field name="code">CPP_EE</field>
<field name="sequence">150</field>
<field name="struct_id" ref="hr_payroll_structure_canada"/>
<field name="category_id" ref="hr_payroll.DED"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="appears_on_payslip">True</field>
<field name="amount_python_compute">
# CPP Employee Deduction - Using Rule Parameters
CPP_RATE = payslip._rule_parameter('ca_cpp_rate')
CPP_EXEMPTION = payslip._rule_parameter('ca_cpp_exemption')
CPP_MAX = payslip._rule_parameter('ca_cpp_max')
PAY_PERIODS = 24 # Semi-monthly
exemption_per_period = CPP_EXEMPTION / PAY_PERIODS
gross = categories['GROSS']
pensionable = max(0, gross - exemption_per_period)
cpp = pensionable * CPP_RATE
# YTD check - get year start
from datetime import date
year_start = date(payslip.date_from.year, 1, 1)
ytd = payslip._sum('CPP_EE', year_start, payslip.date_from) or 0
remaining = CPP_MAX + ytd # ytd is negative
if remaining &lt;= 0:
result = 0
elif cpp > remaining:
result = -remaining
else:
result = -cpp
</field>
</record>
<!-- ============================================================ -->
<!-- CPP EMPLOYER - 1:1 Match -->
<!-- ============================================================ -->
<record id="hr_cpp_employer" model="hr.salary.rule">
<field name="name">CPP Employer</field>
<field name="code">CPP_ER</field>
<field name="sequence">151</field>
<field name="struct_id" ref="hr_payroll_structure_canada"/>
<field name="category_id" ref="hr_payroll.COMP"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="appears_on_payslip">True</field>
<field name="amount_python_compute">
# CPP Employer - 1:1 match with employee
result = abs(CPP_EE) if CPP_EE else 0
</field>
</record>
<!-- ============================================================ -->
<!-- CPP2 EMPLOYEE - Second Canada Pension Plan -->
<!-- Uses rule parameters for rates and limits -->
<!-- ============================================================ -->
<record id="hr_cpp2_employee" model="hr.salary.rule">
<field name="name">CPP2 Employee</field>
<field name="code">CPP2_EE</field>
<field name="sequence">152</field>
<field name="struct_id" ref="hr_payroll_structure_canada"/>
<field name="category_id" ref="hr_payroll.DED"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="appears_on_payslip">True</field>
<field name="amount_python_compute">
# CPP2 (Second CPP) - Using Rule Parameters
CPP2_RATE = payslip._rule_parameter('ca_cpp2_rate')
YMPE = payslip._rule_parameter('ca_ympe')
YAMPE = payslip._rule_parameter('ca_yampe')
CPP2_MAX = payslip._rule_parameter('ca_cpp2_max')
PAY_PERIODS = 24
gross = categories['GROSS']
annual_equiv = gross * PAY_PERIODS
result = 0
# CPP2 only on earnings between YMPE and YAMPE
if annual_equiv > YMPE:
cpp2_base = min(annual_equiv, YAMPE) - YMPE
cpp2_per_period = (cpp2_base * CPP2_RATE) / PAY_PERIODS
# YTD check
from datetime import date
year_start = date(payslip.date_from.year, 1, 1)
ytd = abs(payslip._sum('CPP2_EE', year_start, payslip.date_from) or 0)
remaining = CPP2_MAX - ytd
if remaining > 0:
result = -min(cpp2_per_period, remaining)
</field>
</record>
<!-- ============================================================ -->
<!-- CPP2 EMPLOYER - 1:1 Match -->
<!-- ============================================================ -->
<record id="hr_cpp2_employer" model="hr.salary.rule">
<field name="name">CPP2 Employer</field>
<field name="code">CPP2_ER</field>
<field name="sequence">153</field>
<field name="struct_id" ref="hr_payroll_structure_canada"/>
<field name="category_id" ref="hr_payroll.COMP"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="appears_on_payslip">True</field>
<field name="amount_python_compute">
# CPP2 Employer - 1:1 match
result = abs(CPP2_EE) if CPP2_EE else 0
</field>
</record>
<!-- ============================================================ -->
<!-- EI EMPLOYEE - Employment Insurance -->
<!-- Uses rule parameters for rates and limits -->
<!-- ============================================================ -->
<record id="hr_ei_employee" model="hr.salary.rule">
<field name="name">EI Employee</field>
<field name="code">EI_EE</field>
<field name="sequence">154</field>
<field name="struct_id" ref="hr_payroll_structure_canada"/>
<field name="category_id" ref="hr_payroll.DED"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="appears_on_payslip">True</field>
<field name="amount_python_compute">
# EI Employee - Using Rule Parameters
EI_RATE = payslip._rule_parameter('ca_ei_rate')
EI_MAX = payslip._rule_parameter('ca_ei_max')
gross = categories['GROSS']
ei = gross * EI_RATE
# YTD check
from datetime import date
year_start = date(payslip.date_from.year, 1, 1)
ytd = abs(payslip._sum('EI_EE', year_start, payslip.date_from) or 0)
remaining = EI_MAX - ytd
if remaining &lt;= 0:
result = 0
elif ei > remaining:
result = -remaining
else:
result = -ei
</field>
</record>
<!-- ============================================================ -->
<!-- EI EMPLOYER - 1.4x Employee Premium -->
<!-- ============================================================ -->
<record id="hr_ei_employer" model="hr.salary.rule">
<field name="name">EI Employer</field>
<field name="code">EI_ER</field>
<field name="sequence">155</field>
<field name="struct_id" ref="hr_payroll_structure_canada"/>
<field name="category_id" ref="hr_payroll.COMP"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="appears_on_payslip">True</field>
<field name="amount_python_compute">
# EI Employer - Using Rule Parameter for multiplier
EI_ER_MULT = payslip._rule_parameter('ca_ei_employer_mult')
result = abs(EI_EE) * EI_ER_MULT if EI_EE else 0
</field>
</record>
<!-- ============================================================ -->
<!-- FEDERAL INCOME TAX -->
<!-- Uses rule parameters for brackets and credits -->
<!-- ============================================================ -->
<record id="hr_fed_tax" model="hr.salary.rule">
<field name="name">Federal Income Tax</field>
<field name="code">FED_TAX</field>
<field name="sequence">160</field>
<field name="struct_id" ref="hr_payroll_structure_canada"/>
<field name="category_id" ref="hr_payroll.DED"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="appears_on_payslip">True</field>
<field name="amount_python_compute">
# Federal Income Tax - Using Rule Parameters
PAY_PERIODS = 24
brackets = payslip._rule_parameter('ca_fed_brackets')
BPA = payslip._rule_parameter('ca_fed_bpa')
CEA = payslip._rule_parameter('ca_fed_cea')
CPP_MAX = payslip._rule_parameter('ca_cpp_max')
EI_MAX = payslip._rule_parameter('ca_ei_max')
gross = categories['GROSS']
annual = gross * PAY_PERIODS
# Calculate tax using brackets
tax = 0
prev_threshold = 0
for threshold, rate in brackets:
if annual &lt;= threshold:
tax += (annual - prev_threshold) * rate
break
else:
tax += (threshold - prev_threshold) * rate
prev_threshold = threshold
# Basic personal amount credit
tax_credit = BPA * brackets[0][1] # Lowest rate
# CPP/EI credits
cpp_credit = min(abs(CPP_EE) * PAY_PERIODS if CPP_EE else 0, CPP_MAX) * brackets[0][1]
ei_credit = min(abs(EI_EE) * PAY_PERIODS if EI_EE else 0, EI_MAX) * brackets[0][1]
# Canada Employment Amount credit
cea_credit = CEA * brackets[0][1]
annual_tax = max(0, tax - tax_credit - cpp_credit - ei_credit - cea_credit)
result = -(annual_tax / PAY_PERIODS)
</field>
</record>
<!-- ============================================================ -->
<!-- PROVINCIAL INCOME TAX (ONTARIO) -->
<!-- Uses rule parameters for brackets and credits -->
<!-- ============================================================ -->
<record id="hr_prov_tax" model="hr.salary.rule">
<field name="name">Provincial Income Tax</field>
<field name="code">PROV_TAX</field>
<field name="sequence">161</field>
<field name="struct_id" ref="hr_payroll_structure_canada"/>
<field name="category_id" ref="hr_payroll.DED"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="appears_on_payslip">True</field>
<field name="amount_python_compute">
# Ontario Provincial Tax - Using Rule Parameters
PAY_PERIODS = 24
brackets = payslip._rule_parameter('ca_on_brackets')
BPA_ON = payslip._rule_parameter('ca_on_bpa')
CPP_MAX = payslip._rule_parameter('ca_cpp_max')
EI_MAX = payslip._rule_parameter('ca_ei_max')
gross = categories['GROSS']
annual = gross * PAY_PERIODS
# Calculate tax using brackets
tax = 0
prev_threshold = 0
for threshold, rate in brackets:
if annual &lt;= threshold:
tax += (annual - prev_threshold) * rate
break
else:
tax += (threshold - prev_threshold) * rate
prev_threshold = threshold
# Ontario Basic Personal Amount credit
tax_credit = BPA_ON * brackets[0][1] # Lowest rate
# CPP/EI credits at lowest rate
cpp_credit = min(abs(CPP_EE) * PAY_PERIODS if CPP_EE else 0, CPP_MAX) * brackets[0][1]
ei_credit = min(abs(EI_EE) * PAY_PERIODS if EI_EE else 0, EI_MAX) * brackets[0][1]
annual_tax = max(0, tax - tax_credit - cpp_credit - ei_credit)
result = -(annual_tax / PAY_PERIODS)
</field>
</record>
<!-- ============================================================ -->
<!-- VACATION PAY - 4% of Earnings -->
<!-- ============================================================ -->
<record id="hr_vacation_pay" model="hr.salary.rule">
<field name="name">Vacation Pay</field>
<field name="code">VAC_PAY</field>
<field name="sequence">170</field>
<field name="struct_id" ref="hr_payroll_structure_canada"/>
<field name="category_id" ref="hr_payroll.ALW"/>
<field name="condition_select">none</field>
<field name="amount_select">code</field>
<field name="appears_on_payslip">True</field>
<field name="amount_python_compute">
# Vacation Pay - Using Rule Parameter
VAC_RATE = payslip._rule_parameter('ca_vacation_rate')
result = categories['BASIC'] * VAC_RATE
</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,134 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- ============================================================ -->
<!-- FISCAL YEAR 2025 -->
<!-- Note: This references account.fiscal.year - ensure it exists -->
<!-- ============================================================ -->
<!-- ============================================================ -->
<!-- FEDERAL TAX RATES 2025 -->
<!-- Source: Canada Revenue Agency (CRA) 2025 Tax Brackets -->
<!-- ============================================================ -->
<record id="federal_tax" model="tax.yearly.rates">
<field name="tax_type">federal</field>
<field name="canada_emp_amount">1433.00</field>
<!-- fiscal_year field should reference your fiscal year record -->
</record>
<!-- Federal Tax Bracket 1: 15% on first $55,867 -->
<record id="federal_tax_line_1" model="tax.yearly.rate.line">
<field name="tax_id" ref="federal_tax"/>
<field name="tax_bracket">55867.00</field>
<field name="tax_rate">15.00</field>
<field name="tax_constant">0.00</field>
</record>
<!-- Federal Tax Bracket 2: 20.5% on $55,867 to $111,733 -->
<record id="federal_tax_line_2" model="tax.yearly.rate.line">
<field name="tax_id" ref="federal_tax"/>
<field name="tax_bracket">111733.00</field>
<field name="tax_rate">20.50</field>
<field name="tax_constant">0.00</field>
</record>
<!-- Federal Tax Bracket 3: 26% on $111,733 to $173,205 -->
<record id="federal_tax_line_3" model="tax.yearly.rate.line">
<field name="tax_id" ref="federal_tax"/>
<field name="tax_bracket">173205.00</field>
<field name="tax_rate">26.00</field>
<field name="tax_constant">0.00</field>
</record>
<!-- Federal Tax Bracket 4: 29% on $173,205 to $246,752 -->
<record id="federal_tax_line_4" model="tax.yearly.rate.line">
<field name="tax_id" ref="federal_tax"/>
<field name="tax_bracket">246752.00</field>
<field name="tax_rate">29.00</field>
<field name="tax_constant">0.00</field>
</record>
<!-- Federal Tax Bracket 5: 33% on over $246,752 -->
<record id="federal_tax_line_5" model="tax.yearly.rate.line">
<field name="tax_id" ref="federal_tax"/>
<field name="tax_bracket">246752.01</field>
<field name="tax_rate">33.00</field>
<field name="tax_constant">0.00</field>
</record>
<!-- ============================================================ -->
<!-- PROVINCIAL TAX RATES 2025 - ONTARIO -->
<!-- Source: Ontario 2025 Tax Brackets -->
<!-- ============================================================ -->
<record id="provincial_tax" model="tax.yearly.rates">
<field name="tax_type">provincial</field>
<!-- fiscal_year field should reference your fiscal year record -->
</record>
<!-- Ontario Tax Bracket 1: 5.05% on first $52,886 -->
<record id="provincial_tax_line_1" model="tax.yearly.rate.line">
<field name="tax_id" ref="provincial_tax"/>
<field name="tax_bracket">52886.00</field>
<field name="tax_rate">5.05</field>
<field name="tax_constant">0.00</field>
</record>
<!-- Ontario Tax Bracket 2: 9.15% on $52,886 to $105,775 -->
<record id="provincial_tax_line_2" model="tax.yearly.rate.line">
<field name="tax_id" ref="provincial_tax"/>
<field name="tax_bracket">105775.00</field>
<field name="tax_rate">9.15</field>
<field name="tax_constant">0.00</field>
</record>
<!-- Ontario Tax Bracket 3: 11.16% on $105,775 to $150,000 -->
<record id="provincial_tax_line_3" model="tax.yearly.rate.line">
<field name="tax_id" ref="provincial_tax"/>
<field name="tax_bracket">150000.00</field>
<field name="tax_rate">11.16</field>
<field name="tax_constant">0.00</field>
</record>
<!-- Ontario Tax Bracket 4: 12.16% on $150,000 to $220,000 -->
<record id="provincial_tax_line_4" model="tax.yearly.rate.line">
<field name="tax_id" ref="provincial_tax"/>
<field name="tax_bracket">220000.00</field>
<field name="tax_rate">12.16</field>
<field name="tax_constant">0.00</field>
</record>
<!-- Ontario Tax Bracket 5: 13.16% on over $220,000 -->
<record id="provincial_tax_line_5" model="tax.yearly.rate.line">
<field name="tax_id" ref="provincial_tax"/>
<field name="tax_bracket">220000.01</field>
<field name="tax_rate">13.16</field>
<field name="tax_constant">0.00</field>
</record>
<!-- ============================================================ -->
<!-- CPP - CANADA PENSION PLAN 2025 -->
<!-- ============================================================ -->
<record id="cpp_ded" model="tax.yearly.rates">
<field name="ded_type">cpp</field>
<field name="emp_contribution_rate">5.95</field>
<field name="employer_contribution_rate">5.95</field>
<field name="exemption">134.61</field>
<field name="max_cpp">4034.10</field>
<!-- fiscal_year field should reference your fiscal year record -->
</record>
<!-- ============================================================ -->
<!-- EI - EMPLOYMENT INSURANCE 2025 -->
<!-- ============================================================ -->
<record id="ei_ded" model="tax.yearly.rates">
<field name="ded_type">ei</field>
<field name="ei_rate">1.64</field>
<field name="ei_earnings">65700.00</field>
<field name="emp_ei_amount">1077.48</field>
<field name="employer_ei_amount">1508.47</field>
<!-- fiscal_year field should reference your fiscal year record -->
</record>
</data>
</odoo>

View File

@@ -0,0 +1,324 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- ============================================================ -->
<!-- DEMO EMPLOYEES WITH CANADIAN PAYROLL FIELDS -->
<!-- ============================================================ -->
<!-- Update existing demo employees with Canadian fields -->
<record id="hr.employee_admin" model="hr.employee">
<field name="employment_status">active</field>
<field name="hire_date">2020-01-15</field>
<field name="pay_schedule">semi_monthly</field>
<field name="employee_number">EMP001</field>
<field name="payment_method">direct_deposit</field>
<field name="sin_number">123456789</field>
<field name="home_street">123 Main Street</field>
<field name="home_city">Toronto</field>
<field name="home_province">ON</field>
<field name="home_postal_code">M5V 2K7</field>
<field name="home_country">Canada</field>
<field name="federal_td1_amount">16129.00</field>
<field name="provincial_claim_amount">12399.00</field>
<field name="t4_dental_code">3</field>
<field name="communication_language">E</field>
<field name="emergency_first_name">Jane</field>
<field name="emergency_last_name">Doe</field>
<field name="emergency_relationship">Spouse</field>
<field name="emergency_phone">416-555-0100</field>
</record>
<!-- Demo Employee 1: Active full-time -->
<record id="demo_employee_john" model="hr.employee">
<field name="name">John Smith</field>
<field name="employment_status">active</field>
<field name="hire_date">2022-03-01</field>
<field name="pay_schedule">semi_monthly</field>
<field name="employee_number">EMP002</field>
<field name="payment_method">direct_deposit</field>
<field name="sin_number">987654321</field>
<field name="home_street">456 Oak Avenue</field>
<field name="home_street2">Unit 12</field>
<field name="home_city">Vancouver</field>
<field name="home_province">BC</field>
<field name="home_postal_code">V6B 1A1</field>
<field name="home_country">Canada</field>
<field name="federal_td1_amount">16129.00</field>
<field name="provincial_claim_amount">12399.00</field>
<field name="t4_dental_code">3</field>
<field name="communication_language">E</field>
<field name="preferred_name">Johnny</field>
<field name="gender_identity">male</field>
<field name="billing_rate">75.00</field>
<field name="billable_by_default" eval="True"/>
<field name="emergency_first_name">Sarah</field>
<field name="emergency_last_name">Smith</field>
<field name="emergency_relationship">Wife</field>
<field name="emergency_phone">604-555-0200</field>
<field name="emergency_email">sarah.smith@email.com</field>
</record>
<!-- Demo Employee 2: On Leave -->
<record id="demo_employee_maria" model="hr.employee">
<field name="name">Maria Garcia</field>
<field name="employment_status">on_leave</field>
<field name="hire_date">2021-06-15</field>
<field name="pay_schedule">biweekly</field>
<field name="employee_number">EMP003</field>
<field name="payment_method">direct_deposit</field>
<field name="sin_number">456789123</field>
<field name="home_street">789 Pine Road</field>
<field name="home_city">Montreal</field>
<field name="home_province">QC</field>
<field name="home_postal_code">H2X 1Y6</field>
<field name="home_country">Canada</field>
<field name="federal_td1_amount">16129.00</field>
<field name="provincial_claim_amount">12399.00</field>
<field name="t4_dental_code">1</field>
<field name="communication_language">F</field>
<field name="gender_identity">female</field>
<field name="emergency_first_name">Carlos</field>
<field name="emergency_last_name">Garcia</field>
<field name="emergency_relationship">Brother</field>
<field name="emergency_phone">514-555-0300</field>
</record>
<!-- Demo Employee 3: Terminated (for ROE testing) -->
<record id="demo_employee_terminated" model="hr.employee">
<field name="name">Robert Wilson</field>
<field name="employment_status">terminated</field>
<field name="hire_date">2020-09-01</field>
<field name="last_day_of_work">2025-11-30</field>
<field name="roe_reason_code">A00</field>
<field name="pay_schedule">semi_monthly</field>
<field name="employee_number">EMP004</field>
<field name="payment_method">cheque</field>
<field name="sin_number">789123456</field>
<field name="home_street">321 Elm Street</field>
<field name="home_city">Calgary</field>
<field name="home_province">AB</field>
<field name="home_postal_code">T2P 1J9</field>
<field name="home_country">Canada</field>
<field name="federal_td1_amount">16129.00</field>
<field name="provincial_claim_amount">12399.00</field>
<field name="t4_dental_code">2</field>
<field name="communication_language">E</field>
<field name="roe_issued" eval="False"/>
</record>
<!-- Demo Employee 4: High earner (for CPP2 testing) -->
<record id="demo_employee_executive" model="hr.employee">
<field name="name">Elizabeth Chen</field>
<field name="employment_status">active</field>
<field name="hire_date">2019-01-02</field>
<field name="pay_schedule">semi_monthly</field>
<field name="employee_number">EMP005</field>
<field name="payment_method">direct_deposit</field>
<field name="sin_number">111222333</field>
<field name="home_street">1000 Bay Street</field>
<field name="home_street2">Penthouse</field>
<field name="home_city">Toronto</field>
<field name="home_province">ON</field>
<field name="home_postal_code">M5S 2B4</field>
<field name="home_country">Canada</field>
<field name="federal_td1_amount">16129.00</field>
<field name="federal_additional_tax">500.00</field>
<field name="provincial_claim_amount">12399.00</field>
<field name="t4_dental_code">3</field>
<field name="communication_language">E</field>
<field name="billing_rate">250.00</field>
</record>
<!-- ============================================================ -->
<!-- DEMO ROE (Record of Employment) -->
<!-- ============================================================ -->
<record id="demo_roe_1" model="hr.roe">
<field name="employee_id" ref="demo_employee_terminated"/>
<field name="last_day_paid">2025-11-30</field>
<field name="final_pay_period_end">2025-11-30</field>
<field name="reason_code">A</field>
<field name="total_insurable_hours">1820.00</field>
<field name="total_insurable_earnings">52000.00</field>
<field name="contact_name">HR Manager</field>
<field name="contact_phone">416-555-1000</field>
<field name="communication_language">E</field>
<field name="state">draft</field>
</record>
<!-- ROE Pay Period Lines -->
<record id="demo_roe_pp_1" model="hr.roe.pay.period">
<field name="roe_id" ref="demo_roe_1"/>
<field name="sequence">1</field>
<field name="amount">2166.67</field>
</record>
<record id="demo_roe_pp_2" model="hr.roe.pay.period">
<field name="roe_id" ref="demo_roe_1"/>
<field name="sequence">2</field>
<field name="amount">2166.67</field>
</record>
<record id="demo_roe_pp_3" model="hr.roe.pay.period">
<field name="roe_id" ref="demo_roe_1"/>
<field name="sequence">3</field>
<field name="amount">2166.67</field>
</record>
<record id="demo_roe_pp_4" model="hr.roe.pay.period">
<field name="roe_id" ref="demo_roe_1"/>
<field name="sequence">4</field>
<field name="amount">2166.67</field>
</record>
<!-- ============================================================ -->
<!-- DEMO TAX REMITTANCE -->
<!-- ============================================================ -->
<!-- October 2025 Remittance - Paid -->
<record id="demo_remittance_oct" model="hr.tax.remittance">
<field name="period_type">monthly</field>
<field name="period_start">2025-10-01</field>
<field name="period_end">2025-10-31</field>
<field name="due_date">2025-11-15</field>
<field name="cpp_employee">1850.00</field>
<field name="cpp_employer">1850.00</field>
<field name="cpp2_employee">125.00</field>
<field name="cpp2_employer">125.00</field>
<field name="ei_employee">720.00</field>
<field name="ei_employer">1008.00</field>
<field name="income_tax">8500.00</field>
<field name="payment_date">2025-11-14</field>
<field name="payment_method">cra_my_business</field>
<field name="payment_reference">CRA-2025-10-001</field>
</record>
<!-- November 2025 Remittance - Due Soon -->
<record id="demo_remittance_nov" model="hr.tax.remittance">
<field name="period_type">monthly</field>
<field name="period_start">2025-11-01</field>
<field name="period_end">2025-11-30</field>
<field name="due_date">2025-12-15</field>
<field name="cpp_employee">1920.00</field>
<field name="cpp_employer">1920.00</field>
<field name="cpp2_employee">130.00</field>
<field name="cpp2_employer">130.00</field>
<field name="ei_employee">750.00</field>
<field name="ei_employer">1050.00</field>
<field name="income_tax">8750.00</field>
</record>
<!-- December 2025 Remittance - Draft -->
<record id="demo_remittance_dec" model="hr.tax.remittance">
<field name="period_type">monthly</field>
<field name="period_start">2025-12-01</field>
<field name="period_end">2025-12-31</field>
<field name="due_date">2026-01-15</field>
<field name="cpp_employee">0</field>
<field name="cpp_employer">0</field>
<field name="cpp2_employee">0</field>
<field name="cpp2_employer">0</field>
<field name="ei_employee">0</field>
<field name="ei_employer">0</field>
<field name="income_tax">0</field>
</record>
<!-- ============================================================ -->
<!-- DEMO T4 SUMMARY AND SLIPS -->
<!-- ============================================================ -->
<!-- T4 Summary for 2024 -->
<record id="demo_t4_summary_2024" model="hr.t4.summary">
<field name="tax_year">2024</field>
<field name="contact_name">Payroll Manager</field>
<field name="contact_phone">416-555-2000</field>
<field name="total_remittances">150000.00</field>
<field name="state">filed</field>
<field name="filing_date">2025-02-28</field>
</record>
<!-- T4 Slip for John Smith 2024 -->
<record id="demo_t4_slip_john_2024" model="hr.t4.slip">
<field name="summary_id" ref="demo_t4_summary_2024"/>
<field name="employee_id" ref="demo_employee_john"/>
<field name="employment_income">65000.00</field>
<field name="cpp_employee">3867.50</field>
<field name="cpp2_employee">0</field>
<field name="ei_employee">1049.12</field>
<field name="income_tax">12500.00</field>
<field name="cpp_employer">3867.50</field>
<field name="cpp2_employer">0</field>
<field name="ei_employer">1468.77</field>
</record>
<!-- T4 Slip for Maria Garcia 2024 -->
<record id="demo_t4_slip_maria_2024" model="hr.t4.slip">
<field name="summary_id" ref="demo_t4_summary_2024"/>
<field name="employee_id" ref="demo_employee_maria"/>
<field name="employment_income">55000.00</field>
<field name="cpp_employee">3272.50</field>
<field name="cpp2_employee">0</field>
<field name="ei_employee">902.00</field>
<field name="income_tax">9800.00</field>
<field name="cpp_employer">3272.50</field>
<field name="cpp2_employer">0</field>
<field name="ei_employer">1262.80</field>
</record>
<!-- T4 Slip for Elizabeth Chen (High Earner) 2024 -->
<record id="demo_t4_slip_elizabeth_2024" model="hr.t4.slip">
<field name="summary_id" ref="demo_t4_summary_2024"/>
<field name="employee_id" ref="demo_employee_executive"/>
<field name="employment_income">150000.00</field>
<field name="cpp_employee">4034.10</field>
<field name="cpp2_employee">396.00</field>
<field name="ei_employee">1077.48</field>
<field name="income_tax">42000.00</field>
<field name="cpp_employer">4034.10</field>
<field name="cpp2_employer">396.00</field>
<field name="ei_employer">1508.47</field>
</record>
<!-- T4 Summary for 2025 (Draft) -->
<record id="demo_t4_summary_2025" model="hr.t4.summary">
<field name="tax_year">2025</field>
<field name="contact_name">Payroll Manager</field>
<field name="contact_phone">416-555-2000</field>
<field name="state">draft</field>
</record>
<!-- ============================================================ -->
<!-- DEMO TAX YEARLY RATES (Legacy model for backward compat) -->
<!-- ============================================================ -->
<!-- CPP 2025 -->
<record id="demo_tax_cpp_2025" model="tax.yearly.rates">
<field name="ded_type">cpp</field>
<field name="emp_contribution_rate">5.95</field>
<field name="employer_contribution_rate">5.95</field>
<field name="exemption">3500.00</field>
<field name="max_cpp">4034.10</field>
</record>
<!-- EI 2025 -->
<record id="demo_tax_ei_2025" model="tax.yearly.rates">
<field name="ded_type">ei</field>
<field name="ei_rate">1.64</field>
<field name="ei_earnings">65700.00</field>
<field name="emp_ei_amount">1077.48</field>
<field name="employer_ei_amount">1508.47</field>
</record>
<!-- Federal Tax 2025 -->
<record id="demo_tax_federal_2025" model="tax.yearly.rates">
<field name="tax_type">federal</field>
<field name="canada_emp_amount">1433.00</field>
<field name="fed_tax_credit">16129.00</field>
</record>
<!-- Provincial Tax (Ontario) 2025 -->
<record id="demo_tax_provincial_on_2025" model="tax.yearly.rates">
<field name="tax_type">provincial</field>
<field name="provincial_tax_credit">12399.00</field>
</record>
</data>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,188 @@
# Fusion Payroll - Canada
## Reverse Engineered from: pragmatic_odoo_canada_payroll
**Extraction Date**: 2024-12-31
**Target Base**: Odoo Enterprise hr_payroll
**Original Module**: pragmatic_odoo_canada_payroll (90 external identifiers)
---
## Module Summary
### What Was Extracted
| Component | Count | Status |
|-----------|-------|--------|
| New Models | 2 | ✅ Complete |
| Extended Models | 2 | ✅ Complete |
| Views | 4 | ✅ Complete |
| Salary Structure | 1 | ✅ Complete |
| Salary Rule Category | 1 | ✅ Complete |
| Salary Rules | 14 | ✅ Complete |
| Tax Data Records | 14 | ✅ Complete |
| Security Rules | 2 | ✅ Complete |
---
## Models
### New Models Created
#### 1. tax.yearly.rates
Configuration for yearly tax rates (Federal, Provincial, CPP, EI)
| Field | Type | Description |
|-------|------|-------------|
| fiscal_year | Many2one | Link to account.fiscal.year |
| tax_type | Selection | federal / provincial |
| ded_type | Selection | cpp / ei |
| tax_yearly_rate_ids | One2many | Tax bracket lines |
| fed_tax_credit | Float | Federal tax credit |
| provincial_tax_credit | Float | Provincial tax credit |
| canada_emp_amount | Float | Canada Employment Amount |
| exemption | Float | CPP exemption amount |
| max_cpp | Float | Maximum CPP contribution |
| emp_contribution_rate | Float | Employee CPP rate |
| employer_contribution_rate | Float | Employer CPP rate |
| ei_date | Date | EI effective date |
| ei_rate | Float | EI rate |
| ei_earnings | Float | Maximum EI earnings |
| emp_ei_amount | Float | Employee EI amount |
| employer_ei_amount | Float | Employer EI amount |
#### 2. tax.yearly.rate.line
Tax bracket lines for federal/provincial taxes
| Field | Type | Description |
|-------|------|-------------|
| tax_id | Many2one | Parent tax.yearly.rates |
| tax_bracket | Float | Income threshold |
| tax_rate | Float | Tax rate percentage |
| tax_constant | Float | Tax constant |
### Extended Models
#### 1. hr.contract
Added fields for employee tax credits:
- `fed_tax_credit` - Federal Tax Credit
- `provincial_tax_credit` - Provincial Tax Credit
#### 2. hr.salary.rule.category
Added fields to link Canadian payroll configuration:
- `cpp_deduction_id` - Link to CPP yearly rates
- `ei_deduction_id` - Link to EI yearly rates
- `fed_tax_id` - Link to Federal tax rates
- `provincial_tax_id` - Link to Provincial tax rates
- Related fields for CPP/EI values
---
## Salary Structure
**Name**: Canada salary structure
**Code**: Canada
**Parent**: Base for new structures
### Salary Rules in Structure
| Sequence | Name | Code | Category | Type |
|----------|------|------|----------|------|
| 1 | Basic Salary | BASIC | Basic | Base |
| 5 | House Rent Allowance | HRA | Allowance | Base |
| 6 | Dearness Allowance | DA | Allowance | Base |
| 7 | Travel Allowance | Travel | Allowance | Base |
| 8 | Meal Allowance | Meal | Allowance | Base |
| 9 | Medical Allowance | Medical | Allowance | Base |
| 100 | Gross | GROSS | Gross | Computed |
| 103 | CPP_Employee | CPP_EMPLOYEE | Deduction | Python |
| 104 | CPP_Employer | CPP_EMPLOYER | Company Contribution | Python |
| 105 | EI_Employee | EI_EMPLOYEE | Deduction | Python |
| 106 | EI_Employer | EI_EMPLOYER | Company Contribution | Python |
| 107 | Federal Income Tax | FED | Deduction | Python |
| 108 | Province Income Tax | PR | Deduction | Python |
| 200 | Net Salary | NET | Net | Computed |
---
## Tax Data (2025)
### Federal Tax Brackets (Canada)
| Bracket | Rate | Constant |
|---------|------|----------|
| $55,867 | 15.00% | $0 |
| $111,733 | 20.50% | $0 |
| $173,205 | 26.00% | $0 |
| $246,752 | 29.00% | $0 |
| $246,752+ | 33.00% | $0 |
**Canada Employment Amount**: $1,433.00
### Provincial Tax Brackets (Ontario)
| Bracket | Rate | Constant |
|---------|------|----------|
| $52,886 | 5.05% | $0 |
| $105,775 | 9.15% | $0 |
| $150,000 | 11.16% | $0 |
| $220,000 | 12.16% | $0 |
| $220,000+ | 13.16% | $0 |
### CPP (Canada Pension Plan)
- Employee Contribution Rate: 5.95%
- Employer Contribution Rate: 5.95%
- Exemption Amount: $134.61 (per pay period)
- Maximum CPP: $4,034.10
### EI (Employment Insurance)
- EI Rate: 1.64%
- Maximum EI Earnings: $65,700.00
- Employee EI Amount: $1,077.48
- Employer EI Amount: $1,508.47
---
## Calculation Logic
### Pay Period
The module assumes **semi-monthly** pay periods (24 per year).
All annual amounts are divided by 24.
### CPP Calculation
```
CPP = (GROSS - Exemption/24) * Rate / 100
```
Capped at annual maximum with year-to-date tracking.
### EI Calculation
```
EI = GROSS * Rate / 100
```
Capped at annual maximum with year-to-date tracking.
Employer pays 1.4x employee portion.
### Tax Calculation
Uses progressive tax brackets:
1. Determine bracket based on semi-monthly wage
2. Apply rate and constant
3. Subtract tax credits (personal credits, CPP, EI, Canada Employment)
---
## Installation
1. Copy module to Odoo addons directory
2. Update Apps List
3. Install "Fusion Payroll - Canada"
4. Configure:
- Create Fiscal Year if needed
- Go to Payroll → Configuration → Yearly Rates
- Link CPP, EI, Federal, Provincial records to Salary Rule Category
- Assign Canada structure to employee contracts
---
## Notes
- Year-to-date calculations use hardcoded date ranges (update for each year)
- Tax brackets should be updated annually per CRA guidelines
- Employer EI is 1.4x employee portion
- Module designed for Ontario - add other provinces as needed

View File

@@ -0,0 +1,444 @@
# PDF Field Positioning System
## Overview
The PDF Field Positioning System is a dynamic configuration interface that allows users to define where text fields should be placed on flattened PDF templates (T4, T4 Summary, T4A, T4A Summary). This system was implemented to solve the problem of encrypted PDFs that couldn't be filled using traditional form-filling libraries.
Instead of using fillable PDF forms, the system overlays text directly onto flattened PDF templates using ReportLab, with user-configurable positions, font sizes, and font names.
**Status**: ✅ **IMPLEMENTED** (January 2025)
---
## Problem Statement
### Original Issue
- Fillable PDF templates were encrypted, preventing automated form filling
- Libraries like `pdfrw` and `PyPDF2` couldn't access form fields due to encryption
- Error: "Permission error for the encrypted pdf"
### Solution
- Use flattened (non-fillable) PDF templates
- Overlay text directly onto PDF pages using ReportLab
- Provide a user interface to configure field positions dynamically
- Store positions in database for easy adjustment without code changes
---
## Architecture
### Model: `pdf.field.position`
**File**: `models/pdf_field_position.py`
Stores configuration for each field position on PDF templates.
#### Fields
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `template_type` | Selection | Yes | Template type: T4, T4 Summary, T4A, T4A Summary |
| `field_name` | Char | Yes | Field identifier (e.g., "EmployeeLastName", "SIN", "Box14") |
| `field_label` | Char | No | Human-readable label for display |
| `x_position` | Float | Yes | X coordinate in points (default: 0.0) |
| `y_position` | Float | Yes | Y coordinate in points (default: 0.0) |
| `font_size` | Integer | Yes | Font size in points (default: 10) |
| `font_name` | Char | Yes | Font family name (default: "Helvetica") |
| `active` | Boolean | Yes | Whether this position is active (default: True) |
| `sequence` | Integer | Yes | Display order (default: 10) |
#### Coordinate System
- **Origin**: Bottom-left corner of the page (0, 0)
- **Units**: Points (1 point = 1/72 inch)
- **Standard Letter Size**: 612 x 792 points (8.5" x 11")
- **X-axis**: Increases to the right
- **Y-axis**: Increases upward
#### Methods
##### `get_coordinates_dict(template_type)`
Returns a dictionary mapping field names to their position data.
**Parameters:**
- `template_type` (str): Template type (e.g., "T4", "T4 Summary")
**Returns:**
- `dict`: Format: `{'field_name': (x, y, font_size, font_name)}`
- Only includes active positions
**Example:**
```python
coordinates = self.env['pdf.field.position'].get_coordinates_dict('T4')
# Returns: {'EmployeeLastName': (100.0, 700.0, 12, 'Helvetica'), ...}
```
---
## Integration with PDF Generation
### T4 Summary (`hr.t4.summary`)
**File**: `models/hr_payroll_t4.py`
#### Method: `_get_pdf_text_coordinates()`
Queries the `pdf.field.position` model for T4 Summary coordinates.
```python
def _get_pdf_text_coordinates(self):
"""Get text coordinates for PDF overlay from configuration."""
return self.env['pdf.field.position'].get_coordinates_dict('T4 Summary')
```
#### Method: `_overlay_text_on_pdf()`
Overlays text onto the PDF template using ReportLab.
**Process:**
1. Loads flattened PDF template
2. Retrieves coordinates from `_get_pdf_text_coordinates()`
3. For each field in the data mapping:
- Gets position data (x, y, font_size, font_name) from coordinates
- Uses ReportLab's `canvas` to draw text at the specified position
4. Returns base64-encoded filled PDF
**Key Code:**
```python
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import letter
from PyPDF2 import PdfReader, PdfWriter
import io
# Create canvas for text overlay
packet = io.BytesIO()
can = canvas.Canvas(packet, pagesize=letter)
# Get coordinates
coordinates = self._get_pdf_text_coordinates()
if not coordinates:
raise UserError('No field positions configured. Please configure positions in: Payroll → Configuration → PDF Field Positions')
# Overlay text
for field_name, value in field_mapping.items():
if field_name in coordinates:
x, y, font_size, font_name = coordinates[field_name]
can.setFont(font_name, font_size)
can.drawString(x, y, str(value))
can.save()
```
### T4 Slip (`hr.t4.slip`)
**File**: `models/hr_payroll_t4.py`
Same pattern as T4 Summary, but queries for template type `'T4'`:
```python
def _get_pdf_text_coordinates(self):
return self.env['pdf.field.position'].get_coordinates_dict('T4')
```
### T4A Summary (`hr.t4a.summary`)
**File**: `models/hr_payroll_t4a.py`
Queries for template type `'T4A Summary'`:
```python
def _get_pdf_text_coordinates(self):
return self.env['pdf.field.position'].get_coordinates_dict('T4A Summary')
```
### T4A Slip (`hr.t4a.slip`)
**File**: `models/hr_payroll_t4a.py`
Queries for template type `'T4A'`:
```python
def _get_pdf_text_coordinates(self):
return self.env['pdf.field.position'].get_coordinates_dict('T4A')
```
---
## User Interface
### Menu Location
**Path**: Payroll → Configuration → PDF Field Positions
**Menu Item ID**: `menu_fusion_pdf_field_positions`
**File**: `views/fusion_payroll_menus.xml`
### Views
#### List View (`pdf.field.position.list`)
**File**: `views/pdf_field_position_views.xml`
Displays all field positions in a table:
- Template Type
- Field Name
- Field Label
- X Position
- Y Position
- Font Size
- Font Name
- Active (toggle widget)
- Sequence (handle widget for drag-and-drop ordering)
**Default Order**: `template_type, sequence, field_name`
#### Form View (`pdf.field.position.form`)
**File**: `views/pdf_field_position_views.xml`
**Sections:**
1. **Header**: Active toggle
2. **Basic Info**:
- Template Type (required)
- Field Name (required)
- Field Label (optional)
- Sequence (handle widget)
3. **Position** (4-column layout):
- X Position
- Y Position
- Font Size
- Font Name
4. **Info Alert**: Coordinate system explanation
#### Search View (`pdf.field.position.search`)
**File**: `views/pdf_field_position_views.xml`
**Searchable Fields:**
- Template Type
- Field Name
**Filters**: None (simplified for Odoo 19 compatibility)
---
## Security
### Access Rights
**File**: `security/ir.model.access.csv`
| ID | Name | Model | Group | Permissions |
|----|------|-------|-------|-------------|
| `pdf_field_position_hr_user` | PDF Field Position (HR User) | `pdf.field.position` | `hr.group_hr_user` | Read, Write, Create, Unlink |
| `pdf_field_position_payroll_user` | PDF Field Position (Payroll User) | `pdf.field.position` | `hr_payroll.group_hr_payroll_user` | Read, Write, Create, Unlink |
---
## Module Manifest
**File**: `__manifest__.py`
The view file is included in the data list:
```python
'data': [
...
'views/pdf_field_position_views.xml',
...
]
```
---
## Usage Workflow
### 1. Configure Field Positions
1. Navigate to **Payroll → Configuration → PDF Field Positions**
2. Click **Create**
3. Fill in:
- **Template Type**: Select T4, T4 Summary, T4A, or T4A Summary
- **Field Name**: Enter the field identifier (must match the field name used in PDF generation code)
- **Field Label**: Optional human-readable label
- **X Position**: X coordinate in points
- **Y Position**: Y coordinate in points
- **Font Size**: Font size in points
- **Font Name**: Font family (e.g., "Helvetica", "Times-Roman", "Courier")
- **Active**: Check to enable this position
- **Sequence**: Display order
4. Click **Save**
### 2. Finding Coordinates
To determine X and Y coordinates:
1. Open the flattened PDF template in a PDF viewer
2. Use a PDF coordinate tool or measure from bottom-left corner
3. Convert inches to points: `points = inches × 72`
4. For example:
- 1 inch from left = 72 points
- 1 inch from bottom = 72 points
- Center of page (4.25" × 5.5") = (306, 396) points
### 3. Testing Positions
1. Configure a few test positions
2. Generate a T4/T4A PDF
3. Check if text appears in the correct location
4. Adjust X/Y positions as needed
5. Refine font size and font name for readability
### 4. Field Name Mapping
The `field_name` must match the keys used in the PDF generation code. Common field names:
**T4/T4 Summary:**
- `EmployeeLastName`
- `EmployeeFirstName`
- `SIN`
- `Box14` (Employment income)
- `Box16` (CPP contributions)
- `Box18` (EI premiums)
- `Box22` (Income tax deducted)
- `Box27` (CPP employer)
- `Box19` (EI employer)
- `PayerName`
- `PayerAddress`
- `TaxYear`
**T4A/T4A Summary:**
- `RecipientLastName`
- `RecipientFirstName`
- `SIN`
- `Box14` (Pension or superannuation)
- `PayerName`
- `PayerAddress`
- `TaxYear`
---
## Technical Details
### Dependencies
- **ReportLab**: For text overlay (`pip install reportlab`)
- **PyPDF2**: For PDF manipulation (`pip install PyPDF2`)
### PDF Overlay Process
1. **Load Template**: Read flattened PDF using PyPDF2
2. **Create Canvas**: Create ReportLab canvas for text overlay
3. **Get Coordinates**: Query `pdf.field.position` model
4. **Draw Text**: For each field, draw text at configured position
5. **Merge**: Overlay text canvas onto PDF pages
6. **Encode**: Convert to base64 for storage
### Error Handling
If no coordinates are found for a template type, the system raises a `UserError`:
```python
raise UserError(
'No field positions configured for this template type. '
'Please configure positions in: Payroll → Configuration → PDF Field Positions'
)
```
---
## Issues Encountered & Resolved
### Issue 1: External ID Not Found
**Error**: `ValueError: External ID not found in the system: fusion_payroll.action_pdf_field_position`
**Cause**: Action definition order issue in XML files
**Resolution**: Ensured action is defined before menu item references it
### Issue 2: Invalid Model Name
**Error**: `ParseError: Invalid model name "pdf.field.position" in action definition`
**Cause**: Model not registered when XML was parsed
**Resolution**:
- Verified model import in `models/__init__.py`
- Ensured proper module loading order
- Removed any syntax errors in model definition
### Issue 3: Invalid View Type
**Error**: `ParseError: Invalid view type: 'tree'`
**Cause**: Odoo 19 deprecated `tree` view type in favor of `list`
**Resolution**:
- Changed `<tree>` to `<list>` in view definition
- Updated view name from `pdf.field.position.tree` to `pdf.field.position.list`
- Updated `view_mode` in action from `tree,form` to `list,form`
### Issue 4: Invalid Search View Definition
**Error**: `ParseError: Invalid view pdf.field.position.search definition`
**Cause**: Odoo 19 compatibility issue with search view structure
**Resolution**: Simplified search view to minimal structure (removed group filters)
---
## Future Enhancements
### Potential Improvements
1. **Visual Position Editor**: Drag-and-drop interface to position fields visually
2. **PDF Preview**: Preview PDF with current positions before saving
3. **Bulk Import**: Import positions from CSV or JSON
4. **Template Presets**: Pre-configured positions for common templates
5. **Font Preview**: Preview font appearance before applying
6. **Coordinate Calculator**: Tool to convert from inches/millimeters to points
7. **Field Validation**: Validate field names against known field mappings
8. **Version Control**: Track changes to positions over time
---
## Related Files
### Models
- `models/pdf_field_position.py` - Model definition
- `models/hr_payroll_t4.py` - T4/T4 Summary PDF generation
- `models/hr_payroll_t4a.py` - T4A/T4A Summary PDF generation
### Views
- `views/pdf_field_position_views.xml` - UI views
- `views/fusion_payroll_menus.xml` - Menu integration
### Security
- `security/ir.model.access.csv` - Access rights
### Manifest
- `__manifest__.py` - Module configuration
---
## Version History
| Date | Changes |
|------|---------|
| 2025-01-XX | Initial implementation |
| 2025-01-XX | Fixed Odoo 19 compatibility issues (tree→list, search view) |
| 2025-01-XX | Removed debug instrumentation |
---
## Notes
- The system uses ReportLab's coordinate system (bottom-left origin)
- Font names must match ReportLab's supported fonts (Helvetica, Times-Roman, Courier, etc.)
- Positions are stored per template type, allowing different layouts for T4 vs T4A
- Active flag allows temporarily disabling positions without deleting them
- Sequence field enables drag-and-drop ordering in the list view

View File

@@ -0,0 +1,113 @@
# Fusion Payroll Documentation
This directory contains comprehensive documentation for the Fusion Payroll module.
## Documentation Index
### 📋 Planning & Design
- **[ENHANCEMENT_PLAN.md](./ENHANCEMENT_PLAN.md)** - Comprehensive enhancement plan for QuickBooks-like features, including ROE, T4/T4A, Payroll Tax Centre, and more.
### 🔧 Implementation Documentation
- **[PDF_FIELD_POSITIONING.md](./PDF_FIELD_POSITIONING.md)** - Complete documentation for the PDF Field Positioning System, including architecture, usage, and troubleshooting.
### 📊 Model Documentation
- **[models/README.md](./models/README.md)** - Guide for documenting model fields and relationships.
### 📝 Extraction Logs
- **[EXTRACTION_LOG.md](./EXTRACTION_LOG.md)** - Log of extracted information from codebase analysis.
---
## Quick Links
### Key Features Documented
1. **PDF Field Positioning System**
- Dynamic configuration interface for T4/T4A PDF text overlay
- User-configurable positions, fonts, and sizes
- See: [PDF_FIELD_POSITIONING.md](./PDF_FIELD_POSITIONING.md)
2. **T4/T4A Forms**
- T4 Summary and T4 Slip generation
- T4A Summary and T4A Slip generation
- PDF field positioning integration
- See: [PDF_FIELD_POSITIONING.md](./PDF_FIELD_POSITIONING.md) and [ENHANCEMENT_PLAN.md](./ENHANCEMENT_PLAN.md)
3. **ROE (Record of Employment)** 📋
- ROE generation workflow
- BLK file export format
- Service Canada codes
- See: [ENHANCEMENT_PLAN.md](./ENHANCEMENT_PLAN.md) - Phase 2B
4. **Payroll Tax Centre** 📋
- Tax remittance tracking
- T4 filing dashboard
- See: [ENHANCEMENT_PLAN.md](./ENHANCEMENT_PLAN.md) - Phase 10
---
## Module Structure
```
fusion_payroll/
├── docs/ # This directory
│ ├── README.md # This file
│ ├── ENHANCEMENT_PLAN.md # Enhancement plan
│ ├── PDF_FIELD_POSITIONING.md # PDF positioning docs
│ ├── EXTRACTION_LOG.md # Extraction logs
│ └── models/ # Model documentation
├── models/ # Python models
│ ├── pdf_field_position.py # PDF positioning model
│ ├── hr_payroll_t4.py # T4/T4 Summary
│ ├── hr_payroll_t4a.py # T4A/T4A Summary
│ └── ...
├── views/ # XML views
│ ├── pdf_field_position_views.xml
│ ├── hr_t4_views.xml
│ ├── hr_t4a_views.xml
│ └── ...
├── security/ # Access rights
│ └── ir.model.access.csv
└── __manifest__.py # Module manifest
```
---
## Getting Started
### For Developers
1. **Understanding the PDF System**: Start with [PDF_FIELD_POSITIONING.md](./PDF_FIELD_POSITIONING.md)
2. **Planning New Features**: Review [ENHANCEMENT_PLAN.md](./ENHANCEMENT_PLAN.md)
3. **Model Documentation**: Check [models/README.md](./models/README.md) for field documentation
### For Users
1. **Configuring PDF Fields**: See [PDF_FIELD_POSITIONING.md](./PDF_FIELD_POSITIONING.md) - Usage Workflow section
2. **Understanding Features**: Review [ENHANCEMENT_PLAN.md](./ENHANCEMENT_PLAN.md) for feature descriptions
---
## Status Legend
-**Implemented** - Feature is complete and working
- 📋 **Planned** - Feature is documented but not yet implemented
- 🔄 **In Progress** - Feature is currently being developed
-**Not Started** - Feature is planned but not yet started
---
## Contributing
When adding new features or documentation:
1. Update the relevant documentation file
2. Add entries to this README if creating new documentation
3. Update version history in relevant files
4. Keep documentation in sync with code changes
---
## Last Updated
January 2025

View File

@@ -0,0 +1,11 @@
# Models Documentation
This folder contains detailed field documentation for each model discovered in the payroll module.
## How to Document
For each model, create a file named `{model_name}.md` with:
- All fields and their properties
- Computed field method names
- Relations to other models
- Constraints mentioned

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
from . import tax_yearly_rates
from . import tax_yearly_rate_line
from . import hr_employee
from . import hr_payslip
from . import hr_roe
from . import hr_tax_remittance
from . import hr_payroll_t4
from . import hr_payroll_t4a
from . import pdf_field_position
from . import payroll_report
from . import payroll_report_paycheque
from . import payroll_report_tax
from . import payroll_report_summary
from . import payroll_report_employee
from . import payroll_report_cost
from . import payroll_config_settings
from . import payroll_work_location
from . import payroll_tax_payment_schedule
from . import payroll_accounting_mapping
from . import pay_period
from . import payroll_entry
from . import payroll_dashboard
from . import payroll_cheque
from . import cheque_layout_settings

View File

@@ -0,0 +1,506 @@
# -*- coding: utf-8 -*-
"""
Cheque Layout Settings
======================
Configurable cheque layout with visual preview.
"""
from odoo import api, fields, models, _
from odoo.exceptions import UserError
import base64
class ChequeLayoutSettings(models.Model):
"""
Cheque Layout Settings with XY positioning for all fields.
Includes image upload for visual preview alignment.
"""
_name = 'cheque.layout.settings'
_description = 'Cheque Layout Settings'
_rec_name = 'name'
name = fields.Char(string='Layout Name', required=True, default='Default Layout')
active = fields.Boolean(default=True)
is_default = fields.Boolean(string='Default Layout', default=False)
company_id = fields.Many2one(
'res.company',
string='Company',
default=lambda self: self.env.company,
required=True,
)
# Background Image for Preview
cheque_image = fields.Binary(
string='Cheque Background Image',
help='Upload your pre-printed cheque stock image for alignment preview',
)
cheque_image_filename = fields.Char(string='Cheque Image Filename')
# =============== PAGE SETTINGS ===============
page_width = fields.Float(string='Page Width (inches)', default=8.5)
page_height = fields.Float(string='Page Height (inches)', default=11.0)
# =============== SECTION HEIGHTS ===============
# All values in inches from top of page
section1_height = fields.Float(
string='Cheque Section Height (in)',
default=3.67,
help='Height of the cheque section (top section)',
)
section2_start = fields.Float(
string='Stub 1 Start Position (in)',
default=3.67,
help='Y position where stub 1 begins',
)
section2_height = fields.Float(
string='Stub 1 Height (in)',
default=3.67,
help='Height of stub 1 (middle section)',
)
section3_start = fields.Float(
string='Stub 2 Start Position (in)',
default=7.34,
help='Y position where stub 2 begins',
)
section3_height = fields.Float(
string='Stub 2 Height (in)',
default=3.66,
help='Height of stub 2 (bottom section)',
)
# =============== CHEQUE SECTION POSITIONS ===============
# Date
date_x = fields.Float(string='Date X (in)', default=6.8)
date_y = fields.Float(string='Date Y (in)', default=0.35)
date_font_size = fields.Integer(string='Date Font Size (pt)', default=11)
# Amount (numeric)
amount_x = fields.Float(string='Amount X (in)', default=6.9)
amount_y = fields.Float(string='Amount Y (in)', default=0.75)
amount_font_size = fields.Integer(string='Amount Font Size (pt)', default=12)
# Amount in Words
amount_words_x = fields.Float(string='Amount Words X (in)', default=0.6)
amount_words_y = fields.Float(string='Amount Words Y (in)', default=1.25)
amount_words_font_size = fields.Integer(string='Amount Words Font Size (pt)', default=9)
# Payee Name
payee_x = fields.Float(string='Payee Name X (in)', default=0.6)
payee_y = fields.Float(string='Payee Name Y (in)', default=1.65)
payee_font_size = fields.Integer(string='Payee Font Size (pt)', default=11)
# Payee Address (separate from name)
payee_address_x = fields.Float(string='Payee Address X (in)', default=0.6)
payee_address_y = fields.Float(string='Payee Address Y (in)', default=1.85)
payee_address_font_size = fields.Integer(string='Payee Address Font Size (pt)', default=9)
# Pay Period (on cheque)
cheque_pay_period_x = fields.Float(string='Pay Period X (in)', default=0.6)
cheque_pay_period_y = fields.Float(string='Pay Period Y (in)', default=2.1)
# Memo
memo_x = fields.Float(string='Memo X (in)', default=0.6)
memo_y = fields.Float(string='Memo Y (in)', default=3.1)
memo_font_size = fields.Integer(string='Memo Font Size (pt)', default=8)
# =============== STUB SECTION OFFSETS ===============
# These are relative to the section start position
stub_padding_top = fields.Float(string='Stub Top Padding (in)', default=0.35)
stub_padding_left = fields.Float(string='Stub Left Padding (in)', default=0.1)
stub_padding_right = fields.Float(string='Stub Right Padding (in)', default=0.1)
stub_padding_bottom = fields.Float(string='Stub Bottom Padding (in)', default=0.1)
stub_full_width = fields.Boolean(string='Use Full Page Width', default=True, help='Stub content uses full page width')
stub_center_data = fields.Boolean(string='Center Data in Stub', default=True, help='Center the stub data horizontally')
stub_content_margin = fields.Float(string='Stub Content Margin (in)', default=0.5, help='Left and right margin for stub content when centering. Increase to push content more toward center.')
# Column widths (as percentages)
col1_width = fields.Integer(string='Column 1 Width (%)', default=22, help='Employee/Company Info')
col2_width = fields.Integer(string='Column 2 Width (%)', default=26, help='PAY/Benefits')
col3_width = fields.Integer(string='Column 3 Width (%)', default=26, help='Taxes/Deductions')
col4_width = fields.Integer(string='Column 4 Width (%)', default=26, help='Summary')
# Font sizes for stubs
stub_title_font_size = fields.Integer(string='Stub Title Font Size', default=9)
stub_content_font_size = fields.Integer(string='Stub Content Font Size', default=7)
stub_header_font_size = fields.Integer(string='Stub Header Font Size', default=6)
# =============== METHODS ===============
@api.model
def get_default_settings(self):
"""Get or create the default layout settings for the current company."""
settings = self.search([
('company_id', '=', self.env.company.id),
('is_default', '=', True),
], limit=1)
if not settings:
settings = self.search([
('company_id', '=', self.env.company.id),
], limit=1)
if not settings:
settings = self.create({
'name': 'Default Cheque Layout',
'company_id': self.env.company.id,
'is_default': True,
})
return settings
def action_set_as_default(self):
"""Set this layout as the default for the company."""
# Unset other defaults
self.search([
('company_id', '=', self.company_id.id),
('is_default', '=', True),
('id', '!=', self.id),
]).write({'is_default': False})
self.is_default = True
def get_layout_data(self):
"""Return all layout settings as a dictionary for the report template."""
self.ensure_one()
return {
'page': {
'width': self.page_width,
'height': self.page_height,
},
'sections': {
'cheque': {
'height': self.section1_height,
},
'stub1': {
'start': self.section2_start,
'height': self.section2_height,
},
'stub2': {
'start': self.section3_start,
'height': self.section3_height,
},
},
'cheque': {
'date': {'x': self.date_x, 'y': self.date_y, 'font_size': self.date_font_size},
'amount': {'x': self.amount_x, 'y': self.amount_y, 'font_size': self.amount_font_size},
'amount_words': {'x': self.amount_words_x, 'y': self.amount_words_y, 'font_size': self.amount_words_font_size},
'payee': {'x': self.payee_x, 'y': self.payee_y, 'font_size': self.payee_font_size},
'pay_period': {'x': self.cheque_pay_period_x, 'y': self.cheque_pay_period_y},
'memo': {'x': self.memo_x, 'y': self.memo_y, 'font_size': self.memo_font_size},
},
'stub': {
'padding': {
'top': self.stub_padding_top,
'left': self.stub_padding_left,
'right': self.stub_padding_right,
'bottom': self.stub_padding_bottom,
},
'columns': {
'col1': self.col1_width,
'col2': self.col2_width,
'col3': self.col3_width,
'col4': self.col4_width,
},
'fonts': {
'title': self.stub_title_font_size,
'content': self.stub_content_font_size,
'header': self.stub_header_font_size,
},
},
}
def action_open_preview(self):
"""Open the visual preview configuration wizard."""
self.ensure_one()
# Ensure the record is saved to database (required for related fields to work)
# Check if it's a real database ID (integer) vs a NewId
if not isinstance(self.id, int):
# Record not yet saved - need to save first
raise UserError(_('Please save the record first before opening the preview.'))
return {
'type': 'ir.actions.act_window',
'name': _('Cheque Layout Preview'),
'res_model': 'cheque.layout.preview.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_settings_id': self.id,
},
}
class ChequeLayoutPreviewWizard(models.TransientModel):
"""
Wizard for visual preview of cheque layout.
Shows the cheque image with overlaid field positions.
"""
_name = 'cheque.layout.preview.wizard'
_description = 'Cheque Layout Preview'
settings_id = fields.Many2one('cheque.layout.settings', string='Layout Settings', required=True)
# Local copies of fields for editing (will be saved back to settings on close)
cheque_image = fields.Binary(string='Cheque Background Image')
cheque_image_filename = fields.Char(string='Cheque Image Filename')
# Section positions
section1_height = fields.Float(string='Cheque End (in)', default=3.67)
section2_start = fields.Float(string='Stub 1 Start (in)', default=3.67)
section3_start = fields.Float(string='Stub 2 Start (in)', default=7.34)
# Cheque field positions
date_x = fields.Float(string='Date X', default=6.8)
date_y = fields.Float(string='Date Y', default=0.35)
amount_x = fields.Float(string='Amount X', default=6.9)
amount_y = fields.Float(string='Amount Y', default=0.75)
amount_words_x = fields.Float(string='Amount Words X', default=0.6)
amount_words_y = fields.Float(string='Amount Words Y', default=1.25)
payee_x = fields.Float(string='Payee X', default=0.6)
payee_y = fields.Float(string='Payee Y', default=1.65)
payee_address_x = fields.Float(string='Payee Address X', default=0.6)
payee_address_y = fields.Float(string='Payee Address Y', default=1.85)
cheque_pay_period_x = fields.Float(string='Pay Period X', default=0.6)
cheque_pay_period_y = fields.Float(string='Pay Period Y', default=2.1)
memo_x = fields.Float(string='Memo X', default=0.6)
memo_y = fields.Float(string='Memo Y', default=3.1)
# Stub positions
stub_padding_top = fields.Float(string='Stub Top Padding', default=0.35)
stub_padding_left = fields.Float(string='Stub Left Padding', default=0.1)
stub_content_margin = fields.Float(string='Stub Content Margin (in)', default=0.5)
stub_center_data = fields.Boolean(string='Center Data in Stub', default=True)
# Preview HTML (computed)
preview_html = fields.Html(string='Preview', compute='_compute_preview_html', sanitize=False)
@api.model
def default_get(self, fields_list):
"""Load values from settings_id when wizard is opened."""
res = super().default_get(fields_list)
settings_id = self.env.context.get('default_settings_id')
if settings_id:
settings = self.env['cheque.layout.settings'].browse(settings_id)
if settings.exists():
res.update({
'cheque_image': settings.cheque_image,
'cheque_image_filename': settings.cheque_image_filename,
'section1_height': settings.section1_height,
'section2_start': settings.section2_start,
'section3_start': settings.section3_start,
'date_x': settings.date_x,
'date_y': settings.date_y,
'amount_x': settings.amount_x,
'amount_y': settings.amount_y,
'amount_words_x': settings.amount_words_x,
'amount_words_y': settings.amount_words_y,
'payee_x': settings.payee_x,
'payee_y': settings.payee_y,
'payee_address_x': settings.payee_address_x,
'payee_address_y': settings.payee_address_y,
'cheque_pay_period_x': settings.cheque_pay_period_x,
'cheque_pay_period_y': settings.cheque_pay_period_y,
'memo_x': settings.memo_x,
'memo_y': settings.memo_y,
'stub_padding_top': settings.stub_padding_top,
'stub_padding_left': settings.stub_padding_left,
'stub_content_margin': settings.stub_content_margin,
'stub_center_data': settings.stub_center_data,
})
return res
@api.depends(
'cheque_image', 'section1_height', 'section2_start', 'section3_start',
'date_x', 'date_y', 'amount_x', 'amount_y', 'amount_words_x', 'amount_words_y',
'payee_x', 'payee_y', 'payee_address_x', 'payee_address_y',
'cheque_pay_period_x', 'cheque_pay_period_y',
'memo_x', 'memo_y', 'stub_padding_top', 'stub_padding_left'
)
def _compute_preview_html(self):
"""Generate HTML preview with overlaid positions."""
for wizard in self:
# Scale factor: 1 inch = 72 pixels for preview
scale = 72
page_width = 8.5 * scale
page_height = 11 * scale
# Build background image style
bg_style = ''
if wizard.cheque_image:
image_data = wizard.cheque_image.decode('utf-8') if isinstance(wizard.cheque_image, bytes) else wizard.cheque_image
bg_style = f'background-image: url(data:image/png;base64,{image_data}); background-size: 100% 100%;'
# Helper to convert inches to pixels
def px(inches):
return (inches or 0) * scale
# Section divider lines
section_lines = f'''
<div style="position: absolute; top: {px(wizard.section1_height)}px; left: 0; right: 0;
border-bottom: 2px dashed red; z-index: 10;">
<span style="background: red; color: white; font-size: 10px; padding: 2px 4px;">
Cheque End ({wizard.section1_height:.2f} in)
</span>
</div>
<div style="position: absolute; top: {px(wizard.section3_start)}px; left: 0; right: 0;
border-bottom: 2px dashed red; z-index: 10;">
<span style="background: red; color: white; font-size: 10px; padding: 2px 4px;">
Stub 2 Start ({wizard.section3_start:.2f} in)
</span>
</div>
'''
# Field markers (cheque section)
field_markers = f'''
<!-- Date -->
<div style="position: absolute; left: {px(wizard.date_x)}px; top: {px(wizard.date_y)}px;
background: rgba(0,128,255,0.9); color: white; padding: 2px 6px; font-size: 10px;
border: 1px solid blue; z-index: 20; white-space: nowrap;">
DATE: JAN 11, 2026
</div>
<!-- Amount -->
<div style="position: absolute; left: {px(wizard.amount_x)}px; top: {px(wizard.amount_y)}px;
background: rgba(0,180,0,0.9); color: white; padding: 2px 6px; font-size: 10px;
border: 1px solid green; z-index: 20; white-space: nowrap;">
**1,465.19
</div>
<!-- Amount in Words -->
<div style="position: absolute; left: {px(wizard.amount_words_x)}px; top: {px(wizard.amount_words_y)}px;
background: rgba(255,128,0,0.9); color: white; padding: 2px 6px; font-size: 9px;
border: 1px solid orange; z-index: 20; white-space: nowrap;">
AMOUNT IN WORDS
</div>
<!-- Payee Name -->
<div style="position: absolute; left: {px(wizard.payee_x)}px; top: {px(wizard.payee_y)}px;
background: rgba(128,0,255,0.9); color: white; padding: 2px 6px; font-size: 10px;
border: 1px solid purple; z-index: 20; white-space: nowrap;">
PAYEE NAME
</div>
<!-- Payee Address -->
<div style="position: absolute; left: {px(wizard.payee_address_x)}px; top: {px(wizard.payee_address_y)}px;
background: rgba(180,0,180,0.9); color: white; padding: 2px 6px; font-size: 9px;
border: 1px solid #b400b4; z-index: 20; white-space: nowrap;">
PAYEE ADDRESS
</div>
<!-- Pay Period -->
<div style="position: absolute; left: {px(wizard.cheque_pay_period_x)}px; top: {px(wizard.cheque_pay_period_y)}px;
background: rgba(255,0,128,0.9); color: white; padding: 2px 6px; font-size: 9px;
border: 1px solid #ff0080; z-index: 20; white-space: nowrap;">
PAY PERIOD
</div>
<!-- Memo -->
<div style="position: absolute; left: {px(wizard.memo_x)}px; top: {px(wizard.memo_y)}px;
background: rgba(128,128,128,0.9); color: white; padding: 2px 6px; font-size: 9px;
border: 1px solid gray; z-index: 20; white-space: nowrap;">
MEMO
</div>
'''
# Stub markers - full width centered
stub1_top = (wizard.section2_start or 0) + (wizard.stub_padding_top or 0)
stub2_top = (wizard.section3_start or 0) + (wizard.stub_padding_top or 0)
stub_markers = f'''
<!-- Stub 1 Content Area (Full Width, Centered) -->
<div style="position: absolute; left: {px(wizard.stub_padding_left)}px; top: {px(stub1_top)}px;
right: {px(wizard.stub_padding_left)}px; height: 200px;
border: 2px dashed #00aa00; z-index: 15; display: flex; align-items: flex-start; justify-content: center;">
<span style="background: #00aa00; color: white; font-size: 10px; padding: 2px 6px;">
STUB 1 - FULL WIDTH CENTERED
</span>
</div>
<!-- Stub 2 Content Area (Full Width, Centered) -->
<div style="position: absolute; left: {px(wizard.stub_padding_left)}px; top: {px(stub2_top)}px;
right: {px(wizard.stub_padding_left)}px; height: 200px;
border: 2px dashed #0000aa; z-index: 15; display: flex; align-items: flex-start; justify-content: center;">
<span style="background: #0000aa; color: white; font-size: 10px; padding: 2px 6px;">
STUB 2 - FULL WIDTH CENTERED
</span>
</div>
'''
wizard.preview_html = f'''
<div style="position: relative; width: {page_width}px; height: {page_height}px;
border: 1px solid #ccc; {bg_style} overflow: hidden; margin: 0 auto; background-color: #f8f8f8;">
{section_lines}
{field_markers}
{stub_markers}
</div>
'''
def action_save_and_close(self):
"""Save changes back to settings and close the wizard."""
self.ensure_one()
if self.settings_id:
self.settings_id.write({
'cheque_image': self.cheque_image,
'cheque_image_filename': self.cheque_image_filename,
'section1_height': self.section1_height,
'section2_start': self.section2_start,
'section3_start': self.section3_start,
'date_x': self.date_x,
'date_y': self.date_y,
'amount_x': self.amount_x,
'amount_y': self.amount_y,
'amount_words_x': self.amount_words_x,
'amount_words_y': self.amount_words_y,
'payee_x': self.payee_x,
'payee_y': self.payee_y,
'payee_address_x': self.payee_address_x,
'payee_address_y': self.payee_address_y,
'cheque_pay_period_x': self.cheque_pay_period_x,
'cheque_pay_period_y': self.cheque_pay_period_y,
'memo_x': self.memo_x,
'memo_y': self.memo_y,
'stub_padding_top': self.stub_padding_top,
'stub_padding_left': self.stub_padding_left,
'stub_content_margin': self.stub_content_margin,
'stub_center_data': self.stub_center_data,
})
return {'type': 'ir.actions.act_window_close'}
def action_print_test(self):
"""Save current wizard values and print a test cheque."""
self.ensure_one()
# First, save current wizard values to the settings record
if self.settings_id:
self.settings_id.write({
'cheque_image': self.cheque_image,
'cheque_image_filename': self.cheque_image_filename,
'section1_height': self.section1_height,
'section2_start': self.section2_start,
'section3_start': self.section3_start,
'date_x': self.date_x,
'date_y': self.date_y,
'amount_x': self.amount_x,
'amount_y': self.amount_y,
'amount_words_x': self.amount_words_x,
'amount_words_y': self.amount_words_y,
'payee_x': self.payee_x,
'payee_y': self.payee_y,
'payee_address_x': self.payee_address_x,
'payee_address_y': self.payee_address_y,
'cheque_pay_period_x': self.cheque_pay_period_x,
'cheque_pay_period_y': self.cheque_pay_period_y,
'memo_x': self.memo_x,
'memo_y': self.memo_y,
'stub_padding_top': self.stub_padding_top,
'stub_padding_left': self.stub_padding_left,
'stub_content_margin': self.stub_content_margin,
'stub_center_data': self.stub_center_data,
})
# Find a sample cheque to print
cheque = self.env['payroll.cheque'].search([], limit=1)
if cheque:
return self.env.ref('fusion_payroll.action_report_payroll_cheque').report_action(cheque)
else:
raise UserError(_('No cheque records found to print test.'))

View File

@@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class HrContract(models.Model):
_inherit = 'hr.contract'
# === Work Location ===
work_location_id = fields.Many2one(
'payroll.work.location',
string='Primary Work Location',
help='Primary work location for this contract (used for tax calculations)',
domain="[('company_id', '=', company_id), ('status', '=', 'active')]",
)
# === Canadian Tax Credits ===
fed_tax_credit = fields.Float(
string='Federal Tax Credit',
help='Federal personal tax credit amount for the employee',
)
provincial_tax_credit = fields.Float(
string='Provincial Tax Credit',
help='Provincial personal tax credit amount for the employee',
)

View File

@@ -0,0 +1,468 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
from odoo.exceptions import ValidationError
class HrEmployee(models.Model):
_inherit = 'hr.employee'
# === Canadian Provinces/Territories ===
PROVINCES = [
('AB', 'Alberta'),
('BC', 'British Columbia'),
('MB', 'Manitoba'),
('NB', 'New Brunswick'),
('NL', 'Newfoundland and Labrador'),
('NS', 'Nova Scotia'),
('NT', 'Northwest Territories'),
('NU', 'Nunavut'),
('ON', 'Ontario'),
('PE', 'Prince Edward Island'),
('QC', 'Quebec'),
('SK', 'Saskatchewan'),
('YT', 'Yukon'),
]
# === Work Locations ===
work_location_ids = fields.Many2many(
'payroll.work.location',
'payroll_work_location_employee_rel',
'employee_id',
'location_id',
string='Work Locations',
help='Work locations where this employee works',
)
# === ROE (Record of Employment) Reason Codes ===
# These are official Service Canada codes for EI claims
ROE_REASON_CODES = [
# A - Shortage of Work
('A00', 'A00 - Shortage of work/End of contract or season'),
('A01', 'A01 - Employer bankruptcy or receivership'),
# B - Strike/Lockout
('B00', 'B00 - Strike or lockout'),
# D - Illness
('D00', 'D00 - Illness or injury'),
# E - Quit
('E00', 'E00 - Quit'),
('E02', 'E02 - Quit/Follow spouse'),
('E03', 'E03 - Quit/Return to school'),
('E04', 'E04 - Quit/Health Reasons'),
('E05', 'E05 - Quit/Voluntary retirement'),
('E06', 'E06 - Quit/Take another job'),
('E09', 'E09 - Quit/Employer relocation'),
('E10', 'E10 - Quit/Care for a dependent'),
('E11', 'E11 - Quit/To become self-employed'),
# F - Maternity
('F00', 'F00 - Maternity'),
# G - Retirement
('G00', 'G00 - Mandatory retirement'),
('G07', 'G07 - Retirement/Approved workforce reduction'),
# H - Work-Sharing
('H00', 'H00 - Work-Sharing'),
# J - Apprentice
('J00', 'J00 - Apprentice training'),
# K - Other
('K00', 'K00 - Other'),
('K12', 'K12 - Other/Change of payroll frequency'),
('K13', 'K13 - Other/Change of ownership'),
('K14', 'K14 - Other/Requested by Employment Insurance'),
('K15', 'K15 - Other/Canadian Forces - Queen\'s Regulations/Orders'),
('K16', 'K16 - Other/At the employee\'s request'),
('K17', 'K17 - Other/Change of Service Provider'),
# M - Dismissal
('M00', 'M00 - Dismissal'),
('M08', 'M08 - Dismissal/Terminated within probationary period'),
# N - Leave
('N00', 'N00 - Leave of absence'),
# P - Parental
('P00', 'P00 - Parental'),
# Z - Compassionate Care
('Z00', 'Z00 - Compassionate Care/Family Caregiver'),
]
EMPLOYEE_STATUS = [
('active', 'Active'),
('on_leave', 'On Leave'),
('terminated', 'Terminated'),
]
PAY_SCHEDULE = [
('weekly', 'Weekly'),
('biweekly', 'Bi-Weekly'),
('semi_monthly', 'Semi-Monthly'),
('monthly', 'Monthly'),
]
# === Employment Status Fields ===
employment_status = fields.Selection(
selection=EMPLOYEE_STATUS,
string='Employment Status',
default='active',
tracking=True,
)
hire_date = fields.Date(
string='Hire Date',
tracking=True,
)
last_day_of_work = fields.Date(
string='Last Day of Work',
help='Required when employee status is Terminated',
tracking=True,
)
roe_reason_code = fields.Selection(
selection=ROE_REASON_CODES,
string='Reason for Status Change',
help='Record of Employment (ROE) reason code for Service Canada',
tracking=True,
)
show_in_employee_lists_only = fields.Boolean(
string='Show in Employee Lists Only',
help='If checked, terminated employee will still appear in employee lists',
)
# === Pay Schedule ===
pay_schedule = fields.Selection(
selection=PAY_SCHEDULE,
string='Pay Schedule',
default='biweekly',
)
# === Employee Identification ===
employee_number = fields.Char(
string='Employee ID',
copy=False,
)
sin_number = fields.Char(
string='Social Insurance Number',
groups='hr.group_hr_user,account.group_account_manager',
copy=False,
help='9-digit Social Insurance Number (SIN)',
)
sin_number_display = fields.Char(
string='SIN (Masked)',
compute='_compute_sin_display',
help='Masked SIN showing only last 3 digits',
)
# === Base Pay ===
pay_type = fields.Selection([
('hourly', 'Hourly'),
('salary', 'Salary'),
('commission', 'Commission only'),
], string='Pay Type', default='hourly')
hourly_rate = fields.Monetary(
string='Rate per Hour',
currency_field='currency_id',
)
salary_amount = fields.Monetary(
string='Salary Amount',
currency_field='currency_id',
help='Monthly or annual salary amount',
)
default_hours_per_day = fields.Float(
string='Hours per Day',
default=8.0,
)
default_days_per_week = fields.Float(
string='Days per Week',
default=5.0,
)
default_hours_per_week = fields.Float(
string='Default Hours/Week',
compute='_compute_default_hours_week',
store=True,
)
base_pay_effective_date = fields.Date(
string='Base Pay Effective Date',
help='Date when current base pay became effective',
)
currency_id = fields.Many2one(
'res.currency',
string='Currency',
default=lambda self: self.env.company.currency_id,
)
# === Vacation Policy ===
vacation_policy = fields.Selection([
('percent_4', '4.00% Paid out each pay period'),
('percent_6', '6.00% Paid out each pay period'),
('hours_accrued', '0.04 hours/hour worked'),
('annual_80', '80 hours/year (accrued each pay period)'),
('no_track', "Don't track vacation"),
], string='Vacation Policy', default='percent_4')
vacation_rate = fields.Float(
string='Vacation Rate %',
default=4.0,
help='Percentage of vacationable earnings',
)
# === Personal Address ===
home_street = fields.Char(
string='Street Address',
)
home_street2 = fields.Char(
string='Address Line 2',
)
home_city = fields.Char(
string='City',
)
home_province = fields.Selection(
selection=PROVINCES,
string='Province',
default='ON',
)
home_postal_code = fields.Char(
string='Postal Code',
help='Format: A1A 1A1',
)
home_country = fields.Char(
string='Country',
default='Canada',
)
mailing_same_as_home = fields.Boolean(
string='Mailing Address Same as Home',
default=True,
)
# === Mailing Address (if different) ===
mailing_street = fields.Char(string='Mailing Street')
mailing_street2 = fields.Char(string='Mailing Address Line 2')
mailing_city = fields.Char(string='Mailing City')
mailing_province = fields.Selection(selection=PROVINCES, string='Mailing Province')
mailing_postal_code = fields.Char(string='Mailing Postal Code')
# === Personal Info ===
preferred_name = fields.Char(
string='Preferred First Name',
help='Name the employee prefers to be called',
)
gender_identity = fields.Selection([
('male', 'Male'),
('female', 'Female'),
('other', 'Other'),
('prefer_not', 'Prefer not to say'),
], string='Gender')
# === Communication Preference (for ROE) ===
communication_language = fields.Selection([
('E', 'English'),
('F', 'French'),
], string='Preferred Language', default='E',
help='Communication preference for government forms')
# === Billing/Rate ===
billing_rate = fields.Float(
string='Billing Rate (per hour)',
help='Hourly billing rate for this employee',
)
billable_by_default = fields.Boolean(
string='Billable by Default',
)
# === Emergency Contact ===
emergency_first_name = fields.Char(string='Emergency Contact First Name')
emergency_last_name = fields.Char(string='Emergency Contact Last Name')
emergency_relationship = fields.Char(string='Relationship')
emergency_phone = fields.Char(string='Emergency Phone')
emergency_email = fields.Char(string='Emergency Email')
# === Payment Method ===
payment_method = fields.Selection([
('cheque', 'Cheque'),
('direct_deposit', 'Direct Deposit'),
], string='Payment Method', default='direct_deposit',
help='How employee receives their pay')
# === Tax Withholdings ===
federal_td1_amount = fields.Float(
string='Federal TD1 Amount',
default=16129.00, # 2025 Basic Personal Amount
help='Federal personal tax credit claim amount from TD1 form',
)
federal_additional_tax = fields.Float(
string='Additional Federal Tax',
help='Additional income tax to be deducted from each pay',
)
provincial_claim_amount = fields.Float(
string='Provincial Claim Amount',
default=12399.00, # 2025 Ontario BPA
help='Provincial personal tax credit claim amount from TD1 form',
)
# === Tax Exemptions ===
exempt_cpp = fields.Boolean(
string='Exempt from CPP',
help='Check if employee is exempt from Canada Pension Plan contributions',
)
exempt_ei = fields.Boolean(
string='Exempt from EI',
help='Check if employee is exempt from Employment Insurance premiums',
)
exempt_federal_tax = fields.Boolean(
string='Exempt from Federal Tax',
help='Check if employee is exempt from Federal income tax',
)
# === T4 Dental Benefits Code (CRA requirement 2023+) ===
t4_dental_code = fields.Selection([
('1', 'Code 1 - No dental benefits'),
('2', 'Code 2 - Payee only'),
('3', 'Code 3 - Payee, spouse, and dependents'),
('4', 'Code 4 - Payee and spouse'),
('5', 'Code 5 - Payee and dependents'),
], string='T4 Dental Benefits Code',
help='Required for T4 reporting - indicates access to employer dental benefits',
)
# === ROE Tracking Fields ===
roe_issued = fields.Boolean(
string='ROE Issued',
help='Check when Record of Employment has been issued',
)
roe_issued_date = fields.Date(
string='ROE Issue Date',
)
roe_serial_number = fields.Char(
string='ROE Serial Number',
)
@api.depends('sin_number')
def _compute_sin_display(self):
"""Show only last 3 digits of SIN for non-privileged users"""
for employee in self:
if employee.sin_number:
sin = employee.sin_number.replace('-', '').replace(' ', '')
if len(sin) >= 3:
employee.sin_number_display = f"XXX-XXX-{sin[-3:]}"
else:
employee.sin_number_display = "XXX-XXX-XXX"
else:
employee.sin_number_display = ""
@api.depends('default_hours_per_day', 'default_days_per_week')
def _compute_default_hours_week(self):
for employee in self:
employee.default_hours_per_week = employee.default_hours_per_day * employee.default_days_per_week
@api.depends('home_street', 'home_city', 'home_province', 'home_postal_code')
def _compute_full_address(self):
for employee in self:
parts = []
if employee.home_street:
parts.append(employee.home_street)
if employee.home_street2:
parts.append(employee.home_street2)
city_prov = []
if employee.home_city:
city_prov.append(employee.home_city)
if employee.home_province:
city_prov.append(employee.home_province)
if city_prov:
parts.append(', '.join(city_prov))
if employee.home_postal_code:
parts.append(employee.home_postal_code)
employee.full_home_address = '\n'.join(parts) if parts else ''
full_home_address = fields.Text(
string='Full Address',
compute='_compute_full_address',
store=False,
)
@api.constrains('sin_number')
def _check_sin_number(self):
"""Validate SIN format (9 digits)"""
for employee in self:
if employee.sin_number:
# Remove any formatting (dashes, spaces)
sin = employee.sin_number.replace('-', '').replace(' ', '')
if not sin.isdigit() or len(sin) != 9:
raise ValidationError(
'Social Insurance Number must be exactly 9 digits.'
)
@api.constrains('home_postal_code')
def _check_postal_code(self):
"""Validate Canadian postal code format"""
import re
for employee in self:
if employee.home_postal_code:
# Canadian postal code: A1A 1A1 or A1A1A1
pattern = r'^[A-Za-z]\d[A-Za-z][ -]?\d[A-Za-z]\d$'
if not re.match(pattern, employee.home_postal_code):
raise ValidationError(
'Invalid postal code format. Use format: A1A 1A1'
)
@api.constrains('employment_status', 'last_day_of_work', 'roe_reason_code')
def _check_termination_fields(self):
for employee in self:
if employee.employment_status == 'terminated':
if not employee.last_day_of_work:
raise ValidationError(
'Last Day of Work is required when terminating an employee.'
)
if not employee.roe_reason_code:
raise ValidationError(
'Reason for Status Change (ROE Code) is required when terminating an employee.'
)
@api.onchange('employment_status')
def _onchange_employment_status(self):
"""Clear termination fields when status changes back to active"""
if self.employment_status == 'active':
self.last_day_of_work = False
self.roe_reason_code = False
self.show_in_employee_lists_only = False
elif self.employment_status == 'terminated':
# Set archive flag based on preference
if not self.show_in_employee_lists_only:
self.active = False
def action_terminate_employee(self):
"""Open wizard to terminate employee with proper ROE handling"""
return {
'name': 'Terminate Employee',
'type': 'ir.actions.act_window',
'res_model': 'hr.employee.terminate.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_employee_id': self.id,
'default_last_day_of_work': fields.Date.today(),
},
}
def action_issue_roe(self):
"""Mark ROE as issued and record date"""
self.ensure_one()
self.write({
'roe_issued': True,
'roe_issued_date': fields.Date.today(),
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'ROE Issued',
'message': f'Record of Employment marked as issued for {self.name}',
'type': 'success',
}
}
def action_view_sin(self):
"""Open wizard to view/edit SIN - restricted to admin/accountant"""
self.ensure_one()
return {
'name': 'View/Edit Social Insurance Number',
'type': 'ir.actions.act_window',
'res_model': 'hr.employee.sin.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_employee_id': self.id,
'default_sin_number': self.sin_number or '',
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,704 @@
# -*- 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',
)

View File

@@ -0,0 +1,380 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class HrPayslip(models.Model):
_inherit = 'hr.payslip'
# === Additional Fields for QuickBooks-style Paycheque Entry ===
cheque_id = fields.Many2one(
'payroll.cheque',
string='Cheque',
copy=False,
help='Linked cheque record for paper cheque payments',
)
cheque_number = fields.Char(
string='Cheque Number',
related='cheque_id.cheque_number',
store=True,
copy=False,
help='Cheque number for paper cheque payments',
)
cheque_state = fields.Selection(
related='cheque_id.state',
string='Cheque Status',
store=True,
)
memo = fields.Text(
string='Memo',
help='Internal notes for this paycheque',
)
paid_by = fields.Selection([
('cheque', 'Paper Cheque'),
('direct_deposit', 'Direct Deposit'),
], string='Payment Method', compute='_compute_paid_by', store=True, readonly=False)
@api.depends('employee_id', 'employee_id.payment_method')
def _compute_paid_by(self):
"""Set payment method from employee's default payment method."""
for payslip in self:
if payslip.employee_id and hasattr(payslip.employee_id, 'payment_method'):
payslip.paid_by = payslip.employee_id.payment_method or 'direct_deposit'
else:
payslip.paid_by = 'direct_deposit'
def action_print_cheque(self):
"""Print cheque for this payslip - always opens wizard to set/change cheque number."""
self.ensure_one()
if self.paid_by != 'cheque':
raise UserError(_("This payslip is not set to be paid by cheque."))
# Create cheque if not exists
if not self.cheque_id:
cheque = self.env['payroll.cheque'].create_from_payslip(self)
if cheque:
self.cheque_id = cheque.id
else:
raise UserError(_("Failed to create cheque. Check employee payment method."))
# Always open the cheque number wizard to allow changing the 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.cheque_id.id,
},
}
def action_create_cheque(self):
"""Create a cheque for this payslip without printing."""
self.ensure_one()
if self.paid_by != 'cheque':
raise UserError(_("This payslip is not set to be paid by cheque."))
if self.cheque_id:
raise UserError(_("A cheque already exists for this payslip."))
cheque = self.env['payroll.cheque'].create_from_payslip(self)
if cheque:
self.cheque_id = cheque.id
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Cheque Created'),
'message': _('Cheque created for %s.') % self.employee_id.name,
'type': 'success',
'sticky': False,
}
}
else:
raise UserError(_("Failed to create cheque."))
# === YTD Computed Fields ===
ytd_gross = fields.Monetary(
string='YTD Gross',
compute='_compute_ytd_amounts',
currency_field='currency_id',
help='Year-to-date gross earnings',
)
ytd_cpp = fields.Monetary(
string='YTD CPP',
compute='_compute_ytd_amounts',
currency_field='currency_id',
help='Year-to-date CPP contributions (employee)',
)
ytd_cpp2 = fields.Monetary(
string='YTD CPP2',
compute='_compute_ytd_amounts',
currency_field='currency_id',
help='Year-to-date CPP2 contributions (employee)',
)
ytd_ei = fields.Monetary(
string='YTD EI',
compute='_compute_ytd_amounts',
currency_field='currency_id',
help='Year-to-date EI contributions (employee)',
)
ytd_income_tax = fields.Monetary(
string='YTD Income Tax',
compute='_compute_ytd_amounts',
currency_field='currency_id',
help='Year-to-date income tax withheld',
)
ytd_net = fields.Monetary(
string='YTD Net',
compute='_compute_ytd_amounts',
currency_field='currency_id',
help='Year-to-date net pay',
)
# === Employer Tax Totals (for display) ===
employer_cpp = fields.Monetary(
string='Employer CPP',
compute='_compute_employer_contributions',
currency_field='currency_id',
)
employer_cpp2 = fields.Monetary(
string='Employer CPP2',
compute='_compute_employer_contributions',
currency_field='currency_id',
)
employer_ei = fields.Monetary(
string='Employer EI',
compute='_compute_employer_contributions',
currency_field='currency_id',
)
total_employer_cost = fields.Monetary(
string='Total Employer Cost',
compute='_compute_employer_contributions',
currency_field='currency_id',
help='Total employer contributions (CPP + CPP2 + EI)',
)
# === Employee Tax Totals (for summary) ===
employee_cpp = fields.Monetary(
string='Employee CPP',
compute='_compute_employee_deductions',
currency_field='currency_id',
)
employee_cpp2 = fields.Monetary(
string='Employee CPP2',
compute='_compute_employee_deductions',
currency_field='currency_id',
)
employee_ei = fields.Monetary(
string='Employee EI',
compute='_compute_employee_deductions',
currency_field='currency_id',
)
employee_income_tax = fields.Monetary(
string='Income Tax',
compute='_compute_employee_deductions',
currency_field='currency_id',
)
total_employee_deductions = fields.Monetary(
string='Total Employee Deductions',
compute='_compute_employee_deductions',
currency_field='currency_id',
)
@api.depends('employee_id', 'date_from', 'line_ids', 'line_ids.total')
def _compute_ytd_amounts(self):
"""Calculate year-to-date amounts for each payslip"""
for payslip in self:
if not payslip.employee_id or not payslip.date_from:
payslip.ytd_gross = 0
payslip.ytd_cpp = 0
payslip.ytd_cpp2 = 0
payslip.ytd_ei = 0
payslip.ytd_income_tax = 0
payslip.ytd_net = 0
continue
# Get the start of the year
year_start = payslip.date_from.replace(month=1, day=1)
# Find all payslips for this employee in the same year, up to and including this one
domain = [
('employee_id', '=', payslip.employee_id.id),
('date_from', '>=', year_start),
('date_to', '<=', payslip.date_to),
('state', 'in', ['done', 'paid']),
]
# Include current payslip if it's in draft/verify state
if payslip.state in ['draft', 'verify']:
domain = ['|', ('id', '=', payslip.id)] + domain
ytd_payslips = self.search(domain)
# Calculate YTD totals
ytd_gross = 0
ytd_cpp = 0
ytd_cpp2 = 0
ytd_ei = 0
ytd_income_tax = 0
ytd_net = 0
for slip in ytd_payslips:
ytd_gross += slip.gross_wage or 0
ytd_net += slip.net_wage or 0
# Sum up specific rule amounts
for line in slip.line_ids:
code = line.code or ''
if code == 'CPP':
ytd_cpp += abs(line.total or 0)
elif code == 'CPP2':
ytd_cpp2 += abs(line.total or 0)
elif code == 'EI':
ytd_ei += abs(line.total or 0)
elif code in ['FED_TAX', 'PROV_TAX', 'INCOME_TAX']:
ytd_income_tax += abs(line.total or 0)
payslip.ytd_gross = ytd_gross
payslip.ytd_cpp = ytd_cpp
payslip.ytd_cpp2 = ytd_cpp2
payslip.ytd_ei = ytd_ei
payslip.ytd_income_tax = ytd_income_tax
payslip.ytd_net = ytd_net
@api.depends('line_ids', 'line_ids.total', 'line_ids.code')
def _compute_employer_contributions(self):
"""Calculate employer contribution totals from payslip lines"""
for payslip in self:
employer_cpp = 0
employer_cpp2 = 0
employer_ei = 0
for line in payslip.line_ids:
code = line.code or ''
if code == 'CPP_ER':
employer_cpp = abs(line.total or 0)
elif code == 'CPP2_ER':
employer_cpp2 = abs(line.total or 0)
elif code == 'EI_ER':
employer_ei = abs(line.total or 0)
payslip.employer_cpp = employer_cpp
payslip.employer_cpp2 = employer_cpp2
payslip.employer_ei = employer_ei
payslip.total_employer_cost = employer_cpp + employer_cpp2 + employer_ei
@api.depends('line_ids', 'line_ids.total', 'line_ids.code')
def _compute_employee_deductions(self):
"""Calculate employee deduction totals from payslip lines"""
for payslip in self:
employee_cpp = 0
employee_cpp2 = 0
employee_ei = 0
employee_income_tax = 0
for line in payslip.line_ids:
code = line.code or ''
if code == 'CPP':
employee_cpp = abs(line.total or 0)
elif code == 'CPP2':
employee_cpp2 = abs(line.total or 0)
elif code == 'EI':
employee_ei = abs(line.total or 0)
elif code in ['FED_TAX', 'PROV_TAX', 'INCOME_TAX']:
employee_income_tax += abs(line.total or 0)
payslip.employee_cpp = employee_cpp
payslip.employee_cpp2 = employee_cpp2
payslip.employee_ei = employee_ei
payslip.employee_income_tax = employee_income_tax
payslip.total_employee_deductions = (
employee_cpp + employee_cpp2 + employee_ei + employee_income_tax
)
# =========================================================================
# PAY TYPE IDENTIFICATION HELPERS (for T4 and ROE reporting)
# =========================================================================
@api.model
def _get_pay_type_from_code(self, code, category_code=None):
"""
Map salary rule code to pay type for ROE/T4 reporting.
Returns one of: 'salary', 'hourly', 'overtime', 'bonus', 'stat_holiday',
'commission', 'allowance', 'reimbursement', 'union_dues', 'other'
:param code: Salary rule code (e.g., 'OT_PAY', 'BONUS_PAY')
:param category_code: Category code (e.g., 'BASIC', 'ALW', 'DED')
:return: Pay type string
"""
if not code:
return 'other'
code_upper = code.upper()
# Direct code matches
if code_upper == 'OT_PAY':
return 'overtime'
elif code_upper == 'BONUS_PAY' or code_upper == 'BONUS':
return 'bonus'
elif code_upper == 'STAT_PAY' or code_upper == 'STAT_HOLIDAY':
return 'stat_holiday'
# Pattern matching for commissions
if 'COMMISSION' in code_upper or 'COMM' in code_upper:
return 'commission'
# Pattern matching for union dues
if 'UNION' in code_upper or 'DUES' in code_upper:
return 'union_dues'
# Pattern matching for reimbursements
if 'REIMBURSEMENT' in code_upper or 'REIMB' in code_upper:
return 'reimbursement'
# Pattern matching for allowances
if 'ALLOWANCE' in code_upper or 'ALW' in code_upper:
# Check if it's a reimbursement first
if 'REIMBURSEMENT' in code_upper or 'REIMB' in code_upper:
return 'reimbursement'
return 'allowance'
# Category-based identification
if category_code:
category_upper = category_code.upper()
if category_upper == 'BASIC':
return 'salary' # Could be salary or hourly, default to salary
elif category_upper == 'ALW':
# Already checked for allowance patterns above
return 'allowance'
elif category_upper == 'DED':
# Deductions - check if union dues
if 'UNION' in code_upper or 'DUES' in code_upper:
return 'union_dues'
return 'other'
return 'other'
@api.model
def _is_reimbursement(self, code, category_code=None):
"""
Check if salary rule code represents a reimbursement (non-taxable).
:param code: Salary rule code
:param category_code: Category code
:return: True if reimbursement, False otherwise
"""
if not code:
return False
code_upper = code.upper()
# Direct pattern matching
if 'REIMBURSEMENT' in code_upper or 'REIMB' in code_upper:
return True
return False

View File

@@ -0,0 +1,528 @@
# -*- 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: <strong>{filename}</strong><br/>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 nbr="{pp.sequence}">
<AMT>{pp.amount:.2f}</AMT>
</PP>''')
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'''<?xml version="1.0" encoding="UTF-8"?>
<ROEHEADER FileVersion="W-2.0" ProductName="Fusion Payroll"
SoftwareVendor="Nexa Systems Inc.">
<ROE Issue="D" PrintingLanguage="{self.communication_language}">
<B5>{self.cra_business_number or ''}</B5>
<B6>{self.pay_period_type}</B6>
<B8>{sin}</B8>
<B9>
<FN>{first_name}</FN>
<LN>{last_name}</LN>
<A1>{emp.home_street or ''}</A1>
<A2>{emp.home_city or ''}</A2>
<A3>{emp.home_province or 'ON'}, CA</A3>
<PC>{(emp.home_postal_code or '').replace(' ', '')}</PC>
</B9>
<B10>{self.first_day_worked.strftime('%Y-%m-%d') if self.first_day_worked else ''}</B10>
<B11>{self.last_day_paid.strftime('%Y-%m-%d') if self.last_day_paid else ''}</B11>
<B12>{self.final_pay_period_end.strftime('%Y-%m-%d') if self.final_pay_period_end else ''}</B12>
<B14>
<CD>{'R' if self.expected_recall_date else 'U'}</CD>
</B14>
<B15A>{self.total_insurable_hours:.0f}</B15A>
<B15C>
{pp_xml}
</B15C>
<B16>
<CD>{self.reason_code}</CD>
<FN>{contact_first}</FN>
<LN>{contact_last}</LN>
<AC>{area_code}</AC>
<TEL>{phone_number}</TEL>
</B16>
<B20>{self.communication_language}</B20>
</ROE>
</ROEHEADER>'''
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',
)

View File

@@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class HrSalaryRuleCategory(models.Model):
_inherit = 'hr.salary.rule.category'
# === Canadian Pension Plan (CPP) Configuration ===
cpp_deduction_id = fields.Many2one(
'tax.yearly.rates',
string='Canadian Configuration Values',
domain="[('ded_type', '=', 'cpp')]",
help='Link to CPP yearly rates configuration',
)
cpp_date = fields.Date(
string='CPP Date',
related='cpp_deduction_id.cpp_date',
readonly=True,
)
max_cpp = fields.Float(
string='Maximum CPP',
related='cpp_deduction_id.max_cpp',
readonly=True,
)
emp_contribution_rate = fields.Float(
string='Employee Contribution Rate',
related='cpp_deduction_id.emp_contribution_rate',
readonly=True,
)
employer_contribution_rate = fields.Float(
string='Employer Contribution Rate',
related='cpp_deduction_id.employer_contribution_rate',
readonly=True,
)
exemption = fields.Float(
string='Exemption Amount',
related='cpp_deduction_id.exemption',
readonly=True,
)
# === Employment Insurance (EI) Configuration ===
ei_deduction_id = fields.Many2one(
'tax.yearly.rates',
string='Employment Insurance',
domain="[('ded_type', '=', 'ei')]",
help='Link to EI yearly rates configuration',
)
ei_date = fields.Date(
string='EI Date',
related='ei_deduction_id.ei_date',
readonly=True,
)
ei_rate = fields.Float(
string='EI Rate',
related='ei_deduction_id.ei_rate',
readonly=True,
)
ei_earnings = fields.Float(
string='Maximum EI Earnings',
related='ei_deduction_id.ei_earnings',
readonly=True,
)
emp_ei_amount = fields.Float(
string='Employee EI Amount',
related='ei_deduction_id.emp_ei_amount',
readonly=True,
)
employer_ei_amount = fields.Float(
string='Employer EI Amount',
related='ei_deduction_id.employer_ei_amount',
readonly=True,
)
# === Federal Tax Configuration ===
fed_tax_id = fields.Many2one(
'tax.yearly.rates',
string='Federal Tax',
domain="[('tax_type', '=', 'federal')]",
help='Link to Federal tax yearly rates configuration',
)
# === Provincial Tax Configuration ===
provincial_tax_id = fields.Many2one(
'tax.yearly.rates',
string='Provincial Tax',
domain="[('tax_type', '=', 'provincial')]",
help='Link to Provincial tax yearly rates configuration',
)

View File

@@ -0,0 +1,306 @@
# -*- coding: utf-8 -*-
from datetime import date, timedelta
from dateutil.relativedelta import relativedelta
from odoo import models, fields, api
from odoo.exceptions import UserError
class HrTaxRemittance(models.Model):
_name = 'hr.tax.remittance'
_description = 'Payroll Tax Remittance'
_order = 'period_start desc'
_inherit = ['mail.thread', 'mail.activity.mixin']
STATE_SELECTION = [
('draft', 'Draft'),
('pending', 'Pending'),
('due', 'Due Soon'),
('past_due', 'Past Due'),
('paid', 'Paid'),
]
PERIOD_TYPE = [
('monthly', 'Monthly'),
('quarterly', 'Quarterly'),
]
name = fields.Char(
string='Reference',
required=True,
copy=False,
default=lambda self: self.env['ir.sequence'].next_by_code('hr.tax.remittance') or 'New',
)
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',
string='Currency',
)
state = fields.Selection(
selection=STATE_SELECTION,
string='Status',
default='draft',
tracking=True,
compute='_compute_state',
store=True,
)
# === Period Information ===
period_type = fields.Selection(
selection=PERIOD_TYPE,
string='Period Type',
default='monthly',
)
period_start = fields.Date(
string='Period Start',
required=True,
)
period_end = fields.Date(
string='Period End',
required=True,
)
due_date = fields.Date(
string='Due Date',
required=True,
help='CRA remittance due date (15th of following month for regular remitters)',
)
# === CPP Amounts ===
cpp_employee = fields.Monetary(
string='CPP Employee',
currency_field='currency_id',
help='Total employee CPP contributions for period',
)
cpp_employer = fields.Monetary(
string='CPP Employer',
currency_field='currency_id',
help='Total employer CPP contributions for period',
)
cpp2_employee = fields.Monetary(
string='CPP2 Employee',
currency_field='currency_id',
help='Total employee CPP2 (second CPP) contributions for period',
)
cpp2_employer = fields.Monetary(
string='CPP2 Employer',
currency_field='currency_id',
help='Total employer CPP2 contributions for period',
)
# === EI Amounts ===
ei_employee = fields.Monetary(
string='EI Employee',
currency_field='currency_id',
help='Total employee EI premiums for period',
)
ei_employer = fields.Monetary(
string='EI Employer',
currency_field='currency_id',
help='Total employer EI premiums (1.4x employee) for period',
)
# === Tax Amounts ===
income_tax = fields.Monetary(
string='Income Tax',
currency_field='currency_id',
help='Total federal + provincial income tax withheld for period',
)
# === Totals ===
total = fields.Monetary(
string='Total Remittance',
currency_field='currency_id',
compute='_compute_total',
store=True,
)
# === Payment Information ===
payment_date = fields.Date(
string='Payment Date',
tracking=True,
)
payment_reference = fields.Char(
string='Payment Reference',
help='Bank reference or confirmation number',
)
payment_method = fields.Selection([
('cra_my_business', 'CRA My Business Account'),
('bank_payment', 'Bank Payment'),
('cheque', 'Cheque'),
], string='Payment Method')
# === Linked Payslips ===
payslip_ids = fields.Many2many(
'hr.payslip',
string='Related Payslips',
help='Payslips included in this remittance period',
)
payslip_count = fields.Integer(
string='Payslip Count',
compute='_compute_payslip_count',
)
@api.depends('cpp_employee', 'cpp_employer', 'cpp2_employee', 'cpp2_employer',
'ei_employee', 'ei_employer', 'income_tax')
def _compute_total(self):
for rec in self:
rec.total = (
rec.cpp_employee + rec.cpp_employer +
rec.cpp2_employee + rec.cpp2_employer +
rec.ei_employee + rec.ei_employer +
rec.income_tax
)
@api.depends('due_date', 'payment_date')
def _compute_state(self):
today = date.today()
for rec in self:
if rec.payment_date:
rec.state = 'paid'
elif not rec.due_date:
rec.state = 'draft'
elif rec.due_date < today:
rec.state = 'past_due'
elif rec.due_date <= today + timedelta(days=7):
rec.state = 'due'
else:
rec.state = 'pending'
def _compute_payslip_count(self):
for rec in self:
rec.payslip_count = len(rec.payslip_ids)
def action_calculate_amounts(self):
"""Calculate remittance amounts from payslips in the period"""
self.ensure_one()
# Find all confirmed payslips in the period
payslips = self.env['hr.payslip'].search([
('company_id', '=', self.company_id.id),
('state', 'in', ['validated', 'paid']),
('date_from', '>=', self.period_start),
('date_to', '<=', self.period_end),
])
if not payslips:
raise UserError('No confirmed payslips found for this period.')
# Sum up amounts by rule code
cpp_ee = cpp_er = cpp2_ee = cpp2_er = 0
ei_ee = ei_er = 0
income_tax = 0
for payslip in payslips:
for line in payslip.line_ids:
code = line.code
amount = abs(line.total)
if code == 'CPP_EE':
cpp_ee += amount
elif code == 'CPP_ER':
cpp_er += amount
elif code == 'CPP2_EE':
cpp2_ee += amount
elif code == 'CPP2_ER':
cpp2_er += amount
elif code == 'EI_EE':
ei_ee += amount
elif code == 'EI_ER':
ei_er += amount
elif code in ('FED_TAX', 'PROV_TAX'):
income_tax += amount
self.write({
'cpp_employee': cpp_ee,
'cpp_employer': cpp_er,
'cpp2_employee': cpp2_ee,
'cpp2_employer': cpp2_er,
'ei_employee': ei_ee,
'ei_employer': ei_er,
'income_tax': income_tax,
'payslip_ids': [(6, 0, payslips.ids)],
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Amounts Calculated',
'message': f'Calculated from {len(payslips)} payslips. Total: ${self.total:,.2f}',
'type': 'success',
}
}
def action_mark_paid(self):
"""Mark remittance as paid"""
self.ensure_one()
if not self.payment_date:
self.payment_date = date.today()
self.state = 'paid'
def action_view_payslips(self):
"""View related payslips"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Payslips',
'res_model': 'hr.payslip',
'view_mode': 'list,form',
'domain': [('id', 'in', self.payslip_ids.ids)],
}
@api.model
def _get_payment_frequency_from_settings(self, company_id=None):
"""Get payment frequency from payroll settings."""
if not company_id:
company_id = self.env.company.id
settings = self.env['payroll.config.settings'].get_settings(company_id)
return settings.federal_tax_payment_frequency or 'monthly'
def create_monthly_remittance(self, year, month, company_id=None):
"""Create a monthly remittance record"""
if not company_id:
company_id = self.env.company.id
# Get settings for payment frequency and CRA info
settings = self.env['payroll.config.settings'].get_settings(company_id)
payment_freq = settings.federal_tax_payment_frequency or 'monthly'
# Calculate period dates
period_start = date(year, month, 1)
if month == 12:
period_end = date(year + 1, 1, 1) - timedelta(days=1)
due_date = date(year + 1, 1, 15)
else:
period_end = date(year, month + 1, 1) - timedelta(days=1)
due_date = date(year, month + 1, 15)
# Create the remittance
remittance = self.create({
'company_id': company_id,
'period_type': payment_freq if payment_freq in ['monthly', 'quarterly'] else 'monthly',
'period_start': period_start,
'period_end': period_end,
'due_date': due_date,
})
# Calculate amounts
remittance.action_calculate_amounts()
return remittance
class HrTaxRemittanceSequence(models.Model):
"""Create sequence for tax remittance"""
_name = 'hr.tax.remittance.sequence'
_description = 'Tax Remittance Sequence Setup'
_auto = False
def init(self):
# This will be handled by ir.sequence data instead
pass

View File

@@ -0,0 +1,387 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError, ValidationError
from datetime import date, timedelta
from dateutil.relativedelta import relativedelta
class PayPeriod(models.Model):
"""
Pay Period Management
Stores configured pay periods with auto-generation of future periods.
"""
_name = 'payroll.pay.period'
_description = 'Pay Period'
_order = 'date_start asc' # Chronological: oldest first (scroll UP for past, scroll DOWN for future)
_rec_name = 'name'
display_order = fields.Integer(
string='Display Order',
compute='_compute_display_order',
store=True,
help='For reference only - actual ordering uses date_start desc',
)
company_id = fields.Many2one(
'res.company',
string='Company',
required=True,
default=lambda self: self.env.company,
ondelete='cascade',
)
date_start = fields.Date(
string='Period Start',
required=True,
)
date_end = fields.Date(
string='Period End',
required=True,
)
pay_date = fields.Date(
string='Pay Date',
help='Date when employees receive payment',
)
schedule_type = fields.Selection([
('weekly', 'Weekly'),
('biweekly', 'Bi-Weekly'),
('semi_monthly', 'Semi-Monthly'),
('monthly', 'Monthly'),
], string='Schedule Type', required=True, default='biweekly')
state = fields.Selection([
('draft', 'Open'),
('in_progress', 'In Progress'),
('paid', 'Paid'),
('closed', 'Closed'),
], string='Status', default='draft', tracking=True)
payslip_run_id = fields.Many2one(
'hr.payslip.run',
string='Payslip Batch',
ondelete='set null',
)
name = fields.Char(
string='Period Name',
compute='_compute_name',
store=True,
)
is_current = fields.Boolean(
string='Current Period',
compute='_compute_is_current',
)
@api.depends('date_start', 'date_end')
def _compute_name(self):
for period in self:
if period.date_start and period.date_end:
period.name = f"{period.date_start.strftime('%m.%d.%Y')} to {period.date_end.strftime('%m.%d.%Y')}"
else:
period.name = _('New Period')
def _compute_is_current(self):
today = fields.Date.context_today(self)
for period in self:
period.is_current = period.date_start <= today <= period.date_end
@api.depends('date_start', 'date_end')
def _compute_display_order(self):
"""
Compute display order for proper dropdown sorting:
- Current period: 0
- Future periods: 1-999 (by days from today)
- Past periods: 1000+ (by days from today, reversed)
"""
today = fields.Date.context_today(self)
for period in self:
if not period.date_start or not period.date_end:
period.display_order = 9999
elif period.date_start <= today <= period.date_end:
# Current period - top priority
period.display_order = 0
elif period.date_start > today:
# Future period - closer dates have lower order
days_ahead = (period.date_start - today).days
period.display_order = min(days_ahead, 999)
else:
# Past period - more recent dates have lower order
days_ago = (today - period.date_end).days
period.display_order = 1000 + days_ago
@api.constrains('date_start', 'date_end')
def _check_dates(self):
for period in self:
if period.date_end < period.date_start:
raise ValidationError(_('Period end date must be after start date.'))
# Check for overlaps within same company and schedule type
overlapping = self.search([
('id', '!=', period.id),
('company_id', '=', period.company_id.id),
('schedule_type', '=', period.schedule_type),
('date_start', '<=', period.date_end),
('date_end', '>=', period.date_start),
])
if overlapping:
raise ValidationError(_('Pay periods cannot overlap. Found overlap with: %s') % overlapping[0].name)
@api.model
def generate_periods(self, company_id, schedule_type, start_date, num_periods=12, pay_day_offset=7):
"""
Generate pay periods based on schedule type.
Args:
company_id: Company ID
schedule_type: weekly, biweekly, semi_monthly, monthly
start_date: First period start date
num_periods: Number of periods to generate
pay_day_offset: Days after period end to set pay date
"""
periods = []
current_start = start_date
for _ in range(num_periods):
if schedule_type == 'weekly':
current_end = current_start + timedelta(days=6)
next_start = current_start + timedelta(days=7)
elif schedule_type == 'biweekly':
current_end = current_start + timedelta(days=13)
next_start = current_start + timedelta(days=14)
elif schedule_type == 'semi_monthly':
if current_start.day <= 15:
current_end = current_start.replace(day=15)
next_start = current_start.replace(day=16)
else:
# End of month
next_month = current_start + relativedelta(months=1, day=1)
current_end = next_month - timedelta(days=1)
next_start = next_month
elif schedule_type == 'monthly':
next_month = current_start + relativedelta(months=1, day=1)
current_end = next_month - timedelta(days=1)
next_start = next_month
else:
raise ValidationError(_('Unknown schedule type: %s') % schedule_type)
pay_date = current_end + timedelta(days=pay_day_offset)
# Check if period already exists
existing = self.search([
('company_id', '=', company_id),
('schedule_type', '=', schedule_type),
('date_start', '=', current_start),
], limit=1)
if not existing:
period = self.create({
'company_id': company_id,
'schedule_type': schedule_type,
'date_start': current_start,
'date_end': current_end,
'pay_date': pay_date,
})
periods.append(period)
current_start = next_start
return periods
@api.model
def get_current_period(self, company_id, schedule_type):
"""Get the current active pay period."""
today = fields.Date.context_today(self)
period = self.search([
('company_id', '=', company_id),
('schedule_type', '=', schedule_type),
('date_start', '<=', today),
('date_end', '>=', today),
], limit=1)
return period
@api.model
def get_available_periods(self, company_id, schedule_type, include_past_months=6, include_future_months=6):
"""
Get list of available pay periods for selection.
Ordered: Current period first, then future periods, then past periods.
"""
today = fields.Date.context_today(self)
# Calculate date range
past_date = today - relativedelta(months=include_past_months)
future_date = today + relativedelta(months=include_future_months)
# Get all periods within range
all_periods = self.search([
('company_id', '=', company_id),
('schedule_type', '=', schedule_type),
('date_start', '>=', past_date),
('date_end', '<=', future_date),
])
# Separate into current, future, and past
current = all_periods.filtered(lambda p: p.date_start <= today <= p.date_end)
future = all_periods.filtered(lambda p: p.date_start > today).sorted('date_start')
past = all_periods.filtered(lambda p: p.date_end < today).sorted('date_start', reverse=True)
# Combine: current first, then future (ascending), then past (descending)
return current + future + past
@api.model
def auto_generate_periods_if_needed(self, company_id, schedule_type):
"""
Automatically generate pay periods for past 6 months and future 6 months if not exist.
"""
settings = self.env['payroll.pay.period.settings'].get_or_create_settings(company_id)
today = fields.Date.context_today(self)
# Calculate how far back and forward we need
past_date = today - relativedelta(months=6)
future_date = today + relativedelta(months=6)
# Find the first period start date aligned to schedule
first_start = settings.first_period_start
# If first_period_start is in the future, work backwards
if first_start > past_date:
# Calculate periods going backwards
periods_to_generate = []
current_start = first_start
# Go backwards to cover past 6 months
while current_start > past_date:
if schedule_type == 'weekly':
current_start = current_start - timedelta(days=7)
elif schedule_type == 'biweekly':
current_start = current_start - timedelta(days=14)
elif schedule_type == 'semi_monthly':
if current_start.day <= 15:
# Go to previous month's 16th
prev_month = current_start - relativedelta(months=1)
current_start = prev_month.replace(day=16)
else:
current_start = current_start.replace(day=1)
elif schedule_type == 'monthly':
current_start = current_start - relativedelta(months=1)
first_start = current_start
# Now generate from that start date forward
periods_needed = 0
temp_start = first_start
while temp_start <= future_date:
periods_needed += 1
if schedule_type == 'weekly':
temp_start = temp_start + timedelta(days=7)
elif schedule_type == 'biweekly':
temp_start = temp_start + timedelta(days=14)
elif schedule_type == 'semi_monthly':
if temp_start.day <= 15:
temp_start = temp_start.replace(day=16)
else:
temp_start = temp_start + relativedelta(months=1, day=1)
elif schedule_type == 'monthly':
temp_start = temp_start + relativedelta(months=1)
# Generate the periods
self.generate_periods(
company_id=company_id,
schedule_type=schedule_type,
start_date=first_start,
num_periods=periods_needed,
pay_day_offset=settings.pay_day_offset,
)
class PayrollPayPeriodSettings(models.Model):
"""
Pay Period Settings per Company
Stores the payroll schedule configuration.
"""
_name = 'payroll.pay.period.settings'
_description = 'Pay Period Settings'
company_id = fields.Many2one(
'res.company',
string='Company',
required=True,
default=lambda self: self.env.company,
ondelete='cascade',
)
schedule_type = fields.Selection([
('weekly', 'Weekly'),
('biweekly', 'Bi-Weekly'),
('semi_monthly', 'Semi-Monthly'),
('monthly', 'Monthly'),
], string='Pay Schedule', required=True, default='biweekly')
first_period_start = fields.Date(
string='First Period Start',
required=True,
help='Start date of the first pay period',
)
pay_day_offset = fields.Integer(
string='Days Until Pay Date',
default=7,
help='Number of days after period end until pay date',
)
auto_generate_periods = fields.Boolean(
string='Auto-Generate Periods',
default=True,
help='Automatically generate future pay periods',
)
periods_to_generate = fields.Integer(
string='Periods to Generate',
default=12,
help='Number of future periods to keep generated',
)
_sql_constraints = [
('unique_company', 'unique(company_id)', 'Only one pay period settings record per company.'),
]
@api.model
def get_or_create_settings(self, company_id=None):
"""Get or create settings for a company."""
if not company_id:
company_id = self.env.company.id
settings = self.search([('company_id', '=', company_id)], limit=1)
if not settings:
# Create with sensible defaults
today = fields.Date.context_today(self)
# Default to start of current bi-weekly period (Monday)
first_start = today - timedelta(days=today.weekday())
settings = self.create({
'company_id': company_id,
'schedule_type': 'biweekly',
'first_period_start': first_start,
})
return settings
def action_generate_periods(self):
"""Generate pay periods based on settings."""
self.ensure_one()
periods = self.env['payroll.pay.period'].generate_periods(
company_id=self.company_id.id,
schedule_type=self.schedule_type,
start_date=self.first_period_start,
num_periods=self.periods_to_generate,
pay_day_offset=self.pay_day_offset,
)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Pay Periods Generated'),
'message': _('%d pay periods have been generated.') % len(periods),
'type': 'success',
},
}

View File

@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
class PayrollAccountingMapping(models.Model):
"""
Payroll Accounting Mapping
Maps payroll items to chart of accounts.
Only available if account module is installed.
"""
_name = 'payroll.accounting.mapping'
_description = 'Payroll Accounting Mapping'
_rec_name = 'payroll_item'
config_id = fields.Many2one(
'payroll.config.settings',
string='Payroll Settings',
required=True,
ondelete='cascade',
)
company_id = fields.Many2one(
related='config_id.company_id',
string='Company',
store=True,
)
payroll_item = fields.Selection([
('bank_account', 'Bank Account'),
('wage_expense', 'Wage Expenses'),
('employer_tax_expense', 'Employer Tax Expenses'),
('federal_tax_liability', 'Federal Tax Liability'),
('ontario_tax_liability', 'Ontario Tax Liability'),
('vacation_pay_liability', 'Vacation Pay Liability'),
], string='Payroll Item', required=True)
account_id = fields.Many2one(
'account.account',
string='Chart of Accounts',
required=True,
domain="[('company_id', '=', company_id)]",
)
_sql_constraints = [
('unique_payroll_item_per_config',
'unique(config_id, payroll_item)',
'Each payroll item can only be mapped once per configuration.'),
]

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

View File

@@ -0,0 +1,546 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from datetime import date
class PayrollConfigSettings(models.Model):
"""
Payroll Configuration Settings
One record per company storing all payroll-related settings.
"""
_name = 'payroll.config.settings'
_description = 'Payroll Configuration Settings'
_rec_name = 'company_id'
company_id = fields.Many2one(
'res.company',
string='Company',
required=True,
default=lambda self: self.env.company,
ondelete='cascade',
)
@api.model
def default_get(self, fields_list):
"""Ensure we get or create settings for the current company."""
res = super().default_get(fields_list)
company_id = self.env.context.get('default_company_id') or self.env.company.id
if 'company_id' in fields_list:
res['company_id'] = company_id
return res
@api.model_create_multi
def create(self, vals_list):
"""Ensure only one settings record per company."""
records = self.browse()
for vals in vals_list:
company_id = vals.get('company_id') or self.env.company.id
# Check if settings already exist for this company
existing = self.search([('company_id', '=', company_id)], limit=1)
if existing:
# Update existing instead of creating new
existing.write(vals)
records |= existing
else:
# Create new record
new_record = super(PayrollConfigSettings, self).create([vals])
records |= new_record
return records
currency_id = fields.Many2one(
related='company_id.currency_id',
string='Currency',
)
# =========================================================================
# GENERAL TAX INFO
# =========================================================================
company_legal_name = fields.Char(
string='Company Legal Name',
help='Legal name of the company (may differ from trade/DBA name)',
)
company_legal_street = fields.Char(
string='Street Address',
help='Legal address street',
)
company_legal_street2 = fields.Char(
string='Street Address 2',
)
company_legal_city = fields.Char(
string='City',
)
company_legal_country_id = fields.Many2one(
'res.country',
string='Country',
default=lambda self: self.env.ref('base.ca', raise_if_not_found=False),
)
company_legal_state_id = fields.Many2one(
'res.country.state',
string='Province',
domain="[('country_id', '=?', company_legal_country_id)]",
)
@api.onchange('company_legal_country_id')
def _onchange_company_legal_country_id(self):
"""Clear state when country changes."""
if self.company_legal_country_id and self.company_legal_state_id:
if self.company_legal_state_id.country_id != self.company_legal_country_id:
self.company_legal_state_id = False
company_legal_zip = fields.Char(
string='Postal Code',
)
# =========================================================================
# CONTACT INFORMATION
# =========================================================================
payroll_contact_first_name = fields.Char(
string='First Name',
help='First name of the primary payroll contact',
)
payroll_contact_last_name = fields.Char(
string='Last Name',
help='Last name of the primary payroll contact',
)
payroll_contact_phone = fields.Char(
string='Business Phone',
help='Business phone number for payroll contact',
)
payroll_contact_email = fields.Char(
string='Email Address',
help='Email address for payroll contact (required for ROE and T4 forms)',
)
# =========================================================================
# FEDERAL TAX INFO
# =========================================================================
cra_business_number = fields.Char(
string='CRA Business Number',
help='Canada Revenue Agency Business Number',
)
cra_reference_number = fields.Char(
string='Reference Number',
help='CRA Reference Number (RP prefix)',
default='0001',
)
cra_owner1_sin = fields.Char(
string='SIN: Owner 1',
help='Social Insurance Number for Owner 1',
)
cra_owner2_sin = fields.Char(
string='SIN: Owner 2',
help='Social Insurance Number for Owner 2 (optional)',
)
cra_representative_rac = fields.Char(
string='Representative Identifier (RAC)',
help='CRA Representative Authorization Code (optional)',
)
federal_tax_payment_frequency = fields.Selection([
('monthly', 'Monthly'),
('quarterly', 'Quarterly'),
('annually', 'Annually'),
], string='Payment Frequency', default='monthly')
federal_tax_effective_date = fields.Date(
string='Effective Date',
help='Date when this payment frequency became effective',
)
federal_tax_form_type = fields.Char(
string='Form Type',
default='Form PD7A',
help='CRA form type (e.g., Form PD7A)',
)
federal_tax_display = fields.Char(
string='Current Schedule',
compute='_compute_federal_tax_display',
help='Display format: "Form PD7A, paying monthly since 01/01/2019"',
)
# =========================================================================
# PROVINCIAL TAX INFO (One2many to payment schedules)
# =========================================================================
provincial_tax_schedule_ids = fields.One2many(
'payroll.tax.payment.schedule',
'config_id',
string='Provincial Tax Payment Schedules',
help='Date-effective payment schedules by province',
)
# =========================================================================
# EMAIL NOTIFICATIONS
# =========================================================================
notification_email_primary_ids = fields.Many2many(
'res.partner',
'payroll_config_notification_primary_rel',
'config_id',
'partner_id',
string='Primary Notification Recipients',
help='Contacts who will receive primary payroll notifications',
)
notification_send_to_options = [
('you', 'Send to you'),
('accountants', 'Send to accountant(s)'),
('both', 'Send to you and accountant(s)'),
]
notification_setup_send_to = fields.Selection(
notification_send_to_options,
string='Setup Notifications',
default='both',
)
notification_form_filing_send_to = fields.Selection(
notification_send_to_options,
string='Form Filing Notifications',
default='both',
)
notification_payday_send_to = fields.Selection(
notification_send_to_options,
string='Payday Notifications',
default='both',
)
notification_tax_send_to = fields.Selection(
notification_send_to_options,
string='Tax Notifications',
default='both',
)
notification_payday_reminders_send_to = fields.Selection(
notification_send_to_options,
string='Payday Reminders',
default='both',
)
notification_tax_setup_reminders_send_to = fields.Selection(
notification_send_to_options,
string='Tax Setup Reminders',
default='both',
)
# =========================================================================
# PRINTING PREFERENCES
# =========================================================================
print_preference = fields.Selection([
('pay_stubs_only', 'Pay stubs only'),
('paycheques_and_stubs', 'Paycheques and pay stubs on QuickBooks-compatible cheque paper'),
], string='Print Preference', default='paycheques_and_stubs')
paystub_layout = fields.Selection([
('one_pay_stub', 'Paycheque and 1 pay stub'),
('two_pay_stubs', 'Paycheque and 2 pay stubs'),
], string='Paystub Layout',
help='Number of pay stubs per paycheque',
default='two_pay_stubs',
)
show_accrued_vacation_hours = fields.Boolean(
string='Show Accrued Vacation Hours on Pay Stub',
default=True,
)
show_accrued_vacation_balance = fields.Boolean(
string='Show Accrued Vacation Balance on Pay Stub',
default=False,
)
# =========================================================================
# DIRECT DEPOSIT
# =========================================================================
direct_deposit_funding_time = fields.Selection([
('1-day', '1-day'),
('2-day', '2-day'),
('3-day', '3-day'),
], string='Funding Time', default='2-day',
help='Time for direct deposit funds to be available',
)
direct_deposit_funding_limit = fields.Monetary(
string='Funding Limit',
currency_field='currency_id',
default=0.0,
help='Maximum amount per payroll for direct deposit (0 = no limit)',
)
direct_deposit_funding_period_days = fields.Integer(
string='Funding Period (Days)',
default=6,
help='Period in days for funding limit calculation',
)
# =========================================================================
# BANK ACCOUNTS
# =========================================================================
payroll_bank_account_id = fields.Many2one(
'account.journal',
string='Payroll Bank Account',
domain="[('type', 'in', ('bank', 'cash')), ('company_id', '=', company_id)]",
help='Bank or cash journal used for payroll payments',
ondelete='set null',
)
@api.onchange('company_id')
def _onchange_company_id(self):
"""Update journal and account domains when company changes."""
if self.company_id:
return {
'domain': {
'payroll_bank_account_id': [('type', 'in', ('bank', 'cash')), ('company_id', '=', self.company_id.id)],
'account_bank_account_id': [('account_type', '=', 'asset_cash'), ('company_ids', 'parent_of', self.company_id.id)],
'account_wage_expense_id': [('account_type', 'in', ('expense', 'liability_current')), ('company_ids', 'parent_of', self.company_id.id)],
'account_employer_tax_expense_id': [('account_type', 'in', ('expense', 'liability_current')), ('company_ids', 'parent_of', self.company_id.id)],
'account_federal_tax_liability_id': [('account_type', 'in', ('expense', 'liability_current')), ('company_ids', 'parent_of', self.company_id.id)],
'account_ontario_tax_liability_id': [('account_type', 'in', ('expense', 'liability_current')), ('company_ids', 'parent_of', self.company_id.id)],
'account_vacation_pay_liability_id': [('account_type', 'in', ('expense', 'liability_current')), ('company_ids', 'parent_of', self.company_id.id)],
}
}
else:
return {
'domain': {
'payroll_bank_account_id': [('type', 'in', ('bank', 'cash'))],
'account_bank_account_id': [('account_type', '=', 'asset_cash')],
'account_wage_expense_id': [('account_type', 'in', ('expense', 'liability_current'))],
'account_employer_tax_expense_id': [('account_type', 'in', ('expense', 'liability_current'))],
'account_federal_tax_liability_id': [('account_type', 'in', ('expense', 'liability_current'))],
'account_ontario_tax_liability_id': [('account_type', 'in', ('expense', 'liability_current'))],
'account_vacation_pay_liability_id': [('account_type', 'in', ('expense', 'liability_current'))],
}
}
payroll_principal_officer_info = fields.Text(
string='Principal Officer Information',
help='Information about the principal officer for payroll',
)
# =========================================================================
# WORKERS' COMPENSATION
# =========================================================================
workers_comp_province = fields.Selection([
('AB', 'Alberta'),
('BC', 'British Columbia'),
('MB', 'Manitoba'),
('NB', 'New Brunswick'),
('NL', 'Newfoundland and Labrador'),
('NS', 'Nova Scotia'),
('NT', 'Northwest Territories'),
('NU', 'Nunavut'),
('ON', 'Ontario'),
('PE', 'Prince Edward Island'),
('QC', 'Quebec'),
('SK', 'Saskatchewan'),
('YT', 'Yukon'),
], string='Province', help='Province for workers\' compensation')
workers_comp_class = fields.Char(
string='Workers\' Comp Class',
help='Workers\' compensation class code',
)
workers_comp_account_number = fields.Char(
string='Workers\' Comp Account Number',
help='Workers\' compensation account number',
)
# =========================================================================
# ACCOUNTING PREFERENCES (Optional - requires account module)
# =========================================================================
account_bank_account_id = fields.Many2one(
'account.account',
string='Paycheque and Payroll Tax Payments',
domain="[('account_type', '=', 'asset_cash'), ('company_ids', 'parent_of', company_id)]",
help='Bank and cash account in chart of accounts for payroll',
ondelete='set null',
)
account_wage_expense_id = fields.Many2one(
'account.account',
string='Wage Expenses',
domain="[('account_type', 'in', ('expense', 'liability_current')), ('company_ids', 'parent_of', company_id)]",
help='Account for wage expenses (expense or current liability)',
ondelete='set null',
)
account_employer_tax_expense_id = fields.Many2one(
'account.account',
string='Employer Tax Expenses',
domain="[('account_type', 'in', ('expense', 'liability_current')), ('company_ids', 'parent_of', company_id)]",
help='Account for employer tax expenses (expense or current liability)',
ondelete='set null',
)
account_federal_tax_liability_id = fields.Many2one(
'account.account',
string='Federal Tax Liability',
domain="[('account_type', 'in', ('expense', 'liability_current')), ('company_ids', 'parent_of', company_id)]",
help='Account for federal tax liabilities (expense or current liability)',
ondelete='set null',
)
account_ontario_tax_liability_id = fields.Many2one(
'account.account',
string='Ontario Tax Liability',
domain="[('account_type', 'in', ('expense', 'liability_current')), ('company_ids', 'parent_of', company_id)]",
help='Account for Ontario tax liabilities (expense or current liability)',
ondelete='set null',
)
account_vacation_pay_liability_id = fields.Many2one(
'account.account',
string='Vacation Pay Liability',
domain="[('account_type', 'in', ('expense', 'liability_current')), ('company_ids', 'parent_of', company_id)]",
help='Account for vacation pay liabilities (expense or current liability)',
ondelete='set null',
)
account_class_tracking = fields.Selection([
('none', 'I don\'t use classes for payroll transactions'),
('same', 'I use the same class for all employees'),
('different', 'I use different classes for different employees'),
], string='Class Tracking',
default='none',
help='How do you want to track classes for payroll transactions in QuickBooks?',
)
# =========================================================================
# AUTO PAYROLL
# =========================================================================
auto_payroll_enabled = fields.Boolean(
string='Auto Payroll Enabled',
default=False,
compute='_compute_auto_payroll',
help='Whether auto payroll is currently enabled (feature not implemented)',
)
auto_payroll_ineligibility_reason = fields.Char(
string='Ineligibility Reason',
compute='_compute_auto_payroll',
help='Reason why auto payroll is not available',
)
# =========================================================================
# WORK LOCATIONS (One2many)
# =========================================================================
work_location_ids = fields.One2many(
'payroll.work.location',
'company_id',
string='Work Locations',
)
# =========================================================================
# COMPUTED FIELDS
# =========================================================================
@api.depends('federal_tax_form_type', 'federal_tax_payment_frequency', 'federal_tax_effective_date')
def _compute_federal_tax_display(self):
"""Format federal tax schedule display."""
for record in self:
if record.federal_tax_effective_date and record.federal_tax_payment_frequency:
freq_map = {
'monthly': 'monthly',
'quarterly': 'quarterly',
'annually': 'annually',
}
freq = freq_map.get(record.federal_tax_payment_frequency, '')
form_type = record.federal_tax_form_type or 'Form PD7A'
date_str = record.federal_tax_effective_date.strftime('%m/%d/%Y')
record.federal_tax_display = f"{form_type}, paying {freq} since {date_str}"
else:
record.federal_tax_display = ''
@api.depends('company_id')
def _compute_auto_payroll(self):
"""Compute auto payroll eligibility (currently always False)."""
for record in self:
# Feature not implemented yet
record.auto_payroll_enabled = False
# Check for employees enrolled (placeholder logic)
employee_count = self.env['hr.employee'].search_count([
('company_id', '=', record.company_id.id),
])
if employee_count == 0:
record.auto_payroll_ineligibility_reason = 'No employees enrolled'
else:
record.auto_payroll_ineligibility_reason = 'Feature not yet implemented'
# =========================================================================
# HELPER METHODS
# =========================================================================
def get_payroll_contact_name(self):
"""Return full name of payroll contact."""
self.ensure_one()
parts = []
if self.payroll_contact_first_name:
parts.append(self.payroll_contact_first_name)
if self.payroll_contact_last_name:
parts.append(self.payroll_contact_last_name)
return ' '.join(parts) if parts else ''
def get_primary_notification_emails(self):
"""Return comma-separated list of emails from primary notification recipients."""
self.ensure_one()
emails = []
for partner in self.notification_email_primary_ids:
if partner.email:
emails.append(partner.email)
return ', '.join(emails) if emails else ''
def get_primary_notification_partners(self):
"""Return recordset of partners who should receive primary notifications."""
self.ensure_one()
return self.notification_email_primary_ids.filtered(lambda p: p.email)
def get_cra_payroll_account_number(self):
"""Return formatted CRA payroll account number."""
self.ensure_one()
if self.cra_business_number and self.cra_reference_number:
return f"{self.cra_business_number}RP{self.cra_reference_number.zfill(4)}"
return ''
def get_current_tax_schedule(self, province, check_date=None):
"""Get current tax payment schedule for a province."""
self.ensure_one()
if not check_date:
check_date = date.today()
schedule = self.provincial_tax_schedule_ids.filtered(
lambda s: s.province == province and s.is_current
)
return schedule[0] if schedule else False
_sql_constraints = [
('unique_company', 'unique(company_id)', 'Only one payroll settings record is allowed per company.'),
]
@api.model
def get_settings(self, company_id=None):
"""Get or create settings for a company."""
if not company_id:
company_id = self.env.company.id
settings = self.search([('company_id', '=', company_id)], limit=1)
if not settings:
settings = self.create({'company_id': company_id})
return settings
def action_save(self):
"""Save settings."""
self.ensure_one()
# Settings are auto-saved, this is just for the button
return True
def action_edit_federal_tax(self):
"""Toggle edit mode for federal tax fields."""
self.ensure_one()
# In a real implementation, this would toggle visibility of edit fields
# For now, fields are always editable
return True
def action_align_printer(self):
"""Action for printer alignment (placeholder)."""
self.ensure_one()
# This would typically open a wizard or generate a test print
# For now, just return a notification
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Printer Alignment'),
'message': _('Printer alignment feature will be implemented in a future update.'),
'type': 'info',
},
}
def action_open_location(self):
"""Open work location form (used from tree view)."""
# This is called from the tree view button
# The actual location opening is handled by the tree view's default behavior
return True
@api.model
def name_get(self):
"""Return display name with company."""
result = []
for record in self:
name = f"Payroll Settings - {record.company_id.name}"
result.append((record.id, name))
return result

View File

@@ -0,0 +1,379 @@
# -*- coding: utf-8 -*-
"""
Fusion Payroll Dashboard
========================
Modern dashboard with statistics, quick actions, and charts.
"""
from odoo import api, fields, models, _
from datetime import date
from dateutil.relativedelta import relativedelta
class PayrollDashboard(models.Model):
"""
Dashboard model for Fusion Payroll.
This is a singleton model that provides computed statistics.
"""
_name = 'fusion.payroll.dashboard'
_description = 'Fusion Payroll Dashboard'
name = fields.Char(default='Fusion Payroll Dashboard')
company_id = fields.Many2one(
'res.company',
default=lambda self: self.env.company,
)
# ==================== STATISTICS ====================
# Employee Stats
total_employees = fields.Integer(
string='Total Employees',
compute='_compute_employee_stats',
)
active_employees = fields.Integer(
string='Active Employees',
compute='_compute_employee_stats',
)
on_leave_employees = fields.Integer(
string='On Leave',
compute='_compute_employee_stats',
)
terminated_employees = fields.Integer(
string='Terminated',
compute='_compute_employee_stats',
)
# Payroll Stats
payroll_this_month = fields.Monetary(
string='Payroll This Month',
currency_field='currency_id',
compute='_compute_payroll_stats',
)
payroll_this_year = fields.Monetary(
string='Payroll This Year',
currency_field='currency_id',
compute='_compute_payroll_stats',
)
payroll_last_month = fields.Monetary(
string='Payroll Last Month',
currency_field='currency_id',
compute='_compute_payroll_stats',
)
payroll_count_this_month = fields.Integer(
string='Payslips This Month',
compute='_compute_payroll_stats',
)
avg_payroll_per_employee = fields.Monetary(
string='Avg Pay Per Employee',
currency_field='currency_id',
compute='_compute_payroll_stats',
)
# Trend indicators
payroll_trend = fields.Float(
string='Payroll Trend (%)',
compute='_compute_payroll_stats',
help='Percentage change from last month',
)
currency_id = fields.Many2one(
'res.currency',
default=lambda self: self.env.company.currency_id,
)
# ==================== COMPUTED METHODS ====================
@api.depends('company_id')
def _compute_employee_stats(self):
for dashboard in self:
Employee = self.env['hr.employee']
company_domain = [('company_id', '=', dashboard.company_id.id)]
dashboard.total_employees = Employee.search_count(company_domain)
# Check if employment_status field exists
if 'employment_status' in Employee._fields:
dashboard.active_employees = Employee.search_count(
company_domain + [('employment_status', '=', 'active')]
)
dashboard.on_leave_employees = Employee.search_count(
company_domain + [('employment_status', '=', 'on_leave')]
)
dashboard.terminated_employees = Employee.search_count(
company_domain + [('employment_status', '=', 'terminated')]
)
else:
dashboard.active_employees = Employee.search_count(
company_domain + [('active', '=', True)]
)
dashboard.on_leave_employees = 0
dashboard.terminated_employees = Employee.search_count(
company_domain + [('active', '=', False)]
)
@api.depends('company_id')
def _compute_payroll_stats(self):
today = date.today()
month_start = today.replace(day=1)
year_start = today.replace(month=1, day=1)
last_month_start = month_start - relativedelta(months=1)
last_month_end = month_start - relativedelta(days=1)
for dashboard in self:
Payslip = self.env['hr.payslip']
company_domain = [
('company_id', '=', dashboard.company_id.id),
('state', 'in', ['done', 'paid']),
]
# This month
month_payslips = Payslip.search(
company_domain + [
('date_from', '>=', month_start),
('date_to', '<=', today),
]
)
dashboard.payroll_this_month = sum(month_payslips.mapped('net_wage'))
dashboard.payroll_count_this_month = len(month_payslips)
# This year
year_payslips = Payslip.search(
company_domain + [
('date_from', '>=', year_start),
]
)
dashboard.payroll_this_year = sum(year_payslips.mapped('net_wage'))
# Last month (for trend)
last_month_payslips = Payslip.search(
company_domain + [
('date_from', '>=', last_month_start),
('date_to', '<=', last_month_end),
]
)
dashboard.payroll_last_month = sum(last_month_payslips.mapped('net_wage'))
# Trend calculation
if dashboard.payroll_last_month > 0:
dashboard.payroll_trend = (
(dashboard.payroll_this_month - dashboard.payroll_last_month)
/ dashboard.payroll_last_month * 100
)
else:
dashboard.payroll_trend = 0
# Average per employee
if dashboard.active_employees > 0 and dashboard.payroll_this_month > 0:
dashboard.avg_payroll_per_employee = (
dashboard.payroll_this_month / dashboard.active_employees
)
else:
dashboard.avg_payroll_per_employee = 0
# ==================== ACTION METHODS ====================
def action_run_payroll(self):
"""Open Run Payroll wizard."""
action = self.env['run.payroll.wizard'].action_open_run_payroll()
# Ensure edit is enabled (override dashboard context)
if action.get('context'):
ctx = action['context']
if isinstance(ctx, str):
try:
ctx = eval(ctx, {'uid': self.env.uid})
except:
ctx = {}
if not isinstance(ctx, dict):
ctx = {}
else:
ctx = {}
ctx.update({'create': True, 'edit': True, 'delete': True})
action['context'] = ctx
return action
def _get_action_context(self, action):
"""Parse action context and add create/edit/delete permissions."""
ctx = action.get('context', {})
if isinstance(ctx, str):
try:
ctx = eval(ctx, {'uid': self.env.uid, 'active_id': self.id})
except:
ctx = {}
if not isinstance(ctx, dict):
ctx = {}
ctx.update({'create': True, 'edit': True, 'delete': True})
return ctx
def action_view_employees(self):
"""Open All Employees view using existing action."""
action = self.env['ir.actions.act_window']._for_xml_id('fusion_payroll.action_fusion_employees')
action['context'] = self._get_action_context(action)
return action
def action_cra_remittance(self):
"""Open CRA Remittance view using existing action."""
action = self.env['ir.actions.act_window']._for_xml_id('fusion_payroll.action_fusion_tax_remittances')
action['context'] = self._get_action_context(action)
return action
def action_t4_slips(self):
"""Open T4 Slips view using existing action."""
action = self.env['ir.actions.act_window']._for_xml_id('fusion_payroll.action_fusion_t4_slips')
action['context'] = self._get_action_context(action)
return action
def action_roe(self):
"""Open Record of Employment view using existing action."""
action = self.env['ir.actions.act_window']._for_xml_id('fusion_payroll.action_fusion_roe')
action['context'] = self._get_action_context(action)
return action
def action_reports(self):
"""Open Reports hub using existing action."""
try:
action = self.env['ir.actions.act_window']._for_xml_id('fusion_payroll.action_fusion_report_hub_page')
return action
except ValueError:
# Fallback if action doesn't exist
return {
'type': 'ir.actions.act_window',
'name': _('Payroll Reports'),
'res_model': 'ir.actions.report',
'view_mode': 'kanban,list',
'domain': [('model', 'like', 'hr.payslip')],
}
def action_settings(self):
"""Open Fusion Payroll Settings."""
# Get or create settings record
settings = self.env['payroll.config.settings'].get_settings()
return {
'type': 'ir.actions.act_window',
'name': _('Fusion Payroll Settings'),
'res_model': 'payroll.config.settings',
'res_id': settings.id,
'view_mode': 'form',
'target': 'current',
'context': {'form_view_initial_mode': 'edit'},
}
# ==================== SINGLETON PATTERN ====================
@api.model
def get_dashboard(self):
"""Get or create the dashboard singleton."""
dashboard = self.search([
('company_id', '=', self.env.company.id)
], limit=1)
if not dashboard:
dashboard = self.create({
'name': 'Fusion Payroll Dashboard',
'company_id': self.env.company.id,
})
return dashboard
@api.model
def action_open_dashboard(self):
"""Open the dashboard."""
dashboard = self.get_dashboard()
return {
'type': 'ir.actions.act_window',
'name': _('Fusion Payroll'),
'res_model': 'fusion.payroll.dashboard',
'res_id': dashboard.id,
'view_mode': 'form',
'target': 'current',
'flags': {'mode': 'readonly'},
}
class PayrollDashboardStats(models.Model):
"""
Monthly payroll statistics for graphing.
This model stores aggregated monthly data for trend charts.
"""
_name = 'fusion.payroll.stats'
_description = 'Payroll Statistics'
_order = 'year desc, month desc'
name = fields.Char(compute='_compute_name', store=True)
company_id = fields.Many2one('res.company', required=True)
year = fields.Integer(required=True)
month = fields.Integer(required=True)
month_name = fields.Char(compute='_compute_name', store=True)
total_payroll = fields.Monetary(currency_field='currency_id')
total_gross = fields.Monetary(currency_field='currency_id')
total_taxes = fields.Monetary(currency_field='currency_id')
employee_count = fields.Integer()
payslip_count = fields.Integer()
currency_id = fields.Many2one(
'res.currency',
default=lambda self: self.env.company.currency_id,
)
@api.depends('year', 'month')
def _compute_name(self):
month_names = [
'', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
]
for stat in self:
if stat.month and stat.year:
stat.month_name = month_names[stat.month]
stat.name = f"{month_names[stat.month]} {stat.year}"
else:
stat.month_name = ''
stat.name = 'New'
@api.model
def refresh_stats(self):
"""Refresh statistics from payslip data."""
today = date.today()
company = self.env.company
# Get data for last 12 months
for i in range(12):
target_date = today - relativedelta(months=i)
year = target_date.year
month = target_date.month
month_start = target_date.replace(day=1)
month_end = (month_start + relativedelta(months=1)) - relativedelta(days=1)
# Find payslips for this month
payslips = self.env['hr.payslip'].search([
('company_id', '=', company.id),
('state', 'in', ['done', 'paid']),
('date_from', '>=', month_start),
('date_to', '<=', month_end),
])
# Find or create stat record
stat = self.search([
('company_id', '=', company.id),
('year', '=', year),
('month', '=', month),
], limit=1)
vals = {
'company_id': company.id,
'year': year,
'month': month,
'total_payroll': sum(payslips.mapped('net_wage')),
'total_gross': sum(payslips.mapped('basic_wage')) if 'basic_wage' in payslips._fields else 0,
'total_taxes': 0, # Would need to sum tax lines
'employee_count': len(set(payslips.mapped('employee_id.id'))),
'payslip_count': len(payslips),
}
if stat:
stat.write(vals)
else:
self.create(vals)
return True

View File

@@ -0,0 +1,485 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError, ValidationError
from datetime import date, timedelta
class PayrollEntry(models.TransientModel):
"""
Payroll Entry - Represents a single employee's payroll for a period.
This is the QuickBooks-like payroll line item.
Transient because it's linked to the wizard.
"""
_name = 'payroll.entry'
_description = 'Payroll Entry'
_order = 'employee_id'
wizard_id = fields.Many2one(
'run.payroll.wizard',
string='Payroll Wizard',
ondelete='cascade',
)
employee_id = fields.Many2one(
'hr.employee',
string='Employee',
required=True,
ondelete='cascade',
)
# Display fields for Edit Paycheque dialog
employee_address = fields.Char(
string='Employee Address',
compute='_compute_display_fields',
)
pay_date = fields.Date(
string='Pay Date',
related='wizard_id.pay_date',
)
pay_period_display = fields.Char(
string='Pay Period',
compute='_compute_display_fields',
)
paid_from = fields.Char(
string='Paid From',
compute='_compute_display_fields',
)
# Employee info (computed for display)
employee_type = fields.Selection([
('hourly', 'Hourly'),
('salary', 'Salary'),
], string='Type', compute='_compute_employee_info')
hourly_rate = fields.Monetary(
string='Hourly Rate',
currency_field='currency_id',
compute='_compute_employee_info',
)
# Pay components
regular_hours = fields.Float(
string='Regular Hours',
default=0.0,
)
regular_pay = fields.Monetary(
string='Regular Pay',
currency_field='currency_id',
compute='_compute_pay_amounts',
)
vacation_pay_percent = fields.Float(
string='Vacation %',
default=4.0,
help='Vacation pay percentage (default 4%)',
)
vacation_pay = fields.Monetary(
string='Vacation Pay',
currency_field='currency_id',
compute='_compute_pay_amounts',
)
stat_holiday_hours = fields.Float(
string='Stat Holiday Hours',
default=0.0,
)
stat_holiday_pay = fields.Monetary(
string='Stat Holiday Pay',
currency_field='currency_id',
compute='_compute_pay_amounts',
)
stat_pay_avg_daily_wage = fields.Monetary(
string='Stat Pay - Avg Daily Wage',
currency_field='currency_id',
default=0.0,
help='Additional stat pay based on average daily wage',
)
# Totals
total_hours = fields.Float(
string='Total Hrs',
compute='_compute_totals',
)
gross_pay = fields.Monetary(
string='Gross Pay',
currency_field='currency_id',
compute='_compute_totals',
)
# Deductions (Employee Taxes)
income_tax = fields.Monetary(
string='Income Tax',
currency_field='currency_id',
compute='_compute_taxes',
)
employment_insurance = fields.Monetary(
string='Employment Insurance',
currency_field='currency_id',
compute='_compute_taxes',
)
cpp = fields.Monetary(
string='Canada Pension Plan',
currency_field='currency_id',
compute='_compute_taxes',
)
cpp2 = fields.Monetary(
string='Second Canada Pension Plan',
currency_field='currency_id',
compute='_compute_taxes',
)
total_employee_tax = fields.Monetary(
string='Total Employee Tax',
currency_field='currency_id',
compute='_compute_taxes',
)
# Employer Taxes
employer_ei = fields.Monetary(
string='EI Employer',
currency_field='currency_id',
compute='_compute_employer_taxes',
)
employer_cpp = fields.Monetary(
string='CPP Employer',
currency_field='currency_id',
compute='_compute_employer_taxes',
)
employer_cpp2 = fields.Monetary(
string='CPP2 Employer',
currency_field='currency_id',
compute='_compute_employer_taxes',
)
total_employer_tax = fields.Monetary(
string='Total Employer Tax',
currency_field='currency_id',
compute='_compute_employer_taxes',
)
# Net Pay
net_pay = fields.Monetary(
string='Net Pay',
currency_field='currency_id',
compute='_compute_net_pay',
)
# Vacation Time Off Tracking
vacation_hours_accrued = fields.Float(
string='Vacation Hours Accrued',
default=0.0,
)
vacation_hours_used = fields.Float(
string='Vacation Hours Used',
default=0.0,
)
vacation_hours_available = fields.Float(
string='Vacation Hours Available',
compute='_compute_vacation_balance',
)
vacation_amount_accrued = fields.Monetary(
string='Vacation Amount Accrued',
currency_field='currency_id',
compute='_compute_vacation_balance',
)
vacation_amount_used = fields.Monetary(
string='Vacation Amount Used',
currency_field='currency_id',
default=0.0,
)
vacation_amount_available = fields.Monetary(
string='Vacation Amount Available',
currency_field='currency_id',
compute='_compute_vacation_balance',
)
# Other fields
memo = fields.Text(
string='Memo',
)
payment_method = fields.Selection([
('cheque', 'Paper cheque'),
('direct_deposit', 'Direct Deposit'),
], string='Pay Method', default='cheque')
# Currency
currency_id = fields.Many2one(
'res.currency',
string='Currency',
default=lambda self: self.env.company.currency_id,
)
# Previous payroll indicator
previous_payroll_id = fields.Many2one(
'hr.payslip',
string='Previous Payroll',
compute='_compute_previous_payroll',
)
previous_payroll_amount = fields.Monetary(
string='Previous Amount',
currency_field='currency_id',
compute='_compute_previous_payroll',
)
previous_payroll_date = fields.Date(
string='Previous Pay Date',
compute='_compute_previous_payroll',
)
has_previous_payroll = fields.Boolean(
string='Has Previous',
compute='_compute_previous_payroll',
)
change_from_last_payroll = fields.Char(
string='Change from Last',
compute='_compute_previous_payroll',
help='Percentage change from last payroll',
)
@api.depends('employee_id')
def _compute_employee_info(self):
for entry in self:
if entry.employee_id:
employee = entry.employee_id
# Get pay type and rate from Fusion Payroll fields
pay_type = getattr(employee, 'pay_type', 'hourly')
if pay_type == 'hourly':
entry.employee_type = 'hourly'
# Use hourly_rate from Fusion Payroll
hourly_rate = getattr(employee, 'hourly_rate', 0.0)
entry.hourly_rate = hourly_rate if hourly_rate else 0.0
elif pay_type == 'salary':
entry.employee_type = 'salary'
# Calculate hourly from salary
salary = getattr(employee, 'salary_amount', 0.0)
hours_per_week = getattr(employee, 'default_hours_per_week', 40.0) or 40.0
# Bi-weekly = salary / 2, then divide by hours per pay period
entry.hourly_rate = (salary / 2) / hours_per_week if salary else 0.0
else:
entry.employee_type = 'hourly'
entry.hourly_rate = 0.0
# If still no rate, fallback to contract or hourly_cost
if entry.hourly_rate == 0.0:
if hasattr(employee, 'hourly_cost') and employee.hourly_cost:
entry.hourly_rate = employee.hourly_cost
elif hasattr(employee, 'contract_id') and employee.contract_id:
contract = employee.contract_id
if hasattr(contract, 'wage') and contract.wage:
entry.hourly_rate = contract.wage / 160
else:
entry.employee_type = 'hourly'
entry.hourly_rate = 0.0
@api.depends('regular_hours', 'hourly_rate', 'vacation_pay_percent', 'stat_holiday_hours')
def _compute_pay_amounts(self):
for entry in self:
# Regular pay = hours * rate
entry.regular_pay = entry.regular_hours * entry.hourly_rate
# Vacation pay = vacation_pay_percent% of regular pay
entry.vacation_pay = entry.regular_pay * (entry.vacation_pay_percent / 100)
# Stat holiday pay = hours * rate
entry.stat_holiday_pay = entry.stat_holiday_hours * entry.hourly_rate
@api.depends('regular_hours', 'stat_holiday_hours', 'regular_pay', 'vacation_pay', 'stat_holiday_pay', 'stat_pay_avg_daily_wage')
def _compute_totals(self):
for entry in self:
entry.total_hours = entry.regular_hours + entry.stat_holiday_hours
entry.gross_pay = entry.regular_pay + entry.vacation_pay + entry.stat_holiday_pay + entry.stat_pay_avg_daily_wage
@api.depends('gross_pay', 'employee_id')
def _compute_taxes(self):
"""Calculate employee tax deductions."""
for entry in self:
if entry.gross_pay <= 0:
entry.income_tax = 0
entry.employment_insurance = 0
entry.cpp = 0
entry.cpp2 = 0
entry.total_employee_tax = 0
continue
# Get tax rates from parameters or use defaults
# These are simplified calculations - actual payroll uses full tax rules
gross = entry.gross_pay
# Simplified tax calculations (bi-weekly)
# Income tax: ~15-20% average for Canadian employees
entry.income_tax = round(gross * 0.128, 2) # Approximate federal + provincial
# EI: 1.64% of gross (2025 rate) up to maximum
entry.employment_insurance = round(min(gross * 0.0164, 1049.12 / 26), 2)
# CPP: 5.95% of pensionable earnings above basic exemption (2025)
cpp_exempt = 3500 / 26 # Annual exemption / 26 pay periods
pensionable = max(0, gross - cpp_exempt)
entry.cpp = round(min(pensionable * 0.0595, 4034.10 / 26), 2)
# CPP2: 4% on earnings above first ceiling (2025)
entry.cpp2 = 0 # Only applies if earnings exceed $71,300/year
entry.total_employee_tax = entry.income_tax + entry.employment_insurance + entry.cpp + entry.cpp2
@api.depends('employment_insurance', 'cpp', 'cpp2')
def _compute_employer_taxes(self):
"""Calculate employer tax contributions."""
for entry in self:
# EI employer: 1.4x employee rate
entry.employer_ei = round(entry.employment_insurance * 1.4, 2)
# CPP employer: same as employee
entry.employer_cpp = entry.cpp
# CPP2 employer: same as employee
entry.employer_cpp2 = entry.cpp2
entry.total_employer_tax = entry.employer_ei + entry.employer_cpp + entry.employer_cpp2
@api.depends('gross_pay', 'total_employee_tax')
def _compute_net_pay(self):
for entry in self:
entry.net_pay = entry.gross_pay - entry.total_employee_tax
@api.depends('vacation_pay', 'vacation_hours_used')
def _compute_vacation_balance(self):
for entry in self:
entry.vacation_hours_available = entry.vacation_hours_accrued - entry.vacation_hours_used
entry.vacation_amount_accrued = entry.vacation_pay # Current period accrual
entry.vacation_amount_available = entry.vacation_amount_accrued - entry.vacation_amount_used
@api.depends('employee_id', 'wizard_id.date_start', 'wizard_id.date_end', 'gross_pay')
def _compute_previous_payroll(self):
"""Check if employee has been paid in the current period and compute change."""
for entry in self:
entry.previous_payroll_id = False
entry.previous_payroll_amount = 0
entry.previous_payroll_date = False
entry.has_previous_payroll = False
entry.change_from_last_payroll = ''
if not entry.employee_id:
continue
# Search for the last payslip for this employee (not in current period)
payslip = self.env['hr.payslip'].search([
('employee_id', '=', entry.employee_id.id),
('state', 'in', ['done', 'paid']),
], limit=1, order='date_to desc')
if payslip:
entry.previous_payroll_id = payslip
entry.previous_payroll_amount = payslip.net_wage
entry.previous_payroll_date = payslip.date_to
entry.has_previous_payroll = True
# Calculate change percentage
if payslip.basic_wage and payslip.basic_wage > 0 and entry.gross_pay > 0:
change = ((entry.gross_pay - payslip.basic_wage) / payslip.basic_wage) * 100
if change > 0:
entry.change_from_last_payroll = f"↑ Up {abs(change):.0f}%"
elif change < 0:
entry.change_from_last_payroll = f"↓ Down {abs(change):.0f}%"
else:
entry.change_from_last_payroll = "No change"
def action_load_attendance_hours(self):
"""Load hours from hr_attendance for the pay period."""
self.ensure_one()
if not self.wizard_id:
return
# Search for attendance records in the period
attendances = self.env['hr.attendance'].search([
('employee_id', '=', self.employee_id.id),
('check_in', '>=', self.wizard_id.date_start),
('check_in', '<=', self.wizard_id.date_end),
('check_out', '!=', False),
])
total_hours = 0
for att in attendances:
if att.check_out:
delta = att.check_out - att.check_in
total_hours += delta.total_seconds() / 3600
self.regular_hours = round(total_hours, 2)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Attendance Hours Loaded'),
'message': _('Loaded %.2f hours from attendance records.') % total_hours,
'type': 'success',
},
}
def action_view_previous_payroll(self):
"""Open the previous payroll record."""
self.ensure_one()
if self.previous_payroll_id:
return {
'type': 'ir.actions.act_window',
'name': _('Previous Payslip'),
'res_model': 'hr.payslip',
'res_id': self.previous_payroll_id.id,
'view_mode': 'form',
'target': 'new',
}
def action_open_edit_paycheque(self):
"""Open the detailed paycheque editor dialog."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Edit Paycheque - %s') % self.employee_id.name,
'res_model': 'payroll.entry',
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
'context': {'form_view_ref': 'fusion_payroll.payroll_entry_edit_form'},
}
@api.depends('employee_id', 'wizard_id')
def _compute_display_fields(self):
for entry in self:
# Employee address
if entry.employee_id:
emp = entry.employee_id
parts = []
if hasattr(emp, 'home_street') and emp.home_street:
parts.append(emp.home_street)
if hasattr(emp, 'home_city') and emp.home_city:
city_part = emp.home_city
if hasattr(emp, 'home_province') and emp.home_province:
city_part += f", {emp.home_province}"
if hasattr(emp, 'home_postal_code') and emp.home_postal_code:
city_part += f" {emp.home_postal_code}"
parts.append(city_part)
entry.employee_address = '\n'.join(parts) if parts else ''
else:
entry.employee_address = ''
# Pay period display
if entry.wizard_id and entry.wizard_id.date_start and entry.wizard_id.date_end:
entry.pay_period_display = f"{entry.wizard_id.date_start.strftime('%m.%d.%Y')} to {entry.wizard_id.date_end.strftime('%m.%d.%Y')}"
else:
entry.pay_period_display = ''
# Paid from (company bank or cheque info)
if entry.wizard_id and entry.wizard_id.company_id:
company = entry.wizard_id.company_id
if entry.payment_method == 'cheque':
entry.paid_from = f"Cheque ({company.name})"
else:
entry.paid_from = f"Direct Deposit ({company.name})"
else:
entry.paid_from = ''
def action_save_entry(self):
"""Save the entry and close the dialog."""
self.ensure_one()
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,465 @@
# -*- coding: utf-8 -*-
"""
Payroll Report Framework
========================
Base abstract model for all payroll reports, modeled after Odoo's account_reports.
Provides filter options, data fetching, and export functionality.
"""
import io
import json
from datetime import date, datetime
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.tools import format_date, float_round
from odoo.tools.misc import formatLang
class PayrollReport(models.AbstractModel):
"""
Abstract base class for all payroll reports.
Provides common filter options, column definitions, and export methods.
"""
_name = 'payroll.report'
_description = 'Payroll Report Base'
# =========================================================================
# REPORT CONFIGURATION (Override in concrete reports)
# =========================================================================
# Filter options - set to True to enable
filter_date_range = True
filter_employee = True
filter_department = False
filter_pay_period = False
# Report metadata
report_name = 'Payroll Report'
report_code = 'payroll_report'
# Date filter presets matching QuickBooks
DATE_FILTER_OPTIONS = [
('last_pay_date', 'Last pay date'),
('this_month', 'This month'),
('this_quarter', 'This quarter'),
('this_year', 'This year'),
('last_month', 'Last month'),
('last_quarter', 'Last quarter'),
('last_year', 'Last year'),
('first_quarter', 'First quarter'),
('custom', 'Custom'),
]
# =========================================================================
# OPTIONS HANDLING
# =========================================================================
def _get_default_options(self):
"""Return default options for the report."""
today = date.today()
options = {
'report_name': self.report_name,
'report_code': self.report_code,
'date': {
'filter': 'this_year',
'date_from': today.replace(month=1, day=1).strftime('%Y-%m-%d'),
'date_to': today.strftime('%Y-%m-%d'),
},
'columns': self._get_columns(),
'currency_id': self.env.company.currency_id.id,
}
if self.filter_employee:
options['employee_ids'] = []
options['all_employees'] = True
if self.filter_department:
options['department_ids'] = []
options['all_departments'] = True
return options
def _get_options(self, previous_options=None):
"""
Build report options, merging with previous options if provided.
"""
options = self._get_default_options()
if previous_options:
# Merge date options
if 'date' in previous_options:
options['date'].update(previous_options['date'])
# Merge employee filter
if 'employee_ids' in previous_options:
options['employee_ids'] = previous_options['employee_ids']
options['all_employees'] = not previous_options['employee_ids']
# Merge department filter
if 'department_ids' in previous_options:
options['department_ids'] = previous_options['department_ids']
options['all_departments'] = not previous_options['department_ids']
# Apply date filter preset
self._apply_date_filter(options)
return options
def _apply_date_filter(self, options):
"""Calculate date_from and date_to based on filter preset."""
today = date.today()
date_filter = options.get('date', {}).get('filter', 'this_year')
if date_filter == 'custom':
# Keep existing dates
return
date_from = today
date_to = today
if date_filter == 'last_pay_date':
# Find the last payslip date
last_payslip = self.env['hr.payslip'].search([
('state', 'in', ['done', 'paid']),
('company_id', '=', self.env.company.id),
], order='date_to desc', limit=1)
if last_payslip:
date_from = last_payslip.date_from
date_to = last_payslip.date_to
else:
date_from = today
date_to = today
elif date_filter == 'this_month':
date_from = today.replace(day=1)
date_to = (today.replace(day=1) + relativedelta(months=1)) - relativedelta(days=1)
elif date_filter == 'this_quarter':
quarter_month = ((today.month - 1) // 3) * 3 + 1
date_from = today.replace(month=quarter_month, day=1)
date_to = (date_from + relativedelta(months=3)) - relativedelta(days=1)
elif date_filter == 'this_year':
date_from = today.replace(month=1, day=1)
date_to = today.replace(month=12, day=31)
elif date_filter == 'last_month':
last_month = today - relativedelta(months=1)
date_from = last_month.replace(day=1)
date_to = today.replace(day=1) - relativedelta(days=1)
elif date_filter == 'last_quarter':
quarter_month = ((today.month - 1) // 3) * 3 + 1
last_quarter_start = today.replace(month=quarter_month, day=1) - relativedelta(months=3)
date_from = last_quarter_start
date_to = (last_quarter_start + relativedelta(months=3)) - relativedelta(days=1)
elif date_filter == 'last_year':
date_from = today.replace(year=today.year - 1, month=1, day=1)
date_to = today.replace(year=today.year - 1, month=12, day=31)
elif date_filter == 'first_quarter':
date_from = today.replace(month=1, day=1)
date_to = today.replace(month=3, day=31)
options['date']['date_from'] = date_from.strftime('%Y-%m-%d')
options['date']['date_to'] = date_to.strftime('%Y-%m-%d')
# =========================================================================
# COLUMN DEFINITIONS (Override in concrete reports)
# =========================================================================
def _get_columns(self):
"""
Return column definitions for the report.
Override in concrete reports.
Returns:
list: List of column dictionaries with:
- name: Display name
- field: Data field name
- type: 'char', 'date', 'monetary', 'float', 'integer'
- sortable: bool
- width: optional width class
"""
return [
{'name': _('Name'), 'field': 'name', 'type': 'char', 'sortable': True},
]
# =========================================================================
# DATA FETCHING (Override in concrete reports)
# =========================================================================
def _get_domain(self, options):
"""
Build search domain from options.
Override in concrete reports for specific filtering.
"""
domain = [('company_id', '=', self.env.company.id)]
# Date filter
date_from = options.get('date', {}).get('date_from')
date_to = options.get('date', {}).get('date_to')
if date_from:
domain.append(('date_from', '>=', date_from))
if date_to:
domain.append(('date_to', '<=', date_to))
# Employee filter
if not options.get('all_employees') and options.get('employee_ids'):
domain.append(('employee_id', 'in', options['employee_ids']))
# Department filter
if not options.get('all_departments') and options.get('department_ids'):
domain.append(('employee_id.department_id', 'in', options['department_ids']))
return domain
def _get_lines(self, options):
"""
Fetch and return report lines.
Override in concrete reports.
Returns:
list: List of line dictionaries with:
- id: unique line id
- name: display name
- columns: list of column values
- level: hierarchy level (0, 1, 2...)
- unfoldable: bool for drill-down
- unfolded: bool current state
- class: CSS classes
"""
return []
def _get_report_data(self, options):
"""
Get complete report data for frontend.
"""
return {
'options': options,
'columns': self._get_columns(),
'lines': self._get_lines(options),
'date_filter_options': self.DATE_FILTER_OPTIONS,
}
# =========================================================================
# TOTALS AND AGGREGATIONS
# =========================================================================
def _get_total_line(self, lines, options):
"""
Calculate totals from lines.
Override for custom total calculations.
"""
if not lines:
return {}
columns = self._get_columns()
total_values = {}
for col in columns:
if col.get('type') in ('monetary', 'float', 'integer'):
field = col['field']
try:
total_values[field] = sum(
float(line.get('values', {}).get(field, 0) or 0)
for line in lines
if line.get('level', 0) == 0 # Only sum top-level lines
)
except (ValueError, TypeError):
total_values[field] = 0
return {
'id': 'total',
'name': _('Total'),
'values': total_values,
'level': -1, # Special level for total
'class': 'o_payroll_report_total fw-bold',
}
# =========================================================================
# SETTINGS HELPERS
# =========================================================================
def _get_payroll_settings(self):
"""Get payroll settings for current company."""
return self.env['payroll.config.settings'].get_settings()
def _get_company_legal_info(self):
"""Get company legal name and address from settings."""
settings = self._get_payroll_settings()
return {
'legal_name': settings.company_legal_name or self.env.company.name,
'legal_street': settings.company_legal_street or self.env.company.street or '',
'legal_street2': settings.company_legal_street2 or self.env.company.street2 or '',
'legal_city': settings.company_legal_city or self.env.company.city or '',
'legal_state': settings.company_legal_state_id.name if settings.company_legal_state_id else (self.env.company.state_id.name if self.env.company.state_id else ''),
'legal_zip': settings.company_legal_zip or self.env.company.zip or '',
'legal_country': settings.company_legal_country_id.name if settings.company_legal_country_id else (self.env.company.country_id.name if self.env.company.country_id else ''),
}
def _get_payroll_contact_info(self):
"""Get payroll contact information from settings."""
settings = self._get_payroll_settings()
return {
'name': settings.get_payroll_contact_name(),
'phone': settings.payroll_contact_phone or '',
'email': settings.payroll_contact_email or '',
}
# =========================================================================
# FORMATTING HELPERS
# =========================================================================
def _format_value(self, value, column_type, options):
"""Format value based on column type."""
if value is None:
return ''
currency = self.env['res.currency'].browse(options.get('currency_id'))
if column_type == 'monetary':
return formatLang(self.env, value, currency_obj=currency)
elif column_type == 'float':
return formatLang(self.env, value, digits=2)
elif column_type == 'integer':
return str(int(value))
elif column_type == 'date':
if isinstance(value, str):
value = fields.Date.from_string(value)
return format_date(self.env, value)
elif column_type == 'percentage':
return f"{float_round(value * 100, 2)}%"
else:
return str(value) if value else ''
# =========================================================================
# EXPORT METHODS
# =========================================================================
def get_xlsx(self, options):
"""
Generate Excel export of the report.
Returns binary data.
"""
try:
import xlsxwriter
except ImportError:
raise ImportError("xlsxwriter library is required for Excel export")
output = io.BytesIO()
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
sheet = workbook.add_worksheet(self.report_name[:31]) # Max 31 chars
# Styles
header_style = workbook.add_format({
'bold': True,
'bg_color': '#4a86e8',
'font_color': 'white',
'border': 1,
})
money_style = workbook.add_format({'num_format': '$#,##0.00'})
date_style = workbook.add_format({'num_format': 'yyyy-mm-dd'})
total_style = workbook.add_format({'bold': True, 'top': 2})
# Get data
columns = self._get_columns()
lines = self._get_lines(options)
# Add company info from settings
company_info = self._get_company_legal_info()
contact_info = self._get_payroll_contact_info()
# Write report header with company info
sheet.write(0, 0, company_info['legal_name'] or self.env.company.name, header_style)
if company_info['legal_street']:
sheet.write(1, 0, company_info['legal_street'])
if company_info['legal_city']:
addr_line = f"{company_info['legal_city']}, {company_info['legal_state']} {company_info['legal_zip']}"
sheet.write(2, 0, addr_line)
if contact_info['name']:
sheet.write(3, 0, f"Contact: {contact_info['name']}")
# Write headers (starting at row 5)
header_row = 5
for col_idx, col in enumerate(columns):
sheet.write(header_row, col_idx, col['name'], header_style)
sheet.set_column(col_idx, col_idx, 15)
# Write data (starting after header row)
row = header_row + 1
for line in lines:
values = line.get('values', {})
style = total_style if line.get('level', 0) < 0 else None
for col_idx, col in enumerate(columns):
value = values.get(col['field'], '')
cell_style = style
if col['type'] == 'monetary':
cell_style = money_style
elif col['type'] == 'date':
cell_style = date_style
if cell_style:
sheet.write(row, col_idx, value, cell_style)
else:
sheet.write(row, col_idx, value)
row += 1
workbook.close()
output.seek(0)
return output.read()
def get_pdf(self, options):
"""
Generate PDF export of the report.
Returns binary data.
"""
report_data = self._get_report_data(options)
# Get settings data
company_info = self._get_company_legal_info()
contact_info = self._get_payroll_contact_info()
# Render QWeb template
html = self.env['ir.qweb']._render(
'fusion_payroll.payroll_report_pdf_template',
{
'report': self,
'options': options,
'columns': report_data['columns'],
'lines': report_data['lines'],
'company': self.env.company,
'company_info': company_info,
'contact_info': contact_info,
'format_value': self._format_value,
}
)
# Convert to PDF using wkhtmltopdf
pdf = self.env['ir.actions.report']._run_wkhtmltopdf(
[html],
landscape=True,
specific_paperformat_args={'data-report-margin-top': 10}
)
return pdf
# =========================================================================
# ACTION METHODS
# =========================================================================
def action_open_report(self):
"""Open the report in client action."""
return {
'type': 'ir.actions.client',
'tag': 'fusion_payroll.payroll_report_action',
'name': self.report_name,
'context': {
'report_model': self._name,
},
}

View File

@@ -0,0 +1,422 @@
# -*- coding: utf-8 -*-
"""
Cost Reports
============
- Total Pay
- Total Payroll Cost
- Deductions and Contributions
- Workers' Compensation
"""
from collections import defaultdict
from odoo import api, fields, models, _
class PayrollReportTotalPay(models.AbstractModel):
"""
Total Pay Report
Breakdown of pay by type per employee.
"""
_name = 'payroll.report.total.pay'
_inherit = 'payroll.report'
_description = 'Total Pay Report'
report_name = 'Total Pay'
report_code = 'total_pay'
def _get_columns(self):
return [
{'name': _('Name'), 'field': 'name', 'type': 'char', 'sortable': True},
{'name': _('Regular Pay'), 'field': 'regular_pay', 'type': 'monetary', 'sortable': True},
{'name': _('Stat Holiday Pay'), 'field': 'stat_holiday', 'type': 'monetary', 'sortable': True},
{'name': _('Vacation Pay'), 'field': 'vacation_pay', 'type': 'monetary', 'sortable': True},
{'name': _('Total'), 'field': 'total', 'type': 'monetary', 'sortable': True},
]
def _get_lines(self, options):
domain = self._get_domain(options)
domain.append(('state', 'in', ['done', 'paid']))
payslips = self.env['hr.payslip'].search(domain)
# Aggregate by employee
emp_data = defaultdict(lambda: {
'regular_pay': 0,
'stat_holiday': 0,
'vacation_pay': 0,
'total': 0,
})
for slip in payslips:
if not slip.employee_id:
continue
emp_key = slip.employee_id.id
emp_data[emp_key]['name'] = slip.employee_id.name or 'Unknown'
if hasattr(slip, 'line_ids') and slip.line_ids:
for line in slip.line_ids:
if hasattr(line, 'category_id') and line.category_id and hasattr(line.category_id, 'code'):
if line.category_id.code == 'BASIC':
emp_data[emp_key]['regular_pay'] += line.total or 0
if hasattr(line, 'code') and line.code:
if line.code == 'STAT_HOLIDAY':
emp_data[emp_key]['stat_holiday'] += line.total or 0
elif line.code == 'VACATION':
emp_data[emp_key]['vacation_pay'] += line.total or 0
emp_data[emp_key]['total'] += getattr(slip, 'gross_wage', 0) or 0
lines = []
totals = defaultdict(float)
for emp_id, data in emp_data.items():
for key in ['regular_pay', 'stat_holiday', 'vacation_pay', 'total']:
totals[key] += data[key]
lines.append({
'id': f'emp_{emp_id}',
'name': data['name'],
'values': {
'name': data['name'],
'regular_pay': data['regular_pay'],
'stat_holiday': data['stat_holiday'],
'vacation_pay': data['vacation_pay'],
'total': data['total'],
},
'level': 0,
})
# Sort by name
lines.sort(key=lambda x: x['name'])
# Total line
if lines:
lines.append({
'id': 'total',
'name': _('Total Pay'),
'values': {
'name': _('Total Pay'),
'regular_pay': totals['regular_pay'],
'stat_holiday': totals['stat_holiday'],
'vacation_pay': totals['vacation_pay'],
'total': totals['total'],
},
'level': -1,
'class': 'o_payroll_report_total fw-bold bg-success text-white',
})
return lines
class PayrollReportTotalCost(models.AbstractModel):
"""
Total Payroll Cost Report
Summary of all payroll costs.
"""
_name = 'payroll.report.total.cost'
_inherit = 'payroll.report'
_description = 'Total Payroll Cost Report'
report_name = 'Total Payroll Cost'
report_code = 'total_cost'
filter_employee = False
def _get_columns(self):
return [
{'name': _('Item'), 'field': 'item', 'type': 'char', 'sortable': False},
{'name': _('Amount'), 'field': 'amount', 'type': 'monetary', 'sortable': True},
]
def _get_lines(self, options):
domain = [
('state', 'in', ['done', 'paid']),
('company_id', '=', self.env.company.id),
]
date_from = options.get('date', {}).get('date_from')
date_to = options.get('date', {}).get('date_to')
if date_from:
domain.append(('date_from', '>=', date_from))
if date_to:
domain.append(('date_to', '<=', date_to))
payslips = self.env['hr.payslip'].search(domain)
# Calculate totals - safely handle missing fields
paycheque_wages = 0
non_paycheque = 0 # Reimbursements, etc.
reimbursements = 0
ei_employer = 0
cpp_employer = 0
cpp2_employer = 0
for slip in payslips:
paycheque_wages += getattr(slip, 'gross_wage', 0) or 0
ei_employer += getattr(slip, 'employer_ei', 0) or 0
cpp_employer += getattr(slip, 'employer_cpp', 0) or 0
cpp2_employer += getattr(slip, 'employer_cpp2', 0) or 0
total_employer_taxes = ei_employer + cpp_employer + cpp2_employer
total_pay = paycheque_wages + non_paycheque + reimbursements
total_cost = total_pay + total_employer_taxes
lines = [
# Total Pay Section
{
'id': 'total_pay_header',
'name': _('Total Pay'),
'values': {'item': _('Total Pay'), 'amount': ''},
'level': -1,
'class': 'fw-bold',
},
{
'id': 'paycheque_wages',
'name': _('Paycheque Wages'),
'values': {'item': _(' Paycheque Wages'), 'amount': paycheque_wages},
'level': 0,
},
{
'id': 'non_paycheque',
'name': _('Non-paycheque Wages'),
'values': {'item': _(' Non-paycheque Wages'), 'amount': non_paycheque},
'level': 0,
},
{
'id': 'reimbursements',
'name': _('Reimbursements'),
'values': {'item': _(' Reimbursements'), 'amount': reimbursements},
'level': 0,
},
{
'id': 'subtotal_pay',
'name': _('Subtotal'),
'values': {'item': _('Subtotal'), 'amount': total_pay},
'level': 0,
'class': 'fw-bold',
},
# Company Contributions Section
{
'id': 'contributions_header',
'name': _('Company Contributions'),
'values': {'item': _('Company Contributions'), 'amount': ''},
'level': -1,
'class': 'fw-bold',
},
{
'id': 'subtotal_contributions',
'name': _('Subtotal'),
'values': {'item': _('Subtotal'), 'amount': 0},
'level': 0,
'class': 'fw-bold',
},
# Employer Taxes Section
{
'id': 'employer_taxes_header',
'name': _('Employer Taxes'),
'values': {'item': _('Employer Taxes'), 'amount': ''},
'level': -1,
'class': 'fw-bold',
},
{
'id': 'ei_employer',
'name': _('Employment Insurance Employer'),
'values': {'item': _(' Employment Insurance Employer'), 'amount': ei_employer},
'level': 0,
},
{
'id': 'cpp_employer',
'name': _('Canada Pension Plan Employer'),
'values': {'item': _(' Canada Pension Plan Employer'), 'amount': cpp_employer},
'level': 0,
},
{
'id': 'cpp2_employer',
'name': _('Second Canada Pension Plan Employer'),
'values': {'item': _(' Second Canada Pension Plan Employer'), 'amount': cpp2_employer},
'level': 0,
},
{
'id': 'subtotal_employer',
'name': _('Subtotal'),
'values': {'item': _('Subtotal'), 'amount': total_employer_taxes},
'level': 0,
'class': 'fw-bold',
},
# Grand Total
{
'id': 'total_cost',
'name': _('Total Payroll Cost'),
'values': {'item': _('Total Payroll Cost'), 'amount': total_cost},
'level': -1,
'class': 'o_payroll_report_total fw-bold bg-dark text-white',
},
]
return lines
class PayrollReportDeductions(models.AbstractModel):
"""
Deductions and Contributions Report
"""
_name = 'payroll.report.deductions'
_inherit = 'payroll.report'
_description = 'Deductions and Contributions Report'
report_name = 'Deductions and Contributions'
report_code = 'deductions'
filter_employee = False
def _get_columns(self):
return [
{'name': _('Description'), 'field': 'description', 'type': 'char', 'sortable': True},
{'name': _('Type'), 'field': 'type', 'type': 'char', 'sortable': True},
{'name': _('Employee Deductions'), 'field': 'employee_deductions', 'type': 'monetary', 'sortable': True},
{'name': _('Company Contributions'), 'field': 'company_contributions', 'type': 'monetary', 'sortable': True},
{'name': _('Plan Total'), 'field': 'plan_total', 'type': 'monetary', 'sortable': True},
]
def _get_lines(self, options):
domain = [
('state', 'in', ['done', 'paid']),
('company_id', '=', self.env.company.id),
]
date_from = options.get('date', {}).get('date_from')
date_to = options.get('date', {}).get('date_to')
if date_from:
domain.append(('date_from', '>=', date_from))
if date_to:
domain.append(('date_to', '<=', date_to))
payslips = self.env['hr.payslip'].search(domain)
# Aggregate deductions
deduction_data = defaultdict(lambda: {'employee': 0, 'company': 0})
deduction_codes = {
'CPP': {'name': 'Canada Pension Plan', 'type': 'Tax', 'employer_code': 'CPP_ER'},
'CPP2': {'name': 'Second Canada Pension Plan', 'type': 'Tax', 'employer_code': 'CPP2_ER'},
'EI': {'name': 'Employment Insurance', 'type': 'Tax', 'employer_code': 'EI_ER'},
}
for slip in payslips:
if not hasattr(slip, 'line_ids') or not slip.line_ids:
continue
for line in slip.line_ids:
if not hasattr(line, 'code') or not line.code:
continue
if line.code in deduction_codes:
deduction_data[line.code]['employee'] += abs(line.total or 0)
elif line.code.endswith('_ER'):
base_code = line.code[:-3]
if base_code in deduction_codes:
deduction_data[base_code]['company'] += abs(line.total or 0)
lines = []
for code, info in deduction_codes.items():
data = deduction_data[code]
total = data['employee'] + data['company']
lines.append({
'id': f'ded_{code}',
'name': info['name'],
'values': {
'description': info['name'],
'type': info['type'],
'employee_deductions': data['employee'],
'company_contributions': data['company'],
'plan_total': total,
},
'level': 0,
})
return lines
class PayrollReportWorkersComp(models.AbstractModel):
"""
Workers' Compensation Report
"""
_name = 'payroll.report.workers.comp'
_inherit = 'payroll.report'
_description = 'Workers Compensation Report'
report_name = "Workers' Compensation"
report_code = 'workers_comp'
filter_employee = False
def _get_columns(self):
return [
{'name': _('Province'), 'field': 'province', 'type': 'char', 'sortable': True},
{'name': _("Workers' Comp Class"), 'field': 'wc_class', 'type': 'char', 'sortable': True},
{'name': _('Premium Wage Paid'), 'field': 'premium_wage', 'type': 'monetary', 'sortable': True},
{'name': _('Tips Paid'), 'field': 'tips_paid', 'type': 'monetary', 'sortable': True},
{'name': _('Employee Taxes Paid by Employer'), 'field': 'emp_taxes_employer', 'type': 'monetary', 'sortable': True},
{'name': _('Wages Paid'), 'field': 'wages_paid', 'type': 'monetary', 'sortable': True},
]
def _get_lines(self, options):
domain = [
('state', 'in', ['done', 'paid']),
('company_id', '=', self.env.company.id),
]
date_from = options.get('date', {}).get('date_from')
date_to = options.get('date', {}).get('date_to')
if date_from:
domain.append(('date_from', '>=', date_from))
if date_to:
domain.append(('date_to', '<=', date_to))
payslips = self.env['hr.payslip'].search(domain)
# Group by province
province_data = defaultdict(lambda: {'wages': 0, 'premium': 0, 'tips': 0, 'emp_taxes': 0})
for slip in payslips:
if not slip.employee_id:
continue
province = 'ON' # Default, would get from employee address
if hasattr(slip.employee_id, 'province_of_employment'):
province = getattr(slip.employee_id, 'province_of_employment', None) or 'ON'
province_data[province]['wages'] += getattr(slip, 'gross_wage', 0) or 0
lines = []
total_wages = 0
for province, data in province_data.items():
total_wages += data['wages']
lines.append({
'id': f'prov_{province}',
'name': province,
'values': {
'province': province,
'wc_class': 'No Name Specified',
'premium_wage': data['premium'],
'tips_paid': data['tips'],
'emp_taxes_employer': data['emp_taxes'],
'wages_paid': data['wages'],
},
'level': 0,
})
# Total
if lines:
lines.append({
'id': 'total',
'name': _('Total'),
'values': {
'province': _('Total'),
'wc_class': '',
'premium_wage': 0,
'tips_paid': 0,
'emp_taxes_employer': 0,
'wages_paid': total_wages,
},
'level': -1,
'class': 'o_payroll_report_total fw-bold bg-success text-white',
})
return lines

View File

@@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
"""
Employee Reports
================
- Employee Directory (Payroll Item List)
- Time Off Report
"""
from odoo import api, fields, models, _
class PayrollReportEmployeeDirectory(models.AbstractModel):
"""
Employee Directory / Payroll Item List Report
Shows employees with their pay rates and status.
"""
_name = 'payroll.report.employee.directory'
_inherit = 'payroll.report'
_description = 'Employee Directory Report'
report_name = 'Payroll Item List'
report_code = 'employee_directory'
filter_date_range = False # Not date-dependent
filter_employee = False
def _get_columns(self):
return [
{'name': _('Name'), 'field': 'name', 'type': 'char', 'sortable': True},
{'name': _('Salary'), 'field': 'salary', 'type': 'monetary', 'sortable': True},
{'name': _('Regular Pay'), 'field': 'regular_pay', 'type': 'char', 'sortable': False},
{'name': _('Hourly 2'), 'field': 'hourly_2', 'type': 'char', 'sortable': False},
{'name': _('Overtime Pay'), 'field': 'overtime_pay', 'type': 'char', 'sortable': False},
{'name': _('Double Overtime Pay'), 'field': 'double_overtime', 'type': 'char', 'sortable': False},
{'name': _('Stat Holiday Pay'), 'field': 'stat_holiday', 'type': 'char', 'sortable': False},
{'name': _('Bonus'), 'field': 'bonus', 'type': 'char', 'sortable': False},
{'name': _('Status'), 'field': 'status', 'type': 'char', 'sortable': True},
]
def _get_lines(self, options):
# Get all employees
employees = self.env['hr.employee'].search([
('company_id', '=', self.env.company.id),
], order='name')
lines = []
for emp in employees:
# Get pay info directly from employee (Fusion Payroll fields)
salary = ''
regular_pay = ''
# Use Fusion Payroll fields if available
pay_type = getattr(emp, 'pay_type', None)
if pay_type == 'salary':
salary_amount = getattr(emp, 'salary_amount', 0) or 0
if salary_amount:
salary = f"${salary_amount * 12:,.2f}/year"
elif pay_type == 'hourly':
hourly_rate = getattr(emp, 'hourly_rate', 0) or 0
if hourly_rate:
regular_pay = f"${hourly_rate:,.2f}/hr"
else:
# Fallback to hourly_cost if available
hourly_cost = getattr(emp, 'hourly_cost', 0) or 0
if hourly_cost:
regular_pay = f"${hourly_cost:,.2f}/hr"
status = 'Active'
if hasattr(emp, 'employment_status') and 'employment_status' in emp._fields:
if hasattr(emp._fields['employment_status'], 'selection'):
status = dict(emp._fields['employment_status'].selection).get(
emp.employment_status, 'Active'
)
# Calculate salary value for sorting
salary_value = 0
if pay_type == 'salary':
salary_value = (getattr(emp, 'salary_amount', 0) or 0) * 12
lines.append({
'id': f'emp_{emp.id}',
'name': emp.name,
'values': {
'name': emp.name,
'salary': salary_value if salary_value else '',
'regular_pay': regular_pay,
'hourly_2': '',
'overtime_pay': '',
'double_overtime': '',
'stat_holiday': '',
'bonus': '',
'status': status,
},
'level': 0,
'model': 'hr.employee',
'res_id': emp.id,
})
return lines
class PayrollReportTimeOff(models.AbstractModel):
"""
Time Off Report
Shows vacation/leave balances by employee.
"""
_name = 'payroll.report.time.off'
_inherit = 'payroll.report'
_description = 'Time Off Report'
report_name = 'Time Off'
report_code = 'time_off'
filter_date_range = False
def _get_columns(self):
return [
{'name': _('Employee'), 'field': 'employee', 'type': 'char', 'sortable': True},
{'name': _('Vacation'), 'field': 'vacation', 'type': 'char', 'sortable': False},
{'name': _('Balance'), 'field': 'balance', 'type': 'float', 'sortable': True},
{'name': _('YTD Used'), 'field': 'ytd_used', 'type': 'float', 'sortable': True},
{'name': _('Amount Available'), 'field': 'amount_available', 'type': 'monetary', 'sortable': True},
{'name': _('YTD Amount Used'), 'field': 'ytd_amount_used', 'type': 'monetary', 'sortable': True},
]
def _get_lines(self, options):
# Get employees with vacation policy
domain = [('company_id', '=', self.env.company.id)]
if 'employment_status' in self.env['hr.employee']._fields:
domain.append(('employment_status', '=', 'active'))
employees = self.env['hr.employee'].search(domain, order='name')
lines = []
for emp in employees:
# Try to get leave allocation info if hr_holidays is installed
balance = 0
ytd_used = 0
try:
# Check for vacation allocations
allocations = self.env['hr.leave.allocation'].search([
('employee_id', '=', emp.id),
('state', '=', 'validate'),
('holiday_status_id.name', 'ilike', 'vacation'),
])
balance = sum(allocations.mapped('number_of_days'))
# Get used days this year
year_start = fields.Date.today().replace(month=1, day=1)
leaves = self.env['hr.leave'].search([
('employee_id', '=', emp.id),
('state', '=', 'validate'),
('holiday_status_id.name', 'ilike', 'vacation'),
('date_from', '>=', year_start),
])
ytd_used = sum(leaves.mapped('number_of_days'))
except:
pass # hr_holidays may not be installed or different structure
# Get vacation pay rate from employee
vacation_rate = getattr(emp, 'vacation_pay_rate', 4.0)
vacation_policy = f"{vacation_rate}% Paid out each pay period"
lines.append({
'id': f'time_{emp.id}',
'name': emp.name,
'values': {
'employee': emp.name,
'vacation': vacation_policy,
'balance': balance - ytd_used,
'ytd_used': ytd_used,
'amount_available': 0, # Would need to calculate
'ytd_amount_used': 0,
},
'level': 0,
'model': 'hr.employee',
'res_id': emp.id,
})
return lines

View File

@@ -0,0 +1,232 @@
# -*- coding: utf-8 -*-
"""
Paycheque Reports
=================
- Paycheque History
- Payroll Details
"""
from odoo import api, fields, models, _
class PayrollReportPaychequeHistory(models.AbstractModel):
"""
Paycheque History Report
Shows all paycheques with pay date, employee, amounts, payment method.
"""
_name = 'payroll.report.paycheque.history'
_inherit = 'payroll.report'
_description = 'Paycheque History Report'
report_name = 'Paycheque History'
report_code = 'paycheque_history'
def _get_columns(self):
return [
{'name': _('Pay Date'), 'field': 'pay_date', 'type': 'date', 'sortable': True},
{'name': _('Name'), 'field': 'name', 'type': 'char', 'sortable': True},
{'name': _('Total Pay'), 'field': 'total_pay', 'type': 'monetary', 'sortable': True},
{'name': _('Net Pay'), 'field': 'net_pay', 'type': 'monetary', 'sortable': True},
{'name': _('Pay Method'), 'field': 'pay_method', 'type': 'char', 'sortable': True},
{'name': _('Cheque Number'), 'field': 'cheque_number', 'type': 'char', 'sortable': False},
{'name': _('Status'), 'field': 'status', 'type': 'char', 'sortable': True},
]
def _get_lines(self, options):
domain = self._get_domain(options)
domain.append(('state', 'in', ['done', 'paid']))
payslips = self.env['hr.payslip'].search(domain, order='date_to desc, employee_id')
lines = []
for slip in payslips:
if not slip.employee_id:
continue
pay_method = '-'
if hasattr(slip, 'paid_by') and slip.paid_by:
if 'paid_by' in slip._fields and hasattr(slip._fields['paid_by'], 'selection'):
pay_method = dict(slip._fields['paid_by'].selection).get(slip.paid_by, '-')
status = slip.state
if 'state' in slip._fields and hasattr(slip._fields['state'], 'selection'):
status = dict(slip._fields['state'].selection).get(slip.state, slip.state)
lines.append({
'id': f'payslip_{slip.id}',
'name': slip.employee_id.name or 'Unknown',
'values': {
'pay_date': slip.date_to or '',
'name': slip.employee_id.name or 'Unknown',
'total_pay': slip.gross_wage or 0,
'net_pay': slip.net_wage or 0,
'pay_method': pay_method,
'cheque_number': getattr(slip, 'cheque_number', None) or '-',
'status': status,
},
'level': 0,
'model': 'hr.payslip',
'res_id': slip.id,
})
# Add total
if lines:
lines.append(self._get_total_line(lines, options))
return lines
class PayrollReportPayrollDetails(models.AbstractModel):
"""
Payroll Details Report
Detailed breakdown per employee per pay date with Gross, Taxes, Net.
"""
_name = 'payroll.report.payroll.details'
_inherit = 'payroll.report'
_description = 'Payroll Details Report'
report_name = 'Payroll Details'
report_code = 'payroll_details'
def _get_columns(self):
return [
{'name': _('Pay Date'), 'field': 'pay_date', 'type': 'date', 'sortable': True},
{'name': _('Name'), 'field': 'name', 'type': 'char', 'sortable': True},
{'name': _('Hours'), 'field': 'hours', 'type': 'float', 'sortable': True},
{'name': _('Gross Pay'), 'field': 'gross_pay', 'type': 'monetary', 'sortable': True},
{'name': _('Other Pay'), 'field': 'other_pay', 'type': 'monetary', 'sortable': False},
{'name': _('Employee Taxes'), 'field': 'employee_taxes', 'type': 'monetary', 'sortable': True},
{'name': _('Net Pay'), 'field': 'net_pay', 'type': 'monetary', 'sortable': True},
]
def _get_lines(self, options):
domain = self._get_domain(options)
domain.append(('state', 'in', ['done', 'paid']))
payslips = self.env['hr.payslip'].search(domain, order='date_to desc, employee_id')
lines = []
# Group by pay date for totals
current_date = None
date_totals = {'gross_pay': 0, 'employee_taxes': 0, 'net_pay': 0, 'hours': 0}
# Calculate grand totals
grand_totals = {'gross_pay': 0, 'employee_taxes': 0, 'net_pay': 0, 'hours': 0}
for slip in payslips:
if not slip.employee_id:
continue
# Calculate worked hours
hours = 0
if hasattr(slip, 'worked_days_line_ids') and slip.worked_days_line_ids:
hours = sum(slip.worked_days_line_ids.mapped('number_of_hours')) or 0
# Calculate employee taxes
employee_taxes = getattr(slip, 'total_employee_deductions', 0) or 0
gross_wage = getattr(slip, 'gross_wage', 0) or 0
net_wage = getattr(slip, 'net_wage', 0) or 0
grand_totals['gross_pay'] += gross_wage
grand_totals['employee_taxes'] += employee_taxes
grand_totals['net_pay'] += net_wage
grand_totals['hours'] += hours
lines.append({
'id': f'detail_{slip.id}',
'name': slip.employee_id.name or 'Unknown',
'values': {
'pay_date': slip.date_to or '',
'name': slip.employee_id.name or 'Unknown',
'hours': hours,
'gross_pay': gross_wage,
'other_pay': 0, # Can be calculated from specific line types
'employee_taxes': employee_taxes,
'net_pay': net_wage,
},
'level': 0,
'model': 'hr.payslip',
'res_id': slip.id,
'unfoldable': True, # Can expand to show breakdown
})
# Add grand total
if lines:
lines.append({
'id': 'grand_total',
'name': _('Total'),
'values': {
'pay_date': '',
'name': _('Total'),
'hours': grand_totals['hours'],
'gross_pay': grand_totals['gross_pay'],
'other_pay': 0,
'employee_taxes': grand_totals['employee_taxes'],
'net_pay': grand_totals['net_pay'],
},
'level': -1,
'class': 'o_payroll_report_total fw-bold',
})
return lines
def _get_detail_lines(self, payslip_id, options):
"""Get expanded detail lines for a payslip."""
try:
payslip = self.env['hr.payslip'].browse(payslip_id)
if not payslip.exists():
return []
lines = []
if not hasattr(payslip, 'line_ids') or not payslip.line_ids:
return lines
# Gross breakdown
gross_lines = payslip.line_ids.filtered(
lambda l: hasattr(l, 'category_id') and l.category_id and
hasattr(l.category_id, 'code') and
l.category_id.code in ['BASIC', 'ALW', 'GROSS']
)
for line in gross_lines:
lines.append({
'id': f'line_{line.id}',
'name': line.name or '',
'values': {
'pay_date': '',
'name': f' {line.name or ""}',
'hours': line.quantity if hasattr(line, 'quantity') and line.quantity else '',
'gross_pay': line.total or 0,
'other_pay': '',
'employee_taxes': '',
'net_pay': '',
},
'level': 1,
'class': 'text-muted',
})
# Tax breakdown
tax_lines = payslip.line_ids.filtered(
lambda l: hasattr(l, 'code') and l.code in ['CPP', 'CPP2', 'EI', 'FED_TAX', 'PROV_TAX']
)
for line in tax_lines:
lines.append({
'id': f'tax_{line.id}',
'name': line.name or '',
'values': {
'pay_date': '',
'name': f' {line.name or ""}',
'hours': '',
'gross_pay': '',
'other_pay': '',
'employee_taxes': abs(line.total or 0),
'net_pay': '',
},
'level': 1,
'class': 'text-muted',
})
return lines
except Exception:
return []

View File

@@ -0,0 +1,282 @@
# -*- coding: utf-8 -*-
"""
Summary Reports
===============
- Payroll Summary
- Payroll Summary by Employee
"""
from collections import defaultdict
from odoo import api, fields, models, _
class PayrollReportSummary(models.AbstractModel):
"""
Payroll Summary Report
Per pay date summary with all payroll components.
"""
_name = 'payroll.report.summary'
_inherit = 'payroll.report'
_description = 'Payroll Summary Report'
report_name = 'Payroll Summary'
report_code = 'payroll_summary'
def _get_columns(self):
return [
{'name': _('Pay Date'), 'field': 'pay_date', 'type': 'date', 'sortable': True},
{'name': _('Name'), 'field': 'name', 'type': 'char', 'sortable': True},
{'name': _('Hours'), 'field': 'hours', 'type': 'float', 'sortable': True},
{'name': _('Gross Pay'), 'field': 'gross_pay', 'type': 'monetary', 'sortable': True},
{'name': _('Pretax Deductions'), 'field': 'pretax_deductions', 'type': 'monetary', 'sortable': False},
{'name': _('Other Pay'), 'field': 'other_pay', 'type': 'monetary', 'sortable': False},
{'name': _('Employee Taxes'), 'field': 'employee_taxes', 'type': 'monetary', 'sortable': True},
{'name': _('Aftertax Deductions'), 'field': 'aftertax_deductions', 'type': 'monetary', 'sortable': False},
{'name': _('Net Pay'), 'field': 'net_pay', 'type': 'monetary', 'sortable': True},
{'name': _('Employer Taxes'), 'field': 'employer_taxes', 'type': 'monetary', 'sortable': True},
{'name': _('Company Contributions'), 'field': 'company_contributions', 'type': 'monetary', 'sortable': False},
{'name': _('Total Payroll Cost'), 'field': 'total_cost', 'type': 'monetary', 'sortable': True},
]
def _get_lines(self, options):
domain = self._get_domain(options)
domain.append(('state', 'in', ['done', 'paid']))
payslips = self.env['hr.payslip'].search(domain, order='date_to desc, employee_id')
lines = []
totals = defaultdict(float)
for slip in payslips:
if not slip.employee_id:
continue
hours = 0
if hasattr(slip, 'worked_days_line_ids') and slip.worked_days_line_ids:
hours = sum(slip.worked_days_line_ids.mapped('number_of_hours')) or 0
employee_taxes = getattr(slip, 'total_employee_deductions', 0) or 0
employer_taxes = getattr(slip, 'total_employer_cost', 0) or 0
gross_wage = getattr(slip, 'gross_wage', 0) or 0
net_wage = getattr(slip, 'net_wage', 0) or 0
total_cost = gross_wage + employer_taxes
values = {
'pay_date': slip.date_to or '',
'name': slip.employee_id.name or 'Unknown',
'hours': hours,
'gross_pay': gross_wage,
'pretax_deductions': 0,
'other_pay': 0,
'employee_taxes': employee_taxes,
'aftertax_deductions': 0,
'net_pay': net_wage,
'employer_taxes': employer_taxes,
'company_contributions': 0,
'total_cost': total_cost,
}
# Accumulate totals
for key in ['hours', 'gross_pay', 'employee_taxes', 'net_pay', 'employer_taxes', 'total_cost']:
totals[key] += values[key]
lines.append({
'id': f'summary_{slip.id}',
'name': slip.employee_id.name or 'Unknown',
'values': values,
'level': 0,
'model': 'hr.payslip',
'res_id': slip.id,
})
# Total line
if lines:
lines.insert(0, {
'id': 'total',
'name': _('Total'),
'values': {
'pay_date': '',
'name': _('Total'),
'hours': totals['hours'],
'gross_pay': totals['gross_pay'],
'pretax_deductions': 0,
'other_pay': 0,
'employee_taxes': totals['employee_taxes'],
'aftertax_deductions': 0,
'net_pay': totals['net_pay'],
'employer_taxes': totals['employer_taxes'],
'company_contributions': 0,
'total_cost': totals['total_cost'],
},
'level': -1,
'class': 'o_payroll_report_total fw-bold bg-light',
})
return lines
class PayrollReportSummaryByEmployee(models.AbstractModel):
"""
Payroll Summary by Employee Report
Pivot-style with employees as columns, pay types as rows.
"""
_name = 'payroll.report.summary.by.employee'
_inherit = 'payroll.report'
_description = 'Payroll Summary by Employee Report'
report_name = 'Payroll Summary by Employee'
report_code = 'payroll_summary_employee'
def _get_columns(self):
# Dynamic columns based on employees in date range
# Base columns first
return [
{'name': _('Payroll'), 'field': 'payroll_item', 'type': 'char', 'sortable': False},
{'name': _('Total'), 'field': 'total', 'type': 'monetary', 'sortable': True},
# Employee columns will be added dynamically
]
def _get_dynamic_columns(self, options):
"""Get columns including employee names."""
date_from = options.get('date', {}).get('date_from')
date_to = options.get('date', {}).get('date_to')
domain = [
('state', 'in', ['done', 'paid']),
('company_id', '=', self.env.company.id),
]
if date_from:
domain.append(('date_from', '>=', date_from))
if date_to:
domain.append(('date_to', '<=', date_to))
payslips = self.env['hr.payslip'].search(domain)
employees = payslips.mapped('employee_id')
columns = [
{'name': _('Payroll'), 'field': 'payroll_item', 'type': 'char', 'sortable': False},
{'name': _('Total'), 'field': 'total', 'type': 'monetary', 'sortable': True},
]
for emp in employees.sorted('name'):
columns.append({
'name': emp.name,
'field': f'emp_{emp.id}',
'type': 'monetary',
'sortable': True,
})
return columns, employees
def _get_lines(self, options):
date_from = options.get('date', {}).get('date_from')
date_to = options.get('date', {}).get('date_to')
domain = [
('state', 'in', ['done', 'paid']),
('company_id', '=', self.env.company.id),
]
if date_from:
domain.append(('date_from', '>=', date_from))
if date_to:
domain.append(('date_to', '<=', date_to))
payslips = self.env['hr.payslip'].search(domain)
employees = payslips.mapped('employee_id').sorted('name')
# Initialize data structure
rows = {
'hours': {'name': _('Hours'), 'is_header': True, 'totals': defaultdict(float)},
'regular_pay_hrs': {'name': _('Regular Pay'), 'parent': 'hours', 'totals': defaultdict(float)},
'stat_holiday_hrs': {'name': _('Stat Holiday Pay'), 'parent': 'hours', 'totals': defaultdict(float)},
'gross_pay': {'name': _('Gross Pay'), 'is_header': True, 'totals': defaultdict(float)},
'regular_pay': {'name': _('Regular Pay'), 'parent': 'gross_pay', 'totals': defaultdict(float)},
'stat_holiday_pay': {'name': _('Stat Holiday Pay'), 'parent': 'gross_pay', 'totals': defaultdict(float)},
'vacation_pay': {'name': _('Vacation Pay'), 'parent': 'gross_pay', 'totals': defaultdict(float)},
'adjusted_gross': {'name': _('Adjusted Gross'), 'is_subtotal': True, 'totals': defaultdict(float)},
'employee_taxes': {'name': _('Employee Taxes & Deductions'), 'is_header': True, 'totals': defaultdict(float)},
'income_tax': {'name': _('Income Tax'), 'parent': 'employee_taxes', 'totals': defaultdict(float)},
'ei': {'name': _('Employment Insurance'), 'parent': 'employee_taxes', 'totals': defaultdict(float)},
'cpp': {'name': _('Canada Pension Plan'), 'parent': 'employee_taxes', 'totals': defaultdict(float)},
'net_pay': {'name': _('Net Pay'), 'is_header': True, 'totals': defaultdict(float)},
'employer_taxes': {'name': _('Employer Taxes & Contributions'), 'is_header': True, 'totals': defaultdict(float)},
'ei_employer': {'name': _('Employment Insurance Employer'), 'parent': 'employer_taxes', 'totals': defaultdict(float)},
'cpp_employer': {'name': _('Canada Pension Plan Employer'), 'parent': 'employer_taxes', 'totals': defaultdict(float)},
'total_cost': {'name': _('Total Payroll Cost'), 'is_total': True, 'totals': defaultdict(float)},
}
# Aggregate data by employee
for slip in payslips:
if not slip.employee_id:
continue
emp_key = f'emp_{slip.employee_id.id}'
# Hours
hours = 0
if hasattr(slip, 'worked_days_line_ids') and slip.worked_days_line_ids:
hours = sum(slip.worked_days_line_ids.mapped('number_of_hours')) or 0
rows['hours']['totals'][emp_key] += hours
rows['regular_pay_hrs']['totals'][emp_key] += hours
# Gross
gross_wage = getattr(slip, 'gross_wage', 0) or 0
rows['gross_pay']['totals'][emp_key] += gross_wage
rows['regular_pay']['totals'][emp_key] += gross_wage
rows['adjusted_gross']['totals'][emp_key] += gross_wage
# Employee taxes
rows['employee_taxes']['totals'][emp_key] += getattr(slip, 'total_employee_deductions', 0) or 0
rows['income_tax']['totals'][emp_key] += getattr(slip, 'employee_income_tax', 0) or 0
rows['ei']['totals'][emp_key] += getattr(slip, 'employee_ei', 0) or 0
rows['cpp']['totals'][emp_key] += getattr(slip, 'employee_cpp', 0) or 0
# Net
rows['net_pay']['totals'][emp_key] += getattr(slip, 'net_wage', 0) or 0
# Employer
total_employer_cost = getattr(slip, 'total_employer_cost', 0) or 0
rows['employer_taxes']['totals'][emp_key] += total_employer_cost
rows['ei_employer']['totals'][emp_key] += getattr(slip, 'employer_ei', 0) or 0
rows['cpp_employer']['totals'][emp_key] += getattr(slip, 'employer_cpp', 0) or 0
# Total cost
rows['total_cost']['totals'][emp_key] += gross_wage + total_employer_cost
# Build lines
lines = []
for row_key, row_data in rows.items():
values = {'payroll_item': row_data['name']}
# Calculate total
total = sum(row_data['totals'].values())
values['total'] = total
# Add employee columns
for emp in employees:
emp_field = f'emp_{emp.id}'
values[emp_field] = row_data['totals'].get(emp_field, 0)
level = 0
css_class = ''
if row_data.get('is_header'):
level = -1
css_class = 'fw-bold'
elif row_data.get('parent'):
level = 1
values['payroll_item'] = f" {row_data['name']}"
elif row_data.get('is_subtotal'):
css_class = 'fw-bold'
elif row_data.get('is_total'):
level = -1
css_class = 'fw-bold bg-primary text-white'
lines.append({
'id': row_key,
'name': row_data['name'],
'values': values,
'level': level,
'class': css_class,
})
return lines

View File

@@ -0,0 +1,350 @@
# -*- coding: utf-8 -*-
"""
Tax Reports
===========
- Payroll Tax Liability
- Payroll Tax Payments
- Payroll Tax and Wage Summary
"""
from odoo import api, fields, models, _
class PayrollReportTaxLiability(models.AbstractModel):
"""
Payroll Tax Liability Report
Shows tax amounts owed vs paid.
"""
_name = 'payroll.report.tax.liability'
_inherit = 'payroll.report'
_description = 'Payroll Tax Liability Report'
report_name = 'Payroll Tax Liability'
report_code = 'tax_liability'
filter_employee = False
def _get_columns(self):
return [
{'name': _('Tax Type'), 'field': 'tax_type', 'type': 'char', 'sortable': True},
{'name': _('Tax Amount'), 'field': 'tax_amount', 'type': 'monetary', 'sortable': True},
{'name': _('Tax Paid'), 'field': 'tax_paid', 'type': 'monetary', 'sortable': True},
{'name': _('Tax Owed'), 'field': 'tax_owed', 'type': 'monetary', 'sortable': True},
]
def _get_lines(self, options):
date_from = options.get('date', {}).get('date_from')
date_to = options.get('date', {}).get('date_to')
# Build domain for payslips
domain = [
('state', 'in', ['done', 'paid']),
('company_id', '=', self.env.company.id),
]
if date_from:
domain.append(('date_from', '>=', date_from))
if date_to:
domain.append(('date_to', '<=', date_to))
payslips = self.env['hr.payslip'].search(domain)
# Calculate totals by tax type
tax_totals = {
'income_tax': {'name': _('Income Tax'), 'amount': 0, 'codes': ['FED_TAX', 'PROV_TAX']},
'ei_employee': {'name': _('Employment Insurance'), 'amount': 0, 'codes': ['EI']},
'ei_employer': {'name': _('Employment Insurance Employer'), 'amount': 0, 'codes': ['EI_ER']},
'cpp_employee': {'name': _('Canada Pension Plan'), 'amount': 0, 'codes': ['CPP']},
'cpp_employer': {'name': _('Canada Pension Plan Employer'), 'amount': 0, 'codes': ['CPP_ER']},
'cpp2_employee': {'name': _('Second Canada Pension Plan'), 'amount': 0, 'codes': ['CPP2']},
'cpp2_employer': {'name': _('Second Canada Pension Plan Employer'), 'amount': 0, 'codes': ['CPP2_ER']},
}
for slip in payslips:
if not hasattr(slip, 'line_ids') or not slip.line_ids:
continue
for line in slip.line_ids:
if not hasattr(line, 'code') or not line.code:
continue
for key, data in tax_totals.items():
if line.code in data['codes']:
tax_totals[key]['amount'] += abs(line.total or 0)
# Get paid amounts from remittances
remittance_domain = [
('state', '=', 'paid'),
('company_id', '=', self.env.company.id),
]
if date_from:
remittance_domain.append(('period_start', '>=', date_from))
if date_to:
remittance_domain.append(('period_end', '<=', date_to))
remittances = self.env['hr.tax.remittance'].search(remittance_domain)
# Safely get remittance fields
def safe_sum(records, field_name):
if not records:
return 0
try:
return sum(records.mapped(field_name)) or 0
except:
return 0
paid_totals = {
'income_tax': safe_sum(remittances, 'income_tax'),
'ei_employee': safe_sum(remittances, 'ei_employee'),
'ei_employer': safe_sum(remittances, 'ei_employer'),
'cpp_employee': safe_sum(remittances, 'cpp_employee'),
'cpp2_employee': safe_sum(remittances, 'cpp2_employee'),
'cpp_employer': safe_sum(remittances, 'cpp_employer'),
'cpp2_employer': safe_sum(remittances, 'cpp2_employer'),
}
lines = []
grand_total = {'amount': 0, 'paid': 0, 'owed': 0}
# Federal Taxes header
lines.append({
'id': 'federal_header',
'name': _('Federal Taxes'),
'values': {
'tax_type': _('Federal Taxes'),
'tax_amount': '',
'tax_paid': '',
'tax_owed': '',
},
'level': -1,
'class': 'fw-bold',
})
for key, data in tax_totals.items():
paid = paid_totals.get(key, 0)
owed = data['amount'] - paid
grand_total['amount'] += data['amount']
grand_total['paid'] += paid
grand_total['owed'] += owed
lines.append({
'id': f'tax_{key}',
'name': data['name'],
'values': {
'tax_type': f" {data['name']}",
'tax_amount': data['amount'],
'tax_paid': paid,
'tax_owed': owed,
},
'level': 0,
})
# Grand total
lines.append({
'id': 'grand_total',
'name': _('Total'),
'values': {
'tax_type': _('Total'),
'tax_amount': grand_total['amount'],
'tax_paid': grand_total['paid'],
'tax_owed': grand_total['owed'],
},
'level': -1,
'class': 'o_payroll_report_total fw-bold',
})
return lines
class PayrollReportTaxPayments(models.AbstractModel):
"""
Payroll Tax Payments Report
Shows history of tax remittance payments.
"""
_name = 'payroll.report.tax.payments'
_inherit = 'payroll.report'
_description = 'Payroll Tax Payments Report'
report_name = 'Payroll Tax Payments'
report_code = 'tax_payments'
filter_employee = False
def _get_columns(self):
return [
{'name': _('Payment Date'), 'field': 'payment_date', 'type': 'date', 'sortable': True},
{'name': _('Tax Type'), 'field': 'tax_type', 'type': 'char', 'sortable': True},
{'name': _('Amount'), 'field': 'amount', 'type': 'monetary', 'sortable': True},
{'name': _('Payment Method'), 'field': 'payment_method', 'type': 'char', 'sortable': False},
{'name': _('Notes'), 'field': 'notes', 'type': 'char', 'sortable': False},
]
def _get_lines(self, options):
date_from = options.get('date', {}).get('date_from')
date_to = options.get('date', {}).get('date_to')
domain = [
('state', '=', 'paid'),
('company_id', '=', self.env.company.id),
]
if date_from:
domain.append(('payment_date', '>=', date_from))
if date_to:
domain.append(('payment_date', '<=', date_to))
remittances = self.env['hr.tax.remittance'].search(domain, order='payment_date desc')
lines = []
for rem in remittances:
period_start = getattr(rem, 'period_start', '') or ''
period_end = getattr(rem, 'period_end', '') or ''
period_str = f"{period_start} - {period_end}" if period_start and period_end else ''
lines.append({
'id': f'remit_{rem.id}',
'name': rem.name or 'Unknown',
'values': {
'payment_date': getattr(rem, 'payment_date', '') or '',
'tax_type': f"Federal Taxes\n{period_str}" if period_str else 'Federal Taxes',
'amount': getattr(rem, 'total', 0) or 0,
'payment_method': getattr(rem, 'payment_method', None) or 'Manual',
'notes': getattr(rem, 'payment_reference', None) or '',
},
'level': 0,
'model': 'hr.tax.remittance',
'res_id': rem.id,
})
return lines
class PayrollReportTaxWageSummary(models.AbstractModel):
"""
Payroll Tax and Wage Summary Report
Shows total wages, excess wages, taxable wages, and tax amounts.
"""
_name = 'payroll.report.tax.wage.summary'
_inherit = 'payroll.report'
_description = 'Payroll Tax and Wage Summary Report'
report_name = 'Payroll Tax and Wage Summary'
report_code = 'tax_wage_summary'
filter_employee = False
def _get_columns(self):
return [
{'name': _('Tax Type'), 'field': 'tax_type', 'type': 'char', 'sortable': True},
{'name': _('Total Wages'), 'field': 'total_wages', 'type': 'monetary', 'sortable': True},
{'name': _('Excess Wages'), 'field': 'excess_wages', 'type': 'monetary', 'sortable': True},
{'name': _('Taxable Wages'), 'field': 'taxable_wages', 'type': 'monetary', 'sortable': True},
{'name': _('Tax Amount'), 'field': 'tax_amount', 'type': 'monetary', 'sortable': True},
]
def _get_lines(self, options):
date_from = options.get('date', {}).get('date_from')
date_to = options.get('date', {}).get('date_to')
domain = [
('state', 'in', ['done', 'paid']),
('company_id', '=', self.env.company.id),
]
if date_from:
domain.append(('date_from', '>=', date_from))
if date_to:
domain.append(('date_to', '<=', date_to))
payslips = self.env['hr.payslip'].search(domain)
# Calculate totals - safely handle missing gross_wage field
total_wages = 0
for slip in payslips:
total_wages += getattr(slip, 'gross_wage', 0) or 0
# Tax calculations
tax_data = [
{
'name': _('Income Tax'),
'codes': ['FED_TAX', 'PROV_TAX'],
'total_wages': total_wages,
'excess_wages': 0, # No excess for income tax
},
{
'name': _('Employment Insurance'),
'codes': ['EI'],
'total_wages': total_wages,
'excess_wages': 0, # Would need to calculate based on max
},
{
'name': _('Employment Insurance Employer'),
'codes': ['EI_ER'],
'total_wages': total_wages,
'excess_wages': 0,
},
{
'name': _('Canada Pension Plan'),
'codes': ['CPP'],
'total_wages': total_wages,
'excess_wages': 0,
},
{
'name': _('Canada Pension Plan Employer'),
'codes': ['CPP_ER'],
'total_wages': total_wages,
'excess_wages': 0,
},
{
'name': _('Second Canada Pension Plan'),
'codes': ['CPP2'],
'total_wages': total_wages,
'excess_wages': 0,
},
{
'name': _('Second Canada Pension Plan Employer'),
'codes': ['CPP2_ER'],
'total_wages': total_wages,
'excess_wages': 0,
},
]
lines = []
# Federal header
lines.append({
'id': 'federal_header',
'name': _('Federal Taxes'),
'values': {
'tax_type': _('Federal Taxes'),
'total_wages': '',
'excess_wages': '',
'taxable_wages': '',
'tax_amount': '',
},
'level': -1,
'class': 'fw-bold',
})
grand_tax = 0
for data in tax_data:
tax_amount = 0
for slip in payslips:
if not hasattr(slip, 'line_ids') or not slip.line_ids:
continue
for line in slip.line_ids:
if not hasattr(line, 'code') or not line.code:
continue
if line.code in data['codes']:
tax_amount += abs(line.total or 0)
taxable = data['total_wages'] - data['excess_wages']
grand_tax += tax_amount
lines.append({
'id': f"tax_{data['name']}",
'name': data['name'],
'values': {
'tax_type': f" {data['name']}",
'total_wages': data['total_wages'],
'excess_wages': data['excess_wages'],
'taxable_wages': taxable,
'tax_amount': tax_amount,
},
'level': 0,
})
return lines

View File

@@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from datetime import date
class PayrollTaxPaymentSchedule(models.Model):
"""
Tax Payment Schedule
Date-effective payment schedules for provincial taxes.
"""
_name = 'payroll.tax.payment.schedule'
_description = 'Tax Payment Schedule'
_order = 'province, effective_date desc'
config_id = fields.Many2one(
'payroll.config.settings',
string='Payroll Settings',
required=True,
ondelete='cascade',
)
company_id = fields.Many2one(
related='config_id.company_id',
string='Company',
store=True,
)
province = fields.Selection([
('AB', 'Alberta'),
('BC', 'British Columbia'),
('MB', 'Manitoba'),
('NB', 'New Brunswick'),
('NL', 'Newfoundland and Labrador'),
('NS', 'Nova Scotia'),
('NT', 'Northwest Territories'),
('NU', 'Nunavut'),
('ON', 'Ontario'),
('PE', 'Prince Edward Island'),
('QC', 'Quebec'),
('SK', 'Saskatchewan'),
('YT', 'Yukon'),
], string='Province', required=True)
payment_frequency = fields.Selection([
('monthly', 'Monthly'),
('quarterly', 'Quarterly'),
('annually', 'Annually'),
], string='Payment Frequency', required=True, default='quarterly')
effective_date = fields.Date(
string='Effective Date',
required=True,
help='Date when this payment schedule becomes effective',
)
form_type = fields.Char(
string='Form Type',
help='Tax form type (e.g., Form PD7A)',
)
is_current = fields.Boolean(
string='Current Schedule',
compute='_compute_is_current',
store=True,
help='True if this is the currently active schedule',
)
display_name = fields.Char(
string='Display Name',
compute='_compute_display_name',
)
@api.depends('payment_frequency', 'effective_date', 'is_current')
def _compute_display_name(self):
"""Compute display name for the schedule."""
for schedule in self:
freq_map = {
'monthly': 'Monthly',
'quarterly': 'Quarterly',
'annually': 'Annually',
}
freq = freq_map.get(schedule.payment_frequency, schedule.payment_frequency)
date_str = schedule.effective_date.strftime('%m/%d/%Y') if schedule.effective_date else ''
current = ' (current schedule)' if schedule.is_current else ''
schedule.display_name = f"{freq} since {date_str}{current}"
@api.depends('effective_date', 'province', 'config_id.provincial_tax_schedule_ids')
def _compute_is_current(self):
"""Determine if this is the current active schedule."""
today = date.today()
for schedule in self:
# Get all schedules for this province, ordered by effective_date desc
all_schedules = self.search([
('config_id', '=', schedule.config_id.id),
('province', '=', schedule.province),
], order='effective_date desc')
# The current schedule is the one with the most recent effective_date <= today
current_schedule = None
for sched in all_schedules:
if sched.effective_date <= today:
current_schedule = sched
break
schedule.is_current = (current_schedule and current_schedule.id == schedule.id)
@api.model
def get_current_schedule(self, config_id, province, check_date=None):
"""Get the current active schedule for a province."""
if not check_date:
check_date = date.today()
schedule = self.search([
('config_id', '=', config_id),
('province', '=', province),
('effective_date', '<=', check_date),
], order='effective_date desc', limit=1)
return schedule
@api.constrains('effective_date', 'province', 'config_id')
def _check_overlapping_schedules(self):
"""Warn if schedules overlap (but allow for historical changes)."""
for schedule in self:
# Allow multiple schedules, but warn if dates are very close
overlapping = self.search([
('config_id', '=', schedule.config_id.id),
('province', '=', schedule.province),
('id', '!=', schedule.id),
('effective_date', '=', schedule.effective_date),
])
if overlapping:
raise UserError(_(
'A payment schedule for %s already exists with effective date %s. '
'Please use a different date.'
) % (schedule.province, schedule.effective_date.strftime('%m/%d/%Y')))

View File

@@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api, _
class PayrollWorkLocation(models.Model):
"""
Work Location
Represents a physical work location where employees work.
"""
_name = 'payroll.work.location'
_description = 'Work Location'
_order = 'is_primary desc, name'
name = fields.Char(
string='Location Name',
help='Name or identifier for this work location',
)
company_id = fields.Many2one(
'res.company',
string='Company',
required=True,
default=lambda self: self.env.company,
ondelete='cascade',
)
street = fields.Char(
string='Street Address',
)
street2 = fields.Char(
string='Street Address 2',
)
city = fields.Char(
string='City',
)
state_id = fields.Many2one(
'res.country.state',
string='Province',
domain="[('country_id', '=?', country_id)]",
)
zip = fields.Char(
string='Postal Code',
)
country_id = fields.Many2one(
'res.country',
string='Country',
default=lambda self: self.env.ref('base.ca', raise_if_not_found=False),
)
is_primary = fields.Boolean(
string='Primary Location',
default=False,
help='Mark this as the primary work location',
)
status = fields.Selection([
('active', 'Active'),
('inactive', 'Inactive'),
], string='Status', default='active', required=True)
employee_ids = fields.Many2many(
'hr.employee',
'payroll_work_location_employee_rel',
'location_id',
'employee_id',
string='Employees',
help='Employees assigned to this work location',
)
employee_count = fields.Integer(
string='Employees Assigned',
compute='_compute_employee_count',
store=True,
)
@api.depends('employee_ids')
def _compute_employee_count(self):
"""Compute number of employees assigned to this location."""
for location in self:
location.employee_count = len(location.employee_ids)
@api.constrains('is_primary')
def _check_primary_location(self):
"""Ensure only one primary location per company."""
for location in self:
if location.is_primary:
other_primary = self.search([
('company_id', '=', location.company_id.id),
('is_primary', '=', True),
('id', '!=', location.id),
])
if other_primary:
raise UserError(_('Only one primary location is allowed per company.'))
def name_get(self):
"""Return display name with address."""
result = []
for location in self:
name = location.name or _('Unnamed Location')
if location.city:
name = f"{name}, {location.city}"
if location.state_id:
name = f"{name}, {location.state_id.code}"
if location.is_primary:
name = f"{name} ({_('PRIMARY')})"
result.append((location.id, name))
return result

Some files were not shown because too many files have changed in this diff Show More