diff --git a/sale_timesheet_work_entry_rate/__init__.py b/sale_timesheet_work_entry_rate/__init__.py
new file mode 100644
index 00000000..09434554
--- /dev/null
+++ b/sale_timesheet_work_entry_rate/__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/sale_timesheet_work_entry_rate/__manifest__.py b/sale_timesheet_work_entry_rate/__manifest__.py
new file mode 100644
index 00000000..2abb9061
--- /dev/null
+++ b/sale_timesheet_work_entry_rate/__manifest__.py
@@ -0,0 +1,25 @@
+# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
+
+{
+ 'name': 'Timesheet Billing Rate',
+ 'version': '14.0.1.0.0',
+ 'category': 'Sale',
+ 'author': 'Hibou Corp.',
+ 'license': 'OPL-1',
+ 'website': 'https://hibou.io/',
+ 'depends': [
+ 'hibou_professional',
+ 'hr_timesheet_work_entry',
+ 'sale_timesheet',
+ ],
+ 'data': [
+ ],
+ 'demo': [
+ 'data/hr_timesheet_work_entry_demo.xml',
+ 'views/timesheet_views.xml',
+ 'views/work_entry_views.xml',
+ ],
+ 'installable': True,
+ 'auto_install': True,
+ 'application': False,
+ }
diff --git a/sale_timesheet_work_entry_rate/data/hr_timesheet_work_entry_demo.xml b/sale_timesheet_work_entry_rate/data/hr_timesheet_work_entry_demo.xml
new file mode 100644
index 00000000..2ad38d6a
--- /dev/null
+++ b/sale_timesheet_work_entry_rate/data/hr_timesheet_work_entry_demo.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+ Double Time
+ TS_DOUBLE
+
+
+
+
+
\ No newline at end of file
diff --git a/sale_timesheet_work_entry_rate/models/__init__.py b/sale_timesheet_work_entry_rate/models/__init__.py
new file mode 100644
index 00000000..00c7883c
--- /dev/null
+++ b/sale_timesheet_work_entry_rate/models/__init__.py
@@ -0,0 +1,5 @@
+# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
+
+from . import sale
+from . import timesheet
+from . import work_entry
diff --git a/sale_timesheet_work_entry_rate/models/sale.py b/sale_timesheet_work_entry_rate/models/sale.py
new file mode 100644
index 00000000..f98ad71f
--- /dev/null
+++ b/sale_timesheet_work_entry_rate/models/sale.py
@@ -0,0 +1,59 @@
+# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
+
+from odoo import api, fields, models
+from odoo.osv import expression
+
+
+class SaleOrderLine(models.Model):
+ _inherit = 'sale.order.line'
+
+ @api.depends('analytic_line_ids.work_type_id')
+ def _compute_qty_delivered(self):
+ super(SaleOrderLine, self)._compute_qty_delivered()
+
+ # Overridden to select work_type_id and do multiplication at the end
+ def _get_delivered_quantity_by_analytic(self, additional_domain):
+ """ Compute and write the delivered quantity of current SO lines, based on their related
+ analytic lines.
+ :param additional_domain: domain to restrict AAL to include in computation (required since timesheet is an AAL with a project ...)
+ """
+ result = {}
+
+ # avoid recomputation if no SO lines concerned
+ if not self:
+ return result
+
+ # group analytic lines by product uom and so line
+ domain = expression.AND([[('so_line', 'in', self.ids)], additional_domain])
+ data = self.env['account.analytic.line'].read_group(
+ domain,
+ ['so_line', 'unit_amount', 'product_uom_id', 'work_type_id'], ['product_uom_id', 'so_line', 'work_type_id'], lazy=False
+ )
+
+ # convert uom and sum all unit_amount of analytic lines to get the delivered qty of SO lines
+ # browse so lines and product uoms here to make them share the same prefetch
+ lines = self.browse([item['so_line'][0] for item in data])
+ lines_map = {line.id: line for line in lines}
+ product_uom_ids = [item['product_uom_id'][0] for item in data if item['product_uom_id']]
+ product_uom_map = {uom.id: uom for uom in self.env['uom.uom'].browse(product_uom_ids)}
+ work_type_ids = [item['work_type_id'][0] for item in data if item['work_type_id']]
+ work_type_map = {work.id: work for work in self.env['hr.work.entry.type'].browse(work_type_ids)}
+ for item in data:
+ if not item['product_uom_id']:
+ continue
+ if not item['work_type_id']:
+ continue
+ so_line_id = item['so_line'][0]
+ so_line = lines_map[so_line_id]
+ result.setdefault(so_line_id, 0.0)
+ uom = product_uom_map.get(item['product_uom_id'][0])
+ if so_line.product_uom.category_id == uom.category_id:
+ qty = uom._compute_quantity(item['unit_amount'], so_line.product_uom, rounding_method='HALF-UP')
+ else:
+ qty = item['unit_amount']
+
+ work = work_type_map.get(item['work_type_id'][0])
+ qty *= work.timesheet_billing_rate or 0.0
+ result[so_line_id] += qty
+
+ return result
diff --git a/sale_timesheet_work_entry_rate/models/timesheet.py b/sale_timesheet_work_entry_rate/models/timesheet.py
new file mode 100644
index 00000000..799db068
--- /dev/null
+++ b/sale_timesheet_work_entry_rate/models/timesheet.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 AccountAnalyticLine(models.Model):
+ _inherit = 'account.analytic.line'
+
+ work_billing_rate = fields.Float(related='work_type_id.timesheet_billing_rate', string='Billing Multiplier')
diff --git a/sale_timesheet_work_entry_rate/models/work_entry.py b/sale_timesheet_work_entry_rate/models/work_entry.py
new file mode 100644
index 00000000..800e124e
--- /dev/null
+++ b/sale_timesheet_work_entry_rate/models/work_entry.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 HrWorkEntryType(models.Model):
+ _inherit = 'hr.work.entry.type'
+
+ timesheet_billing_rate = fields.Float(string='Timesheet Billing Multiplier', default=1.0)
diff --git a/sale_timesheet_work_entry_rate/tests/__init__.py b/sale_timesheet_work_entry_rate/tests/__init__.py
new file mode 100644
index 00000000..41510a81
--- /dev/null
+++ b/sale_timesheet_work_entry_rate/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_sale_flow
diff --git a/sale_timesheet_work_entry_rate/tests/test_sale_flow.py b/sale_timesheet_work_entry_rate/tests/test_sale_flow.py
new file mode 100644
index 00000000..302c12c6
--- /dev/null
+++ b/sale_timesheet_work_entry_rate/tests/test_sale_flow.py
@@ -0,0 +1,96 @@
+# Part of Hibou Suite Professional. See LICENSE_PROFESSIONAL file for full copyright and licensing details.
+
+from odoo.addons.sale_timesheet.tests.test_project_billing import TestProjectBilling
+
+
+class TestSaleFlow(TestProjectBilling):
+
+ # Mainly from test_billing_task_rate
+ # Additional tests at the bottom.
+ def test_billing_work_entry_rate(self):
+ Task = self.env['project.task'].with_context(tracking_disable=True)
+ Timesheet = self.env['account.analytic.line']
+
+ # set subtask project on task rate project
+ self.project_task_rate.write({'subtask_project_id': self.project_subtask.id})
+
+ # create a task
+ task = Task.with_context(default_project_id=self.project_task_rate.id).create({
+ 'name': 'first task',
+ })
+ task._onchange_project()
+
+ self.assertEqual(task.sale_line_id, self.project_task_rate.sale_line_id, "Task created in a project billed on 'task rate' should be linked to a SOL of the project")
+ self.assertEqual(task.partner_id, task.project_id.partner_id, "Task created in a project billed on 'employee rate' should have the same customer as the one from the project")
+
+ # log timesheet on task
+ timesheet1 = Timesheet.create({
+ 'name': 'Test Line',
+ 'project_id': task.project_id.id,
+ 'task_id': task.id,
+ 'unit_amount': 50,
+ 'employee_id': self.employee_manager.id,
+ })
+
+ self.assertEqual(self.project_task_rate.sale_line_id, timesheet1.so_line, "The timesheet should be linked to the SOL associated to the Employee manager in the map")
+
+ # create a subtask
+ subtask = Task.with_context(default_project_id=self.project_task_rate.subtask_project_id.id).create({
+ 'name': 'first subtask task',
+ 'parent_id': task.id,
+ })
+
+ self.assertEqual(subtask.partner_id, subtask.parent_id.partner_id, "Subtask should have the same customer as the one from their mother")
+
+ # log timesheet on subtask
+ timesheet2 = Timesheet.create({
+ 'name': 'Test Line on subtask',
+ 'project_id': subtask.project_id.id,
+ 'task_id': subtask.id,
+ 'unit_amount': 50,
+ 'employee_id': self.employee_user.id,
+ })
+
+ self.assertEqual(subtask.project_id, timesheet2.project_id, "The timesheet is in the subtask project")
+ self.assertFalse(timesheet2.so_line, "The timesheet should not be linked to SOL as it's a non billable project")
+
+ # move task and subtask into task rate project
+ task.write({
+ 'project_id': self.project_employee_rate.id,
+ })
+ task._onchange_project()
+ subtask.write({
+ 'project_id': self.project_employee_rate.id,
+ })
+ subtask._onchange_project()
+
+ self.assertFalse(task.sale_line_id, "Task moved in a employee rate billable project have empty so line")
+ self.assertEqual(task.partner_id, task.project_id.partner_id, "Task created in a project billed on 'employee rate' should have the same customer as the one from the project")
+
+ self.assertFalse(subtask.sale_line_id, "Subask moved in a employee rate billable project have empty so line")
+ self.assertEqual(subtask.partner_id, task.project_id.partner_id, "Subask created in a project billed on 'employee rate' should have the same customer as the one from the project")
+
+ # Work Entry Type
+ task.write({
+ 'project_id': self.project_task_rate.id,
+ })
+ task._onchange_project()
+ subtask.write({
+ 'project_id': self.project_task_rate.id,
+ })
+ subtask._onchange_project()
+ default_work_entry_type = self.env.ref('hr_timesheet_work_entry.work_input_timesheet')
+ # Timesheets were for regular default 'Timesheet' type
+ self.assertEqual((timesheet1 + timesheet2).mapped('work_type_id'), default_work_entry_type)
+ # Line is set and total adds up to all of the timesheets.
+ self.assertEqual(task.sale_line_id, self.so2_line_deliver_project_task)
+ self.assertEqual(task.sale_line_id.qty_delivered, 100.0)
+
+ double_rate_work_entry_type = self.env.ref('sale_timesheet_work_entry_rate.work_input_timesheet_double')
+ self.assertEqual(double_rate_work_entry_type.timesheet_billing_rate, 2.0)
+
+ # Convert to double rate.
+ timesheet1.write({
+ 'work_type_id': double_rate_work_entry_type.id,
+ })
+ self.assertEqual(task.sale_line_id.qty_delivered, 150.0)
diff --git a/sale_timesheet_work_entry_rate/views/timesheet_views.xml b/sale_timesheet_work_entry_rate/views/timesheet_views.xml
new file mode 100644
index 00000000..761424bd
--- /dev/null
+++ b/sale_timesheet_work_entry_rate/views/timesheet_views.xml
@@ -0,0 +1,18 @@
+
+
+
+
+ project.task.form.inherited.inherit
+ project.task
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sale_timesheet_work_entry_rate/views/work_entry_views.xml b/sale_timesheet_work_entry_rate/views/work_entry_views.xml
new file mode 100644
index 00000000..ddac159c
--- /dev/null
+++ b/sale_timesheet_work_entry_rate/views/work_entry_views.xml
@@ -0,0 +1,15 @@
+
+
+
+
+ hr.work.type.view.form.inherit
+ hr.work.entry.type
+
+
+
+
+
+
+
+
+