547 lines
22 KiB
Python
547 lines
22 KiB
Python
# -*- 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
|