From 9b00e858eba9ca96e2e64f5ce67cfd71314c978b Mon Sep 17 00:00:00 2001 From: shrm-odoo Date: Tue, 30 Jul 2024 12:11:52 +0530 Subject: [PATCH] [IMP] sale, (*_)loyalty: taxes on discounted amounts Before this commit: Taxes are applied to all reward lines regardless of discount mode or discount applicability . After this commit: Reward lines that reflect discounts on the order total will have a unit price matching the discount amount, and taxes will not be applied unless they are set manually for that reward or discount. This change applies when the discount mode is either per point or per order, and the discount applicability is on the order and also for fixed amount discounts. --- addons/loyalty/__manifest__.py | 2 +- addons/loyalty/models/loyalty_reward.py | 6 + addons/loyalty/views/loyalty_reward_views.xml | 8 ++ .../static/src/overrides/models/loyalty.js | 65 +++++++++++ addons/sale/tests/test_sale_order_discount.py | 18 +++ addons/sale/wizard/sale_order_discount.py | 8 +- .../sale/wizard/sale_order_discount_views.xml | 7 ++ addons/sale_loyalty/models/sale_order.py | 110 ++++++++++++------ .../tests/test_program_numbers.py | 82 ++++++++++--- 9 files changed, 248 insertions(+), 58 deletions(-) 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)