# -*- coding: utf-8 -*- from odoo import api, fields, models from odoo.exceptions import UserError, ValidationError class Payslip(models.Model): _name = 'c2c.payslip' _description = 'Employee Payslip' _inherit = ['mail.thread', 'mail.activity.mixin'] _order = 'date_from desc, id desc' _rec_name = 'display_name' # ------------------------------------------------------------------ # Core fields # ------------------------------------------------------------------ employee_id = fields.Many2one( 'hr.employee', string='Employee', required=True, tracking=True, ) contract_id = fields.Many2one( 'hr.contract', string='Contract', required=True, tracking=True, domain="[('employee_id', '=', employee_id), ('state', '=', 'open')]", ) date_from = fields.Date( string='Period From', required=True, ) date_to = fields.Date( string='Period To', required=True, ) company_id = fields.Many2one( 'res.company', string='Company', required=True, default=lambda self: self.env.company, ) # ------------------------------------------------------------------ # Earnings (computed & stored) # ------------------------------------------------------------------ gross_salary = fields.Float( string='Gross Salary', compute='_compute_salary', store=True, ) basic = fields.Float( string='Basic', compute='_compute_salary', store=True, ) hra = fields.Float( string='HRA', compute='_compute_salary', store=True, ) allowances = fields.Float( string='Allowances', compute='_compute_salary', store=True, ) # ------------------------------------------------------------------ # Deductions (computed & stored) # ------------------------------------------------------------------ pf_deduction = fields.Float( string='PF Deduction', compute='_compute_salary', store=True, ) esi_deduction = fields.Float( string='ESI Deduction', compute='_compute_salary', store=True, ) professional_tax = fields.Float( string='Professional Tax', compute='_compute_salary', store=True, ) total_deductions = fields.Float( string='Total Deductions', compute='_compute_salary', store=True, ) # ------------------------------------------------------------------ # Net # ------------------------------------------------------------------ net_salary = fields.Float( string='Net Salary', compute='_compute_salary', store=True, ) # ------------------------------------------------------------------ # State & Accounting # ------------------------------------------------------------------ state = fields.Selection([ ('draft', 'Draft'), ('confirmed', 'Confirmed'), ('paid', 'Paid'), ], string='Status', default='draft', tracking=True, copy=False) journal_entry_id = fields.Many2one( 'account.move', string='Journal Entry', readonly=True, copy=False, ) journal_entry_count = fields.Integer( compute='_compute_journal_entry_count', ) # ------------------------------------------------------------------ # Display name # ------------------------------------------------------------------ display_name = fields.Char(compute='_compute_display_name', store=True) @api.depends('employee_id', 'date_from', 'date_to') def _compute_display_name(self): for rec in self: emp_name = rec.employee_id.name or 'New' date_from = rec.date_from or '' date_to = rec.date_to or '' rec.display_name = '%s (%s - %s)' % (emp_name, date_from, date_to) # ------------------------------------------------------------------ # Computed salary # ------------------------------------------------------------------ @api.depends( 'contract_id', 'contract_id.gross_salary', 'contract_id.salary_structure_id', 'contract_id.salary_structure_id.basic_percentage', 'contract_id.salary_structure_id.hra_percentage', 'contract_id.salary_structure_id.allowance_percentage', 'contract_id.salary_structure_id.pf_percentage', 'contract_id.salary_structure_id.esi_percentage', 'contract_id.salary_structure_id.professional_tax_fixed', 'contract_id.pf_applicable', 'contract_id.esi_applicable', ) def _compute_salary(self): for rec in self: contract = rec.contract_id structure = contract.salary_structure_id if contract else False if not contract or not structure: rec.gross_salary = 0.0 rec.basic = 0.0 rec.hra = 0.0 rec.allowances = 0.0 rec.pf_deduction = 0.0 rec.esi_deduction = 0.0 rec.professional_tax = 0.0 rec.total_deductions = 0.0 rec.net_salary = 0.0 continue gross = contract.gross_salary rec.gross_salary = gross # Earnings rec.basic = gross * structure.basic_percentage / 100.0 rec.hra = gross * structure.hra_percentage / 100.0 rec.allowances = gross * structure.allowance_percentage / 100.0 # Deductions pf = (rec.basic * structure.pf_percentage / 100.0) if contract.pf_applicable else 0.0 esi = (gross * structure.esi_percentage / 100.0) if contract.esi_applicable else 0.0 pt = structure.professional_tax_fixed rec.pf_deduction = pf rec.esi_deduction = esi rec.professional_tax = pt rec.total_deductions = pf + esi + pt rec.net_salary = gross - rec.total_deductions # ------------------------------------------------------------------ # Journal entry count # ------------------------------------------------------------------ @api.depends('journal_entry_id') def _compute_journal_entry_count(self): for rec in self: rec.journal_entry_count = 1 if rec.journal_entry_id else 0 # ------------------------------------------------------------------ # Onchange helpers # ------------------------------------------------------------------ @api.onchange('employee_id') def _onchange_employee_id(self): """Auto-fill contract when employee changes.""" if self.employee_id: contract = self.env['hr.contract'].search([ ('employee_id', '=', self.employee_id.id), ('state', '=', 'open'), ('company_id', '=', self.env.company.id), ], limit=1) self.contract_id = contract else: self.contract_id = False # ------------------------------------------------------------------ # Constraints # ------------------------------------------------------------------ @api.constrains('date_from', 'date_to') def _check_dates(self): for rec in self: if rec.date_from and rec.date_to and rec.date_from > rec.date_to: raise ValidationError('Period From cannot be after Period To.') # ------------------------------------------------------------------ # Actions # ------------------------------------------------------------------ def action_confirm(self): """Confirm the payslip and create an accounting journal entry.""" for rec in self: if rec.state != 'draft': raise UserError('Only draft payslips can be confirmed.') if not rec.contract_id or not rec.contract_id.salary_structure_id: raise UserError( 'Please set a salary structure on the contract before confirming.' ) if rec.net_salary <= 0: raise UserError('Net salary must be greater than zero to confirm.') # Get accounts from system parameters (company-specific) ICP = self.env['ir.config_parameter'].sudo() expense_account_id = int( ICP.get_param('c2c_payroll.salary_expense_account_id', default=0) ) payable_account_id = int( ICP.get_param('c2c_payroll.salary_payable_account_id', default=0) ) if not expense_account_id or not payable_account_id: raise UserError( 'Please configure Salary Expense and Payable accounts in ' 'Settings → Technical → Parameters → System Parameters.\n\n' 'Keys:\n' ' • c2c_payroll.salary_expense_account_id\n' ' • c2c_payroll.salary_payable_account_id' ) # Validate accounts exist expense_account = self.env['account.account'].browse(expense_account_id) payable_account = self.env['account.account'].browse(payable_account_id) if not expense_account.exists() or not payable_account.exists(): raise UserError( 'Configured salary accounts do not exist. ' 'Please verify the system parameter values.' ) journal = self.env['account.journal'].search([ ('type', '=', 'general'), ('company_id', '=', rec.company_id.id), ], limit=1) if not journal: raise UserError( 'No general journal found for company %s.' % rec.company_id.name ) move_vals = { 'journal_id': journal.id, 'date': rec.date_to, 'ref': 'Payslip: %s' % rec.display_name, 'company_id': rec.company_id.id, 'line_ids': [ (0, 0, { 'name': 'Salary Expense - %s' % rec.employee_id.name, 'account_id': expense_account_id, 'debit': rec.net_salary, 'credit': 0.0, }), (0, 0, { 'name': 'Employee Payable - %s' % rec.employee_id.name, 'account_id': payable_account_id, 'debit': 0.0, 'credit': rec.net_salary, }), ], } move = self.env['account.move'].create(move_vals) rec.write({ 'state': 'confirmed', 'journal_entry_id': move.id, }) def action_mark_paid(self): """Mark confirming payslip as paid.""" for rec in self: if rec.state != 'confirmed': raise UserError('Only confirmed payslips can be marked as paid.') rec.write({'state': 'paid'}) def action_reset_to_draft(self): """Reset to draft (deletes journal entry if unposted).""" for rec in self: if rec.state == 'paid': raise UserError('Paid payslips cannot be reset to draft.') if rec.journal_entry_id: if rec.journal_entry_id.state == 'posted': raise UserError( 'Cannot reset: the journal entry is already posted. ' 'Please cancel it first.' ) rec.journal_entry_id.unlink() rec.write({'state': 'draft', 'journal_entry_id': False}) def action_open_journal_entry(self): """Smart button to open the linked journal entry.""" self.ensure_one() if not self.journal_entry_id: raise UserError('No journal entry linked to this payslip.') return { 'type': 'ir.actions.act_window', 'name': 'Journal Entry', 'res_model': 'account.move', 'res_id': self.journal_entry_id.id, 'view_mode': 'form', 'target': 'current', }