From ac90aa077d2890339f199955c96b95e5269b48ea Mon Sep 17 00:00:00 2001 From: Valentin Chevalier Date: Fri, 2 Jun 2023 16:22:38 +0000 Subject: [PATCH] [IMP] payment stripe: migrate to a direct payment flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Reserve Bank of India (RBI) issued a directive (amended subsequently in December 2020 and March 2021) that introduces additional security measures for recurring payments on India-issued cards. These measures include: - Banks must register cardholders and create an e-mandate through a one-time process, using additional factor authentication (AFA) like 3D Secure (3DS). - Banks must alert cardholders at least 24 hours before charges take place and give them the ability to opt out of transactions. - Recurring transactions over 15,000 INR (or equivalent in other currencies) must go through AFA each time. Stripe has worked with a partner platform to support that, but we must manipulate the PaymentIntent and SetupIntent objects directly through their dedicated API, which the Checkout API does not allow. Therefore, we must now integrate with the Elements API and implement a direct payment flow instead of the current payment with a redirection flow powered by the Checkout API. After this commit, Stripe will create an e-Mandate for every Indian-based card newly saved in Odoo. task-3322020 closes odoo/odoo#123573 Related: odoo/documentation#4719 Related: odoo/upgrade#4748 Related: odoo/enterprise#42196 Signed-off-by: Antoine Vandevenne (anv) --- addons/payment/models/payment_transaction.py | 14 + addons/payment_stripe/__manifest__.py | 4 +- addons/payment_stripe/const.py | 54 ++- addons/payment_stripe/controllers/main.py | 99 ++-- .../data/payment_provider_data.xml | 1 + .../payment_stripe/models/payment_provider.py | 49 +- addons/payment_stripe/models/payment_token.py | 1 + .../models/payment_transaction.py | 440 ++++++++---------- .../static/src/js/checkout_form.js | 41 ++ .../static/src/js/manage_form.js | 37 ++ .../static/src/js/payment_form.js | 37 -- .../static/src/js/stripe_mixin.js | 175 +++++++ addons/payment_stripe/tests/test_stripe.py | 29 +- .../views/payment_stripe_templates.xml | 16 +- addons/sale/views/sale_portal_templates.xml | 5 +- 15 files changed, 629 insertions(+), 373 deletions(-) create mode 100644 addons/payment_stripe/static/src/js/checkout_form.js create mode 100644 addons/payment_stripe/static/src/js/manage_form.js delete mode 100644 addons/payment_stripe/static/src/js/payment_form.js create mode 100644 addons/payment_stripe/static/src/js/stripe_mixin.js diff --git a/addons/payment/models/payment_transaction.py b/addons/payment/models/payment_transaction.py index 48bdce92064..8ff39337395 100644 --- a/addons/payment/models/payment_transaction.py +++ b/addons/payment/models/payment_transaction.py @@ -516,6 +516,20 @@ class PaymentTransaction(models.Model): """ return dict() + def _get_mandate_values(self): + """ Return a dict of module-specific values used to create a mandate. + + For a module to add its own mandate values, it must overwrite this method and return a dict + of module-specific values. + + Note: `self.ensure_one()` + + :return: The dict of module-specific mandate values. + :rtype: dict + """ + self.ensure_one() + return dict() + def _send_payment_request(self): """ Request the provider handling the transaction to make the payment. diff --git a/addons/payment_stripe/__manifest__.py b/addons/payment_stripe/__manifest__.py index 955ac84d34a..3ef59eb4e3f 100644 --- a/addons/payment_stripe/__manifest__.py +++ b/addons/payment_stripe/__manifest__.py @@ -18,8 +18,10 @@ 'uninstall_hook': 'uninstall_hook', 'assets': { 'web.assets_frontend': [ + 'payment_stripe/static/src/js/checkout_form.js', 'payment_stripe/static/src/js/express_checkout_form.js', - 'payment_stripe/static/src/js/payment_form.js', + 'payment_stripe/static/src/js/manage_form.js', + 'payment_stripe/static/src/js/stripe_mixin.js', 'payment_stripe/static/src/js/stripe_options.js', ], }, diff --git a/addons/payment_stripe/const.py b/addons/payment_stripe/const.py index b178eef12f6..0fc0cb4b440 100644 --- a/addons/payment_stripe/const.py +++ b/addons/payment_stripe/const.py @@ -1,29 +1,52 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. -from collections import namedtuple - - API_VERSION = '2019-05-16' # The API version of Stripe implemented in this module # Stripe proxy URL PROXY_URL = 'https://stripe.api.odoo.com/api/stripe/' -# Support payment method types -PMT = namedtuple('PaymentMethodType', ['name', 'countries', 'currencies', 'recurrence']) -PAYMENT_METHOD_TYPES = [ - PMT('card', [], [], 'recurring'), - PMT('ideal', ['nl'], ['eur'], 'punctual'), - PMT('bancontact', ['be'], ['eur'], 'punctual'), - PMT('eps', ['at'], ['eur'], 'punctual'), - PMT('giropay', ['de'], ['eur'], 'punctual'), - PMT('p24', ['pl'], ['eur', 'pln'], 'punctual'), -] +# The payment methods for which Stripe supports tokenization. +# See https://stripe.com/docs/payments/payment-methods/integration-options. +PAYMENT_METHODS_TOKENIZATION_SUPPORT = { + 'acss_debit': True, + 'affirm': False, + 'afterpay_clearpay': False, + 'alipay': False, + 'apple_pay': True, + 'au_becs_debit': True, + 'bacs_debit': False, # Stripe doesn't support saving BACS with setupIntent. + 'bancontact': True, + 'blik': False, + 'boleto': True, + 'card': True, + 'cashapp': True, + 'customer_balance': False, + 'eps': False, + 'fpx': False, + 'giropay': False, + 'google_pay': True, + 'grabpay': False, + 'ideal': True, + 'klarna': False, + 'konbini': False, + 'link': True, + 'mobilepay': False, + 'oxxo': False, + 'p24': False, + 'paynow': False, + 'paypal': True, + 'promptpay': False, + 'sepa_debit': True, + 'sofort': True, + 'us_bank_account': True, + 'wechat_pay': False, + 'zip': False, +} -# Mapping of transaction states to Stripe objects ({Payment,Setup}Intent, Charge, Refund) statuses. +# Mapping of transaction states to Stripe objects ({Payment,Setup}Intent, Refund) statuses. # For each object's exhaustive status list, see: # https://stripe.com/docs/api/payment_intents/object#payment_intent_object-status # https://stripe.com/docs/api/setup_intents/object#setup_intent_object-status -# https://stripe.com/docs/api/charges/object#charge_object-status # https://stripe.com/docs/api/refunds/object#refund_object-status STATUS_MAPPING = { 'draft': ('requires_confirmation', 'requires_action'), @@ -36,6 +59,7 @@ STATUS_MAPPING = { # Events which are handled by the webhook HANDLED_WEBHOOK_EVENTS = [ + 'payment_intent.processing', 'payment_intent.amount_capturable_updated', 'payment_intent.succeeded', 'payment_intent.payment_failed', diff --git a/addons/payment_stripe/controllers/main.py b/addons/payment_stripe/controllers/main.py index 84c3630e5dd..53c0ced248f 100644 --- a/addons/payment_stripe/controllers/main.py +++ b/addons/payment_stripe/controllers/main.py @@ -2,7 +2,6 @@ import hashlib import hmac -import json import logging import pprint from datetime import datetime @@ -23,73 +22,60 @@ _logger = logging.getLogger(__name__) class StripeController(http.Controller): - _checkout_return_url = '/payment/stripe/checkout_return' - _validation_return_url = '/payment/stripe/validation_return' + _return_url = '/payment/stripe/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) - def stripe_return_from_checkout(self, **data): - """ Process the notification data sent by Stripe after redirection from checkout. + @http.route(_return_url, type='http', methods=['GET'], auth='public') + def stripe_return(self, **data): + """ Process the notification data sent by Stripe after redirection from payment. - :param dict data: The GET params appended to the URL in `_stripe_create_checkout_session` + Customers go through this route regardless of whether the payment was direct or with + redirection to Stripe or to an external service (e.g., for strong authentication). + + :param dict data: The notification data, including the reference appended to the URL in + `_get_specific_processing_values`. """ - # Retrieve the tx based on the tx reference included in the return url + # Retrieve the transaction based on the reference included in the return url. tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data( 'stripe', data ) - # Fetch the PaymentIntent, Charge and PaymentMethod objects from Stripe - payment_intent = tx_sudo.provider_id._stripe_make_request( - f'payment_intents/{tx_sudo.stripe_payment_intent}', method='GET' - ) - _logger.info("received payment_intents response:\n%s", pprint.pformat(payment_intent)) - self._include_payment_intent_in_notification_data(payment_intent, data) + if tx_sudo.operation != 'validation': + # Fetch the PaymentIntent and PaymentMethod objects from Stripe. + payment_intent = tx_sudo.provider_id._stripe_make_request( + f'payment_intents/{data.get("payment_intent")}', + payload={'expand[]': 'payment_method'}, # Expand all required objects. + method='GET', + ) + _logger.info("Received payment_intents response:\n%s", pprint.pformat(payment_intent)) + self._include_payment_intent_in_notification_data(payment_intent, data) + else: + # Fetch the SetupIntent and PaymentMethod objects from Stripe. + setup_intent = tx_sudo.provider_id._stripe_make_request( + f'setup_intents/{data.get("setup_intent")}', + payload={'expand[]': 'payment_method'}, # Expand all required objects. + method='GET', + ) + _logger.info("Received setup_intents response:\n%s", pprint.pformat(setup_intent)) + self._include_setup_intent_in_notification_data(setup_intent, data) - # Handle the notification data crafted with Stripe API objects + # Handle the notification data crafted with Stripe API's objects. tx_sudo._handle_notification_data('stripe', data) - # Redirect the user to the status page - return request.redirect('/payment/status') - - @http.route(_validation_return_url, type='http', auth='public', csrf=False) - def stripe_return_from_validation(self, **data): - """ Process the notification data sent by Stripe after redirection for validation. - - :param dict data: The GET params appended to the URL in `_stripe_create_checkout_session` - """ - # Retrieve the transaction based on the tx reference included in the return url - tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data( - 'stripe', data - ) - - # Fetch the Session, SetupIntent and PaymentMethod objects from Stripe - checkout_session = tx_sudo.provider_id._stripe_make_request( - f'checkout/sessions/{data.get("checkout_session_id")}', - payload={'expand[]': 'setup_intent.payment_method'}, # Expand all required objects - method='GET' - ) - _logger.info("received checkout/session response:\n%s", pprint.pformat(checkout_session)) - self._include_setup_intent_in_notification_data( - checkout_session.get('setup_intent', {}), data - ) - - # Handle the notification data crafted with Stripe API objects - tx_sudo._handle_notification_data('stripe', data) - - # Redirect the user to the status page + # Redirect the user to the status page. return request.redirect('/payment/status') @http.route(_webhook_url, type='http', methods=['POST'], auth='public', csrf=False) def stripe_webhook(self): """ Process the notification data sent by Stripe to the webhook. - :return: An empty string to acknowledge the notification + :return: An empty string to acknowledge the notification. :rtype: str """ event = request.get_json_data() - _logger.info("notification received from Stripe with data:\n%s", pprint.pformat(event)) + _logger.info("Notification received from Stripe with data:\n%s", pprint.pformat(event)) try: if event['type'] in HANDLED_WEBHOOK_EVENTS: stripe_object = event['data']['object'] # {Payment,Setup}Intent, Charge, or Refund. @@ -107,6 +93,14 @@ class StripeController(http.Controller): # Handle the notification data. if event['type'].startswith('payment_intent'): # Payment operation. + if tx_sudo.tokenize: + payment_method = tx_sudo.provider_id._stripe_make_request( + f'payment_methods/{stripe_object["payment_method"]}', method='GET' + ) + _logger.info( + "Received payment_methods response:\n%s", pprint.pformat(payment_method) + ) + stripe_object['payment_method'] = payment_method self._include_payment_intent_in_notification_data(stripe_object, data) elif event['type'].startswith('setup_intent'): # Validation operation. # Fetch the missing PaymentMethod object. @@ -114,7 +108,7 @@ class StripeController(http.Controller): f'payment_methods/{stripe_object["payment_method"]}', method='GET' ) _logger.info( - "received payment_methods response:\n%s", pprint.pformat(payment_method) + "Received payment_methods response:\n%s", pprint.pformat(payment_method) ) stripe_object['payment_method'] = payment_method self._include_setup_intent_in_notification_data(stripe_object, data) @@ -160,13 +154,10 @@ class StripeController(http.Controller): @staticmethod def _include_payment_intent_in_notification_data(payment_intent, notification_data): - notification_data.update({'payment_intent': payment_intent}) - if payment_intent.get('charges', {}).get('total_count', 0) > 0: - charge = payment_intent['charges']['data'][0] # Use the latest charge object - notification_data.update({ - 'charge': charge, - 'payment_method': charge.get('payment_method_details'), - }) + notification_data.update({ + 'payment_intent': payment_intent, + 'payment_method': payment_intent.get('payment_method'), + }) @staticmethod def _include_setup_intent_in_notification_data(setup_intent, notification_data): diff --git a/addons/payment_stripe/data/payment_provider_data.xml b/addons/payment_stripe/data/payment_provider_data.xml index 09b67011e05..932deae5a06 100644 --- a/addons/payment_stripe/data/payment_provider_data.xml +++ b/addons/payment_stripe/data/payment_provider_data.xml @@ -3,6 +3,7 @@ stripe + True True diff --git a/addons/payment_stripe/models/payment_provider.py b/addons/payment_stripe/models/payment_provider.py index 5fa2b26856d..774b3438c0a 100644 --- a/addons/payment_stripe/models/payment_provider.py +++ b/addons/payment_stripe/models/payment_provider.py @@ -1,5 +1,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. +import json import logging import uuid @@ -9,10 +10,10 @@ from werkzeug.urls import url_encode, url_join, url_parse from odoo import _, api, fields, models from odoo.exceptions import RedirectWarning, UserError, ValidationError -from odoo.addons.payment_stripe import utils as stripe_utils -from odoo.addons.payment_stripe import const -from odoo.addons.payment_stripe.controllers.onboarding import OnboardingController +from odoo.addons.payment import utils as payment_utils +from odoo.addons.payment_stripe import const, utils as stripe_utils from odoo.addons.payment_stripe.controllers.main import StripeController +from odoo.addons.payment_stripe.controllers.onboarding import OnboardingController _logger = logging.getLogger(__name__) @@ -460,3 +461,45 @@ class PaymentProvider(models.Model): self.ensure_one() return stripe_utils.get_publishable_key(self.sudo()) + + def _stripe_get_inline_form_values(self, amount, currency, partner_id, is_validation, **kwargs): + """ Return a serialized JSON of the required values to render the inline form. + + Note: `self.ensure_one()` + + :param float amount: The amount in major units, to convert in minor units. + :param res.currency currency: The currency of the transaction. + :param int partner_id: The partner of the transaction, as a `res.partner` id. + :param bool is_validation: Whether the operation is a validation. + :return: The JSON serial of the required values to render the inline form. + :rtype: str + """ + self.ensure_one() + + if not is_validation: + currency_name = currency and currency.name.lower() + else: + currency_name = self._get_validation_currency().name.lower() + partner = self.env['res.partner'].with_context(show_address=1).browse(partner_id).exists() + inline_form_values = { + 'publishable_key': self._stripe_get_publishable_key(), + 'currency_name': currency_name, + 'minor_amount': amount and payment_utils.to_minor_currency_units(amount, currency), + 'capture_method': 'manual' if self.capture_manually else 'automatic', + 'billing_details': { + 'name': partner.name or '', + 'email': partner.email or '', + 'phone': partner.phone or '', + 'address': { + 'line1': partner.street or '', + 'line2': partner.street2 or '', + 'city': partner.city or '', + 'state': partner.state_id.code or '', + 'country': partner.country_id.code or '', + 'postal_code': partner.zip or '', + }, + }, + 'payment_methods_tokenization_support': const.PAYMENT_METHODS_TOKENIZATION_SUPPORT, + 'is_tokenization_required': self._is_tokenization_required(**kwargs), + } + return json.dumps(inline_form_values) diff --git a/addons/payment_stripe/models/payment_token.py b/addons/payment_stripe/models/payment_token.py index 2a9f3cb72b9..5891e3cd8c5 100644 --- a/addons/payment_stripe/models/payment_token.py +++ b/addons/payment_stripe/models/payment_token.py @@ -13,6 +13,7 @@ class PaymentToken(models.Model): _inherit = 'payment.token' stripe_payment_method = fields.Char(string="Stripe Payment Method ID", readonly=True) + stripe_mandate = fields.Char(string="Stripe Mandate", readonly=True) def _stripe_sca_migrate_customer(self): """ Migrate a token from the old implementation of Stripe to the SCA-compliant one. diff --git a/addons/payment_stripe/models/payment_transaction.py b/addons/payment_stripe/models/payment_transaction.py index 5065f9138ec..313c01e65e1 100644 --- a/addons/payment_stripe/models/payment_transaction.py +++ b/addons/payment_stripe/models/payment_transaction.py @@ -3,14 +3,13 @@ import logging import pprint -from werkzeug import urls +from werkzeug.urls import url_encode, url_join from odoo import _, fields, models from odoo.exceptions import UserError, ValidationError from odoo.addons.payment import utils as payment_utils -from odoo.addons.payment_stripe import utils as stripe_utils -from odoo.addons.payment_stripe.const import STATUS_MAPPING, PAYMENT_METHOD_TYPES +from odoo.addons.payment_stripe.const import STATUS_MAPPING from odoo.addons.payment_stripe.controllers.main import StripeController @@ -20,8 +19,6 @@ _logger = logging.getLogger(__name__) class PaymentTransaction(models.Model): _inherit = 'payment.transaction' - stripe_payment_intent = fields.Char(string="Stripe Payment Intent ID", readonly=True) - def _get_specific_processing_values(self, processing_values): """ Override of payment to return Stripe-specific processing values. @@ -35,112 +32,142 @@ class PaymentTransaction(models.Model): if self.provider_code != 'stripe' or self.operation == 'online_token': return res - if self.operation in ['online_redirect', 'validation']: - checkout_session = self._stripe_create_checkout_session() - return { - 'publishable_key': stripe_utils.get_publishable_key(self.provider_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'], - } + intent = self._stripe_create_intent() + base_url = self.provider_id.get_base_url() + return { + 'client_secret': intent['client_secret'], + 'return_url': url_join( + base_url, + f'{StripeController._return_url}?{url_encode({"reference": self.reference})}', + ), + } - def _stripe_create_checkout_session(self): - """ Create and return a Checkout Session. + def _send_payment_request(self): + """ Override of payment to send a payment request to Stripe with a confirmed PaymentIntent. - :return: The Checkout Session + Note: self.ensure_one() + + :return: None + :raise: UserError if the transaction is not linked to a token + """ + super()._send_payment_request() + if self.provider_code != 'stripe': + return + + if not self.token_id: + raise UserError("Stripe: " + _("The transaction is not linked to a token.")) + + # Make the payment request to Stripe + payment_intent = self._stripe_create_intent() + _logger.info( + "payment request response for transaction with reference %s:\n%s", + self.reference, pprint.pformat(payment_intent) + ) + if not payment_intent: # The PI might be missing if Stripe failed to create it. + return # There is nothing to process; the transaction is in error at this point. + + # Handle the payment request response + notification_data = {'reference': self.reference} + StripeController._include_payment_intent_in_notification_data( + payment_intent, notification_data + ) + self._handle_notification_data('stripe', notification_data) + + def _stripe_create_intent(self): + """ Create and return a PaymentIntent or a SetupIntent, depending on the operation. + + :return: The created PaymentIntent or SetupIntent object. :rtype: dict """ - def get_linked_pmts(linked_pms_): - linked_pmts_ = linked_pms_ - card_pms_ = [ - self.env.ref(f'payment.payment_method_{pm_code_}', raise_if_not_found=False) - for pm_code_ in ('visa', 'mastercard', 'american_express', 'discover') - ] - card_pms_ = [pm_ for pm_ in card_pms_ if pm_ is not None] # Remove deleted card PMs. - if any(pm_.name.lower() in linked_pms_ for pm_ in card_pms_): - linked_pmts_ += ['card'] - return linked_pmts_ - - # Filter payment method types by available payment method - existing_pms = [pm.name.lower() for pm in self.env['payment.method'].search([])] + ['card'] - linked_pms = [pm.name.lower() for pm in self.provider_id.payment_method_ids] - pm_filtered_pmts = filter( - # If the PM record related to a PMT doesn't exist, don't filter out the PMT because the - # user couldn't even have linked it to the provider in the first place. - lambda pmt: pmt.name in get_linked_pmts(linked_pms) or pmt.name not in existing_pms, - PAYMENT_METHOD_TYPES, - ) - # Filter payment method types by country code - country_code = self.partner_country_id and self.partner_country_id.code.lower() - country_filtered_pmts = filter( - lambda pmt: not pmt.countries or country_code in pmt.countries, pm_filtered_pmts - ) - # Filter payment method types by currency name - currency_name = self.currency_id.name.lower() - currency_filtered_pmts = filter( - lambda pmt: not pmt.currencies or currency_name in pmt.currencies, country_filtered_pmts - ) - # Filter payment method types by recurrence if the transaction must be tokenized - if self.tokenize: - recurrence_filtered_pmts = filter( - lambda pmt: pmt.recurrence == 'recurring', currency_filtered_pmts + if self.operation == 'validation': + response = self.provider_id._stripe_make_request( + 'setup_intents', payload=self._stripe_prepare_setup_intent_payload() + ) + else: # 'online_direct', 'online_token', 'offline'. + response = self.provider_id._stripe_make_request( + 'payment_intents', + payload=self._stripe_prepare_payment_intent_payload(), + offline=self.operation == 'offline', + # Prevent multiple offline payments by token (e.g., due to a cursor rollback). + idempotency_key=payment_utils.generate_idempotency_key( + self, scope='payment_intents_token' + ) if self.operation == 'offline' else None, ) - else: - recurrence_filtered_pmts = currency_filtered_pmts - # Build the session values related to payment method types - pmt_values = {} - for pmt_id, pmt_name in enumerate(map(lambda pmt: pmt.name, recurrence_filtered_pmts)): - pmt_values[f'payment_method_types[{pmt_id}]'] = pmt_name - # Create the session according to the operation and return it + if 'error' not in response: + intent = response + else: # A processing error was returned in place of the intent. + # The request failed and no error was raised because we are in an offline payment flow. + # Extract the error from the response, log it, and set the transaction in error to let + # the calling module handle the issue without rolling back the cursor. + error_msg = response['error'].get('message') + _logger.error( + "The creation of the intent failed.\n" + "Stripe gave us the following info about the problem:\n'%s'", error_msg + ) + self._set_error("Stripe: " + _( + "The communication with the API failed.\n" + "Stripe gave us the following info about the problem:\n'%s'", error_msg + )) # Flag transaction as in error now, as the intent status might have a valid value. + intent = response['error'].get('payment_intent') \ + or response['error'].get('setup_intent') # Get the intent from the error. + + return intent + + def _stripe_prepare_setup_intent_payload(self): + """ Prepare the payload for the creation of a SetupIntent in Stripe format. + + Note: This method serves as a hook for modules that would fully implement Stripe Connect. + + :return: The Stripe-formatted payload for the SetupIntent request. + :rtype: dict + """ customer = self._stripe_create_customer() - common_session_values = self._get_common_stripe_session_values(pmt_values, customer) - base_url = self.provider_id.get_base_url() - if self.operation == 'online_redirect': - return_url = f'{urls.url_join(base_url, StripeController._checkout_return_url)}' \ - f'?reference={urls.url_quote_plus(self.reference)}' - # Specify a future usage for the payment intent to: - # 1. attach the payment method to the created customer - # 2. trigger a 3DS check if one if required, while the customer is still present - future_usage = 'off_session' if self.tokenize else None - capture_method = 'manual' if self.provider_id.capture_manually else 'automatic' - checkout_session = self.provider_id._stripe_make_request( - 'checkout/sessions', payload={ - **common_session_values, - 'mode': 'payment', - 'success_url': return_url, - 'cancel_url': return_url, - 'line_items[0][price_data][currency]': self.currency_id.name, - 'line_items[0][price_data][product_data][name]': self.reference, - 'line_items[0][price_data][unit_amount]': payment_utils.to_minor_currency_units( - self.amount, self.currency_id - ), - 'line_items[0][quantity]': 1, - 'payment_intent_data[description]': self.reference, - 'payment_intent_data[setup_future_usage]': future_usage, - 'payment_intent_data[capture_method]': capture_method, - } - ) - self.stripe_payment_intent = checkout_session['payment_intent'] - else: # 'validation' - # {CHECKOUT_SESSION_ID} is a template filled by Stripe when the Session is created - return_url = f'{urls.url_join(base_url, StripeController._validation_return_url)}' \ - f'?reference={urls.url_quote_plus(self.reference)}' \ - f'&checkout_session_id={{CHECKOUT_SESSION_ID}}' - checkout_session = self.provider_id._stripe_make_request( - 'checkout/sessions', payload={ - **common_session_values, - 'mode': 'setup', - 'success_url': return_url, - 'cancel_url': return_url, - 'setup_intent_data[description]': self.reference, - } - ) - return checkout_session + return { + 'customer': customer['id'], + 'description': self.reference, + 'automatic_payment_methods[enabled]': True, + **self._stripe_prepare_mandate_options(), + } + + def _stripe_prepare_payment_intent_payload(self): + """ Prepare the payload for the creation of a PaymentIntent in Stripe format. + + Note: This method serves as a hook for modules that would fully implement Stripe Connect. + + :return: The Stripe-formatted payload for the PaymentIntent request. + :rtype: dict + """ + payment_intent_payload = { + 'amount': payment_utils.to_minor_currency_units(self.amount, self.currency_id), + 'currency': self.currency_id.name.lower(), + 'description': self.reference, + 'capture_method': 'manual' if self.provider_id.capture_manually else 'automatic', + } + 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() + + payment_intent_payload.update({ + 'confirm': True, + 'customer': self.token_id.provider_ref, + 'off_session': True, + 'payment_method': self.token_id.stripe_payment_method, + 'mandate': self.token_id.stripe_mandate or None, + 'payment_method_types[]': ['card', 'sepa_debit'], # The only possible tokens PMTs. + }) + else: + payment_intent_payload.update({ + 'automatic_payment_methods[enabled]': True, + }) + if self.tokenize: + customer = self._stripe_create_customer() + payment_intent_payload.update( + customer=customer['id'], + setup_future_usage='off_session', + **self._stripe_prepare_mandate_options(), + ) + return payment_intent_payload def _stripe_create_customer(self): """ Create and return a Customer. @@ -163,127 +190,41 @@ class PaymentTransaction(models.Model): ) return customer - def _get_common_stripe_session_values(self, pmt_values, customer): - """ Return the Stripe Session values that are common to redirection and validation. + def _stripe_prepare_mandate_options(self): + """ Prepare the configuration options for setting up an eMandate along with an intent. - Note: This method serves as a hook for modules that would fully implement Stripe Connect. - - :param dict pmt_values: The payment method types values - :param dict customer: The Stripe customer to assign to the session - :return: The common Stripe Session values + :return: The Stripe-formatted payload for the mandate options. :rtype: dict """ - return { - **pmt_values, - # Assign a customer to the session so that Stripe automatically attaches the payment - # method to it in a validation flow. In checkout flow, a customer is automatically - # created if not provided but we still do it here to avoid requiring the customer to - # enter his email on the checkout page. - 'customer': customer['id'], + mandate_values = self._get_mandate_values() + + OPTION_PATH_PREFIX = 'payment_method_options[card][mandate_options]' + mandate_options = { + f'{OPTION_PATH_PREFIX}[reference]': self.reference, + f'{OPTION_PATH_PREFIX}[amount_type]': 'maximum', + f'{OPTION_PATH_PREFIX}[amount]': payment_utils.to_minor_currency_units( + mandate_values.get('amount', 15000), self.currency_id + ), # Use the specified amount, if any, or define the maximum amount of 15.000 INR. + f'{OPTION_PATH_PREFIX}[start_date]': int(round( + (mandate_values.get('start_datetime') or fields.Datetime.now()).timestamp() + )), + f'{OPTION_PATH_PREFIX}[interval]': 'sporadic', + f'{OPTION_PATH_PREFIX}[supported_types][]': 'india', } + if mandate_values.get('end_datetime'): + mandate_options[f'{OPTION_PATH_PREFIX}[end_date]'] = int(round( + mandate_values['end_datetime'].timestamp() + )) + if mandate_values.get('recurrence_unit') and mandate_values.get('recurrence_duration'): + mandate_options.update({ + f'{OPTION_PATH_PREFIX}[interval]': mandate_values['recurrence_unit'], + f'{OPTION_PATH_PREFIX}[interval_count]': mandate_values['recurrence_duration'], + }) + if self.operation == 'validation': + currency_name = self.provider_id._get_validation_currency().name.lower() + mandate_options[f'{OPTION_PATH_PREFIX}[currency]'] = currency_name - def _send_payment_request(self): - """ Override of payment to send a payment request to Stripe with a confirmed PaymentIntent. - - Note: self.ensure_one() - - :return: None - :raise: UserError if the transaction is not linked to a token - """ - super()._send_payment_request() - if self.provider_code != 'stripe': - return - - if not self.token_id: - raise UserError("Stripe: " + _("The transaction is not linked to a token.")) - - # Make the payment request to Stripe - payment_intent = self._stripe_create_payment_intent() - _logger.info( - "payment request response for transaction with reference %s:\n%s", - self.reference, pprint.pformat(payment_intent) - ) - if not payment_intent: # The PI might be missing if Stripe failed to create it. - return # There is nothing to process; the transaction is in error at this point. - self.stripe_payment_intent = payment_intent['id'] - - # Handle the payment request response - notification_data = {'reference': self.reference} - StripeController._include_payment_intent_in_notification_data( - payment_intent, notification_data - ) - self._handle_notification_data('stripe', notification_data) - - def _stripe_create_payment_intent(self): - """ Create and return a PaymentIntent. - - Note: self.ensure_one() - - :return: The Payment Intent - :rtype: dict - """ - 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.provider_id._stripe_make_request( - 'payment_intents', - payload=self._stripe_prepare_payment_intent_payload(payment_by_token=True), - offline=self.operation == 'offline', - # Prevent multiple offline payments by token (e.g., due to a cursor rollback). - idempotency_key=payment_utils.generate_idempotency_key( - self, scope='payment_intents_token' - ) if self.operation == 'offline' else None, - ) - else: # 'online_direct' (express checkout). - response = self.provider_id._stripe_make_request( - 'payment_intents', - payload=self._stripe_prepare_payment_intent_payload(), - ) - - if 'error' not in response: - payment_intent = response - else: # A processing error was returned in place of the payment intent - # The request failed and no error was raised because we are in an offline payment flow. - # Extract the error from the response, log it, and set the transaction in error to let - # the calling module handle the issue without rolling back the cursor. - error_msg = response['error'].get('message') - _logger.error( - "The creation of the payment intent failed.\n" - "Stripe gave us the following info about the problem:\n'%s'", error_msg - ) - self._set_error("Stripe: " + _( - "The communication with the API failed.\n" - "Stripe gave us the following info about the problem:\n'%s'", error_msg - )) # Flag transaction as in error now as the intent status might have a valid value - payment_intent = response['error'].get('payment_intent') # Get the PI from the error - - return payment_intent - - 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 - """ - payment_intent_payload = { - 'amount': payment_utils.to_minor_currency_units(self.amount, self.currency_id), - 'currency': self.currency_id.name.lower(), - 'description': self.reference, - 'capture_method': 'manual' if self.provider_id.capture_manually else 'automatic', - } - if payment_by_token: - payment_intent_payload.update( - confirm=True, - customer=self.token_id.provider_ref, - off_session=True, - payment_method=self.token_id.stripe_payment_method, - ) - return payment_intent_payload + return mandate_options def _send_refund_request(self, amount_to_refund=None): """ Override of payment to send a refund request to Stripe. @@ -301,7 +242,7 @@ class PaymentTransaction(models.Model): # Make the refund request to stripe. data = self.provider_id._stripe_make_request( 'refunds', payload={ - 'charge': self.provider_reference, + 'payment_intent': self.provider_reference, 'amount': payment_utils.to_minor_currency_units( -refund_tx.amount, # Refund transactions' amount is negative, inverse it. refund_tx.currency_id, @@ -327,7 +268,7 @@ class PaymentTransaction(models.Model): # Make the capture request to Stripe payment_intent = self.provider_id._stripe_make_request( - f'payment_intents/{self.stripe_payment_intent}/capture' + f'payment_intents/{self.provider_reference}/capture' ) _logger.info( "capture request response for transaction with reference %s:\n%s", @@ -351,7 +292,7 @@ class PaymentTransaction(models.Model): # Make the void request to Stripe payment_intent = self.provider_id._stripe_make_request( - f'payment_intents/{self.stripe_payment_intent}/cancel' + f'payment_intents/{self.provider_reference}/cancel' ) _logger.info( "void request response for transaction with reference %s:\n%s", @@ -389,7 +330,9 @@ class PaymentTransaction(models.Model): # refund object that has no 'description' (the merchant reference) field. We thus search # the transaction by its provider reference which is the refund id for refund txs. refund_id = notification_data['object_id'] # The object is a refund. - tx = self.search([('provider_reference', '=', refund_id), ('provider_code', '=', 'stripe')]) + tx = self.search( + [('provider_reference', '=', refund_id), ('provider_code', '=', 'stripe')] + ) else: raise ValidationError("Stripe: " + _("Received data with missing merchant reference")) @@ -400,15 +343,15 @@ class PaymentTransaction(models.Model): return tx def _process_notification_data(self, notification_data): - """ Override of payment to process the transaction based on Adyen data. + """ Override of payment to process the transaction based on Stripe data. Note: self.ensure_one() :param dict notification_data: The notification data build from information passed to the return route. Depending on the operation of the transaction, - the entries with the keys 'payment_intent', 'charge', - 'setup_intent' and 'payment_method' can be populated with - their corresponding Stripe API objects. + the entries with the keys 'payment_intent', 'setup_intent' + and 'payment_method' can be populated with their + corresponding Stripe API objects. :return: None :raise: ValidationError if inconsistent data were received """ @@ -418,14 +361,14 @@ class PaymentTransaction(models.Model): # Handle the provider reference and the status. if self.operation == 'validation': - status = notification_data.get('setup_intent', {}).get('status') + self.provider_reference = notification_data['setup_intent']['id'] + status = notification_data['setup_intent']['status'] elif self.operation == 'refund': self.provider_reference = notification_data['refund']['id'] status = notification_data['refund']['status'] - else: # 'online_redirect', 'online_token', 'offline' - if 'charge' in notification_data: # The online_redirect operation may include a charge. - self.provider_reference = notification_data['charge']['id'] - status = notification_data.get('payment_intent', {}).get('status') + else: # 'online_direct', 'online_token', 'offline' + self.provider_reference = notification_data['payment_intent']['id'] + status = notification_data['payment_intent']['status'] if not status: raise ValidationError( "Stripe: " + _("Received data with missing intent status.") @@ -480,33 +423,34 @@ class PaymentTransaction(models.Model): See `_process_notification_data`. :return: None """ - if self.operation == 'online_redirect': - payment_method_id = notification_data.get('charge', {}).get('payment_method') - customer_id = notification_data.get('charge', {}).get('customer') - else: # 'validation' - payment_method_id = notification_data.get('payment_method', {}).get('id') - customer_id = notification_data.get('setup_intent', {}).get('customer') payment_method = notification_data.get('payment_method') - if not payment_method_id or not payment_method: + if not payment_method: _logger.warning( "requested tokenization from notification data with missing payment method" ) return - if payment_method.get('type') != 'card': - # Only 'card' payment methods can be tokenized. This case should normally not happen as - # non-recurring payment methods are not shown to the customer if the "Save my payment - # details checkbox" is shown. Still, better be on the safe side.. - _logger.warning("requested tokenization of non-recurring payment method") - return + # Extract the Stripe objects from the notification data. + if self.operation == 'online_direct': + customer_id = notification_data['payment_intent']['customer'] + else: # 'validation' + customer_id = notification_data['setup_intent']['customer'] + if not payment_method: # Another payment method (e.g., SEPA) might have been generated. + payment_methods = self.provider_id._stripe_make_request( + f'customers/{customer_id}/payment_methods', method='GET' + ) + _logger.info("Received payment_methods response:\n%s", pprint.pformat(payment_methods)) + payment_method = payment_methods['data'][0] + # Create the token. token = self.env['payment.token'].create({ 'provider_id': self.provider_id.id, - 'payment_details': payment_method['card'].get('last4'), + 'payment_details': payment_method[payment_method['type']]['last4'], 'partner_id': self.partner_id.id, 'provider_ref': customer_id, 'verified': True, - 'stripe_payment_method': payment_method_id, + 'stripe_payment_method': payment_method['id'], + 'stripe_mandate': payment_method[payment_method['type']].get('mandate'), }) self.write({ 'token_id': token, diff --git a/addons/payment_stripe/static/src/js/checkout_form.js b/addons/payment_stripe/static/src/js/checkout_form.js new file mode 100644 index 00000000000..fa88716e108 --- /dev/null +++ b/addons/payment_stripe/static/src/js/checkout_form.js @@ -0,0 +1,41 @@ +/** @odoo-module */ + +import checkoutForm from 'payment.checkout_form'; +import stripeMixin from '@payment_stripe/js/stripe_mixin'; + +checkoutForm.include(stripeMixin); +checkoutForm.include({ + + /** + * @override method from payment_stripe.stripe_mixin + * @private + */ + _getElementsOptions() { + const elementsOptions = { + ...this._super(...arguments), + mode: 'payment', + amount: parseInt(this.stripeInlineFormValues['minor_amount']), + }; + if (this.stripeInlineFormValues['is_tokenization_required']) { + elementsOptions.setupFutureUsage = 'off_session'; + } + return elementsOptions; + }, + + /** + * @override method from payment_stripe.stripe_mixin + * @private + * @param {object} processingValues - The processing values of the transaction. + * @return {object} The processing error, if any. + */ + async _stripeConfirmIntent(processingValues) { + await this._super(...arguments); + return await this.stripeJS.confirmPayment({ + elements: this.stripeElements, + clientSecret: processingValues['client_secret'], + confirmParams: { + return_url: processingValues['return_url'], + }, + }); + }, +}); diff --git a/addons/payment_stripe/static/src/js/manage_form.js b/addons/payment_stripe/static/src/js/manage_form.js new file mode 100644 index 00000000000..5a4f8864b00 --- /dev/null +++ b/addons/payment_stripe/static/src/js/manage_form.js @@ -0,0 +1,37 @@ +/** @odoo-module */ + +import manageForm from 'payment.manage_form'; +import stripeMixin from '@payment_stripe/js/stripe_mixin'; + +manageForm.include(stripeMixin); +manageForm.include({ + + /** + * @override method from payment_stripe.stripe_mixin + * @private + */ + _getElementsOptions() { + return { + ...this._super(...arguments), + mode: 'setup', + setupFutureUsage: 'off_session', + }; + }, + + /** + * @override method from payment_stripe.stripe_mixin + * @private + * @param {object} processingValues - The processing values of the transaction. + * @return {object} The processing error, if any. + */ + async _stripeConfirmIntent(processingValues) { + await this._super(...arguments); + return await this.stripeJS.confirmSetup({ + elements: this.stripeElements, + clientSecret: processingValues['client_secret'], + confirmParams: { + return_url: processingValues['return_url'], + }, + }); + } +}); diff --git a/addons/payment_stripe/static/src/js/payment_form.js b/addons/payment_stripe/static/src/js/payment_form.js deleted file mode 100644 index b6d34c2f392..00000000000 --- a/addons/payment_stripe/static/src/js/payment_form.js +++ /dev/null @@ -1,37 +0,0 @@ -/** @odoo-module */ -/* global Stripe */ - -import checkoutForm from 'payment.checkout_form'; -import manageForm from 'payment.manage_form'; -import { StripeOptions } from '@payment_stripe/js/stripe_options'; - -const stripeMixin = { - - /** - * Redirect the customer to Stripe hosted payment page. - * - * @override method from payment.payment_form_mixin - * @private - * @param {string} code - The code of the payment option - * @param {number} paymentOptionId - The id of the payment option handling the transaction - * @param {object} processingValues - The processing values of the transaction - * @return {undefined} - */ - _processRedirectPayment: function (code, paymentOptionId, processingValues) { - if (code !== 'stripe') { - return this._super(...arguments); - } - - const stripeJS = Stripe( - processingValues['publishable_key'], - // Instantiate the StripeOptions class to allow patching the method and add options. - new StripeOptions()._prepareStripeOptions(processingValues), - ); - stripeJS.redirectToCheckout({ - sessionId: processingValues['session_id'] - }); - }, -}; - -checkoutForm.include(stripeMixin); -manageForm.include(stripeMixin); diff --git a/addons/payment_stripe/static/src/js/stripe_mixin.js b/addons/payment_stripe/static/src/js/stripe_mixin.js new file mode 100644 index 00000000000..d1665cc6567 --- /dev/null +++ b/addons/payment_stripe/static/src/js/stripe_mixin.js @@ -0,0 +1,175 @@ +/** @odoo-module */ +/* global Stripe */ + +import core from 'web.core'; +import { StripeOptions } from '@payment_stripe/js/stripe_options'; + +const _t = core._t; + +export default { + + /** + * Prepare the inline form of Stripe for direct payment. + * + * @override method from payment.payment_form_mixin + * @private + * @param {string} code - The code of the selected payment option's provider. + * @param {number} paymentOptionId - The id of the selected payment option. + * @param {string} flow - The online payment flow of the selected payment option. + * @return {void} + */ + _prepareInlineForm(code, paymentOptionId, flow) { + if (code !== 'stripe') { + this._super(...arguments); + return; + } + + // Check if instantiation of the element is needed. + if (flow === 'token') { + return; // No elements for tokens. + } else if (this.stripeElements && this.stripeElements.providerId === paymentOptionId) { + this._setPaymentFlow('direct'); // Overwrite the flow even if no re-instantiation. + return; // Don't re-instantiate if already done for this provider. + } + + // Overwrite the flow of the select payment option. + this._setPaymentFlow('direct'); + + // Extract and deserialize the inline form values. + const stripeInlineForm = document.getElementById( + `o_stripe_${this.formType}_element_container_${paymentOptionId}` + ); + this.stripeInlineFormValues = JSON.parse(stripeInlineForm.dataset['inlineFormValues']); + + // Instantiate the payment element. + this.stripeJS = Stripe( + this.stripeInlineFormValues['publishable_key'], + // The values required by Stripe Connect are inserted into the dataset. + new StripeOptions()._prepareStripeOptions(stripeInlineForm.dataset), + ); + this.stripeElements = this.stripeJS.elements(this._getElementsOptions()); + this.stripeElements.providerId = paymentOptionId; + const paymentElementOptions = { + defaultValues: { + billingDetails: this.stripeInlineFormValues['billingDetails'], + }, + layout: { + type: 'accordion', + defaultCollapsed: false, + radios: false, + spacedAccordionItems: true, + }, + }; + const paymentElement = this.stripeElements.create('payment', paymentElementOptions); + paymentElement.mount(stripeInlineForm); + + const tokenizationCheckbox = document.getElementById( + `o_payment_provider_inline_${this.formType}_form_${paymentOptionId}` + ).querySelector("input[name='o_payment_save_as_token']"); + if (this.formType === 'checkout' && tokenizationCheckbox) { + // Disable the tokenization checkbox for non-compatible payment methods. + paymentElement.addEventListener('change', ev => { + this.selectedPaymentMethod = ev.value.type; + if (this.stripeInlineFormValues['payment_methods_tokenization_support'] + [this.selectedPaymentMethod]) { + tokenizationCheckbox.disabled = false; + tokenizationCheckbox.removeAttribute('title'); + } else { + tokenizationCheckbox.disabled = true; + tokenizationCheckbox.checked = false; + tokenizationCheckbox.title = _t("The selected payment method cannot be saved."); + } + }); + // Display tokenization-specific inputs when the tokenization checkbox is checked. + tokenizationCheckbox.addEventListener('input', () => { + this.stripeElements.update({ + setupFutureUsage: tokenizationCheckbox.checked ? 'off_session' : null, + }); + }); + } + }, + + /** + * Prepare the required options for the configuration of the Elements object. + * + * @private + * @return {Object} + */ + _getElementsOptions() { + return { + appearance: { theme: 'stripe' }, + currency: this.stripeInlineFormValues['currency_name'], + captureMethod: this.stripeInlineFormValues['capture_method'], + }; + }, + + /** + * Trigger the form validation by submitting the payment element. + * + * @override method from payment.payment_form_mixin + * @private + * @param {string} provider - The provider of the payment option's provider. + * @param {number} paymentOptionId - The id of the payment option handling the transaction. + * @param {string} flow - The online payment flow of the transaction. + * @return {void} + */ + async _processPayment(provider, paymentOptionId, flow) { + if (provider !== 'stripe' || flow === 'token') { + await this._super(...arguments); // Tokens are handled by the generic flow. + return; + } + if (this.stripeElements === undefined) { // Elements has not been properly instantiated. + this._displayError( + _t("Server Error"), _t("We are not able to process your payment.") + ); + } else { + // Trigger form validation and wallet collection. + const _super = this._super.bind(this); + const { error: submitError } = await this.stripeElements.submit(); + if (submitError) { + this._displayError( + _t("Incorrect Payment Details"), + _t("Please verify your payment details."), + ); + } else { // There is no invalid input, resume the generic flow. + return await _super(...arguments); + } + } + }, + + /** + * Process the payment. + * + * @override method from payment.payment_form_mixin + * @private + * @param {string} code - The code of the provider + * @param {number} providerId - The id of the provider handling the transaction + * @param {object} processingValues - The processing values of the transaction + * @return {void} + */ + async _processDirectPayment(code, providerId, processingValues) { + if (code !== 'stripe') { + await this._super(...arguments); + return; + } + + const { error } = await this._stripeConfirmIntent(processingValues); + if (error) { + this._displayError( + _t("Server Error"), + _t("We are not able to process your payment."), + error.message, + ); + } + }, + + /** + * Confirm the intent on Stripe's side and handle any next action. + * + * @private + * @param {object} processingValues - The processing values of the transaction. + * @return {object} The processing error, if any. + */ + async _stripeConfirmIntent(processingValues) {}, + +}; diff --git a/addons/payment_stripe/tests/test_stripe.py b/addons/payment_stripe/tests/test_stripe.py index ef2156d6004..bfd90119341 100644 --- a/addons/payment_stripe/tests/test_stripe.py +++ b/addons/payment_stripe/tests/test_stripe.py @@ -3,6 +3,8 @@ import sys from unittest.mock import patch +from werkzeug.urls import url_encode, url_join + from odoo.tests import tagged from odoo.tools import mute_logger @@ -15,31 +17,36 @@ from odoo.addons.payment_stripe.tests.common import StripeCommon class StripeTest(StripeCommon, PaymentHttpCommon): def test_processing_values(self): - dummy_session_id = 'cs_test_sbTG0yGwTszAqFUP8Ulecr1bUwEyQEo29M8taYvdP7UA6Qr37qX6uA6w' - tx = self._create_transaction(flow='redirect') # We don't really care what the flow is here. + dummy_client_secret = 'pi_123456789_secret_dummy_123456789' + tx = self._create_transaction(flow='direct') # We don't really care what the flow is here. # Ensure no external API call is done, we only want to check the processing values logic - def mock_stripe_create_checkout_session(self): - return {'id': dummy_session_id} + def mock_stripe_stripe_create_intent(self): + return {'client_secret': dummy_client_secret} with patch.object( - type(self.env['payment.transaction']), '_stripe_create_checkout_session', - mock_stripe_create_checkout_session, + type(self.env['payment.transaction']), '_stripe_create_intent', + mock_stripe_stripe_create_intent, ), mute_logger('odoo.addons.payment.models.payment_transaction'): processing_values = tx._get_processing_values() - self.assertEqual(processing_values['publishable_key'], self.stripe.stripe_publishable_key) - self.assertEqual(processing_values['session_id'], dummy_session_id) + self.assertEqual(processing_values['client_secret'], dummy_client_secret) + + base_url = self.provider.get_base_url() + return_url = url_join( + base_url, f'{StripeController._return_url}?{url_encode({"reference": tx.reference})}' + ) + self.assertEqual(processing_values['return_url'], return_url) @mute_logger('odoo.addons.payment_stripe.models.payment_transaction') def test_tx_state_after_send_capture_request(self): self.provider.capture_manually = True - tx = self._create_transaction('redirect', state='authorized') + tx = self._create_transaction('direct', state='authorized') with patch( 'odoo.addons.payment_stripe.models.payment_provider.PaymentProvider' '._stripe_make_request', - return_value={'status': 'succeeded'}, + return_value={'id': 'pi_3KTk9zAlCFm536g81Wy7RCPH', 'status': 'succeeded'}, ): tx._send_capture_request() self.assertEqual( @@ -54,7 +61,7 @@ class StripeTest(StripeCommon, PaymentHttpCommon): with patch( 'odoo.addons.payment_stripe.models.payment_provider.PaymentProvider' '._stripe_make_request', - return_value={'status': 'canceled'}, + return_value={'id': 'pi_3KTk9zAlCFm536g81Wy7RCPH', 'status': 'canceled'}, ): tx._send_void_request() self.assertEqual( diff --git a/addons/payment_stripe/views/payment_stripe_templates.xml b/addons/payment_stripe/views/payment_stripe_templates.xml index 1b551a86088..7cae27902fe 100644 --- a/addons/payment_stripe/views/payment_stripe_templates.xml +++ b/addons/payment_stripe/views/payment_stripe_templates.xml @@ -1,14 +1,24 @@ - diff --git a/addons/sale/views/sale_portal_templates.xml b/addons/sale/views/sale_portal_templates.xml index b38bec6253e..ac6ee147a54 100644 --- a/addons/sale/views/sale_portal_templates.xml +++ b/addons/sale/views/sale_portal_templates.xml @@ -257,7 +257,10 @@

Pay with

- + + + +
The order is not in a state requiring customer payment.