diff --git a/addons/event/tests/test_event_slot.py b/addons/event/tests/test_event_slot.py
index 766009472b2ee..d86982c025ec1 100644
--- a/addons/event/tests/test_event_slot.py
+++ b/addons/event/tests/test_event_slot.py
@@ -203,17 +203,19 @@ def test_seats_slots_notickets(self):
new.with_user(self.user_eventmanager).unlink()
# check ``_verify_seats_availability`` itself
- for check_input, should_crash in [
+ for check_input, should_crash, problematic_name in [
# ok for event max seats for both slots
- (((first_slot, False, 2), (second_slot, False, 4)), False),
+ (((first_slot, False, 2), (second_slot, False, 4)), False, None),
# not enough seats on first slot
- (((first_slot, False, 3),), True),
+ (((first_slot, False, 3),), True, first_slot.display_name),
# not enough seats on second slot
- (((second_slot, False, 5),), True),
+ (((second_slot, False, 5),), True, second_slot.display_name),
]:
- with self.subTest(check_input=check_input, should_crash=should_crash):
+ with self.subTest(check_input=check_input, should_crash=should_crash, problematic_name=problematic_name):
if should_crash:
- with self.assertRaises(exceptions.ValidationError):
+ # Error message should reference the slot name.
+ # See `sale.order._is_cart_ready_for_checkout` for a why.
+ with self.assertRaisesRegex(exceptions.ValidationError, problematic_name):
test_event._verify_seats_availability(check_input)
else:
test_event._verify_seats_availability(check_input)
diff --git a/addons/payment_stripe/static/src/interactions/express_checkout.js b/addons/payment_stripe/static/src/interactions/express_checkout.js
index 3439fc278f52c..736efffcf70af 100644
--- a/addons/payment_stripe/static/src/interactions/express_checkout.js
+++ b/addons/payment_stripe/static/src/interactions/express_checkout.js
@@ -1,11 +1,12 @@
/* global Stripe */
-import { patch } from '@web/core/utils/patch';
+import { ExpressCheckout } from '@payment/interactions/express_checkout';
+import { StripeOptions } from '@payment_stripe/js/stripe_options';
+import { ConfirmationDialog } from '@web/core/confirmation_dialog/confirmation_dialog';
import { _t } from '@web/core/l10n/translation';
import { rpc } from '@web/core/network/rpc';
+import { patch } from '@web/core/utils/patch';
import { redirect } from '@web/core/utils/urls';
-import { ExpressCheckout } from '@payment/interactions/express_checkout';
-import { StripeOptions } from '@payment_stripe/js/stripe_options';
patch(ExpressCheckout.prototype, {
/**
@@ -136,7 +137,15 @@ patch(ExpressCheckout.prototype, {
const { client_secret } = await this.waitFor(rpc(
this.paymentContext['transactionRoute'],
this._prepareTransactionRouteParams(providerData.providerId),
- ));
+ ).catch(error => {
+ ev.complete('fail');
+ this.call('dialog', 'add', ConfirmationDialog, {
+ title: _t("Payment failed"),
+ body: error.data.message || "",
+ });
+ return {};
+ }));
+ if (!client_secret) return;
// Confirm the PaymentIntent without handling eventual next actions (e.g. 3DS).
const { paymentIntent, error: confirmError } = await this.waitFor(
stripeJS.confirmCardPayment(
diff --git a/addons/website_event_sale/controllers/__init__.py b/addons/website_event_sale/controllers/__init__.py
index e3b8c5e0e7634..cde2a0c45f3b6 100644
--- a/addons/website_event_sale/controllers/__init__.py
+++ b/addons/website_event_sale/controllers/__init__.py
@@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
from . import main
-from . import payment
from . import sale
diff --git a/addons/website_event_sale/controllers/payment.py b/addons/website_event_sale/controllers/payment.py
deleted file mode 100644
index e4600dc2af65d..0000000000000
--- a/addons/website_event_sale/controllers/payment.py
+++ /dev/null
@@ -1,32 +0,0 @@
-# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
-from odoo.http import request
-
-from odoo.addons.website_sale.controllers.payment import PaymentPortal
-
-
-class PaymentPortalOnsite(PaymentPortal):
-
- def _validate_transaction_for_order(self, transaction, sale_order):
- """
- Throws a ValidationError if the user tries to pay for a ticket which isn't available
- """
- super()._validate_transaction_for_order(transaction, sale_order)
- registration_domain = [
- ('sale_order_id', '=', sale_order.id),
- ('event_ticket_id', '!=', False),
- ('state', '!=', 'cancel'),
- ]
- registrations_per_event = request.env['event.registration'].sudo()._read_group(
- registration_domain,
- ['event_id'], ['id:recordset']
- )
- for event, registrations in registrations_per_event:
- count_per_slot_ticket = request.env['event.registration'].sudo()._read_group(
- [('id', 'in', registrations.ids)],
- ['event_slot_id', 'event_ticket_id'], ['__count']
- )
- event._verify_seats_availability([
- (slot, ticket, count)
- for slot, ticket, count in count_per_slot_ticket
- ])
diff --git a/addons/website_event_sale/models/sale_order.py b/addons/website_event_sale/models/sale_order.py
index b1e892a9bf2a2..f12c4a01f6405 100644
--- a/addons/website_event_sale/models/sale_order.py
+++ b/addons/website_event_sale/models/sale_order.py
@@ -1,7 +1,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, models
-from odoo.exceptions import UserError
+from odoo.exceptions import UserError, ValidationError
class SaleOrder(models.Model):
@@ -125,3 +125,36 @@ def _filter_can_send_abandoned_cart_mail(self):
return super()._filter_can_send_abandoned_cart_mail().filtered(
lambda so: all(ticket.sale_available for ticket in so.order_line.event_ticket_id),
)
+
+ def _is_cart_ready_for_checkout(self):
+ """Override of `website_sale` to check if the user is trying to order a ticket that is no
+ longer available."""
+ ready = super()._is_cart_ready_for_checkout()
+ if not (ready and self.order_line.event_id):
+ return ready
+
+ # Check that there are enough seats available per ticket.
+ registration_domain = [
+ ('sale_order_id', '=', self.id),
+ ('event_ticket_id', '!=', False),
+ ('state', '!=', 'cancel'),
+ ]
+ registrations_per_event = self.env['event.registration'].sudo()._read_group(
+ registration_domain, ['event_id'], ['id:recordset'],
+ )
+ errors = []
+ for event, registrations in registrations_per_event:
+ count_per_slot_ticket = self.env['event.registration'].sudo()._read_group(
+ [('id', 'in', registrations.ids)],
+ ['event_slot_id', 'event_ticket_id'],
+ ['__count'],
+ )
+ try:
+ event._verify_seats_availability(count_per_slot_ticket)
+ except ValidationError as e:
+ errors.append(str(e))
+
+ if errors:
+ # The error messages already inform the customer about which ticket is unavailable.
+ self.shop_warning = "\n".join(errors)
+ return not errors
diff --git a/addons/website_event_sale/tests/__init__.py b/addons/website_event_sale/tests/__init__.py
index 1301c3c4319b6..84c918197a785 100644
--- a/addons/website_event_sale/tests/__init__.py
+++ b/addons/website_event_sale/tests/__init__.py
@@ -1,6 +1,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import common
+from . import test_event_checkout
from . import test_frontend_buy_tickets
from . import test_website_event_sale_cart
from . import test_website_event_sale_pricelist
diff --git a/addons/website_event_sale/tests/test_event_checkout.py b/addons/website_event_sale/tests/test_event_checkout.py
new file mode 100644
index 0000000000000..f4562bcc19035
--- /dev/null
+++ b/addons/website_event_sale/tests/test_event_checkout.py
@@ -0,0 +1,55 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.fields import Command
+from odoo.tests import HttpCase, tagged
+
+from odoo.addons.website_event_sale.tests.common import TestWebsiteEventSaleCommon
+from odoo.addons.website_sale.controllers.main import WebsiteSale as CheckoutController
+from odoo.addons.website_sale.tests.common import MockRequest
+
+
+@tagged('post_install', '-at_install')
+class TestEventCheckout(TestWebsiteEventSaleCommon, HttpCase):
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.CheckoutController = CheckoutController()
+
+ def test_checkout_impossible_if_tickets_are_expired(self):
+ self.ticket.write({
+ 'seats_max': 1,
+ 'seats_limited': True,
+ })
+
+ so1 = self._create_so(order_line=[Command.create({
+ 'product_id': self.ticket.product_id.id,
+ 'event_id': self.event.id,
+ 'event_ticket_id': self.ticket.id,
+ })])
+ so2 = self._create_so(order_line=[Command.create({
+ 'product_id': self.ticket.product_id.id,
+ 'event_id': self.event.id,
+ 'event_ticket_id': self.ticket.id,
+ })])
+ self.env['event.registration'].create([
+ {
+ 'state': 'draft',
+ 'sale_order_id': so1.id,
+ 'partner_id': so1.partner_id.id,
+ 'event_id': self.event.id,
+ 'event_ticket_id': self.ticket.id,
+ },
+ ])
+
+ so2.action_confirm()
+ self.assertEqual(self.event.seats_available, 0)
+ self.assertEqual(self.event.seats_taken, 1)
+
+ website = self.website.with_user(self.public_user)
+ with MockRequest(website.env, website=website, path='/shop/checkout', sale_order_id=so1.id):
+ response = self.CheckoutController.shop_checkout()
+
+ # SO2 was too quick and registered before SO1 => redirected to change tickets
+ self.assertEqual(response.status_code, 303, 'SEE OTHER')
+ self.assertURLEqual(response.location, '/shop/cart')
diff --git a/addons/website_sale/controllers/cart.py b/addons/website_sale/controllers/cart.py
index d1480385dcf04..65842dc57378e 100644
--- a/addons/website_sale/controllers/cart.py
+++ b/addons/website_sale/controllers/cart.py
@@ -52,6 +52,8 @@ def cart(self, id=None, access_token=None, revive_method='', **post):
values.update({
'website_sale_order': order_sudo,
+ 'order': order_sudo,
+ 'shop_warnings': order_sudo._pop_shop_warnings(),
'date': fields.Date.today(),
'suggested_products': [],
})
@@ -223,31 +225,7 @@ def add_to_cart(
)
def quick_add(self, product_template_id, product_id, quantity=1.0, **kwargs):
values = self.add_to_cart(product_template_id, product_id, quantity=quantity, **kwargs)
-
- IrUiView = request.env['ir.ui.view']
- order_sudo = request.cart
- values['website_sale.cart_lines'] = IrUiView._render_template(
- 'website_sale.cart_lines', {
- 'website_sale_order': order_sudo,
- 'date': fields.Date.today(),
- 'suggested_products': order_sudo._cart_accessories(),
- }
- )
- values['website_sale.shorter_cart_summary'] = IrUiView._render_template(
- 'website_sale.shorter_cart_summary', {
- 'website_sale_order': order_sudo,
- 'show_shorter_cart_summary': True,
- **self._get_express_shop_payment_values(order_sudo),
- **request.website._get_checkout_step_values(),
- }
- )
- values['website_sale.quick_reorder_history'] = IrUiView._render_template(
- 'website_sale.quick_reorder_history', {
- 'website_sale_order': order_sudo,
- **self._prepare_order_history(),
- }
- )
- values['cart_ready'] = order_sudo._is_cart_ready()
+ values.update(self._get_cart_update_values(request.cart))
return values
def _get_express_shop_payment_values(self, order, **kwargs):
@@ -298,7 +276,6 @@ def update_cart(self, line_id, quantity, product_id=None, **kwargs):
"""
order_sudo = request.cart
quantity = int(quantity) # Do not allow float values in ecommerce by default
- IrUiView = request.env['ir.ui.view']
# This method must be only called from the cart page BUT in some advanced logic
# eg. website_sale_loyalty, a cart line could be a temporary record without id.
@@ -309,34 +286,43 @@ def update_cart(self, line_id, quantity, product_id=None, **kwargs):
)[:1].id
values = order_sudo._cart_update_line_quantity(line_id, quantity, **kwargs)
+ values.update(self._get_cart_update_values(order_sudo))
+ return values
+
+ def _get_cart_update_values(self, order_sudo):
+ """Construct the values needed to update the UI after a cart update.
+
+ :param sale.order order_sudo: The current cart order.
+ :rtype: dict
+ """
+ IrUiView = request.env['ir.ui.view']
- values['cart_quantity'] = order_sudo.cart_quantity
- values['cart_ready'] = order_sudo._is_cart_ready()
- values['amount'] = order_sudo.amount_total
- values['minor_amount'] = (
- order_sudo and payment_utils.to_minor_currency_units(
+ return {
+ 'cart_quantity': order_sudo.cart_quantity,
+ 'cart_ready': order_sudo._is_cart_ready_for_checkout(),
+ 'amount': order_sudo.amount_total,
+ 'minor_amount': payment_utils.to_minor_currency_units(
order_sudo.amount_total, order_sudo.currency_id
- )
- ) or 0.0
- values['website_sale.cart_lines'] = IrUiView._render_template(
- 'website_sale.cart_lines', {
- 'website_sale_order': order_sudo,
- 'date': fields.Date.today(),
- 'suggested_products': order_sudo._cart_accessories()
- }
- )
- values['website_sale.total'] = IrUiView._render_template(
- 'website_sale.total', {
- 'website_sale_order': order_sudo,
- }
- )
- values['website_sale.quick_reorder_history'] = IrUiView._render_template(
- 'website_sale.quick_reorder_history', {
- 'website_sale_order': order_sudo,
- **self._prepare_order_history(),
- }
- )
- return values
+ ),
+ 'website_sale.cart_lines': IrUiView._render_template(
+ 'website_sale.cart_lines', {
+ 'website_sale_order': order_sudo,
+ 'date': fields.Date.today(),
+ 'suggested_products': order_sudo._cart_accessories(),
+ 'order': order_sudo,
+ 'shop_warnings': order_sudo._pop_shop_warnings(),
+ }
+ ),
+ 'website_sale.total': IrUiView._render_template(
+ 'website_sale.total', {'website_sale_order': order_sudo}
+ ),
+ 'website_sale.quick_reorder_history': IrUiView._render_template(
+ 'website_sale.quick_reorder_history', {
+ 'website_sale_order': order_sudo,
+ **self._prepare_order_history(),
+ }
+ ),
+ }
def _prepare_order_history(self):
"""Prepare the order history of the current user.
diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py
index 5c8b1394654bf..35deeaf5a307b 100644
--- a/addons/website_sale/controllers/main.py
+++ b/addons/website_sale/controllers/main.py
@@ -984,13 +984,12 @@ def shop_checkout(self, try_skip_step=None, **query_params):
:return: The rendered checkout page.
:rtype: str
"""
+ if redirection := self._validate_previous_checkout_steps():
+ return redirection
+
try_skip_step = str2bool(try_skip_step or 'false')
order_sudo = request.cart
request.session['sale_last_order_id'] = order_sudo.id
-
- if redirection := self._check_cart_and_addresses(order_sudo):
- return redirection
-
checkout_page_values = self._prepare_checkout_page_values(order_sudo, **query_params)
can_skip_delivery = True # Delivery is only needed for deliverable products.
@@ -1029,6 +1028,7 @@ def _prepare_checkout_page_values(self, order_sudo, **kwargs):
return {
'order': order_sudo,
'website_sale_order': order_sudo, # Compatibility with other templates.
+ 'shop_warnings': order_sudo._pop_shop_warnings(),
'use_delivery_as_billing': (
order_sudo.partner_shipping_id == order_sudo.partner_invoice_id
),
@@ -1057,11 +1057,11 @@ def shop_address(
:return: The rendered address form.
:rtype: str
"""
- use_delivery_as_billing = str2bool(use_delivery_as_billing or 'false')
+ if redirection := self._validate_previous_checkout_steps():
+ return redirection
+ use_delivery_as_billing = str2bool(use_delivery_as_billing or 'false')
order_sudo = request.cart
- if redirection := self._check_cart(order_sudo):
- return redirection
# Retrieve the partner whose address to update, if any, and its address type.
partner_sudo, address_type = self._prepare_address_update(
@@ -1161,11 +1161,11 @@ def shop_address_submit(
:return: A JSON-encoded feedback, with either the success URL or an error message.
:rtype: str
"""
- order_sudo = request.cart
- if redirection := self._check_cart(order_sudo):
+ if redirection := self._validate_previous_checkout_steps(step_href='/shop/address'):
return json.dumps({'redirectUrl': redirection.location})
# Retrieve the partner whose address to update, if any, and its address type.
+ order_sudo = request.cart
partner_sudo, address_type = self._prepare_address_update(
order_sudo, partner_id=partner_id and int(partner_id), address_type=address_type
)
@@ -1476,21 +1476,21 @@ def extra_info(self, **post):
if not extra_step.active:
return request.redirect("/shop/payment")
- # check that cart is valid
- order_sudo = request.cart
- redirection = self._check_cart(order_sudo)
+ redirection = self._validate_previous_checkout_steps()
open_editor = request.params.get('open_editor') == 'true'
# Do not redirect if it is to edit
# (the information is transmitted via the "open_editor" parameter in the url)
if not open_editor and redirection:
return redirection
+ order_sudo = request.cart
values = {
'website_sale_order': order_sudo,
'post': post,
'escape': lambda x: x.replace("'", r"\'"),
'partner': order_sudo.partner_id.id,
'order': order_sudo,
+ 'shop_warnings': order_sudo._pop_shop_warnings(),
}
values.update(request.website._get_checkout_step_values())
@@ -1500,13 +1500,16 @@ def extra_info(self, **post):
# === CHECKOUT FLOW - PAYMENT/CONFIRMATION METHODS === #
def _get_shop_payment_values(self, order, **kwargs):
+ shop_warnings = order._pop_shop_warnings()
checkout_page_values = {
'sale_order': order,
'website_sale_order': order,
- 'errors': self._get_shop_payment_errors(order),
+ 'shop_warnings': shop_warnings,
'partner': order.partner_invoice_id,
'order': order,
+ 'only_services': order and order.only_services,
'submit_button_label': _("Pay now"),
+ **request.website._get_checkout_step_values(),
}
payment_form_values = {
**sale_portal.CustomerPortal._get_payment_values(
@@ -1517,25 +1520,11 @@ def _get_shop_payment_values(self, order, **kwargs):
'landing_route': '/shop/payment/validate',
'sale_order_id': order.id, # Allow Stripe to check if tokenization is required.
}
+ if shop_warnings:
+ payment_form_values.pop('payment_methods_sudo', '')
+ payment_form_values.pop('tokens_sudo', '')
return checkout_page_values | payment_form_values
- def _get_shop_payment_errors(self, order):
- """ Check that there is no error that should block the payment.
-
- :param sale.order order: The sales order to pay
- :return: A list of errors (error_title, error_message)
- :rtype: list[tuple]
- """
- errors = []
-
- if order._has_deliverable_products() and not order._get_delivery_methods():
- errors.append((
- _("Sorry, we are unable to ship your order."),
- _("No shipping method is available for your current order and shipping address."
- " Please contact us for more information."),
- ))
- return errors
-
@route('/shop/payment', type='http', auth='public', website=True, sitemap=False, list_as_website_content=_lt("Shop Payment"))
def shop_payment(self, **post):
""" Payment step. This page proposes several payment means based on available
@@ -1547,22 +1536,16 @@ def shop_payment(self, **post):
did go to a payment.provider website but closed the tab without
paying / canceling
"""
- order_sudo = request.cart
-
- if redirection := self._check_cart_and_addresses(order_sudo):
+ if redirection := self._validate_previous_checkout_steps():
return redirection
+ order_sudo = request.cart
order_sudo._recompute_cart()
- render_values = self._get_shop_payment_values(order_sudo, **post)
- render_values['only_services'] = order_sudo and order_sudo.only_services
-
- if render_values['errors']:
- render_values.pop('payment_methods_sudo', '')
- render_values.pop('tokens_sudo', '')
-
- render_values.update(request.website._get_checkout_step_values())
- return request.render("website_sale.payment", render_values)
+ return request.render(
+ 'website_sale.payment',
+ self._get_shop_payment_values(order_sudo, **post),
+ )
@route('/shop/payment/validate', type='http', auth="public", website=True, sitemap=False)
def shop_payment_validate(self, sale_order_id=None, **post):
@@ -1586,18 +1569,20 @@ def shop_payment_validate(self, sale_order_id=None, **post):
if not order_sudo:
return request.redirect(self._get_shop_path())
- errors = self._get_shop_payment_errors(order_sudo) if order_sudo.state != 'sale' else []
- if errors:
- first_error = errors[0] # only display first error
- error_msg = f"{first_error[0]}\n{first_error[1]}"
- raise ValidationError(error_msg)
+ if order_sudo.state != 'sale' and not order_sudo._is_cart_ready_to_be_paid():
+ raise ValidationError(request.env._(
+ "An unexpected error occurred, and we could not process your order."
+ " Please contact us to resolve the issue (Order reference: %(order_ref)s)."
+ " We apologize for any inconvenience caused.\n\n%(error)s",
+ order_ref=order_sudo.name,
+ error=order_sudo.shop_warning,
+ ))
tx_sudo = order_sudo.get_portal_last_transaction()
if order_sudo.amount_total and not tx_sudo:
return request.redirect(self._get_shop_path())
if not order_sudo.amount_total and not tx_sudo and order_sudo.state != 'sale':
- order_sudo._check_cart_is_ready_to_be_paid()
# Only confirm the order if it wasn't already confirmed.
order_sudo._validate_order()
@@ -1632,6 +1617,7 @@ def _prepare_shop_payment_confirmation_values(self, order):
return {
'order': order,
'website_sale_order': order,
+ 'shop_warnings': order._pop_shop_warnings(),
'order_tracking_info': self.order_2_return_dict(order),
}
@@ -1646,31 +1632,62 @@ def print_saleorder(self, **kwargs):
# === CHECK METHODS === #
- def _check_cart_and_addresses(self, order_sudo):
- """ Check whether the cart and its addresses are valid, and redirect to the appropriate page
- if not.
+ def _validate_previous_checkout_steps(self, step_href=None, order_sudo=None):
+ """Check that all the checkout steps prior to the current step are valid; otherwise,
+ redirect to the page where actions are still required.
- :param sale.order order_sudo: The cart to check.
- :return: None if both the cart and its addresses are valid; otherwise, a redirection to the
- appropriate page.
+ :param str step_href: The current step href. Defaults to `request.httprequest.path`.
+ :param sale.order order_sudo: The cart to check. Defaults to `request.cart`.
+ :return: None if the user can be on the current step; otherwise, a redirection.
+ :rtype: None | http.Response
"""
- if redirection := self._check_cart(order_sudo):
- return redirection
+ step_href = (
+ step_href
+ or request.env['ir.http'].url_unrewrite(request.httprequest.path, request.website.id)
+ )
+ order_sudo = order_sudo or request.cart
- if redirection := self._check_addresses(order_sudo):
- return redirection
+ previous_steps = request.website._get_checkout_step(step_href)._get_previous_checkout_steps(
+ Domain('website_id', '=', request.website.id),
+ )
+ for prev_step_href in previous_steps.sorted('sequence').mapped('step_href'):
+ if redirection := self._check_checkout_step(prev_step_href, order_sudo):
+ return redirection
+
+ def _check_checkout_step(self, step_href, order_sudo):
+ """Check that the given step is finished and valid; otherwise, redirect to the page where
+ actions are still required.
+
+ This method is intended to be overridden by other modules if further validation is needed at
+ a specific step.
+
+ :param str step_href: The checkout step href to check.
+ :param sale.order order_sudo: The current cart.
+ :return: None if the given step is valid; otherwise, a redirection to the appropriate page.
+ :rtype: None | http.Response
+ """
+ match step_href:
+ case '/shop/cart':
+ return self._check_shop_cart_step_ready(order_sudo)
+ case '/shop/address':
+ return self._check_shop_address_step_ready(order_sudo)
+ case '/shop/checkout':
+ return self._check_shop_checkout_step_ready(order_sudo)
- def _check_cart(self, order_sudo):
- """ Check whether the cart is a valid, and redirect to the appropriate page if not.
+ def _check_shop_cart_step_ready(self, order_sudo):
+ """Check whether the `/shop/cart` step is valid, and redirect to the appropriate page
+ otherwise.
- The cart is only valid if:
+ The `/shop/cart` step is only valid if:
- - it exists and is in the draft state;
- - it contains products (i.e., order lines);
- - either the user is logged in, or public orders are allowed.
+ - the cart exists and is in the draft state;
+ - either the user is logged in, or public orders are allowed;
+ - the cart is ready for checkout (see also `sale.order._is_cart_ready_for_checkout`);
+ - the cart's addresses are complete and valid.
:param sale.order order_sudo: The cart to check.
- :return: None if the cart is valid; otherwise, a redirection to the appropriate page.
+ :return: None if the step is valid; otherwise, a redirection to the appropriate page.
+ :rtype: None | http.Response
"""
# Check that the cart exists and is in the draft state.
if not order_sudo or order_sudo.state != 'draft':
@@ -1678,27 +1695,26 @@ def _check_cart(self, order_sudo):
request.session['sale_transaction_id'] = None
return request.redirect(self._get_shop_path())
- # Check that the cart is not empty.
- if not order_sudo.order_line:
- return request.redirect('/shop/cart')
-
# Check that public orders are allowed.
if request.env.user._is_public() and request.website.account_on_checkout == 'mandatory':
return request.redirect('/web/login?redirect=/shop/checkout')
- def _check_addresses(self, order_sudo):
- """ Check whether the cart's addresses are complete and valid.
+ if not order_sudo._is_cart_ready_for_checkout():
+ return request.redirect('/shop/cart')
+
+ def _check_shop_address_step_ready(self, order_sudo):
+ """Check whether the `/shop/address` step is valid, and redirect to the appropriate page
+ otherwise.
- The addresses are complete and valid if:
+ The addresses are considered complete and valid if:
- at least one address has been added;
- the delivery address is complete;
- the billing address is complete.
- :param sale.order order_sudo: The cart whose addresses to check.
- None if the cart is valid; otherwise, a redirection to the appropriate page.
- :return: None if the cart's addresses are complete and valid; otherwise, a redirection to
- the appropriate page.
+ :param sale.order order_sudo: The cart whose addresses are to be checked.
+ :return: None if the step is valid; otherwise, a redirection to the appropriate page.
+ :rtype: None | http.Response
"""
# Check that an address has been added.
if order_sudo._is_anonymous_cart():
@@ -1724,6 +1740,20 @@ def _check_addresses(self, order_sudo):
f'/shop/address?partner_id={invoice_partner_sudo.id}&address_type=billing'
)
+ def _check_shop_checkout_step_ready(self, order_sudo):
+ """Check whether the `/shop/checkout` step is valid, and redirect to the appropriate page
+ otherwise.
+
+ The `/shop/checkout` step is only valid if the cart is ready for payment. See also
+ `sale.order._is_cart_ready_for_payment`.
+
+ :param sale.order order_sudo: The cart to check.
+ :return: None if the step is valid; otherwise, a redirection to the appropriate page.
+ :rtype: None | http.Response
+ """
+ if not order_sudo._is_cart_ready_for_payment():
+ return request.redirect('/shop/checkout')
+
# ------------------------------------------------------
# Edit
# ------------------------------------------------------
diff --git a/addons/website_sale/controllers/payment.py b/addons/website_sale/controllers/payment.py
index c69891150658c..48fef8da9836c 100644
--- a/addons/website_sale/controllers/payment.py
+++ b/addons/website_sale/controllers/payment.py
@@ -52,7 +52,12 @@ def shop_payment_transaction(self, order_id, access_token, **kwargs):
if order_sudo.state == "cancel":
raise ValidationError(_("The order has been cancelled."))
- order_sudo._check_cart_is_ready_to_be_paid()
+ # Ensure the cart is still valid before proceeding any further.
+ if not order_sudo._is_cart_ready_to_be_paid():
+ raise ValidationError(_(
+ "Your cart is not ready to be paid, please verify previous steps.\n%s",
+ order_sudo.shop_warning,
+ ))
self._validate_transaction_kwargs(kwargs)
kwargs.update({
diff --git a/addons/website_sale/data/website_checkout_step_data.xml b/addons/website_sale/data/website_checkout_step_data.xml
index 37e9f7a4f6dfe..0a746aa3487bc 100644
--- a/addons/website_sale/data/website_checkout_step_data.xml
+++ b/addons/website_sale/data/website_checkout_step_data.xml
@@ -17,6 +17,13 @@