diff --git a/addons/website_sale_coupon/__init__.py b/addons/website_sale_coupon/__init__.py
new file mode 100644
index 00000000000..7d34c7c054a
--- /dev/null
+++ b/addons/website_sale_coupon/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import controllers
+from . import models
diff --git a/addons/website_sale_coupon/__manifest__.py b/addons/website_sale_coupon/__manifest__.py
new file mode 100644
index 00000000000..bec5b9dfa79
--- /dev/null
+++ b/addons/website_sale_coupon/__manifest__.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+{
+ 'name': "Coupons & Promotions for eCommerce",
+ 'summary': """Use coupon & promotion programs in your eCommerce store""",
+ 'description': """
+Create coupon and promotion codes to share in order to boost your sales (free products, discounts, etc.). Shoppers can use them in the eCommerce checkout.
+
+Coupon & promotion programs can be edited in the Catalog menu of the Website app.
+ """,
+ 'category': 'Website',
+ 'version': '1.0',
+ 'depends': ['website_sale', 'sale_coupon'],
+ 'data': [
+ 'views/website_sale_templates.xml',
+ 'views/res_config_settings_views.xml',
+ 'views/sale_coupon_program_views.xml',
+ ],
+ 'auto_install': True,
+}
diff --git a/addons/website_sale_coupon/controllers/__init__.py b/addons/website_sale_coupon/controllers/__init__.py
new file mode 100644
index 00000000000..5d4b25db9c0
--- /dev/null
+++ b/addons/website_sale_coupon/controllers/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import main
diff --git a/addons/website_sale_coupon/controllers/main.py b/addons/website_sale_coupon/controllers/main.py
new file mode 100644
index 00000000000..0e0c87e3d2f
--- /dev/null
+++ b/addons/website_sale_coupon/controllers/main.py
@@ -0,0 +1,53 @@
+# -*- coding: utf-8 -*-
+from odoo import http
+from odoo.addons.website_sale.controllers.main import WebsiteSale
+from odoo.http import request
+
+
+class WebsiteSale(WebsiteSale):
+
+ @http.route(['/shop/pricelist'])
+ def pricelist(self, promo, **post):
+ order = request.website.sale_get_order()
+ coupon_status = request.env['sale.coupon.apply.code'].sudo().apply_coupon(order, promo)
+ if coupon_status.get('not_found'):
+ return super(WebsiteSale, self).pricelist(promo, **post)
+ elif coupon_status.get('error'):
+ request.session['error_promo_code'] = coupon_status['error']
+ return request.redirect(post.get('r', '/shop/cart'))
+
+ @http.route(['/shop/payment'], type='http', auth="public", website=True)
+ def payment(self, **post):
+ order = request.website.sale_get_order()
+ order.recompute_coupon_lines()
+ return super(WebsiteSale, self).payment(**post)
+
+ @http.route(['/shop/cart'], type='http', auth="public", website=True)
+ def cart(self, **post):
+ order = request.website.sale_get_order()
+ order.recompute_coupon_lines()
+ return super(WebsiteSale, self).cart(**post)
+
+ def _update_website_sale_coupon(self, **post):
+ order = request.website.sale_get_order()
+ order.recompute_coupon_lines()
+ free_shipping_lines = order._get_free_shipping_lines()
+ currency = order.currency_id
+ result = {}
+ if free_shipping_lines:
+ amount_free_shipping = sum(free_shipping_lines.mapped('price_subtotal'))
+ result.update({
+ 'new_amount_delivery': self._format_amount(0.0, currency),
+ 'new_amount_untaxed': self._format_amount(order.amount_untaxed, currency),
+ 'new_amount_tax': self._format_amount(order.amount_tax, currency),
+ 'new_amount_total': self._format_amount(order.amount_total, currency),
+ 'new_amount_order_discounted': self._format_amount(order.reward_amount - amount_free_shipping, currency)
+ })
+ return result
+
+ # Override
+ # Add in the rendering the free_shipping_line
+ def _get_shop_payment_values(self, order, **kwargs):
+ values = super(WebsiteSale, self)._get_shop_payment_values(order, **kwargs)
+ values['free_shipping_lines'] = order._get_free_shipping_lines()
+ return values
diff --git a/addons/website_sale_coupon/models/__init__.py b/addons/website_sale_coupon/models/__init__.py
new file mode 100644
index 00000000000..28c756b4fed
--- /dev/null
+++ b/addons/website_sale_coupon/models/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import sale_coupon_program
+from . import sale_order
diff --git a/addons/website_sale_coupon/models/sale_coupon_program.py b/addons/website_sale_coupon/models/sale_coupon_program.py
new file mode 100644
index 00000000000..2fee40ba519
--- /dev/null
+++ b/addons/website_sale_coupon/models/sale_coupon_program.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models, _
+from odoo.exceptions import ValidationError
+
+
+class SaleCouponProgram(models.Model):
+ _name = 'sale.coupon.program'
+ _inherit = ['sale.coupon.program', 'website.multi.mixin']
+
+ @api.constrains('promo_code', 'website_id')
+ def _check_promo_code_constraint(self):
+ """ Only case where multiple same code could coexists is if they all belong to their own website.
+ If the program is website generic, we should ensure there is no generic and no specific (even for other website) already
+ If the program is website specific, we should ensure there is no existing code for this website or False
+ """
+ for program in self.filtered(lambda p: p.promo_code):
+ domain = [('id', '!=', program.id), ('promo_code', '=', program.promo_code)]
+ if program.website_id:
+ domain += program.website_id.website_domain()
+ if self.search(domain):
+ raise ValidationError(_('The program code must be unique by website!'))
diff --git a/addons/website_sale_coupon/models/sale_order.py b/addons/website_sale_coupon/models/sale_order.py
new file mode 100644
index 00000000000..f4a0a5e6620
--- /dev/null
+++ b/addons/website_sale_coupon/models/sale_order.py
@@ -0,0 +1,71 @@
+# -*- coding: utf-8 -*-
+from odoo import models, fields, api
+from odoo.http import request
+
+
+class SaleOrder(models.Model):
+ _inherit = "sale.order"
+
+ @api.one
+ def _compute_website_order_line(self):
+ """ This method will merge multiple discount lines generated by a same program
+ into a single one (temporary line with `new()`).
+ This case will only occur when the program is a discount applied on multiple
+ products with different taxes.
+ In this case, each taxes will have their own discount line. This is required
+ to have correct amount of taxes according to the discount.
+ But we wan't these lines to be `visually` merged into a single one in the
+ e-commerce since the end user should only see one discount line.
+ This is only possible since we don't show taxes in cart.
+ eg:
+ line 1: 10% discount on product with tax `A` - $15
+ line 2: 10% discount on product with tax `B` - $11.5
+ line 3: 10% discount on product with tax `C` - $10
+ would be `hidden` and `replaced` by
+ line 1: 10% discount - $36.5
+
+ Note: The line will be created without tax(es) and the amount will be computed
+ depending if B2B or B2C is enabled.
+ """
+ super(SaleOrder, self)._compute_website_order_line()
+ for program in self._get_applied_programs_with_rewards_on_current_order():
+ program_lines = self.order_line.filtered(lambda line: line.product_id == program.discount_line_product_id)
+ if len(program_lines) > 1:
+ price_unit = sum(l.price_subtotal if self.env.user.has_group('sale.group_show_price_subtotal') else l.price_total for l in program_lines)
+ self.website_order_line += self.env['sale.order.line'].new({
+ 'product_id': program_lines[0].product_id.id,
+ 'price_unit': price_unit,
+ 'name': program_lines[0].name,
+ 'product_uom_qty': 1,
+ 'product_uom': program_lines[0].product_uom.id,
+ 'order_id': self.id,
+ 'is_reward_line': True,
+ })
+ self.website_order_line -= program_lines
+
+
+ def _compute_cart_info(self):
+ super(SaleOrder, self)._compute_cart_info()
+ for order in self:
+ reward_lines = order.website_order_line.filtered(lambda line: line.is_reward_line)
+ order.cart_quantity -= int(sum(reward_lines.mapped('product_uom_qty')))
+
+ def get_promo_code_error(self, delete=True):
+ error = request.session.get('error_promo_code')
+ if error and delete:
+ request.session.pop('error_promo_code')
+ return error
+
+ @api.multi
+ def _cart_update(self, product_id=None, line_id=None, add_qty=0, set_qty=0, **kwargs):
+ res = super(SaleOrder, self)._cart_update(product_id=product_id, line_id=line_id, add_qty=add_qty, set_qty=set_qty, **kwargs)
+ self.recompute_coupon_lines()
+ return res
+
+ def _get_free_shipping_lines(self):
+ self.ensure_one()
+ free_shipping_prgs_ids = self.no_code_promo_program_ids.filtered(lambda p: p.reward_type == 'free_shipping')
+ if not free_shipping_prgs_ids:
+ return self.env['sale.order.line']
+ free_shipping_product_ids = free_shipping_prgs_ids.mapped('discount_line_product_id')
+ return self.order_line.filtered(lambda l: l.product_id in free_shipping_product_ids)
diff --git a/addons/website_sale_coupon/static/description/icon.png b/addons/website_sale_coupon/static/description/icon.png
new file mode 100644
index 00000000000..f88909cc0de
Binary files /dev/null and b/addons/website_sale_coupon/static/description/icon.png differ
diff --git a/addons/website_sale_coupon/static/description/icon.svg b/addons/website_sale_coupon/static/description/icon.svg
new file mode 100644
index 00000000000..4bb830714c8
--- /dev/null
+++ b/addons/website_sale_coupon/static/description/icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/addons/website_sale_coupon/static/src/js/website_sale_coupon.test.js b/addons/website_sale_coupon/static/src/js/website_sale_coupon.test.js
new file mode 100644
index 00000000000..1401015dcad
--- /dev/null
+++ b/addons/website_sale_coupon/static/src/js/website_sale_coupon.test.js
@@ -0,0 +1,171 @@
+odoo.define('website_sale_coupon.test', function (require) {
+'use strict';
+
+require("website_sale.tour");
+var tour = require("web_tour.tour");
+var ajax = require('web.ajax');
+
+tour.register('shop_sale_coupon', {
+ test: true,
+ url: '/shop?search=Large%20Cabinet',
+},
+ [
+ {
+ content: "open customize menu",
+ extra_trigger: '.oe_website_sale .o_wsale_products_searchbar_form',
+ trigger: '#customize-menu > a',
+ },
+ {
+ content: "enable 'Show # found' if needed",
+ trigger: "#customize-menu a:contains(Show # found)",
+ run: function () {
+ if (!$('#customize-menu a:contains(Show # found) input').prop('checked')) {
+ $('#customize-menu a:contains(Show # found)').click();
+ }
+ }
+ },
+ /* 1. Buy 1 Large Cabinet, enable coupon code & insert 10% code */
+ {
+ content: "select Large Cabinet",
+ extra_trigger: '.oe_search_found',
+ trigger: '.oe_product_cart a:contains("Large Cabinet")',
+ },
+ {
+ content: "add 2 Large Cabinet into cart",
+ trigger: '#product_details input[name="add_qty"]',
+ run: "text 2",
+ },
+ {
+ content: "click on 'Add to Cart' button",
+ trigger: "a:contains(Add to Cart)",
+ },
+ {
+ content: "open customize menu",
+ extra_trigger: '.oe_website_sale .oe_cart',
+ trigger: '#customize-menu > a',
+ },
+ {
+ content: "enable 'Promo Code' if needed",
+ trigger: "#customize-menu a:contains(Promo Code)",
+ run: function () {
+ if (!$('#customize-menu a:contains(Promo Code) input').prop('checked')) {
+ $('#customize-menu a:contains(Promo Code)').click();
+ }
+ }
+ },
+ {
+ content: "click on 'I have a promo code'",
+ extra_trigger: '.show_coupon',
+ trigger: '.show_coupon',
+ },
+ {
+ content: "insert promo code '10pc'",
+ extra_trigger: 'form[name="coupon_code"]',
+ trigger: 'form[name="coupon_code"] input[name="promo"]',
+ run: "text 10pc",
+ },
+ {
+ content: "validate the coupon",
+ trigger: 'form[name="coupon_code"] .a-submit',
+ },
+ {
+ content: "check reward product",
+ trigger: '.reward_product:contains("10.0% discount on total amount")',
+ run: function () {}, // it's a check
+ },
+ /* 2. Add some cabinet to get a free one, play with quantity */
+ {
+ content: "go to shop",
+ trigger: '.reward_product:contains("10.0% discount on total amount")',
+ run: function () {
+ ajax.jsonRpc('/web/dataset/call', 'call', {
+ model: 'account.tax',
+ method: 'create',
+ args: [{
+ 'name':'15% tax incl ' + _.now(),
+ 'amount': 15,
+ }],
+ }).then(function (tax_id) {
+ ajax.jsonRpc('/web/dataset/call', 'call', {
+ model: 'product.template',
+ method: 'create',
+ args: [{
+ 'name': 'Taxed Product',
+ 'taxes_id': [([6, false, [tax_id]])],
+ 'list_price': 100,
+ 'website_published': true,
+ }],
+ }).then(function (data) {
+ location.href = '/shop';
+ });
+ });
+ },
+ },
+ {
+ content: "type Taxed Product in search",
+ trigger: 'form input[name="search"]',
+ run: "text Taxed Product",
+ },
+ {
+ content: "start search",
+ trigger: 'form:has(input[name="search"]) .oe_search_button',
+ },
+ {
+ content: "select Taxed Product",
+ extra_trigger: '.oe_search_found', // Wait to be on search results or it sometimes throws concurent error (sent search form + click on product on /shop)
+ trigger: '.oe_product_cart a:containsExact("Taxed Product")',
+ },
+ {
+ content: "click on 'Add to Cart' button",
+ trigger: "a:contains(Add to Cart)",
+ },
+ {
+ content: "check reduction amount got recomputed and merged both discount lines into one only",
+ extra_trigger: '.oe_currency_value:contains("-75.50"):not(#cart_total .oe_currency_value:contains("-75.50"))',
+ trigger: '.oe_website_sale .oe_cart',
+ run: function () {}, // it's a check
+ },
+ /* 3. Add some cabinet to get a free one, play with quantity */
+ {
+ content: "add one Large Cabinet",
+ trigger: '#cart_products input.js_quantity',
+ run: "text 3",
+ },
+ {
+ content: "check reduction amount got recomputed when changing qty",
+ trigger: '.oe_currency_value:contains("-107.50")',
+ run: function () {}, // it's a check
+ },
+ {
+ content: "add more Large Cabinet into cart",
+ trigger: '#cart_products input.js_quantity',
+ run: "text 4",
+ },
+ {
+ content: "check free product is added",
+ trigger: '#wrap:has(.reward_product:contains("Free Product - Large Cabinet"))',
+ run: function () {}, // it's a check
+ },
+ {
+ content: "remove one cabinet from cart",
+ trigger: '#cart_products input.js_quantity[value="4"]',
+ run: "text 3",
+ },
+ {
+ content: "check free product is removed",
+ trigger: '#wrap:not(:has(.reward_product:contains("Free Product - Large Cabinet")))',
+ run: function () {}, // it's a check
+ },
+ /* 4. Check /shop/payment does not break the `merged discount lines split per tax` (eg: with _compute_tax_id) */
+ {
+ content: "go to checkout",
+ trigger: 'a[href="/shop/checkout?express=1"]',
+ },
+ {
+ content: "check total is unchanged",
+ trigger: 'tr#order_total .oe_currency_value:contains("967.50")',
+ run: function () {}, // it's a check
+ },
+ ]
+);
+});
diff --git a/addons/website_sale_coupon/tests/__init__.py b/addons/website_sale_coupon/tests/__init__.py
new file mode 100644
index 00000000000..9f48e6f73be
--- /dev/null
+++ b/addons/website_sale_coupon/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_shop_sale_coupon
diff --git a/addons/website_sale_coupon/tests/test_shop_sale_coupon.py b/addons/website_sale_coupon/tests/test_shop_sale_coupon.py
new file mode 100644
index 00000000000..5f3a128ee2b
--- /dev/null
+++ b/addons/website_sale_coupon/tests/test_shop_sale_coupon.py
@@ -0,0 +1,19 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import odoo.tests
+
+
+class TestUi(odoo.tests.HttpCase):
+
+ post_install = True
+ at_install = False
+
+ def test_01_admin_shop_sale_coupon_tour(self):
+ # pre enable "Show # found" option to avoid race condition...
+ self.env.ref("website_sale.search count").write({"active": True})
+ self.phantom_js(
+ "/",
+ "odoo.__DEBUG__.services['web_tour.tour'].run('shop_sale_coupon')",
+ "odoo.__DEBUG__.services['web_tour.tour'].tours.shop_sale_coupon.ready",
+ login="admin",
+ )
diff --git a/addons/website_sale_coupon/views/res_config_settings_views.xml b/addons/website_sale_coupon/views/res_config_settings_views.xml
new file mode 100644
index 00000000000..af2f1beb791
--- /dev/null
+++ b/addons/website_sale_coupon/views/res_config_settings_views.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ res.config.settings.view.form.inherit.website.sale.coupon
+ res.config.settings
+
+
+
+
+
+
+
+
diff --git a/addons/website_sale_coupon/views/sale_coupon_program_views.xml b/addons/website_sale_coupon/views/sale_coupon_program_views.xml
new file mode 100644
index 00000000000..739498bfab3
--- /dev/null
+++ b/addons/website_sale_coupon/views/sale_coupon_program_views.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+ sale.coupon.program.common.form
+ sale.coupon.program
+
+
+
+
+
+
+
+
+
+
+
+
+ sale.coupon.program.tree
+ sale.coupon.program
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/addons/website_sale_coupon/views/website_sale_templates.xml b/addons/website_sale_coupon/views/website_sale_templates.xml
new file mode 100644
index 00000000000..e4f260beb35
--- /dev/null
+++ b/addons/website_sale_coupon/views/website_sale_templates.xml
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+ line.is_reward_line and "display:none;"
+
+
+
+ 'reward_product' if line.is_reward_line else None
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Discount: |
+
+
+ |
+
+
+
+
+
+
+ 'display: None;' if free_shipping_lines and line in free_shipping_lines else ''
+
+
+