diff --git a/addons/loyalty/__manifest__.py b/addons/loyalty/__manifest__.py
index 54bfed2cd01c9..6855d6ead7d23 100644
--- a/addons/loyalty/__manifest__.py
+++ b/addons/loyalty/__manifest__.py
@@ -5,7 +5,7 @@
'summary': "Use discounts, gift card, eWallets and loyalty programs in different sales channels",
'category': 'Sales',
'version': '1.0',
- 'depends': ['product'],
+ 'depends': ['product', 'portal', 'account'],
'data': [
'security/ir.model.access.csv',
'security/loyalty_security.xml',
diff --git a/addons/loyalty/models/loyalty_reward.py b/addons/loyalty/models/loyalty_reward.py
index 8b54701df34b2..8ca888ce9511e 100644
--- a/addons/loyalty/models/loyalty_reward.py
+++ b/addons/loyalty/models/loyalty_reward.py
@@ -79,6 +79,12 @@ def _compute_display_name(self):
discount_line_product_id = fields.Many2one('product.product', copy=False, ondelete='restrict',
help="Product used in the sales order to apply the discount. Each reward has its own product for reporting purpose")
is_global_discount = fields.Boolean(compute='_compute_is_global_discount')
+ tax_ids = fields.Many2many(
+ string="Taxes",
+ help="Taxes to add on the discount line.",
+ comodel_name='account.tax',
+ domain="[('type_tax_use', '=', 'sale'), ('company_id', '=', company_id)]",
+ )
# Product rewards
reward_product_id = fields.Many2one('product.product', string='Product')
diff --git a/addons/loyalty/views/loyalty_reward_views.xml b/addons/loyalty/views/loyalty_reward_views.xml
index 6e1c8a904c60d..802c739cf3b57 100644
--- a/addons/loyalty/views/loyalty_reward_views.xml
+++ b/addons/loyalty/views/loyalty_reward_views.xml
@@ -33,6 +33,14 @@
+
diff --git a/addons/pos_loyalty/static/src/overrides/models/loyalty.js b/addons/pos_loyalty/static/src/overrides/models/loyalty.js
index 83482f09a97ef..faf020b50d699 100644
--- a/addons/pos_loyalty/static/src/overrides/models/loyalty.js
+++ b/addons/pos_loyalty/static/src/overrides/models/loyalty.js
@@ -6,6 +6,11 @@ import { roundDecimals, roundPrecision } from "@web/core/utils/numbers";
import { _t } from "@web/core/l10n/translation";
import { patch } from "@web/core/utils/patch";
import { ConfirmPopup } from "@point_of_sale/app/utils/confirm_popup/confirm_popup";
+import { loyaltyIdsGenerator } from "./pos_store";
+import {
+ compute_price_force_price_include,
+ getTaxesAfterFiscalPosition,
+} from "@point_of_sale/app/models/utils/tax_utils";
const { DateTime } = luxon;
const mutex = new Mutex(); // Used for sequential cache updates
@@ -1353,6 +1358,66 @@ patch(Order.prototype, {
},
];
}
+
+ if (
+ rewardAppliesTo === "order" &&
+ ["per_point", "per_order"].includes(reward.discount_mode)
+ ) {
+ const rewardLineValues = [
+ {
+ product_id: discountProduct,
+ price_unit: -Math.min(maxDiscount, discountable),
+ qty: 1,
+ reward_id: reward,
+ is_reward_line: true,
+ coupon_id: coupon_id,
+ points_cost: pointCost,
+ reward_identifier_code: rewardCode,
+ tax_ids: [],
+ },
+ ];
+
+ let rewardTaxes = reward.tax_ids;
+ if (rewardTaxes.length > 0) {
+ if (this.fiscal_position_id) {
+ rewardTaxes = getTaxesAfterFiscalPosition(
+ rewardTaxes,
+ this.fiscal_position_id,
+ this.models
+ );
+ }
+
+ // Check for any order line where its taxes exactly match rewardTaxes
+ const matchingLines = this.get_orderlines().filter(
+ (line) =>
+ !line.is_delivery &&
+ line.tax_ids.length === rewardTaxes.length &&
+ line.tax_ids.every((tax_id) => rewardTaxes.includes(tax_id))
+ );
+
+ if (matchingLines.length == 0) {
+ return _t("No product is compatible with this promotion.");
+ }
+
+ const untaxedAmount = matchingLines.reduce(
+ (sum, line) => sum + line.get_price_without_tax(),
+ 0
+ );
+ // Discount amount should not exceed total untaxed amount of the matching lines
+ rewardLineValues[0].price_unit = Math.max(
+ -untaxedAmount,
+ rewardLineValues[0].price_unit
+ );
+
+ rewardLineValues[0].tax_ids = rewardTaxes;
+ }
+ // Discount amount should not exceed the untaxed amount on the order
+ if (Math.abs(rewardLineValues[0].price_unit) > this.amount_untaxed) {
+ rewardLineValues[0].price_unit = -this.amount_untaxed;
+ }
+ return rewardLineValues;
+ }
+
const discountFactor = discountable ? Math.min(1, maxDiscount / discountable) : 1;
const result = Object.entries(discountablePerTax).reduce((lst, entry) => {
// Ignore 0 price lines
diff --git a/addons/sale/tests/test_sale_order_discount.py b/addons/sale/tests/test_sale_order_discount.py
index ab2475ccaaa95..24ec3418dbabc 100644
--- a/addons/sale/tests/test_sale_order_discount.py
+++ b/addons/sale/tests/test_sale_order_discount.py
@@ -30,6 +30,24 @@ def test_amount(self):
self.assertEqual(discount_line.product_uom_qty, 1.0)
self.assertFalse(discount_line.tax_id)
+ def test_amount_with_manual_tax(self):
+ self.tax_15pc_excl = self.env['account.tax'].create({
+ 'name': "15% Tax excl",
+ 'amount_type': 'percent',
+ 'amount': 15,
+ })
+ self.wizard.write({
+ 'discount_amount': 55,
+ 'discount_type': 'amount',
+ 'tax_ids': [(6, 0, (self.tax_15pc_excl.id,))],
+ })
+ self.wizard.action_apply_discount()
+
+ discount_line = self.sale_order.order_line[-1]
+ self.assertEqual(discount_line.price_unit, -55)
+ self.assertEqual(discount_line.product_uom_qty, 1.0)
+ self.assertEqual(discount_line.price_total, -63.25)
+
def test_so_discount(self):
solines = self.sale_order.order_line
amount_before_discount = self.sale_order.amount_total
diff --git a/addons/sale/wizard/sale_order_discount.py b/addons/sale/wizard/sale_order_discount.py
index 5d6815549ceb5..7dd4aaeefbc1b 100644
--- a/addons/sale/wizard/sale_order_discount.py
+++ b/addons/sale/wizard/sale_order_discount.py
@@ -25,6 +25,12 @@ class SaleOrderDiscount(models.TransientModel):
],
default='sol_discount',
)
+ tax_ids = fields.Many2many(
+ string="Taxes",
+ help="Taxes to add on the discount line.",
+ comodel_name='account.tax',
+ domain="[('type_tax_use', '=', 'sale'), ('company_id', '=', company_id)]",
+ )
# CONSTRAINT METHODS #
@@ -97,7 +103,7 @@ def _create_discount_lines(self):
self._prepare_discount_line_values(
product=discount_product,
amount=self.discount_amount,
- taxes=self.env['account.tax'],
+ taxes=self.tax_ids,
)
]
else: # so_discount
diff --git a/addons/sale/wizard/sale_order_discount_views.xml b/addons/sale/wizard/sale_order_discount_views.xml
index 047e7a68b43fc..428348afb5a9f 100644
--- a/addons/sale/wizard/sale_order_discount_views.xml
+++ b/addons/sale/wizard/sale_order_discount_views.xml
@@ -21,6 +21,13 @@
+
diff --git a/addons/sale_loyalty/models/sale_order.py b/addons/sale_loyalty/models/sale_order.py
index a2391c7fb9702..84702dac70c34 100644
--- a/addons/sale_loyalty/models/sale_order.py
+++ b/addons/sale_loyalty/models/sale_order.py
@@ -325,74 +325,78 @@ def _get_reward_values_discount(self, reward, coupon, **kwargs):
self.ensure_one()
assert reward.reward_type == 'discount'
- # Figure out which lines are concerned by the discount
- # cheapest_line = self.env['sale.order.line']
+ reward_applies_on = reward.discount_applicability
+ reward_product = reward.discount_line_product_id
+ reward_program = reward.program_id
+ reward_currency = reward.currency_id
+ sequence = max(
+ self.order_line.filtered(lambda x: not x.is_reward_line).mapped('sequence'),
+ default=10
+ ) + 1
+ base_reward_line_values = {
+ 'product_id': reward_product.id,
+ 'product_uom_qty': 1.0,
+ 'product_uom': reward_product.uom_id.id,
+ 'tax_id': [Command.clear()],
+ 'name': reward.description,
+ 'reward_id': reward.id,
+ 'coupon_id': coupon.id,
+ 'sequence': sequence,
+ 'reward_identifier_code': _generate_random_reward_code(),
+ }
+
discountable = 0
discountable_per_tax = defaultdict(int)
- reward_applies_on = reward.discount_applicability
- sequence = max(self.order_line.filtered(lambda x: not x.is_reward_line).mapped('sequence'), default=10) + 1
if reward_applies_on == 'order':
discountable, discountable_per_tax = self._discountable_order(reward)
elif reward_applies_on == 'specific':
discountable, discountable_per_tax = self._discountable_specific(reward)
elif reward_applies_on == 'cheapest':
discountable, discountable_per_tax = self._discountable_cheapest(reward)
+
if not discountable:
- if not reward.program_id.is_payment_program and any(line.reward_id.program_id.is_payment_program for line in self.order_line):
+ if not reward_program.is_payment_program and any(line.reward_id.program_id.is_payment_program for line in self.order_line):
return [{
+ **base_reward_line_values,
'name': _("TEMPORARY DISCOUNT LINE"),
- 'product_id': reward.discount_line_product_id.id,
'price_unit': 0,
'product_uom_qty': 0,
- 'product_uom': reward.discount_line_product_id.uom_id.id,
- 'reward_id': reward.id,
- 'coupon_id': coupon.id,
'points_cost': 0,
- 'reward_identifier_code': _generate_random_reward_code(),
- 'sequence': sequence,
- 'tax_id': [(Command.CLEAR, 0, 0)]
}]
raise UserError(_('There is nothing to discount'))
- max_discount = reward.currency_id._convert(reward.discount_max_amount, self.currency_id, self.company_id, fields.Date.today()) or float('inf')
+
+ max_discount = reward_currency._convert(reward.discount_max_amount, self.currency_id, self.company_id, fields.Date.today()) or float('inf')
# discount should never surpass the order's current total amount
max_discount = min(self.amount_total, max_discount)
if reward.discount_mode == 'per_point':
points = self._get_real_points_for_coupon(coupon)
- if not reward.program_id.is_payment_program:
+ if not reward_program.is_payment_program:
# Rewards cannot be partially offered to customers
points = points // reward.required_points * reward.required_points
max_discount = min(max_discount,
- reward.currency_id._convert(reward.discount * points,
+ reward_currency._convert(reward.discount * points,
self.currency_id, self.company_id, fields.Date.today()))
elif reward.discount_mode == 'per_order':
max_discount = min(max_discount,
- reward.currency_id._convert(reward.discount, self.currency_id, self.company_id, fields.Date.today()))
+ reward_currency._convert(reward.discount, self.currency_id, self.company_id, fields.Date.today()))
elif reward.discount_mode == 'percent':
max_discount = min(max_discount, discountable * (reward.discount / 100))
+
# Discount per taxes
- reward_code = _generate_random_reward_code()
point_cost = reward.required_points if not reward.clear_wallet else self._get_real_points_for_coupon(coupon)
if reward.discount_mode == 'per_point' and not reward.clear_wallet:
# Calculate the actual point cost if the cost is per point
- converted_discount = self.currency_id._convert(min(max_discount, discountable), reward.currency_id, self.company_id, fields.Date.today())
+ converted_discount = self.currency_id._convert(min(max_discount, discountable), reward_currency, self.company_id, fields.Date.today())
point_cost = converted_discount / reward.discount
- # Gift cards and eWallets are considered gift cards and should not have any taxes
- if reward.program_id.is_payment_program:
- reward_product = reward.discount_line_product_id
+
+ if reward_program.is_payment_program: # Gift card / eWallet
reward_line_values = {
- 'name': reward.description,
- 'product_id': reward_product.id,
+ **base_reward_line_values,
'price_unit': -min(max_discount, discountable),
- 'product_uom_qty': 1.0,
- 'product_uom': reward_product.uom_id.id,
- 'reward_id': reward.id,
- 'coupon_id': coupon.id,
'points_cost': point_cost,
- 'reward_identifier_code': reward_code,
- 'sequence': sequence,
- 'tax_id': [Command.clear()],
}
- if reward.program_id.program_type == 'gift_card':
+
+ if reward_program.program_type == 'gift_card':
# For gift cards, the SOL should consider the discount product taxes
taxes_to_apply = reward_product.taxes_id._filter_taxes_by_company(self.company_id)
if taxes_to_apply:
@@ -417,6 +421,42 @@ def _get_reward_values_discount(self, reward, coupon, **kwargs):
'tax_id': [Command.set(mapped_taxes.ids)],
})
return [reward_line_values]
+
+ if reward_applies_on == 'order' and reward.discount_mode in ['per_point', 'per_order']:
+ reward_line_values = {
+ **base_reward_line_values,
+ 'price_unit': -min(max_discount, discountable),
+ 'points_cost': point_cost,
+ }
+
+ reward_taxes = reward.tax_ids._filter_taxes_by_company(self.company_id)
+ if reward_taxes:
+ mapped_taxes = self.fiscal_position_id.map_tax(reward_taxes)
+
+ # Check for any order line where its taxes exactly match reward_taxes
+ matching_lines = [
+ line for line in self.order_line
+ if not line.is_delivery and set(line.tax_id) == set(mapped_taxes)
+ ]
+
+ if not matching_lines:
+ raise ValidationError(_("No product is compatible with this promotion."))
+
+ untaxed_amount = sum(line.price_subtotal for line in matching_lines)
+ # Discount amount should not exceed total untaxed amount of the matching lines
+ reward_line_values['price_unit'] = max(
+ -untaxed_amount,
+ reward_line_values['price_unit']
+ )
+
+ reward_line_values['tax_id'] = [Command.set(mapped_taxes.ids)]
+
+ # Discount amount should not exceed the untaxed amount on the order
+ if abs(reward_line_values['price_unit']) > self.amount_untaxed:
+ reward_line_values['price_unit'] = -self.amount_untaxed
+
+ return [reward_line_values]
+
discount_factor = min(1, (max_discount / discountable)) if discountable else 1
reward_dict = {}
for tax, price in discountable_per_tax.items():
@@ -430,20 +470,14 @@ def _get_reward_values_discount(self, reward, coupon, **kwargs):
taxes=", ".join(mapped_taxes.mapped('name')),
)
reward_dict[tax] = {
+ **base_reward_line_values,
'name': _(
'Discount: %(desc)s%(tax_str)s',
desc=reward.description,
tax_str=tax_desc,
),
- 'product_id': reward.discount_line_product_id.id,
'price_unit': -(price * discount_factor),
- 'product_uom_qty': 1.0,
- 'product_uom': reward.discount_line_product_id.uom_id.id,
- 'reward_id': reward.id,
- 'coupon_id': coupon.id,
'points_cost': 0,
- 'reward_identifier_code': reward_code,
- 'sequence': sequence,
'tax_id': [Command.clear()] + [Command.link(tax.id) for tax in mapped_taxes]
}
# We only assign the point cost to one line to avoid counting the cost multiple times
diff --git a/addons/sale_loyalty/tests/test_program_numbers.py b/addons/sale_loyalty/tests/test_program_numbers.py
index d570ec9a88269..af2a09c449a7a 100644
--- a/addons/sale_loyalty/tests/test_program_numbers.py
+++ b/addons/sale_loyalty/tests/test_program_numbers.py
@@ -600,6 +600,52 @@ def test_coupon_rule_minimum_amount(self):
self._auto_rewards(order, self.all_programs)
self.assertEqual(order.amount_total, 65.0, "The coupon should not be removed from the order")
+ def test_coupon_discount_with_taxes_applied(self):
+ """Ensure coupon discount with taxes applies correctly
+ and doesn't make the order total go below 0.
+ """
+
+ coupon_program = self.env['loyalty.program'].create({
+ 'name': '$300 coupon',
+ 'program_type': 'coupons',
+ 'trigger': 'with_code',
+ 'applies_on': 'current',
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount_mode': 'per_point',
+ 'discount': 300,
+ 'discount_applicability': 'order',
+ 'required_points': 1,
+ 'tax_ids': [(6, 0, (self.tax_15pc_excl.id,))],
+ })],
+ })
+
+ order = self.empty_order
+ self.env['sale.order.line'].create([
+ {
+ 'product_id': self.conferenceChair.id,
+ 'name': 'Conference Chair',
+ 'product_uom_qty': 1.0,
+ 'price_unit': 100.0,
+ 'order_id': order.id,
+ 'tax_id': [(6, 0, (self.tax_15pc_excl.id,))],
+ },
+ ])
+
+ self.env['loyalty.generate.wizard'].with_context(active_id=coupon_program.id).create({
+ 'coupon_qty': 1,
+ 'points_granted': 1,
+ }).generate_coupons()
+ coupon = coupon_program.coupon_ids
+ self._apply_promo_code(order, coupon.code)
+
+ self.assertEqual(order.amount_tax, 0.0)
+ self.assertEqual(order.amount_untaxed, 0.0, "The untaxed amount should not go below 0")
+ self.assertEqual(
+ order.amount_total, 0.0,
+ "The promotion program should not make the order total go below 0"
+ )
+
def test_coupon_and_program_discount_fixed_amount(self):
""" Ensure coupon and program discount both with
minimum amount rule can cohexists without making
@@ -714,9 +760,9 @@ def test_coupon_and_coupon_discount_fixed_amount_tax_excl(self):
coupon = coupon_program.coupon_ids
self._apply_promo_code(order, coupon.code)
self._auto_rewards(order, self.all_programs)
- self.assertEqual(order.amount_tax, 0.0)
+ self.assertEqual(order.amount_tax, 13.5)
self.assertEqual(order.amount_untaxed, 0.0, "The untaxed amount should not go below 0")
- self.assertEqual(order.amount_total, 0.0, "The promotion program should not make the order total go below 0")
+ self.assertEqual(order.amount_total, 13.5, "The promotion program should not make the order total go below 0")
order.order_line[3:].unlink() #remove all coupon
order._remove_program_from_points(coupon_program)
@@ -729,13 +775,13 @@ def test_coupon_and_coupon_discount_fixed_amount_tax_excl(self):
self._auto_rewards(order, self.all_programs)
self._apply_promo_code(order, 'test_10pc')
self._auto_rewards(order, self.all_programs)
- self.assertAlmostEqual(order.amount_tax, 1.13, 2)
- self.assertEqual(order.amount_untaxed, 22.72)
+ self.assertAlmostEqual(order.amount_tax, 13.5, 2)
+ self.assertEqual(order.amount_untaxed, 10.35)
self.assertEqual(order.amount_total, 23.85, "The promotion program should not make the order total go below 0be altered after recomputation")
# It should stay the same after a recompute, order matters
self._auto_rewards(order, self.all_programs)
- self.assertAlmostEqual(order.amount_tax, 1.13, 2)
- self.assertEqual(order.amount_untaxed, 22.72)
+ self.assertAlmostEqual(order.amount_tax, 13.5, 2)
+ self.assertEqual(order.amount_untaxed, 10.35)
self.assertEqual(order.amount_total, 23.85, "The promotion program should not make the order total go below 0be altered after recomputation")
def test_coupon_and_coupon_discount_fixed_amount_tax_incl(self):
@@ -800,11 +846,11 @@ def test_coupon_and_coupon_discount_fixed_amount_tax_incl(self):
}).generate_coupons()
coupon = coupon_program.coupon_ids
self._apply_promo_code(order, coupon.code)
- self.assertEqual(order.amount_total, 0.0, "The promotion program should not make the order total go below 0")
- self.assertEqual(order.amount_tax, 0)
+ self.assertEqual(order.amount_total, 8.18, "The promotion program should not make the order total go below 0")
+ self.assertEqual(order.amount_tax, 8.18)
self._auto_rewards(order, self.all_programs)
- self.assertEqual(order.amount_total, 0.0, "The promotion program should not be altered after recomputation")
- self.assertEqual(order.amount_tax, 0)
+ self.assertEqual(order.amount_total, 8.18, "The promotion program should not be altered after recomputation")
+ self.assertEqual(order.amount_tax, 8.18)
order.order_line[3:].unlink() #remove all coupon
order._remove_program_from_points(coupon_program)
@@ -817,13 +863,13 @@ def test_coupon_and_coupon_discount_fixed_amount_tax_incl(self):
self._apply_promo_code(order, 'test_10pc')
self._auto_rewards(order, self.all_programs)
self.assertEqual(order.amount_total, 9.0, "The promotion program should not make the order total go below 0")
- self.assertEqual(order.amount_tax, 0.27)
- self.assertEqual(order.amount_untaxed, 8.73)
+ self.assertEqual(order.amount_tax, 8.18)
+ self.assertEqual(order.amount_untaxed, 0.82)
# It should stay the same after a recompute, order matters
self._auto_rewards(order, self.all_programs)
self.assertEqual(order.amount_total, 9.0, "The promotion program should not make the order total go below 0")
- self.assertEqual(order.amount_tax, 0.27)
- self.assertEqual(order.amount_untaxed, 8.73)
+ self.assertEqual(order.amount_tax, 8.18)
+ self.assertEqual(order.amount_untaxed, 0.82)
def test_program_discount_on_multiple_specific_products(self):
""" Ensure a discount on multiple specific products is correctly computed.
@@ -1492,13 +1538,13 @@ def test_fixed_amount_taxes_attribution(self):
self._auto_rewards(order, program)
self.assertEqual(order.amount_total, 7, 'Price should be 12$ - 5$(discount) = 7$')
- self.assertEqual(float_compare(order.amount_tax, 7 / 12, precision_rounding=3), 0, '20% Tax included on 7$')
+ self.assertEqual(float_compare(order.amount_tax, 7 / 4, precision_rounding=3), 0, '20% Tax included on 7$')
sol.tax_id = self.tax_10pc_base_incl + self.tax_10pc_excl
self._auto_rewards(order, program)
self.assertAlmostEqual(order.amount_total, 6, 1, msg='Price should be 11$ - 5$(discount) = 6$')
- self.assertEqual(float_compare(order.amount_tax, 6 / 12, precision_rounding=3), 0, '20% Tax included on 6$')
+ self.assertEqual(float_compare(order.amount_tax, 6 / 4, precision_rounding=3), 0, '20% Tax included on 6$')
def test_fixed_amount_taxes_attribution_multiline(self):
@@ -1548,8 +1594,8 @@ def test_fixed_amount_taxes_attribution_multiline(self):
self._auto_rewards(order, program)
self.assertAlmostEqual(order.amount_total, 16, 1, msg='Price should be 21$ - 5$(discount) = 16$')
- # Tax amount = 10% in 10$ + 10% in 11$ - 10% in 5$ (apply on excluded)
- self.assertEqual(float_compare(order.amount_tax, 5 / 11, precision_rounding=3), 0)
+ # Tax amount = 10% in 10$ + 10% in 11$
+ self.assertEqual(float_compare(order.amount_tax, 5 / 3, precision_rounding=3), 0)
sol2.tax_id = self.tax_10pc_base_incl + self.tax_10pc_excl
self._auto_rewards(order, program)