changes
@@ -1,5 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
from . import wizards
|
||||
from . import controllers
|
||||
@@ -1,129 +0,0 @@
|
||||
# -*- 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
|
||||
],
|
||||
'external_dependencies': {
|
||||
'python': ['pycryptodome'],
|
||||
},
|
||||
'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,
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import payroll_report
|
||||
@@ -1,249 +0,0 @@
|
||||
# -*- 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()}
|
||||
@@ -1,135 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,60 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,222 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,358 +0,0 @@
|
||||
<?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 <= 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 <= 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 <= 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 <= 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>
|
||||
@@ -1,134 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,324 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 330 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 324 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 268 KiB |
|
Before Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 362 KiB |
|
Before Width: | Height: | Size: 693 KiB |
|
Before Width: | Height: | Size: 111 KiB |
@@ -1,188 +0,0 @@
|
||||
# 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
|
||||
@@ -1,444 +0,0 @@
|
||||
# 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
|
||||
@@ -1,113 +0,0 @@
|
||||
# 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
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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
|
||||
@@ -1,26 +0,0 @@
|
||||
# -*- 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
|
||||
@@ -1,506 +0,0 @@
|
||||
# -*- 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.'))
|
||||
@@ -1,25 +0,0 @@
|
||||
# -*- 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',
|
||||
)
|
||||
@@ -1,468 +0,0 @@
|
||||
# -*- 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 '',
|
||||
},
|
||||
}
|
||||
@@ -1,704 +0,0 @@
|
||||
# -*- 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',
|
||||
)
|
||||
@@ -1,380 +0,0 @@
|
||||
# -*- 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
|
||||
@@ -1,528 +0,0 @@
|
||||
# -*- 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',
|
||||
)
|
||||
@@ -1,89 +0,0 @@
|
||||
# -*- 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',
|
||||
)
|
||||
@@ -1,306 +0,0 @@
|
||||
# -*- 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
|
||||
@@ -1,387 +0,0 @@
|
||||
# -*- 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',
|
||||
},
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
# -*- 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.'),
|
||||
]
|
||||
@@ -1,614 +0,0 @@
|
||||
# -*- 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
|
||||
@@ -1,546 +0,0 @@
|
||||
# -*- 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
|
||||
@@ -1,379 +0,0 @@
|
||||
# -*- 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
|
||||
@@ -1,485 +0,0 @@
|
||||
# -*- 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'}
|
||||
@@ -1,465 +0,0 @@
|
||||
# -*- 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,
|
||||
},
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
# -*- 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
|
||||
@@ -1,178 +0,0 @@
|
||||
# -*- 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
|
||||
@@ -1,232 +0,0 @@
|
||||
# -*- 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 []
|
||||
@@ -1,282 +0,0 @@
|
||||
# -*- 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
|
||||
@@ -1,350 +0,0 @@
|
||||
# -*- 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
|
||||
@@ -1,131 +0,0 @@
|
||||
# -*- 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')))
|
||||
@@ -1,103 +0,0 @@
|
||||
# -*- 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
|
||||