diff --git a/addons/payment/__manifest__.py b/addons/payment/__manifest__.py
index 4b7e5b17484..42108fa8045 100644
--- a/addons/payment/__manifest__.py
+++ b/addons/payment/__manifest__.py
@@ -40,6 +40,7 @@
'payment/static/src/scss/payment_form.scss',
'payment/static/lib/jquery.payment/jquery.payment.js',
'payment/static/src/js/checkout_form.js',
+ 'payment/static/src/js/express_checkout_form.js',
'payment/static/src/js/manage_form.js',
'payment/static/src/js/payment_form_mixin.js',
'payment/static/src/js/post_processing.js',
diff --git a/addons/payment/models/payment_acquirer.py b/addons/payment/models/payment_acquirer.py
index 22d1118ebf2..f3322882b5d 100644
--- a/addons/payment/models/payment_acquirer.py
+++ b/addons/payment/models/payment_acquirer.py
@@ -55,6 +55,12 @@ class PaymentAcquirer(models.Model):
help="Capture the amount from Odoo, when the delivery is completed.\n"
"Use this if you want to charge your customers cards only when\n"
"you are sure you can ship the goods to them.")
+ allow_express_checkout = fields.Boolean(
+ string="Allow Express Checkout",
+ help="This controls whether customers can use express payment methods. Express checkout "
+ "enables customers to pay with Google Pay and Apple Pay from which address "
+ "information is collected at payment.",
+ )
redirect_form_view_id = fields.Many2one(
string="Redirect Form Template", comodel_name='ir.ui.view',
help="The template rendering a form submitted to redirect the user when making a payment",
@@ -69,6 +75,12 @@ class PaymentAcquirer(models.Model):
help="The template rendering the inline payment form when making a payment by token.",
domain=[('type', '=', 'qweb')],
)
+ express_checkout_form_view_id = fields.Many2one(
+ string="Express Checkout Form Template",
+ comodel_name='ir.ui.view',
+ help="The template rendering the express payment methods' form.",
+ domain=[('type', '=', 'qweb')],
+ )
journal_id = fields.Many2one(
string="Payment Journal", comodel_name='account.journal',
compute='_compute_journal_id', inverse='_inverse_journal_id',
@@ -126,19 +138,22 @@ class PaymentAcquirer(models.Model):
default=lambda self: _("Your payment has been cancelled."), translate=True)
# Feature support fields
- support_fees = fields.Boolean(
- string="Fees Supported", compute='_compute_feature_support_fields'
+ support_tokenization = fields.Boolean(
+ string="Tokenization Supported", compute='_compute_feature_support_fields'
)
support_manual_capture = fields.Boolean(
string="Manual Capture Supported", compute='_compute_feature_support_fields'
)
+ support_express_checkout = fields.Boolean(
+ string="Express Checkout Supported", compute='_compute_feature_support_fields'
+ )
support_refund = fields.Selection(
string="Type of Refund Supported",
selection=[('full_only', "Full Only"), ('partial', "Partial")],
compute='_compute_feature_support_fields',
)
- support_tokenization = fields.Boolean(
- string="Tokenization Supported", compute='_compute_feature_support_fields'
+ support_fees = fields.Boolean(
+ string="Fees Supported", compute='_compute_feature_support_fields'
)
# Kanban view fields
@@ -158,6 +173,7 @@ class PaymentAcquirer(models.Model):
# View configuration fields
show_credentials_page = fields.Boolean(compute='_compute_view_configuration_fields')
show_allow_tokenization = fields.Boolean(compute='_compute_view_configuration_fields')
+ show_allow_express_checkout = fields.Boolean(compute='_compute_view_configuration_fields')
show_payment_icon_ids = fields.Boolean(compute='_compute_view_configuration_fields')
show_pre_msg = fields.Boolean(compute='_compute_view_configuration_fields')
show_pending_msg = fields.Boolean(compute='_compute_view_configuration_fields')
@@ -196,6 +212,7 @@ class PaymentAcquirer(models.Model):
self.update({
'show_credentials_page': True,
'show_allow_tokenization': True,
+ 'show_allow_express_checkout': True,
'show_payment_icon_ids': True,
'show_pre_msg': True,
'show_pending_msg': True,
@@ -245,10 +262,13 @@ class PaymentAcquirer(models.Model):
:return: None
"""
- self.update(dict.fromkeys(
- ('support_fees', 'support_manual_capture', 'support_refund', 'support_tokenization'),
- None,
- ))
+ self.update(dict.fromkeys((
+ 'support_express_checkout',
+ 'support_fees',
+ 'support_manual_capture',
+ 'support_refund',
+ 'support_tokenization',
+ ), None))
def _get_default_payment_method_id(self):
self.ensure_one()
@@ -393,7 +413,7 @@ class PaymentAcquirer(models.Model):
@api.model
def _get_compatible_acquirers(
self, company_id, partner_id, amount, currency_id=None, force_tokenization=False,
- is_validation=False, **kwargs
+ is_express_checkout=False, is_validation=False, **kwargs
):
""" Select and return the acquirers matching the criteria.
@@ -405,6 +425,7 @@ class PaymentAcquirer(models.Model):
:param float amount: The amount to pay, `0` for validation transactions.
:param int currency_id: The payment currency if known beforehand, as a `res.currency` id
:param bool force_tokenization: Whether only acquirers allowing tokenization can be matched
+ :param bool is_express_checkout: Whether the payment is made through express checkout.
:param bool is_validation: Whether the operation is a validation
:param dict kwargs: Optional data. This parameter is not used here
:return: The compatible acquirers
@@ -447,6 +468,10 @@ class PaymentAcquirer(models.Model):
if force_tokenization or self._is_tokenization_required(**kwargs):
domain = expression.AND([domain, [('allow_tokenization', '=', True)]])
+ # Handle express checkout.
+ if is_express_checkout:
+ domain = expression.AND([domain, [('allow_express_checkout', '=', True)]])
+
compatible_acquirers = self.env['payment.acquirer'].search(domain)
return compatible_acquirers
diff --git a/addons/payment/models/payment_transaction.py b/addons/payment/models/payment_transaction.py
index 45d53510e22..e5b08fcc5a0 100644
--- a/addons/payment/models/payment_transaction.py
+++ b/addons/payment/models/payment_transaction.py
@@ -485,6 +485,7 @@ class PaymentTransaction(models.Model):
self.ensure_one()
processing_values = {
+ 'tx_id': self.id,
'acquirer_id': self.acquirer_id.id,
'provider': self.provider,
'reference': self.reference,
diff --git a/addons/payment/static/src/js/express_checkout_form.js b/addons/payment/static/src/js/express_checkout_form.js
new file mode 100644
index 00000000000..5ae73d4a8a9
--- /dev/null
+++ b/addons/payment/static/src/js/express_checkout_form.js
@@ -0,0 +1,122 @@
+/** @odoo-module */
+
+import core from 'web.core';
+import publicWidget from 'web.public.widget';
+
+publicWidget.registry.PaymentExpressCheckoutForm = publicWidget.Widget.extend({
+ selector: 'form[name="o_payment_express_checkout_form"]',
+
+ /**
+ * @override
+ */
+ start: async function () {
+ await this._super(...arguments);
+ this.txContext = {};
+ Object.assign(this.txContext, this.$el.data());
+ const expressCheckoutForms = this._getExpressCheckoutForms();
+ for (const expressCheckoutForm of expressCheckoutForms) {
+ await this._prepareExpressCheckoutForm(expressCheckoutForm.dataset);
+ }
+ // Monitor updates of the amount on eCommerce's cart pages.
+ core.bus.on('cart_amount_changed', this, this._updateAllAmounts.bind(this));
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Return all express checkout forms found on the page.
+ *
+ * @private
+ * @return {NodeList} - All express checkout forms found on the page.
+ */
+ _getExpressCheckoutForms() {
+ return document.querySelectorAll(
+ 'form[name="o_payment_express_checkout_form"] div[name="o_express_checkout_container"]'
+ );
+ },
+
+ /**
+ * Return whether the shipping information is required or not.
+ *
+ * For a module to request shipping information to the customer, it must override this method.
+ *
+ * @private
+ * @return {Boolean} - Whether the shipping information is required or not.
+ */
+ _isShippingInformationRequired: () => false,
+
+ /**
+ * Prepare the acquirer-specific express checkout form based on the provided data.
+ *
+ * For an acquirer to manage an express checkout form, it must override this method.
+ *
+ * @private
+ * @param {Object} acquirerData - The acquirer-specific data.
+ * @return {Promise}
+ */
+ async _prepareExpressCheckoutForm(acquirerData) {
+ return Promise.resolve();
+ },
+
+ /**
+ * Prepare the params to send to the transaction route.
+ *
+ * For an acquirer to overwrite generic params or to add acquirer-specific ones, it must
+ * override this method and return the extended transaction route params.
+ *
+ * @private
+ * @param {number} acquirerId - The id of the acquirer handling the transaction.
+ * @returns {object} - The transaction route params
+ */
+ _prepareTransactionRouteParams(acquirerId) {
+ return {
+ 'payment_option_id': parseInt(acquirerId),
+ 'reference_prefix': this.txContext.referencePrefix &&
+ this.txContent.referencePrefix.toString(),
+ 'currency_id': this.txContext.currencyId &&
+ parseInt(this.txContext.currencyId),
+ 'partner_id': parseInt(this.txContext.partnerId),
+ 'flow': 'direct',
+ 'tokenization_requested': false,
+ 'landing_route': this.txContext.landingRoute,
+ 'add_id_to_landing_route': true,
+ 'access_token': this.txContext.accessToken,
+ 'csrf_token': core.csrf_token,
+ };
+ },
+
+ /**
+ * Retrieve all the express checkout forms and update the amount in each form.
+ *
+ * Note: triggered by bus event `cart_amount_changed`.
+ *
+ * @private
+ * @param {number} newAmount - The new amount.
+ * @param {number} newMinorAmount - The new minor amount.
+ * @return {undefined}
+ */
+ _updateAllAmounts(newAmount, newMinorAmount) {
+ const expressCheckoutForms = this._getExpressCheckoutForms();
+ for (const expressCheckoutForm of expressCheckoutForms) {
+ this._updateAmount(expressCheckoutForm.dataset, newAmount, newMinorAmount);
+ }
+ },
+
+ /**
+ * Update the amount of the express checkout form.
+ *
+ * For an acquirer to manage an express form, it must override this method.
+ *
+ * @private
+ * @param {Object} acquirerData - The acquirer-specific data.
+ * @param {number} newAmount - The new amount.
+ * @param {number} newMinorAmount - The new minor amount.
+ * @return {undefined}
+ */
+ _updateAmount(acquirerData, newAmount, newMinorAmount) {},
+
+});
+
+export const paymentExpressCheckoutForm = publicWidget.registry.PaymentExpressCheckoutForm;
diff --git a/addons/payment/views/payment_acquirer_views.xml b/addons/payment/views/payment_acquirer_views.xml
index 0e286290bbe..a97912eb382 100644
--- a/addons/payment/views/payment_acquirer_views.xml
+++ b/addons/payment/views/payment_acquirer_views.xml
@@ -12,10 +12,12 @@
+
+
@@ -83,6 +85,7 @@
+
diff --git a/addons/payment/views/payment_templates.xml b/addons/payment/views/payment_templates.xml
index f9eb75c487e..1e6478a1e52 100644
--- a/addons/payment/views/payment_templates.xml
+++ b/addons/payment/views/payment_templates.xml
@@ -292,6 +292,49 @@
+
+
+
+
+
+
diff --git a/addons/payment_stripe/__manifest__.py b/addons/payment_stripe/__manifest__.py
index c1a6f3515ab..8d363de587c 100644
--- a/addons/payment_stripe/__manifest__.py
+++ b/addons/payment_stripe/__manifest__.py
@@ -9,14 +9,16 @@
'description': """Stripe Payment Acquirer""",
'depends': ['payment'],
'data': [
+ 'views/payment_stripe_templates.xml',
+ 'views/payment_templates.xml', # Only load the SDK on pages with a payment form.
'views/payment_views.xml',
- 'views/payment_templates.xml',
- 'data/payment_acquirer_data.xml',
+ 'data/payment_acquirer_data.xml', # Depends on views/payment_stripe_templates.xml
],
'application': True,
'uninstall_hook': 'uninstall_hook',
'assets': {
'web.assets_frontend': [
+ 'payment_stripe/static/src/js/express_checkout_form.js',
'payment_stripe/static/src/js/payment_form.js',
],
},
diff --git a/addons/payment_stripe/controllers/main.py b/addons/payment_stripe/controllers/main.py
index b65c1efc62c..fdbe6956841 100644
--- a/addons/payment_stripe/controllers/main.py
+++ b/addons/payment_stripe/controllers/main.py
@@ -12,6 +12,7 @@ from werkzeug.exceptions import Forbidden
from odoo import http
from odoo.exceptions import ValidationError
from odoo.http import request
+from odoo.tools.misc import file_open
from odoo.addons.payment import utils as payment_utils
from odoo.addons.payment_stripe import utils as stripe_utils
@@ -25,6 +26,7 @@ class StripeController(http.Controller):
_checkout_return_url = '/payment/stripe/checkout_return'
_validation_return_url = '/payment/stripe/validation_return'
_webhook_url = '/payment/stripe/webhook'
+ _apple_pay_domain_association_url = '/.well-known/apple-developer-merchantid-domain-association'
WEBHOOK_AGE_TOLERANCE = 10*60 # seconds
@http.route(_checkout_return_url, type='http', auth='public', csrf=False)
@@ -237,3 +239,20 @@ class StripeController(http.Controller):
if not hmac.compare_digest(received_signature, expected_signature):
_logger.warning("received notification with invalid signature")
raise Forbidden()
+
+ @http.route(_apple_pay_domain_association_url, type='http', auth='public', csrf=False)
+ def stripe_apple_pay_get_domain_association_file(self):
+ """ Get the domain association file for Stripe's Apple Pay.
+
+ Stripe handles the process of "merchant validation" described in Apple's documentation for
+ Apple Pay on the Web. Stripe and Apple will access this route to check the content of the
+ file and verify that the web domain is registered.
+
+ See https://stripe.com/docs/stripe-js/elements/payment-request-button#verifying-your-domain-with-apple-pay.
+
+ :return: The content of the domain association file.
+ :rtype: str
+ """
+ return file_open(
+ 'payment_stripe/static/files/apple-developer-merchantid-domain-association'
+ ).read()
diff --git a/addons/payment_stripe/data/payment_acquirer_data.xml b/addons/payment_stripe/data/payment_acquirer_data.xml
index 7893cdb02e9..14a5043e44d 100644
--- a/addons/payment_stripe/data/payment_acquirer_data.xml
+++ b/addons/payment_stripe/data/payment_acquirer_data.xml
@@ -3,7 +3,9 @@
stripe
+ True
+ True
diff --git a/addons/payment_stripe/models/payment_acquirer.py b/addons/payment_stripe/models/payment_acquirer.py
index 4fd4427142a..4666fd24169 100644
--- a/addons/payment_stripe/models/payment_acquirer.py
+++ b/addons/payment_stripe/models/payment_acquirer.py
@@ -4,10 +4,10 @@ import logging
import uuid
import requests
-from werkzeug.urls import url_encode, url_join
+from werkzeug.urls import url_encode, url_join, url_parse
from odoo import _, api, fields, models
-from odoo.exceptions import ValidationError
+from odoo.exceptions import UserError, ValidationError
from odoo.addons.payment_stripe import utils as stripe_utils
from odoo.addons.payment_stripe.const import API_VERSION, PROXY_URL, HANDLED_WEBHOOK_EVENTS
@@ -40,6 +40,7 @@ class PaymentAcquirer(models.Model):
""" Override of `payment` to enable additional features. """
super()._compute_feature_support_fields()
self.filtered(lambda acq: acq.provider == 'stripe').update({
+ 'support_express_checkout': True,
'support_manual_capture': True,
'support_refund': 'partial',
'support_tokenization': True,
@@ -165,6 +166,37 @@ class PaymentAcquirer(models.Model):
}
}
+ def action_stripe_verify_apple_pay_domain(self):
+ """ Verify the web domain with Stripe to enable Apple Pay.
+
+ The domain is sent to Stripe API for them to verify that it is valid by making a request to
+ the `/.well-known/apple-developer-merchantid-domain-association` route. If the domain is
+ valid, it is registered to use with Apple Pay.
+ See https://stripe.com/docs/stripe-js/elements/payment-request-button#verifying-your-domain-with-apple-pay.
+
+ :return dict: A client action with a success message.
+ :raise UserError: If test keys are used to make the request.
+ """
+ self.ensure_one()
+
+ web_domain = url_parse(self.get_base_url()).netloc
+ response_content = self._stripe_make_request('apple_pay/domains', payload={
+ 'domain_name': web_domain
+ })
+ if not response_content['livemode']:
+ # If test keys are used to make the request, Stripe will respond with an HTTP 200 but
+ # will not register the domain. Ask the user to use live credentials.
+ raise UserError(_("Please use live credentials to enable Apple Pay."))
+
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'message': _("Your web domain was successfully verified."),
+ 'type': 'success',
+ },
+ }
+
def _get_stripe_webhook_url(self):
return url_join(self.get_base_url(), StripeController._webhook_url)
diff --git a/addons/payment_stripe/models/payment_transaction.py b/addons/payment_stripe/models/payment_transaction.py
index a219a41c5a2..5a387bb9d3e 100644
--- a/addons/payment_stripe/models/payment_transaction.py
+++ b/addons/payment_stripe/models/payment_transaction.py
@@ -35,11 +35,18 @@ class PaymentTransaction(models.Model):
if self.provider != 'stripe' or self.operation == 'online_token':
return res
- checkout_session = self._stripe_create_checkout_session()
- return {
- 'publishable_key': stripe_utils.get_publishable_key(self.acquirer_id),
- 'session_id': checkout_session['id'],
- }
+ if self.operation == 'online_redirect':
+ checkout_session = self._stripe_create_checkout_session()
+ return {
+ 'publishable_key': stripe_utils.get_publishable_key(self.acquirer_id),
+ 'session_id': checkout_session['id'],
+ }
+ else: # Express checkout.
+ payment_intent = self._stripe_create_payment_intent()
+ self.stripe_payment_intent = payment_intent['id']
+ return {
+ 'client_secret': payment_intent['client_secret'],
+ }
def _stripe_create_checkout_session(self):
""" Create and return a Checkout Session.
@@ -203,14 +210,21 @@ class PaymentTransaction(models.Model):
:return: The Payment Intent
:rtype: dict
"""
- if not self.token_id.stripe_payment_method: # Pre-SCA token -> migrate it
- self.token_id._stripe_sca_migrate_customer()
+ if self.operation in ['online_token', 'offline']:
+ if not self.token_id.stripe_payment_method: # Pre-SCA token -> migrate it
+ self.token_id._stripe_sca_migrate_customer()
+
+ response = self.acquirer_id._stripe_make_request(
+ 'payment_intents',
+ payload=self._stripe_prepare_payment_intent_payload(payment_by_token=True),
+ offline=self.operation == 'offline',
+ )
+ else: # 'online_direct' (express checkout).
+ response = self.acquirer_id._stripe_make_request(
+ 'payment_intents',
+ payload=self._stripe_prepare_payment_intent_payload(),
+ )
- response = self.acquirer_id._stripe_make_request(
- 'payment_intents',
- payload=self._stripe_prepare_payment_intent_payload(),
- offline=self.operation == 'offline',
- )
if 'error' not in response:
payment_intent = response
else: # A processing error was returned in place of the payment intent
@@ -223,25 +237,30 @@ class PaymentTransaction(models.Model):
return payment_intent
- def _stripe_prepare_payment_intent_payload(self):
+ def _stripe_prepare_payment_intent_payload(self, payment_by_token=False):
""" Prepare the payload for the creation of a payment intent in Stripe format.
Note: This method serves as a hook for modules that would fully implement Stripe Connect.
Note: self.ensure_one()
+ :param boolean payment_by_token: Whether the payment is made by token or not.
:return: The Stripe-formatted payload for the payment intent request
:rtype: dict
"""
- return {
+ payment_intent_payload = {
'amount': payment_utils.to_minor_currency_units(self.amount, self.currency_id),
'currency': self.currency_id.name.lower(),
- 'confirm': True,
- 'customer': self.token_id.acquirer_ref,
- 'off_session': True,
- 'payment_method': self.token_id.stripe_payment_method,
'description': self.reference,
'capture_method': 'manual' if self.acquirer_id.capture_manually else 'automatic',
}
+ if payment_by_token:
+ payment_intent_payload.update(
+ confirm=True,
+ customer=self.token_id.acquirer_ref,
+ off_session=True,
+ payment_method=self.token_id.stripe_payment_method,
+ )
+ return payment_intent_payload
def _send_refund_request(self, amount_to_refund=None, create_refund_transaction=True):
""" Override of payment to send a refund request to Stripe.
diff --git a/addons/payment_stripe/static/files/apple-developer-merchantid-domain-association b/addons/payment_stripe/static/files/apple-developer-merchantid-domain-association
new file mode 100644
index 00000000000..2ff95c96281
--- /dev/null
+++ b/addons/payment_stripe/static/files/apple-developer-merchantid-domain-association
@@ -0,0 +1 @@
+7B227073704964223A2239373943394538343346343131343044463144313834343232393232313734313034353044314339464446394437384337313531303944334643463542433731222C2276657273696F6E223A312C22637265617465644F6E223A313536363233343735303036312C227369676E6174757265223A22333038303036303932613836343838366637306430313037303261303830333038303032303130313331306633303064303630393630383634383031363530333034303230313035303033303830303630393261383634383836663730643031303730313030303061303830333038323033653333303832303338386130303330323031303230323038346333303431343935313964353433363330306130363038326138363438636533643034303330323330376133313265333032633036303335353034303330633235343137303730366336353230343137303730366336393633363137343639366636653230343936653734363536373732363137343639366636653230343334313230326432303437333333313236333032343036303335353034306230633164343137303730366336353230343336353732373436393636363936333631373436393666366532303431373537343638366637323639373437393331313333303131303630333535303430613063306134313730373036633635323034393665363332653331306233303039303630333535303430363133303235353533333031653137306433313339333033353331333833303331333333323335333735613137306433323334333033353331333633303331333333323335333735613330356633313235333032333036303335353034303330633163363536333633326437333664373032643632373236663662363537323264373336393637366535663535343333343264353035323466343433313134333031323036303335353034306230633062363934663533323035333739373337343635366437333331313333303131303630333535303430613063306134313730373036633635323034393665363332653331306233303039303630333535303430363133303235353533333035393330313330363037326138363438636533643032303130363038326138363438636533643033303130373033343230303034633231353737656465626436633762323231386636386464373039306131323138646337623062643666326332383364383436303935643934616634613534313162383334323065643831316633343037653833333331663163353463336637656233323230643662616435643465666634393238393839336537633066313361333832303231313330383230323064333030633036303335353164313330313031666630343032333030303330316630363033353531643233303431383330313638303134323366323439633434663933653465663237653663346636323836633366613262626664326534623330343530363038326230363031303530353037303130313034333933303337333033353036303832623036303130353035303733303031383632393638373437343730336132663266366636333733373032653631373037303663363532653633366636643266366636333733373033303334326436313730373036633635363136393633363133333330333233303832303131643036303335353164323030343832303131343330383230313130333038323031306330363039326138363438383666373633363430353031333038316665333038316333303630383262303630313035303530373032303233303831623630633831623335323635366336393631366536333635323036663665323037343638363937333230363336353732373436393636363936333631373436353230363237393230363136653739323037303631373237343739323036313733373337353664363537333230363136333633363537303734363136653633363532303666363632303734363836353230373436383635366532303631373037303663363936333631363236633635323037333734363136653634363137323634323037343635373236643733323036313665363432303633366636653634363937343639366636653733323036663636323037353733363532633230363336353732373436393636363936333631373436353230373036663663363936333739323036313665363432303633363537323734363936363639363336313734363936663665323037303732363136333734363936333635323037333734363137343635366436353665373437333265333033363036303832623036303130353035303730323031313632613638373437343730336132663266373737373737326536313730373036633635326536333666366432663633363537323734363936363639363336313734363536313735373436383666373236393734373932663330333430363033353531643166303432643330326233303239613032376130323538363233363837343734373033613266326636333732366332653631373037303663363532653633366636643266363137303730366336353631363936333631333332653633373236633330316430363033353531643065303431363034313439343537646236666435373438313836383938393736326637653537383530376537396235383234333030653036303335353164306630313031666630343034303330323037383033303066303630393261383634383836663736333634303631643034303230353030333030613036303832613836343863653364303430333032303334393030333034363032323130306265303935373166653731653165373335623535653561666163623463373266656234343566333031383532323263373235313030326236316562643666353530323231303064313862333530613564643664643665623137343630333562313165623263653837636661336536616636636264383338303839306463383263646461613633333038323032656533303832303237356130303330323031303230323038343936643266626633613938646139373330306130363038326138363438636533643034303330323330363733313162333031393036303335353034303330633132343137303730366336353230353236663666373432303433343132303264323034373333333132363330323430363033353530343062306331643431373037303663363532303433363537323734363936363639363336313734363936663665323034313735373436383666373236393734373933313133333031313036303335353034306130633061343137303730366336353230343936653633326533313062333030393036303335353034303631333032353535333330316531373064333133343330333533303336333233333334333633333330356131373064333233393330333533303336333233333334333633333330356133303761333132653330326330363033353530343033306332353431373037303663363532303431373037303663363936333631373436393666366532303439366537343635363737323631373436393666366532303433343132303264323034373333333132363330323430363033353530343062306331643431373037303663363532303433363537323734363936363639363336313734363936663665323034313735373436383666373236393734373933313133333031313036303335353034306130633061343137303730366336353230343936653633326533313062333030393036303335353034303631333032353535333330353933303133303630373261383634386365336430323031303630383261383634386365336430333031303730333432303030346630313731313834313964373634383564353161356532353831303737366538383061326566646537626165346465303864666334623933653133333536643536363562333561653232643039373736306432323465376262613038666437363137636538386362373662623636373062656338653832393834666635343435613338316637333038316634333034363036303832623036303130353035303730313031303433613330333833303336303630383262303630313035303530373330303138363261363837343734373033613266326636663633373337303265363137303730366336353265363336663664326636663633373337303330333432643631373037303663363537323666366637343633363136373333333031643036303335353164306530343136303431343233663234396334346639336534656632376536633466363238366333666132626266643265346233303066303630333535316431333031303166663034303533303033303130316666333031663036303335353164323330343138333031363830313462626230646561313538333338383961613438613939646562656264656261666461636232346162333033373036303335353164316630343330333032653330326361303261613032383836323636383734373437303361326632663633373236633265363137303730366336353265363336663664326636313730373036633635373236663666373436333631363733333265363337323663333030653036303335353164306630313031666630343034303330323031303633303130303630613261383634383836663736333634303630323065303430323035303033303061303630383261383634386365336430343033303230333637303033303634303233303361636637323833353131363939623138366662333563333536636136326266663431376564643930663735346461323865626566313963383135653432623738396638393866373962353939663938643534313064386639646539633266653032333033323264643534343231623061333035373736633564663333383362393036376664313737633263323136643936346663363732363938323132366635346638376137643162393963623962303938393231363130363939306630393932316430303030333138323031386233303832303138373032303130313330383138363330376133313265333032633036303335353034303330633235343137303730366336353230343137303730366336393633363137343639366636653230343936653734363536373732363137343639366636653230343334313230326432303437333333313236333032343036303335353034306230633164343137303730366336353230343336353732373436393636363936333631373436393666366532303431373537343638366637323639373437393331313333303131303630333535303430613063306134313730373036633635323034393665363332653331306233303039303630333535303430363133303235353533303230383463333034313439353139643534333633303064303630393630383634383031363530333034303230313035303061303831393533303138303630393261383634383836663730643031303930333331306230363039326138363438383666373064303130373031333031633036303932613836343838366637306430313039303533313066313730643331333933303338333133393331333733313332333333303561333032613036303932613836343838366637306430313039333433313164333031623330306430363039363038363438303136353033303430323031303530306131306130363038326138363438636533643034303330323330326630363039326138363438383666373064303130393034333132323034323062303731303365313430613462386231376262613230316130336163643036396234653431366232613263383066383661383338313435633239373566633131333030613036303832613836343863653364303430333032303434363330343430323230343639306264636637626461663833636466343934396534633035313039656463663334373665303564373261313264376335666538633033303033343464663032323032363764353863393365626233353031333836363062353730373938613064643731313734316262353864626436613138363633353038353431656565393035303030303030303030303030227D
\ No newline at end of file
diff --git a/addons/payment_stripe/static/src/js/express_checkout_form.js b/addons/payment_stripe/static/src/js/express_checkout_form.js
new file mode 100644
index 00000000000..fdf3abf1010
--- /dev/null
+++ b/addons/payment_stripe/static/src/js/express_checkout_form.js
@@ -0,0 +1,228 @@
+/** @odoo-module **/
+/* global Stripe */
+
+import { _t } from "@web/core/l10n/translation";
+import { paymentExpressCheckoutForm } from '@payment/js/express_checkout_form';
+import { _prepareStripeOptions } from 'payment_stripe.payment_form';
+
+paymentExpressCheckoutForm.include({
+
+ /**
+ * Prepare the express checkout form of Stripe for direct payment.
+ *
+ * @override method from payment.express_form
+ * @private
+ * @param {Object} acquirerData - The acquirer-specific data.
+ * @return {Promise}
+ */
+ async _prepareExpressCheckoutForm(acquirerData) {
+ if (acquirerData.provider !== 'stripe') {
+ return this._super(...arguments);
+ }
+
+ const isShippingInformationRequired = this._isShippingInformationRequired();
+ const stripeJS = Stripe(
+ acquirerData.stripePublishableKey, _prepareStripeOptions(acquirerData)
+ );
+ this.stripeExpressCheckoutForm = stripeJS.paymentRequest({
+ country: acquirerData.countryCode,
+ currency: this.txContext.currencyName,
+ total: {
+ label: this.txContext.merchantName,
+ amount: this.txContext.minorAmount,
+ // Delay the display of the amount until the shipping price is retrieved.
+ pending: isShippingInformationRequired,
+ },
+ displayItems: !isShippingInformationRequired ? [] : [
+ {
+ label: _t("Your order"),
+ amount: this.txContext.minorAmount,
+ },
+ {
+ label: _t("Delivery"),
+ amount: 0,
+ },
+ ],
+ requestPayerName: true, // Force fetching the billing address for Apple Pay.
+ requestPayerEmail: true,
+ requestPayerPhone: true,
+ requestShipping: isShippingInformationRequired,
+ });
+ const paymentRequestButton = stripeJS.elements().create('paymentRequestButton', {
+ paymentRequest: this.stripeExpressCheckoutForm,
+ style: {paymentRequestButton: {type: 'buy'}},
+ });
+ await (async () => {
+ // Check the availability of the Payment Request API first.
+ const canMakePayment = await this.stripeExpressCheckoutForm.canMakePayment();
+ if (canMakePayment) {
+ paymentRequestButton.mount(
+ `#o_stripe_express_checkout_container_${acquirerData.acquirerId}`
+ );
+ } else {
+ document.querySelector(
+ `#o_stripe_express_checkout_container_${acquirerData.acquirerId}`
+ ).style.display = 'none';
+ }
+ })();
+
+ this.stripeExpressCheckoutForm.on('paymentmethod', async (ev) => {
+ const addresses = {
+ 'billing_address': {
+ name: ev.payerName,
+ email: ev.payerEmail,
+ phone: ev.payerPhone,
+ street: ev.paymentMethod.billing_details.address.line1,
+ street2: ev.paymentMethod.billing_details.address.line2,
+ zip: ev.paymentMethod.billing_details.address.postal_code,
+ city: ev.paymentMethod.billing_details.address.city,
+ country: ev.paymentMethod.billing_details.address.country,
+ state: ev.paymentMethod.billing_details.address.state,
+ }
+ };
+ if (isShippingInformationRequired) {
+ Object.assign(addresses, {
+ 'shipping_address': {
+ name: ev.shippingAddress.recipient,
+ phone: ev.shippingAddress.phone,
+ street: ev.shippingAddress.addressLine[0],
+ street2: ev.shippingAddress.addressLine[1],
+ zip: ev.shippingAddress.postalCode,
+ city: ev.shippingAddress.city,
+ country: ev.shippingAddress.country,
+ state: ev.shippingAddress.region,
+ },
+ 'shipping_option': ev.shippingOption,
+ });
+ }
+ // Update the customer addresses on the related document.
+ this.txContext.partnerId = parseInt(await this._rpc({
+ route: this.txContext.expressRoute, params: addresses,
+ }));
+ // Call the transaction route to create the transaction and retrieve the client secret.
+ const { client_secret } = await this._rpc({
+ route: this.txContext.transactionRoute,
+ params: this._prepareTransactionRouteParams(acquirerData.acquirerId),
+ });
+ // Confirm the PaymentIntent without handling eventual next actions.
+ const { paymentIntent, error: confirmError } = await stripeJS.confirmCardPayment(
+ client_secret, {payment_method: ev.paymentMethod.id}, {handleActions: false}
+ );
+ if (confirmError) {
+ // Report to the browser that the payment failed, prompting it to re-show the
+ // payment interface, or show an error message and close the payment interface.
+ ev.complete('fail');
+ } else {
+ // Report to the browser that the confirmation was successful, prompting it to close
+ // the browser payment method collection interface.
+ ev.complete('success');
+ // Check if the PaymentIntent requires any actions and, if so, let Stripe.js handle
+ // the flow.
+ if (paymentIntent.status !== 'requires_action') { // The payment has succeeded.
+ window.location = '/payment/status';
+ } else { // A next step is required from the customer, trigger it.
+ const { error } = await stripeJS.confirmCardPayment(client_secret);
+ if (!error) { // The payment has succeeded.
+ window.location = '/payment/status';
+ }
+ }
+ }
+ });
+
+ if (isShippingInformationRequired) {
+ // Wait until the express checkout form is loaded for Apple Pay and Google Pay to select
+ // a default shipping address and trigger the `shippingaddresschange` event, so we can
+ // fetch the available shipping options. When the customer manually selects a different
+ // shipping address, the shipping options need to be fetched again.
+ this.stripeExpressCheckoutForm.on('shippingaddresschange', async (ev) => {
+ // Call the shipping address update route to fetch the shipping options.
+ const availableCarriers = await this._rpc({
+ route: this.txContext.shippingAddressUpdateRoute,
+ params: {
+ partial_shipping_address: {
+ zip: ev.shippingAddress.postalCode,
+ city: ev.shippingAddress.city,
+ country: ev.shippingAddress.country,
+ state: ev.shippingAddress.region,
+ },
+ },
+ });
+ if (availableCarriers.length === 0) {
+ ev.updateWith({status: 'invalid_shipping_address'});
+ } else {
+ ev.updateWith({
+ status: 'success',
+ shippingOptions: availableCarriers.map(carrier => ({
+ id: String(carrier.id),
+ label: carrier.name,
+ detail: carrier.description ? carrier.description:"",
+ amount: carrier.minorAmount,
+ })),
+ total: {
+ label: this.txContext.merchantName,
+ amount: this.txContext.minorAmount + availableCarriers[0].minorAmount,
+ pending: false,
+ },
+ displayItems: [
+ {
+ label: _t("Your order"),
+ amount: this.txContext.minorAmount,
+ },
+ {
+ label: _t("Delivery"),
+ amount: availableCarriers[0].minorAmount,
+ },
+ ],
+ });
+ }
+ });
+
+ // When the customer selects a different shipping option, update the displayed total.
+ this.stripeExpressCheckoutForm.on('shippingoptionchange', async (ev) => {
+ ev.updateWith({
+ status: 'success',
+ total: {
+ label: this.txContext.merchantName,
+ amount: this.txContext.minorAmount + ev.shippingOption.amount,
+ pending: false,
+ },
+ displayItems: [
+ {
+ label: _t("Your order"),
+ amount: this.txContext.minorAmount,
+ },
+ {
+ label: _t("Delivery"),
+ amount: ev.shippingOption.amount, // Already in minor amount.
+ },
+ ],
+ });
+ });
+ }
+ },
+
+ /**
+ * Update the amount of the express checkout form.
+ *
+ * @override method from payment.express_form
+ * @private
+ * @param {Object} acquirerData - The acquirer-specific data.
+ * @param {number} newAmount - The new amount.
+ * @param {number} newMinorAmount - The new minor amount.
+ * @return {undefined}
+ */
+ _updateAmount(acquirerData, newAmount, newMinorAmount) {
+ if (acquirerData.provider !== 'stripe') {
+ return this._super(...arguments);
+ }
+
+ this.txContext.amount = parseFloat(newAmount);
+ this.txContext.minorAmount = parseInt(newMinorAmount);
+ this.stripeExpressCheckoutForm.update({
+ total: {
+ amount: parseInt(newMinorAmount),
+ label: this.txContext.merchantName,
+ }
+ });
+ },
+});
diff --git a/addons/payment_stripe/static/src/js/payment_form.js b/addons/payment_stripe/static/src/js/payment_form.js
index efd1245b642..302e3553fd8 100644
--- a/addons/payment_stripe/static/src/js/payment_form.js
+++ b/addons/payment_stripe/static/src/js/payment_form.js
@@ -45,4 +45,5 @@ odoo.define('payment_stripe.payment_form', require => {
checkoutForm.include(stripeMixin);
manageForm.include(stripeMixin);
+ return stripeMixin;
});
diff --git a/addons/payment_stripe/views/payment_stripe_templates.xml b/addons/payment_stripe/views/payment_stripe_templates.xml
new file mode 100644
index 00000000000..8f09b866d13
--- /dev/null
+++ b/addons/payment_stripe/views/payment_stripe_templates.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
diff --git a/addons/payment_stripe/views/payment_templates.xml b/addons/payment_stripe/views/payment_templates.xml
index 006ae23d5f0..e9cf94858c0 100644
--- a/addons/payment_stripe/views/payment_templates.xml
+++ b/addons/payment_stripe/views/payment_templates.xml
@@ -3,18 +3,26 @@
-
-
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/payment_stripe/views/payment_views.xml b/addons/payment_stripe/views/payment_views.xml
index abe870da11c..017cf4758f5 100644
--- a/addons/payment_stripe/views/payment_views.xml
+++ b/addons/payment_stripe/views/payment_views.xml
@@ -39,6 +39,17 @@
+
+
+
+
+
+
+
diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py
index 32e1964a317..3d6e4e76e87 100644
--- a/addons/website_sale/controllers/main.py
+++ b/addons/website_sale/controllers/main.py
@@ -13,6 +13,7 @@ from odoo.fields import Command
from odoo.http import request
from odoo.addons.base.models.ir_qweb_fields import nl2br
from odoo.addons.http_routing.models.ir_http import slug
+from odoo.addons.payment import utils as payment_utils
from odoo.addons.payment.controllers import portal as payment_portal
from odoo.addons.payment.controllers.post_processing import PaymentPostProcessing
from odoo.addons.website.controllers.main import QueryURL
@@ -149,6 +150,19 @@ class Website(main.Website):
}
class WebsiteSale(http.Controller):
+ _express_checkout_route = '/shop/express'
+
+ WRITABLE_PARTNER_FIELDS = [
+ 'name',
+ 'email',
+ 'phone',
+ 'street',
+ 'street2',
+ 'city',
+ 'zip',
+ 'country_id',
+ 'state_id',
+ ]
def _get_search_order(self, post):
# OrderBy will be parsed in orm and so no direct sql injection
@@ -725,6 +739,7 @@ class WebsiteSale(http.Controller):
if order:
order.order_line.filtered(lambda l: not l.product_id.active).unlink()
values['suggested_products'] = order._cart_accessories()
+ values.update(self._get_express_shop_payment_values(order))
if post.get('type') == 'popover':
# force no-cache so IE11 doesn't cache this XHR
@@ -808,6 +823,10 @@ class WebsiteSale(http.Controller):
return values
values['cart_quantity'] = order.cart_quantity
+ values['minor_amount'] = payment_utils.to_minor_currency_units(
+ order.amount_total, order.currency_id
+ ),
+ values['amount'] = order.amount_total
if not display:
return values
@@ -988,8 +1007,12 @@ class WebsiteSale(http.Controller):
Partner.browse(partner_id).sudo().write(checkout)
return partner_id
- def values_preprocess(self, order, mode, values):
- # Convert the values for many2one fields to integer since they are used as IDs
+ def values_preprocess(self, values):
+ """ Convert the values for many2one fields to integer since they are used as IDs.
+
+ :param dict values: partner fields to pre-process.
+ :return dict: partner fields pre-processed.
+ """
partner_fields = request.env['res.partner']._fields
return {
k: (bool(v) and int(v)) if k in partner_fields and partner_fields[k].type == 'many2one' else v
@@ -1069,7 +1092,7 @@ class WebsiteSale(http.Controller):
# IF POSTED
if 'submitted' in kw and request.httprequest.method == "POST":
- pre_values = self.values_preprocess(order, mode, kw)
+ pre_values = self.values_preprocess(kw)
errors, error_msg = self.checkout_form_validate(mode, kw, pre_values)
post, errors, error_msg = self.values_postprocess(order, mode, pre_values, errors, error_msg)
@@ -1117,6 +1140,143 @@ class WebsiteSale(http.Controller):
render_values.update(self._get_country_related_render_values(kw, render_values))
return request.render("website_sale.address", render_values)
+ @http.route(
+ _express_checkout_route, type='json', methods=['POST'], auth="public", website=True,
+ sitemap=False
+ )
+ def process_express_checkout(self, billing_address):
+ """ Records the partner information on the order when using express checkout flow.
+
+ Depending on whether the partner is registered and logged in, either creates a new partner
+ or uses an existing one that matches all received data.
+
+ :param dict billing_address: billing information sent by the express payment form.
+ :return int: The order's partner id.
+ """
+ order_sudo = request.website.sale_get_order()
+ public_partner = request.website.partner_id
+
+ # Update the partner with all the information
+ self._include_country_and_state_in_address(billing_address)
+
+ if order_sudo.partner_id == public_partner:
+ billing_partner_id = self._create_or_edit_partner(billing_address, type='invoice')
+ order_sudo.partner_id = billing_partner_id
+ # Pricelist are recomputed every time the partner is changed. We don't want to recompute
+ # the price with another pricelist at this state since the customer has already accepted
+ # the amount and validated the payment.
+ order_sudo.env.remove_to_compute(
+ order_sudo.env['sale.order']._fields['pricelist_id'], order_sudo
+ )
+ order_sudo.message_partner_ids = request.env['res.partner'].browse(billing_partner_id)
+ elif any(billing_address[k] != order_sudo.partner_invoice_id[k] for k in billing_address):
+ # Check if a child partner doesn't already exist with the same informations. The
+ # phone isn't always checked because it isn't sent in shipping information with
+ # Google Pay.
+ child_partner_id = self._find_child_partner(
+ order_sudo.partner_id.commercial_partner_id.id, billing_address
+ )
+ if child_partner_id:
+ order_sudo.partner_invoice_id = child_partner_id
+ else:
+ billing_partner_id = self._create_or_edit_partner(
+ billing_address,
+ type='invoice',
+ parent_id=order_sudo.partner_id.id,
+ )
+ order_sudo.partner_invoice_id = billing_partner_id
+
+ # In a non-express flow, `sale_last_order_id` would be added in the session before the
+ # payment. As we skip all the steps with the express checkout, `sale_last_order_id` must be
+ # assigned to ensure the right behavior from `shop_payment_confirmation()`.
+ request.session['sale_last_order_id'] = order_sudo.id
+
+ return order_sudo.partner_id.id
+
+ def _find_child_partner(self, commercial_partner_id, address):
+ """ Find a child partner for a specified address
+
+ Compare all keys in the `address` dict with the same keys on the partner object and return
+ the id of the first partner that have the same value than in the dict for all the keys.
+
+ :param int commercial_partner_id: commercial partner for whom we need to find his children.
+ :param dict address: dictionary of address fields.
+ :return int: id of the first child partner that match the criteria, if any.
+ """
+ partners_sudo = request.env['res.partner'].with_context(show_address=1).sudo().search([
+ ('id', 'child_of', commercial_partner_id),
+ ])
+ for partner_sudo in partners_sudo:
+ if all(address[k] == partner_sudo[k] for k in address):
+ return partner_sudo.id
+ return False
+
+ def _include_country_and_state_in_address(self, address):
+ """ This function is used to include country_id and state_id in address.
+
+ Fetch country and state and include the records in address. The object is included to
+ simplify the comparison of addresses.
+
+ :param dict address: An address with country and state defined in ISO 3166.
+ :return None:
+ """
+ country = request.env["res.country"].search([
+ ('code', '=', address.pop('country')),
+ ], limit=1)
+ state = request.env["res.country.state"].search([
+ ('code', '=', address.pop('state')),
+ ], limit=1)
+ address.update(country_id=country, state_id=state)
+
+ def _create_or_edit_partner(self, partner_details, edit=False, **custom_values):
+ """ Create or update a partner
+
+ To create a partner, this controller usually calls `values_preprocess()`, then
+ `checkout_form_validate()`, then `values_postprocess()` and finally `_checkout_form_save()`.
+ Since these methods are very specific to the checkout form, this method makes it possible to
+ create a partner for more specific flows like express payment, which does not require all
+ the checks carried out by the previous methods. Parts of code in this method come from those.
+
+ :param dict partner_details: The values needed to create the partner or to edit the partner.
+ :param bool edit: Whether edit an existing partner or create one, defaults to False.
+ :param dict custom_values: Optional custom values for the creation or edition.
+ :return int: The id of the partner created or edited
+ """
+ values = self.values_preprocess(partner_details)
+
+ # Ensure that we won't write on unallowed fields.
+ sanitized_values = {
+ k: v for k, v in values.items() if k in self.WRITABLE_PARTNER_FIELDS
+ }
+ sanitized_custom_values = {
+ k: v for k, v in custom_values.items()
+ if k in self.WRITABLE_PARTNER_FIELDS + ['partner_id', 'parent_id', 'type']
+ }
+
+ if request.website.specific_user_account:
+ sanitized_values['website_id'] = request.website.id
+
+ lang = request.lang.code if request.lang.code in request.website.mapped(
+ 'language_ids.code'
+ ) else None
+ if lang:
+ sanitized_values['lang'] = lang
+
+ partner_id = sanitized_custom_values.get('partner_id')
+ if edit and partner_id:
+ request.env['res.partner'].browse(partner_id).sudo().write(sanitized_values)
+ else:
+ sanitized_values = dict(sanitized_values, **{
+ 'company_id': request.website.company_id.id,
+ 'team_id': request.website.salesteam_id and request.website.salesteam_id.id,
+ 'user_id': request.website.salesperson_id.id,
+ **sanitized_custom_values
+ })
+ partner_id = request.env['res.partner'].sudo().with_context(
+ tracking_disable=True
+ ).create(sanitized_values).id
+ return partner_id
+
def _get_country_related_render_values(self, kw, render_values):
'''
This method provides fields related to the country to render the website sale form
@@ -1217,6 +1377,39 @@ class WebsiteSale(http.Controller):
# Payment
# ------------------------------------------------------
+ def _get_express_shop_payment_values(self, order, **kwargs):
+ logged_in = not request.website.is_public_user()
+ acquirers_sudo = request.env['payment.acquirer'].sudo()._get_compatible_acquirers(
+ order.company_id.id,
+ order.partner_id.id,
+ order.amount_total,
+ currency_id=order.currency_id.id,
+ is_express_checkout=True,
+ sale_order_id=order.id,
+ website_id=request.website.id,
+ ) # In sudo mode to read the fields of acquirers, order and partner (if not logged in)
+ fees_by_acquirer = {
+ acq_sudo: acq_sudo._compute_fees(
+ order.amount_total, order.currency_id, order.partner_id.country_id
+ ) for acq_sudo in acquirers_sudo.filtered('fees_active')
+ }
+ return {
+ # Payment express form values
+ 'acquirers_sudo': acquirers_sudo,
+ 'fees_by_acquirer': fees_by_acquirer,
+ 'amount': order.amount_total,
+ 'minor_amount': payment_utils.to_minor_currency_units(
+ order.amount_total, order.currency_id
+ ),
+ 'merchant_name': request.website.name,
+ 'currency': order.currency_id,
+ 'partner_id': order.partner_id.id if logged_in else -1,
+ 'payment_access_token': order._portal_ensure_token(),
+ 'transaction_route': f'/shop/payment/transaction/{order.id}',
+ 'express_route': self._express_checkout_route,
+ 'landing_route': '/shop/payment/validate',
+ }
+
def _get_shop_payment_values(self, order, **kwargs):
logged_in = not request.env.user._is_public()
acquirers_sudo = request.env['payment.acquirer'].sudo()._get_compatible_acquirers(
@@ -1310,8 +1503,8 @@ class WebsiteSale(http.Controller):
assert order.id == request.session.get('sale_last_order_id')
if transaction_id:
- tx = request.env['payment.transaction'].sudo().browse(transaction_id)
- assert tx in order.transaction_ids()
+ tx = request.env['payment.transaction'].sudo().browse(int(transaction_id))
+ assert tx in order.transaction_ids
elif order:
tx = order.get_portal_last_transaction()
else:
@@ -1523,6 +1716,8 @@ class PaymentPortal(payment_portal.PaymentPortal):
'sale_order_id': order_id, # Include the SO to allow Subscriptions to tokenize the tx
})
kwargs.pop('custom_create_values', None) # Don't allow passing arbitrary create values
+ if not kwargs.get('amount'):
+ kwargs['amount'] = order_sudo.amount_total
tx_sudo = self._create_transaction(
custom_create_values={'sale_order_ids': [Command.set([order_id])]}, **kwargs,
)
@@ -1534,6 +1729,8 @@ class PaymentPortal(payment_portal.PaymentPortal):
if last_tx:
PaymentPostProcessing.remove_transactions(last_tx)
request.session['__website_sale_last_tx_id'] = tx_sudo.id
+ if kwargs.pop('add_id_to_landing_route', False):
+ tx_sudo.landing_route += f'?transaction_id={tx_sudo.id}'
self._validate_transaction_for_order(tx_sudo, order_id)
diff --git a/addons/website_sale/static/src/js/website_sale.js b/addons/website_sale/static/src/js/website_sale.js
index 6c538a238f8..9266bf10d41 100644
--- a/addons/website_sale/static/src/js/website_sale.js
+++ b/addons/website_sale/static/src/js/website_sale.js
@@ -386,6 +386,8 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, car
wSaleUtils.updateCartNavBar(data);
wSaleUtils.showWarning(data.warning);
+ // Propagating the change to the express checkout forms
+ core.bus.trigger('cart_amount_changed', data.amount, data.minor_amount);
});
},
/**
diff --git a/addons/website_sale/static/src/js/website_sale_utils.js b/addons/website_sale/static/src/js/website_sale_utils.js
index e942d9e88de..956b190ff9c 100644
--- a/addons/website_sale/static/src/js/website_sale_utils.js
+++ b/addons/website_sale/static/src/js/website_sale_utils.js
@@ -110,7 +110,7 @@ function updateCartNavBar(data) {
});
$(".js_cart_lines").first().before(data['website_sale.cart_lines']).end().remove();
- $(".js_cart_summary").first().before(data['website_sale.short_cart_summary']).end().remove();
+ $(".js_cart_summary").replaceWith(data['website_sale.short_cart_summary']);
}
/**
diff --git a/addons/website_sale/tests/__init__.py b/addons/website_sale/tests/__init__.py
index be7117e8f82..699be578b07 100644
--- a/addons/website_sale/tests/__init__.py
+++ b/addons/website_sale/tests/__init__.py
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_customize
+from . import test_express_checkout_flows
from . import test_sale_process
from . import test_sitemap
from . import test_website_sale_add_to_cart_snippet
diff --git a/addons/website_sale/tests/test_express_checkout_flows.py b/addons/website_sale/tests/test_express_checkout_flows.py
new file mode 100644
index 00000000000..b1a6119b4cc
--- /dev/null
+++ b/addons/website_sale/tests/test_express_checkout_flows.py
@@ -0,0 +1,192 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import json
+from uuid import uuid4
+from werkzeug import urls
+
+from odoo import Command
+from odoo.http import root
+from odoo.tests import tagged
+
+from odoo.addons.base.tests.common import HttpCaseWithUserDemo
+from odoo.addons.website_sale.controllers.main import WebsiteSale as WebsiteSaleController
+
+
+@tagged('at_install')
+class TestWebsiteSaleExpressCheckoutFlows(HttpCaseWithUserDemo):
+ """ The goal of this method class is to test the address management on
+ express checkout.
+ """
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.website = cls.env.ref('website.default_website')
+ cls.country_id = cls.env.ref('base.be').id
+ cls.sale_order = cls.env['sale.order'].create({
+ 'partner_id': cls.website.user_id.partner_id.id,
+ 'website_id': cls.website.id,
+ 'order_line': [Command.create({
+ 'product_id': cls.env['product.product'].create({
+ 'name': 'Product A',
+ 'list_price': 100,
+ 'website_published': True,
+ 'sale_ok': True}).id,
+ 'name': 'Product A',
+ })]
+ })
+ cls.express_checkout_billing_values = {
+ 'name': 'Express Checkout Partner',
+ 'email': 'express@check.out',
+ 'phone': '0000000000',
+ 'street': 'ooo',
+ 'street2': 'ppp',
+ 'city': 'ooo',
+ 'zip': '1200',
+ 'country': 'US',
+ 'state': 'AL',
+ }
+
+ def _make_json_rpc_request(self, url, data=None):
+ """ Make a JSON-RPC request to the provided URL.
+
+ :param str url: The URL to make the request to
+ :param dict data: The data to be send in the request body in JSON-RPC 2.0 format
+ :return dict: The result of the JSON-RPC request
+ """
+ rpc_request = {
+ "jsonrpc": "2.0",
+ "method": "call",
+ "id": str(uuid4()),
+ "params": data,
+ }
+
+ result = self.url_open(
+ url,
+ data=json.dumps(rpc_request).encode(),
+ headers={"Content-Type": "application/json"},
+ )
+
+ if not result.ok:
+ return {}
+
+ return result.json().get("result", {})
+
+ def test_express_checkout_public_user(self):
+ """ Test that when using express checkout as a public user, a new partner is created. """
+ session = self.authenticate(None, None)
+ session['sale_order_id'] = self.sale_order.id
+ root.session_store.save(session)
+
+ self._make_json_rpc_request(
+ urls.url_join(
+ self.base_url(), WebsiteSaleController._express_checkout_route
+ ), data={
+ 'billing_address': dict(self.express_checkout_billing_values)
+ }
+ )
+
+ new_partner = self.sale_order.partner_id
+ self.assertNotEqual(new_partner, self.website.user_id.partner_id)
+ for k in self.express_checkout_billing_values:
+ if k in ['state', 'country']:
+ # State and country are stored as ids in `new_partner` and therefore cannot be
+ # compared.
+ continue
+ self.assertEqual(new_partner[k], self.express_checkout_billing_values[k])
+
+ def test_express_checkout_registered_user(self):
+ """ Test that when you use express checkout as a registered user and the address sent by the
+ express checkout form exactly matches the one registered in odoo, we do not create a new
+ partner and reuse the existing one.
+ """
+ self.sale_order.partner_id = self.user_demo.partner_id.id
+ session = self.authenticate(self.user_demo.login, self.user_demo.login)
+ session['sale_order_id'] = self.sale_order.id
+ root.session_store.save(session)
+
+ self._make_json_rpc_request(
+ urls.url_join(
+ self.base_url(), WebsiteSaleController._express_checkout_route
+ ), data={
+ 'billing_address': {
+ 'name': self.user_demo.partner_id.name,
+ 'email': self.user_demo.partner_id.email,
+ 'phone': self.user_demo.partner_id.phone,
+ 'street': self.user_demo.partner_id.street,
+ 'street2': self.user_demo.partner_id.street2,
+ 'city': self.user_demo.partner_id.city,
+ 'zip': self.user_demo.partner_id.zip,
+ 'country': self.user_demo.partner_id.country_id.code,
+ 'state': self.user_demo.partner_id.state_id.code,
+ }
+ }
+ )
+
+ self.assertEqual(self.sale_order.partner_id.id, self.user_demo.partner_id.id)
+ self.assertEqual(self.sale_order.partner_invoice_id.id, self.user_demo.partner_id.id)
+
+ def test_express_checkout_registered_user_existing_address(self):
+ """ Test that when you use the express checkout as a registered user and the address sent by
+ the express checkout form exactly matches to one of the addresses linked to this user in
+ odoo, we do not create a new partner and reuse the existing one.
+ """
+ # Create a child partner for the demo partner
+ child_partner_address = dict(self.express_checkout_billing_values)
+ child_partner_country = self.env['res.country'].search([
+ ('code', '=', child_partner_address.pop('country')),
+ ], limit=1)
+ child_partner_state = self.env['res.country.state'].search([
+ ('code', '=', child_partner_address.pop('state')),
+ ], limit=1)
+ child_partner = self.env['res.partner'].create(dict(
+ **child_partner_address,
+ parent_id=self.user_demo.partner_id.id,
+ type='invoice',
+ country_id=child_partner_country.id,
+ state_id=child_partner_state.id,
+ ))
+
+ self.sale_order.partner_id = self.user_demo.partner_id.id
+ session = self.authenticate(self.user_demo.login, self.user_demo.login)
+ session['sale_order_id'] = self.sale_order.id
+ root.session_store.save(session)
+
+ self._make_json_rpc_request(
+ urls.url_join(
+ self.base_url(), WebsiteSaleController._express_checkout_route
+ ), data={
+ 'billing_address': dict(self.express_checkout_billing_values)
+ }
+ )
+
+ self.assertEqual(self.sale_order.partner_id.id, self.user_demo.partner_id.id)
+ self.assertEqual(self.sale_order.partner_invoice_id.id, child_partner.id)
+
+ def test_express_checkout_registered_user_new_address(self):
+ """ Test that when you use the express checkout as a registered user and the address sent by
+ the express checkout form doesn't match to one of the addresses linked to this user in
+ odoo, we create a new partner.
+ """
+ self.sale_order.partner_id = self.user_demo.partner_id.id
+ session = self.authenticate(self.user_demo.login, self.user_demo.login)
+ session['sale_order_id'] = self.sale_order.id
+ root.session_store.save(session)
+
+ self._make_json_rpc_request(
+ urls.url_join(
+ self.base_url(), WebsiteSaleController._express_checkout_route
+ ), data={
+ 'billing_address': dict(self.express_checkout_billing_values)
+ }
+ )
+
+ self.assertEqual(self.sale_order.partner_id.id, self.user_demo.partner_id.id)
+ new_partner = self.sale_order.partner_invoice_id
+ self.assertNotEqual(new_partner, self.website.user_id.partner_id)
+ for k in self.express_checkout_billing_values:
+ if k in ['state', 'country']:
+ # State and country are stored as ids in `new_partner` and therefore cannot be
+ # compared.
+ continue
+ self.assertEqual(new_partner[k], self.express_checkout_billing_values[k])
diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml
index ba709db7f60..60c511777e3 100644
--- a/addons/website_sale/views/templates.xml
+++ b/addons/website_sale/views/templates.xml
@@ -1204,30 +1204,30 @@
-
-
- Continue Shopping
-
-
- Process Checkout
-
-
+
diff --git a/addons/website_sale_delivery/controllers/main.py b/addons/website_sale_delivery/controllers/main.py
index 138c6f45fe4..2f8c5496bcb 100644
--- a/addons/website_sale_delivery/controllers/main.py
+++ b/addons/website_sale_delivery/controllers/main.py
@@ -3,11 +3,13 @@
from odoo import http, _
from odoo.http import request
+from odoo.addons.payment import utils as payment_utils
from odoo.addons.website_sale.controllers.main import WebsiteSale
from odoo.exceptions import UserError
class WebsiteSaleDelivery(WebsiteSale):
+ _express_checkout_shipping_route = '/shop/express/shipping_address_change'
@http.route()
def shop_payment(self, **post):
@@ -46,9 +48,176 @@ class WebsiteSaleDelivery(WebsiteSale):
res = {'carrier_id': carrier_id}
carrier = request.env['delivery.carrier'].sudo().browse(int(carrier_id))
- rate = carrier.rate_shipment(order)
+ rate = WebsiteSaleDelivery._get_rate(carrier, order)
if rate.get('success'):
- tax_ids = carrier.product_id.taxes_id.filtered(lambda t: t.company_id == order.company_id)
+ res['status'] = True
+ res['new_amount_delivery'] = Monetary.value_to_html(rate['price'], {'display_currency': order.currency_id})
+ res['is_free_delivery'] = not bool(rate['price'])
+ res['error_message'] = rate['warning_message']
+ else:
+ res['status'] = False
+ res['new_amount_delivery'] = Monetary.value_to_html(0.0, {'display_currency': order.currency_id})
+ res['error_message'] = rate['error_message']
+ return res
+
+ @http.route()
+ def cart(self, **post):
+ order = request.website.sale_get_order()
+ if order and order.carrier_id:
+ # Express checkout is based on the amout of the sale order. If there is already a
+ # delivery line, Express Checkout form will display and compute the price of the
+ # delivery two times (One already computed in the total amount of the SO and one added
+ # in the form while selecting the delivery carrier)
+ order._remove_delivery_line()
+
+ return super().cart(**post)
+
+ @http.route()
+ def process_express_checkout(self, shipping_address, shipping_option, **post):
+ """ Override of `website_sale` to records the shipping information on the order when using
+ express checkout flow.
+
+ Depending on whether the partner is registered and logged in, either creates a new partner
+ or uses an existing one that matches all received data.
+
+ :param dict shipping_address: shipping information sent by the express payment form.
+ :param dict shipping_option: carrier information sent by the express payment form.
+ :param dict post: other information used in the parent route.
+ :return int: The order's partner id.
+ """
+ order_sudo = request.website.sale_get_order()
+
+ # Update the partner with all the information
+ self._include_country_and_state_in_address(shipping_address)
+
+ # At this point, if the user is a public user, the order will have a partner created by
+ # `process_express_checkout_delivery_choice`. No need to check if he is connected or not.
+
+ if order_sudo.partner_shipping_id.name.endswith(order_sudo.name):
+ # The existing partner was created by `process_express_checkout_delivery_choice`, it
+ # means that the partner is missing information, so we update it.
+ order_sudo.partner_shipping_id = self._create_or_edit_partner(
+ shipping_address,
+ edit=True,
+ type='delivery',
+ partner_id=order_sudo.partner_shipping_id.id,
+ )
+ elif any(
+ shipping_address[k] != order_sudo.partner_shipping_id[k] for k in shipping_address
+ ):
+ # The sale order's shipping partner's address is different from the one received. If all
+ # the sale order's child partners' address differs from the one received, we create a
+ # new partner. The phone isn't always checked because it isn't sent in shipping
+ # information with Google Pay.
+ child_partner_id = self._find_child_partner(
+ order_sudo.partner_id.commercial_partner_id.id, shipping_address
+ )
+ if child_partner_id:
+ order_sudo.partner_shipping_id = child_partner_id
+ else:
+ order_sudo.partner_shipping_id = self._create_or_edit_partner(
+ shipping_address,
+ type='delivery',
+ parent_id=order_sudo.partner_id.id,
+ )
+
+ # Process the delivery carrier
+ order_sudo._check_carrier_quotation(force_carrier_id=int(shipping_option['id']))
+
+ return super().process_express_checkout(**post)
+
+ @http.route(
+ _express_checkout_shipping_route, type='json', auth='public', methods=['POST'],
+ website=True, sitemap=False
+ )
+ def express_checkout_process_shipping_address(self, partial_shipping_address):
+ """ Processes shipping address and returns available carriers.
+
+ Depending on whether the partner is registered and logged in or not, creates a new partner
+ or uses an existing partner that matches the partial shipping address received.
+
+ :param dict shipping_address: a dictionary containing part of shipping information sent by
+ the express payment provider.
+ :return dict: all available carriers for `shipping_address` sorted by lowest price.
+ """
+ order_sudo = request.website.sale_get_order()
+ public_partner = request.website.partner_id
+
+ self._include_country_and_state_in_address(partial_shipping_address)
+ if order_sudo.partner_id == public_partner:
+ # The partner_shipping_id and partner_invoice_id will be automatically computed when
+ # changing the partner_id of the SO. This allow website_sale to avoid create duplicates.
+ order_sudo.partner_id = self._create_or_edit_partner(
+ partial_shipping_address,
+ type='delivery',
+ name=_('Anonymous express checkout partner for order %s', order_sudo.name),
+ )
+ # Pricelist are recomputed every time the partner is changed. We don't want to recompute
+ # the price with another pricelist at this state since the customer has already accepted
+ # the amount and validated the payment.
+ order_sudo.env.remove_to_compute(
+ order_sudo._fields['pricelist_id'], order_sudo
+ )
+ elif order_sudo.partner_shipping_id.name.endswith(order_sudo.name):
+ self._create_or_edit_partner(
+ partial_shipping_address,
+ edit=True,
+ type='delivery',
+ partner_id=order_sudo.partner_shipping_id.id,
+ )
+ elif any(
+ partial_shipping_address[k] != order_sudo.partner_shipping_id[k]
+ for k in partial_shipping_address
+ ):
+ # Check if a child partner doesn't already exist with the same informations. The
+ # phone isn't always checked because it isn't sent in shipping information with
+ # Google Pay.
+ child_partner_id = self._find_child_partner(
+ order_sudo.partner_id.commercial_partner_id.id, partial_shipping_address
+ )
+ if child_partner_id:
+ order_sudo.partner_shipping_id = child_partner_id
+ else:
+ order_sudo.partner_shipping_id = self._create_or_edit_partner(
+ partial_shipping_address,
+ type='delivery',
+ parent_id=order_sudo.partner_id.id,
+ name=_('Anonymous express checkout partner for order %s', order_sudo.name),
+ )
+
+ # Returns the list of develivery carrier available for the sale order.
+ return sorted([{
+ 'id': carrier.id,
+ 'name': carrier.name,
+ 'description': carrier.website_description,
+ 'minorAmount': payment_utils.to_minor_currency_units(
+ WebsiteSaleDelivery._get_rate(carrier, order_sudo, is_express_checkout_flow=True)['price'],
+ order_sudo.currency_id,
+ ),
+ } for carrier in order_sudo._get_delivery_methods()],
+ key=lambda carrier: carrier['minorAmount'])
+
+ @staticmethod
+ def _get_rate(carrier, order, is_express_checkout_flow=False):
+ """ Compute the price of the order shipment and apply the taxes if relevant
+
+ :param recordset carrier: the carrier for which the rate is to be recovered
+ :param recordset order: the order for which the rate is to be recovered
+ :param boolean is_express_checkout_flow: Whether the flow is express checkout or not
+ :return dict: the rate, as returned in `rate_shipment()`
+ """
+ # Some delivery carriers check if all the required fields are available before computing the
+ # rate, even if those fields aren't required for computing the rate (although they are for
+ # delivering the goods). If we only have partial information about the delivery address but
+ # still want to compute the rate, this context key will ensure that we only check the
+ # required fields for a partial delivery address (city, zip, country_code, state_code).
+ rate = carrier.rate_shipment(order.with_context(
+ express_checkout_partial_delivery_address=is_express_checkout_flow
+ ))
+ if rate.get('success'):
+ tax_ids = carrier.product_id.taxes_id.filtered(
+ lambda t: t.company_id == order.company_id
+ )
if tax_ids:
fpos = order.fiscal_position_id
tax_ids = fpos.map_tax(tax_ids)
@@ -59,20 +228,13 @@ class WebsiteSaleDelivery(WebsiteSale):
product=carrier.product_id,
partner=order.partner_shipping_id,
)
- if request.env.user.has_group('account.group_show_line_subtotals_tax_excluded'):
+ if not is_express_checkout_flow and request.env.user.has_group(
+ 'account.group_show_line_subtotals_tax_excluded'
+ ):
rate['price'] = taxes['total_excluded']
else:
rate['price'] = taxes['total_included']
-
- res['status'] = True
- res['new_amount_delivery'] = Monetary.value_to_html(rate['price'], {'display_currency': order.currency_id})
- res['is_free_delivery'] = not bool(rate['price'])
- res['error_message'] = rate['warning_message']
- else:
- res['status'] = False
- res['new_amount_delivery'] = Monetary.value_to_html(0.0, {'display_currency': order.currency_id})
- res['error_message'] = rate['error_message']
- return res
+ return rate
def order_lines_2_google_api(self, order_lines):
""" Transforms a list of order lines into a dict for google analytics """
@@ -87,6 +249,11 @@ class WebsiteSaleDelivery(WebsiteSale):
ret['shipping'] = delivery_line.price_unit
return ret
+ def _get_express_shop_payment_values(self, order, **kwargs):
+ values = super(WebsiteSaleDelivery, self)._get_express_shop_payment_values(order, **kwargs)
+ values['shipping_address_update_route'] = self._express_checkout_shipping_route
+ return values
+
def _get_shop_payment_values(self, order, **kwargs):
values = super(WebsiteSaleDelivery, self)._get_shop_payment_values(order, **kwargs)
has_storable_products = any(line.product_id.type in ['consu', 'product'] for line in order.order_line)
diff --git a/addons/website_sale_delivery/static/src/js/express_checkout_form.js b/addons/website_sale_delivery/static/src/js/express_checkout_form.js
new file mode 100644
index 00000000000..b4178cf7ab2
--- /dev/null
+++ b/addons/website_sale_delivery/static/src/js/express_checkout_form.js
@@ -0,0 +1,14 @@
+/** @odoo-module **/
+
+import { paymentExpressCheckoutForm } from '@payment/js/express_checkout_form';
+
+paymentExpressCheckoutForm.include({
+ /**
+ * Return whether the shipping information is required or not
+ *
+ * @private
+ * @override method from payment.express_form
+ * @return {Boolean} - Whether the shipping information is required or not
+ */
+ _isShippingInformationRequired: () => true,
+});
diff --git a/addons/website_sale_delivery/tests/__init__.py b/addons/website_sale_delivery/tests/__init__.py
index 6b0e351c072..afa9885962d 100644
--- a/addons/website_sale_delivery/tests/__init__.py
+++ b/addons/website_sale_delivery/tests/__init__.py
@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from . import test_express_checkout_flows
from . import test_ui
from . import test_controller
diff --git a/addons/website_sale_delivery/tests/test_express_checkout_flows.py b/addons/website_sale_delivery/tests/test_express_checkout_flows.py
new file mode 100644
index 00000000000..e07bc433545
--- /dev/null
+++ b/addons/website_sale_delivery/tests/test_express_checkout_flows.py
@@ -0,0 +1,384 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import json
+from uuid import uuid4
+from unittest.mock import Mock, patch
+from werkzeug import urls
+
+from odoo import Command
+from odoo.http import root
+from odoo.tests import tagged
+
+from odoo.addons.base.tests.common import HttpCaseWithUserDemo
+from odoo.addons.website_sale_delivery.controllers.main import WebsiteSaleDelivery as WebsiteSaleDeliveryController
+
+
+@tagged('post_install', '-at_install')
+class TestWebsiteSaleDeliveryExpressCheckoutFlows(HttpCaseWithUserDemo):
+ """ The goal of this method class is to test the address management on
+ express checkout.
+ """
+
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.website = cls.env.ref('website.default_website')
+ cls.country_id = cls.env.ref('base.be').id
+ cls.sale_order = cls.env['sale.order'].create({
+ 'partner_id': cls.website.user_id.partner_id.id,
+ 'website_id': cls.website.id,
+ 'order_line': [Command.create({
+ 'product_id': cls.env['product.product'].create({
+ 'name': 'Product A',
+ 'list_price': 100,
+ 'website_published': True,
+ 'sale_ok': True}).id,
+ 'name': 'Product A',
+ })]
+ })
+ cls.rate_shipment_result = {
+ 'success': True,
+ 'price': 5.0,
+ 'warning_message': '',
+ }
+ cls.express_checkout_billing_values = {
+ 'name': 'Express Checkout Partner',
+ 'email': 'express@check.out',
+ 'phone': '0000000000',
+ 'street': 'ooo',
+ 'street2': 'ppp',
+ 'city': 'ooo',
+ 'zip': '1200',
+ 'country': 'US',
+ 'state': 'AL',
+ }
+ cls.express_checkout_shipping_values = {
+ 'name': 'Express Checkout Shipping Partner',
+ 'email': 'express_shipping@check.out',
+ 'phone': '1111111111',
+ 'street': 'ooo shipping',
+ 'street2': 'ppp shipping',
+ 'city': 'ooo shipping',
+ 'zip': '25781',
+ 'country': 'US',
+ 'state': 'WA',
+ }
+ cls.express_checkout_anonymized_shipping_values = {
+ 'city': 'ooo shipping',
+ 'zip': '25781',
+ 'country': 'US',
+ 'state': 'WA',
+ }
+ cls.express_checkout_anonymized_shipping_values_2 = {
+ 'city': 'ooo shipping 2',
+ 'zip': '91200',
+ 'country': 'US',
+ 'state': 'AL',
+ }
+
+ def setUp(self):
+ super().setUp()
+ self.express_checkout_demo_shipping_values = {
+ 'name': self.user_demo.partner_id.name,
+ 'email': self.user_demo.partner_id.email,
+ 'phone': self.user_demo.partner_id.phone,
+ 'street': self.user_demo.partner_id.street,
+ 'street2': self.user_demo.partner_id.street2,
+ 'city': self.user_demo.partner_id.city,
+ 'zip': self.user_demo.partner_id.zip,
+ 'country': self.user_demo.partner_id.country_id.code,
+ 'state': self.user_demo.partner_id.state_id.code,
+ }
+ self.express_checkout_anonymized_demo_shipping_values = {
+ 'city': self.user_demo.partner_id.city,
+ 'zip': self.user_demo.partner_id.zip,
+ 'country': self.user_demo.partner_id.country_id.code,
+ 'state': self.user_demo.partner_id.state_id.code,
+ }
+ self.express_checkout_demo_shipping_values_2 = {
+ 'name': 'Express Checkout Shipping Partner',
+ 'email': 'express_shipping@check.out',
+ 'phone': '1111111111',
+ 'street': 'ooo shipping',
+ 'street2': 'ppp shipping',
+ 'city': self.user_demo.partner_id.city,
+ 'zip': self.user_demo.partner_id.zip,
+ 'country': self.user_demo.partner_id.country_id.code,
+ 'state': self.user_demo.partner_id.state_id.code,
+ }
+
+ def _make_json_rpc_request(self, url, data=None):
+ """ Make a JSON-RPC request to the provided URL.
+
+ :param str url: The URL to make the request to
+ :param dict data: The data to be send in the request body in JSON-RPC 2.0 format
+ :return dict: The result of the JSON-RPC request
+ """
+ rpc_request = {
+ "jsonrpc": "2.0",
+ "method": "call",
+ "id": str(uuid4()),
+ "params": data,
+ }
+
+ result = self.url_open(
+ url,
+ data=json.dumps(rpc_request).encode(),
+ headers={"Content-Type": "application/json"},
+ )
+
+ if not result.ok:
+ return {}
+
+ return result.json().get("result", {})
+
+ def test_express_checkout_public_user_shipping_address_change(self):
+ """ Test that when using express checkout as a public user and selecting a shipping address,
+ a new partner is created if the partner of the SO is the public partner.
+ """
+ session = self.authenticate(None, None)
+ session['sale_order_id'] = self.sale_order.id
+ root.session_store.save(session)
+ with patch(
+ 'odoo.addons.delivery.models.delivery_carrier.DeliveryCarrier.rate_shipment',
+ return_value=self.rate_shipment_result
+ ):
+ self._make_json_rpc_request(
+ urls.url_join(
+ self.base_url(), WebsiteSaleDeliveryController._express_checkout_shipping_route
+ ), data={
+ 'partial_shipping_address': dict(self.express_checkout_anonymized_shipping_values)
+ }
+ )
+ new_partner = self.sale_order.partner_shipping_id
+ self.assertNotEqual(new_partner, self.website.user_id.partner_id)
+ self.assertTrue(new_partner.name.endswith(self.sale_order.name))
+ for k in self.express_checkout_anonymized_shipping_values:
+ if k in ['state', 'country']:
+ # State and country are stored as ids in `new_partner` and therefore cannot be
+ # compared.
+ continue
+ self.assertEqual(
+ new_partner[k],
+ self.express_checkout_anonymized_shipping_values[k]
+ )
+
+ def test_express_checkout_public_user_shipping_address_change_twice(self):
+ """ Test that when using express checkout as a public user and selecting a shipping address
+ more than once, a new partner is created if the partner of the SO is the public partner
+ (only creates one new partner that is updated).
+ """
+ session = self.authenticate(None, None)
+ session['sale_order_id'] = self.sale_order.id
+ root.session_store.save(session)
+ with patch(
+ 'odoo.addons.delivery.models.delivery_carrier.DeliveryCarrier.rate_shipment',
+ return_value=self.rate_shipment_result
+ ):
+ self._make_json_rpc_request(
+ urls.url_join(
+ self.base_url(), WebsiteSaleDeliveryController._express_checkout_shipping_route
+ ), data={
+ 'partial_shipping_address': dict(self.express_checkout_anonymized_shipping_values)
+ }
+ )
+ new_partner = self.sale_order.partner_shipping_id
+ self._make_json_rpc_request(
+ urls.url_join(
+ self.base_url(), WebsiteSaleDeliveryController._express_checkout_shipping_route
+ ), data={
+ 'partial_shipping_address': dict(self.express_checkout_anonymized_shipping_values_2)
+ }
+ )
+ self.assertEqual(new_partner.id, self.sale_order.partner_shipping_id.id)
+ for k in self.express_checkout_anonymized_shipping_values_2:
+ if k in ['state', 'country']:
+ # State and country are stored as ids in `new_partner` and therefore cannot be
+ # compared.
+ continue
+ self.assertEqual(
+ new_partner[k],
+ self.express_checkout_anonymized_shipping_values_2[k]
+ )
+
+ def test_express_checkout_registered_user_exisiting_shipping_address_change(self):
+ """ Test that when using express checkout as a registered user and selecting an exisiting
+ shipping address, the existing partner (the one of the so) is reused.
+ """
+ self.sale_order.partner_id = self.user_demo.partner_id.id
+ session = self.authenticate(self.user_demo.login, self.user_demo.login)
+ session['sale_order_id'] = self.sale_order.id
+ root.session_store.save(session)
+ with patch(
+ 'odoo.addons.delivery.models.delivery_carrier.DeliveryCarrier.rate_shipment',
+ return_value=self.rate_shipment_result
+ ):
+ self._make_json_rpc_request(
+ urls.url_join(
+ self.base_url(), WebsiteSaleDeliveryController._express_checkout_shipping_route
+ ), data={
+ 'partial_shipping_address': dict(self.express_checkout_anonymized_shipping_values)
+ }
+ )
+ self.assertEqual(self.sale_order.partner_id.id, self.user_demo.partner_id.id)
+
+ def test_express_checkout_registered_user_new_shipping_address_change(self):
+ """ Test that when using express checkout as a registered user and selecting a new shipping
+ address, a new partner is created if the partner of the SO or his children are different
+ than the delivery information received.
+ """
+ self.sale_order.partner_id = self.user_demo.partner_id.id
+ session = self.authenticate(self.user_demo.login, self.user_demo.login)
+ session['sale_order_id'] = self.sale_order.id
+ root.session_store.save(session)
+ with patch(
+ 'odoo.addons.delivery.models.delivery_carrier.DeliveryCarrier.rate_shipment',
+ return_value=self.rate_shipment_result
+ ):
+ self._make_json_rpc_request(
+ urls.url_join(
+ self.base_url(), WebsiteSaleDeliveryController._express_checkout_shipping_route
+ ), data={
+ 'partial_shipping_address': dict(self.express_checkout_anonymized_shipping_values)
+ }
+ )
+ new_partner = self.sale_order.partner_shipping_id
+ self.assertEqual(self.sale_order.partner_id.id, self.user_demo.partner_id.id)
+ self.assertNotEqual(new_partner.id, self.user_demo.partner_id.id)
+ self.assertTrue(new_partner.name.endswith(self.sale_order.name))
+ for k in self.express_checkout_anonymized_shipping_values:
+ if k in ['state', 'country']:
+ # State and country are stored as ids in `new_partner` and therefore cannot be
+ # compared.
+ continue
+ self.assertEqual(
+ new_partner[k],
+ self.express_checkout_anonymized_shipping_values[k]
+ )
+
+ def test_express_checkout_registered_user_new_shipping_address_change_twice(self):
+ """ Test that when using express checkout as a registered user and selecting a new
+ shipping address more than once, a new partner is created if the partner of the SO is
+ the public partner (only creates one new partner that is updated).
+ """
+ self.sale_order.partner_id = self.user_demo.partner_id.id
+ session = self.authenticate(self.user_demo.login, self.user_demo.login)
+ session['sale_order_id'] = self.sale_order.id
+ root.session_store.save(session)
+ with patch(
+ 'odoo.addons.delivery.models.delivery_carrier.DeliveryCarrier.rate_shipment',
+ return_value=self.rate_shipment_result
+ ):
+ self._make_json_rpc_request(
+ urls.url_join(
+ self.base_url(), WebsiteSaleDeliveryController._express_checkout_shipping_route
+ ), data={
+ 'partial_shipping_address': dict(self.express_checkout_anonymized_shipping_values)
+ }
+ )
+ new_partner = self.sale_order.partner_shipping_id
+ self._make_json_rpc_request(
+ urls.url_join(
+ self.base_url(), WebsiteSaleDeliveryController._express_checkout_shipping_route
+ ), data={
+ 'partial_shipping_address': dict(self.express_checkout_anonymized_shipping_values_2)
+ }
+ )
+ self.assertEqual(new_partner.id, self.sale_order.partner_shipping_id.id)
+ for k in self.express_checkout_anonymized_shipping_values_2:
+ if k in ['state', 'country']:
+ # State and country are stored as ids in `new_partner` and therefore cannot be
+ # compared.
+ continue
+ self.assertEqual(
+ new_partner[k],
+ self.express_checkout_anonymized_shipping_values_2[k]
+ )
+
+ def test_express_checkout_partial_delivery_address_context_key(self):
+ """ Test that when using express checkout with only partial delivery information,
+ `express_checkout_partial_delivery_address` context key is in the context.
+ """
+ delivery_carrier_mock = Mock()
+ delivery_carrier_mock.rate_shipment = Mock(
+ # Since we didn't mock the product ids for the mocked carrier, we return an unsuccessful
+ # response to skip the part where the product ids are checked on the carrier.
+ return_value=dict(self.rate_shipment_result, **{'success': False})
+ )
+
+ WebsiteSaleDeliveryController._get_rate(
+ delivery_carrier_mock, self.sale_order, is_express_checkout_flow=True
+ )
+ sale_order = delivery_carrier_mock.rate_shipment.call_args[0][0]
+ self.assertTrue(sale_order._context.get('express_checkout_partial_delivery_address'))
+
+ def test_express_checkout_registered_user_with_shipping_option(self):
+ """ Test that when you use the express checkout as a registered user and the shipping
+ address sent by the express checkout form exactly matches to one of the addresses linked
+ to this user in odoo, we do not create a new partner and reuse the existing one.
+ """
+ self.sale_order.partner_id = self.user_demo.partner_id.id
+ session = self.authenticate(self.user_demo.login, self.user_demo.login)
+ session['sale_order_id'] = self.sale_order.id
+ root.session_store.save(session)
+ with patch(
+ 'odoo.addons.delivery.models.delivery_carrier.DeliveryCarrier.rate_shipment',
+ return_value=self.rate_shipment_result
+ ):
+ shipping_options = self._make_json_rpc_request(
+ urls.url_join(
+ self.base_url(), WebsiteSaleDeliveryController._express_checkout_shipping_route
+ ), data={
+ 'partial_shipping_address': dict(self.express_checkout_anonymized_demo_shipping_values)
+ }
+ )
+ self.assertEqual(self.sale_order.partner_id.id, self.user_demo.partner_id.id)
+
+ self._make_json_rpc_request(urls.url_join(self.base_url(), '/shop/express'), data={
+ 'billing_address': dict(self.express_checkout_billing_values),
+ 'shipping_address': dict(self.express_checkout_demo_shipping_values),
+ 'shipping_option': shipping_options[0],
+ })
+ self.assertEqual(self.sale_order.partner_id.id, self.user_demo.partner_id.id)
+
+ def test_express_checkout_registered_user_with_shipping_option_new_address(self):
+ """ Test that when you use the express checkout as a registered user and the shipping
+ address sent by the express checkout form doesn't exist in odoo, we create a new partner.
+ """
+ self.sale_order.partner_id = self.user_demo.partner_id.id
+ session = self.authenticate(self.user_demo.login, self.user_demo.login)
+ session['sale_order_id'] = self.sale_order.id
+ root.session_store.save(session)
+ with patch(
+ 'odoo.addons.delivery.models.delivery_carrier.DeliveryCarrier.rate_shipment',
+ return_value=self.rate_shipment_result
+ ):
+ # Won't create a new partner because the partial information are the same the an
+ # exisiting partner linked to the SO
+ shipping_options = self._make_json_rpc_request(
+ urls.url_join(
+ self.base_url(), WebsiteSaleDeliveryController._express_checkout_shipping_route
+ ), data={
+ 'partial_shipping_address': dict(self.express_checkout_anonymized_demo_shipping_values)
+ }
+ )
+ self.assertEqual(self.sale_order.partner_shipping_id, self.user_demo.partner_id)
+
+ # Will create a new partner because the complete shipping information are different than
+ # the partner actually selected.
+ self._make_json_rpc_request(
+ urls.url_join(
+ self.base_url(),
+ WebsiteSaleDeliveryController._express_checkout_route
+ ), data={
+ 'billing_address': dict(self.express_checkout_billing_values),
+ 'shipping_address': dict(self.express_checkout_demo_shipping_values_2),
+ 'shipping_option': shipping_options[0],
+ }
+ )
+ self.assertNotEqual(
+ self.sale_order.partner_shipping_id.id, self.user_demo.partner_id.id
+ )
+ self.assertFalse(
+ self.sale_order.partner_shipping_id.name.endswith(self.sale_order.name)
+ )