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)