diff --git a/addons/sale/models/res_config_settings.py b/addons/sale/models/res_config_settings.py
index a077b690030..806a81187d9 100644
--- a/addons/sale/models/res_config_settings.py
+++ b/addons/sale/models/res_config_settings.py
@@ -77,7 +77,7 @@ class ResConfigSettings(models.TransientModel):
module_product_email_template = fields.Boolean("Specific Email")
module_sale_amazon = fields.Boolean("Amazon Sync")
- module_sale_coupon = fields.Boolean("Coupons & Promotions")
+ module_sale_loyalty = fields.Boolean("Coupons & Loyalty")
module_sale_margin = fields.Boolean("Margins")
#=== ONCHANGE METHODS ===#
diff --git a/addons/sale/views/res_config_settings_views.xml b/addons/sale/views/res_config_settings_views.xml
index b7bec091b48..2b778e70fbe 100644
--- a/addons/sale/views/res_config_settings_views.xml
+++ b/addons/sale/views/res_config_settings_views.xml
@@ -105,10 +105,10 @@
id="coupon_settings"
title="Boost your sales with two kinds of discount programs: promotions and coupon codes. Specific conditions can be set (products, customers, minimum purchase amount, period). Rewards can be discounts (% or amount) or free products.">
-
+
-
+
Manage promotion & coupon programs
diff --git a/addons/sale_loyalty/__init__.py b/addons/sale_loyalty/__init__.py
new file mode 100644
index 00000000000..2ae6446f9dc
--- /dev/null
+++ b/addons/sale_loyalty/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import models
+from . import wizard
diff --git a/addons/sale_loyalty/__manifest__.py b/addons/sale_loyalty/__manifest__.py
new file mode 100644
index 00000000000..4deec5d4df1
--- /dev/null
+++ b/addons/sale_loyalty/__manifest__.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+{
+ 'name': 'Sale Loyalty',
+ 'summary': 'Use discounts and loyalty programs in sales orders',
+ 'description': 'Integrate discount and loyalty programs mechanisms in sales orders.',
+ 'category': 'Sales/Sales',
+ 'version': '1.0',
+ 'depends': ['sale', 'loyalty'],
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'data/sale_loyalty_data.xml',
+ 'wizard/sale_loyalty_coupon_wizard_views.xml',
+ 'wizard/sale_loyalty_reward_wizard_views.xml',
+ 'views/loyalty_card_views.xml',
+ 'views/loyalty_program_views.xml',
+ 'views/sale_order_views.xml',
+ 'views/templates.xml',
+ ],
+ 'license': 'LGPL-3',
+}
diff --git a/addons/sale_loyalty/data/sale_loyalty_data.xml b/addons/sale_loyalty/data/sale_loyalty_data.xml
new file mode 100644
index 00000000000..aa3e3f61a48
--- /dev/null
+++ b/addons/sale_loyalty/data/sale_loyalty_data.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/sale_loyalty/models/__init__.py b/addons/sale_loyalty/models/__init__.py
new file mode 100644
index 00000000000..f909a66085e
--- /dev/null
+++ b/addons/sale_loyalty/models/__init__.py
@@ -0,0 +1,9 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import loyalty_card
+from . import loyalty_program
+from . import loyalty_reward
+from . import sale_order_coupon_points
+from . import sale_order_line
+from . import sale_order
diff --git a/addons/sale_loyalty/models/loyalty_card.py b/addons/sale_loyalty/models/loyalty_card.py
new file mode 100644
index 00000000000..4ad8f546e3a
--- /dev/null
+++ b/addons/sale_loyalty/models/loyalty_card.py
@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+
+class LoyaltyCard(models.Model):
+ _inherit = 'loyalty.card'
+
+ order_id = fields.Many2one('sale.order', 'Order Reference', readonly=True,
+ help="The sales order from which coupon is generated")
+
+ def _get_default_template(self):
+ default_template = super()._get_default_template()
+ if not default_template:
+ default_template = self.env.ref('sale_loyalty.mail_template_sale_loyalty', raise_if_not_found=False)
+ return default_template
+
+ def _get_mail_partner(self):
+ return super()._get_mail_partner() or self.order_id.partner_id
+
+ def _get_signature(self):
+ return self.order_id.user_id.signature or super()._get_signature()
+
+ def _compute_use_count(self):
+ super()._compute_use_count()
+ read_group_res = self.env['sale.order.line']._read_group(
+ [('coupon_id', 'in', self.ids)], ['id'], ['coupon_id'])
+ count_per_coupon = {r['coupon_id'][0]: r['coupon_id_count'] for r in read_group_res}
+ for card in self:
+ card.use_count += count_per_coupon.get(card.id, 0)
diff --git a/addons/sale_loyalty/models/loyalty_program.py b/addons/sale_loyalty/models/loyalty_program.py
new file mode 100644
index 00000000000..835827bc7fd
--- /dev/null
+++ b/addons/sale_loyalty/models/loyalty_program.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+class LoyaltyProgram(models.Model):
+ _inherit = 'loyalty.program'
+
+ order_count = fields.Integer(compute='_compute_order_count')
+
+ def _compute_order_count(self):
+ # An order should count only once PER program but may appear in multiple programs
+ read_group_res = self.env['sale.order.line']._read_group(
+ [('reward_id', 'in', self.reward_ids.ids)], ['reward_id:array_agg'], ['order_id'])
+ for program in self:
+ program_reward_ids = program.reward_ids.ids
+ program.order_count = sum(1 if any(id in group['reward_id'] for id in program_reward_ids) else 0 for group in read_group_res)
+
+ def _compute_total_order_count(self):
+ super()._compute_total_order_count()
+ for program in self:
+ program.total_order_count += program.order_count
diff --git a/addons/sale_loyalty/models/loyalty_reward.py b/addons/sale_loyalty/models/loyalty_reward.py
new file mode 100644
index 00000000000..98bbe1d9deb
--- /dev/null
+++ b/addons/sale_loyalty/models/loyalty_reward.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+
+class LoyaltyReward(models.Model):
+ _inherit = 'loyalty.reward'
+
+ def _get_discount_product_values(self):
+ res = super()._get_discount_product_values()
+ for vals in res:
+ vals.update({
+ 'taxes_id': False,
+ 'supplier_taxes_id': False,
+ })
+ return res
diff --git a/addons/sale_loyalty/models/sale_order.py b/addons/sale_loyalty/models/sale_order.py
new file mode 100644
index 00000000000..37c29d032f5
--- /dev/null
+++ b/addons/sale_loyalty/models/sale_order.py
@@ -0,0 +1,972 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from collections import defaultdict
+
+import itertools
+
+import random
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError, ValidationError
+from odoo.fields import Command
+from odoo.tools.float_utils import float_is_zero, float_round
+from odoo.osv import expression
+
+def _generate_random_reward_code():
+ return str(random.getrandbits(32))
+
+class SaleOrder(models.Model):
+ _inherit = "sale.order"
+
+ # Contains how much points should be given to a coupon upon validating the order
+ applied_coupon_ids = fields.Many2many('loyalty.card', string="Manually Applied Coupons", copy=False)
+ code_enabled_rule_ids = fields.Many2many('loyalty.rule', string="Manually Triggered Rules", copy=False)
+ coupon_point_ids = fields.One2many('sale.order.coupon.points', 'order_id', copy=False)
+ reward_amount = fields.Float(compute='_compute_reward_total')
+
+ @api.depends('order_line')
+ def _compute_reward_total(self):
+ for order in self:
+ reward_amount = 0
+ for line in order.order_line:
+ if not line.reward_id:
+ continue
+ if line.reward_id.reward_type != 'product':
+ reward_amount += line.price_subtotal
+ else:
+ # Free product are 'regular' product lines with a price_unit of 0
+ reward_amount -= line.product_id.lst_price * line.product_uom_qty
+ order.reward_amount = reward_amount
+
+ def _get_no_effect_on_threshold_lines(self):
+ """
+ Returns the lines that have no effect on the minimum amount to reach
+ """
+ self.ensure_one()
+ return self.env['sale.order.line']
+
+ @api.returns('self', lambda value: value.id)
+ def copy(self, default=None):
+ order = super(SaleOrder, self).copy(default)
+ reward_lines = order.order_line.filtered('is_reward_line')
+ if reward_lines:
+ reward_lines.unlink()
+ return order
+
+ def action_confirm(self):
+ for order in self:
+ all_coupons = order.applied_coupon_ids | order.coupon_point_ids.coupon_id | order.order_line.coupon_id
+ if any(order._get_real_points_for_coupon(coupon) < 0 for coupon in all_coupons):
+ raise ValidationError(_('One or more rewards on the sale order is invalid. Please check them.'))
+ order._update_programs_and_rewards()
+
+ # Remove any coupon from 'current' program that don't claim any reward.
+ # This is to avoid ghost coupons that are lost forever.
+ # Claiming a reward for that program will require either an automated check or a manual input again.
+ reward_coupons = self.order_line.coupon_id
+ self.coupon_point_ids.filtered(
+ lambda pe: pe.coupon_id.program_id.applies_on == 'current' and pe.coupon_id not in reward_coupons)\
+ .coupon_id.sudo().unlink()
+ # Add/remove the points to our coupons
+ for coupon, change in self.filtered(lambda s: s.state != 'sale')._get_point_changes().items():
+ coupon.points += change
+ self._send_reward_coupon_mail()
+ return super(SaleOrder, self).action_confirm()
+
+ def action_cancel(self):
+ previously_confirmed = self.filtered(lambda s: s.state in ('sale', 'done'))
+ res = super(SaleOrder, self).action_cancel()
+ # Add/remove the points to our coupons
+ for coupon, changes in previously_confirmed.filtered(lambda s: s.state not in ('sale', 'done'))._get_point_changes().items():
+ coupon.points -= changes
+ # Remove any rewards
+ self.order_line.filtered(lambda l: l.is_reward_line).unlink()
+ self.coupon_point_ids.coupon_id.filtered(
+ lambda c: not c.program_id.is_nominative and c.order_id in self and not c.use_count)\
+ .sudo().unlink()
+ self.coupon_point_ids.unlink()
+ return res
+
+ def action_open_reward_wizard(self):
+ self.ensure_one()
+ self._update_programs_and_rewards()
+ claimable_rewards = self._get_claimable_rewards()
+ if len(claimable_rewards) == 1:
+ coupon = next(iter(claimable_rewards))
+ if len(claimable_rewards[coupon]) == 1:
+ self._apply_program_reward(claimable_rewards[coupon], coupon)
+ return True
+ elif not claimable_rewards:
+ return True
+ return self.env['ir.actions.actions']._for_xml_id('sale_loyalty.sale_loyalty_reward_wizard_action')
+
+ def _send_reward_coupon_mail(self):
+ coupons = self.env['loyalty.card']
+ for order in self:
+ coupons |= order._get_reward_coupons()
+ if coupons:
+ coupons._send_creation_communication()
+
+ def _get_applied_global_discount_lines(self):
+ """
+ Returns the first line of the currently applied global discount or False
+ """
+ self.ensure_one()
+ return self.order_line.filtered(lambda l: l.reward_id.is_global_discount)
+
+ def _get_applied_global_discount(self):
+ """
+ Returns the currently applied global discount reward or False
+ """
+ return self._get_applied_global_discount_lines().reward_id
+
+ def _get_reward_values_product(self, reward, coupon, product=None, **kwargs):
+ """
+ Returns an array of dict containing the values required for the reward lines
+ """
+ self.ensure_one()
+ assert reward.reward_type == 'product'
+
+ reward_products = reward.reward_product_ids
+ product = product or reward_products[:1]
+ if not product or not product in reward_products:
+ raise UserError(_('Invalid product to claim.'))
+ taxes = self.fiscal_position_id.map_tax(product.taxes_id.filtered(lambda t: t.company_id == self.company_id))
+ points = self._get_real_points_for_coupon(coupon)
+ claimable_count = float_round(points / reward.required_points, precision_rounding=1, rounding_method='DOWN') if not reward.clear_wallet else 1
+ cost = points if reward.clear_wallet else claimable_count * reward.required_points
+ return [{
+ 'name': _("Free Product - %(product)s", product=product.name),
+ 'product_id': product.id,
+ 'price_unit': 0,
+ 'product_uom_qty': reward.reward_product_qty * claimable_count,
+ 'reward_id': reward.id,
+ 'coupon_id': coupon.id,
+ 'points_cost': cost,
+ 'reward_identifier_code': _generate_random_reward_code(),
+ 'product_uom': product.uom_id.id,
+ 'tax_id': [(Command.CLEAR, 0, 0)] + [(Command.LINK, tax.id, False) for tax in taxes]
+ }]
+
+ def _discountable_order(self, reward):
+ """
+ Returns the discountable and discountable_per_tax for a discount that applies to the whole order
+ """
+ self.ensure_one()
+ assert reward.discount_applicability == 'order'
+
+ discountable = 0
+ discountable_per_tax = defaultdict(int)
+ for line in (self.order_line - self._get_no_effect_on_threshold_lines()):
+ # Ignore lines from this reward
+ if not line.product_uom_qty or not line.price_unit:
+ continue
+ line_discountable = line.price_unit * line.product_uom_qty * (1 - (line.discount or 0.0) / 100.0)
+ discountable += line.price_total
+ discountable_per_tax[line.tax_id] += line_discountable
+ return discountable, discountable_per_tax
+
+ def _cheapest_line(self):
+ self.ensure_one()
+ cheapest_line = False
+ for line in (self.order_line - self._get_no_effect_on_threshold_lines()):
+ if line.reward_id or not line.product_uom_qty or not line.price_unit:
+ continue
+ if not cheapest_line or cheapest_line.price_unit > line.price_unit:
+ cheapest_line = line
+ return cheapest_line
+
+ def _discountable_cheapest(self, reward):
+ """
+ Returns the discountable and discountable_per_tax for a discount that applies to the cheapest line
+ """
+ self.ensure_one()
+ assert reward.discount_applicability == 'cheapest'
+
+ cheapest_line = self._cheapest_line()
+ discountable = cheapest_line.price_unit * (1 - (cheapest_line.discount or 0) / 100)
+ return discountable, {cheapest_line.tax_id: discountable}
+
+ def _get_specific_discountable_lines(self, reward):
+ """
+ Returns all lines to which `reward` can apply
+ """
+ self.ensure_one()
+ assert reward.discount_applicability == 'specific'
+
+ discountable_lines = self.env['sale.order.line']
+ for line in (self.order_line - self._get_no_effect_on_threshold_lines()):
+ if not line.reward_id and line.product_id in reward.all_discount_product_ids:
+ discountable_lines |= line
+ return discountable_lines
+
+ def _discountable_specific(self, reward):
+ """
+ Special function to compute the discountable for 'specific' types of discount.
+ The goal of this function is to make sure that applying a 5$ discount on an order with a
+ 5$ product and a 5% discount does not make the order go below 0.
+
+ Returns the discountable and discountable_per_tax for a discount that only applies to specific products.
+ """
+ self.ensure_one()
+ assert reward.discount_applicability == 'specific'
+
+ lines_to_discount = self.env['sale.order.line']
+ discount_lines = defaultdict(lambda: self.env['sale.order.line'])
+ order_lines = self.order_line - self._get_no_effect_on_threshold_lines()
+ remaining_amount_per_line = defaultdict(int)
+ for line in order_lines:
+ if not line.product_uom_qty or not line.price_unit:
+ continue
+ remaining_amount_per_line[line] = line.price_total
+ if not line.reward_id and line.product_id in reward.all_discount_product_ids:
+ lines_to_discount |= line
+ elif line.reward_id.reward_type == 'discount':
+ if line.reward_id == reward:
+ continue
+ discount_lines[line.reward_identifier_code] |= line
+
+ cheapest_line = False
+ for lines in discount_lines.values():
+ line_reward = lines.reward_id
+ discounted_lines = order_lines
+ if line_reward.discount_applicability == 'cheapest':
+ cheapest_line = cheapest_line or self._cheapest_line()
+ discounted_lines = cheapest_line
+ elif line_reward.discount_applicability == 'specific':
+ discounted_lines = self._get_specific_discountable_lines(line_reward)
+ if not discounted_lines:
+ continue
+ common_lines = discounted_lines & lines_to_discount
+ if line_reward.discount_mode == 'percent':
+ for line in discounted_lines:
+ remaining_amount_per_line[line] *= (1 - line_reward.discount/100)
+ else:
+ non_common_lines = discounted_lines - lines_to_discount
+ discounted_amount = abs(sum(lines.mapped('price_total')))
+ for line in itertools.chain(non_common_lines, common_lines):
+ if discounted_amount == 0:
+ break
+ remaining = remaining_amount_per_line[line]
+ consumed = min(remaining, discounted_amount)
+ discounted_amount -= consumed
+ remaining_amount_per_line[line] -= consumed
+
+ discountable = 0
+ discountable_per_tax = defaultdict(int)
+ for line in lines_to_discount:
+ discountable += remaining_amount_per_line[line]
+ line_discountable = line.price_unit * line.product_uom_qty * (1 - (line.discount or 0.0) / 100.0)
+ # line_discountable is the same as in a 'order' discount
+ # but first multiplied by a factor for the taxes to apply
+ # and then multiplied by another factor coming from the discountable
+ discountable_per_tax[line.tax_id] += line_discountable *\
+ (remaining_amount_per_line[line] / line.price_total)
+ return discountable, discountable_per_tax
+
+ 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']
+ discountable = 0
+ discountable_per_tax = defaultdict(int)
+ reward_applies_on = reward.discount_applicability
+ 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)
+ # Discountable should never surpass the order's current total amount
+ discountable = min(self.amount_total, discountable)
+ if not discountable:
+ 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')
+ if reward.discount_mode == 'per_point':
+ max_discount = min(max_discount,
+ reward.currency_id._convert(reward.discount * self._get_real_points_for_coupon(coupon),
+ 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()))
+ 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())
+ point_cost = converted_discount / reward.discount
+ # Gift cards and eWallets are considered gift cards and should not have any taxes
+ if reward.program_id.program_type in ('ewallet', 'gift_card'):
+ return [{
+ 'name': reward.description,
+ 'product_id': reward.discount_line_product_id.id,
+ 'price_unit': -min(max_discount, discountable),
+ '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': point_cost,
+ 'reward_identifier_code': reward_code,
+ }]
+ discount_factor = min(1, (max_discount / discountable)) if discountable else 1
+ mapped_taxes = {tax: self.fiscal_position_id.map_tax(tax) for tax in discountable_per_tax}
+ reward_dict = {tax: {
+ 'name': _(
+ 'Discount: %(desc)s%(tax_str)s',
+ desc=reward.description,
+ tax_str=_(' - On product with the following taxes: %(taxes)s', taxes=", ".join(mapped_taxes[tax].mapped('name'))),
+ ),
+ '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,
+ 'tax_id': [(Command.CLEAR, 0, 0)] + [(Command.LINK, tax.id, False) for tax in mapped_taxes[tax]]
+ } for tax, price in discountable_per_tax.items()}
+ # We only assign the point cost to one line to avoid counting the cost multiple times
+ if reward_dict:
+ reward_dict[next(iter(reward_dict))]['points_cost'] = point_cost
+ # Returning .values() directly does not return a subscribable list
+ return list(reward_dict.values())
+
+ def _get_program_domain(self):
+ """
+ Returns the base domain that all programs have to comply to.
+ """
+ self.ensure_one()
+ return [('active', '=', True),
+ ('company_id', 'in', (self.company_id.id, False)),
+ '|', ('date_to', '=', False), ('date_to', '>=', fields.Date.context_today(self))]
+
+ def _get_trigger_domain(self):
+ """
+ Returns the base domain that all triggers have to comply to.
+ """
+ self.ensure_one()
+ return [('active', '=', True),
+ ('company_id', 'in', (self.company_id.id, False)),
+ '|', ('program_id.date_to', '=', False), ('program_id.date_to', '<=', fields.Date.context_today(self))]
+
+ def _get_applicable_program_points(self, domain=None):
+ """
+ Returns a dict with the points per program for each (automatic) program that is applicable
+ """
+ self.ensure_one()
+ if not domain:
+ domain = [('trigger', '=', 'auto')]
+ # Make sure domain always complies with the order's domain rules
+ domain = expression.AND([self._get_program_domain(), domain])
+ # No other way than to test all programs to the order
+ programs = self.env['loyalty.program'].search(domain)
+ all_status = self._program_check_compute_points(programs)
+ program_points = {p: status['points'][0] for p, status in all_status.items() if 'points' in status}
+ return program_points
+
+ def _get_points_programs(self):
+ """
+ Returns all programs that give points on the current order.
+ """
+ self.ensure_one()
+ return self.coupon_point_ids.coupon_id.program_id
+
+ def _get_reward_programs(self):
+ """
+ Returns all programs that are being used for rewards.
+ """
+ self.ensure_one()
+ return self.order_line.reward_id.program_id
+
+ def _get_reward_coupons(self):
+ """
+ Returns all coupons that are a reward.
+ """
+ self.ensure_one()
+ return self.coupon_point_ids.coupon_id.filtered(lambda c: c.program_id.applies_on == 'future')
+
+ def _get_applied_programs(self):
+ """
+ Returns all applied programs on current order.
+
+ Applied programs is the combination of both new points for your order and the programs linked to rewards.
+ """
+ self.ensure_one()
+ return self._get_points_programs() | self._get_reward_programs()
+
+ def _get_invoice_status(self):
+ # Handling of a specific situation: an order contains
+ # a product invoiced on delivery and a promo line invoiced
+ # on order. We would avoid having the invoice status 'to_invoice'
+ # if the created invoice will only contain the promotion line
+ super()._get_invoice_status()
+ for order in self:
+ if order.invoice_status != 'to_invoice':
+ continue
+ if not any(not line.is_reward_line and line.invoice_status == 'to invoice' for line in order.order_line):
+ order.invoice_status = 'no'
+
+ def _get_invoiceable_lines(self, final=False):
+ """ Ensures we cannot invoice only reward lines.
+
+ Since promotion lines are specified with service products,
+ those lines are directly invoiceable when the order is confirmed
+ which can result in invoices containing only promotion lines.
+
+ To avoid those cases, we allow the invoicing of promotion lines
+ if at least another 'basic' lines is also invoiceable.
+ """
+ invoiceable_lines = super()._get_invoiceable_lines(final)
+ for line in invoiceable_lines:
+ if not line.is_reward_line:
+ return invoiceable_lines
+ return self.env['sale.order.line']
+
+ def update_prices(self):
+ """Recompute coupons/promotions after pricelist prices reset."""
+ super().update_prices()
+ if any(line.is_reward_line for line in self.order_line):
+ self._update_programs_and_rewards()
+
+ def _get_point_changes(self):
+ """
+ Returns the changes in points per coupon as a dict.
+
+ Used when validating/cancelling an order
+ """
+ points_per_coupon = defaultdict(lambda: 0)
+ for coupon_point in self.coupon_point_ids:
+ points_per_coupon[coupon_point.coupon_id] += coupon_point.points
+ for line in self.order_line:
+ if not line.reward_id or not line.coupon_id:
+ continue
+ points_per_coupon[line.coupon_id] -= line.points_cost
+ return points_per_coupon
+
+ def _get_real_points_for_coupon(self, coupon, post_confirm=False):
+ """
+ Returns the actual points usable for this coupon for this order. Set pos_confirm to True to include points for future orders.
+
+ This is calculated by taking the points on the coupon, the points the order will give to the coupon (if applicable) and removing the points taken by already applied rewards.
+ """
+ self.ensure_one()
+ points = coupon.points
+ if (coupon.program_id.applies_on != 'future' and self.state not in ('sale', 'done')) or post_confirm:
+ # Points that will be given by the order upon confirming the order
+ points += self.coupon_point_ids.filtered(lambda p: p.coupon_id == coupon).points
+ # Points already used by rewards
+ points -= sum(self.order_line.filtered(lambda l: l.coupon_id == coupon).mapped('points_cost'))
+ return points
+
+ def _add_points_for_coupon(self, coupon_points):
+ """
+ Updates (or creates) an entry in coupon_point_ids for the given coupons.
+ """
+ self.ensure_one()
+ if self.state in ('sale', 'done'):
+ for coupon, points in coupon_points.items():
+ coupon.sudo().points += points
+ for pe in self.coupon_point_ids.sudo():
+ if pe.coupon_id in coupon_points:
+ pe.points = coupon_points.pop(pe.coupon_id)
+ if coupon_points:
+ self.sudo().with_context(tracking_disable=True).write({
+ 'coupon_point_ids': [(0, 0, {
+ 'coupon_id': coupon.id,
+ 'points': points,
+ }) for coupon, points in coupon_points.items()]
+ })
+
+ def _remove_program_from_points(self, programs):
+ self.coupon_point_ids.filtered(lambda p: p.coupon_id.program_id in programs).sudo().unlink()
+
+ def _get_reward_line_values(self, reward, coupon, **kwargs):
+ self.ensure_one()
+ self = self.with_context(lang=self.partner_id.lang)
+ reward = reward.with_context(lang=self.partner_id.lang)
+ if reward.reward_type == 'discount':
+ return self._get_reward_values_discount(reward, coupon, **kwargs)
+ elif reward.reward_type == 'product':
+ return self._get_reward_values_product(reward, coupon, **kwargs)
+
+ def _write_vals_from_reward_vals(self, reward_vals, old_lines, delete=True):
+ """
+ Update, create new reward line and delete old lines in one write on `order_line`
+
+ Returns the untouched old lines.
+ """
+ self.ensure_one()
+ command_list = []
+ for vals, line in zip(reward_vals, old_lines):
+ command_list.append((Command.UPDATE, line.id, vals))
+ if len(reward_vals) > len(old_lines):
+ command_list.extend((Command.CREATE, 0, vals) for vals in reward_vals[len(old_lines):])
+ elif len(reward_vals) < len(old_lines) and delete:
+ command_list.extend((Command.DELETE, line.id) for line in old_lines[len(reward_vals):])
+ self.write({'order_line': command_list})
+ return self.env['sale.order.line'] if delete else old_lines[len(reward_vals):]
+
+ def _apply_program_reward(self, reward, coupon, **kwargs):
+ """
+ Applies the reward to the order provided the given coupon has enough points.
+ This method does not check for program rules.
+
+ This method also assumes the points added by the program triggers have already been computed.
+ The temporary points are used if the program is applicable to the current order.
+
+ Returns a dict containing the error message or empty if everything went correctly.
+ NOTE: A call to `_update_programs_and_rewards` is expected to reorder the discounts.
+ """
+ self.ensure_one()
+ # Use the old lines before creating new ones. These should already be in a 'reset' state.
+ old_reward_lines = kwargs.get('old_lines', self.env['sale.order.line'])
+ if reward.is_global_discount:
+ global_discount_reward_lines = self._get_applied_global_discount_lines()
+ global_discount_reward = global_discount_reward_lines.reward_id
+ if global_discount_reward and global_discount_reward != reward and global_discount_reward.discount >= reward.discount:
+ return {'error': _('A better global discount is already applied.')}
+ elif global_discount_reward and global_discount_reward != reward:
+ # Invalidate the old global discount as it may impact the new discount to apply
+ global_discount_reward_lines._reset_loyalty(True)
+ old_reward_lines |= global_discount_reward_lines
+ if not reward.program_id.is_nominative and reward.program_id.applies_on == 'future' and coupon in self.coupon_point_ids.coupon_id:
+ return {'error': _('The coupon can only be claimed on future orders.')}
+ elif self._get_real_points_for_coupon(coupon) < reward.required_points:
+ return {'error': _('The coupon does not have enough points for the selected reward.')}
+ reward_vals = self._get_reward_line_values(reward, coupon, **kwargs)
+ self._write_vals_from_reward_vals(reward_vals, old_reward_lines)
+ return {}
+
+ def _get_claimable_rewards(self, forced_coupons=None):
+ """
+ Fetch all rewards that are currently claimable from all concerned coupons,
+ meaning coupons from applied programs and applied rewards or the coupons given as parameter.
+
+ Returns a dict containing the all the claimable rewards grouped by coupon.
+ Coupons that can not claim any reward are not contained in the result.
+ """
+ self.ensure_one()
+ all_coupons = forced_coupons or (self.coupon_point_ids.coupon_id | self.order_line.coupon_id | self.applied_coupon_ids)
+ total_is_zero = float_is_zero(self.amount_total, precision_digits=2)
+ result = defaultdict(lambda: self.env['loyalty.reward'])
+ global_discount_reward = self._get_applied_global_discount()
+ for coupon in all_coupons:
+ points = self._get_real_points_for_coupon(coupon)
+ for reward in coupon.program_id.reward_ids:
+ if reward.is_global_discount and global_discount_reward and global_discount_reward.discount >= reward.discount:
+ continue
+ if reward.reward_type == 'discount' and total_is_zero:
+ continue
+ if points >= reward.required_points:
+ result[coupon] |= reward
+ return result
+
+ def _allow_nominative_programs(self):
+ """
+ Whether or not this order may use nominative programs.
+ """
+ self.ensure_one()
+ return True
+
+ def _update_programs_and_rewards(self):
+ """
+ Updates applied programs's given points with the current state of the order.
+ Checks automatic programs for applicability.
+ Updates applied rewards using the new points and the current state of the order (for example with % discounts).
+ """
+ self.ensure_one()
+
+ # +===================================================+
+ # | STEP 1: Retrieve all applicable programs |
+ # +===================================================+
+
+ # Automatically load in eWallet coupons
+ if self._allow_nominative_programs():
+ ewallet_coupons = self.env['loyalty.card'].search(
+ [('id', 'not in', self.applied_coupon_ids.ids), ('partner_id', '=', self.partner_id.id),
+ ('points', '>', 0), ('program_id.program_type', '=', 'ewallet')])
+ if ewallet_coupons:
+ self.applied_coupon_ids += ewallet_coupons
+ # Programs that are applied to the order and count points
+ points_programs = self._get_points_programs()
+ # Coupon programs that require the program's rules to match but do not count for points
+ coupon_programs = self.applied_coupon_ids.program_id
+ # Programs that are automatic and not yet applied
+ program_domain = self._get_program_domain()
+ domain = expression.AND([program_domain, [('id', 'not in', points_programs.ids), ('trigger', '=', 'auto'), ('rule_ids.mode', '=', 'auto')]])
+ automatic_programs = self.env['loyalty.program'].search(domain).filtered(lambda p:
+ not p.limit_usage or p.total_order_count < p.max_usage)
+
+ all_programs_to_check = points_programs | coupon_programs | automatic_programs
+ all_coupons = self.coupon_point_ids.coupon_id | self.applied_coupon_ids
+ # First basic check using the program_domain -> for example if a program gets archived mid quotation
+ domain_matching_programs = all_programs_to_check.filtered_domain(program_domain)
+ all_programs_status = {p: {'error': 'error'} for p in all_programs_to_check - domain_matching_programs}
+ # Compute applicability and points given for all programs that passed the domain check
+ # Note that points are computed with reward lines present
+ all_programs_status.update(self._program_check_compute_points(domain_matching_programs))
+ # Delay any unlink to the end of the function since they cause a full cache invalidation
+ lines_to_unlink = self.env['sale.order.line']
+ coupons_to_unlink = self.env['loyalty.card']
+ point_entries_to_unlink = self.env['sale.order.coupon.points']
+ # Remove any coupons that do not belong to the customer or are expired
+ self.applied_coupon_ids = self.applied_coupon_ids.filtered(lambda c:
+ (not c.partner_id or c.partner_id == self.partner_id) and
+ (not c.expiration_date or c.expiration_date >= fields.Date.today())
+ )
+ point_ids_per_program = defaultdict(lambda: self.env['sale.order.coupon.points'])
+ for pe in self.coupon_point_ids:
+ # Remove any point entry for a coupon that does not belong to the customer
+ if pe.coupon_id.partner_id and pe.coupon_id.partner_id != self.partner_id:
+ pe.points = 0
+ point_entries_to_unlink |= pe
+ else:
+ point_ids_per_program[pe.coupon_id.program_id] |= pe
+
+ # +==========================================+
+ # | STEP 2: Update applied programs |
+ # +==========================================+
+
+ # Programs that were not applied via a coupon
+ for program in points_programs:
+ status = all_programs_status[program]
+ program_point_entries = point_ids_per_program[program]
+ if 'error' in status:
+ # Program is not applicable anymore
+ coupons_from_order = program_point_entries.coupon_id.filtered(lambda c: c.order_id == self)
+ all_coupons -= coupons_from_order
+ # Invalidate those lines so that they don't impact anything further down the line
+ program_reward_lines = self.order_line.filtered(lambda l: l.coupon_id in coupons_from_order)
+ program_reward_lines._reset_loyalty(True)
+ lines_to_unlink |= program_reward_lines
+ # Delete coupon created by this order for this program if it is not nominative
+ if not program.is_nominative:
+ coupons_to_unlink |= coupons_from_order
+ else:
+ # Only remove the coupon_point_id
+ point_entries_to_unlink |= program_point_entries
+ point_entries_to_unlink.points = 0
+ # Remove the code activated rules
+ self.code_enabled_rule_ids -= program.rule_ids
+ else:
+ # Program stays applicable, update our points
+ all_point_changes = [p for p in status['points'] if p]
+ if not all_point_changes and program.is_nominative:
+ all_point_changes = [0]
+ for pe, points in zip(program_point_entries.sudo(), all_point_changes):
+ pe.points = points
+ if len(program_point_entries) < len(all_point_changes):
+ new_coupon_points = all_point_changes[len(program_point_entries):]
+ # NOTE: Maybe we could batch the creation of coupons across multiple programs but this really only applies to gift cards
+ new_coupons = self.env['loyalty.card'].with_context(loyalty_no_mail=True, tracking_disable=True).create([{
+ 'program_id': program.id,
+ 'partner_id': False,
+ 'points': 0,
+ 'order_id': self.id,
+ } for _ in new_coupon_points])
+ self._add_points_for_coupon({coupon: x for coupon, x in zip(new_coupons, new_coupon_points)})
+ elif len(program_point_entries) > len(all_point_changes):
+ point_ids_to_unlink = program_point_entries[len(all_point_changes):]
+ all_coupons -= point_ids_to_unlink.coupon_id
+ coupons_to_unlink |= point_ids_to_unlink.coupon_id
+ point_ids_to_unlink.points = 0
+
+ # Programs applied using a coupon
+ applied_coupon_per_program = defaultdict(lambda: self.env['loyalty.card'])
+ for coupon in self.applied_coupon_ids:
+ applied_coupon_per_program[coupon.program_id] |= coupon
+ for program in coupon_programs:
+ if program not in domain_matching_programs or\
+ (program.applies_on == 'current' and 'error' in all_programs_status[program]):
+ program_reward_lines = self.order_line.filtered(lambda l: l.coupon_id in applied_coupon_per_program[program])
+ program_reward_lines._reset_loyalty(True)
+ lines_to_unlink |= program_reward_lines
+ self.applied_coupon_ids -= applied_coupon_per_program[program]
+ all_coupons -= applied_coupon_per_program[program]
+
+ # +==========================================+
+ # | STEP 3: Update reward lines |
+ # +==========================================+
+
+ # We will reuse these lines as much as possible, this resets the order in a reward-less state
+ reward_line_pool = self.order_line.filtered(lambda l: l.reward_id and l.coupon_id)._reset_loyalty()
+ seen_rewards = set()
+ line_rewards = []
+ payment_rewards = [] # gift_card and ewallet are considered as payments and should always be applied last
+ for line in self.order_line:
+ if line.reward_identifier_code in seen_rewards or not line.reward_id or\
+ not line.coupon_id:
+ continue
+ seen_rewards.add(line.reward_identifier_code)
+ if line.reward_id.program_id.program_type not in ('gift_card', 'ewallet'):
+ line_rewards.append((line.reward_id, line.coupon_id, line.reward_identifier_code, line.product_id))
+ else:
+ payment_rewards.append((line.reward_id, line.coupon_id, line.reward_identifier_code, line.product_id))
+
+ for reward_key in itertools.chain(line_rewards, payment_rewards):
+ coupon = reward_key[1]
+ reward = reward_key[0]
+ program = reward.program_id
+ points = self._get_real_points_for_coupon(coupon)
+ if coupon not in all_coupons or points < reward.required_points or program not in domain_matching_programs:
+ # Reward is not applicable anymore, the reward lines will simply be removed at the end of this function
+ continue
+ values_list = self._get_reward_line_values(reward, coupon, product=reward_key[3])
+ reward_line_pool = self._write_vals_from_reward_vals(values_list, reward_line_pool, delete=False)
+
+ lines_to_unlink |= reward_line_pool
+
+ # +==========================================+
+ # | STEP 4: Apply new programs |
+ # +==========================================+
+
+ for program in automatic_programs:
+ program_status = all_programs_status[program]
+ if 'error' in program_status:
+ continue
+ self.__try_apply_program(program, False, program_status)
+
+ # +==========================================+
+ # | STEP 5: Cleanup |
+ # +==========================================+
+
+ order_line_update = [(Command.DELETE, line.id) for line in lines_to_unlink]
+ if order_line_update:
+ self.write({'order_line': order_line_update})
+ if coupons_to_unlink:
+ coupons_to_unlink.sudo().unlink()
+ if point_entries_to_unlink:
+ point_entries_to_unlink.sudo().unlink()
+
+ def _program_check_compute_points(self, programs):
+ """
+ Checks the program validity from the order lines aswell as computing the number of points to add.
+
+ Returns a dict containing the error message or the points that will be given with the keys 'points'.
+ """
+ self.ensure_one()
+
+ # Prepare quantities
+ order_lines = self.order_line.filtered(lambda line: line.product_id and not line.reward_id)
+ products = order_lines.product_id
+ products_qties = dict.fromkeys(products, 0)
+ for line in order_lines:
+ products_qties[line.product_id] += line.product_uom_qty
+ # Contains the products that can be applied per rule
+ products_per_rule = programs._get_valid_products(products)
+
+ # Prepare amounts
+ no_effect_lines = self._get_no_effect_on_threshold_lines()
+ base_untaxed_amount = self.amount_untaxed - sum(line.price_subtotal for line in no_effect_lines)
+ base_tax_amount = self.amount_tax - sum(line.price_tax for line in no_effect_lines)
+ amounts_per_program = {p: {'untaxed': base_untaxed_amount, 'tax': base_tax_amount} for p in programs}
+ for line in self.order_line:
+ if not line.reward_id or line.reward_id.reward_type != 'discount':
+ continue
+ for program in programs:
+ # Do not consider the program's discount + automatic discount lines for the amount to check.
+ if line.reward_id.program_id.trigger == 'auto' or line.reward_id.program_id == program:
+ amounts_per_program[program]['untaxed'] -= line.price_subtotal
+ amounts_per_program[program]['tax'] -= line.price_tax
+
+ result = {}
+ for program in programs:
+ untaxed_amount = amounts_per_program[program]['untaxed']
+ tax_amount = amounts_per_program[program]['tax']
+
+ # Used for error messages
+ # By default False, but True if no rules and applies_on current -> misconfigured coupons program
+ code_matched = not bool(program.rule_ids) and program.applies_on == 'current' # Stays false if all triggers have code and none have been activated
+ minimum_amount_matched = code_matched
+ product_qty_matched = code_matched
+ points = 0
+ # Some rules may split their points per unit / money spent
+ # (i.e. gift cards 2x50$ must result in two 50$ codes)
+ rule_points = []
+ program_result = result.setdefault(program, dict())
+ for rule in program.rule_ids:
+ if rule.mode == 'with_code' and rule not in self.code_enabled_rule_ids:
+ continue
+ code_matched = True
+ rule_amount = rule._compute_amount(self.currency_id)
+ if rule_amount > (rule.minimum_amount_tax_mode == 'incl' and (untaxed_amount + tax_amount) or untaxed_amount):
+ continue
+ minimum_amount_matched = True
+ rule_products = products_per_rule[rule]
+ ordered_rule_products_qty = sum(products_qties[product] for product in rule_products)
+ if ordered_rule_products_qty < rule.minimum_qty or not rule_products:
+ continue
+ product_qty_matched = True
+ if not rule.reward_point_amount:
+ continue
+ # Count all points separately if the order is for the future and the split option is enabled
+ if program.applies_on == 'future' and rule.reward_point_split and rule.reward_point_mode != 'order':
+ if rule.reward_point_mode == 'unit':
+ rule_points.extend(rule.reward_point_amount for _ in range(int(ordered_rule_products_qty)))
+ elif rule.reward_point_mode == 'money':
+ for line in self.order_line:
+ if line.is_reward_line or line.product_id not in rule_products or line.product_uom_qty <= 0:
+ continue
+ points_per_unit = float_round(
+ (rule.reward_point_amount * line.price_total / line.product_uom_qty),
+ precision_digits=2, rounding_method='DOWN')
+ if not points_per_unit:
+ continue
+ rule_points.extend([points_per_unit] * int(line.product_uom_qty))
+ else:
+ # All checks have been passed we can now compute the points to give
+ if rule.reward_point_mode == 'order':
+ points += rule.reward_point_amount
+ elif rule.reward_point_mode == 'money':
+ # Compute amount paid for rule
+ # NOTE: this does not account for discounts -> 1 point per $ * (100$ - 30%) will result in 100 points
+ amount_paid = sum(max(0, line.price_total) for line in order_lines if line.product_id in rule_products)
+ points += float_round(rule.reward_point_amount * amount_paid, precision_digits=2, rounding_method='DOWN')
+ elif rule.reward_point_mode == 'unit':
+ points += rule.reward_point_amount * ordered_rule_products_qty
+ # NOTE: for programs that are nominative we always allow the program to be 'applied' on the order
+ # with 0 points so that `_get_claimable_rewards` returns the rewards associated with those programs
+ if not program.is_nominative:
+ if not code_matched:
+ program_result['error'] = _("This program requires a code to be applied.")
+ elif not minimum_amount_matched:
+ program_result['error'] = _(
+ 'A minimum of %(amount)s %(currency)s should be purchased to get the reward',
+ amount=min(program.rule_ids.mapped('minimum_amount')),
+ currency=program.currency_id.name,
+ )
+ elif not product_qty_matched:
+ program_result['error'] = _("You don't have the required product quantities on your sales order.")
+ elif not self._allow_nominative_programs():
+ program_result['error'] = _("This program is not available for public users.")
+ if 'error' not in program_result:
+ points_result = [points] + rule_points
+ program_result['points'] = points_result
+ return result
+
+ def __try_apply_program(self, program, coupon, status):
+ self.ensure_one()
+ all_points = status['points']
+ points = all_points[0]
+ coupons = coupon or self.env['loyalty.card']
+ if coupon:
+ if program.is_nominative:
+ self._add_points_for_coupon({coupon: points})
+ elif not coupon:
+ # If the program only applies on the current order it does not make sense to fetch already existing coupons
+ if program.is_nominative:
+ coupon = self.env['loyalty.card'].search(
+ [('partner_id', '=', self.partner_id.id), ('program_id', '=', program.id)], limit=1)
+ # Do not apply 'nominative' programs if no point is given and no coupon exists
+ if not points and not coupon:
+ return {'error': _('No card found for this loyalty program and no points will be given with this order.')}
+ elif coupon:
+ self._add_points_for_coupon({coupon: points})
+ coupons = coupon
+ if not coupon:
+ all_points = [p for p in all_points if p]
+ partner = False
+ # Loyalty programs and ewallets are nominative
+ if program.is_nominative:
+ partner = self.partner_id.id
+ coupons = self.env['loyalty.card'].sudo().with_context(loyalty_no_mail=True, tracking_disable=True).create([{
+ 'program_id': program.id,
+ 'partner_id': partner,
+ 'points': 0,
+ 'order_id': self.id,
+ } for _ in all_points])
+ self._add_points_for_coupon({coupon: x for coupon, x in zip(coupons, all_points)})
+ return {'coupon': coupons}
+
+ def _try_apply_program(self, program, coupon=None):
+ """
+ Tries to apply a program using the coupon if provided.
+
+ This function provides the full routine to apply a program, it will check for applicability
+ aswell as creating the necessary coupons and co-models to give the points to the customer.
+
+ This function does not apply any reward to the order, rewards have to be given manually.
+
+ Returns a dict containing the error message or containing the associated coupon(s).
+ """
+ self.ensure_one()
+ # Basic checks
+ if not program.filtered_domain(self._get_program_domain()):
+ return {'error': _('The program is not available for this order.')}
+ elif program in self._get_applied_programs():
+ return {'error': _('This program is already applied to this order.')}
+ # Check for applicability from the program's triggers/rules.
+ # This step should also compute the amount of points to give for that program on that order.
+ status = self._program_check_compute_points(program)[program]
+ if 'error' in status:
+ return status
+ return self.__try_apply_program(program, coupon, status)
+
+ def _try_apply_code(self, code):
+ """
+ Tries to apply a promotional code to the sales order.
+ It can be either from a coupon or a program rule.
+
+ Returns a dict with the following possible keys:
+ - 'not_found': Populated with True if the code did not yield any result.
+ - 'error': Any error message that could occur.
+ OR The result of `_get_claimable_rewards` with the found or newly created coupon, it will be empty if the coupon was consumed completely.
+ """
+ self.ensure_one()
+
+ base_domain = self._get_trigger_domain()
+ domain = expression.AND([base_domain, [('mode', '=', 'with_code'), ('code', '=', code)]])
+ rule = self.env['loyalty.rule'].search(domain)
+ program = rule.program_id
+ coupon = False
+
+ if rule in self.code_enabled_rule_ids:
+ return {'error': _('This promo code is already applied.')}
+
+ # No trigger was found from the code, try to find a coupon
+ if not program:
+ coupon = self.env['loyalty.card'].search([('partner_id', 'in', (False, self.partner_id.id)), ('code', '=', code)])
+ if not coupon or not coupon.program_id.active or not coupon.program_id.reward_ids:
+ return {'error': _('This code is invalid (%s).', code), 'not_found': True}
+ elif coupon.expiration_date and coupon.expiration_date < fields.Date.today():
+ return {'error': _('This coupon is expired.')}
+ elif coupon.points < min(coupon.program_id.reward_ids.mapped('required_points')):
+ return {'error': _('This coupon has already been used.')}
+ program = coupon.program_id
+
+ if not program or not program.active:
+ return {'error': _('This code is invalid (%s).', code), 'not_found': True}
+ elif (program.limit_usage and program.total_order_count >= program.max_usage) or\
+ (program.date_to and program.date_to <= fields.Date.context_today(self)):
+ return {'error': _('This code is expired (%s).', code)}
+
+ # Rule will count the next time the points are updated
+ if rule:
+ self.code_enabled_rule_ids |= rule
+ program_is_applied = program in self._get_points_programs()
+ # Condition that need to apply program (if not applied yet):
+ # current -> always
+ # future -> if no coupon
+ # nominative -> non blocking if card exists with points
+ if coupon:
+ self.applied_coupon_ids += coupon
+ if program_is_applied:
+ # Update the points for our programs, this will take the new trigger in account
+ self._update_programs_and_rewards()
+ elif program.applies_on != 'future' or not coupon:
+ apply_result = self._try_apply_program(program, coupon)
+ if 'error' in apply_result and (not program.is_nominative or (program.is_nominative and not coupon)):
+ if rule:
+ self.code_enabled_rule_ids -= rule
+ if coupon:
+ self.applied_coupon_ids -= coupon
+ return apply_result
+ coupon = apply_result.get('coupon', self.env['loyalty.card'])
+ return self._get_claimable_rewards(forced_coupons=coupon)
diff --git a/addons/sale_loyalty/models/sale_order_coupon_points.py b/addons/sale_loyalty/models/sale_order_coupon_points.py
new file mode 100644
index 00000000000..9c113501439
--- /dev/null
+++ b/addons/sale_loyalty/models/sale_order_coupon_points.py
@@ -0,0 +1,17 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models
+
+class SaleOrderCouponPoints(models.Model):
+ _name = 'sale.order.coupon.points'
+ _description = 'Sale Order Coupon Points - Keeps track of how a sale order impacts a coupon'
+
+ order_id = fields.Many2one('sale.order', required=True, ondelete='cascade')
+ coupon_id = fields.Many2one('loyalty.card', required=True, ondelete='cascade')
+ points = fields.Float(required=True)
+
+ _sql_constraints = [
+ ('order_coupon_unique', 'UNIQUE (order_id, coupon_id)',
+ 'The coupon points entry already exists.')
+ ]
diff --git a/addons/sale_loyalty/models/sale_order_line.py b/addons/sale_loyalty/models/sale_order_line.py
new file mode 100644
index 00000000000..638beff879b
--- /dev/null
+++ b/addons/sale_loyalty/models/sale_order_line.py
@@ -0,0 +1,116 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+class SaleOrderLine(models.Model):
+ _inherit = "sale.order.line"
+
+ is_reward_line = fields.Boolean('Is a program reward line', compute='_compute_is_reward_line')
+ reward_id = fields.Many2one('loyalty.reward', ondelete='restrict', readonly=True)
+ coupon_id = fields.Many2one('loyalty.card', ondelete='restrict', readonly=True)
+ reward_identifier_code = fields.Char(help="""
+ Technical field used to link multiple reward lines from the same reward together.
+ """)
+ points_cost = fields.Float(help='How much point this reward cost on the loyalty card.')
+
+ def _compute_name(self):
+ # Avoid computing the name for reward lines
+ reward = self.filtered('reward_id')
+ super(SaleOrderLine, self - reward)._compute_name()
+
+ @api.depends('reward_id')
+ def _compute_is_reward_line(self):
+ for line in self:
+ line.is_reward_line = bool(line.reward_id)
+
+ def _compute_tax_id(self):
+ reward_lines = self.filtered('is_reward_line')
+ super(SaleOrderLine, self - reward_lines)._compute_tax_id()
+ # Discount reward line is split per tax, the discount is set on the line but not on the product
+ # as the product is the generic discount line.
+ # In case of a free product, retrieving the tax on the line instead of the product won't affect the behavior.
+ for line in reward_lines:
+ line = line.with_company(line.company_id)
+ fpos = line.order_id.fiscal_position_id or line.order_id.fiscal_position_id._get_fiscal_position(line.order_partner_id)
+ # If company_id is set, always filter taxes by the company
+ taxes = line.tax_id.filtered(lambda r: not line.company_id or r.company_id == line.company_id)
+ line.tax_id = fpos.map_tax(taxes)
+
+ def _get_display_price(self):
+ # A product created from a promotion does not have a list_price.
+ # The price_unit of a reward order line is computed by the promotion, so it can be used directly
+ if self.is_reward_line:
+ return self.price_unit
+ return super()._get_display_price()
+
+ def _is_not_sellable_line(self):
+ return self.is_reward_line or super()._is_not_sellable_line()
+
+ def _reset_loyalty(self, complete=False):
+ """
+ Reset the line(s) to a state which does not impact reward computation.
+ If complete is set to True we also remove the coupon and reward from the line(s).
+ This option should be used when the line will be unlinked.
+
+ Returns self
+ """
+ vals = {
+ 'points_cost': 0,
+ 'price_unit': 0,
+ }
+ if complete:
+ vals.update({
+ 'coupon_id': False,
+ 'reward_id': False,
+ })
+ self.write(vals)
+ return self
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ res = super().create(vals_list)
+ # Update our coupon points if the order is in a confirmed state
+ for line in res:
+ if line.coupon_id and line.points_cost and line.order_id.state in ('sale', 'done'):
+ line.coupon_id.points -= line.points_cost
+ return res
+
+ def write(self, vals):
+ cost_in_vals = 'points_cost' in vals
+ if cost_in_vals:
+ previous_cost = {l: l.points_cost for l in self}
+ res = super().write(vals)
+ if cost_in_vals:
+ # Update our coupon points if the order is in a confirmed state
+ for line in self:
+ if previous_cost[line] != line.points_cost and line.order_id.state in ('sale', 'done'):
+ line.coupon_id.points += (previous_cost[line] - line.points_cost)
+ return res
+
+ def unlink(self):
+ # Remove related reward lines
+ reward_coupon_set = {(l.reward_id, l.coupon_id, l.reward_identifier_code) for l in self if l.reward_id}
+ related_lines = self.env['sale.order.line']
+ related_lines |= self.order_id.order_line.filtered(lambda l: (l.reward_id, l.coupon_id, l.reward_identifier_code) in reward_coupon_set)
+ # Remove the line's coupon from order if it is the last line using that coupon
+ coupons_to_unlink = self.env['loyalty.card']
+ for line in self:
+ if line.coupon_id:
+ # 2 cases:
+ # case 1: coupon has been applied directly
+ # case 2: coupon was created from a program
+ if line.coupon_id in line.order_id.applied_coupon_ids:
+ line.order_id.applied_coupon_ids -= line.coupon_id
+ elif line.coupon_id.order_id == line.order_id and line.coupon_id.program_id.applies_on == 'current' and\
+ not any(oLine.coupon_id == line.coupon_id and oLine not in related_lines for oLine in line.order_id.order_line):
+ # ondelete='restrict' would prevent deletion of the coupon unlink after unlinking lines
+ coupons_to_unlink |= line.coupon_id
+ line.order_id.code_enabled_rule_ids = line.order_id.code_enabled_rule_ids.filtered(lambda r: r.program_id != line.coupon_id.program_id)
+ # Give back the points if the order is confirmed, points are given back if the order is cancelled but in this case we need to do it directly
+ for line in related_lines:
+ if line.order_id.state in ('sale', 'done'):
+ line.coupon_id.points += line.points_cost
+ res = super(SaleOrderLine, self | related_lines).unlink()
+ coupons_to_unlink.sudo().unlink()
+ return res
diff --git a/addons/sale_loyalty/security/ir.model.access.csv b/addons/sale_loyalty/security/ir.model.access.csv
new file mode 100644
index 00000000000..fef3562c101
--- /dev/null
+++ b/addons/sale_loyalty/security/ir.model.access.csv
@@ -0,0 +1,16 @@
+id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink
+access_program_salesman,Loyalty Program (Salesperson),loyalty.model_loyalty_program,sales_team.group_sale_salesman,1,0,0,0
+access_program_manager,Loyalty Program (Manager),loyalty.model_loyalty_program,sales_team.group_sale_manager,1,1,1,1
+access_applicability_salesman,Loyalty Rule (Salesperson),loyalty.model_loyalty_rule,sales_team.group_sale_salesman,1,0,0,0
+access_applicability_manager,Loyalty Rule (Manager),loyalty.model_loyalty_rule,sales_team.group_sale_manager,1,1,1,1
+access_coupon_salesman,Loyalty (Salesperson),loyalty.model_loyalty_card,sales_team.group_sale_salesman,1,1,0,0
+access_coupon_manager,Loyalty (Manager),loyalty.model_loyalty_card,sales_team.group_sale_manager,1,1,1,0
+access_reward_salesman,Loyalty Reward (Salesperson),loyalty.model_loyalty_reward,sales_team.group_sale_salesman,1,0,0,0
+access_reward_manager,Loyalty Reward (Manager),loyalty.model_loyalty_reward,sales_team.group_sale_manager,1,1,1,1
+access_communication_salesman,Loyalty Communication (Salesperson),loyalty.model_loyalty_mail,sales_team.group_sale_salesman,1,0,0,0
+access_communication_manager,Loyalty Communication (Manager),loyalty.model_loyalty_mail,sales_team.group_sale_manager,1,1,1,1
+access_sale_coupon_apply_code,Loyalty Coupon Wizard,model_sale_loyalty_coupon_wizard,sales_team.group_sale_salesman,1,1,1,0
+access_sale_coupon_apply_code_line,Loyalty Reward Wizard,model_sale_loyalty_reward_wizard,sales_team.group_sale_salesman,1,1,1,0
+access_sale_coupon_generate,Coupon Generation,loyalty.model_loyalty_generate_wizard,sales_team.group_sale_salesman,1,1,1,0
+access_sale_order_coupon_points_manager,Coupon Points (Manager),model_sale_order_coupon_points,sales_team.group_sale_manager,1,1,1,1
+access_sale_order_coupon_points_salesman,Coupon Points (Salesperson),model_sale_order_coupon_points,sales_team.group_sale_salesman,1,0,0,0
diff --git a/addons/sale_loyalty/tests/__init__.py b/addons/sale_loyalty/tests/__init__.py
new file mode 100644
index 00000000000..a86c253fc20
--- /dev/null
+++ b/addons/sale_loyalty/tests/__init__.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import test_buy_gift_card
+from . import test_loyalty
+from . import test_pay_with_gift_card
+from . import test_program_multi_company
+from . import test_program_numbers
+from . import test_program_rules
+from . import test_program_with_code_operations
+from . import test_program_without_code_operations
+from . import test_sale_invoicing
diff --git a/addons/sale_loyalty/tests/common.py b/addons/sale_loyalty/tests/common.py
new file mode 100644
index 00000000000..f1af56da556
--- /dev/null
+++ b/addons/sale_loyalty/tests/common.py
@@ -0,0 +1,201 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from collections import defaultdict
+from odoo.addons.sale.tests.test_sale_product_attribute_value_config import TestSaleProductAttributeValueCommon
+from odoo.exceptions import ValidationError
+
+
+class TestSaleCouponCommon(TestSaleProductAttributeValueCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super(TestSaleCouponCommon, cls).setUpClass()
+
+ # set currency to not rely on demo data and avoid possible race condition
+ cls.currency_ratio = 1.0
+ pricelist = cls.env.ref('product.list0')
+ pricelist.currency_id = cls._setup_currency(cls.currency_ratio)
+
+ # Set all the existing programs to active=False to avoid interference
+ cls.env['loyalty.program'].search([]).write({'active': False})
+
+ # create partner for sale order.
+ cls.steve = cls.env['res.partner'].create({
+ 'name': 'Steve Bucknor',
+ 'email': 'steve.bucknor@example.com',
+ })
+
+ cls.empty_order = cls.env['sale.order'].create({
+ 'partner_id': cls.steve.id
+ })
+
+ cls.uom_unit = cls.env.ref('uom.product_uom_unit')
+
+ # Taxes
+ cls.tax_15pc_excl = cls.env['account.tax'].create({
+ 'name': "Tax 15%",
+ 'amount_type': 'percent',
+ 'amount': 15,
+ 'type_tax_use': 'sale',
+ })
+
+ cls.tax_10pc_incl = cls.env['account.tax'].create({
+ 'name': "10% Tax incl",
+ 'amount_type': 'percent',
+ 'amount': 10,
+ 'price_include': True,
+ })
+
+ #products
+ cls.product_A = cls.env['product.product'].create({
+ 'name': 'Product A',
+ 'list_price': 100,
+ 'sale_ok': True,
+ 'taxes_id': [(6, 0, [cls.tax_15pc_excl.id])],
+ })
+
+ cls.product_B = cls.env['product.product'].create({
+ 'name': 'Product B',
+ 'list_price': 5,
+ 'sale_ok': True,
+ 'taxes_id': [(6, 0, [cls.tax_15pc_excl.id])],
+ })
+
+ cls.product_C = cls.env['product.product'].create({
+ 'name': 'Product C',
+ 'list_price': 100,
+ 'sale_ok': True,
+ 'taxes_id': [(6, 0, [])],
+ })
+
+ cls.product_gift_card = cls.env['product.product'].create({
+ 'name': 'Gift Card 50',
+ 'detailed_type': 'service',
+ 'list_price': 50,
+ 'sale_ok': True,
+ 'taxes_id': False,
+ })
+
+ # Immediate Program By A + B: get B free
+ # No Conditions
+ cls.program_gift_card = cls.env['loyalty.program'].create({
+ 'name': 'Gift Cards',
+ 'applies_on': 'future',
+ 'program_type': 'gift_card',
+ 'trigger': 'auto',
+ 'rule_ids': [(0, 0, {
+ 'product_ids': cls.product_gift_card,
+ 'reward_point_amount': 1,
+ 'reward_point_mode': 'money',
+ 'reward_point_split': True,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount': 1,
+ 'discount_mode': 'per_point',
+ 'discount_applicability': 'order',
+ })]
+ })
+ cls.immediate_promotion_program = cls.env['loyalty.program'].create({
+ 'name': 'Buy A + 1 B, 1 B are free',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'company_id': cls.env.company.id,
+ 'trigger': 'auto',
+ 'rule_ids': [(0, 0, {
+ 'product_ids': cls.product_A,
+ 'reward_point_amount': 1,
+ 'reward_point_mode': 'order',
+ 'minimum_qty': 1,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'product',
+ 'reward_product_id': cls.product_B.id,
+ 'reward_product_qty': 1,
+ 'required_points': 1,
+ })],
+ })
+ cls.code_promotion_program = cls.env['loyalty.program'].create({
+ 'name': 'Buy 1 A + Enter code, 1 A is free',
+ 'program_type': 'coupons',
+ 'trigger': 'with_code',
+ 'applies_on': 'current',
+ 'company_id': cls.env.company.id,
+ 'rule_ids': [(0, 0, {
+ 'product_ids': cls.product_A,
+ 'reward_point_amount': 1,
+ 'reward_point_mode': 'order',
+ 'minimum_qty': 1,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'product',
+ 'reward_product_id': cls.product_A.id,
+ 'reward_product_qty': 1,
+ 'required_points': 1,
+ })],
+ })
+ cls.code_promotion_program_with_discount = cls.env['loyalty.program'].create({
+ 'name': 'Buy 1 C + Enter code, 10 percent discount on C',
+ 'program_type': 'coupons',
+ 'trigger': 'with_code',
+ 'applies_on': 'current',
+ 'company_id': cls.env.company.id,
+ 'rule_ids': [(0, 0, {
+ 'mode': 'with_code',
+ 'code': 'promotion_code_disc',
+ 'product_ids': cls.product_C,
+ 'reward_point_amount': 1,
+ 'reward_point_mode': 'order',
+ 'minimum_qty': 1,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount_mode': 'percent',
+ 'discount': 10,
+ 'discount_applicability': 'order',
+ 'required_points': 1,
+ })],
+ })
+
+ def _extract_rewards_from_claimable(self, status):
+ rewards = self.env['loyalty.reward']
+ for info in status.values():
+ for reward_count in info['rewards']:
+ rewards |= reward_count[0]
+
+ def _apply_promo_code(self, order, code, no_reward_fail=True):
+ status = order._try_apply_code(code)
+ if 'error' in status:
+ raise ValidationError(status['error'])
+ if not status and no_reward_fail:
+ # Can happen if global discount got filtered out in `_get_claimable_rewards`
+ raise ValidationError('No reward to claim with this coupon')
+ coupons = self.env['loyalty.card']
+ rewards = self.env['loyalty.reward']
+ for coupon, coupon_rewards in status.items():
+ coupons |= coupon
+ rewards |= coupon_rewards
+ if len(coupons) == 1 and len(rewards) == 1:
+ status = order._apply_program_reward(rewards, coupons)
+ if 'error' in status:
+ raise ValidationError(status['error'])
+
+ def _claim_reward(self, order, program, coupon=False):
+ if len(program.reward_ids) != 1:
+ return False
+ coupon = coupon or order.coupon_point_ids.coupon_id.filtered(lambda c: c.program_id == program)
+ if len(coupon) != 1:
+ return False
+ status = order._apply_program_reward(program.reward_ids, coupon)
+ return 'error' not in status
+
+ def _auto_rewards(self, order, programs):
+ order._update_programs_and_rewards()
+ coupons_per_program = defaultdict(lambda: self.env['loyalty.card'])
+ for coupon in order.coupon_point_ids.coupon_id:
+ coupons_per_program[coupon.program_id] |= coupon
+ for program in programs:
+ if len(program.reward_ids) > 1 or len(coupons_per_program[program]) != 1 or not program.active:
+ continue
+ self._claim_reward(order, program, coupons_per_program[program])
diff --git a/addons/sale_loyalty/tests/test_buy_gift_card.py b/addons/sale_loyalty/tests/test_buy_gift_card.py
new file mode 100644
index 00000000000..5739ee4f00e
--- /dev/null
+++ b/addons/sale_loyalty/tests/test_buy_gift_card.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon
+from odoo.tests.common import tagged
+
+@tagged('-at_install', 'post_install')
+class TestBuyGiftCard(TestSaleCouponCommon):
+
+ def test_buying_gift_card(self):
+ order = self.empty_order
+ self.immediate_promotion_program.active = False
+ order.write({'order_line': [
+ (0, False, {
+ 'product_id': self.product_A.id,
+ 'name': 'Ordinary Product A',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ }),
+ (0, False, {
+ 'product_id': self.product_gift_card.id,
+ 'name': 'Gift Card Product',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ self.assertEqual(len(order.order_line.ids), 2)
+ self.assertEqual(len(order._get_reward_coupons()), 0)
+ order._update_programs_and_rewards()
+ self.assertEqual(len(order._get_reward_coupons()), 1)
+ order.order_line[1].product_uom_qty = 2
+ order._update_programs_and_rewards()
+ self.assertEqual(len(order._get_reward_coupons()), 2)
+ order.order_line[1].product_uom_qty = 1
+ order._update_programs_and_rewards()
+ self.assertEqual(len(order._get_reward_coupons()), 1)
diff --git a/addons/sale_loyalty/tests/test_loyalty.py b/addons/sale_loyalty/tests/test_loyalty.py
new file mode 100644
index 00000000000..fa433bfbdf4
--- /dev/null
+++ b/addons/sale_loyalty/tests/test_loyalty.py
@@ -0,0 +1,78 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.tests import TransactionCase, tagged
+
+@tagged('post_install', '-at_install')
+class TestLoyalty(TransactionCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.env['loyalty.program'].search([]).write({'active': False})
+
+ def test_nominative_programs(self):
+ loyalty_program, ewallet_program = self.env['loyalty.program'].create([
+ {
+ 'name': 'Loyalty Program',
+ 'program_type': 'loyalty',
+ 'trigger': 'auto',
+ 'applies_on': 'both',
+ 'rule_ids': [(0, 0, {
+ 'reward_point_mode': 'money',
+ 'reward_point_amount': 10,
+ })],
+ 'reward_ids': [(0, 0, {})],
+ },
+ {
+ 'name': 'eWallet Program',
+ 'program_type': 'ewallet',
+ 'applies_on': 'future',
+ 'trigger': 'auto',
+ 'rule_ids': [(0, 0, {
+ 'reward_point_mode': 'money',
+ 'reward_point_amount': 10,
+ })],
+ 'reward_ids': [(0, 0, {})],
+ }
+ ])
+ product = self.env['product.product'].create({
+ 'name': 'Product C',
+ 'list_price': 100,
+ 'sale_ok': True,
+ 'taxes_id': [(6, 0, [])],
+ })
+
+ jean_jacques = self.env['res.partner'].create({'name': 'Jean Jacques'})
+ order = self.env['sale.order'].create({
+ 'partner_id': jean_jacques.id,
+ })
+ order._update_programs_and_rewards()
+ claimable_rewards = order._get_claimable_rewards()
+ # Should be empty since we do not have any coupon created yet
+ self.assertFalse(claimable_rewards, "No program should be applicable")
+ _, ewallet_coupon = self.env['loyalty.card'].create([
+ {
+ 'program_id': loyalty_program.id,
+ 'partner_id': jean_jacques.id,
+ 'points': 10,
+ },
+ {
+ 'program_id': ewallet_program.id,
+ 'partner_id': jean_jacques.id,
+ 'points': 0,
+ },
+ ])
+ order.write({
+ 'order_line': [(0, 0, {
+ 'product_id': product.id,
+ 'product_uom_qty': 1,
+ })]
+ })
+ order._update_programs_and_rewards()
+ claimable_rewards = order._get_claimable_rewards()
+ self.assertEqual(len(claimable_rewards), 1, "The ewallet program should not be applicable since the card has no points.")
+ ewallet_coupon.points = 50
+ order._update_programs_and_rewards()
+ claimable_rewards = order._get_claimable_rewards()
+ self.assertEqual(len(claimable_rewards), 2, "Now that the ewallet has some points they should both be applicable.")
diff --git a/addons/sale_loyalty/tests/test_pay_with_gift_card.py b/addons/sale_loyalty/tests/test_pay_with_gift_card.py
new file mode 100644
index 00000000000..66c715e90c9
--- /dev/null
+++ b/addons/sale_loyalty/tests/test_pay_with_gift_card.py
@@ -0,0 +1,71 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import Command
+from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon
+from odoo.tests.common import tagged
+
+@tagged('-at_install', 'post_install')
+class TestPayWithGiftCard(TestSaleCouponCommon):
+
+ def test_paying_with_single_gift_card_over(self):
+ self.env['loyalty.generate.wizard'].with_context(active_id=self.program_gift_card.id).create({
+ 'coupon_qty': 1,
+ 'points_granted': 100,
+ }).generate_coupons()
+ gift_card = self.program_gift_card.coupon_ids[0]
+ order = self.empty_order
+ order.write({'order_line': [
+ Command.create({
+ 'product_id': self.product_A.id,
+ 'name': 'Ordinary Product A',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ before_gift_card_payment = order.amount_total
+ self.assertNotEqual(before_gift_card_payment, 0)
+ self._apply_promo_code(order, gift_card.code)
+ order.action_confirm()
+ self.assertEqual(before_gift_card_payment - order.amount_total, 100 - gift_card.points)
+
+ def test_paying_with_single_gift_card_under(self):
+ self.env['loyalty.generate.wizard'].with_context(active_id=self.program_gift_card.id).create({
+ 'coupon_qty': 1,
+ 'points_granted': 100,
+ }).generate_coupons()
+ gift_card = self.program_gift_card.coupon_ids[0]
+ order = self.empty_order
+ order.write({'order_line': [
+ Command.create({
+ 'product_id': self.product_B.id,
+ 'name': 'Ordinary Product b',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ before_gift_card_payment = order.amount_total
+ self.assertNotEqual(before_gift_card_payment, 0)
+ self._apply_promo_code(order, gift_card.code)
+ order.action_confirm()
+ self.assertEqual(before_gift_card_payment - order.amount_total, 100 - gift_card.points)
+
+ def test_paying_with_multiple_gift_card(self):
+ self.env['loyalty.generate.wizard'].with_context(active_id=self.program_gift_card.id).create({
+ 'coupon_qty': 2,
+ 'points_granted': 100,
+ }).generate_coupons()
+ gift_card_1, gift_card_2 = self.program_gift_card.coupon_ids
+ order = self.empty_order
+ order.write({'order_line': [
+ Command.create({
+ 'product_id': self.product_A.id,
+ 'name': 'Ordinary Product A',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 20.0,
+ })
+ ]})
+ before_gift_card_payment = order.amount_total
+ self._apply_promo_code(order, gift_card_1.code)
+ self._apply_promo_code(order, gift_card_2.code)
+ self.assertEqual(order.amount_total, before_gift_card_payment - 200)
diff --git a/addons/sale_loyalty/tests/test_program_multi_company.py b/addons/sale_loyalty/tests/test_program_multi_company.py
new file mode 100644
index 00000000000..1a5d79e88f5
--- /dev/null
+++ b/addons/sale_loyalty/tests/test_program_multi_company.py
@@ -0,0 +1,83 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon
+from odoo.exceptions import UserError
+from odoo.tests import tagged
+
+
+@tagged('post_install', '-at_install')
+class TestSaleCouponMultiCompany(TestSaleCouponCommon):
+
+ def setUp(self):
+ super(TestSaleCouponMultiCompany, self).setUp()
+
+ self.company_a = self.env.company
+ self.company_b = self.env['res.company'].create(dict(name="TEST"))
+
+ self.immediate_promotion_program_c2 = self.env['loyalty.program'].create({
+ 'name': 'Buy A + 1 B, 1 B are free',
+ 'trigger': 'auto',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'company_id': self.company_b.id,
+ 'rule_ids': [(0, 0, {
+ 'product_ids': self.product_A,
+ 'reward_point_amount': 1,
+ 'reward_point_mode': 'order',
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'product',
+ 'reward_product_id': self.product_B.id,
+ 'reward_product_qty': 1,
+ 'required_points': 1,
+ })],
+ })
+
+ def _get_applicable_programs(self, order):
+ return self.env['loyalty.program'].browse(p.id for p in order._get_applicable_program_points())
+
+ def test_applicable_programs(self):
+
+ order = self.empty_order
+ order.write({'order_line': [
+ (0, False, {
+ 'product_id': self.product_A.id,
+ 'name': '1 Product A',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ }),
+ (0, False, {
+ 'product_id': self.product_B.id,
+ 'name': '2 Product B',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ order._update_programs_and_rewards()
+
+ self.assertNotIn(self.immediate_promotion_program_c2, self._get_applicable_programs(order))
+ self.assertNotIn(self.immediate_promotion_program_c2, order._get_applied_programs())
+
+ order_b = self.env["sale.order"].create({
+ 'company_id': self.company_b.id,
+ 'partner_id': order.partner_id.id,
+ })
+ order_b.write({'order_line': [
+ (0, False, {
+ 'product_id': self.product_A.id,
+ 'name': '1 Product A',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ }),
+ (0, False, {
+ 'product_id': self.product_B.id,
+ 'name': '2 Product B',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ self.assertNotIn(self.immediate_promotion_program, self._get_applicable_programs(order_b))
+ order_b._update_programs_and_rewards()
+ self.assertIn(self.immediate_promotion_program_c2, order_b._get_applied_programs())
+ self.assertNotIn(self.immediate_promotion_program, order_b._get_applied_programs())
diff --git a/addons/sale_loyalty/tests/test_program_numbers.py b/addons/sale_loyalty/tests/test_program_numbers.py
new file mode 100644
index 00000000000..c5c6f66fbd3
--- /dev/null
+++ b/addons/sale_loyalty/tests/test_program_numbers.py
@@ -0,0 +1,1463 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon
+from odoo.exceptions import ValidationError
+from odoo.tests import tagged
+
+
+@tagged('post_install', '-at_install')
+class TestSaleCouponProgramNumbers(TestSaleCouponCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ cls.largeCabinet = cls.env['product.product'].create({
+ 'name': 'Large Cabinet',
+ 'list_price': 320.0,
+ 'taxes_id': False,
+ })
+ cls.conferenceChair = cls.env['product.product'].create({
+ 'name': 'Conference Chair',
+ 'list_price': 16.5,
+ 'taxes_id': False,
+ })
+ cls.pedalBin = cls.env['product.product'].create({
+ 'name': 'Pedal Bin',
+ 'list_price': 47.0,
+ 'taxes_id': False,
+ })
+ cls.drawerBlack = cls.env['product.product'].create({
+ 'name': 'Drawer Black',
+ 'list_price': 25.0,
+ 'taxes_id': False,
+ })
+ cls.largeMeetingTable = cls.env['product.product'].create({
+ 'name': 'Large Meeting Table',
+ 'list_price': 40000.0,
+ 'taxes_id': False,
+ })
+
+ cls.steve = cls.env['res.partner'].create({
+ 'name': 'Steve Bucknor',
+ 'email': 'steve.bucknor@example.com',
+ })
+ cls.empty_order = cls.env['sale.order'].create({
+ 'partner_id': cls.steve.id
+ })
+
+ cls.p1 = cls.env['loyalty.program'].create({
+ 'name': 'Code for 10% on orders',
+ 'trigger': 'with_code',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {
+ 'mode': 'with_code',
+ 'code': 'test_10pc',
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount_mode': 'percent',
+ 'discount': 10,
+ 'discount_applicability': 'order',
+ 'required_points': 1,
+ })],
+ })
+ cls.p2 = cls.env['loyalty.program'].create({
+ 'name': 'Buy 3 cabinets, get one for free',
+ 'trigger': 'auto',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {
+ 'product_ids': cls.largeCabinet,
+ 'reward_point_mode': 'unit',
+ 'minimum_qty': 3,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'product',
+ 'reward_product_id': cls.largeCabinet.id,
+ 'reward_product_qty': 1,
+ 'required_points': 3,
+ })],
+ })
+ cls.p3 = cls.env['loyalty.program'].create({
+ 'name': 'Buy 1 drawer black, get a free Large Meeting Table',
+ 'trigger': 'auto',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {
+ 'product_ids': cls.drawerBlack,
+ 'reward_point_mode': 'order',
+ 'minimum_qty': 1,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'product',
+ 'reward_product_id': cls.largeMeetingTable.id,
+ 'reward_product_qty': 1,
+ 'required_points': 1,
+ })],
+ })
+ cls.discount_coupon_program = cls.env['loyalty.program'].create({
+ 'name': '$100 coupon',
+ 'program_type': 'coupons',
+ 'trigger': 'with_code',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {
+ 'minimum_amount': 100,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount_mode': 'per_point',
+ 'discount': 100,
+ 'discount_applicability': 'order',
+ 'required_points': 1,
+ })],
+ })
+ cls.all_programs = cls.env['loyalty.program'].search([])
+
+ def test_program_numbers_free_and_paid_product_qty(self):
+ # These tests will focus on numbers (free product qty, SO total, reduction total..)
+ order = self.empty_order
+ sol1 = self.env['sale.order.line'].create({
+ 'product_id': self.largeCabinet.id,
+ 'name': 'Large Cabinet',
+ 'product_uom_qty': 3.0,
+ 'order_id': order.id,
+ })
+
+ # Check we correctly get a free product
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(len(order.order_line.ids), 2, "We should have 2 lines as we now have one 'Free Large Cabinet' line as we bought 3 of them")
+
+ # Check free product's price is not added to total when applying reduction (Or the discount will also be applied on the free product's price)
+ self._apply_promo_code(order, 'test_10pc')
+ self.assertEqual(len(order.order_line.ids), 3, "We should have 3 lines as we should have a new line for promo code reduction")
+ self.assertEqual(order.amount_total, 864, "Only paid product should have their price discounted")
+ order.order_line.filtered(lambda x: 'Discount' in x.name).unlink() # Remove Discount
+ order._remove_program_from_points(self.p1)
+
+ # Check free product is removed since we are below minimum required quantity
+ sol1.product_uom_qty = 2
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(len(order.order_line.ids), 1, "Free Large Cabinet should have been removed")
+
+ # Free product in cart will be considered as paid product when changing quantity of paid product, so the free product quantity computation will be wrong.
+ # 75 Large Cabinet in cart, 25 free, set quantity to 6 Large Cabinet, you should have 2 free Large Cabinet but you get 8 because it add the 25 initial free Large Cabinet to the total paid Large Cabinet when computing (25+10 > 35 > /4 = 8 free Large Cabinet)
+ sol1.product_uom_qty = 75
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(sum(order.order_line.filtered(lambda x: x.is_reward_line).mapped('product_uom_qty')), 25, "We should have 25 Free Large Cabinet")
+ sol1.product_uom_qty = 6
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(sum(order.order_line.filtered(lambda x: x.is_reward_line).mapped('product_uom_qty')), 2, "We should have 2 Free Large Cabinet")
+
+ def test_program_numbers_check_eligibility(self):
+ # These tests will focus on numbers (free product qty, SO total, reduction total..)
+
+ # Check if we have enough paid product to receive free product in case of a free product that is different from the paid product required
+ # Buy A, get free b. (remember we need a paid B in cart to receive free b). If your cart is 4A 1B then you should receive 1b (you are eligible to receive 4 because you have 4A but since you dont have enought B in your cart, you are limited to the B quantity)
+ order = self.empty_order
+ sol1 = self.env['sale.order.line'].create({
+ 'product_id': self.drawerBlack.id,
+ 'name': 'drawer black',
+ 'product_uom_qty': 3.0,
+ 'order_id': order.id,
+ })
+ sol2 = self.env['sale.order.line'].create({
+ 'product_id': self.largeMeetingTable.id,
+ 'name': 'Large Meeting Table',
+ 'product_uom_qty': 1.0,
+ 'order_id': order.id,
+ })
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(len(order.order_line.ids), 3, "We should have a 'Free Large Meeting Table' promotion line")
+ self.assertEqual(sum(order.order_line.filtered(lambda x: x.is_reward_line).mapped('product_uom_qty')), 1, "We should receive one and only one free Large Meeting Table")
+
+ # Check the required value amount to be eligible for the program is correctly computed (eg: it does not add negative value (from free product) to total)
+ # A = free b | Have your cart with A 2B b | cart value should be A + 1B but in code it is only A (free b value is subsstract 2 times)
+ # This is because _amount_all() is summing all SO lines (so + (-b.value)) and again in _check_promo_code() order.amount_untaxed + order.reward_amount | amount_untaxed has already free product value substracted (_amount_all)
+ sol1.product_uom_qty = 1
+ sol2.product_uom_qty = 2
+ self.p1.rule_ids.minimum_amount = 5000
+ self._auto_rewards(order, self.all_programs)
+ self._apply_promo_code(order, 'test_10pc')
+ self.assertEqual(len(order.order_line.ids), 4, "We should have 4 lines as we should have a new line for promo code reduction")
+
+ # Check you can still have auto applied promotion if you have a promo code set to the order
+ self.env['sale.order.line'].create({
+ 'product_id': self.largeCabinet.id,
+ 'name': 'Large Cabinet',
+ 'product_uom_qty': 4.0,
+ 'order_id': order.id,
+ })
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(len(order.order_line.ids), 6, "We should have 2 more lines as we now have one 'Free Large Cabinet' line since we bought 4 of them")
+
+ def test_program_numbers_taxes_and_rules(self):
+ percent_tax = self.env['account.tax'].create({
+ 'name': "15% Tax",
+ 'amount_type': 'percent',
+ 'amount': 15,
+ 'price_include': True,
+ })
+ p_specific_product = self.env['loyalty.program'].create({
+ 'name': '20% reduction on Large Cabinet in cart',
+ 'trigger': 'auto',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {
+ 'reward_point_mode': 'order',
+ 'minimum_amount_tax_mode': 'excl',
+ 'minimum_amount': 320.00,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount': 20,
+ 'discount_mode': 'percent',
+ 'discount_applicability': 'specific',
+ 'discount_product_ids': self.largeCabinet,
+ 'required_points': 1,
+ })],
+ })
+ self.all_programs |= p_specific_product
+ order = self.empty_order
+ self.largeCabinet.taxes_id = percent_tax
+ sol1 = self.env['sale.order.line'].create({
+ 'product_id': self.largeCabinet.id,
+ 'name': 'Large Cabinet',
+ 'product_uom_qty': 1.0,
+ 'order_id': order.id,
+ })
+
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(len(order.order_line.ids), 1, "We should not get the reduction line since we dont have 320$ tax excluded (cabinet is 320$ tax included)")
+ sol1.tax_id.price_include = False
+ sol1._compute_tax_id()
+ self.env['account.tax'].flush()
+ self.env['account.tax'].invalidate_cache(fnames=['price_include'])
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(len(order.order_line.ids), 2, "We should now get the reduction line since we have 320$ tax included (cabinet is 320$ tax included)")
+ # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
+ # --------------------------------------------------------------------------------
+ # Conference Chair | 1 | 320.00 | 15% excl | 320.00 | 368.00 | 48.00
+ # 20% discount on | 1 | -64.00 | 15% excl | -64.00 | -73.60 | -9.60
+ # large cabinet |
+ # --------------------------------------------------------------------------------
+ # TOTAL | 256.00 | 294.40 | 38.40
+ self.assertAlmostEqual(order.amount_total, 294.4, 2, "Check discount has been applied correctly (eg: on taxes aswell)")
+
+ # test coupon with code works the same as auto applied_programs
+ p_specific_product.write({'trigger': 'with_code'})
+ p_specific_product.rule_ids.write({'mode': 'with_code', 'code': '20pc'})
+ order.order_line.filtered(lambda l: l.is_reward_line).unlink()
+ order._remove_program_from_points(p_specific_product)
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(len(order.order_line.ids), 1, "Reduction should be removed since we deleted it and it is now a promo code usage, it shouldn't be automatically reapplied")
+
+ self._apply_promo_code(order, '20pc')
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(len(order.order_line.ids), 2, "We should now get the reduction line since we have 320$ tax included (cabinet is 320$ tax included)")
+
+ # check discount applied only on Large Cabinet
+ self.env['sale.order.line'].create({
+ 'product_id': self.drawerBlack.id,
+ 'name': 'Drawer Black',
+ 'product_uom_qty': 10.0,
+ 'order_id': order.id,
+ })
+ self._auto_rewards(order, self.all_programs)
+ # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
+ # --------------------------------------------------------------------------------
+ # Drawer Black | 10 | 25.00 | / | 250.00 | 250.00 | /
+ # Large Cabinet | 1 | 320.00 | 15% excl | 320.00 | 368.00 | 48.00
+ # 20% discount on | 1 | -64.00 | 15% excl | -64.00 | -73.60 | -9.60
+ # large cabinet |
+ # --------------------------------------------------------------------------------
+ # TOTAL | 506.00 | 544.40 | 38.40
+ self.assertEqual(order.amount_total, 544.4, "We should only get reduction on cabinet")
+ sol1.product_uom_qty = 8
+ self._auto_rewards(order, self.all_programs)
+ # Note: Since we now have 2 free Large Cabinet, we should discount only 8 of the 10 Large Cabinet in carts since we don't want to discount free Large Cabinet
+ # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
+ # --------------------------------------------------------------------------------
+ # Drawer Black | 10 | 25.00 | / | 250.00 | 250.00 | /
+ # Large Cabinet | 8 | 320.00 | 15% excl | 2560.00 | 2944.00 | 384.00
+ # Free Large Cabinet | 2 | 0.00 | 15% excl | 0.00 | 0.00 | 0.00
+ # 20% discount on | 1 | -512.00 | 15% excl | -512.00 | -588.80 | -78.80
+ # large cabinet |
+ # --------------------------------------------------------------------------------
+ # TOTAL | 2298.00 | 2605.20 | 305.20
+ self.assertAlmostEqual(order.amount_total, 2605.20, 2, "Changing cabinet quantity should change discount amount correctly")
+
+ p_specific_product.reward_ids.discount_max_amount = 200
+ self._auto_rewards(order, self.all_programs)
+ # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
+ # --------------------------------------------------------------------------------
+ # Drawer Black | 10 | 25.00 | / | 250.00 | 250.00 | /
+ # Large Cabinet | 8 | 320.00 | 15% excl | 2560.00 | 2944.00 | 384.00
+ # Free Large Cabinet | 2 | 0.00 | 15% excl | 0.00 | 0.00 | 0.00
+ # 20% discount on | 1 | -173.91 | 15% excl | -173.91 | -200.00 | -26.09
+ # large cabinet |
+ # limited to 200 HTVA
+ # --------------------------------------------------------------------------------
+ # TOTAL | 2636.09 | 2994.00 | 357.91
+ self.assertEqual(order.amount_total, 2994.0, "The discount should be limited to $200 tax included")
+ self.assertEqual(order.amount_untaxed, 2636.09, "The discount should be limited to $200 tax included (2)")
+
+ def test_program_numbers_one_discount_line_per_tax(self):
+ order = self.empty_order
+ # Create taxes
+ self.tax_15pc_excl = self.env['account.tax'].create({
+ 'name': "15% Tax excl",
+ 'amount_type': 'percent',
+ 'amount': 15,
+ })
+ self.tax_50pc_excl = self.env['account.tax'].create({
+ 'name': "50% Tax excl",
+ 'amount_type': 'percent',
+ 'amount': 50,
+ })
+ self.tax_35pc_incl = self.env['account.tax'].create({
+ 'name': "35% Tax incl",
+ 'amount_type': 'percent',
+ 'amount': 35,
+ 'price_include': True,
+ })
+
+ # Set tax and prices on products as neeed for the test
+ (self.product_A + self.largeCabinet + self.conferenceChair + self.pedalBin + self.drawerBlack).write({'list_price': 100})
+ (self.largeCabinet + self.drawerBlack).write({'taxes_id': [(4, self.tax_15pc_excl.id, False)]})
+ self.conferenceChair.taxes_id = self.tax_10pc_incl
+ self.pedalBin.taxes_id = None
+ self.product_A.taxes_id = (self.tax_35pc_incl + self.tax_50pc_excl)
+
+ # Add products in order
+ self.env['sale.order.line'].create({
+ 'product_id': self.largeCabinet.id,
+ 'name': 'Large Cabinet',
+ 'product_uom_qty': 4.0,
+ 'order_id': order.id,
+ })
+ sol2 = self.env['sale.order.line'].create({
+ 'product_id': self.conferenceChair.id,
+ 'name': 'Conference Chair',
+ 'product_uom_qty': 3.0,
+ 'order_id': order.id,
+ })
+ self.env['sale.order.line'].create({
+ 'product_id': self.pedalBin.id,
+ 'name': 'Pedal Bin',
+ 'product_uom_qty': 5.0,
+ 'order_id': order.id,
+ })
+ self.env['sale.order.line'].create({
+ 'product_id': self.product_A.id,
+ 'name': 'product A with multiple taxes',
+ 'product_uom_qty': 3.0,
+ 'order_id': order.id,
+ })
+ self.env['sale.order.line'].create({
+ 'product_id': self.drawerBlack.id,
+ 'name': 'Drawer Black',
+ 'product_uom_qty': 2.0,
+ 'order_id': order.id,
+ })
+
+ # Create needed programs
+ self.immediate_promotion_program.active = False
+ self.p2.active = False
+ self.p3.active = False
+
+ # NOTE: programs may not make much sense but they have been modified in order to validate the result since the change from coupon to loyalty.
+ self.p_large_cabinet = self.env['loyalty.program'].create({
+ 'name': 'Buy 1 large cabinet, get 3/4 for free',
+ 'trigger': 'auto',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {
+ 'product_ids': self.largeCabinet,
+ 'reward_point_mode': 'unit',
+ 'minimum_qty': 1,
+ 'reward_point_amount': 0.752,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'product',
+ 'reward_product_id': self.largeCabinet.id,
+ 'reward_product_qty': 1,
+ 'required_points': 1,
+ })],
+ })
+ self.p_conference_chair = self.env['loyalty.program'].create({
+ 'name': 'Buy 1 chair, get one for free',
+ 'trigger': 'auto',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {
+ 'product_ids': self.conferenceChair,
+ 'reward_point_mode': 'unit',
+ 'minimum_qty': 1,
+ 'reward_point_amount': 0.4,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'product',
+ 'reward_product_id': self.conferenceChair.id,
+ 'reward_product_qty': 1,
+ 'required_points': 1,
+ })],
+ })
+ self.p_pedal_bin = self.env['loyalty.program'].create({
+ 'name': 'Buy 1 bin, get one for free',
+ 'trigger': 'auto',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {
+ 'product_ids': self.pedalBin,
+ 'reward_point_mode': 'unit',
+ 'minimum_qty': 1,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'product',
+ 'reward_product_id': self.pedalBin.id,
+ 'reward_product_qty': 1,
+ 'required_points': 1,
+ })],
+ })
+ self.all_programs |= (self.p_large_cabinet | self.p_conference_chair | self.p_pedal_bin)
+ # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
+ # --------------------------------------------------------------------------------
+ # Conference Chair | 5 | 100.00 | 10% incl | 454.55 | 500.00 | 45.45
+ # Pedal bin | 10 | 100.00 | / | 1000.00 | 1000.00 | /
+ # Large Cabinet | 7 | 100.00 | 15% excl | 700.00 | 805.00 | 105.00
+ # Drawer Black | 2 | 100.00 | 15% excl | 200.00 | 230.00 | 30.00
+ # Product A | 3 | 100.00 | 35% incl | 222.22 | 411.11 | 188.89
+ # 50% excl
+ # --------------------------------------------------------------------------------
+ # TOTAL | 2576.77 | 2946.11 | 369.34
+
+ self.assertAlmostEqual(order.amount_total, 1901.11, 2, "The order total with programs should be 1901.11")
+ self.assertEqual(order.amount_untaxed, 1594.95, "The order untaxed total without any programs should be 2576.77")
+ self.assertEqual(len(order.order_line.ids), 5, "The order without any programs should have 5 lines")
+
+ # Apply all the programs
+ self._auto_rewards(order, self.all_programs)
+
+ # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
+ # --------------------------------------------------------------------------------
+ # Free ConferenceChair | 2 | -100.00 | 10% incl | -181.82 | -200.00 | -18.18
+ # Free Pedal Bin | 5 | -100.00 | / | -500.00 | -500.00 | /
+ # Free Large Cabinet | 3 | -100.00 | 15% excl | -300.00 | -345.00 | -45.00
+ # --------------------------------------------------------------------------------
+ # TOTAL AFTER APPLYING FREE PRODUCT PROGRAMS | 1594.95 | 1901.11 | 306.16
+
+ self.assertAlmostEqual(order.amount_total, 1901.11, 2, "The order total with programs should be 1901.11")
+ self.assertEqual(order.amount_untaxed, 1594.95, "The order untaxed total with programs should be 1594.95")
+ self.assertEqual(len(order.order_line.ids), 8, "Order should contains 5 regular product lines and 3 free product lines")
+
+ # Apply 10% on top of everything
+ self._apply_promo_code(order, 'test_10pc')
+
+ # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
+ # --------------------------------------------------------------------------------
+ # 10% on tax 10% incl | 1 | -30.00 | 10% incl | -27.27 | -30.00 | -2.73
+ # 10% on no tax | 1 | -50.00 | / | -50.00 | -50.00 | /
+ # 10% on tax 15% excl | 1 | -60.00 | 15% excl | -60.00 | -69.00 | -9.00
+ # 10% on tax 35%+50% | 1 | -30.00 | 35% incl | -22.22 | -41.11 | -18.89
+ # 50% excl
+ # --------------------------------------------------------------------------------
+ # TOTAL AFTER APPLYING 10% GLOBAL PROGRAM | 1435.46 | 1711.00 | 275.54
+
+ self.assertEqual(order.amount_total, 1711, "The order total with programs should be 1711")
+ self.assertEqual(order.amount_untaxed, 1435.46, "The order untaxed total with programs should be 1435.46")
+ self.assertEqual(len(order.order_line.ids), 12, "Order should contains 5 regular product lines, 3 free product lines and 4 discount lines (one for every tax)")
+
+ # -- This is a test inside the test
+ order.order_line._compute_tax_id()
+ self.assertEqual(order.amount_total, 1711, "Recomputing tax on sale order lines should not change total amount")
+ self.assertEqual(order.amount_untaxed, 1435.46, "Recomputing tax on sale order lines should not change untaxed amount")
+ self.assertEqual(len(order.order_line.ids), 12, "Recomputing tax on sale order lines should not change number of order line")
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(order.amount_total, 1711, "Recomputing tax on sale order lines should not change total amount")
+ self.assertEqual(order.amount_untaxed, 1435.46, "Recomputing tax on sale order lines should not change untaxed amount")
+ self.assertEqual(len(order.order_line.ids), 12, "Recomputing tax on sale order lines should not change number of order line")
+ # -- End test inside the test
+
+ # Now we want to apply a 20% discount only on Large Cabinet
+ self.all_programs |= self.env['loyalty.program'].create({
+ 'name': '20% reduction on Large Cabinet in cart',
+ 'trigger': 'auto',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {})],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount': 20,
+ 'discount_applicability': 'specific',
+ 'discount_product_ids': self.largeCabinet,
+ 'required_points': 1,
+ 'clear_wallet': 1,
+ })],
+ })
+ self._auto_rewards(order, self.all_programs)
+
+ # 20% on large cabinet which are already discounted by 10%
+ # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
+ # --------------------------------------------------------------------------------
+ # 20% on Large Cabinet | 1 | -72.00 | 15% excl | -72.00 | -82.8 | -10.80
+ # --------------------------------------------------------------------------------
+ # TOTAL AFTER APPLYING 20% ON LARGE CABINET | 1363.46 | 1628.2 | 264.74
+
+ self.assertEqual(order.amount_total, 1628.2, "The order total with programs should be 1628.2")
+ self.assertEqual(order.amount_untaxed, 1363.46, "The order untaxed total with programs should be 1363.45")
+ self.assertEqual(len(order.order_line.ids), 13, "Order should have a new discount line for 20% on Large Cabinet")
+
+ # Check that if you delete one of the discount tax line, the others tax lines from the same promotion got deleted as well.
+ order.order_line.filtered(lambda l: '10%' in l.name)[0].unlink()
+ order._remove_program_from_points(self.p1)
+ self.assertEqual(len(order.order_line.ids), 9, "All of the 10% discount line per tax should be removed")
+ # At this point, removing the Conference Chair's discount line (split per tax) removed also the others discount lines
+ # linked to the same program (eg: other taxes lines). So the coupon got removed from the SO since there were no discount lines left
+
+ # Add back the coupon to continue the test flow
+ self._apply_promo_code(order, 'test_10pc')
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(len(order.order_line.ids), 13, "The 10% discount line should be back")
+
+ # Check that if you change a product qty, his discount tax line got updated
+ self.p_conference_chair.rule_ids.reward_point_amount = 0.752
+ sol2.product_uom_qty = 4
+ self._auto_rewards(order, self.all_programs)
+ # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
+ # --------------------------------------------------------------------------------
+ # Large Cabinet | 4 | 100.00 | 15% excl | 400.00 | 460.00 | 60.00
+ # Conference Chair | 4 | 100.00 | 10% incl | 363.64 | 400.00 | 36.36
+ # Pedal Bins | 5 | 100.00 | / | 500.00 | 500.00 | /
+ # Drawer Black | 2 | 100.00 | 15% excl | 200.00 | 230.00 | 30.00
+ # Product A | 3 | 100.00 | 35% incl | 222.22 | 411.11 | 188.89
+ # 50% excl
+ # Free - Large Cabinet | 3 | 0.00 | 15% excl | 0.00 | 0.00 | 0.00
+ # Free - Conference Ch | 3 | 0.00 | 10% incl | 0.00 | 0.00 | 0.00
+ # Free - Pedal Bins | 5 | 0.00 | / | 0.00 | 0.00 | /
+ # 20% on Large Cabinet | 1 | -80.00 | 15% excl | -80.00 | -92.00 | -12.00
+ # 10% on tax 15% excl | 1 | -52.00 | 15% excl | -52.00 | -59.80 | -7.80
+ # 10% on tax 10% excl | 1 | -40.00 | 15% excl | -36.36 | -40.00 | -3.64
+ # 10% on no tax | 1 | -50.00 | / | -50.00 | -50.00 | /
+ # 10% on tax 35+50% | 1 | -30.00 | 35% incl | -22.22 | -41.11 | -18.89
+ # 50% excl
+ # --------------------------------------------------------------------------------
+ # TOTAL | 1445.28 | 1718.20 | 272.92
+
+ self.assertEqual(order.amount_untaxed, 1445.28, "The order should have one more paid Conference Chair with 10% incl tax and discounted by 10%")
+
+ # Check that if you remove a product, his reward lines got removed, especially the discount per tax one
+ sol2.unlink()
+ self._auto_rewards(order, self.all_programs)
+ # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
+ # --------------------------------------------------------------------------------
+ # Pedal Bins | 5 | 100.00 | / | 500.00 | 500.00 | /
+ # Large Cabinet | 4 | 100.00 | 15% excl | 400.00 | 460.00 | 60.00
+ # Drawer Black | 2 | 100.00 | 15% excl | 200.00 | 230.00 | 30.00
+ # Product A | 3 | 100.00 | 35% incl | 222.22 | 411.11 | 188.89
+ # 50% excl
+ # Pedal Bins | 5 | 0.00 | / | 0.00 | 0.00 | /
+ # Large Cabinet | 3 | 0.00 | 15% excl | 0.00 | 0.00 | 0.00
+ # 20% on Large Cabinet | 1 | -80.00 | 15% excl | -80.00 | -92.00 | -12.00
+ # 10% on tax 15% excl | 1 | -52.00 | 15% excl | -52.00 | -59.80 | -7.80
+ # 10% on no tax | 1 | -50.00 | / | -50.00 | -50.00 | /
+ # 10% on tax 35+50% | 1 | -30.00 | 35% incl | -22.22 | -41.11 | -18.89
+ # 50% excl
+ # --------------------------------------------------------------------------------
+ # TOTAL | 1118.00 | 1349.00 | 240.20
+
+ self.assertAlmostEqual(order.amount_total, 1358.2, 2, "The order total with programs should be 1349.00")
+ self.assertEqual(order.amount_untaxed, 1118, "The order untaxed total with programs should be 1110.00")
+ self.assertEqual(len(order.order_line.ids), 10, "Order should contains 7 lines: 4 products lines, 2 free products lines and a 20% discount line")
+
+ def test_program_numbers_extras(self):
+ # Check that you can't apply a global discount promo code if there is already an auto applied global discount
+ p1_copy = self.p1.copy({'trigger': 'auto', 'name': 'Auto applied 10% global discount', 'rule_ids': [(0, 0, {})]})
+ self.all_programs |= p1_copy
+ order = self.empty_order
+ self.env['sale.order.line'].create({
+ 'product_id': self.largeCabinet.id,
+ 'name': 'Large Cabinet',
+ 'product_uom_qty': 1.0,
+ 'order_id': order.id,
+ })
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(len(order.order_line.ids), 2, "We should get 1 Large Cabinet line and 1 10% auto applied global discount line")
+ self.assertEqual(order.amount_total, 288, "320$ - 10%")
+ with self.assertRaises(ValidationError):
+ # Can't apply a second global discount
+ self._apply_promo_code(order, 'test_10pc')
+
+ def test_program_fixed_price(self):
+ # Check fixed amount discount
+ order = self.empty_order
+ self.p3.active = False
+ fixed_amount_program = self.env['loyalty.program'].create({
+ 'name': '$249 discount',
+ 'trigger': 'auto',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {
+ 'reward_point_mode': 'order',
+ 'reward_point_amount': 1,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount': 249,
+ 'discount_mode': 'per_point',
+ 'discount_applicability': 'order',
+ 'required_points': 1,
+ })],
+ })
+ self.all_programs |= fixed_amount_program
+ self.tax_0pc_excl = self.env['account.tax'].create({
+ 'name': "0% Tax excl",
+ 'amount_type': 'percent',
+ 'amount': 0,
+ })
+ fixed_amount_program.reward_ids.discount_line_product_id.write({'taxes_id': [(4, self.tax_0pc_excl.id, False)]})
+ sol1 = self.env['sale.order.line'].create({
+ 'product_id': self.drawerBlack.id,
+ 'name': 'Drawer Black',
+ 'product_uom_qty': 1.0,
+ 'order_id': order.id,
+ })
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(order.amount_total, 0, "Total should be null. The fixed amount discount is higher than the SO total, it should be reduced to the SO total")
+ self.assertEqual(len(order.order_line.ids), 2, "There should be the product line and the reward line")
+ sol1.product_uom_qty = 17
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(order.amount_total, 176, "Fixed amount discount should be totally deduced")
+ self.assertEqual(len(order.order_line.ids), 2, "Number of lines should be unchanged as we just recompute the reward line")
+ fixed_amount_program.write({'active': False}) # Check archived product will remove discount lines on recompute
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(len(order.order_line.ids), 1, "Archiving the program should remove the program reward line")
+
+ def test_program_next_order(self):
+ order = self.empty_order
+ self.all_programs |= self.env['loyalty.program'].create({
+ 'name': 'Free Pedal Bin if at least 1 article',
+ 'trigger': 'auto',
+ 'applies_on': 'future',
+ 'program_type': 'promotion',
+ 'rule_ids': [(0, 0, {
+ 'minimum_qty': 2,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'product',
+ 'reward_product_id': self.pedalBin.id,
+ 'reward_product_qty': 1,
+ 'required_points': 1,
+ })],
+ })
+ sol1 = self.env['sale.order.line'].create({
+ 'product_id': self.largeCabinet.id,
+ 'name': 'Large Cabinet',
+ 'product_uom_qty': 1.0,
+ 'order_id': order.id,
+ })
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(len(order.order_line.ids), 1, "Nothing should be added to the cart")
+ self.assertEqual(len(order._get_reward_coupons()), 0, "No coupon should have been generated yet")
+
+ sol1.product_uom_qty = 2
+ self._auto_rewards(order, self.all_programs)
+ generated_coupon = order._get_reward_coupons()
+ self.assertEqual(len(order.order_line.ids), 1, "Nothing should be added to the cart (2)")
+ self.assertEqual(len(generated_coupon), 1, "A coupon should have been generated")
+ self.assertEqual(generated_coupon.points, 0, "The coupon should not have it's points already.")
+
+ sol1.product_uom_qty = 1
+ self._auto_rewards(order, self.all_programs)
+ generated_coupon = order._get_reward_coupons()
+ self.assertEqual(len(order.order_line.ids), 1, "Nothing should be added to the cart (3)")
+ self.assertEqual(len(generated_coupon), 0, "No more coupon should have been generated and the existing one should not have been deleted")
+
+ sol1.product_uom_qty = 2
+ self._auto_rewards(order, self.all_programs)
+ generated_coupon = order._get_reward_coupons()
+ self.assertEqual(len(generated_coupon), 1, "We should still have only 1 coupon as we now benefit again from the program but no need to create a new one (see next assert)")
+ self.assertEqual(generated_coupon.points, 0, "The coupon should not have it's points already.")
+
+ def test_coupon_rule_minimum_amount(self):
+ """ Ensure coupon with minimum amount rule are correctly
+ applied on orders
+ """
+ order = self.empty_order
+ self.env['sale.order.line'].create({
+ 'product_id': self.conferenceChair.id,
+ 'name': 'Conference Chair',
+ 'product_uom_qty': 10.0,
+ 'order_id': order.id,
+ })
+ self.assertEqual(order.amount_total, 165.0, "The order amount is not correct")
+ self.env['loyalty.generate.wizard'].with_context(active_id=self.discount_coupon_program.id).create({
+ 'coupon_qty': 1,
+ 'points_granted': 1,
+ }).generate_coupons()
+ coupon = self.discount_coupon_program.coupon_ids[0]
+ self._apply_promo_code(order, coupon.code)
+ self.assertEqual(order.amount_total, 65.0, "The coupon should be correctly applied")
+ 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_and_program_discount_fixed_amount(self):
+ """ Ensure coupon and program discount both with
+ minimum amount rule can cohexists without making
+ the order go below 0
+ """
+ order = self.empty_order
+ orderline = self.env['sale.order.line'].create({
+ 'product_id': self.conferenceChair.id,
+ 'name': 'Conference Chair',
+ 'product_uom_qty': 10.0,
+ 'order_id': order.id,
+ })
+ self.assertEqual(order.amount_total, 165.0, "The order amount is not correct")
+
+ self.env['loyalty.program'].create({
+ 'name': '$100 promotion program',
+ 'program_type': 'promotion',
+ 'trigger': 'with_code',
+ 'rule_ids': [(0, 0, {
+ 'mode': 'with_code',
+ 'code': 'testpromo',
+ 'minimum_amount': 100,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount': 100,
+ 'discount_mode': 'per_point',
+ 'discount_applicability': 'order',
+ })],
+ })
+
+ self._apply_promo_code(order, 'testpromo')
+ self.assertEqual(order.amount_total, 65.0, "The promotion program should be correctly applied")
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(order.amount_total, 65.0, "The promotion program should not be removed after recomputation")
+
+ self.env['loyalty.generate.wizard'].with_context(active_id=self.discount_coupon_program.id).create({
+ 'coupon_qty': 1,
+ 'points_granted': 1,
+ }).generate_coupons()
+ coupon = self.discount_coupon_program.coupon_ids[0]
+ with self.assertRaises(ValidationError):
+ self._apply_promo_code(order, coupon.code)
+ orderline.write({'product_uom_qty': 15})
+ self._apply_promo_code(order, coupon.code)
+ self.assertEqual(order.amount_total, 47.5, "The promotion program should now be correctly applied")
+
+ orderline.write({'product_uom_qty': 5})
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(order.amount_total, 82.5, "The promotion programs should have been removed from the order to avoid negative amount")
+
+ def test_coupon_and_coupon_discount_fixed_amount_tax_excl(self):
+ """ Ensure multiple coupon can cohexists without making
+ the order go below 0
+ * Have an order of 300 (3 lines: 1 tax excl 15%, 2 notax)
+ * Apply a coupon A of 10% discount, unconditioned
+ * Apply a coupon B of 288.5 discount, unconditioned
+ * Order should not go below 0
+ * Even applying the coupon in reverse order should yield same result
+ """
+
+ self.immediate_promotion_program.active = False
+ coupon_program = self.env['loyalty.program'].create({
+ 'name': '$288.5 coupon',
+ 'program_type': 'coupons',
+ 'trigger': 'with_code',
+ 'applies_on': 'current',
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount_mode': 'per_point',
+ 'discount': 288.5,
+ 'discount_applicability': 'order',
+ 'required_points': 1,
+ })],
+ })
+
+ 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,))],
+ },
+ {
+ 'product_id': self.pedalBin.id,
+ 'name': 'Computer Case',
+ 'product_uom_qty': 1.0,
+ 'price_unit': 100.0,
+ 'order_id': order.id,
+ 'tax_id': [(6, 0, [])],
+ },
+ {
+ 'product_id': self.product_A.id,
+ 'name': 'Computer Case',
+ 'product_uom_qty': 1.0,
+ 'price_unit': 100.0,
+ 'order_id': order.id,
+ 'tax_id': [(6, 0, [])],
+ },
+ ])
+
+ self._apply_promo_code(order, 'test_10pc')
+ self.assertEqual(order.amount_total, 283.5, "The promotion program should be correctly applied")
+
+ 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._auto_rewards(order, self.all_programs)
+ 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")
+
+ order.order_line[3:].unlink() #remove all coupon
+ order._remove_program_from_points(coupon_program)
+ order._remove_program_from_points(self.p1)
+
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(len(order.order_line), 3, "The promotion program should be removed")
+ self._apply_promo_code(order, coupon.code)
+ self.assertEqual(order.amount_total, 26.5, "The promotion program should be correctly applied")
+ 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.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.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):
+ """ Ensure multiple coupon can cohexists without making
+ the order go below 0
+ * Have an order of 300 (3 lines: 1 tax incl 10%, 2 notax)
+ * Apply a coupon A of 10% discount, unconditioned
+ * Apply a coupon B of 290 discount, unconditioned
+ * Order should not go below 0
+ * Even applying the coupon in reverse order should yield same result
+ """
+
+ self.immediate_promotion_program.active = False
+ coupon_program = self.env['loyalty.program'].create({
+ 'name': '$290 coupon',
+ 'program_type': 'coupons',
+ 'trigger': 'with_code',
+ 'applies_on': 'current',
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount_mode': 'per_point',
+ 'discount': 290,
+ 'discount_applicability': 'order',
+ 'required_points': 1,
+ })],
+ })
+
+ 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_10pc_incl.id,))],
+ },
+ {
+ 'product_id': self.pedalBin.id,
+ 'name': 'Computer Case',
+ 'product_uom_qty': 1.0,
+ 'price_unit': 100.0,
+ 'order_id': order.id,
+ 'tax_id': [(6, 0, [])],
+ },
+ {
+ 'product_id': self.product_A.id,
+ 'name': 'Computer Case',
+ 'product_uom_qty': 1.0,
+ 'price_unit': 100.0,
+ 'order_id': order.id,
+ 'tax_id': [(6, 0, [])],
+ },
+ ])
+
+ self._apply_promo_code(order, 'test_10pc')
+ self.assertEqual(order.amount_total, 270.0, "The promotion program should be correctly applied")
+
+ 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_total, 0.0, "The promotion program should not make the order total go below 0")
+ self.assertEqual(order.amount_tax, 0)
+ 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)
+
+ order.order_line[3:].unlink() #remove all coupon
+ order._remove_program_from_points(coupon_program)
+ order._remove_program_from_points(self.p1)
+
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(len(order.order_line), 3, "The promotion program should be removed")
+ self._apply_promo_code(order, coupon.code)
+ self.assertEqual(order.amount_total, 10.0, "The promotion program should be correctly applied")
+ 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)
+ # 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)
+
+ def test_program_discount_on_multiple_specific_products(self):
+ """ Ensure a discount on multiple specific products is correctly computed.
+ - Simple: Discount must be applied on all the products set on the promotion
+ - Advanced: This discount must be split by different taxes
+ """
+ order = self.empty_order
+ self.p3.active = False
+ p_specific_products = self.env['loyalty.program'].create({
+ 'name': '20% reduction on Conference Chair and Drawer Black in cart',
+ 'program_type': 'promotion',
+ 'trigger': 'auto',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {})],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount_mode': 'percent',
+ 'discount': 25,
+ 'discount_applicability': 'specific',
+ 'discount_product_ids': [(6, 0, [self.conferenceChair.id, self.drawerBlack.id])],
+ 'required_points': 1,
+ })],
+ })
+ self.all_programs |= p_specific_products
+
+ self.env['sale.order.line'].create({
+ 'product_id': self.conferenceChair.id,
+ 'name': 'Conference Chair',
+ 'product_uom_qty': 4.0,
+ 'order_id': order.id,
+ })
+ sol2 = self.env['sale.order.line'].create({
+ 'product_id': self.drawerBlack.id,
+ 'name': 'Drawer Black',
+ 'product_uom_qty': 2.0,
+ 'order_id': order.id,
+ })
+
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(len(order.order_line.ids), 3, "Conference Chair + Drawer Black + 20% discount line")
+ # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
+ # --------------------------------------------------------------------------------
+ # Conference Chair | 4 | 16.50 | / | 66.00 | 66.00 | 0.00
+ # Drawer Black | 2 | 25.00 | / | 50.00 | 50.00 | 0.00
+ # 25% discount | 1 | -29.00 | / | -29.00 | -29.00 | 0.00
+ # --------------------------------------------------------------------------------
+ # TOTAL | 87.00 | 87.00 | 0.00
+ self.assertEqual(order.amount_total, 87.00, "Total should be 87.00, see above comment")
+
+ # remove Drawer Black case from promotion
+ p_specific_products.reward_ids.discount_product_ids = [(6, 0, [self.conferenceChair.id])]
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(len(order.order_line.ids), 3, "Should still be Conference Chair + Drawer Black + 20% discount line")
+ # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
+ # --------------------------------------------------------------------------------
+ # Conference Chair | 4 | 16.50 | / | 66.00 | 66.00 | 0.00
+ # Drawer Black | 2 | 25.00 | / | 50.00 | 50.00 | 0.00
+ # 25% discount | 1 | -16.50 | / | -16.50 | -16.50 | 0.00
+ # --------------------------------------------------------------------------------
+ # TOTAL | 99.50 | 99.50 | 0.00
+ self.assertEqual(order.amount_total, 99.50, "The 12.50 discount from the drawer black should be gone")
+
+ # =========================================================================
+ # PART 2: Same flow but with different taxes on products to ensure discount is split per VAT
+ # Add back Drawer Black in promotion
+ p_specific_products.reward_ids.discount_product_ids = [(6, 0, [self.conferenceChair.id, self.drawerBlack.id])]
+
+ percent_tax = self.env['account.tax'].create({
+ 'name': "30% Tax",
+ 'amount_type': 'percent',
+ 'amount': 30,
+ 'price_include': True,
+ })
+ sol2.tax_id = percent_tax
+
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(len(order.order_line.ids), 4, "Conference Chair + Drawer Black + 20% on no TVA product (Conference Chair) + 20% on 15% tva product (Drawer Black)")
+ # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
+ # --------------------------------------------------------------------------------
+ # Conference Chair | 4 | 16.50 | / | 66.00 | 66.00 | 0.00
+ # Drawer Black | 2 | 25.00 | 30% incl | 38.46 | 50.00 | 11.54
+ # 25% discount | 1 | -16.50 | / | -16.50 | -16.50 | 0.00
+ # 25% discount | 1 | -12.50 | 30% incl | -9.62 | -12.50 | -2.88
+ # --------------------------------------------------------------------------------
+ # TOTAL | 78.34 | 87.00 | 8.66
+ self.assertEqual(order.amount_total, 87.00, "Total untaxed should be as per above comment")
+ self.assertEqual(order.amount_untaxed, 78.34, "Total with taxes should be as per above comment")
+
+ def test_program_numbers_free_prod_with_min_amount_and_qty_on_same_prod(self):
+ # This test focus on giving a free product based on both
+ # minimum amount and quantity condition on an
+ # auto applied promotion program
+
+ order = self.empty_order
+ self.p3.active = False
+ self.all_programs |= self.env['loyalty.program'].create({
+ 'name': 'Buy 2 Chairs, get 1 free',
+ 'trigger': 'auto',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {
+ 'product_ids': self.conferenceChair,
+ 'reward_point_mode': 'order',
+ 'minimum_qty': 2,
+ 'minimum_amount': self.conferenceChair.lst_price * 2,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'product',
+ 'reward_product_id': self.conferenceChair.id,
+ 'reward_product_qty': 1,
+ 'required_points': 1,
+ })],
+ })
+ sol1 = self.env['sale.order.line'].create({
+ 'product_id': self.conferenceChair.id,
+ 'name': 'Conf Chair',
+ 'product_uom_qty': 1.0,
+ 'order_id': order.id,
+ })
+ sol2 = self.env['sale.order.line'].create({
+ 'product_id': self.drawerBlack.id,
+ 'name': 'Drawer',
+ 'product_uom_qty': 1.0,
+ 'order_id': order.id,
+ }) # dummy line
+
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(len(order.order_line.ids), 2, "The promotion lines should not be applied")
+ sol1.write({'product_uom_qty': 2.0})
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(len(order.order_line.ids), 3, "The promotion lines should have been added")
+ self.assertEqual(order.amount_total, self.conferenceChair.lst_price * (sol1.product_uom_qty) + self.drawerBlack.lst_price * sol2.product_uom_qty, "The promotion line was not applied to the amount total")
+ sol2.unlink()
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(len(order.order_line.ids), 2, "The other product should not affect the promotion")
+ self.assertEqual(order.amount_total, self.conferenceChair.lst_price * (sol1.product_uom_qty), "The promotion line was not applied to the amount total")
+ sol1.write({'product_uom_qty': 1.0})
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(len(order.order_line.ids), 1, "The promotion lines should have been removed")
+
+ def test_program_step_percentages(self):
+ # test step-like percentages increase over amount
+ testprod = self.env['product.product'].create({
+ 'name': 'testprod',
+ 'lst_price': 118.0,
+ })
+
+ self.all_programs |= self.env['loyalty.program'].create({
+ 'name': '10% discount',
+ 'trigger': 'auto',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {
+ 'reward_point_mode': 'order',
+ 'minimum_amount': 1500.00,
+ 'minimum_amount_tax_mode': 'incl',
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount': 10,
+ 'discount_mode': 'percent',
+ 'discount_applicability': 'order',
+ 'required_points': 1,
+ })],
+ })
+ self.all_programs |= self.env['loyalty.program'].create({
+ 'name': '15% discount',
+ 'trigger': 'auto',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {
+ 'reward_point_mode': 'order',
+ 'minimum_amount': 1750.00,
+ 'minimum_amount_tax_mode': 'incl',
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount': 15,
+ 'discount_mode': 'percent',
+ 'discount_applicability': 'order',
+ 'required_points': 1,
+ })],
+ })
+ self.all_programs |= self.env['loyalty.program'].create({
+ 'name': '20% discount',
+ 'trigger': 'auto',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {
+ 'reward_point_mode': 'order',
+ 'minimum_amount': 2000.00,
+ 'minimum_amount_tax_mode': 'incl',
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount': 20,
+ 'discount_mode': 'percent',
+ 'discount_applicability': 'order',
+ 'required_points': 1,
+ })],
+ })
+ self.all_programs |= self.env['loyalty.program'].create({
+ 'name': '25% discount',
+ 'trigger': 'auto',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {
+ 'reward_point_mode': 'order',
+ 'minimum_amount': 2500.00,
+ 'minimum_amount_tax_mode': 'incl',
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount': 25,
+ 'discount_mode': 'percent',
+ 'discount_applicability': 'order',
+ 'required_points': 1,
+ })],
+ })
+
+ #apply 10%
+ order = self.empty_order
+ order_line = self.env['sale.order.line'].create({
+ 'product_id': testprod.id,
+ 'name': 'testprod',
+ 'product_uom_qty': 14.0,
+ 'price_unit': 118.0,
+ 'order_id': order.id,
+ 'tax_id': False,
+ })
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(order.amount_total, 1486.80, "10% discount should be applied")
+ self.assertEqual(len(order.order_line.ids), 2, "discount should be applied")
+
+ #switch to 15%
+ order_line.write({'product_uom_qty': 15})
+ self.assertEqual(order.amount_total, 1604.8, "Discount improperly applied")
+ self.assertEqual(len(order.order_line.ids), 2, "No discount applied while it should")
+
+ #switch to 20%
+ order_line.write({'product_uom_qty': 17})
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(order.amount_total, 1604.8, "Discount improperly applied")
+ self.assertEqual(len(order.order_line.ids), 2, "No discount applied while it should")
+
+ #still 20%
+ order_line.write({'product_uom_qty': 20})
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(order.amount_total, 1888.0, "Discount improperly applied")
+ self.assertEqual(len(order.order_line.ids), 2, "No discount applied while it should")
+
+ #back to 10%
+ order_line.write({'product_uom_qty': 14})
+ self._auto_rewards(order, self.all_programs)
+ self.assertEqual(order.amount_total, 1486.80, "Discount improperly applied")
+ self.assertEqual(len(order.order_line.ids), 2, "No discount applied while it should")
+
+ def test_program_free_prods_with_min_qty_and_reward_qty_and_rule(self):
+ order = self.empty_order
+ coupon_program = self.env['loyalty.program'].create({
+ 'name': '2 free conference chair if at least 1 large cabinet',
+ 'trigger': 'with_code',
+ 'program_type': 'coupons',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {
+ 'product_ids': self.largeCabinet,
+ 'reward_point_mode': 'order',
+ 'minimum_qty': 1,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount': 100,
+ 'discount_mode': 'percent',
+ 'discount_applicability': 'specific',
+ 'discount_product_ids': self.conferenceChair,
+ 'discount_max_amount': 200,
+ 'required_points': 1,
+ })],
+ })
+ # set large cabinet and conference chair prices
+ self.largeCabinet.write({'list_price': 500, 'sale_ok': True,})
+ self.conferenceChair.write({'list_price': 100, 'sale_ok': True})
+
+ # create SOL
+ self.env['sale.order.line'].create({
+ 'product_id': self.largeCabinet.id,
+ 'name': 'Large Cabinet',
+ 'product_uom_qty': 1.0,
+ 'order_id': order.id,
+ })
+ sol2 = self.env['sale.order.line'].create({
+ 'product_id': self.conferenceChair.id,
+ 'name': 'Conference chair',
+ 'product_uom_qty': 2.0,
+ 'order_id': order.id,
+ })
+
+ self.assertEqual(len(order.order_line), 2, 'The order must contain 2 order lines since the coupon is not yet applied')
+ self.assertEqual(order.amount_total, 700.0, 'The price must be 500.0 since the coupon is not yet applied')
+
+ # generate and apply coupon
+ 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)
+
+ # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
+ # --------------------------------------------------------------------------------
+ # Conference Chair | 2 | 100.00 | / | 200.00 | 200.00 | /
+ # Large Cabinet | 1 | 500.00 | / | 500.00 | 500.00 | /
+ #
+ # Free Conference Chair | 2 | -100.00 | / | -200.00 | -200.00 | /
+ # --------------------------------------------------------------------------------
+ # TOTAL | 500.00 | 500.00 | /
+
+ self.assertEqual(len(order.order_line), 3, 'The order must contain 3 order lines including one for free conference chair')
+ self.assertEqual(order.amount_total, 500.0, 'The price must be 500.0 since two conference chairs are free')
+ self.assertEqual(order.order_line[2].price_total, -200.0, 'The last order line should apply a reduction of 200.0 since there are two conference chairs that cost 100.0 each')
+
+ # prevent user to get illicite discount by decreasing the to 1 the reward product qty after applying the coupon
+ sol2.product_uom_qty = 1.0
+ self._auto_rewards(order, self.all_programs)
+
+ # in this case user should not have -200.0
+ # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
+ # --------------------------------------------------------------------------------
+ # Conference Chair | 1 | 100.00 | / | 100.00 | 100.00 | /
+ # Large Cabine | 1 | 500.00 | / | 500.00 | 500.00 | /
+ #
+ # Free Conference Chair | 2 | -100.00 | / | -200.00 | -200.00 | /
+ # --------------------------------------------------------------------------------
+ # TOTAL | 400.00 | 400.00 | /
+
+
+ # he should rather have this one
+ # Name | Qty | price_unit | Tax | HTVA | TVAC | TVA |
+ # --------------------------------------------------------------------------------
+ # Conference Chair | 1 | 100.00 | / | 100.00 | 100.00 | /
+ # Large Cabinet | 1 | 500.00 | / | 500.00 | 500.00 | /
+ #
+ # Free Conference Chair | 1 | -100.00 | / | -100.00 | -100.00 | /
+ # --------------------------------------------------------------------------------
+ # TOTAL | 500.00 | 500.00 | /
+
+ self.assertEqual(order.amount_total, 500.0, 'The price must be 500.0 since two conference chairs are free and the user only bought one')
+ self.assertEqual(order.order_line[2].price_total, -100.0, 'The last order line should apply a reduction of 100.0 since there is one conference chair that cost 100.0')
+
+ def test_program_free_product_different_than_rule_product_with_multiple_application(self):
+ order = self.empty_order
+
+ self.p3.active = False
+ self.all_programs |= self.env['loyalty.program'].create({
+ 'name': 'Buy 1 drawer black, get a free Large Meeting Table',
+ 'trigger': 'auto',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {
+ 'product_ids': self.drawerBlack,
+ 'reward_point_mode': 'order',
+ 'minimum_qty': 1,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount': 100,
+ 'discount_mode': 'percent',
+ 'discount_applicability': 'specific',
+ 'discount_product_ids': self.largeMeetingTable,
+ 'required_points': 1,
+ })],
+ })
+
+ self.env['sale.order.line'].create({
+ 'product_id': self.drawerBlack.id,
+ 'product_uom_qty': 2.0,
+ 'order_id': order.id,
+ })
+ sol_B = self.env['sale.order.line'].create({
+ 'product_id': self.largeMeetingTable.id,
+ 'product_uom_qty': 1.0,
+ 'order_id': order.id,
+ })
+
+ self._auto_rewards(order, self.all_programs)
+
+ self.assertEqual(len(order.order_line), 3, 'The order must contain 3 order lines: 1x for Black Drawer, 1x for Large Meeting Table and 1x for free Large Meeting Table')
+ self.assertEqual(order.amount_total, self.drawerBlack.list_price * 2, 'The price must be 50.0 since the Large Meeting Table is free: 2*25.00 (Black Drawer) + 1*40000.00 (Large Meeting Table) - 1*40000.00 (free Large Meeting Table)')
+
+ sol_B.product_uom_qty = 2
+
+ self._auto_rewards(order, self.all_programs)
+
+ self.assertEqual(len(order.order_line), 3, 'The order must contain 3 order lines: 1x for Black Drawer, 1x for Large Meeting Table and 1x for free Large Meeting Table')
+ self.assertEqual(order.amount_total, self.drawerBlack.list_price * 2, 'The price must be 50.0 since the 2 Large Meeting Table are free: 2*25.00 (Black Drawer) + 2*40000.00 (Large Meeting Table) - 2*40000.00 (free Large Meeting Table)')
+
+ def test_program_modify_reward_line_qty(self):
+ order = self.empty_order
+ product_F = self.env['product.product'].create({
+ 'name': 'Product F',
+ 'list_price': 100,
+ 'sale_ok': True,
+ 'taxes_id': [(6, 0, [])],
+ })
+ self.all_programs |= self.env['loyalty.program'].create({
+ 'name': '1 Product F = 5$ discount',
+ 'trigger': 'auto',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {
+ 'product_ids': product_F,
+ 'reward_point_mode': 'order',
+ 'minimum_qty': 1,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount': 5,
+ 'discount_mode': 'per_point',
+ 'required_points': 1,
+ })],
+ })
+
+ self.env['sale.order.line'].create({
+ 'product_id': product_F.id,
+ 'product_uom_qty': 2.0,
+ 'order_id': order.id,
+ })
+
+ self._auto_rewards(order, self.all_programs)
+
+ self.assertEqual(len(order.order_line), 2, 'The order must contain 2 order lines: 1x Product F and 1x 5$ discount')
+ self.assertEqual(order.amount_total, 195.0, 'The price must be 195.0 since there is a 5$ discount and 2x Product F')
+ self.assertEqual(sum(order.order_line.filtered(lambda x: x.is_reward_line).mapped('product_uom_qty')), 1, 'The reward line should have a quantity of 1 since Fixed Amount discounts apply only once per Sale Order')
+
+ order.order_line[1].product_uom_qty = 2
+
+ self.assertEqual(len(order.order_line), 2, 'The order must contain 2 order lines: 1x Product F and 1x 5$ discount')
+ self.assertEqual(order.amount_total, 190.0, 'The price must be 190.0 since there is now 2x 5$ discount and 2x Product F')
+ self.assertEqual(order.order_line.filtered(lambda x: x.is_reward_line).price_unit, -5, 'The discount unit price should still be -5 after the quantity was manually changed')
+
+ def test_specific_discount_product_group(self):
+ # Tests the following:
+ # 1 program: -5$ on [A, B]
+ # 1 program: -10$ on A
+ # Order with A (6$) B (4$) C (10$)
+ # Apply both coupons -> order total should be 10$
+ # Apply a 10% discount -> order total should be 9$
+ # Redo the same process but discount first
+ product_a, product_b, product_c = self.env['product.product'].create([
+ {
+ 'name': 'Product A',
+ 'list_price': 6,
+ 'sale_ok': True,
+ 'taxes_id': [(6, 0, [])],
+ },
+ {
+ 'name': 'Product B',
+ 'list_price': 4,
+ 'sale_ok': True,
+ 'taxes_id': [(6, 0, [])],
+ },
+ {
+ 'name': 'Product C',
+ 'list_price': 10,
+ 'sale_ok': True,
+ 'taxes_id': [(6, 0, [])],
+ },
+ ])
+ programs = self.env['loyalty.program'].create([
+ {
+ 'name': '-5 USD on [A, B]',
+ 'trigger': 'auto',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount': 5,
+ 'discount_mode': 'per_point',
+ 'discount_applicability': 'specific',
+ 'discount_product_ids': product_a | product_b,
+ 'required_points': 1,
+ })],
+ },
+ {
+ 'name': '-10 USD on A',
+ 'trigger': 'auto',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount': 10,
+ 'discount_mode': 'per_point',
+ 'discount_applicability': 'specific',
+ 'discount_product_ids': product_a,
+ 'required_points': 1,
+ })],
+ },
+ ])
+ order = self.empty_order
+ self.env['sale.order.line'].create([
+ {
+ 'product_id': product_a.id,
+ 'name': 'Product A',
+ 'product_uom_qty': 1,
+ 'order_id': order.id,
+ },
+ {
+ 'product_id': product_b.id,
+ 'name': 'Product B',
+ 'product_uom_qty': 1,
+ 'order_id': order.id,
+ },
+ {
+ 'product_id': product_c.id,
+ 'name': 'Product C',
+ 'product_uom_qty': 1,
+ 'order_id': order.id,
+ },
+ ])
+ self._auto_rewards(order, programs)
+ self.assertEqual(order.amount_total, 10, "The total should be 10$.")
+ # Try to apply another 10%
+ self._apply_promo_code(order, 'test_10pc')
+ self.assertEqual(order.amount_total, 9, "The total should be 9$.")
+ # Now the order way around
+ order.order_line.filtered('reward_id').unlink()
+ self._apply_promo_code(order, 'test_10pc')
+ self.assertEqual(order.amount_total, 18, "The total should be 9$.")
+ self._auto_rewards(order, programs)
+ self.assertEqual(order.amount_total, 9, "The total should be 9$.")
diff --git a/addons/sale_loyalty/tests/test_program_rules.py b/addons/sale_loyalty/tests/test_program_rules.py
new file mode 100644
index 00000000000..0e4d924ca2d
--- /dev/null
+++ b/addons/sale_loyalty/tests/test_program_rules.py
@@ -0,0 +1,276 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import date, timedelta
+
+from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon
+from odoo.exceptions import ValidationError
+
+class TestProgramRules(TestSaleCouponCommon):
+ # Test all the validity rules to allow a customer to have a reward.
+ # The check based on the products is already done in the basic operations test
+
+ def test_program_rules_minimum_purchased_amount(self):
+ # Test case: Based on the minimum purchased
+
+ self.immediate_promotion_program.rule_ids.write({
+ 'minimum_amount': 1006,
+ 'minimum_amount_tax_mode': 'excl'
+ })
+
+ order = self.empty_order
+ order.write({'order_line': [
+ (0, False, {
+ 'product_id': self.product_A.id,
+ 'name': '1 Product A',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ }),
+ (0, False, {
+ 'product_id': self.product_B.id,
+ 'name': '2 Product B',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ order._update_programs_and_rewards()
+ self._claim_reward(order, self.immediate_promotion_program)
+ self.assertEqual(len(order.order_line.ids), 2, "The promo offer shouldn't have been applied as the purchased amount is not enough")
+
+ order = self.env['sale.order'].create({'partner_id': self.steve.id})
+ order.write({'order_line': [
+ (0, False, {
+ 'product_id': self.product_A.id,
+ 'name': '10 Product A',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 10.0,
+ }),
+ (0, False, {
+ 'product_id': self.product_B.id,
+ 'name': '2 Product B',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ order._update_programs_and_rewards()
+ self._claim_reward(order, self.immediate_promotion_program)
+ # 10*100 + 5 = 1005
+ self.assertEqual(len(order.order_line.ids), 2, "The promo offer should not be applied as the purchased amount is not enough")
+
+ self.immediate_promotion_program.rule_ids.minimum_amount = 1005
+ order._update_programs_and_rewards()
+ self._claim_reward(order, self.immediate_promotion_program)
+ self.assertEqual(len(order.order_line.ids), 3, "The promo offer should be applied as the purchased amount is now enough")
+
+ # 10*(100*1.15) + (5*1.15) = 10*115 + 5.75 = 1155.75
+ self.immediate_promotion_program.rule_ids.minimum_amount = 1006
+ self.immediate_promotion_program.rule_ids.minimum_amount_tax_mode = 'incl'
+ order._update_programs_and_rewards()
+ self._claim_reward(order, self.immediate_promotion_program)
+ self.assertEqual(len(order.order_line.ids), 3, "The promo offer should be applied as the initial amount required is now tax included")
+
+ def test_program_rules_coupon_qty_and_amount_remove_not_eligible(self):
+ ''' This test will:
+ * Check quantity and amount requirements works as expected (since it's slightly different from a promotion_program)
+ * Ensure that if a reward from a coupon_program was allowed and the conditions are not met anymore,
+ the reward will be removed on recompute.
+ '''
+ self.immediate_promotion_program.active = False # Avoid having this program to add rewards on this test
+ order = self.empty_order
+
+ program = self.env['loyalty.program'].create({
+ 'name': 'Get 10% discount if buy at least 4 Product A and $320',
+ 'program_type': 'coupons',
+ 'applies_on': 'current',
+ 'trigger': 'with_code',
+ 'rule_ids': [(0, 0, {
+ 'product_ids': self.product_A,
+ 'minimum_qty': 3,
+ 'minimum_amount': 320,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount_mode': 'percent',
+ 'discount': 10,
+ 'discount_applicability': 'order',
+ })],
+ })
+
+ sol1 = self.env['sale.order.line'].create({
+ 'product_id': self.product_A.id,
+ 'name': 'Product A',
+ 'product_uom_qty': 2.0,
+ 'order_id': order.id,
+ })
+
+ sol2 = self.env['sale.order.line'].create({
+ 'product_id': self.product_B.id,
+ 'name': 'Product B',
+ 'product_uom_qty': 4.0,
+ 'order_id': order.id,
+ })
+
+ # Default value for coupon generate wizard is generate by quantity and generate only one coupon
+ self.env['loyalty.generate.wizard'].with_context(active_id=program.id).create({'coupon_qty': 1, 'points_granted': 1}).generate_coupons()
+ coupon = program.coupon_ids[0]
+
+ # Not enough amount since we only have 220 (100*2 + 5*4)
+ with self.assertRaises(ValidationError):
+ self._apply_promo_code(order, coupon.code)
+
+ sol2.product_uom_qty = 24
+
+ # Not enough qty since we only have 3 Product A (Amount is ok: 100*2 + 5*24 = 320)
+ with self.assertRaises(ValidationError):
+ self._apply_promo_code(order, coupon.code)
+
+ sol1.product_uom_qty = 3
+
+ self._apply_promo_code(order, coupon.code)
+ self._claim_reward(order, program, coupon)
+
+ self.assertEqual(len(order.order_line.ids), 3, "The order should contain the Product A line, the Product B line and the discount line")
+
+ sol1.product_uom_qty = 2
+ order._update_programs_and_rewards()
+
+ self.assertEqual(len(order.order_line.ids), 2, "The discount line should have been removed as we don't meet the program requirements")
+
+ def test_program_rules_promotion_use_best(self):
+ ''' This test verifies that only the best global discount is applied.
+ '''
+ self.immediate_promotion_program.active = False # Avoid having this program to add rewards on this test
+ order = self.empty_order
+
+ p1 = self.env['loyalty.program'].create({
+ 'name': 'Get 5% discount if buy at least 2 Product',
+ 'trigger': 'auto',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {
+ 'reward_point_mode': 'order',
+ 'minimum_qty': 2,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount': 5,
+ 'discount_mode': 'percent',
+ 'discount_applicability': 'order',
+ 'required_points': 1,
+ })],
+ })
+ p2 = self.env['loyalty.program'].create({
+ 'name': 'Get 10% discount if buy at least 4 Product',
+ 'trigger': 'auto',
+ 'program_type': 'promotion',
+ 'applies_on': 'current',
+ 'rule_ids': [(0, 0, {
+ 'reward_point_mode': 'order',
+ 'minimum_qty': 4,
+ })],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount': 10,
+ 'discount_mode': 'percent',
+ 'discount_applicability': 'order',
+ 'required_points': 1,
+ })],
+ })
+ sol = self.env['sale.order.line'].create({
+ 'product_id': self.product_A.id,
+ 'name': 'Product A',
+ 'product_uom_qty': 1.0,
+ 'order_id': order.id,
+ })
+
+ order._update_programs_and_rewards()
+ self._claim_reward(order, p1)
+ self._claim_reward(order, p2)
+ self.assertEqual(len(order.order_line.ids), 1, "The order should only contains the Product A line")
+
+ sol.product_uom_qty = 3
+ order._update_programs_and_rewards()
+ self._claim_reward(order, p1)
+ self._claim_reward(order, p2)
+ discounts = set(order.order_line.mapped('name')) - {'Product A'}
+ self.assertEqual(len(discounts), 1, "The order should contains the Product A line and a discount")
+ # The name of the discount is dynamically changed to smth looking like:
+ # "Discount: Get 5% discount if buy at least 2 Product - On product with following tax: Tax 15.00%"
+ self.assertTrue('Discount: 5% on your order' in discounts.pop(), "The discount should be a 5% discount")
+
+ sol.product_uom_qty = 5
+ order._update_programs_and_rewards()
+ self._claim_reward(order, p1)
+ self._claim_reward(order, p2)
+ discounts = set(order.order_line.mapped('name')) - {'Product A'}
+ self.assertEqual(len(discounts), 1, "The order should contains the Product A line and a discount")
+ self.assertTrue('Discount: 10% on your order' in discounts.pop(), "The discount should be a 10% discount")
+
+ def test_program_rules_validity_dates_and_uses(self):
+ # Test case: Based on the validity dates and the number of allowed uses
+
+ self.immediate_promotion_program.write({
+ 'date_to': date.today() - timedelta(days=2),
+ 'limit_usage': True,
+ 'max_usage': 1,
+ })
+
+ order = self.empty_order
+ order.write({'order_line': [
+ (0, False, {
+ 'product_id': self.product_A.id,
+ 'name': '1 Product A',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ }),
+ (0, False, {
+ 'product_id': self.product_B.id,
+ 'name': '2 Product B',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ self._auto_rewards(order, self.immediate_promotion_program)
+ self.assertEqual(len(order.order_line.ids), 2, "The promo offer shouldn't have been applied we're not between the validity dates")
+
+ self.immediate_promotion_program.write({
+ 'date_to': date.today() + timedelta(days=2),
+ })
+ order = self.env['sale.order'].create({'partner_id': self.steve.id})
+ order.write({'order_line': [
+ (0, False, {
+ 'product_id': self.product_A.id,
+ 'name': '1 Product A',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 10.0,
+ }),
+ (0, False, {
+ 'product_id': self.product_B.id,
+ 'name': '2 Product B',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ self._auto_rewards(order, self.immediate_promotion_program)
+ self.assertEqual(len(order.order_line.ids), 3, "The promo offer should have been applied as we're between the validity dates")
+ order = self.env['sale.order'].create({'partner_id': self.env['res.partner'].create({'name': 'My Partner'}).id})
+ order.write({'order_line': [
+ (0, False, {
+ 'product_id': self.product_A.id,
+ 'name': '1 Product A',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 10.0,
+ }),
+ (0, False, {
+ 'product_id': self.product_B.id,
+ 'name': '2 Product B',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ # Invalidate total_order_count
+ field_order_count = self.env['loyalty.program']._fields['order_count']
+ field_total_order_count = self.env['loyalty.program']._fields['total_order_count']
+ self.env.cache.invalidate([(field_order_count, self.immediate_promotion_program.ids), (field_total_order_count, self.immediate_promotion_program.ids)])
+ self._auto_rewards(order, self.immediate_promotion_program)
+ self.assertEqual(len(order.order_line.ids), 2, "The promo offer shouldn't have been applied as the number of uses is exceeded")
diff --git a/addons/sale_loyalty/tests/test_program_with_code_operations.py b/addons/sale_loyalty/tests/test_program_with_code_operations.py
new file mode 100644
index 00000000000..2501ad484d5
--- /dev/null
+++ b/addons/sale_loyalty/tests/test_program_with_code_operations.py
@@ -0,0 +1,333 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon
+from odoo.exceptions import ValidationError
+
+
+class TestProgramWithCodeOperations(TestSaleCouponCommon):
+ # Test the basic operation (apply_coupon) on an coupon program on which we should
+ # apply the reward when the code is correct or remove the reward automatically when the reward is
+ # not valid anymore.
+
+ def test_program_usability(self):
+ # After clicking "Generate coupons", there is no domain so it shows "Match all records".
+ # But when you click, domain is false (default field value; empty string) so it won't generate anything.
+ # This is even more weird because if you add something in the domain and then delete it,
+ # you visually come back to the initial state except the domain became '[]' instead of ''.
+ # In this case, it will generate the coupon for every partner.
+ # Thus, we should ensure that if you leave the domain untouched, it generates a coupon for each partner
+ # as hinted on the screen ('Match all records (X records)')
+ self.env['loyalty.generate.wizard'].with_context(active_id=self.code_promotion_program.id).create({
+ 'mode': 'selected',
+ }).generate_coupons()
+ self.assertEqual(len(self.code_promotion_program.coupon_ids), len(self.env['res.partner'].search([])), "It should have generated a coupon for every partner")
+
+ def test_program_basic_operation_coupon_code(self):
+ # Test case: Generate a coupon for my customer, and add a reward then remove it automatically
+
+ self.immediate_promotion_program.active = False
+ self.code_promotion_program.reward_ids.reward_type = 'discount'
+ self.code_promotion_program.reward_ids.discount = 10
+
+ self.env['loyalty.generate.wizard'].with_context(active_id=self.code_promotion_program.id).create({
+ 'mode': 'selected',
+ 'customer_ids': self.steve,
+ 'points_granted': 1,
+ }).generate_coupons()
+ coupon = self.code_promotion_program.coupon_ids
+
+ # Test the valid code on a wrong sales order
+ wrong_partner_order = self.env['sale.order'].create({
+ 'partner_id': self.env['res.partner'].create({'name': 'My Partner'}).id,
+ })
+ with self.assertRaises(ValidationError):
+ self._apply_promo_code(wrong_partner_order, coupon.code)
+
+ # Test now on a valid sales order
+ order = self.empty_order
+ order.write({'order_line': [
+ (0, False, {
+ 'product_id': self.product_A.id,
+ 'name': '1 Product A',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ self._apply_promo_code(order, coupon.code)
+ self.assertEqual(len(order.order_line.ids), 2)
+
+ # Remove the product A from the sale order
+ order.write({'order_line': [(2, order.order_line[0].id, False)]})
+ order._update_programs_and_rewards()
+ self.assertEqual(len(order.order_line.ids), 0)
+
+ def test_program_coupon_double_consuming(self):
+ # Test case:
+ # - Generate a coupon
+ # - add to a sale order A, cancel the sale order
+ # - add to a sale order B, confirm the order
+ # - go back to A, reset to draft and confirm
+
+ self.immediate_promotion_program.active = False
+ self.code_promotion_program.applies_on = 'future'
+ self.code_promotion_program.reward_ids.reward_type = 'discount'
+ self.code_promotion_program.reward_ids.discount = 10
+
+ self.env['loyalty.generate.wizard'].with_context(active_id=self.code_promotion_program.id).create({
+ 'coupon_qty': 1,
+ 'points_granted': 1,
+ }).generate_coupons()
+ coupon = self.code_promotion_program.coupon_ids
+
+ sale_order_a = self.empty_order.copy()
+ sale_order_b = self.empty_order.copy()
+
+ sale_order_a.write({'order_line': [
+ (0, False, {
+ 'product_id': self.product_A.id,
+ 'name': '1 Product A',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ self._apply_promo_code(sale_order_a, coupon.code)
+ self.assertEqual(len(sale_order_a.order_line.ids), 2)
+
+ sale_order_a.action_cancel()
+
+ sale_order_b.write({'order_line': [
+ (0, False, {
+ 'product_id': self.product_A.id,
+ 'name': '1 Product A',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ self._apply_promo_code(sale_order_b, coupon.code)
+ self.assertEqual(len(sale_order_b.order_line.ids), 2)
+
+ sale_order_b.action_confirm()
+
+ sale_order_a.action_draft()
+ sale_order_a.action_confirm()
+ # reward line removed automatically
+ self.assertEqual(len(sale_order_a.order_line.ids), 1)
+
+ def test_coupon_code_with_pricelist(self):
+ # Test case: Generate a coupon (10% discount) and apply it on an order with a specific pricelist (10% discount)
+
+ self.code_promotion_program_with_discount.applies_on = 'future'
+ self.env['loyalty.generate.wizard'].with_context(active_id=self.code_promotion_program_with_discount.id).create({
+ 'coupon_qty': 1,
+ 'points_granted': 1,
+ }).generate_coupons()
+ coupon = self.code_promotion_program_with_discount.coupon_ids
+
+ first_pricelist = self.env['product.pricelist'].create({
+ 'name': 'First pricelist',
+ 'discount_policy': 'with_discount',
+ 'item_ids': [(0, 0, {
+ 'compute_price': 'percentage',
+ 'base': 'list_price',
+ 'percent_price': 10,
+ 'applied_on': '3_global',
+ 'name': 'First discount'
+ })]
+ })
+
+ order = self.empty_order
+ order.pricelist_id = first_pricelist
+ order.write({'order_line': [
+ (0, False, {
+ 'product_id': self.product_C.id,
+ 'name': '1 Product C',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ self._apply_promo_code(order, coupon.code)
+ self.assertEqual(len(order.order_line.ids), 2)
+ self.assertEqual(order.amount_total, 81, "SO total should be 81: (10% of 100 with pricelist) + 10% of 90 with coupon code")
+
+ def test_on_next_order_reward_promotion_program(self):
+ # The flow:
+ # 1. Create a program `A` that gives a free `Product B` on next order if you buy a an `product A`
+ # This program should be code_needed with code `free_B_on_next_order`
+ # 2. Create a program `B` that gives 10% discount on next order automatically
+ # 3. Create a SO with a `third product` and recompute coupon, you SHOULD get a coupon (from program `B`) for your next order that will discount 10%
+ # 4. Try to apply `A`, it should error since we did not buy any product A.
+ # 5. Add a product A to the cart and try to apply `A` again, this time it should work
+ # 6. Verify you have 2 generated coupons and validate the SO (so the 2 generated coupons will be valid)
+ # 7. Create a new SO (with the same partner)
+ # 8. Add a Product B in the cart
+ # 9. Try to apply once again coupon generated by `A`, it should give you the free product B
+ # 10. Try to apply coupon generated by `B`, it should give you 10% discount.
+ # => SO will then be 0$ until we recompute the order lines
+
+ # 1.
+ self.immediate_promotion_program.write({
+ 'applies_on': 'future',
+ 'trigger': 'with_code',
+ })
+ self.immediate_promotion_program.rule_ids.write({
+ 'mode': 'with_code',
+ 'code': 'free_B_on_next_order',
+ })
+ # 2.
+ self.p1 = self.env['loyalty.program'].create({
+ 'name': 'Code for 10% on next order',
+ 'program_type': 'promotion',
+ 'applies_on': 'future',
+ 'trigger': 'auto',
+ 'rule_ids': [(0, 0, {})],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount': 10,
+ 'discount_mode': 'percent',
+ 'discount_applicability': 'order',
+ })],
+ })
+ # 3.
+ order = self.empty_order.copy()
+ self.third_product = self.env['product.product'].create({
+ 'name': 'Thrid Product',
+ 'list_price': 5,
+ 'sale_ok': True
+ })
+ order.write({'order_line': [
+ (0, False, {
+ 'product_id': self.third_product.id,
+ 'name': '1 Third Product',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ order._update_programs_and_rewards()
+ self.assertEqual(len(self.p1.coupon_ids.ids), 1, "You should get a coupon for you next order that will offer 10% discount")
+ # 4.
+ with self.assertRaises(ValidationError):
+ self._apply_promo_code(order, 'free_B_on_next_order')
+ # 5.
+ order.write({'order_line': [
+ (0, False, {
+ 'product_id': self.product_A.id,
+ 'name': '1 Product A',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ self._apply_promo_code(order, 'free_B_on_next_order', no_reward_fail=False)
+ # 6.
+ self.assertEqual(len(order._get_reward_coupons()), 2, "You should get a second coupon for your next order that will offer a free Product B")
+ order.action_confirm()
+ # 7.
+ order_bis = self.empty_order
+
+ # 8.
+ order_bis.write({'order_line': [
+ (0, False, {
+ 'product_id': self.product_B.id,
+ 'name': '1 Product B',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ # 9.
+ self._apply_promo_code(order_bis, order._get_reward_coupons()[1].code)
+ self.assertEqual(len(order_bis.order_line), 2, "You should get a free Product B")
+ # 10.
+ self._apply_promo_code(order_bis, order._get_reward_coupons()[0].code)
+ self.assertEqual(len(order_bis.order_line), 3, "You should get a 10% discount line")
+ self.assertAlmostEqual(order_bis.amount_total, order_bis.order_line[0].price_total * 0.9, 2, "SO total should be null: (Paid product - Free product = 0) + 10% of nothing")
+
+ def test_on_next_order_reward_promotion_program_with_requirements(self):
+ self.immediate_promotion_program.write({
+ 'applies_on': 'future',
+ 'trigger': 'with_code',
+ })
+ self.immediate_promotion_program.rule_ids.write({
+ 'minimum_amount': 700,
+ 'minimum_amount_tax_mode': 'excl',
+ 'mode': 'with_code',
+ 'code': 'free_B_on_next_order',
+ })
+ order = self.empty_order.copy()
+ self.product_A.lst_price = 700
+ order.write({'order_line': [
+ (0, False, {
+ 'product_id': self.product_A.id,
+ 'name': '1 Product A',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ self._apply_promo_code(order, 'free_B_on_next_order', no_reward_fail=False)
+ self.assertEqual(len(self.immediate_promotion_program.coupon_ids.ids), 1, "You should get a coupon for you next order that will offer a free product B")
+ order_bis = self.empty_order
+ order_bis.write({'order_line': [
+ (0, False, {
+ 'product_id': self.product_B.id,
+ 'name': '1 Product B',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ with self.assertRaises(ValidationError):
+ # It should error since we did not validate the previous SO, so the coupon is `reserved` but not `new`
+ self._apply_promo_code(order_bis, order._get_reward_coupons()[0].code)
+ order.action_confirm()
+ # It should not error even if the SO does not have the requirements (700$ and 1 product A), since these requirements where only used to generate the coupon that we are now applying
+ self._apply_promo_code(order_bis, order._get_reward_coupons()[0].code, no_reward_fail=False)
+ self.assertEqual(len(order_bis.order_line), 2, "You should get 1 regular product_B and 1 free product_B")
+ order_bis._update_programs_and_rewards()
+ self.assertEqual(len(order_bis.order_line), 2, "Free product from a coupon generated from a promotion program on next order should not dissapear")
+
+ def test_edit_and_reapply_promotion_program(self):
+ # The flow:
+ # 1. Create a program auto applied, giving a fixed amount discount
+ # 2. Create a SO and apply the program
+ # 3. Change the program, requiring a mandatory code
+ # 4. Reapply the program on the same SO via code
+
+ self.immediate_promotion_program.active = False
+ # 1.
+ self.p1 = self.env['loyalty.program'].create({
+ 'name': 'Promo fixed amount',
+ 'trigger': 'auto',
+ 'program_type': 'promotion',
+ 'rule_ids': [(0, 0, {})],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount': 10,
+ 'discount_mode': 'per_point',
+ 'discount_applicability': 'order',
+ })]
+ })
+ # 2.
+ order = self.empty_order.copy()
+ order.write({'order_line': [
+ (0, False, {
+ 'product_id': self.product_A.id,
+ 'name': '1 Product A',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ order._update_programs_and_rewards()
+ self._claim_reward(order, self.p1)
+ self.assertEqual(len(order.order_line), 2, "You should get a discount line") # product + discount
+ # 3.
+ self.p1.write({
+ 'trigger': 'with_code',
+ })
+ self.p1.rule_ids.write({
+ 'mode': 'with_code',
+ 'code': 'test',
+ })
+ order._update_programs_and_rewards()
+ self.assertEqual(len(order.order_line), 1, "You loose a discount line")
+ # 4.
+ self._apply_promo_code(order, 'test')
+ # But the above line should not add any reward
+ self.assertEqual(len(order.order_line), 2, "You should get a discount line") # product + discount
diff --git a/addons/sale_loyalty/tests/test_program_without_code_operations.py b/addons/sale_loyalty/tests/test_program_without_code_operations.py
new file mode 100644
index 00000000000..91d90e33cbc
--- /dev/null
+++ b/addons/sale_loyalty/tests/test_program_without_code_operations.py
@@ -0,0 +1,64 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon
+
+
+class TestProgramWithoutCodeOperations(TestSaleCouponCommon):
+ # Test some basic operation (create, write, unlink) on an immediate coupon program on which we should
+ # apply or remove the reward automatically, as there's no program code.
+
+ def test_immediate_program_basic_operation(self):
+
+ # 2 products A are needed
+ self.immediate_promotion_program.rule_ids.write({'minimum_qty': 2.0})
+ order = self.empty_order
+ # Test case 1 (1 A): Assert that no reward is given, as the product B is missing
+ order.write({'order_line': [
+ (0, False, {
+ 'product_id': self.product_A.id,
+ 'name': '1 Product A',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ order._update_programs_and_rewards()
+ self._claim_reward(order, self.immediate_promotion_program)
+ self.assertEqual(len(order.order_line.ids), 1, "The promo offer shouldn't have been applied as the product B isn't in the order")
+
+ # Test case 2 (1 A 1 B): Assert that no reward is given, as the product A is not present in the correct quantity
+ order.write({'order_line': [
+ (0, False, {
+ 'product_id': self.product_B.id,
+ 'name': '2 Product B',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ })
+ ]})
+ order._update_programs_and_rewards()
+ self._claim_reward(order, self.immediate_promotion_program)
+ self.assertEqual(len(order.order_line.ids), 2, "The promo offer shouldn't have been applied as 2 product A aren't in the order")
+
+ # Test case 3 (2 A 1 B): Assert that the reward is given as the product B is now in the order
+ order.write({'order_line': [(1, order.order_line[0].id, {'product_uom_qty': 2.0})]})
+ order._update_programs_and_rewards()
+ self._claim_reward(order, self.immediate_promotion_program)
+ self.assertEqual(len(order.order_line.ids), 3, "The promo offer should have been applied, the discount is not created")
+
+ # Test case 4 (1 A 1 B): Assert that the reward is removed as we don't buy 2 products B anymore
+ order.write({'order_line': [(1, order.order_line[0].id, {'product_uom_qty': 1.0})]})
+ order._update_programs_and_rewards()
+ self._claim_reward(order, self.immediate_promotion_program)
+ self.assertEqual(len(order.order_line.ids), 2, "The promo reward should have been removed as the rules are not matched anymore")
+ self.assertEqual(order.order_line[0].product_id.id, self.product_A.id, "The wrong line has been removed")
+ self.assertEqual(order.order_line[1].product_id.id, self.product_B.id, "The wrong line has been removed")
+
+ # Test case 5 (1 B): Assert that the reward is removed when the order is modified and doesn't match the rules anymore
+ order.write({'order_line': [
+ (1, order.order_line[0].id, {'product_uom_qty': 2.0}),
+ (2, order.order_line[0].id, False)
+ ]})
+ order._update_programs_and_rewards()
+ self._claim_reward(order, self.immediate_promotion_program)
+ self.assertEqual(len(order.order_line.ids), 1, "The promo reward should have been removed as the rules are not matched anymore")
+ self.assertEqual(order.order_line.product_id.id, self.product_B.id, "The wrong line has been removed")
diff --git a/addons/sale_loyalty/tests/test_sale_invoicing.py b/addons/sale_loyalty/tests/test_sale_invoicing.py
new file mode 100644
index 00000000000..40a4e083faa
--- /dev/null
+++ b/addons/sale_loyalty/tests/test_sale_invoicing.py
@@ -0,0 +1,61 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon
+from odoo.exceptions import UserError
+from odoo.tests import tagged
+
+
+@tagged('post_install', '-at_install')
+class TestSaleInvoicing(TestSaleCouponCommon):
+
+ def test_invoicing_order_with_promotions(self):
+ discount_coupon_program = self.env['loyalty.program'].create({
+ 'name': '10% Discount',
+ 'program_type': 'coupons',
+ 'applies_on': 'current',
+ 'trigger': 'auto',
+ 'rule_ids': [(0, 0, {})],
+ 'reward_ids': [(0, 0, {
+ 'reward_type': 'discount',
+ 'discount': 10,
+ 'discount_mode': 'percent',
+ 'discount_applicability': 'order',
+ })]
+ })
+ # Override the default invoice_policy on products
+ discount_coupon_program.reward_ids.discount_line_product_id.invoice_policy = 'order'
+ product = self.env['product.product'].create({
+ 'invoice_policy': 'delivery',
+ 'name': 'Product invoiced on delivery',
+ 'lst_price': 500,
+ })
+
+ order = self.empty_order
+ order.write({
+ 'order_line': [
+ (0, 0, {
+ 'product_id': product.id,
+ })
+ ]
+ })
+
+ order._update_programs_and_rewards()
+ self._claim_reward(order, discount_coupon_program)
+ # Order is not confirmed, there shouldn't be any invoiceable line
+ invoiceable_lines = order._get_invoiceable_lines()
+ self.assertEqual(len(invoiceable_lines), 0)
+
+ order.action_confirm()
+ invoiceable_lines = order._get_invoiceable_lines()
+ # Product was not delivered, we cannot invoice
+ # the product line nor the promotion line
+ self.assertEqual(len(invoiceable_lines), 0)
+ with self.assertRaises(UserError):
+ order._create_invoices()
+
+ order.order_line[0].qty_delivered = 1
+ # Product is delivered, the two lines can be invoiced.
+ invoiceable_lines = order._get_invoiceable_lines()
+ self.assertEqual(order.order_line, invoiceable_lines)
+ account_move = order._create_invoices()
+ self.assertEqual(len(account_move.invoice_line_ids), 2)
diff --git a/addons/sale_loyalty/views/loyalty_card_views.xml b/addons/sale_loyalty/views/loyalty_card_views.xml
new file mode 100644
index 00000000000..bb3ee12286b
--- /dev/null
+++ b/addons/sale_loyalty/views/loyalty_card_views.xml
@@ -0,0 +1,13 @@
+
+
+
+ loyalty.card.view.form.inherit.sale.loyalty
+ loyalty.card
+
+
+
+
+
+
+
+
diff --git a/addons/sale_loyalty/views/loyalty_program_views.xml b/addons/sale_loyalty/views/loyalty_program_views.xml
new file mode 100644
index 00000000000..ad7fb5fd964
--- /dev/null
+++ b/addons/sale_loyalty/views/loyalty_program_views.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/addons/sale_loyalty/views/sale_order_views.xml b/addons/sale_loyalty/views/sale_order_views.xml
new file mode 100644
index 00000000000..e2a2bdd6f57
--- /dev/null
+++ b/addons/sale_loyalty/views/sale_order_views.xml
@@ -0,0 +1,20 @@
+
+
+
+ sale.order.view.form.inherit.sale.loyalty
+ sale.order
+
+ 10
+
+
+