diff --git a/addons/loyalty/models/loyalty_program.py b/addons/loyalty/models/loyalty_program.py
index 90bc8214441..dce5d2c3315 100644
--- a/addons/loyalty/models/loyalty_program.py
+++ b/addons/loyalty/models/loyalty_program.py
@@ -21,6 +21,9 @@ class LoyaltyProgram(models.Model):
currency_id = fields.Many2one('res.currency', 'Currency', compute='_compute_currency_id',
readonly=False, required=True, store=True, precompute=True)
currency_symbol = fields.Char(related='currency_id.symbol')
+ pricelist_ids = fields.Many2many(
+ 'product.pricelist', string="Pricelist", domain="[('currency_id', '=', currency_id)]"
+ )
total_order_count = fields.Integer("Total Order Count", compute="_compute_total_order_count")
@@ -100,6 +103,17 @@ class LoyaltyProgram(models.Model):
'Max usage must be strictly positive if a limit is used.'),
]
+ @api.constrains('currency_id', 'pricelist_ids')
+ def _check_pricelist_currency(self):
+ if any(
+ pricelist.currency_id != program.currency_id
+ for program in self
+ for pricelist in program.pricelist_ids
+ ):
+ raise UserError(_(
+ "The loyalty program's currency must be the same as all it's pricelists ones."
+ ))
+
@api.constrains('date_from', 'date_to')
def _check_date_from_date_to(self):
if any(p.date_to and p.date_from and p.date_from > p.date_to for p in self):
diff --git a/addons/loyalty/views/loyalty_program_views.xml b/addons/loyalty/views/loyalty_program_views.xml
index 93d7dae857f..c75ea42ca4e 100644
--- a/addons/loyalty/views/loyalty_program_views.xml
+++ b/addons/loyalty/views/loyalty_program_views.xml
@@ -85,6 +85,7 @@
+
diff --git a/addons/pos_loyalty/models/pos_config.py b/addons/pos_loyalty/models/pos_config.py
index eaa67334f61..bb91c17b5f7 100644
--- a/addons/pos_loyalty/models/pos_config.py
+++ b/addons/pos_loyalty/models/pos_config.py
@@ -71,7 +71,7 @@ class PosConfig(models.Model):
return super()._check_before_creating_new_session()
- def use_coupon_code(self, code, creation_date, partner_id):
+ def use_coupon_code(self, code, creation_date, partner_id, pricelist_id):
self.ensure_one()
# Ordering by partner id to use the first assigned to the partner in case multiple coupons have the same code
# it could happen with loyalty programs using a code
@@ -79,7 +79,8 @@ class PosConfig(models.Model):
coupon = self.env['loyalty.card'].search(
[('program_id', 'in', self._get_program_ids().ids), ('partner_id', 'in', (False, partner_id)), ('code', '=', code)],
order='partner_id, points desc', limit=1)
- if not coupon or not coupon.program_id.active:
+ program = coupon.program_id
+ if not coupon or not program.active:
return {
'successful': False,
'payload': {
@@ -91,17 +92,19 @@ class PosConfig(models.Model):
error_message = False
if (
(coupon.expiration_date and coupon.expiration_date < check_date)
- or (coupon.program_id.date_to and coupon.program_id.date_to < today_date)
- or (coupon.program_id.limit_usage and coupon.program_id.total_order_count >= coupon.program_id.max_usage)
+ or (program.date_to and program.date_to < today_date)
+ or (program.limit_usage and program.total_order_count >= program.max_usage)
):
error_message = _("This coupon is expired (%s).", code)
- elif coupon.program_id.date_from and coupon.program_id.date_from > today_date:
+ elif program.date_from and program.date_from > today_date:
error_message = _("This coupon is not yet valid (%s).", code)
elif (
- not coupon.program_id.reward_ids or
- not any(r.required_points <= coupon.points for r in coupon.program_id.reward_ids)
+ not program.reward_ids or
+ not any(r.required_points <= coupon.points for r in program.reward_ids)
):
error_message = _("No reward can be claimed with this coupon.")
+ elif program.pricelist_ids and pricelist_id not in program.pricelist_ids.ids:
+ error_message = _("This coupon is not available with the current pricelist.")
if error_message:
return {
@@ -114,7 +117,7 @@ class PosConfig(models.Model):
return {
'successful': True,
'payload': {
- 'program_id': coupon.program_id.id,
+ 'program_id': program.id,
'coupon_id': coupon.id,
'coupon_partner_id': coupon.partner_id.id,
'points': coupon.points,
diff --git a/addons/pos_loyalty/models/pos_session.py b/addons/pos_loyalty/models/pos_session.py
index 48bbf7603bc..f91d384012c 100644
--- a/addons/pos_loyalty/models/pos_session.py
+++ b/addons/pos_loyalty/models/pos_session.py
@@ -21,8 +21,11 @@ class PosSession(models.Model):
return {
'search_params': {
'domain': [('id', 'in', self.config_id._get_program_ids().ids)],
- 'fields': ['name', 'trigger', 'applies_on', 'program_type', 'date_from', 'date_to',
- 'limit_usage', 'max_usage', 'is_nominative', 'portal_visible', 'portal_point_name', 'trigger_product_ids'],
+ 'fields': [
+ 'name', 'trigger', 'applies_on', 'program_type', 'pricelist_ids', 'date_from',
+ 'date_to', 'limit_usage', 'max_usage', 'is_nominative', 'portal_visible',
+ 'portal_point_name', 'trigger_product_ids',
+ ],
},
}
diff --git a/addons/pos_loyalty/static/src/app/pos_store.js b/addons/pos_loyalty/static/src/app/pos_store.js
index 3318ed81e33..a466f69af00 100644
--- a/addons/pos_loyalty/static/src/app/pos_store.js
+++ b/addons/pos_loyalty/static/src/app/pos_store.js
@@ -163,6 +163,11 @@ patch(PosStore.prototype, "pos_loyalty.PosStore", {
const result = [];
for (const couponProgram of allCouponPrograms) {
const program = this.program_by_id[couponProgram.program_id];
+ if (program.pricelist_ids.length > 0
+ && (!order.pricelist || !program.pricelist_ids.includes(order.pricelist.id))) {
+ continue;
+ }
+
const points = order._getRealCouponPoints(couponProgram.coupon_id);
const hasLine = order.orderlines.filter((line) => !line.is_reward_line).length > 0;
for (const reward of program.rewards.filter(
diff --git a/addons/pos_loyalty/static/src/js/Loyalty.js b/addons/pos_loyalty/static/src/js/Loyalty.js
index 46e3d24d375..19b2472978c 100644
--- a/addons/pos_loyalty/static/src/js/Loyalty.js
+++ b/addons/pos_loyalty/static/src/js/Loyalty.js
@@ -301,7 +301,23 @@ patch(Order.prototype, "pos_loyalty.Order", {
return orderLines[orderLines.length - 1];
},
set_pricelist(pricelist) {
+ const oldPricelist = this.pricelist
this._super(...arguments);
+ if (this.couponPointChanges && oldPricelist !== pricelist) {
+ // Remove couponPointChanges for cards in no longer available programs.
+ // This makes sure that counting of points on loyalty and ewallet programs is updated after pricelist changes.
+ const loyaltyProgramIds = new Set(
+ this.pos.programs
+ .filter((program) => program.pricelist_ids.length > 0
+ && (!pricelist || !program.pricelist_ids.includes(pricelist.id)))
+ .map((program) => program.id)
+ );
+ for (const [key, pointChange] of Object.entries(this.couponPointChanges)) {
+ if (loyaltyProgramIds.has(pointChange.program_id)) {
+ delete this.couponPointChanges[key];
+ }
+ }
+ }
this._updateRewards();
},
set_orderline_options(line, options) {
@@ -701,6 +717,9 @@ patch(Order.prototype, "pos_loyalty.Order", {
if (program.limit_usage && program.total_order_count >= program.max_usage) {
return false;
}
+ if (program.pricelist_ids.length > 0 && (!this.pricelist || !program.pricelist_ids.includes(this.pricelist.id))) {
+ return false;
+ }
return true;
},
/**
@@ -929,6 +948,10 @@ patch(Order.prototype, "pos_loyalty.Order", {
: 0;
for (const couponProgram of allCouponPrograms) {
const program = this.pos.program_by_id[couponProgram.program_id];
+ if (program.pricelist_ids.length > 0
+ && (!this.pricelist || !program.pricelist_ids.includes(this.pricelist.id))) {
+ continue;
+ }
if (program.trigger == "with_code") {
// For coupon programs, the rules become conditions.
// Points to purchase rewards will only come from the scanned coupon.
@@ -1471,6 +1494,11 @@ patch(Order.prototype, "pos_loyalty.Order", {
let claimableRewards = null;
let coupon = null;
if (rule) {
+ let program_pricelists = rule.program_id.pricelist_ids;
+ if (program_pricelists.length > 0
+ && (!this.pricelist || !program_pricelists.includes(this.pricelist.id))) {
+ return _t("That promo code program requires a specific pricelist.");
+ }
if (this.codeActivatedProgramRules.includes(rule.id)) {
return _t("That promo code program has already been activated.");
}
@@ -1481,11 +1509,11 @@ patch(Order.prototype, "pos_loyalty.Order", {
if (this.codeActivatedCoupons.find((coupon) => coupon.code === code)) {
return _t("That coupon code has already been scanned and activated.");
}
- const customer = this.get_partner();
+ const customerId = this.get_partner() ? this.get_partner().id : false;
const { successful, payload } = await this.env.services.orm.call(
"pos.config",
"use_coupon_code",
- [[this.pos.config.id], code, this.creation_date, customer ? customer.id : false]
+ [[this.pos.config.id], code, this.creation_date, customerId, this.pricelist ? this.pricelist.id : false]
);
if (successful) {
// Allow rejecting a gift card that is not yet paid.
diff --git a/addons/sale_loyalty/models/sale_order.py b/addons/sale_loyalty/models/sale_order.py
index f5e0407f902..40a2efccc59 100644
--- a/addons/sale_loyalty/models/sale_order.py
+++ b/addons/sale_loyalty/models/sale_order.py
@@ -386,6 +386,7 @@ class SaleOrder(models.Model):
today = fields.Date.context_today(self)
return [('active', '=', True), ('sale_ok', '=', True),
('company_id', 'in', (self.company_id.id, False)),
+ '|', ('pricelist_ids', '=', False), ('pricelist_ids', 'in', [self.pricelist_id.id]),
'|', ('date_from', '=', False), ('date_from', '<=', today),
'|', ('date_to', '=', False), ('date_to', '>=', today)]
@@ -397,6 +398,8 @@ class SaleOrder(models.Model):
today = fields.Date.context_today(self)
return [('active', '=', True), ('program_id.sale_ok', '=', True),
('company_id', 'in', (self.company_id.id, False)),
+ '|', ('program_id.pricelist_ids', '=', False),
+ ('program_id.pricelist_ids', 'in', [self.pricelist_id.id]),
'|', ('program_id.date_from', '=', False), ('program_id.date_from', '<=', today),
'|', ('program_id.date_to', '=', False), ('program_id.date_to', '>=', today)]
diff --git a/addons/sale_loyalty/tests/test_loyalty.py b/addons/sale_loyalty/tests/test_loyalty.py
index a93cca75308..8ece9035efb 100644
--- a/addons/sale_loyalty/tests/test_loyalty.py
+++ b/addons/sale_loyalty/tests/test_loyalty.py
@@ -1,10 +1,13 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo.tests import tagged, new_test_user
-from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon
-from odoo.tools.float_utils import float_compare
from odoo import Command
+from odoo.exceptions import ValidationError
+from odoo.tests import tagged, new_test_user
+from odoo.tools.float_utils import float_compare
+
+from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon
+
@tagged('post_install', '-at_install')
class TestLoyalty(TestSaleCouponCommon):
@@ -339,3 +342,133 @@ class TestLoyalty(TestSaleCouponCommon):
order._update_programs_and_rewards()
self._claim_reward(order, coupon_program)
self.assertEqual(float_compare(order.amount_total, 218.7, precision_rounding=3), 0, "300 * 0.9 * 0.9 * 0.9 = 218.7")
+
+ def test_promotion_program_restricted_to_pricelists(self):
+ self.env['product.pricelist'].search([]).action_archive()
+ company_currency = self.env.company.currency_id
+ pricelist_1, pricelist_2 = self.env['product.pricelist'].create([
+ {'name': 'Basic company_currency pricelist', 'currency_id': company_currency.id},
+ {'name': 'Other company_currency pricelist', 'currency_id': company_currency.id},
+ ])
+ self.immediate_promotion_program.active = True
+ 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,
+ }),
+ (0, False, {
+ 'product_id': self.product_B.id,
+ 'name': '2 Product B',
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 1.0,
+ }),
+ ]})
+
+ applied_message = "The promo offer should have been applied."
+ not_applied_message = "The promo offer should not have been applied because the order's " \
+ "pricelist is not eligible to this promotion."
+
+ order.pricelist_id = self.env['product.pricelist']
+ order._update_programs_and_rewards()
+ self._claim_reward(order, self.immediate_promotion_program)
+ self.assertEqual(len(order.order_line.ids), 3, applied_message)
+
+ order.pricelist_id = pricelist_1
+ order._update_programs_and_rewards()
+ self._claim_reward(order, self.immediate_promotion_program)
+ self.assertEqual(len(order.order_line.ids), 3, applied_message)
+
+ self.immediate_promotion_program.pricelist_ids = [pricelist_1.id]
+ order.pricelist_id = self.env['product.pricelist']
+ order._update_programs_and_rewards()
+ self._claim_reward(order, self.immediate_promotion_program)
+ self.assertEqual(len(order.order_line.ids), 2, not_applied_message)
+
+ order.pricelist_id = pricelist_1
+ order._update_programs_and_rewards()
+ self._claim_reward(order, self.immediate_promotion_program)
+ self.assertEqual(len(order.order_line.ids), 3, applied_message)
+
+ order.pricelist_id = pricelist_2
+ order._update_programs_and_rewards()
+ self._claim_reward(order, self.immediate_promotion_program)
+ self.assertEqual(len(order.order_line.ids), 2, not_applied_message)
+
+ self.immediate_promotion_program.pricelist_ids = [pricelist_1.id, pricelist_2.id]
+ order.pricelist_id = self.env['product.pricelist']
+ order._update_programs_and_rewards()
+ self._claim_reward(order, self.immediate_promotion_program)
+ self.assertEqual(len(order.order_line.ids), 2, not_applied_message)
+
+ order.pricelist_id = pricelist_1
+ order._update_programs_and_rewards()
+ self._claim_reward(order, self.immediate_promotion_program)
+ self.assertEqual(len(order.order_line.ids), 3, applied_message)
+
+ def test_coupon_program_restricted_to_pricelists(self):
+ self.env['product.pricelist'].search([]).action_archive()
+ company_currency = self.env.company.currency_id
+ pricelist_1, pricelist_2 = self.env['product.pricelist'].create([
+ {'name': 'Basic company_currency pricelist', 'currency_id': company_currency.id},
+ {'name': 'Other company_currency pricelist', 'currency_id': company_currency.id},
+ ])
+
+ self.code_promotion_program.active = True
+ self.env['loyalty.generate.wizard'].with_context(
+ active_id=self.code_promotion_program.id
+ ).create({'coupon_qty': 7, 'points_granted': 1}).generate_coupons()
+ coupons = self.code_promotion_program.coupon_ids
+
+ order_no_pricelist = self.empty_order.copy()
+ order_no_pricelist.write({'pricelist_id': None, '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_pricelist_1 = order_no_pricelist.copy()
+ order_pricelist_1.pricelist_id = pricelist_1
+ order_pricelist_2 = order_no_pricelist.copy()
+ order_pricelist_2.pricelist_id = pricelist_2
+
+ applied_message = "The coupon code should have been applied."
+ not_applied_message = "The coupon code should not have been applied because the order's " \
+ "pricelist is not eligible to this promotion."
+
+ order_0 = order_no_pricelist.copy()
+ self._apply_promo_code(order_0, coupons[0].code)
+ self.assertEqual(len(order_0.order_line.ids), 2, applied_message)
+
+ order_1 = order_pricelist_1.copy()
+ self._apply_promo_code(order_1, coupons[1].code)
+ self.assertEqual(len(order_1.order_line.ids), 2, applied_message)
+
+ self.code_promotion_program.pricelist_ids = [pricelist_1.id]
+ order_2 = order_no_pricelist.copy()
+ with self.assertRaises(ValidationError):
+ self._apply_promo_code(order_2, coupons[2].code)
+ self.assertEqual(len(order_2.order_line.ids), 1, not_applied_message)
+
+ order_3 = order_pricelist_1.copy()
+ self._apply_promo_code(order_3, coupons[3].code)
+ self.assertEqual(len(order_3.order_line.ids), 2, applied_message)
+
+ order_4 = order_pricelist_2.copy()
+ with self.assertRaises(ValidationError):
+ self._apply_promo_code(order_4, coupons[4].code)
+ self.assertEqual(len(order_4.order_line.ids), 1, not_applied_message)
+
+ self.code_promotion_program.pricelist_ids = [pricelist_1.id, pricelist_2.id]
+ order_5 = order_no_pricelist.copy()
+ with self.assertRaises(ValidationError):
+ self._apply_promo_code(order_5, coupons[5].code)
+ self.assertEqual(len(order_5.order_line.ids), 1, not_applied_message)
+
+ order_6 = order_pricelist_1.copy()
+ self._apply_promo_code(order_6, coupons[6].code)
+ self.assertEqual(len(order_6.order_line.ids), 2, applied_message)