diff --git a/hr_payroll_payment/README.md b/hr_payroll_payment/README.md new file mode 100644 index 00000000..b35dfe73 --- /dev/null +++ b/hr_payroll_payment/README.md @@ -0,0 +1,27 @@ +Pay your Payroll +================ + +Hibou's Payroll Payments modifies, and abstracts, the way that the accounting for payslips is generated. + +In stock Odoo 13, journal entries are grouped by account and name, but has no linking to partners. + +On the Payroll Journal, you can now select optional journal entry creation with the options: + +- Original: Stock Implementation +- Grouped: Lines are grouped by account and partner. The slip line names will be comma separated in the line name. +- Payslip: Lines are grouped by account and partner, as above, but a single journal entry will be created per payslip. + +Adds configuration on how you would pay your employees on the Payroll journal. e.g. You write a "check" from "Bank" + +Adds button on payslip and payslip batch to generate payment for the employee's payable portion. + +When paying on a batch, a "Batch Payment" will be generated and linked to the whole payslip run. + +Adds Accounting Date field on Batch to use when creating slips with the batch's date. + +Adds fiscal position mappings to set a fiscal position on the contract and have payslips map their accounts. + +Tested +------ + +Passes original Payroll Accounting tests and additional ones for gouping behavior. \ No newline at end of file diff --git a/hr_payroll_payment/__init__.py b/hr_payroll_payment/__init__.py new file mode 100755 index 00000000..c7120225 --- /dev/null +++ b/hr_payroll_payment/__init__.py @@ -0,0 +1,4 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import models +from . import wizard diff --git a/hr_payroll_payment/__manifest__.py b/hr_payroll_payment/__manifest__.py new file mode 100755 index 00000000..ec89ba7d --- /dev/null +++ b/hr_payroll_payment/__manifest__.py @@ -0,0 +1,52 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +{ + 'name': 'Payroll Payments', + 'author': 'Hibou Corp. ', + 'version': '15.0.1.0.0', + 'category': 'Human Resources', + 'sequence': 95, + 'summary': 'Register payments for Payroll Payslips', + 'description': """ +Pay your Payroll +================ + +Hibou's Payroll Payments modifies, and abstracts, the way that the accounting for payslips is generated. + +In stock Odoo 15, journal entries are grouped by account and name, but has no linking to partners. + +On the Payroll Journal, you can now select optional journal entry creation with the options: + +- Original: Stock Implementation +- Grouped: Lines are grouped by account and partner. The slip line names will be comma separated in the line name. +- Payslip: Lines are grouped by account and partner, as above, but a single journal entry will be created per payslip. + +Adds configuration on how you would pay your employees on the Payroll journal. e.g. You write a "check" from "Bank" + +Adds button on payslip and payslip batch to generate payment for the employee's payable portion. + +When paying on a batch, a "Batch Payment" will be generated and linked to the whole payslip run. + +Adds Accounting Date field on Batch to use when creating slips with the batch's date. + +Adds fiscal position mappings to set a fiscal position on the contract and have payslips map their accounts. + +Tested +------ + +Passes original Payroll Accounting tests and additional ones for gouping behavior. + """, + 'website': 'https://hibou.io/', + 'depends': [ + 'hr_payroll_account', + 'account_batch_payment', + 'hibou_professional', + ], + 'data': [ + 'views/account_views.xml', + 'views/hr_payslip_views.xml', + ], + 'installable': True, + 'application': False, + 'license': 'OPL-1', +} diff --git a/hr_payroll_payment/models/__init__.py b/hr_payroll_payment/models/__init__.py new file mode 100644 index 00000000..335a4142 --- /dev/null +++ b/hr_payroll_payment/models/__init__.py @@ -0,0 +1,5 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import account +from . import hr_payslip +from . import hr_payslip_patch diff --git a/hr_payroll_payment/models/account.py b/hr_payroll_payment/models/account.py new file mode 100644 index 00000000..d37b34c5 --- /dev/null +++ b/hr_payroll_payment/models/account.py @@ -0,0 +1,19 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import fields, models + + +class AccountJournal(models.Model): + _inherit = 'account.journal' + + payroll_entry_type = fields.Selection([ + ('original', 'Original'), + ('grouped', 'Grouped'), + ('slip', 'Payslip'), + ], string='Payroll Entry Type', + default='grouped', + help="Grouped and Payslip will add partner info and group by account and partner. " + "Payslip will generate a journal entry for every payslip.") + payroll_payment_journal_id = fields.Many2one('account.journal', string='Payroll Payment Journal') + payroll_payment_method_id = fields.Many2one('account.payment.method', string='Payroll Payment Method') + payroll_payment_method_refund_id = fields.Many2one('account.payment.method', string='Payroll Refund Method') diff --git a/hr_payroll_payment/models/hr_payslip.py b/hr_payroll_payment/models/hr_payslip.py new file mode 100644 index 00000000..91d9a2d5 --- /dev/null +++ b/hr_payroll_payment/models/hr_payslip.py @@ -0,0 +1,593 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError, ValidationError +from odoo.tools import float_compare, float_is_zero + + +class HrContract(models.Model): + _inherit = 'hr.contract' + + payroll_fiscal_position_id = fields.Many2one('account.fiscal.position', 'Payroll Fiscal Position', domain="[('company_id', '=', company_id)]", + help="Used for mapping accounts when processing payslip journal entries.") + + +class HrPayslipRun(models.Model): + _inherit = 'hr.payslip.run' + + @api.depends('slip_ids.is_paid') + def _is_paid(self): + for run in self: + run.is_paid = all(run.slip_ids.mapped('is_paid')) + + is_paid = fields.Boolean(string="Payslips Paid", compute='_is_paid', store=True) + date = fields.Date('Date Account', states={'draft': [('readonly', False)], 'verify': [('readonly', False)]}, + readonly=True, + help="Keep empty to use the period of the validation(Payslip) date.") + batch_payment_id = fields.Many2one('account.batch.payment', string='Payment Batch') + + def action_register_payment(self): + action = self.mapped('slip_ids').action_register_payment() + payments = self.env['account.payment'].browse(action['res_ids']) + batch_action = payments.create_batch_payment() + self.write({'batch_payment_id': batch_action['res_id']}) + return batch_action + + def write(self, values): + if 'date' in values: + slips = self.mapped('slip_ids').filtered(lambda s: s.state in ('draft', 'verify')) + slips.write({'date': values['date']}) + return super().write(values) + + +class HrPayslip(models.Model): + _inherit = 'hr.payslip' + + @api.depends('move_id', 'move_id.line_ids.full_reconcile_id') + def _is_paid(self): + for payslip in self: + payslip.is_paid = ( + payslip.move_id and len(payslip.move_id.line_ids.filtered(lambda l: ( + l.partner_id == payslip.employee_id.address_home_id and + l.account_id.internal_type == 'payable' and + not l.reconciled + ))) == 0 + ) + + is_paid = fields.Boolean(string="Has been Paid", compute='_is_paid', store=True) + + @api.model + def create(self, vals): + if 'date' in self.env.context: + vals['date'] = self.env.context.get('date') + return super(HrPayslip, self).create(vals) + + def _payment_values(self, amount): + values = { + 'payment_reference': self.number, + 'ref': self.number + ' - ' + self.name, + 'journal_id': self.move_id.journal_id.payroll_payment_journal_id.id, + 'payment_method_id': self.move_id.journal_id.payroll_payment_method_id.id, + 'partner_type': 'supplier', + 'partner_id': self.employee_id.address_home_id.id, + 'payment_type': 'outbound', + 'amount': -amount, + } + if amount > 0.0: + values.update({ + 'payment_type': 'inbound', + 'amount': amount, + 'payment_method_id': self.move_id.journal_id.payroll_payment_method_refund_id.id, + }) + return values + + def action_register_payment(self): + slips = self.filtered(lambda s: s.move_id.state in ('draft', 'posted') and not s.is_paid) + if not all(slip.move_id.journal_id.payroll_payment_journal_id for slip in slips): + raise UserError(_('Payroll Payment journal not configured on the existing entry\'s journal.')) + if not all(slip.move_id.journal_id.payroll_payment_method_id for slip in slips): + raise UserError(_('Payroll Payment method not configured on the existing entry\'s journal.')) + + # as of 14, you cannot reconcile to un-posted moves + # so if you are paying it, we must assume you want to post any draft entries + slip_moves = slips.mapped('move_id') + unposted_moves = slip_moves.filtered(lambda m: m.state == 'draft') + unposted_moves._post(soft=False) + + payments = self.env['account.payment'] + for slip in slips: + lines_to_pay = slip.move_id.line_ids.filtered(lambda l: l.partner_id == slip.employee_id.address_home_id + and l.account_id == slip.employee_id.address_home_id.property_account_payable_id) + amount = sum(lines_to_pay.mapped('amount_residual')) + if not amount: + continue + payment_values = slip._payment_values(amount) + payment = payments.create(payment_values) + payment.action_post() + lines_paid = payment.line_ids.filtered(lambda l: l.account_id == slip.employee_id.address_home_id.property_account_payable_id) + lines_to_reconcile = lines_to_pay + lines_paid + lines_to_reconcile.reconcile() + payments += payment + action = self.env.ref('account.action_account_payments_payable').read()[0] + action.update({ + 'res_ids': payments.ids, + 'domain': [('id', 'in', payments.ids)], + }) + return action + + def action_payslip_done(self): + res = super(HrPayslip, self).action_payslip_done() + self._generate_move() + return res + + def _generate_move(self): + """ + Generate the accounting entries related to the selected payslips + A move is created for each journal and for each month. + """ + # Not needed after abstraction + + #res = super(HrPayslip, self).action_payslip_done() + #precision = self.env['decimal.precision'].precision_get('Payroll') + + # Add payslip without run + payslips_to_post = self.filtered(lambda slip: not slip.payslip_run_id) + + # Adding pay slips from a batch and deleting pay slips with a batch that is not ready for validation. + payslip_runs = (self - payslips_to_post).mapped('payslip_run_id') + for run in payslip_runs: + if run._are_payslips_ready(): + payslips_to_post |= run.slip_ids + + # A payslip need to have a done state and not an accounting move. + payslips_to_post = payslips_to_post.filtered(lambda slip: slip.state == 'done' and not slip.move_id) + + # Check that a journal exists on all the structures + if any(not payslip.struct_id for payslip in payslips_to_post): + raise ValidationError(_('One of the contract for these payslips has no structure type.')) + if any(not structure.journal_id for structure in payslips_to_post.mapped('struct_id')): + raise ValidationError(_('One of the payroll structures has no account journal defined on it.')) + + # Map all payslips by structure journal and pay slips month. + # {'journal_id': {'month': [slip_ids]}} + # slip_mapped_data = { + # slip.struct_id.journal_id.id: {fields.Date().end_of(slip.date_to, 'month'): self.env['hr.payslip']} for slip + # in + # payslips_to_post} + # Hibou Customization: group with journal itself so that journal behavior can be derived. + # Hibou Customization: prefer slip's `date` over end of month + # Hibou Customization: add an account mapping based on fiscal position + slip_mapped_data = { + slip.struct_id.journal_id: {slip.date or fields.Date().end_of(slip.date_to, 'month'): self.env['hr.payslip']} for slip + in + payslips_to_post} + for slip in payslips_to_post: + slip_mapped_data[slip.struct_id.journal_id][slip.date or fields.Date().end_of(slip.date_to, 'month')] |= slip + + account_map = payslips_to_post._generate_account_map() + + for journal in slip_mapped_data: # For each journal_id. + """ + All methods to generate journal entry should generate one or more + journal entries given this format. + """ + for slip_date in slip_mapped_data[journal]: # For each month. + if hasattr(self, '_generate_move_' + str(journal.payroll_entry_type)): + getattr(self, '_generate_move_' + str(journal.payroll_entry_type))(slip_mapped_data, journal, slip_date, account_map) + else: + self._generate_move_original(slip_mapped_data, journal, slip_date, account_map) + + def _check_slips_employee_home_address(self): + employees_missing_partner = self.mapped('employee_id').filtered(lambda e: not e.address_home_id) + if employees_missing_partner: + raise UserError(_('The following employees are missing private addresses. %s') % \ + (', '.join(employees_missing_partner.mapped('name')))) + address_ap = self.mapped('employee_id.address_home_id.property_account_payable_id') + if len(address_ap) > 1: + raise UserError(_('Employee\'s private address account payable not the same for all addresses.')) + + def _generate_account_map(self): + account_map = {} + rules = self.mapped('line_ids.salary_rule_id') + base_account_map = {a: a for a in (rules.mapped('account_debit') | rules.mapped('account_credit'))} + account_map[self.env['account.fiscal.position']] = base_account_map + fiscal_positions = self.mapped('contract_id.payroll_fiscal_position_id') + for fp in fiscal_positions: + account_map[fp] = fp.map_accounts(base_account_map.copy()) + return account_map + + def _process_journal_lines_grouped(self, line_ids, date, precision, account_map): + slip = self + employee_partner_id = slip.employee_id.address_home_id.id + for line in slip.line_ids.filtered(lambda l: l.category_id): + amount = -line.total if slip.credit_note else line.total + if line.code == 'NET': # Check if the line is the 'Net Salary'. + for tmp_line in slip.line_ids.filtered(lambda l: l.category_id): + if tmp_line.salary_rule_id.not_computed_in_net: # Check if the rule must be computed in the 'Net Salary' or not. + if amount > 0: + amount -= abs(tmp_line.total) + elif amount < 0: + amount += abs(tmp_line.total) + if float_is_zero(amount, precision_digits=precision): + continue + debit_account = line.salary_rule_id.account_debit + debit_account_id = account_map[debit_account].id if debit_account else False + credit_account = line.salary_rule_id.account_credit + credit_account_id = account_map[credit_account].id if credit_account else False + partner_id = line.salary_rule_id.partner_id.id or employee_partner_id + + if debit_account_id: # If the rule has a debit account. + debit = amount if amount > 0.0 else 0.0 + credit = -amount if amount < 0.0 else 0.0 + + existing_debit_lines = ( + line_id for line_id in line_ids if + # line_id['name'] == line.name + line_id['partner_id'] == partner_id + and line_id['account_id'] == debit_account_id + and ((line_id['debit'] > 0 and credit <= 0) or (line_id['credit'] > 0 and debit <= 0))) + debit_line = next(existing_debit_lines, False) + + if not debit_line: + debit_line = { + 'name': line.name, + 'partner_id': partner_id, + 'account_id': debit_account_id, + 'journal_id': slip.struct_id.journal_id.id, + 'date': date, + 'debit': debit, + 'credit': credit, + 'analytic_account_id': line.salary_rule_id.analytic_account_id.id or slip.contract_id.analytic_account_id.id, + } + line_ids.append(debit_line) + else: + line_name_pieces = set(debit_line['name'].split(', ')) + line_name_pieces.add(line.name) + debit_line['name'] = ', '.join(line_name_pieces) + debit_line['debit'] += debit + debit_line['credit'] += credit + + if credit_account_id: # If the rule has a credit account. + debit = -amount if amount < 0.0 else 0.0 + credit = amount if amount > 0.0 else 0.0 + existing_credit_line = ( + line_id for line_id in line_ids if + # line_id['name'] == line.name + line_id['partner_id'] == partner_id + and line_id['account_id'] == credit_account_id + and ((line_id['debit'] > 0 and credit <= 0) or (line_id['credit'] > 0 and debit <= 0)) + ) + credit_line = next(existing_credit_line, False) + + if not credit_line: + credit_line = { + 'name': line.name, + 'partner_id': partner_id, + 'account_id': credit_account_id, + 'journal_id': slip.struct_id.journal_id.id, + 'date': date, + 'debit': debit, + 'credit': credit, + 'analytic_account_id': line.salary_rule_id.analytic_account_id.id or slip.contract_id.analytic_account_id.id, + } + line_ids.append(credit_line) + else: + line_name_pieces = set(credit_line['name'].split(', ')) + line_name_pieces.add(line.name) + credit_line['name'] = ', '.join(line_name_pieces) + credit_line['debit'] += debit + credit_line['credit'] += credit + + def _generate_move_grouped(self, slip_mapped_data, journal, slip_date, account_map): + slip_mapped_data[journal][slip_date]._check_slips_employee_home_address() + + precision = self.env['decimal.precision'].precision_get('Payroll') + line_ids = [] + debit_sum = 0.0 + credit_sum = 0.0 + date = slip_date + move_dict = { + 'narration': '', + 'ref': date.strftime('%B %Y'), + 'journal_id': journal.id, + 'date': date, + } + + for slip in slip_mapped_data[journal][slip_date]: + slip_accounts = account_map[slip.contract_id.payroll_fiscal_position_id] + move_dict['narration'] += slip.number or '' + ' - ' + slip.employee_id.name or '' + move_dict['narration'] += '\n' + slip._process_journal_lines_grouped(line_ids, date, precision, slip_accounts) + + for line_id in line_ids: # Get the debit and credit sum. + debit_sum += line_id['debit'] + credit_sum += line_id['credit'] + + # The code below is called if there is an error in the balance between credit and debit sum. + if float_compare(credit_sum, debit_sum, precision_digits=precision) == -1: + acc_id = slip.journal_id.default_account_id.id + if not acc_id: + raise UserError( + _('The Expense Journal "%s" has not properly configured the Default Account!') % ( + slip.journal_id.name)) + existing_adjustment_line = ( + line_id for line_id in line_ids if line_id['name'] == _('Adjustment Entry') + ) + adjust_credit = next(existing_adjustment_line, False) + + if not adjust_credit: + adjust_credit = { + 'name': _('Adjustment Entry'), + 'partner_id': False, + 'account_id': acc_id, + 'journal_id': slip.journal_id.id, + 'date': date, + 'debit': 0.0, + 'credit': debit_sum - credit_sum, + } + line_ids.append(adjust_credit) + else: + adjust_credit['credit'] = debit_sum - credit_sum + + elif float_compare(debit_sum, credit_sum, precision_digits=precision) == -1: + acc_id = slip.journal_id.default_account_id.id + if not acc_id: + raise UserError(_('The Expense Journal "%s" has not properly configured the Default Account!') % ( + slip.journal_id.name)) + existing_adjustment_line = ( + line_id for line_id in line_ids if line_id['name'] == _('Adjustment Entry') + ) + adjust_debit = next(existing_adjustment_line, False) + + if not adjust_debit: + adjust_debit = { + 'name': _('Adjustment Entry'), + 'partner_id': False, + 'account_id': acc_id, + 'journal_id': slip.journal_id.id, + 'date': date, + 'debit': credit_sum - debit_sum, + 'credit': 0.0, + } + line_ids.append(adjust_debit) + else: + adjust_debit['debit'] = credit_sum - debit_sum + + # Add accounting lines in the move + move_dict['line_ids'] = [(0, 0, line_vals) for line_vals in line_ids] + move = self.env['account.move'].create(move_dict) + for slip in slip_mapped_data[journal][slip_date]: + slip.write({'move_id': move.id, 'date': date}) + + def _generate_move_slip(self, slip_mapped_data, journal, slip_date, account_map): + slip_mapped_data[journal][slip_date]._check_slips_employee_home_address() + + precision = self.env['decimal.precision'].precision_get('Payroll') + + for slip in slip_mapped_data[journal][slip_date]: + slip_accounts = account_map[slip.contract_id.payroll_fiscal_position_id] + line_ids = [] + debit_sum = 0.0 + credit_sum = 0.0 + date = slip_date + move_dict = { + 'narration': '', + 'ref': date.strftime('%B %Y'), + 'journal_id': journal.id, + 'date': date, + } + + move_dict['narration'] += slip.number or '' + ' - ' + slip.employee_id.name or '' + move_dict['narration'] += '\n' + slip._process_journal_lines_grouped(line_ids, date, precision, slip_accounts) + + for line_id in line_ids: # Get the debit and credit sum. + debit_sum += line_id['debit'] + credit_sum += line_id['credit'] + + # The code below is called if there is an error in the balance between credit and debit sum. + if float_compare(credit_sum, debit_sum, precision_digits=precision) == -1: + acc_id = slip.journal_id.default_account_id.id + if not acc_id: + raise UserError( + _('The Expense Journal "%s" has not properly configured the Default Account!') % ( + slip.journal_id.name)) + existing_adjustment_line = ( + line_id for line_id in line_ids if line_id['name'] == _('Adjustment Entry') + ) + adjust_credit = next(existing_adjustment_line, False) + + if not adjust_credit: + adjust_credit = { + 'name': _('Adjustment Entry'), + 'partner_id': False, + 'account_id': acc_id, + 'journal_id': slip.journal_id.id, + 'date': date, + 'debit': 0.0, + 'credit': debit_sum - credit_sum, + } + line_ids.append(adjust_credit) + else: + adjust_credit['credit'] = debit_sum - credit_sum + + elif float_compare(debit_sum, credit_sum, precision_digits=precision) == -1: + acc_id = slip.journal_id.default_account_id.id + if not acc_id: + raise UserError(_('The Expense Journal "%s" has not properly configured the Default Account!') % ( + slip.journal_id.name)) + existing_adjustment_line = ( + line_id for line_id in line_ids if line_id['name'] == _('Adjustment Entry') + ) + adjust_debit = next(existing_adjustment_line, False) + + if not adjust_debit: + adjust_debit = { + 'name': _('Adjustment Entry'), + 'partner_id': False, + 'account_id': acc_id, + 'journal_id': slip.journal_id.id, + 'date': date, + 'debit': credit_sum - debit_sum, + 'credit': 0.0, + } + line_ids.append(adjust_debit) + else: + adjust_debit['debit'] = credit_sum - debit_sum + + # Add accounting lines in the move + move_dict['line_ids'] = [(0, 0, line_vals) for line_vals in line_ids] + move = self.env['account.move'].create(move_dict) + slip.write({'move_id': move.id, 'date': date}) + + def _generate_move_original(self, slip_mapped_data, journal, slip_date, account_map): + """ + Odoo's original version. + Fixed bug with 'matching' credit line + """ + precision = self.env['decimal.precision'].precision_get('Payroll') + line_ids = [] + debit_sum = 0.0 + credit_sum = 0.0 + date = slip_date + move_dict = { + 'narration': '', + 'ref': date.strftime('%B %Y'), + 'journal_id': journal.id, + 'date': date, + } + + for slip in slip_mapped_data[journal][slip_date]: + slip_accounts = account_map[slip.contract_id.payroll_fiscal_position_id] + move_dict['narration'] += slip.number or '' + ' - ' + slip.employee_id.name or '' + move_dict['narration'] += '\n' + for line in slip.line_ids.filtered(lambda l: l.category_id): + amount = -line.total if slip.credit_note else line.total + if line.code == 'NET': # Check if the line is the 'Net Salary'. + for tmp_line in slip.line_ids.filtered(lambda l: l.category_id): + if tmp_line.salary_rule_id.not_computed_in_net: # Check if the rule must be computed in the 'Net Salary' or not. + if amount > 0: + amount -= abs(tmp_line.total) + elif amount < 0: + amount += abs(tmp_line.total) + if float_is_zero(amount, precision_digits=precision): + continue + + debit_account = line.salary_rule_id.account_debit + debit_account_id = slip_accounts[debit_account].id if debit_account else False + credit_account = line.salary_rule_id.account_credit + credit_account_id = slip_accounts[credit_account].id if credit_account else False + + if debit_account_id: # If the rule has a debit account. + debit = amount if amount > 0.0 else 0.0 + credit = -amount if amount < 0.0 else 0.0 + + existing_debit_lines = ( + line_id for line_id in line_ids if + line_id['name'] == line.name + and line_id['account_id'] == debit_account_id + and ((line_id['debit'] > 0 and credit <= 0) or (line_id['credit'] > 0 and debit <= 0))) + debit_line = next(existing_debit_lines, False) + + if not debit_line: + debit_line = { + 'name': line.name, + 'partner_id': False, + 'account_id': debit_account_id, + 'journal_id': slip.struct_id.journal_id.id, + 'date': date, + 'debit': debit, + 'credit': credit, + 'analytic_account_id': line.salary_rule_id.analytic_account_id.id or slip.contract_id.analytic_account_id.id, + } + line_ids.append(debit_line) + else: + debit_line['debit'] += debit + debit_line['credit'] += credit + + if credit_account_id: # If the rule has a credit account. + debit = -amount if amount < 0.0 else 0.0 + credit = amount if amount > 0.0 else 0.0 + existing_credit_line = ( + line_id for line_id in line_ids if + line_id['name'] == line.name + and line_id['account_id'] == credit_account_id + and ((line_id['debit'] > 0 and credit <= 0) or (line_id['credit'] > 0 and debit <= 0)) + ) + credit_line = next(existing_credit_line, False) + + if not credit_line: + credit_line = { + 'name': line.name, + 'partner_id': False, + 'account_id': credit_account_id, + 'journal_id': slip.struct_id.journal_id.id, + 'date': date, + 'debit': debit, + 'credit': credit, + 'analytic_account_id': line.salary_rule_id.analytic_account_id.id or slip.contract_id.analytic_account_id.id, + } + line_ids.append(credit_line) + else: + credit_line['debit'] += debit + credit_line['credit'] += credit + + for line_id in line_ids: # Get the debit and credit sum. + debit_sum += line_id['debit'] + credit_sum += line_id['credit'] + + # The code below is called if there is an error in the balance between credit and debit sum. + if float_compare(credit_sum, debit_sum, precision_digits=precision) == -1: + acc_id = slip.journal_id.default_account_id.id + if not acc_id: + raise UserError( + _('The Expense Journal "%s" has not properly configured the Default Account!') % ( + slip.journal_id.name)) + existing_adjustment_line = ( + line_id for line_id in line_ids if line_id['name'] == _('Adjustment Entry') + ) + adjust_credit = next(existing_adjustment_line, False) + + if not adjust_credit: + adjust_credit = { + 'name': _('Adjustment Entry'), + 'partner_id': False, + 'account_id': acc_id, + 'journal_id': slip.journal_id.id, + 'date': date, + 'debit': 0.0, + 'credit': debit_sum - credit_sum, + } + line_ids.append(adjust_credit) + else: + adjust_credit['credit'] = debit_sum - credit_sum + + elif float_compare(debit_sum, credit_sum, precision_digits=precision) == -1: + acc_id = slip.journal_id.default_account_id.id + if not acc_id: + raise UserError(_('The Expense Journal "%s" has not properly configured the Default Account!') % ( + slip.journal_id.name)) + existing_adjustment_line = ( + line_id for line_id in line_ids if line_id['name'] == _('Adjustment Entry') + ) + adjust_debit = next(existing_adjustment_line, False) + + if not adjust_debit: + adjust_debit = { + 'name': _('Adjustment Entry'), + 'partner_id': False, + 'account_id': acc_id, + 'journal_id': slip.journal_id.id, + 'date': date, + 'debit': credit_sum - debit_sum, + 'credit': 0.0, + } + line_ids.append(adjust_debit) + else: + adjust_debit['debit'] = credit_sum - debit_sum + + # Add accounting lines in the move + move_dict['line_ids'] = [(0, 0, line_vals) for line_vals in line_ids] + move = self.env['account.move'].create(move_dict) + for slip in slip_mapped_data[journal][slip_date]: + slip.write({'move_id': move.id, 'date': date}) diff --git a/hr_payroll_payment/models/hr_payslip_patch.py b/hr_payroll_payment/models/hr_payslip_patch.py new file mode 100644 index 00000000..3f8230a1 --- /dev/null +++ b/hr_payroll_payment/models/hr_payslip_patch.py @@ -0,0 +1,9 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo.addons.hr_payroll_account.models.hr_payroll_account import HrPayslip + +# this is patched because this method is replaced in our implementation (and overridable via _generate_move()) +def action_payslip_done(self): + return super(HrPayslip, self).action_payslip_done() + +HrPayslip.action_payslip_done = action_payslip_done diff --git a/hr_payroll_payment/static/description/icon.png b/hr_payroll_payment/static/description/icon.png new file mode 100644 index 00000000..bb8c4aae Binary files /dev/null and b/hr_payroll_payment/static/description/icon.png differ diff --git a/hr_payroll_payment/tests/__init__.py b/hr_payroll_payment/tests/__init__.py new file mode 100644 index 00000000..335ab6a2 --- /dev/null +++ b/hr_payroll_payment/tests/__init__.py @@ -0,0 +1,3 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import test_hr_payroll_account \ No newline at end of file diff --git a/hr_payroll_payment/tests/test_hr_payroll_account.py b/hr_payroll_payment/tests/test_hr_payroll_account.py new file mode 100644 index 00000000..36e31970 --- /dev/null +++ b/hr_payroll_payment/tests/test_hr_payroll_account.py @@ -0,0 +1,125 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + + +import odoo.tests +from odoo.addons.hr_payroll_account.tests.test_hr_payroll_account import TestHrPayrollAccount as TestBase + + +@odoo.tests.tagged('post_install', '-at_install') +class TestHrPayrollAccount(TestBase): + + def setUp(self): + super().setUp() + # upstream code no-longer sets the journal, though it does create it.... + self.hr_structure_softwaredeveloper.journal_id = self.account_journal + # upstream code no-longer has any accounts (just makes journal entries without any lines) + demo_account = self.env.ref('hr_payroll_account.demo_account') + self.hr_structure_softwaredeveloper.rule_ids.filtered(lambda r: r.code == 'HRA').account_debit = demo_account + # Need a default account as there will be adjustment lines equal and opposite to the above PT rule... + self.account_journal.default_account_id = demo_account + + # Two employees, but in stock tests they share the same partner... + self.hr_employee_mark.address_home_id = self.env['res.partner'].create({ + 'name': 'employee_mark', + }) + + # This rule has a partner, and is the only one with any accounting side effects. + # Remove partner to use the home address... + self.rule = self.hr_structure_softwaredeveloper.rule_ids.filtered(lambda r: r.code == 'HRA') + self.rule.partner_id = False + + # configure journal to be able to make payments + ap = self.hr_employee_mark.address_home_id.property_account_payable_id + self.assertTrue(ap) + # note there is no NET rule, so I just use a random allowance with fixed 800.0 amount + net_rule = self.hr_structure_softwaredeveloper.rule_ids.filtered(lambda r: r.code == 'CA') + self.assertTrue(net_rule) + net_rule.account_credit = ap + bank_journal = self.env['account.journal'].search([('type', '=', 'bank')], limit=1) + self.account_journal.payroll_payment_journal_id = bank_journal + self.account_journal.payroll_payment_method_id = bank_journal.outbound_payment_method_line_ids[0].payment_method_id + + def _setup_fiscal_position(self): + account_rule_debit = self.rule.account_debit + self.assertTrue(account_rule_debit) + account_other = self.env['account.account'].search([('id', '!=', account_rule_debit.id)], limit=1) + self.assertTrue(account_other) + fp = self.env['account.fiscal.position'].create({ + 'name': 'Salary Remap 1', + 'account_ids': [(0, 0, { + 'account_src_id': account_rule_debit.id, + 'account_dest_id': account_other.id, + })] + }) + self.hr_contract_john.payroll_fiscal_position_id = fp + + def _setup_fiscal_position_empty(self): + self._setup_fiscal_position() + self.hr_contract_john.payroll_fiscal_position_id.write({'account_ids': [(5, 0, 0)]}) + + def test_00_hr_payslip_run(self): + # Original method groups but has no partners. + self.account_journal.payroll_entry_type = 'original' + super().test_00_hr_payslip_run() + self.assertEqual(len(self.payslip_run.slip_ids), 2) + self.assertEqual(len(self.payslip_run.slip_ids.mapped('move_id')), 1) + self.assertEqual(len(self.payslip_run.slip_ids.mapped('move_id.line_ids.partner_id')), 0) + + def test_00_fiscal_position(self): + self._setup_fiscal_position() + self.test_00_hr_payslip_run() + self.assertEqual(len(self.payslip_run.slip_ids.mapped('move_id.line_ids.account_id')), 3) + + def test_00_fiscal_position_empty(self): + self._setup_fiscal_position_empty() + self.test_00_hr_payslip_run() + self.assertEqual(len(self.payslip_run.slip_ids.mapped('move_id.line_ids.account_id')), 2) + + def test_01_hr_payslip_run(self): + # Grouped method groups but has partners. + self.account_journal.payroll_entry_type = 'grouped' + super().test_01_hr_payslip_run() + self.assertEqual(len(self.payslip_run.slip_ids), 2) + self.assertEqual(len(self.payslip_run.slip_ids.mapped('move_id')), 1) + self.assertEqual(len(self.payslip_run.slip_ids.mapped('move_id.line_ids.partner_id')), 2) + # what is going on with the 3rd one?! + slips_to_pay = self.payslip_run.slip_ids + action = slips_to_pay.action_register_payment() + payment_ids = action['res_ids'] + self.assertEqual(len(payment_ids), 2) + + def test_01_fiscal_position(self): + self._setup_fiscal_position() + self.test_01_hr_payslip_run() + self.assertEqual(len(self.payslip_run.slip_ids.mapped('move_id.line_ids.account_id')), 3) + + def test_01_fiscal_position_empty(self): + self._setup_fiscal_position_empty() + self.test_01_hr_payslip_run() + self.assertEqual(len(self.payslip_run.slip_ids.mapped('move_id.line_ids.account_id')), 2) + + def test_01_2_hr_payslip_run(self): + # Payslip method makes an entry per payslip + self.account_journal.payroll_entry_type = 'slip' + # Call 'other' implementation. + super().test_01_hr_payslip_run() + self.assertEqual(len(self.payslip_run.slip_ids), 2) + self.assertEqual(len(self.payslip_run.slip_ids.mapped('move_id')), 2) + self.assertEqual(len(self.payslip_run.slip_ids.mapped('move_id.line_ids.partner_id')), 2) + slips_to_pay = self.payslip_run.slip_ids + # what is going on with the 3rd one?! + # it is possible to filter it out, but it doesn't change it + self.assertEqual(len(slips_to_pay), 2) + action = slips_to_pay.action_register_payment() + payment_ids = action['res_ids'] + self.assertEqual(len(payment_ids), 2) + + def test_01_2_fiscal_position(self): + self._setup_fiscal_position() + self.test_01_2_hr_payslip_run() + self.assertEqual(len(self.payslip_run.slip_ids.mapped('move_id.line_ids.account_id')), 3) + + def test_01_2_fiscal_position_empty(self): + self._setup_fiscal_position_empty() + self.test_01_2_hr_payslip_run() + self.assertEqual(len(self.payslip_run.slip_ids.mapped('move_id.line_ids.account_id')), 2) diff --git a/hr_payroll_payment/views/account_views.xml b/hr_payroll_payment/views/account_views.xml new file mode 100644 index 00000000..e81d5dee --- /dev/null +++ b/hr_payroll_payment/views/account_views.xml @@ -0,0 +1,21 @@ + + + + account.journal.form.inherit + account.journal + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/hr_payroll_payment/views/hr_payslip_views.xml b/hr_payroll_payment/views/hr_payslip_views.xml new file mode 100644 index 00000000..d46e3895 --- /dev/null +++ b/hr_payroll_payment/views/hr_payslip_views.xml @@ -0,0 +1,75 @@ + + + + + hr.payslip.select.payment + hr.payslip + + + + + + + + + + hr.payslip.run.select.payment + hr.payslip.run + + + + + + + + + + + hr.payslip.form.payment + hr.payslip + + + +