diff --git a/hr_payroll_hibou/models/res_config_settings.py b/hr_payroll_hibou/models/res_config_settings.py
index d282e2a7..b1c52e91 100644
--- a/hr_payroll_hibou/models/res_config_settings.py
+++ b/hr_payroll_hibou/models/res_config_settings.py
@@ -7,6 +7,7 @@ class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
# TODO We need MORE here...
+ module_hr_payroll_payment = fields.Boolean(string='Payments & Advanced Accounting')
module_hr_payroll_attendance = fields.Boolean(string='Attendance Entries & Overtime')
module_hr_payroll_timesheet = fields.Boolean(string='Timesheet Entries & Overtime')
module_l10n_us_hr_payroll = fields.Boolean(string='USA Payroll')
diff --git a/hr_payroll_hibou/views/res_config_settings_views.xml b/hr_payroll_hibou/views/res_config_settings_views.xml
index 9933edbd..4d516318 100644
--- a/hr_payroll_hibou/views/res_config_settings_views.xml
+++ b/hr_payroll_hibou/views/res_config_settings_views.xml
@@ -42,6 +42,20 @@
+
+
+
+
+
+
+
+ Register payments on payslips! Have control over journal entries created from
+ payroll to include partner details, set grouping options, and apply fiscal position
+ account mappings.
+ Hibou Professional
+
+
+
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..1ed17fdd
--- /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': '14.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 14, 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..2d1e0013
--- /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_ids[0]
+
+ 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), 3)
+ 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), 3)
+ 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), 3)
+ self.assertEqual(len(self.payslip_run.slip_ids.mapped('move_id')), 3)
+ 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), 3)
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+ hr.payslip.run.form.payment
+ hr.payslip.run
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ hr.contract.view.form.inherit
+ hr.contract
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/hr_payroll_payment/wizard/__init__.py b/hr_payroll_payment/wizard/__init__.py
new file mode 100644
index 00000000..5e360726
--- /dev/null
+++ b/hr_payroll_payment/wizard/__init__.py
@@ -0,0 +1,3 @@
+# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
+
+from . import hr_payroll_payslips_by_employees
diff --git a/hr_payroll_payment/wizard/hr_payroll_payslips_by_employees.py b/hr_payroll_payment/wizard/hr_payroll_payslips_by_employees.py
new file mode 100644
index 00000000..be4c2f0e
--- /dev/null
+++ b/hr_payroll_payment/wizard/hr_payroll_payslips_by_employees.py
@@ -0,0 +1,13 @@
+# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
+
+from odoo import api, models
+
+
+class HrPayslipEmployees(models.TransientModel):
+ _inherit = 'hr.payslip.employees'
+
+ def compute_sheet(self):
+ date = False
+ if self.env.context.get('active_id'):
+ date = self.env['hr.payslip.run'].browse(self.env.context.get('active_id')).date
+ return super(HrPayslipEmployees, self.with_context(date=date)).compute_sheet()