diff --git a/hr_commission/__init__.py b/hr_commission/__init__.py new file mode 100644 index 00000000..09434554 --- /dev/null +++ b/hr_commission/__init__.py @@ -0,0 +1,3 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import models diff --git a/hr_commission/__manifest__.py b/hr_commission/__manifest__.py new file mode 100644 index 00000000..52e7d5d7 --- /dev/null +++ b/hr_commission/__manifest__.py @@ -0,0 +1,26 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +{ + 'name': 'Hibou Commissions', + 'author': 'Hibou Corp. ', + 'version': '13.0.1.0.1', + 'category': 'Accounting/Commissions', + 'license': 'OPL-1', + 'website': 'https://hibou.io/', + 'depends': [ + # 'account_invoice_margin', # optional + 'hr_contract', + ], + 'data': [ + 'security/commission_security.xml', + 'security/ir.model.access.csv', + 'views/account_views.xml', + 'views/commission_views.xml', + 'views/hr_views.xml', + 'views/partner_views.xml', + 'views/res_config_settings_views.xml', + ], + 'installable': True, + 'application': True, + 'auto_install': False, + } diff --git a/hr_commission/models/__init__.py b/hr_commission/models/__init__.py new file mode 100755 index 00000000..d739b6ed --- /dev/null +++ b/hr_commission/models/__init__.py @@ -0,0 +1,8 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from . import account +from . import commission +from . import hr +from . import partner +from . import res_company +from . import res_config_settings diff --git a/hr_commission/models/account.py b/hr_commission/models/account.py new file mode 100644 index 00000000..9fc6c166 --- /dev/null +++ b/hr_commission/models/account.py @@ -0,0 +1,50 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import api, fields, models + + +class AccountMove(models.Model): + _inherit = 'account.move' + + commission_ids = fields.One2many(comodel_name='hr.commission', inverse_name='source_move_id', string='Commissions') + commission_count = fields.Integer(string='Number of Commissions', compute='_compute_commission_count') + + @api.depends('state', 'commission_ids') + def _compute_commission_count(self): + for move in self: + move.commission_count = len(move.commission_ids) + return True + + def open_commissions(self): + return { + 'type': 'ir.actions.act_window', + 'name': 'Invoice Commissions', + 'res_model': 'hr.commission', + 'view_mode': 'tree,form', + 'context': {'search_default_source_move_id': self[0].id} + } + + def action_post(self): + res = super(AccountMove, self).action_post() + invoices = self.filtered(lambda m: m.is_invoice()) + if invoices: + self.env['hr.commission'].invoice_validated(invoices) + return res + + def action_invoice_paid(self): + res = super(AccountMove, self).action_invoice_paid() + self.env['hr.commission'].invoice_paid(self) + return res + + def amount_for_commission(self): + # TODO Should toggle in Config Params + if hasattr(self, 'margin') and self.company_id.commission_amount_type == 'on_invoice_margin': + sign = -1 if self.type in ['in_refund', 'out_refund'] else 1 + return self.margin * sign + return self.amount_total_signed + + def action_cancel(self): + res = super(AccountMove, self).action_cancel() + for move in self: + move.sudo().commission_ids.unlink() + return res diff --git a/hr_commission/models/commission.py b/hr_commission/models/commission.py new file mode 100644 index 00000000..3a491080 --- /dev/null +++ b/hr_commission/models/commission.py @@ -0,0 +1,329 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import api, fields, models +from odoo.tools import float_is_zero +from odoo.exceptions import UserError + + +class Commission(models.Model): + _name = 'hr.commission' + _description = 'Commission' + _order = 'id desc' + + state = fields.Selection([ + ('draft', 'New'), + ('done', 'Confirmed'), + ('paid', 'Paid'), + ('cancel', 'Cancelled'), + ], 'Status', default='draft') + employee_id = fields.Many2one('hr.employee', required=1) + user_id = fields.Many2one('res.users', related='employee_id.user_id') + source_move_id = fields.Many2one('account.move') + contract_id = fields.Many2one('hr.contract') + structure_id = fields.Many2one('hr.commission.structure') + rate_type = fields.Selection([ + ('normal', 'Normal'), + ('structure', 'Structure'), + ('admin', 'Admin'), + ('manual', 'Manual'), + ], 'Rate Type', default='normal') + rate = fields.Float('Rate') + base_total = fields.Float('Base Total') + base_amount = fields.Float(string='Base Amount') + amount = fields.Float(string='Amount') + move_id = fields.Many2one('account.move', ondelete='set null') + move_date = fields.Date(related='move_id.date', store=True) + company_id = fields.Many2one('res.company', 'Company', required=True, + default=lambda s: s.env['res.company']._company_default_get('hr.commission')) + memo = fields.Char(string='Memo') + accounting_date = fields.Date('Force Accounting Date', + help="Choose the accounting date at which you want to value the commission " + "moves created by the commission instead of the default one.") + payment_id = fields.Many2one('hr.commission.payment', string='Commission Payment', ondelete='set null') + + @api.depends('employee_id', 'source_move_id') + def name_get(self): + res = [] + for commission in self: + name = '' + if commission.source_move_id: + name += commission.source_move_id.name + if commission.employee_id: + if name: + name += ' - ' + commission.employee_id.name + else: + name += commission.employee_id.name + res.append((commission.id, name)) + return res + + @api.onchange('rate_type') + def _onchange_rate_type(self): + for commission in self.filtered(lambda c: c.rate_type == 'manual'): + commission.rate = 100.0 + + @api.onchange('source_move_id', 'contract_id', 'rate_type', 'base_amount', 'rate') + def _compute_amount(self): + for commission in self: + # Determine rate (if needed) + if commission.structure_id and commission.rate_type == 'structure': + line = commission.structure_id.line_ids.filtered(lambda l: l.employee_id == commission.employee_id) + commission.rate = line.get_rate() + elif commission.contract_id and commission.rate_type != 'manual': + if commission.rate_type == 'normal': + commission.rate = commission.contract_id.commission_rate + else: + commission.rate = commission.contract_id.admin_commission_rate + + rounding = 2 + if commission.source_move_id: + rounding = commission.source_move_id.company_currency_id.rounding + commission.base_total = commission.source_move_id.amount_total_signed + commission.base_amount = commission.source_move_id.amount_for_commission() + + amount = (commission.base_amount * commission.rate) / 100.0 + if float_is_zero(amount, precision_rounding=rounding): + amount = 0.0 + commission.amount = amount + + + @api.model + def create(self, values): + res = super(Commission, self).create(values) + res._compute_amount() + if res.amount == 0.0 and res.state == 'draft': + res.state = 'done' + return res + + def unlink(self): + if self.filtered(lambda c: c.move_id): + raise UserError('You cannot delete a commission when it has an accounting entry.') + return super(Commission, self).unlink() + + def _filter_source_moves_for_creation(self, moves): + return moves.filtered(lambda i: i.user_id and not i.commission_ids) + + @api.model + def _commissions_to_confirm(self, moves): + commissions = moves.mapped('commission_ids') + return commissions.filtered(lambda c: c.state != 'cancel' and not c.move_id) + + @api.model + def invoice_validated(self, moves): + employee_obj = self.env['hr.employee'].sudo() + commission_obj = self.sudo() + for move in self._filter_source_moves_for_creation(moves): + move_amount = move.amount_for_commission() + + # Does the invoice have a commission structure? + partner = move.partner_id + commission_structure = partner.commission_structure_id + while not commission_structure and partner: + partner = partner.parent_id + commission_structure = partner.commission_structure_id + + if commission_structure: + commission_structure.create_for_source_move(move, move_amount) + else: + employee = employee_obj.search([('user_id', '=', move.user_id.id)], limit=1) + contract = employee.contract_id + if all((employee, contract)): + move.commission_ids += commission_obj.create({ + 'employee_id': employee.id, + 'contract_id': contract.id, + 'source_move_id': move.id, + 'base_amount': move_amount, + 'rate_type': 'normal', + 'company_id': move.company_id.id, + }) + + # Admin/Coach commission. + employee = employee.coach_id + contract = employee.contract_id + if all((employee, contract)): + move.commission_ids += commission_obj.create({ + 'employee_id': employee.id, + 'contract_id': contract.id, + 'source_move_id': move.id, + 'base_amount': move_amount, + 'rate_type': 'admin', + 'company_id': move.company_id.id, + }) + + if move.commission_ids and move.company_id.commission_type == 'on_invoice': + commissions = self._commissions_to_confirm(move) + commissions.sudo().action_confirm() + + return True + + @api.model + def invoice_paid(self, moves): + commissions = self._commissions_to_confirm(moves) + commissions.sudo().action_confirm() + return True + + def action_confirm(self): + move_obj = self.env['account.move'].sudo() + + for commission in self: + if commission.state == 'cancel': + continue + if commission.move_id or commission.amount == 0.0: + commission.write({'state': 'done'}) + continue + + journal = commission.company_id.commission_journal_id + if not journal or not journal.default_debit_account_id or not journal.default_credit_account_id: + raise UserError('Commission Journal not configured.') + + liability_account = commission.company_id.commission_liability_id + if not liability_account: + liability_account = commission.employee_id.address_home_id.property_account_payable_id + if not liability_account: + raise UserError('Commission liability account must be configured if employee\'s don\'t have AP setup.') + + date = commission.source_move_id.date if commission.source_move_id else fields.Date.context_today(commission) + + # Already paid. + payments = commission.source_move_id._get_reconciled_payments() + if payments: + date = max(payments.mapped('payment_date')) + if commission.accounting_date: + date = commission.accounting_date + + ref = 'Commission for ' + commission.name_get()[0][1] + if commission.memo: + ref += ' :: ' + commission.memo + + move = move_obj.create({ + 'date': date, + 'ref': ref, + 'journal_id': journal.id, + 'type': 'entry', + 'line_ids': [ + (0, 0, { + 'name': ref, + 'partner_id': commission.employee_id.address_home_id.id, + 'account_id': liability_account.id, + 'credit': commission.amount if commission.amount > 0.0 else 0.0, + 'debit': 0.0 if commission.amount > 0.0 else -commission.amount, + }), + (0, 0, { + 'name': ref, + 'partner_id': commission.employee_id.address_home_id.id, + 'account_id': journal.default_credit_account_id.id if commission.amount > 0.0 else journal.default_debit_account_id.id, + 'credit': 0.0 if commission.amount > 0.0 else -commission.amount, + 'debit': commission.amount if commission.amount > 0.0 else 0.0, + }), + ], + }) + move.post() + commission.write({'state': 'done', 'move_id': move.id}) + return True + + def action_mark_paid(self): + if self.filtered(lambda c: c.state != 'done'): + raise UserError('You cannot mark a commission "paid" if it is not already "done".') + if not self: + raise UserError('You must have at least one "done" commission.') + payments = self._mark_paid() + action = self.env.ref('hr_commission.action_hr_commission_payment').read()[0] + action['res_ids'] = payments.ids + return action + + def _mark_paid(self): + employees = self.mapped('employee_id') + payments = self.env['hr.commission.payment'] + for employee in employees: + commissions = self.filtered(lambda c: c.employee_id == employee) + min_date = False + max_date = False + for commission in commissions: + if not min_date or (commission.move_date and min_date > commission.move_date): + min_date = commission.move_date + if not max_date or (commission.move_date and max_date < commission.move_date): + max_date = commission.move_date + payment = payments.create({ + 'employee_id': employee.id, + 'name': ('Commissions %s - %s' % (min_date, max_date)), + 'date': fields.Date.today(), + }) + payments += payment + commissions.write({'state': 'paid', 'payment_id': payment.id}) + return payments + + def action_cancel(self): + for commission in self: + if commission.move_id: + commission.move_id.write({'state': 'draft'}) + commission.move_id.unlink() + commission.write({'state': 'cancel'}) + return True + + def action_draft(self): + for commission in self.filtered(lambda c: c.state == 'cancel'): + commission.write({'state': 'draft'}) + + +class CommissionPayment(models.Model): + _name = 'hr.commission.payment' + _description = 'Commission Payment' + _order = 'id desc' + + name = fields.Char(string='Name') + employee_id = fields.Many2one('hr.employee', required=1) + user_id = fields.Many2one('res.users', related='employee_id.user_id') + date = fields.Date(string='Date') + commission_ids = fields.One2many('hr.commission', 'payment_id', string='Paid Commissions', readonly=True) + commission_count = fields.Integer(string='Commission Count', compute='_compute_commission_stats', store=True) + commission_amount = fields.Float(string='Commission Amount', compute='_compute_commission_stats', store=True) + + @api.depends('commission_ids') + def _compute_commission_stats(self): + for payment in self: + payment.commission_count = len(payment.commission_ids) + payment.commission_amount = sum(payment.commission_ids.mapped('amount')) + + +class CommissionStructure(models.Model): + _name = 'hr.commission.structure' + _description = 'Commission Structure' + _order = 'id desc' + + name = fields.Char(string='Name') + line_ids = fields.One2many('hr.commission.structure.line', 'structure_id', string='Lines') + + def create_for_source_move(self, move, amount): + self.ensure_one() + commission_obj = self.env['hr.commission'].sudo() + + for line in self.line_ids: + employee = line.employee_id + rate = line.get_rate() + if all((employee, rate)): + contract = False + if not line.rate: + # The rate must have come from the contract. + contract = employee.contract_id + move.commission_ids += commission_obj.create({ + 'employee_id': employee.id, + 'structure_id': self.id, + 'source_move_id': move.id, + 'base_amount': amount, + 'rate_type': 'structure', + 'contract_id': contract.id if contract else False, + 'company_id': move.company_id.id, + }) + + +class CommissionStructureLine(models.Model): + _name = 'hr.commission.structure.line' + _description = 'Commission Structure Line' + + structure_id = fields.Many2one('hr.commission.structure', string='Structure', required=True) + employee_id = fields.Many2one('hr.employee', string='Employee', required=True) + rate = fields.Float(string='Commission %', default=0.0, help='Leave 0.0 to use the employee\'s current contract rate.') + + def get_rate(self): + if not self.rate: + return self.employee_id.contract_id.commission_rate + return self.rate diff --git a/hr_commission/models/hr.py b/hr_commission/models/hr.py new file mode 100644 index 00000000..4047ee34 --- /dev/null +++ b/hr_commission/models/hr.py @@ -0,0 +1,11 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import api, fields, models + + +class Contract(models.Model): + _inherit = 'hr.contract' + + commission_rate = fields.Float(string='Commission %', default=0.0) + admin_commission_rate = fields.Float(string='Admin Commission %', default=0.0) + diff --git a/hr_commission/models/partner.py b/hr_commission/models/partner.py new file mode 100644 index 00000000..b88149ad --- /dev/null +++ b/hr_commission/models/partner.py @@ -0,0 +1,9 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import fields, models + + +class Partner(models.Model): + _inherit = 'res.partner' + + commission_structure_id = fields.Many2one('hr.commission.structure', string='Commission Structure') diff --git a/hr_commission/models/res_company.py b/hr_commission/models/res_company.py new file mode 100644 index 00000000..26a2acb4 --- /dev/null +++ b/hr_commission/models/res_company.py @@ -0,0 +1,18 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = 'res.company' + + commission_journal_id = fields.Many2one('account.journal', string='Commission Journal') + commission_liability_id = fields.Many2one('account.account', string='Commission Liability Account') + commission_type = fields.Selection([ + ('on_invoice', 'On Invoice Validation'), + ('on_invoice_paid', 'On Invoice Paid'), + ], string='Pay Commission', default='on_invoice_paid') + commission_amount_type = fields.Selection([ + ('on_invoice_margin', 'On Invoice Margin'), + ('on_invoice_total', 'On Invoice Total'), + ], string='Commission Base', default='on_invoice_margin') diff --git a/hr_commission/models/res_config_settings.py b/hr_commission/models/res_config_settings.py new file mode 100644 index 00000000..d09b5b38 --- /dev/null +++ b/hr_commission/models/res_config_settings.py @@ -0,0 +1,12 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + commission_journal_id = fields.Many2one(related='company_id.commission_journal_id', readonly=False) + commission_liability_id = fields.Many2one(related='company_id.commission_liability_id', readonly=False) + commission_type = fields.Selection(related='company_id.commission_type', readonly=False) + commission_amount_type = fields.Selection(related='company_id.commission_amount_type', readonly=False) diff --git a/hr_commission/security/commission_security.xml b/hr_commission/security/commission_security.xml new file mode 100644 index 00000000..844471ff --- /dev/null +++ b/hr_commission/security/commission_security.xml @@ -0,0 +1,34 @@ + + + + + + Commission User + + [('user_id', '=', user.id)] + + + + + Commission Manager + + [(1, '=', 1)] + + + + + Commission Payment User + + [('user_id', '=', user.id)] + + + + + Commission Payment Manager + + [(1, '=', 1)] + + + + + diff --git a/hr_commission/security/ir.model.access.csv b/hr_commission/security/ir.model.access.csv new file mode 100644 index 00000000..59b621a1 --- /dev/null +++ b/hr_commission/security/ir.model.access.csv @@ -0,0 +1,9 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_commission_user,commission user,model_hr_commission,base.group_user,1,0,0,0 +access_commission_manager,commission manager,model_hr_commission,account.group_account_manager,1,1,1,1 +access_commission_payment_user,commission payment user,model_hr_commission_payment,base.group_user,1,0,0,0 +access_commission_payment_manager,commission payment manager,model_hr_commission_payment,account.group_account_manager,1,1,1,1 +access_commission_structure_user,commission structure user,model_hr_commission_structure,base.group_user,1,0,0,0 +access_commission_structure_manager,commission structure manager,model_hr_commission_structure,account.group_account_manager,1,1,1,1 +access_commission_structure_line_user,commission structure line user,model_hr_commission_structure_line,base.group_user,1,0,0,0 +access_commission_structure_line_manager,commission structure line manager,model_hr_commission_structure_line,account.group_account_manager,1,1,1,1 diff --git a/hr_commission/static/description/icon.png b/hr_commission/static/description/icon.png new file mode 100644 index 00000000..f4d8b501 Binary files /dev/null and b/hr_commission/static/description/icon.png differ diff --git a/hr_commission/tests/__init__.py b/hr_commission/tests/__init__.py new file mode 100755 index 00000000..4745f69f --- /dev/null +++ b/hr_commission/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_commission diff --git a/hr_commission/tests/test_commission.py b/hr_commission/tests/test_commission.py new file mode 100644 index 00000000..688513d1 --- /dev/null +++ b/hr_commission/tests/test_commission.py @@ -0,0 +1,227 @@ +# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details. + +from odoo.tests import common + + +class TestCommission(common.TransactionCase): + + def setUp(self): + super().setUp() + self.user = self.browse_ref('base.user_demo') + self.employee = self.browse_ref('hr.employee_qdp') # This is the employee associated with above user. + + def _createUser(self): + return self.env['res.users'].create({ + 'name': 'Coach', + 'email': 'coach', + }) + + def _createEmployee(self, user): + return self.env['hr.employee'].create({ + 'birthday': '1985-03-14', + 'country_id': self.ref('base.us'), + 'department_id': self.ref('hr.dep_rd'), + 'gender': 'male', + 'name': 'Jared', + 'address_home_id': user.partner_id.id, + 'user_id': user.id, + }) + + def _createContract(self, employee, commission_rate, admin_commission_rate=0.0): + return self.env['hr.contract'].create({ + 'date_start': '2016-01-01', + 'date_end': '2030-12-31', + 'name': 'Contract for tests', + 'wage': 1000.0, + # 'type_id': self.ref('hr_contract.hr_contract_type_emp'), + 'employee_id': employee.id, + 'resource_calendar_id': self.ref('resource.resource_calendar_std'), + 'commission_rate': commission_rate, + 'admin_commission_rate': admin_commission_rate, + 'state': 'open', # if not "Running" then no automatic selection when Payslip is created in 11.0 + }) + + def _createInvoiceableSaleOrder(self, user): + product = self.env.ref('sale.advance_product_0') + partner = self.env.ref('base.res_partner_12') + sale = self.env['sale.order'].create({ + 'partner_id': partner.id, + 'user_id': user.id, + 'order_line': [(0, 0, { + 'name': 'test deposit', + 'product_id': product.id, + 'product_uom_qty': 1.0, + 'product_uom': product.uom_id.id, + 'price_unit': 5.0, + })] + }) + self.assertEqual(sale.user_id, user) + sale.action_confirm() + self.assertTrue(sale.state in ('sale', 'done')) + self.assertEqual(sale.invoice_status, 'to invoice') + return sale + + def test_commission(self): + # find and configure company commissions journal + commission_journal = self.env['account.journal'].search([('type', '=', 'general')], limit=1) + self.assertTrue(commission_journal) + expense_account = self.env.ref('l10n_generic_coa.1_expense') + commission_journal.default_debit_account_id = expense_account + commission_journal.default_credit_account_id = expense_account + self.env.user.company_id.commission_journal_id = commission_journal + + coach = self._createEmployee(self.browse_ref('base.user_root')) + coach_contract = self._createContract(coach, 12.0, admin_commission_rate=2.0) + user = self.user + emp = self.employee + emp.address_home_id = user.partner_id # Important field for payables. + emp.coach_id = coach + + contract = self._createContract(emp, 5.0) + + so = self._createInvoiceableSaleOrder(user) + inv = so._create_invoices() + self.assertFalse(inv.commission_ids, 'Commissions exist when invoice is created.') + inv.action_post() # validate + self.assertEqual(inv.state, 'posted') + self.assertEqual(inv.invoice_payment_state, 'not_paid') + self.assertTrue(inv.commission_ids, 'Commissions not created when invoice is validated.') + + user_commission = inv.commission_ids.filtered(lambda c: c.employee_id.id == emp.id) + self.assertEqual(len(user_commission), 1, 'Incorrect commission count %d (expect 1)' % len(user_commission)) + self.assertEqual(user_commission.state, 'draft', 'Commission is not draft.') + self.assertFalse(user_commission.move_id, 'Commission has existing journal entry.') + + # Amounts + commission_rate = contract.commission_rate + self.assertEqual(commission_rate, 5.0) + expected = (inv.amount_for_commission() * commission_rate) / 100.0 + actual = user_commission.amount + self.assertAlmostEqual(actual, expected, int(inv.company_currency_id.rounding)) + + # Pay. + pay_journal = self.env['account.journal'].search([('type', '=', 'bank')], limit=1) + payment = self.env['account.payment'].create({ + 'payment_type': 'inbound', + 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id, + 'partner_type': 'customer', + 'partner_id': inv.partner_id.id, + 'amount': inv.amount_residual, + 'currency_id': inv.currency_id.id, + 'journal_id': pay_journal.id, + }) + payment.post() + + receivable_line = payment.move_line_ids.filtered('credit') + inv.js_assign_outstanding_line(receivable_line.id) + self.assertEqual(inv.invoice_payment_state, 'paid', 'Invoice is not paid.') + + user_commission = inv.commission_ids.filtered(lambda c: c.employee_id.id == emp.id) + self.assertEqual(user_commission.state, 'done', 'Commission is not done.') + self.assertTrue(user_commission.move_id, 'Commission didn\'t create a journal entry.') + inv.company_currency_id.rounding + + # Coach/Admin commissions + coach_commission = inv.commission_ids.filtered(lambda c: c.employee_id.id == coach.id) + self.assertEqual(len(coach_commission), 1, 'Incorrect commission count %d (expect 1)' % len(coach_commission)) + + commission_rate = coach_contract.admin_commission_rate + expected = (inv.amount_for_commission() * commission_rate) / 100.0 + actual = coach_commission.amount + self.assertAlmostEqual( + actual, + expected, + int(inv.company_currency_id.rounding)) + + # Use the "Mark Paid" button + result_action = user_commission.action_mark_paid() + self.assertEqual(user_commission.state, 'paid') + self.assertTrue(user_commission.payment_id) + + def test_commission_on_invoice(self): + # Set to be On Invoice instead of On Invoice Paid + self.env.user.company_id.commission_type = 'on_invoice' + + # find and configure company commissions journal + commission_journal = self.env['account.journal'].search([('type', '=', 'general')], limit=1) + self.assertTrue(commission_journal) + expense_account = self.env.ref('l10n_generic_coa.1_expense') + commission_journal.default_debit_account_id = expense_account + commission_journal.default_credit_account_id = expense_account + self.env.user.company_id.commission_journal_id = commission_journal + + + coach = self._createEmployee(self.browse_ref('base.user_root')) + coach_contract = self._createContract(coach, 12.0, admin_commission_rate=2.0) + user = self.user + emp = self.employee + emp.address_home_id = user.partner_id # Important field for payables. + emp.coach_id = coach + + contract = self._createContract(emp, 5.0) + + so = self._createInvoiceableSaleOrder(user) + inv = so._create_invoices() + self.assertFalse(inv.commission_ids, 'Commissions exist when invoice is created.') + inv.action_post() # validate + self.assertEqual(inv.state, 'posted') + self.assertTrue(inv.commission_ids, 'Commissions not created when invoice is validated.') + + user_commission = inv.commission_ids.filtered(lambda c: c.employee_id.id == emp.id) + self.assertEqual(len(user_commission), 1, 'Incorrect commission count %d (expect 1)' % len(user_commission)) + self.assertEqual(user_commission.state, 'done', 'Commission is not done.') + self.assertTrue(user_commission.move_id, 'Commission missing journal entry.') + + # Use the "Mark Paid" button + user_commission.action_mark_paid() + self.assertEqual(user_commission.state, 'paid') + + def test_commission_structure(self): + # Set to be On Invoice instead of On Invoice Paid + self.env.user.company_id.commission_type = 'on_invoice' + + # find and configure company commissions journal + commission_journal = self.env['account.journal'].search([('type', '=', 'general')], limit=1) + self.assertTrue(commission_journal) + expense_account = self.env.ref('l10n_generic_coa.1_expense') + commission_journal.default_debit_account_id = expense_account + commission_journal.default_credit_account_id = expense_account + self.env.user.company_id.commission_journal_id = commission_journal + + + coach = self._createEmployee(self.browse_ref('base.user_root')) + coach_contract = self._createContract(coach, 12.0, admin_commission_rate=2.0) + user = self.user + emp = self.employee + emp.address_home_id = user.partner_id # Important field for payables. + emp.coach_id = coach + + contract = self._createContract(emp, 5.0) + + so = self._createInvoiceableSaleOrder(user) + + # Create and set commission structure + commission_structure = self.env['hr.commission.structure'].create({ + 'name': 'Test Structure', + 'line_ids': [ + (0, 0, {'employee_id': emp.id, 'rate': 13.0}), + (0, 0, {'employee_id': coach.id, 'rate': 0.0}), # This means it will use the coach's contract normal rate + ], + }) + so.partner_id.commission_structure_id = commission_structure + + inv = so._create_invoices() + self.assertFalse(inv.commission_ids, 'Commissions exist when invoice is created.') + inv.action_post() # validate + self.assertEqual(inv.state, 'posted') + self.assertTrue(inv.commission_ids, 'Commissions not created when invoice is validated.') + + user_commission = inv.commission_ids.filtered(lambda c: c.employee_id.id == emp.id) + self.assertEqual(len(user_commission), 1, 'Incorrect commission count %d (expect 1)' % len(user_commission)) + self.assertEqual(user_commission.state, 'done', 'Commission is not done.') + self.assertEqual(user_commission.rate, 13.0) + + coach_commission = inv.commission_ids.filtered(lambda c: c.employee_id.id == coach.id) + self.assertEqual(len(coach_commission), 1, 'Incorrect commission count %d (expect 1)' % len(coach_commission)) + self.assertEqual(coach_commission.state, 'done', 'Commission is not done.') + self.assertEqual(coach_commission.rate, 12.0, 'Commission rate should be the contract rate.') diff --git a/hr_commission/views/account_views.xml b/hr_commission/views/account_views.xml new file mode 100644 index 00000000..5caf5cd3 --- /dev/null +++ b/hr_commission/views/account_views.xml @@ -0,0 +1,18 @@ + + + + + account.move.form.inherit + account.move + + + + + + + + + \ No newline at end of file diff --git a/hr_commission/views/commission_views.xml b/hr_commission/views/commission_views.xml new file mode 100644 index 00000000..0e99a965 --- /dev/null +++ b/hr_commission/views/commission_views.xml @@ -0,0 +1,264 @@ + + + + hr.commission.form + hr.commission + +
+
+
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + hr.commission.tree + hr.commission + + + + + + + + + + + + + + + + + + hr.commission.pivot + hr.commission + + + + + + + + + + + + hr.commission.graph + hr.commission + + + + + + + + + + + hr.commission.search + hr.commission + + + + + + + + + + + + + + + + + + + + + Commissions + hr.commission + tree,form,pivot,graph + + + + + + + + + + + + Mark Paid + ir.actions.server + code + + + +action = records.action_mark_paid() + + + + + + hr.commission.payment.form + hr.commission.payment + +
+
+ +
+

+ + + + + + + + + + + + + + + + + + + + + + + + hr.commission.payment.tree + hr.commission.payment + + + + + + + + + + + + + hr.commission.payment.search + hr.commission.payment + + + + + + + + + + + + + Commission Payments + hr.commission.payment + tree,form + + + + + + + + + hr.commission.structure.form + hr.commission.structure + +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + diff --git a/hr_commission/views/hr_views.xml b/hr_commission/views/hr_views.xml new file mode 100644 index 00000000..6977afa2 --- /dev/null +++ b/hr_commission/views/hr_views.xml @@ -0,0 +1,16 @@ + + + + + hr.contract.form.inherit + hr.contract + + + + + + + + + + \ No newline at end of file diff --git a/hr_commission/views/partner_views.xml b/hr_commission/views/partner_views.xml new file mode 100644 index 00000000..23fcc43d --- /dev/null +++ b/hr_commission/views/partner_views.xml @@ -0,0 +1,15 @@ + + + + + res.parter.view.form.inherit + res.partner + + + + + + + + + \ No newline at end of file diff --git a/hr_commission/views/res_config_settings_views.xml b/hr_commission/views/res_config_settings_views.xml new file mode 100644 index 00000000..645c8f06 --- /dev/null +++ b/hr_commission/views/res_config_settings_views.xml @@ -0,0 +1,46 @@ + + + + + res.config.settings.view.form.inherit.account + res.config.settings + + + + +

Commissions

+
+
+
+
+ +
+ Commission journal default accounts can be thought of as the 'expense' side of the commission. If a Liability account + is not chosen, then the employee's home address partner's Account Payable will be used instead. +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + \ No newline at end of file