[IMP] payment(_stripe), website_sale(_delivery): support express checkout
This commit enables eCommerce customers to pay with the express payment methods Apple Pay and Google Pay from the cart page. For the moment, only Stripe supports this additional feature but it was designed to make it easy to implement with a new provider. task-2754209 closes odoo/odoo#88374 Related: odoo/enterprise#29915 Related: odoo/documentation#2392 Signed-off-by: Antoine Vandevenne (anv) <anv@odoo.com>
This commit is contained in:
committed by
Antoine Vandevenne (anv)
parent
7ceb079455
commit
c624ed5603
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -12,10 +12,12 @@
|
||||
<field name="support_fees" invisible="1"/>
|
||||
<field name="support_manual_capture" invisible="1"/>
|
||||
<field name="support_tokenization" invisible="1"/>
|
||||
<field name="support_express_checkout" invisible="1"/>
|
||||
<field name="module_id" invisible="1"/>
|
||||
<field name="module_state" invisible="1"/>
|
||||
<field name="module_to_buy" invisible="1"/>
|
||||
<field name="show_credentials_page" invisible="1"/>
|
||||
<field name="show_allow_express_checkout" invisible="1"/>
|
||||
<field name="show_allow_tokenization" invisible="1"/>
|
||||
<field name="show_payment_icon_ids" invisible="1"/>
|
||||
<field name="show_pre_msg" invisible="1"/>
|
||||
@@ -83,6 +85,7 @@
|
||||
<field name="payment_icon_ids" attrs="{'invisible': [('show_payment_icon_ids', '=', False)]}" widget="many2many_tags"/>
|
||||
<field name="allow_tokenization" attrs="{'invisible': ['|', ('support_tokenization', '=', False), ('show_allow_tokenization', '=', False)]}"/>
|
||||
<field name="capture_manually" attrs="{'invisible': [('support_manual_capture', '=', False)]}"/>
|
||||
<field name="allow_express_checkout" attrs="{'invisible': ['|', ('support_express_checkout', '=', False), ('show_allow_express_checkout', '=', False)]}"/>
|
||||
</group>
|
||||
<group string="Availability" name="availability">
|
||||
<field name="maximum_amount" widget="monetary" options="{'currency_field': 'main_currency_id'}"/>
|
||||
|
||||
@@ -292,6 +292,49 @@
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<!-- Express Checkout form -->
|
||||
<template id="express_checkout" name="Payment Express Checkout">
|
||||
<!-- Variables description:
|
||||
- 'acquirers' - The payment acquirers compatible with the current transaction.
|
||||
- 'reference_prefix' - The custom prefix to compute the full transaction reference.
|
||||
- 'amount' - The amount to pay.
|
||||
- 'minor_amount' - The amount to pay in the minor units of its currency.
|
||||
- 'currency' - The currency of the transaction, as a `res.currency` record.
|
||||
- 'merchant_name' - The merchant name.
|
||||
- 'access_token' - The access token used to authenticate the partner.
|
||||
- 'transaction_route' - The route used to create a transaction when the user clicks Pay.
|
||||
- 'express_route' - The route where the billing and shipping information are sent.
|
||||
- 'shipping_address_update_route' - The route where available carriers are computed
|
||||
based on the (partial) shipping information
|
||||
available. Optional
|
||||
- 'landing_route' - The route the user is redirected to after the transaction.
|
||||
-->
|
||||
<form name="o_payment_express_checkout_form"
|
||||
class="container"
|
||||
t-att-data-reference-prefix="reference_prefix"
|
||||
t-att-data-amount="amount"
|
||||
t-att-data-minor-amount="minor_amount"
|
||||
t-att-data-currency-id="currency and currency.id"
|
||||
t-att-data-currency-name="currency.name.lower()"
|
||||
t-att-data-merchant-name="merchant_name"
|
||||
t-att-data-partner-id="partner_id"
|
||||
t-att-data-access-token="payment_access_token"
|
||||
t-att-data-transaction-route="transaction_route"
|
||||
t-att-data-landing-route="landing_route"
|
||||
t-att-data-express-route="express_route"
|
||||
t-att-data-shipping-address-update-route="shipping_address_update_route">
|
||||
<t t-foreach="acquirers_sudo" t-as="acquirer_sudo">
|
||||
<t t-set="express_checkout_form_xml_id"
|
||||
t-value="acquirer_sudo.express_checkout_form_view_id.xml_id"/>
|
||||
<t t-if="express_checkout_form_xml_id">
|
||||
<t t-call="{{express_checkout_form_xml_id}}">
|
||||
<t t-set="acquirer_sudo" t-value="acquirer_sudo"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<!-- Expandable payment icon list -->
|
||||
<template id="icon_list" name="Payment Icon List">
|
||||
<ul class="payment_icon_list float-end list-inline" data-max-icons="3">
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
|
||||
<record id="payment.payment_acquirer_stripe" model="payment.acquirer">
|
||||
<field name="provider">stripe</field>
|
||||
<field name="express_checkout_form_view_id" ref="express_checkout_form"/>
|
||||
<field name="allow_tokenization">True</field>
|
||||
<field name="allow_express_checkout">True</field>
|
||||
</record>
|
||||
|
||||
<record id="payment_method_stripe" model="account.payment.method">
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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,
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -45,4 +45,5 @@ odoo.define('payment_stripe.payment_form', require => {
|
||||
checkoutForm.include(stripeMixin);
|
||||
manageForm.include(stripeMixin);
|
||||
|
||||
return stripeMixin;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<template id="express_checkout_form">
|
||||
<div name="o_express_checkout_container"
|
||||
t-attf-id="o_stripe_express_checkout_container_{{acquirer_sudo.id}}"
|
||||
t-att-data-acquirer-id="acquirer_sudo.id"
|
||||
t-att-data-provider="acquirer_sudo.provider"
|
||||
t-att-data-stripe-publishable-key="acquirer_sudo.stripe_publishable_key"
|
||||
t-att-data-country-code="acquirer_sudo.company_id.country_id.code"
|
||||
class="w-100 mt-2"/>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -3,18 +3,26 @@
|
||||
|
||||
<template id="checkout" inherit_id="payment.checkout">
|
||||
<xpath expr="." position="inside">
|
||||
<!-- As the following link does not end with '.js', it's not loaded when
|
||||
placed in __manifest__.py. The following declaration fix this problem -->
|
||||
<script type="text/javascript" src="https://js.stripe.com/v3/"></script>
|
||||
<t t-call="payment_stripe.sdk_assets"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="manage" inherit_id="payment.manage">
|
||||
<xpath expr="." position="inside">
|
||||
<!-- As the following link does not end with '.js', it's not loaded when
|
||||
placed in __manifest__.py. The following declaration fix this problem -->
|
||||
<script type="text/javascript" src="https://js.stripe.com/v3/"></script>
|
||||
<t t-call="payment_stripe.sdk_assets"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="express_checkout" inherit_id="payment.express_checkout">
|
||||
<xpath expr="." position="inside">
|
||||
<t t-call="payment_stripe.sdk_assets"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="sdk_assets">
|
||||
<!-- As the following link does not end with '.js', it's not loaded when
|
||||
placed in __manifest__.py. The following declaration fix this problem -->
|
||||
<script type="text/javascript" src="https://js.stripe.com/v3/"></script>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -39,6 +39,17 @@
|
||||
</a>
|
||||
</div>
|
||||
</xpath>
|
||||
<field name="allow_express_checkout" position="replace">
|
||||
<label for="allow_express_checkout"/>
|
||||
<div class="o_row" col="2">
|
||||
<field name="allow_express_checkout"/>
|
||||
<button string="Enable Apple Pay"
|
||||
type="object"
|
||||
name="action_stripe_verify_apple_pay_domain"
|
||||
class="btn btn-primary"
|
||||
attrs="{'invisible': [('allow_express_checkout', '!=', True)]}"/>
|
||||
</div>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
@@ -1204,30 +1204,30 @@
|
||||
</div>
|
||||
<t t-call="website_sale.cart_lines"/>
|
||||
<div class="clearfix" />
|
||||
<a role="button" href="/shop" class="btn btn-secondary mb32 d-none d-xl-inline-block">
|
||||
<span class="fa fa-chevron-left" />
|
||||
<span class="">Continue Shopping</span>
|
||||
</a>
|
||||
<a role="button" t-if="website_sale_order and website_sale_order.website_order_line" class="btn btn-primary float-end d-none d-xl-inline-block" href="/shop/checkout?express=1">
|
||||
<span class="">Process Checkout</span>
|
||||
<span class="fa fa-chevron-right" />
|
||||
</a>
|
||||
<div class="d-xl-none mb-2" id="o_cart_summary">
|
||||
<t t-call="website_sale.short_cart_summary"/>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<a role="button" href="/shop" class="btn btn-secondary mb32">
|
||||
<span class="fa fa-chevron-left" />
|
||||
<span>Continue<span class="d-none d-md-inline"> Shopping</span></span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="d-flex flex-column" t-if="website_sale_order and website_sale_order.website_order_line">
|
||||
<a role="button" class="btn btn-primary float-end" href="/shop/checkout?express=1">
|
||||
<span>Process Checkout</span>
|
||||
<span class="fa fa-chevron-right"/>
|
||||
</a>
|
||||
<t t-call="payment.express_checkout"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="oe_structure" id="oe_structure_website_sale_cart_1"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-4" id="o_cart_summary">
|
||||
<div class="d-none d-xl-block col-xl-4" id="o_cart_summary">
|
||||
<t t-call="website_sale.short_cart_summary"/>
|
||||
<div class="d-xl-none mt8">
|
||||
<a role="button" href="/shop" class="btn btn-secondary mb32">
|
||||
<span class="fa fa-chevron-left" />
|
||||
Continue<span class="d-none d-md-inline"> Shopping</span>
|
||||
</a>
|
||||
<a role="button" t-if="website_sale_order and website_sale_order.website_order_line" class="btn btn-primary float-end" href="/shop/checkout?express=1">
|
||||
<span class="">Process Checkout</span>
|
||||
<span class="fa fa-chevron-right" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1858,8 +1858,8 @@
|
||||
<t t-call="website_sale.payment_confirmation_status"/>
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<t t-set="same_shipping" t-value="bool(order.partner_shipping_id==order.partner_id or only_services)" />
|
||||
<div><b>Billing <t t-if="same_shipping and not only_services"> & Shipping</t>: </b><span t-esc='order.partner_id' t-options="dict(widget='contact', fields=['address'], no_marker=True, separator=', ')" class="address-inline"/></div>
|
||||
<t t-set="same_shipping" t-value="bool(order.partner_shipping_id==order.partner_invoice_id or only_services)" />
|
||||
<div><b>Billing <t t-if="same_shipping and not only_services"> & Shipping</t>: </b><span t-esc='order.partner_invoice_id' t-options="dict(widget='contact', fields=['address'], no_marker=True, separator=', ')" class="address-inline"/></div>
|
||||
<div t-if="not same_shipping and not only_services" groups="account.group_delivery_invoice_address"><b>Shipping: </b><span t-esc='order.partner_shipping_id' t-options="dict(widget='contact', fields=['address'], no_marker=True, separator=', ')" class="address-inline"/></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1882,7 +1882,7 @@
|
||||
|
||||
<template id="total">
|
||||
<div id="cart_total" t-att-class="extra_class or ''" t-if="website_sale_order and website_sale_order.website_order_line">
|
||||
<table class="table">
|
||||
<table class="table mb-0">
|
||||
<tr id="empty">
|
||||
<t t-if='not no_rowspan'><td rowspan="10" class="border-0"/></t>
|
||||
<td class="col-md-2 col-3 border-0"></td>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
Reference in New Issue
Block a user