Skip to content

Commit e9fe5f4

Browse files
committed
[IMP] delivery_fee: support refunds and one time fees
MT-13719
1 parent d87070e commit e9fe5f4

File tree

11 files changed

+277
-17
lines changed

11 files changed

+277
-17
lines changed

delivery_fee/__manifest__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"data": [
1515
"views/res_partner_views.xml",
1616
"views/delivery_carrier_views.xml",
17+
"views/res_config_settings.xml",
1718
"reports/delivery_slip_report.xml",
1819
"reports/invoice_report.xml",
1920
],

delivery_fee/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from . import delivery_carrier
2+
from . import res_config_settings
3+
from . import res_company
24
from . import res_partner
35
from . import sale_order
46
from . import stock_picking

delivery_fee/models/delivery_carrier.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,8 @@ class DeliveryCarrier(models.Model):
1111
ondelete="restrict",
1212
domain="[('type', '=', 'service')]",
1313
)
14+
fee_return_percentage = fields.Float(
15+
default=0,
16+
help="% of the fee to be returned to the customer in case of full return. "
17+
"E.g.: 0% for no return, 100% for full return",
18+
)

delivery_fee/models/res_company.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Copyright 2026 Moduon
2+
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
3+
from odoo import fields, models
4+
5+
6+
class ResCompany(models.Model):
7+
_inherit = "res.company"
8+
9+
one_delivery_fee_by_sale_order = fields.Boolean(
10+
help="The delivery fee will be applied just once to the order",
11+
)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Copyright 2026 Moduon
2+
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
3+
from odoo import fields, models
4+
5+
6+
class ResConfigSetting(models.TransientModel):
7+
_inherit = "res.config.settings"
8+
9+
one_delivery_fee_by_sale_order = fields.Boolean(
10+
related="company_id.one_delivery_fee_by_sale_order",
11+
readonly=False,
12+
)

delivery_fee/models/sale_order.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,22 @@
66
class SaleOrder(models.Model):
77
_inherit = "sale.order"
88

9+
all_fee_pickings_returned = fields.Boolean(
10+
compute="_compute_all_fee_pickings_returned"
11+
)
12+
13+
def _compute_all_fee_pickings_returned(self):
14+
self.all_fee_pickings_returned = False
15+
for order in self:
16+
if not order.order_line.filtered("is_delivery_fee"):
17+
continue
18+
pickings = order.picking_ids.filtered(
19+
lambda x: x._is_to_external_location()
20+
)
21+
order.all_fee_pickings_returned = all(
22+
pick._full_returned() for pick in pickings
23+
)
24+
925
def _prepare_delivery_fee_line_vals(self, picking):
1026
# Based on core `_prepare_delivery_line_vals`
1127
carrier = picking.carrier_id
@@ -47,6 +63,12 @@ def _create_delivery_fee_line(self, picking):
4763
values = self._prepare_delivery_fee_line_vals(picking)
4864
return self.env["sale.order.line"].sudo().create(values)
4965

66+
def copy(self, default=None):
67+
sale_copy = super().copy(default)
68+
# Don't copy fees from one order to another
69+
sale_copy.order_line.filtered("is_delivery_fee").unlink()
70+
return sale_copy
71+
5072

5173
class SaleOrderLine(models.Model):
5274
_inherit = "sale.order.line"
Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,66 @@
11
# Copyright 2026 Moduon
22
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl).
33
from odoo import models
4+
from odoo.tools.float_utils import float_compare
45

56

67
class StockPicking(models.Model):
78
_inherit = "stock.picking"
89

9-
def _add_delivery_cost_to_so(self):
10-
res = super()._add_delivery_cost_to_so()
11-
self._add_delivery_fee_to_order()
10+
def _action_done(self):
11+
res = super()._action_done()
12+
# For returns, we deal with fee reimburse
13+
if self.move_ids.origin_returned_move_id:
14+
self._update_delivery_fee_on_return()
15+
else:
16+
self._add_delivery_fee_to_order()
1217
return res
1318

19+
def _full_returned(self):
20+
full_returned = False
21+
for move in self.move_ids:
22+
full_returned = not float_compare(
23+
move.quantity_done,
24+
sum(
25+
move.returned_move_ids.filtered(lambda x: x.state == "done").mapped(
26+
"quantity_done"
27+
)
28+
),
29+
precision_rounding=move.product_uom.rounding,
30+
)
31+
if not full_returned:
32+
break
33+
return full_returned
34+
35+
def _update_delivery_fee_on_return(self):
36+
"""All pickings returned: we can refund the fee"""
37+
sale = self.move_ids.origin_returned_move_id.picking_id.sale_id
38+
if not sale.all_fee_pickings_returned:
39+
return
40+
for fee_line in sale.order_line.filtered("is_delivery_fee"):
41+
carrier = fee_line.delivery_fee_picking_id.carrier_id
42+
# No fee refund for this carrier
43+
if not carrier.fee_return_percentage:
44+
continue
45+
# We change the initial demand so the type of invoicing policy doesn't
46+
# affect in order to trigger the refund.
47+
fee_line.product_uom_qty = (
48+
fee_line.product_uom_qty
49+
- (fee_line.product_uom_qty * carrier.fee_return_percentage) / 100
50+
)
51+
1452
def _add_delivery_fee_to_order(self):
1553
if (
16-
not self.sale_id
54+
self.picking_type_code != "outgoing"
55+
or not self.sale_id
1756
or self.partner_id.delivery_fee_exemption
1857
or not self.carrier_id.fee_product_id
1958
):
2059
return
60+
# In the case we want to apply the fee just once
61+
if (
62+
self.company_id.one_delivery_fee_by_sale_order
63+
and self.sale_id.order_line.filtered("is_delivery_fee")
64+
):
65+
return
2166
self.sale_id._create_delivery_fee_line(self)

delivery_fee/reports/delivery_slip_report.xml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55
inherit_id="delivery.report_delivery_document2"
66
>
77
<xpath expr="//div[hasclass('page')]" position="inside">
8-
<t t-set="fee_product" t-value="o.carrier_id.fee_product_id" />
8+
<t
9+
t-set="fee_line"
10+
t-value="o.sale_id.order_line.filtered(lambda x: x.is_delivery_fee and x.delivery_fee_picking_id == o)"
11+
/>
12+
<t t-set="fee_product" t-value="fee_line and fee_line.product_id" />
913
<div
10-
t-if="o.sale_id and fee_product.description_sale and not o.partner_id.delivery_fee_exemption"
14+
t-if="fee_product and fee_product.description_sale and not o.partner_id.delivery_fee_exemption"
1115
>
1216
<p class="mt-5 fw-bolder fst-italic">
1317
<img

delivery_fee/tests/test_delivery_fee.py

Lines changed: 140 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ def setUpClass(cls):
3131
"product_id": cls.delivery_product.id,
3232
"fixed_price": 5.0,
3333
"fee_product_id": cls.fee_product.id,
34+
"fee_return_percentage": 75,
3435
}
3536
)
3637
cls.carrier_without_fee = cls.env["delivery.carrier"].create(
@@ -69,30 +70,153 @@ def setUpClass(cls):
6970
],
7071
}
7172
)
73+
# Defaults to `False`, but it's useful to declare it explicitly for local tests
74+
cls.env.company.one_delivery_fee_by_sale_order = False
7275

73-
def test_delivery_fee_added_on_picking_validation(self):
74-
"""Test that delivery fee is added when picking is validated"""
76+
def _validate_picking(self, picking):
77+
picking.action_set_quantities_to_reservation()
78+
picking._action_done()
79+
80+
def _picking_return(self, picking, qty=None):
81+
stock_return_picking_form = Form(
82+
self.env["stock.return.picking"].with_context(
83+
active_ids=picking.ids,
84+
active_id=picking.id,
85+
active_model="stock.picking",
86+
)
87+
)
88+
stock_return_picking = stock_return_picking_form.save()
89+
if qty:
90+
stock_return_picking.product_return_moves.quantity = qty
91+
stock_return_picking_action = stock_return_picking.create_returns()
92+
return_pick = self.env["stock.picking"].browse(
93+
stock_return_picking_action["res_id"]
94+
)
95+
self._validate_picking(return_pick)
96+
return return_pick
97+
98+
def _add_line_to_sale_order(self, sale):
99+
so_form = Form(self.sale_order)
100+
with so_form.order_line.new() as line:
101+
line.product_id = self.product
102+
so_form.save()
103+
104+
def _test_regex_in_report(self, report, res_ids, expression, expected_in_html=True):
105+
"""Helper method to test whether or not a regular expression should be
106+
expected in the report resulting rendering"""
107+
html, _ = self.env["ir.actions.report"]._render_qweb_html(report, res_ids)
108+
assertion = self.assertRegex if expected_in_html else self.assertNotRegex
109+
assertion(str(html), expression)
110+
111+
def _common_test_delivery_fee_added_on_picking_validation(self):
75112
self.sale_order.carrier_id = self.carrier_with_fee
76113
self.sale_order.action_confirm()
77114
picking = self.sale_order.picking_ids
78-
picking.action_set_quantities_to_reservation()
79-
picking._action_done()
115+
self._validate_picking(picking)
80116
fee_lines = self.sale_order.order_line.filtered("is_delivery_fee")
81117
self.assertEqual(len(fee_lines), 1)
82118
self.assertEqual(fee_lines.price_unit, 2.0)
83-
# TODO: test render
84-
# TODO: test invoice
85-
# TODO: test multiple fees
119+
# The fee text is printed
120+
self._test_regex_in_report(
121+
"stock.report_deliveryslip",
122+
picking.ids,
123+
r"Delivery fee charged per shipment.+2",
124+
)
125+
126+
def _common_fee_added_on_picking_validation_refund(self):
127+
""""""
128+
picking_2, picking_1 = self.sale_order.picking_ids
129+
return_pick_1 = self._picking_return(picking_1)
130+
# The fee shouldn't show up in returns
131+
self._test_regex_in_report(
132+
"stock.report_deliveryslip",
133+
return_pick_1.ids,
134+
r"Delivery fee charged per shipment",
135+
expected_in_html=False,
136+
)
137+
self.assertFalse(self.sale_order.all_fee_pickings_returned)
138+
picking_1_fee = self.sale_order.order_line.filtered(
139+
lambda x, pick=picking_1: x.is_delivery_fee
140+
and x.delivery_fee_picking_id == pick
141+
)
142+
self.assertAlmostEqual(picking_1_fee.price_subtotal, 2)
143+
self.assertAlmostEqual(picking_1_fee.product_uom_qty, 1)
144+
return_pick_2 = self._picking_return(picking_2)
145+
self._test_regex_in_report(
146+
"stock.report_deliveryslip",
147+
return_pick_2.ids,
148+
r"Delivery fee charged per shipment",
149+
expected_in_html=False,
150+
)
151+
self.assertTrue(self.sale_order.all_fee_pickings_returned)
152+
self.assertAlmostEqual(picking_1_fee.price_subtotal, 0.50)
153+
self.assertAlmostEqual(picking_1_fee.product_uom_qty, 0.25)
154+
155+
def test_delivery_fee_added_on_picking_validation(self):
156+
"""Test that delivery fee is added when picking is validated"""
157+
self._common_test_delivery_fee_added_on_picking_validation()
158+
existing_picking = self.sale_order.picking_ids
159+
# Let's add a new picking
160+
self._add_line_to_sale_order(self.sale_order)
161+
new_picking = self.sale_order.picking_ids - existing_picking
162+
self._validate_picking(new_picking)
163+
fee_lines = self.sale_order.order_line.filtered("is_delivery_fee")
164+
self.assertEqual(len(fee_lines), 2)
165+
self._test_regex_in_report(
166+
"stock.report_deliveryslip",
167+
new_picking.ids,
168+
r"Delivery fee charged per shipment.+2",
169+
)
170+
# The fee is printed in the invoice report as well
171+
invoice = self.sale_order._create_invoices()
172+
self._test_regex_in_report(
173+
"account.report_invoice",
174+
invoice.ids,
175+
r"Delivery fee charged per shipment.+2",
176+
)
177+
self._common_fee_added_on_picking_validation_refund()
178+
179+
def test_delivery_fee_added_on_picking_validation_one_fee_per_order(self):
180+
"""Same tests as before, but now only one fee is added when the first
181+
picking is validated"""
182+
self.env.company.one_delivery_fee_by_sale_order = True
183+
self._common_test_delivery_fee_added_on_picking_validation()
184+
existing_picking = self.sale_order.picking_ids
185+
# Let's add a new picking
186+
self._add_line_to_sale_order(self.sale_order)
187+
new_picking = self.sale_order.picking_ids - existing_picking
188+
self._validate_picking(new_picking)
189+
fee_lines = self.sale_order.order_line.filtered("is_delivery_fee")
190+
self.assertEqual(len(fee_lines), 1, "The fee should be added just once!")
191+
self._test_regex_in_report(
192+
"stock.report_deliveryslip",
193+
new_picking.ids,
194+
r"Delivery fee charged per shipment",
195+
expected_in_html=False,
196+
)
197+
# The fee is printed in the invoice report as well
198+
invoice = self.sale_order._create_invoices()
199+
self._test_regex_in_report(
200+
"account.report_invoice",
201+
invoice.ids,
202+
r"Delivery fee charged per shipment.+2",
203+
)
204+
self._common_fee_added_on_picking_validation_refund()
86205

87206
def test_no_fee_for_carrier_without_fee_product(self):
88207
"""Test that no fee is added if carrier has no fee product"""
89208
self.sale_order.carrier_id = self.carrier_without_fee
90209
self.sale_order.action_confirm()
91210
picking = self.sale_order.picking_ids
92-
picking.move_ids.quantity_done = 1
93-
picking._action_done()
211+
self._validate_picking(picking)
94212
fee_lines = self.sale_order.order_line.filtered("is_delivery_fee")
95213
self.assertEqual(len(fee_lines), 0)
214+
self._test_regex_in_report(
215+
"stock.report_deliveryslip",
216+
picking.ids,
217+
r"Delivery fee charged per shipment",
218+
expected_in_html=False,
219+
)
96220

97221
def test_exempt_customer_no_fee(self):
98222
"""Test that exempt customers don't get charged delivery fees"""
@@ -102,7 +226,12 @@ def test_exempt_customer_no_fee(self):
102226
self.sale_order.carrier_id = self.carrier_with_fee
103227
self.sale_order.action_confirm()
104228
picking = self.sale_order.picking_ids
105-
picking.move_ids.quantity_done = 1
106-
picking._action_done()
229+
self._validate_picking(picking)
107230
fee_lines = self.sale_order.order_line.filtered("is_delivery_fee")
108231
self.assertEqual(len(fee_lines), 0)
232+
self._test_regex_in_report(
233+
"stock.report_deliveryslip",
234+
picking.ids,
235+
r"Delivery fee charged per shipment",
236+
expected_in_html=False,
237+
)

delivery_fee/views/delivery_carrier_views.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
name="fee_product_id"
1010
context="{'default_detailed_type': 'service', 'default_sale_ok': False, 'default_purchase_ok': False, 'default_invoice_policy': 'order'}"
1111
/>
12+
<field
13+
name="fee_return_percentage"
14+
attrs="{'invisible': [('fee_product_id', '=', False)]}"
15+
/>
1216
</xpath>
1317
</field>
1418
</record>

0 commit comments

Comments
 (0)