diff --git a/addons/payment_stripe/__init__.py b/addons/payment_stripe/__init__.py index cb4afd418b5..f11a519ce7b 100644 --- a/addons/payment_stripe/__init__.py +++ b/addons/payment_stripe/__init__.py @@ -1,10 +1,11 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import models from . import controllers -from odoo.addons.payment.models.payment_acquirer import create_missing_journal_for_acquirers -from odoo.addons.payment import reset_payment_provider +from . import models + +from odoo.addons.payment import reset_payment_acquirer +from odoo.addons.payment.models.payment_acquirer import create_missing_journals # post-init hook + def uninstall_hook(cr, registry): - reset_payment_provider(cr, registry, 'stripe') + reset_payment_acquirer(cr, registry, 'stripe') diff --git a/addons/payment_stripe/__manifest__.py b/addons/payment_stripe/__manifest__.py index a7fad40be8e..0b9275d7bad 100644 --- a/addons/payment_stripe/__manifest__.py +++ b/addons/payment_stripe/__manifest__.py @@ -1,21 +1,19 @@ -# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. { 'name': 'Stripe Payment Acquirer', + 'version': '2.0', 'category': 'Accounting/Payment Acquirers', 'sequence': 380, 'summary': 'Payment Acquirer: Stripe Implementation', - 'version': '1.0', 'description': """Stripe Payment Acquirer""", 'depends': ['payment'], 'data': [ + 'views/assets.xml', 'views/payment_views.xml', - 'views/payment_stripe_templates.xml', 'data/payment_acquirer_data.xml', ], - 'images': ['static/description/icon.png'], - 'installable': True, 'application': True, - 'post_init_hook': 'create_missing_journal_for_acquirers', + 'post_init_hook': 'create_missing_journals', 'uninstall_hook': 'uninstall_hook', } diff --git a/addons/payment_stripe/const.py b/addons/payment_stripe/const.py new file mode 100644 index 00000000000..938a09d8a33 --- /dev/null +++ b/addons/payment_stripe/const.py @@ -0,0 +1,24 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from collections import namedtuple + + +# 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'), +] + +# Mapping of transaction states to Stripe {Payment,Setup}Intent statuses. +# See https://stripe.com/docs/payments/intents#intent-statuses for the exhaustive list of status. +INTENT_STATUS_MAPPING = { + 'draft': ('requires_payment_method', 'requires_confirmation', 'requires_action'), + 'pending': ('processing',), + 'done': ('succeeded',), + 'cancel': ('canceled',), +} diff --git a/addons/payment_stripe/controllers/__init__.py b/addons/payment_stripe/controllers/__init__.py index 65a8c12013d..80ee4da1c5e 100644 --- a/addons/payment_stripe/controllers/__init__.py +++ b/addons/payment_stripe/controllers/__init__.py @@ -1,3 +1,3 @@ -# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. from . import main diff --git a/addons/payment_stripe/controllers/main.py b/addons/payment_stripe/controllers/main.py index 763a4d05a2a..f0afff4cebe 100644 --- a/addons/payment_stripe/controllers/main.py +++ b/addons/payment_stripe/controllers/main.py @@ -1,62 +1,171 @@ -# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import hashlib +import hmac import json import logging import pprint +from datetime import datetime + import werkzeug from odoo import http +from odoo.exceptions import ValidationError from odoo.http import request +from odoo.tools import consteq _logger = logging.getLogger(__name__) class StripeController(http.Controller): - _success_url = '/payment/stripe/success' - _cancel_url = '/payment/stripe/cancel' + _checkout_return_url = '/payment/stripe/checkout_return' + _validation_return_url = '/payment/stripe/validation_return' + WEBHOOK_AGE_TOLERANCE = 10*60 # seconds - @http.route(['/payment/stripe/success', '/payment/stripe/cancel'], type='http', auth='public') - def stripe_success(self, **kwargs): - request.env['payment.transaction'].sudo().form_feedback(kwargs, 'stripe') - return werkzeug.utils.redirect('/payment/process') + @http.route(_checkout_return_url, type='http', auth='public', csrf=False) + def stripe_return_from_checkout(self, **data): + """ Process the data returned by Stripe after redirection for checkout. - @http.route(['/payment/stripe/s2s/create_json_3ds'], type='json', auth='public', csrf=False) - def stripe_s2s_create_json_3ds(self, verify_validity=False, **kwargs): - if not kwargs.get('partner_id'): - kwargs = dict(kwargs, partner_id=request.env.user.partner_id.id) - token = request.env['payment.acquirer'].browse(int(kwargs.get('acquirer_id'))).with_context(stripe_manual_payment=True).s2s_process(kwargs) + :param dict data: The GET params appended to the URL in `_stripe_create_checkout_session` + """ + # Retrieve the tx and acquirer based on the tx reference included in the return url + tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_feedback_data( + 'stripe', data + ) + acquirer_sudo = tx_sudo.acquirer_id - if not token: - res = { - 'result': False, - } - return res + # Fetch the PaymentIntent, Charge and PaymentMethod objects from Stripe + payment_intent = acquirer_sudo._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_feedback_data(payment_intent, data) - res = { - 'result': True, - 'id': token.id, - 'short_name': token.short_name, - '3d_secure': False, - 'verified': False, - } + # Handle the feedback data crafted with Stripe API objects + request.env['payment.transaction'].sudo()._handle_feedback_data('stripe', data) - if verify_validity != False: - token.validate() - res['verified'] = token.verified + # Redirect the user to the status page + return werkzeug.utils.redirect('/payment/status') - return res + @http.route(_validation_return_url, type='http', auth='public', csrf=False) + def stripe_return_from_validation(self, **data): + """ Process the data returned by Stripe after redirection for validation. - @http.route('/payment/stripe/s2s/create_setup_intent', type='json', auth='public', csrf=False) - def stripe_s2s_create_setup_intent(self, acquirer_id, **kwargs): - acquirer = request.env['payment.acquirer'].browse(int(acquirer_id)) - res = acquirer.with_context(stripe_manual_payment=True)._create_setup_intent(kwargs) - return res.get('client_secret') + :param dict data: The GET params appended to the URL in `_stripe_create_checkout_session` + """ + # Retrieve the acquirer based on the tx reference included in the return url + acquirer_sudo = request.env['payment.transaction'].sudo()._get_tx_from_feedback_data( + 'stripe', data + ).acquirer_id - @http.route('/payment/stripe/s2s/process_payment_intent', type='json', auth='public', csrf=False) - def stripe_s2s_process_payment_intent(self, **post): - return request.env['payment.transaction'].sudo().form_feedback(post, 'stripe') + # Fetch the Session, SetupIntent and PaymentMethod objects from Stripe + checkout_session = acquirer_sudo._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_feedback_data(checkout_session.get('setup_intent', {}), data) - @http.route('/payment/stripe/webhook', type='json', auth='public', csrf=False) - def stripe_webhook(self, **kwargs): - data = json.loads(request.httprequest.data) - request.env['payment.acquirer'].sudo()._handle_stripe_webhook(data) - return 'OK' \ No newline at end of file + # Handle the feedback data crafted with Stripe API objects + request.env['payment.transaction'].sudo()._handle_feedback_data('stripe', data) + + # Redirect the user to the status page + return werkzeug.utils.redirect('/payment/status') + + @http.route('/payment/stripe/webhook', type='json', auth='public') + def stripe_webhook(self): + """ Process the `checkout.session.completed` event sent by Stripe to the webhook. + + :return: An empty string to acknowledge the notification with an HTTP 200 response + :rtype: str + """ + event = json.loads(request.httprequest.data) + _logger.info("event received:\n%s", pprint.pformat(event)) + if event['type'] == 'checkout.session.completed': + checkout_session = event['data']['object'] + + # Check the source and integrity of the event + data = {'reference': checkout_session['client_reference_id']} + tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_feedback_data( + 'stripe', data + ) + if self._verify_webhook_signature(tx_sudo.acquirer_id.stripe_webhook_secret): + if checkout_session.get('payment_intent'): # Can be None + # Fetch the PaymentIntent, Charge and PaymentMethod objects from Stripe + payment_intent = tx_sudo.acquirer_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_feedback_data(payment_intent, data) + if checkout_session.get('setup_intent'): # Can be None + # Fetch the SetupIntent and PaymentMethod objects from Stripe + setup_intent = tx_sudo.acquirer_id._stripe_make_request( + f'setup_intents/{checkout_session.get("setup_intent")}', + payload={'expand[]': 'payment_method'}, + method='GET' + ) + _logger.info( + "received setup_intents response:\n%s", pprint.pformat(setup_intent) + ) + self._include_setup_intent_in_feedback_data(setup_intent, data) + try: + # Handle the feedback data crafted with Stripe API objects as a regular feedback + request.env['payment.transaction'].sudo()._handle_feedback_data('stripe', data) + except ValidationError: # Acknowledge the notification to avoid getting spammed + _logger.exception("unable to handle the event data; skipping to acknowledge") + return '' + + def _include_payment_intent_in_feedback_data(self, payment_intent, data): + 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 + data.update({ + 'charge': charge, + 'payment_method': charge.get('payment_method_details'), + }) + + def _include_setup_intent_in_feedback_data(self, setup_intent, data): + data.update({ + 'setup_intent': setup_intent, + 'payment_method': setup_intent.get('payment_method') + }) + + def _verify_webhook_signature(self, webhook_secret): + """ Check that the signature computed from the feedback matches the received one. + + See https://stripe.com/docs/webhooks/signatures#verify-manually. + + :param str webhook_secret: The secret webhook key of the acquirer handling the transaction + :return: Whether the signatures match + :rtype: str + """ + if not webhook_secret: + _logger.warning("ignored webhook event due to undefined webhook secret") + return False + + notification_payload = request.httprequest.data.decode('utf-8') + signature_entries = request.httprequest.headers.get('Stripe-Signature').split(',') + signature_data = {k: v for k, v in [entry.split('=') for entry in signature_entries]} + + # Check the timestamp of the event + event_timestamp = int(signature_data['t']) + if datetime.utcnow().timestamp() - event_timestamp > self.WEBHOOK_AGE_TOLERANCE: + _logger.warning("ignored webhook event due to age tolerance: %s", event_timestamp) + return False + + # Compare signatures + received_signature = signature_data['v1'] + signed_payload = f'{event_timestamp}.{notification_payload}' + expected_signature = hmac.new( + webhook_secret.encode('utf-8'), + signed_payload.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + if not consteq(received_signature, expected_signature): + _logger.warning("ignored event with invalid signature") + return False + + return True diff --git a/addons/payment_stripe/data/payment_acquirer_data.xml b/addons/payment_stripe/data/payment_acquirer_data.xml index 2aeeb9d8125..30396ead025 100644 --- a/addons/payment_stripe/data/payment_acquirer_data.xml +++ b/addons/payment_stripe/data/payment_acquirer_data.xml @@ -1,13 +1,12 @@ - - - - Stripe - - stripe - - - - - + + + + stripe + False + False + True + True + + diff --git a/addons/payment_stripe/models/__init__.py b/addons/payment_stripe/models/__init__.py index ef12533682f..22eb69117db 100644 --- a/addons/payment_stripe/models/__init__.py +++ b/addons/payment_stripe/models/__init__.py @@ -1,3 +1,5 @@ -# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import payment +from . import payment_acquirer +from . import payment_token +from . import payment_transaction diff --git a/addons/payment_stripe/models/payment.py b/addons/payment_stripe/models/payment.py deleted file mode 100644 index 53ac5f5682a..00000000000 --- a/addons/payment_stripe/models/payment.py +++ /dev/null @@ -1,516 +0,0 @@ -# coding: utf-8 - -from collections import namedtuple -from datetime import datetime -from hashlib import sha256 -import hmac -import json -import logging -import requests -import pprint -from requests.exceptions import HTTPError -from werkzeug import urls - -from odoo import api, fields, models, _ -from odoo.http import request -from odoo.tools.float_utils import float_round -from odoo.tools import consteq -from odoo.exceptions import ValidationError - -from odoo.addons.payment_stripe.controllers.main import StripeController - -_logger = logging.getLogger(__name__) - -# The following currencies are integer only, see https://stripe.com/docs/currencies#zero-decimal -INT_CURRENCIES = [ - u'BIF', u'XAF', u'XPF', u'CLP', u'KMF', u'DJF', u'GNF', u'JPY', u'MGA', u'PYG', u'RWF', u'KRW', - u'VUV', u'VND', u'XOF' -] -STRIPE_SIGNATURE_AGE_TOLERANCE = 600 # in seconds - - -class PaymentAcquirerStripe(models.Model): - _inherit = 'payment.acquirer' - - provider = fields.Selection(selection_add=[ - ('stripe', 'Stripe') - ], ondelete={'stripe': 'set default'}) - stripe_secret_key = fields.Char(required_if_provider='stripe', groups='base.group_user') - stripe_publishable_key = fields.Char(required_if_provider='stripe', groups='base.group_user') - stripe_webhook_secret = fields.Char( - string='Stripe Webhook Secret', groups='base.group_user', - help="If you enable webhooks, this secret is used to verify the electronic " - "signature of events sent by Stripe to Odoo. Failing to set this field in Odoo " - "will disable the webhook system for this acquirer entirely.") - stripe_image_url = fields.Char( - "Checkout Image URL", groups='base.group_user', - help="A relative or absolute URL pointing to a square image of your " - "brand or product. As defined in your Stripe profile. See: " - "https://stripe.com/docs/checkout") - - def stripe_form_generate_values(self, tx_values): - self.ensure_one() - - base_url = self.get_base_url() - stripe_session_data = { - 'line_items[][amount]': int(tx_values['amount'] if tx_values['currency'].name in INT_CURRENCIES else float_round(tx_values['amount'] * 100, 2)), - 'line_items[][currency]': tx_values['currency'].name, - 'line_items[][quantity]': 1, - 'line_items[][name]': tx_values['reference'], - 'client_reference_id': tx_values['reference'], - 'success_url': urls.url_join(base_url, StripeController._success_url) + '?reference=%s' % tx_values['reference'], - 'cancel_url': urls.url_join(base_url, StripeController._cancel_url) + '?reference=%s' % tx_values['reference'], - 'payment_intent_data[description]': tx_values['reference'], - 'customer_email': tx_values.get('partner_email') or tx_values.get('billing_partner_email'), - } - - self._add_available_payment_method_types(stripe_session_data, tx_values) - - tx_values['session_id'] = self.with_context(stripe_manual_payment=True)._create_stripe_session(stripe_session_data) - - return tx_values - - @api.model - def _add_available_payment_method_types(self, stripe_session_data, tx_values): - """ - Add payment methods available for the given transaction - - :param stripe_session_data: dictionary to add the payment method types to - :param tx_values: values of the transaction to consider the payment method types for - """ - PMT = namedtuple('PaymentMethodType', ['name', 'countries', 'currencies', 'recurrence']) - all_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'), - ] - - existing_icons = [(icon.name or '').lower() for icon in self.env['payment.icon'].search([])] - linked_icons = [(icon.name or '').lower() for icon in self.payment_icon_ids] - - # We don't filter out pmt in the case the icon doesn't exist at all as it would be **implicit** exclusion - icon_filtered = filter(lambda pmt: pmt.name == 'card' or - pmt.name in linked_icons or - pmt.name not in existing_icons, all_payment_method_types) - country = (tx_values['billing_partner_country'].code or 'no_country').lower() - pmt_country_filtered = filter(lambda pmt: not pmt.countries or country in pmt.countries, icon_filtered) - currency = (tx_values.get('currency').name or 'no_currency').lower() - pmt_currency_filtered = filter(lambda pmt: not pmt.currencies or currency in pmt.currencies, pmt_country_filtered) - pmt_recurrence_filtered = filter(lambda pmt: tx_values.get('type') != 'form_save' or pmt.recurrence == 'recurring', - pmt_currency_filtered) - - available_payment_method_types = map(lambda pmt: pmt.name, pmt_recurrence_filtered) - - for idx, payment_method_type in enumerate(available_payment_method_types): - stripe_session_data[f'payment_method_types[{idx}]'] = payment_method_type - - def _stripe_request(self, url, data=False, method='POST'): - self.ensure_one() - url = urls.url_join(self._get_stripe_api_url(), url) - headers = { - 'AUTHORIZATION': 'Bearer %s' % self.sudo().stripe_secret_key, - 'Stripe-Version': '2019-05-16', # SetupIntent need a specific version - } - resp = requests.request(method, url, data=data, headers=headers) - # Stripe can send 4XX errors for payment failure (not badly-formed requests) - # check if error `code` is present in 4XX response and raise only if not - # cfr https://stripe.com/docs/error-codes - # these can be made customer-facing, as they usually indicate a problem with the payment - # (e.g. insufficient funds, expired card, etc.) - # if the context key `stripe_manual_payment` is set then these errors will be raised as ValidationError, - # otherwise, they will be silenced, and the will be returned no matter the status. - # This key should typically be set for payments in the present and unset for automated payments - # (e.g. through crons) - if not resp.ok and self._context.get('stripe_manual_payment') and (400 <= resp.status_code < 500 and resp.json().get('error', {}).get('code')): - try: - resp.raise_for_status() - except HTTPError: - _logger.error(resp.text) - stripe_error = resp.json().get('error', {}).get('message', '') - error_msg = " " + (_("Stripe gave us the following info about the problem: '%s'", stripe_error)) - raise ValidationError(error_msg) - return resp.json() - - def _create_stripe_session(self, kwargs): - self.ensure_one() - resp = self._stripe_request('checkout/sessions', kwargs) - if resp.get('payment_intent') and kwargs.get('client_reference_id'): - tx = self.env['payment.transaction'].sudo().search([('reference', '=', kwargs['client_reference_id'])]) - tx.stripe_payment_intent = resp['payment_intent'] - if 'id' not in resp and 'error' in resp: - _logger.error(resp['error']['message']) - return resp['id'] - - def _create_setup_intent(self, kwargs): - self.ensure_one() - params = { - 'usage': 'off_session', - } - _logger.info('_stripe_create_setup_intent: Sending values to stripe, values:\n%s', pprint.pformat(params)) - - res = self._stripe_request('setup_intents', params) - - _logger.info('_stripe_create_setup_intent: Values received:\n%s', pprint.pformat(res)) - return res - - @api.model - def _get_stripe_api_url(self): - return 'https://api.stripe.com/v1/' - - @api.model - def stripe_s2s_form_process(self, data): - if 'card' in data and not data.get('card'): - # coming back from a checkout payment and iDeal (or another non-card pm) - # can't save the token if it's not a card - # note that in the case of a s2s payment, 'card' wont be - # in the data dict because we need to fetch it from the stripe server - _logger.info('unable to save card info from Stripe since the payment was not done with a card') - return self.env['payment.token'] - last4 = data.get('card', {}).get('last4') - if not last4: - # PM was created with a setup intent, need to get last4 digits through - # yet another call -_- - acquirer_id = self.env['payment.acquirer'].browse(int(data['acquirer_id'])) - pm = data.get('payment_method') - res = acquirer_id._stripe_request('payment_methods/%s' % pm, data=False, method='GET') - last4 = res.get('card', {}).get('last4', '****') - - payment_token = self.env['payment.token'].sudo().create({ - 'acquirer_id': int(data['acquirer_id']), - 'partner_id': int(data['partner_id']), - 'stripe_payment_method': data.get('payment_method'), - 'name': 'XXXXXXXXXXXX%s' % last4, - 'acquirer_ref': data.get('customer') - }) - return payment_token - - def _get_feature_support(self): - """Get advanced feature support by provider. - - Each provider should add its technical in the corresponding - key for the following features: - * tokenize: support saving payment data in a payment.tokenize - object - """ - res = super(PaymentAcquirerStripe, self)._get_feature_support() - res['tokenize'].append('stripe') - return res - - def _handle_stripe_webhook(self, data): - """Process a webhook payload from Stripe. - - Post-process a webhook payload to act upon the matching payment.transaction - record in Odoo. - """ - wh_type = data.get('type') - if wh_type != 'checkout.session.completed': - _logger.info('unsupported webhook type %s, ignored', wh_type) - return False - - _logger.info('handling %s webhook event from stripe', wh_type) - - stripe_object = data.get('data', {}).get('object') - if not stripe_object: - raise ValidationError('Stripe Webhook data does not conform to the expected API.') - if wh_type == 'checkout.session.completed': - return self._handle_checkout_webhook(stripe_object) - return False - - def _verify_stripe_signature(self): - """ - :return: true if and only if signature matches hash of payload calculated with secret - :raises ValidationError: if signature doesn't match - """ - if not self.stripe_webhook_secret: - raise ValidationError('webhook event received but webhook secret is not configured') - signature = request.httprequest.headers.get('Stripe-Signature') - body = request.httprequest.data - - sign_data = {k: v for (k, v) in [s.split('=') for s in signature.split(',')]} - event_timestamp = int(sign_data['t']) - if datetime.utcnow().timestamp() - event_timestamp > STRIPE_SIGNATURE_AGE_TOLERANCE: - _logger.error('stripe event is too old, event is discarded') - raise ValidationError('event timestamp older than tolerance') - - signed_payload = "%s.%s" % (event_timestamp, body.decode('utf-8')) - - actual_signature = sign_data['v1'] - expected_signature = hmac.new(self.stripe_webhook_secret.encode('utf-8'), - signed_payload.encode('utf-8'), - sha256).hexdigest() - - if not consteq(expected_signature, actual_signature): - _logger.error( - 'incorrect webhook signature from Stripe, check if the webhook signature ' - 'in Odoo matches to one in the Stripe dashboard') - raise ValidationError('incorrect webhook signature') - - return True - - def _handle_checkout_webhook(self, checkout_object: dir): - """ - Process a checkout.session.completed Stripe web hook event, - mark related payment successful - - :param checkout_object: provided in the request body - :return: True if and only if handling went well, False otherwise - :raises ValidationError: if input isn't usable - """ - tx_reference = checkout_object.get('client_reference_id') - data = {'reference': tx_reference} - try: - odoo_tx = self.env['payment.transaction']._stripe_form_get_tx_from_data(data) - except ValidationError as e: - _logger.info('Received notification for tx %s. Skipped it because of %s', tx_reference, e) - return False - - PaymentAcquirerStripe._verify_stripe_signature(odoo_tx.acquirer_id) - - url = 'payment_intents/%s' % odoo_tx.stripe_payment_intent - stripe_tx = odoo_tx.acquirer_id._stripe_request(url) - - if 'error' in stripe_tx: - error = stripe_tx['error'] - raise ValidationError("Could not fetch Stripe payment intent related to %s because of %s; see %s" % ( - odoo_tx, error['message'], error['doc_url'])) - - if stripe_tx.get('charges') and stripe_tx.get('charges').get('total_count'): - charge = stripe_tx.get('charges').get('data')[0] - data.update(charge) - - return odoo_tx.form_feedback(data, 'stripe') - - -class PaymentTransactionStripe(models.Model): - _inherit = 'payment.transaction' - - stripe_payment_intent = fields.Char(string='Stripe Payment Intent ID', readonly=True) - stripe_payment_intent_secret = fields.Char(string='Stripe Payment Intent Secret', readonly=True) - - def _get_processing_info(self): - res = super()._get_processing_info() - if self.acquirer_id.provider == 'stripe': - stripe_info = { - 'stripe_payment_intent': self.stripe_payment_intent, - 'stripe_payment_intent_secret': self.stripe_payment_intent_secret, - 'stripe_publishable_key': self.acquirer_id.stripe_publishable_key, - } - res.update(stripe_info) - return res - - def form_feedback(self, data, acquirer_name): - if data.get('reference') and acquirer_name == 'stripe': - transaction = self.env['payment.transaction'].search([('reference', '=', data['reference'])]) - - url = 'payment_intents/%s' % transaction.stripe_payment_intent - resp = transaction.acquirer_id._stripe_request(url) - if resp.get('charges') and resp.get('charges').get('total_count'): - resp = resp.get('charges').get('data')[0] - - data.update(resp) - _logger.info('Stripe: entering form_feedback with post data %s' % pprint.pformat(data)) - return super(PaymentTransactionStripe, self).form_feedback(data, acquirer_name) - - def _stripe_create_payment_intent(self, acquirer_ref=None, email=None): - if not self.payment_token_id.stripe_payment_method: - # old token before using sca, need to fetch data from the api - self.payment_token_id._stripe_sca_migrate_customer() - - charge_params = { - 'amount': int(self.amount if self.currency_id.name in INT_CURRENCIES else float_round(self.amount * 100, 2)), - 'currency': self.currency_id.name.lower(), - 'off_session': True, - 'confirm': True, - 'payment_method': self.payment_token_id.stripe_payment_method, - 'customer': self.payment_token_id.acquirer_ref, - "description": self.reference, - } - if not self.env.context.get('off_session'): - charge_params.update(setup_future_usage='off_session', off_session=False) - _logger.info('_stripe_create_payment_intent: Sending values to stripe, values:\n%s', pprint.pformat(charge_params)) - - res = self.acquirer_id._stripe_request('payment_intents', charge_params) - if res.get('charges') and res.get('charges').get('total_count'): - res = res.get('charges').get('data')[0] - - _logger.info('_stripe_create_payment_intent: Values received:\n%s', pprint.pformat(res)) - return res - - def stripe_s2s_do_transaction(self, **kwargs): - self.ensure_one() - result = self._stripe_create_payment_intent(acquirer_ref=self.payment_token_id.acquirer_ref, email=self.partner_email) - return self._stripe_s2s_validate_tree(result) - - def _create_stripe_refund(self): - - refund_params = { - 'charge': self.acquirer_reference, - 'amount': int(float_round(self.amount * 100, 2)), # by default, stripe refund the full amount (we don't really need to specify the value) - 'metadata[reference]': self.reference, - } - - _logger.info('_create_stripe_refund: Sending values to stripe URL, values:\n%s', pprint.pformat(refund_params)) - res = self.acquirer_id._stripe_request('refunds', refund_params) - _logger.info('_create_stripe_refund: Values received:\n%s', pprint.pformat(res)) - - return res - - def stripe_s2s_do_refund(self, **kwargs): - self.ensure_one() - result = self._create_stripe_refund() - return self._stripe_s2s_validate_tree(result) - - @api.model - def _stripe_form_get_tx_from_data(self, data): - """ Given a data dict coming from stripe, verify it and find the related - transaction record. """ - reference = data.get('reference') - if not reference: - stripe_error = data.get('error', {}).get('message', '') - _logger.error('Stripe: invalid reply received from stripe API, looks like ' - 'the transaction failed. (error: %s)', stripe_error or 'n/a') - error_msg = _("We're sorry to report that the transaction has failed.") - if stripe_error: - error_msg += " " + (_("Stripe gave us the following info about the problem: '%s'") % - stripe_error) - error_msg += " " + _("Perhaps the problem can be solved by double-checking your " - "credit card details, or contacting your bank?") - raise ValidationError(error_msg) - - tx = self.search([('reference', '=', reference)]) - if not tx: - error_msg = _('Stripe: no order found for reference %s', reference) - _logger.error(error_msg) - raise ValidationError(error_msg) - elif len(tx) > 1: - error_msg = _('Stripe: %(count)s orders found for reference %(reference)s', count=len(tx), reference=reference) - _logger.error(error_msg) - raise ValidationError(error_msg) - return tx[0] - - def _stripe_s2s_validate_tree(self, tree): - self.ensure_one() - if self.state not in ("draft", "pending"): - _logger.info('Stripe: trying to validate an already validated tx (ref %s)', self.reference) - return True - - status = tree.get('status') - tx_id = tree.get('id') - tx_secret = tree.get("client_secret") - pi_id = tree.get('payment_intent') - vals = { - "date": fields.datetime.now(), - "acquirer_reference": tx_id, - "stripe_payment_intent": pi_id or tx_id, - "stripe_payment_intent_secret": tx_secret - } - if status == 'succeeded': - self.write(vals) - self._set_transaction_done() - self.execute_callback() - if self.type == 'form_save': - s2s_data = { - 'customer': tree.get('customer'), - 'payment_method': tree.get('payment_method'), - 'card': tree.get('payment_method_details').get('card'), - 'acquirer_id': self.acquirer_id.id, - 'partner_id': self.partner_id.id - } - token = self.acquirer_id.stripe_s2s_form_process(s2s_data) - self.payment_token_id = token.id - if self.payment_token_id: - self.payment_token_id.verified = True - return True - if status in ('processing', 'requires_action'): - self.write(vals) - self._set_transaction_pending() - return True - if status == 'requires_payment_method': - self._set_transaction_cancel() - self.acquirer_id._stripe_request('payment_intents/%s/cancel' % self.stripe_payment_intent) - return False - else: - error = tree.get("failure_message") or tree.get('error', {}).get('message') - self._set_transaction_error(error) - return False - - def _stripe_form_get_invalid_parameters(self, data): - invalid_parameters = [] - if data.get('amount') != int(self.amount if self.currency_id.name in INT_CURRENCIES else float_round(self.amount * 100, 2)): - invalid_parameters.append(('Amount', data.get('amount'), self.amount * 100)) - if data.get('currency') and data.get('currency').upper() != self.currency_id.name: - invalid_parameters.append(('Currency', data.get('currency'), self.currency_id.name)) - if data.get('payment_intent') and data.get('payment_intent') != self.stripe_payment_intent: - invalid_parameters.append(('Payment Intent', data.get('payment_intent'), self.stripe_payment_intent)) - return invalid_parameters - - def _stripe_form_validate(self, data): - return self._stripe_s2s_validate_tree(data) - - -class PaymentTokenStripe(models.Model): - _inherit = 'payment.token' - - stripe_payment_method = fields.Char('Payment Method ID') - - @api.model - def stripe_create(self, values): - if values.get('stripe_payment_method') and not values.get('acquirer_ref'): - partner_id = self.env['res.partner'].browse(values.get('partner_id')) - payment_acquirer = self.env['payment.acquirer'].browse(values.get('acquirer_id')) - - # create customer to stipe - customer_data = { - 'email': partner_id.email - } - cust_resp = payment_acquirer._stripe_request('customers', customer_data) - - # link customer with payment method - api_url_payment_method = 'payment_methods/%s/attach' % values['stripe_payment_method'] - method_data = { - 'customer': cust_resp.get('id') - } - payment_acquirer._stripe_request(api_url_payment_method, method_data) - return { - 'acquirer_ref': cust_resp['id'], - } - return values - - def _stripe_sca_migrate_customer(self): - """Migrate a token from the old implementation of Stripe to the SCA one. - - In the old implementation, it was possible to create a valid charge just by - giving the customer ref to ask Stripe to use the default source (= default - card). Since we have a one-to-one matching between a saved card, this used to - work well - but now we need to specify the payment method for each call and so - we have to contact stripe to get the default source for the customer and save it - in the payment token. - This conversion will happen once per token, the first time it gets used following - the installation of the module.""" - self.ensure_one() - url = "customers/%s" % (self.acquirer_ref) - data = self.acquirer_id._stripe_request(url, method="GET") - sources = data.get('sources', {}).get('data', []) - pm_ref = False - if sources: - if len(sources) > 1: - _logger.warning('stripe sca customer conversion: there should be a single saved source per customer!') - pm_ref = sources[0].get('id') - else: - url = 'payment_methods' - params = { - 'type': 'card', - 'customer': self.acquirer_ref, - } - payment_methods = self.acquirer_id._stripe_request(url, params, method='GET') - cards = payment_methods.get('data', []) - if len(cards) > 1: - _logger.warning('stripe sca customer conversion: there should be a single saved source per customer!') - pm_ref = cards and cards[0].get('id') - if not pm_ref: - raise ValidationError(_('Unable to convert Stripe customer for SCA compatibility. Is there at least one card for this customer in the Stripe backend?')) - self.stripe_payment_method = pm_ref - _logger.info('converted old customer ref to sca-compatible record for payment token %s', self.id) diff --git a/addons/payment_stripe/models/payment_acquirer.py b/addons/payment_stripe/models/payment_acquirer.py new file mode 100644 index 00000000000..8940e113624 --- /dev/null +++ b/addons/payment_stripe/models/payment_acquirer.py @@ -0,0 +1,83 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging + +import requests +from werkzeug import urls + +from odoo import _, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class PaymentAcquirer(models.Model): + _inherit = 'payment.acquirer' + + provider = fields.Selection( + selection_add=[('stripe', "Stripe")], ondelete={'stripe': 'set default'}) + stripe_publishable_key = fields.Char( + string="Publishable Key", help="The key solely used to identify the account with Stripe", + required_if_provider='stripe') + stripe_secret_key = fields.Char( + string="Secret Key", required_if_provider='stripe', groups='base.group_system') + stripe_webhook_secret = fields.Char( + string="Webhook Signing Secret", + help="If a webhook is enabled on your Stripe account, this signing secret must be set to " + "authenticate the messages sent from Stripe to Odoo.", + groups='base.group_system') + + def _get_validation_amount(self): + """ Override of payment to return the amount for Stripe validation operations. + + :return: The validation amount + :rtype: float + """ + res = super()._get_validation_amount() + if self.provider != 'stripe': + return res + + return 1.0 + + def _stripe_make_request(self, endpoint, payload=None, method='POST'): + """ Make a request to Stripe API at the specified endpoint. + + Note: self.ensure_one() + + :param str endpoint: The endpoint to be reached by the request + :param dict payload: The payload of the request + :param str method: The HTTP method of the request + :return The JSON-formatted content of the response + :rtype: dict + :raise: ValidationError if an HTTP error occurs + """ + self.ensure_one() + + url = urls.url_join('https://api.stripe.com/v1/', endpoint) + headers = { + 'AUTHORIZATION': f'Bearer {self.stripe_secret_key}', + 'Stripe-Version': '2019-05-16', # SetupIntent needs a specific version + } + try: + response = requests.request(method, url, data=payload, headers=headers, timeout=60) + # Stripe can send 4XX errors for payment failures (not only for badly-formed requests). + # Check if an error code is present in the response content and raise only if not. + # See https://stripe.com/docs/error-codes. + if not response.ok \ + and 400 <= response.status_code < 500 \ + and response.json().get('error'): # The 'code' entry is sometimes missing + try: + response.raise_for_status() + except requests.exceptions.HTTPError: + _logger.exception("invalid API request at %s with data %s", url, payload) + error_msg = response.json().get('error', {}).get('message', '') + raise ValidationError( + "Stripe: " + _( + "The communication with the API failed.\n" + "Stripe gave us the following info about the problem:\n'%s'", error_msg + ) + ) + except requests.exceptions.ConnectionError: + _logger.exception("unable to reach endpoint at %s", url) + raise ValidationError("Stripe: " + _("Could not establish the connection to the API.")) + return response.json() diff --git a/addons/payment_stripe/models/payment_token.py b/addons/payment_stripe/models/payment_token.py new file mode 100644 index 00000000000..367c4b4effa --- /dev/null +++ b/addons/payment_stripe/models/payment_token.py @@ -0,0 +1,50 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import pprint + +from odoo import _, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class PaymentToken(models.Model): + _inherit = 'payment.token' + + stripe_payment_method = fields.Char(string="Stripe Payment Method ID", readonly=True) + + def _stripe_sca_migrate_customer(self): + """ Migrate a token from the old implementation of Stripe to the SCA-compliant one. + + In the old implementation, it was possible to create a Charge by giving only the customer id + and let Stripe use the default source (= default payment method). Stripe now requires to + specify the payment method for each new PaymentIntent. To do so, we fetch the payment method + associated to a customer and save its id on the token. + This migration happens once per token created with the old implementation. + + Note: self.ensure_one() + + :return: None + """ + self.ensure_one() + + # Fetch the available payment method of type 'card' for the given customer + response_content = self.acquirer_id._stripe_make_request( + 'payment_methods', + payload={ + 'customer': self.acquirer_ref, + 'type': 'card', + 'limit': 1, # A new customer is created for each new token. Never > 1 card. + }, + method='GET' + ) + _logger.info("received payment_methods response:\n%s", pprint.pformat(response_content)) + + # Store the payment method ID on the token + payment_methods = response_content.get('data', []) + payment_method_id = payment_methods and payment_methods[0].get('id') + if not payment_method_id: + raise ValidationError("Stripe: " + _("Unable to convert payment token to new API.")) + self.stripe_payment_method = payment_method_id + _logger.info("converted token with id %s to new API", self.id) diff --git a/addons/payment_stripe/models/payment_transaction.py b/addons/payment_stripe/models/payment_transaction.py new file mode 100644 index 00000000000..4e4bbeb1292 --- /dev/null +++ b/addons/payment_stripe/models/payment_transaction.py @@ -0,0 +1,301 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import pprint + +from werkzeug import urls + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + +from odoo.addons.payment import utils as payment_utils +from odoo.addons.payment_stripe.const import INTENT_STATUS_MAPPING, PAYMENT_METHOD_TYPES +from odoo.addons.payment_stripe.controllers.main import StripeController + +_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. + + Note: self.ensure_one() from `_get_processing_values` + + :param dict processing_values: The generic processing values of the transaction + :return: The dict of acquirer-specific processing values + :rtype: dict + """ + res = super()._get_specific_processing_values(processing_values) + if self.provider != 'stripe' or self.operation == 'online_token': + return res + + checkout_session = self._stripe_create_checkout_session() + return { + 'publishable_key': self.acquirer_id.stripe_publishable_key, + 'session_id': checkout_session['id'], + } + + def _stripe_create_checkout_session(self): + """ Create and return a Checkout Session. + + :return: The Checkout Session + :rtype: dict + """ + # Filter payment method types by available payment method + existing_pms = [pm.name.lower() for pm in self.env['payment.icon'].search([])] + linked_pms = [pm.name.lower() for pm in self.acquirer_id.payment_icon_ids] + pm_filtered_pmts = filter( + lambda pmt: pmt.name == 'card' + # If the PM (payment.icon) 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 acquirer in the first place. + or (pmt.name in 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 + ) + 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 + customer = self._stripe_create_customer() + common_session_values = { + **pmt_values, + 'client_reference_id': self.reference, + # 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'], + } + base_url = self.acquirer_id._get_base_url() + if self.operation == 'online_redirect': + return_url = f'{urls.url_join(base_url, StripeController._checkout_return_url)}' \ + f'?reference={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 + checkout_session = self.acquirer_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, + } + ) + 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={self.reference}&checkout_session_id={{CHECKOUT_SESSION_ID}}' + checkout_session = self.acquirer_id._stripe_make_request( + 'checkout/sessions', payload={ + **common_session_values, + 'mode': 'setup', + 'success_url': return_url, + 'cancel_url': return_url, + } + ) + return checkout_session + + def _stripe_create_customer(self): + """ Create and return a Customer. + + :return: The Customer + :rtype: dict + """ + customer = self.acquirer_id._stripe_make_request( + 'customers', payload={ + 'address[city]': self.partner_city or None, + 'address[country]': self.partner_country_id.code or None, + 'address[line1]': self.partner_address or None, + 'address[postal_code]': self.partner_zip or None, + 'address[state]': self.partner_state_id.name or None, + 'description': f'ODOO_PARTNER_{self.partner_id.id}', + 'email': self.partner_email, + 'name': self.partner_name, + 'phone': self.partner_phone or None, + } + ) + return customer + + 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 != 'stripe': + return + + # Make the payment request to Stripe + if not self.token_id: + raise UserError("Stripe: " + _("The transaction is not linked to a token.")) + + payment_intent = self._stripe_create_payment_intent() + feedback_data = { + 'reference': self.reference, + 'payment_intent': payment_intent, + } + _logger.info("entering _handle_feedback_data with data:\n%s", pprint.pformat(feedback_data)) + self._handle_feedback_data('stripe', feedback_data) + + def _stripe_create_payment_intent(self): + """ Create and return a PaymentIntent. + + :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() + + payment_intent = self.acquirer_id._stripe_make_request('payment_intents', 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, + }) + return payment_intent + + @api.model + def _get_tx_from_feedback_data(self, provider, data): + """ Override of payment to find the transaction based on Stripe data. + + :param str provider: The provider of the acquirer that handled the transaction + :param dict data: The feedback data sent by the provider + :return: The transaction if found + :rtype: recordset of `payment.transaction` + :raise: ValidationError if inconsistent data were received + :raise: ValidationError if the data match no transaction + """ + tx = super()._get_tx_from_feedback_data(provider, data) + if provider != 'stripe': + return tx + + reference = data.get('reference') + if not reference: + raise ValidationError("Stripe: " + _("Received data with missing merchant reference")) + + tx = self.search([('reference', '=', reference), ('provider', '=', 'stripe')]) + if not tx: + raise ValidationError( + "Stripe: " + _("No transaction found matching reference %s.", reference) + ) + return tx + + def _process_feedback_data(self, data): + """ Override of payment to process the transaction based on Adyen data. + + Note: self.ensure_one() + + :param dict data: The feedback 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. + :return: None + :raise: ValidationError if inconsistent data were received + """ + super()._process_feedback_data(data) + if self.provider != 'stripe': + return + + # Handle the intent status + if self.operation == 'online_redirect' or self.operation == 'online_token': + intent_status = data.get('payment_intent', {}).get('status') + else: # 'validation' + intent_status = data.get('setup_intent', {}).get('status') + if not intent_status: + raise ValidationError( + "Stripe: " + _("Received data with missing intent status.") + ) + + if intent_status in INTENT_STATUS_MAPPING['draft']: + pass + elif intent_status in INTENT_STATUS_MAPPING['pending']: + self._set_pending() + elif intent_status in INTENT_STATUS_MAPPING['done']: + if self.tokenize: + self._stripe_tokenize_from_feedback_data(data) + self._set_done() + elif intent_status in INTENT_STATUS_MAPPING['cancel']: + self._set_canceled() + else: # Classify unknown intent statuses as `error` tx state + _logger.warning("received data with invalid intent status: %s", intent_status) + self._set_error( + "Stripe: " + _("Received data with invalid intent status: %s", intent_status) + ) + + def _stripe_tokenize_from_feedback_data(self, data): + """ Create a new token based on the feedback data. + + :param dict data: The feedback data built with Stripe objects. See `_process_feedback_data`. + :return: None + """ + if self.operation == 'online_redirect': + payment_method_id = data.get('charge', {}).get('payment_method') + customer_id = data.get('charge', {}).get('customer') + else: # 'validation' + payment_method_id = data.get('setup_intent', {}).get('payment_method', {}).get('id') + customer_id = data.get('setup_intent', {}).get('customer') + payment_method = data.get('payment_method') + if not payment_method_id or not payment_method: + _logger.warning("requested tokenization with payment method missing from feedback data") + 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 + + token = self.env['payment.token'].create({ + 'acquirer_id': self.acquirer_id.id, + 'name': payment_utils.build_token_name(payment_method['card'].get('last4')), + 'partner_id': self.partner_id.id, + 'acquirer_ref': customer_id, + 'verified': True, + 'stripe_payment_method': payment_method_id, + }) + self.write({ + 'token_id': token, + 'tokenize': False, + }) + _logger.info( + "created token with id %s for partner with id %s", token.id, self.partner_id.id + ) diff --git a/addons/payment_stripe/static/src/js/payment_form.js b/addons/payment_stripe/static/src/js/payment_form.js index 311f62bfc8e..3510f6fec41 100644 --- a/addons/payment_stripe/static/src/js/payment_form.js +++ b/addons/payment_stripe/static/src/js/payment_form.js @@ -1,190 +1,35 @@ -odoo.define('payment_stripe.payment_form', function (require) { -"use strict"; +odoo.define('payment_stripe.payment_form', require => { + 'use strict'; -var ajax = require('web.ajax'); -var core = require('web.core'); -var Dialog = require('web.Dialog'); -var PaymentForm = require('payment.payment_form'); + const checkoutForm = require('payment.checkout_form'); + const manageForm = require('payment.manage_form'); -var qweb = core.qweb; -var _t = core._t; + const stripeMixin = { -ajax.loadXML('/payment_stripe/static/src/xml/stripe_templates.xml', qweb); - -PaymentForm.include({ - - willStart: function () { - return this._super.apply(this, arguments).then(function () { - return ajax.loadJS("https://js.stripe.com/v3/"); - }) - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * called when clicking on pay now or add payment event to create token for credit card/debit card. - * - * @private - * @param {Event} ev - * @param {DOMElement} checkedRadio - * @param {Boolean} addPmEvent - */ - _createStripeToken: function (ev, $checkedRadio, addPmEvent) { - var self = this; - if (ev.type === 'submit') { - var button = $(ev.target).find('*[type="submit"]')[0] - } else { - var button = ev.target; - } - this.disableButton(button); - var acquirerID = this.getAcquirerIdFromRadio($checkedRadio); - var acquirerForm = this.$('#o_payment_add_token_acq_' + acquirerID); - var inputsForm = $('input', acquirerForm); - if (this.options.partnerId === undefined) { - console.warn('payment_form: unset partner_id when adding new token; things could go wrong'); - } - - var formData = self.getFormData(inputsForm); - var stripe = this.stripe; - var card = this.stripe_card_element; - if (card._invalid) { - return; - } - return this._rpc({ - route: '/payment/stripe/s2s/create_setup_intent', - params: {'acquirer_id': formData.acquirer_id} - }).then(function(intent_secret){ - return stripe.handleCardSetup(intent_secret, card); - }).then(function(result) { - if (result.error) { - return Promise.reject({"message": {"data": { "arguments": [result.error.message]}}}); - } else { - _.extend(formData, {"payment_method": result.setupIntent.payment_method}); - return self._rpc({ - route: formData.data_set, - params: formData, - }) + /** + * Redirect the customer to Stripe hosted payment page. + * + * @override method from payment.payment_form_mixin + * @private + * @param {string} provider - The provider of the payment option's acquirer + * @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 (provider, paymentOptionId, processingValues) { + if (provider !== 'stripe') { + return this._super(...arguments); } - }).then(function(result) { - if (addPmEvent) { - if (formData.return_url) { - window.location = formData.return_url; - } else { - window.location.reload(); - } - } else { - $checkedRadio.val(result.id); - self.el.submit(); - } - }).guardedCatch(function (error) { - // We don't want to open the Error dialog since - // we already have a container displaying the error - if (error.event) { - error.event.preventDefault(); - } - // if the rpc fails, pretty obvious - self.enableButton(button); - self.displayError( - _t('Unable to save card'), - _t("We are not able to add your payment method at the moment. ") + - self._parseError(error) - ); - }); - }, - /** - * called when clicking a Stripe radio if configured for s2s flow; instanciates the card and bind it to the widget. - * - * @private - * @param {DOMElement} checkedRadio - */ - _bindStripeCard: function ($checkedRadio) { - var acquirerID = this.getAcquirerIdFromRadio($checkedRadio); - var acquirerForm = this.$('#o_payment_add_token_acq_' + acquirerID); - var inputsForm = $('input', acquirerForm); - var formData = this.getFormData(inputsForm); - var stripe = Stripe(formData.stripe_publishable_key); - var element = stripe.elements(); - var card = element.create('card', {hidePostalCode: true}); - card.mount('#card-element'); - card.on('ready', function(ev) { - card.focus(); - }); - card.addEventListener('change', function (event) { - var displayError = document.getElementById('card-errors'); - displayError.textContent = ''; - if (event.error) { - displayError.textContent = event.error.message; - } - }); - this.stripe = stripe; - this.stripe_card_element = card; - }, - /** - * destroys the card element and any stripe instance linked to the widget. - * - * @private - */ - _unbindStripeCard: function () { - if (this.stripe_card_element) { - this.stripe_card_element.destroy(); - } - this.stripe = undefined; - this.stripe_card_element = undefined; - }, - /** - * @override - */ - updateNewPaymentDisplayStatus: function () { - var $checkedRadio = this.$('input[type="radio"]:checked'); - if ($checkedRadio.length !== 1) { - return; - } - var provider = $checkedRadio.data('provider') - if (provider === 'stripe') { - // always re-init stripe (in case of multiple acquirers for stripe, make sure the stripe instance is using the right key) - this._unbindStripeCard(); - if (this.isNewPaymentRadio($checkedRadio)) { - this._bindStripeCard($checkedRadio); - } - } - return this._super.apply(this, arguments); - }, + const stripeJS = Stripe(processingValues['publishable_key']); + stripeJS.redirectToCheckout({ + sessionId: processingValues['session_id'] + }); + }, - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- + }; - /** - * @override - */ - payEvent: function (ev) { - ev.preventDefault(); - var $checkedRadio = this.$('input[type="radio"]:checked'); + checkoutForm.include(stripeMixin); + manageForm.include(stripeMixin); - // first we check that the user has selected a stripe as s2s payment method - if ($checkedRadio.length === 1 && this.isNewPaymentRadio($checkedRadio) && $checkedRadio.data('provider') === 'stripe') { - return this._createStripeToken(ev, $checkedRadio); - } else { - return this._super.apply(this, arguments); - } - }, - /** - * @override - */ - addPmEvent: function (ev) { - ev.stopPropagation(); - ev.preventDefault(); - var $checkedRadio = this.$('input[type="radio"]:checked'); - - // first we check that the user has selected a stripe as add payment method - if ($checkedRadio.length === 1 && this.isNewPaymentRadio($checkedRadio) && $checkedRadio.data('provider') === 'stripe') { - return this._createStripeToken(ev, $checkedRadio, true); - } else { - return this._super.apply(this, arguments); - } - }, -}); }); diff --git a/addons/payment_stripe/static/src/js/payment_processing.js b/addons/payment_stripe/static/src/js/payment_processing.js deleted file mode 100644 index f98cd05a8c7..00000000000 --- a/addons/payment_stripe/static/src/js/payment_processing.js +++ /dev/null @@ -1,48 +0,0 @@ -odoo.define('payment_stripe.processing', function (require) { -'use strict'; - -var ajax = require('web.ajax'); -var rpc = require('web.rpc') -var publicWidget = require('web.public.widget'); - -var PaymentProcessing = publicWidget.registry.PaymentProcessing; - -return PaymentProcessing.include({ - init: function () { - this._super.apply(this, arguments); - this._authInProgress = false; - }, - willStart: function () { - return this._super.apply(this, arguments).then(function () { - return ajax.loadJS("https://js.stripe.com/v3/"); - }) - }, - _stripeAuthenticate: function (tx) { - var stripe = Stripe(tx.stripe_publishable_key); - return stripe.handleCardPayment(tx.stripe_payment_intent_secret) - .then(function(result) { - if (result.error) { - return Promise.reject({"message": {"data": { "message": result.error.message}}}); - } - return rpc.query({ - route: '/payment/stripe/s2s/process_payment_intent', - params: _.extend({}, result.paymentIntent, {reference: tx.reference}), - }); - }).then(function() { - window.location = '/payment/process'; - }).guardedCatch(function () { - this._authInProgress = false; - }); - }, - processPolledData: function(transactions) { - this._super.apply(this, arguments); - for (var itx=0; itx < transactions.length; itx++) { - var tx = transactions[itx]; - if (tx.acquirer_provider === 'stripe' && tx.state === 'pending' && tx.stripe_payment_intent_secret && !this._authInProgress) { - this._authInProgress = true; - this._stripeAuthenticate(tx); - } - } - }, -}); -}); \ No newline at end of file diff --git a/addons/payment_stripe/static/src/js/stripe.js b/addons/payment_stripe/static/src/js/stripe.js deleted file mode 100644 index 4868c9db9bd..00000000000 --- a/addons/payment_stripe/static/src/js/stripe.js +++ /dev/null @@ -1,81 +0,0 @@ -odoo.define('payment_stripe.stripe', function (require) { -"use strict"; - -var ajax = require('web.ajax'); -var core = require('web.core'); - -var qweb = core.qweb; -var _t = core._t; - -ajax.loadXML('/payment_stripe/static/src/xml/stripe_templates.xml', qweb); - -if ($.blockUI) { - // our message needs to appear above the modal dialog - $.blockUI.defaults.baseZ = 2147483647; //same z-index as StripeCheckout - $.blockUI.defaults.css.border = '0'; - $.blockUI.defaults.css["background-color"] = ''; - $.blockUI.defaults.overlayCSS["opacity"] = '0.9'; -} - -require('web.dom_ready'); -if (!$('.o_payment_form').length) { - return Promise.reject("DOM doesn't contain '.o_payment_form'"); -} - -var observer = new MutationObserver(function (mutations, observer) { - for (var i = 0; i < mutations.length; ++i) { - for (var j = 0; j < mutations[i].addedNodes.length; ++j) { - if (mutations[i].addedNodes[j].tagName.toLowerCase() === "form" && mutations[i].addedNodes[j].getAttribute('provider') === 'stripe') { - _redirectToStripeCheckout($(mutations[i].addedNodes[j])); - } - } - } -}); - -function displayError(message) { - var wizard = $(qweb.render('stripe.error', {'msg': message || _t('Payment error')})); - wizard.appendTo($('body')).modal({'keyboard': true}); - if ($.blockUI) { - $.unblockUI(); - } - $("#o_payment_form_pay").removeAttr('disabled'); -} - - -function _redirectToStripeCheckout(providerForm) { - // Open Checkout with further options - if ($.blockUI) { - var msg = _t("Just one more second, We are redirecting you to Stripe..."); - $.blockUI({ - 'message': '

' + - '
' + msg + - '

' - }); - } - - var paymentForm = $('.o_payment_form'); - if (!paymentForm.find('i').length) { - paymentForm.append(''); - paymentForm.attr('disabled', 'disabled'); - } - - var _getStripeInputValue = function (name) { - return providerForm.find('input[name="' + name + '"]').val(); - }; - - var stripe = Stripe(_getStripeInputValue('stripe_key')); - - stripe.redirectToCheckout({ - sessionId: _getStripeInputValue('session_id') - }).then(function (result) { - if (result.error) { - displayError(result.error.message); - } - }); -} - -$.getScript("https://js.stripe.com/v3/", function (data, textStatus, jqxhr) { - observer.observe(document.body, {childList: true}); - _redirectToStripeCheckout($('form[provider="stripe"]')); -}); -}); diff --git a/addons/payment_stripe/static/src/xml/stripe_templates.xml b/addons/payment_stripe/static/src/xml/stripe_templates.xml deleted file mode 100644 index 97fd4c9796c..00000000000 --- a/addons/payment_stripe/static/src/xml/stripe_templates.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - diff --git a/addons/payment_stripe/tests/__init__.py b/addons/payment_stripe/tests/__init__.py index 228d5775da5..97819241ff5 100644 --- a/addons/payment_stripe/tests/__init__.py +++ b/addons/payment_stripe/tests/__init__.py @@ -1,2 +1,4 @@ -# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import common from . import test_stripe diff --git a/addons/payment_stripe/tests/common.py b/addons/payment_stripe/tests/common.py new file mode 100644 index 00000000000..4c928a9b65a --- /dev/null +++ b/addons/payment_stripe/tests/common.py @@ -0,0 +1,18 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.payment.tests.common import PaymentCommon + + +class StripeCommon(PaymentCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.stripe = cls._prepare_acquirer('stripe', update_values={ + 'stripe_secret_key': 'sk_test_KJtHgNwt2KS3xM7QJPr4O5E8', + 'stripe_publishable_key': 'pk_test_QSPnimmb4ZhtkEy3Uhdm4S6J', + 'stripe_webhook_secret': 'whsec_vG1fL6CMUouQ7cObF2VJprLVXT5jBLxB', + 'payment_icon_ids': [(5, 0, 0)], + }) + + cls.acquirer = cls.stripe diff --git a/addons/payment_stripe/tests/stripe_mocks.py b/addons/payment_stripe/tests/stripe_mocks.py deleted file mode 100644 index 6364fbf115a..00000000000 --- a/addons/payment_stripe/tests/stripe_mocks.py +++ /dev/null @@ -1,31 +0,0 @@ -checkout_session_signature = 't=1591264652,v1=1f0d3e035d8de956396b1d91727267fbbf483253e7702e46357b4d2bfa078ba4,v0=20d76342f4704d49f8f89db03acff7cf04afa48ca70a22d608b4649b332c1f51' -checkout_session_body = b'{\n "id": "evt_1GqFpHAlCFm536g8NYSLoccF",\n "object": "event",\n "api_version": "2019-05-16",\n "created": 1591264651,\n "data": {\n "object": {\n "id": "cs_test_SI8yz61JCZ4gxd7Z5oGfQSn9ZbubC6SZF3bJTxvy2PVqSd3dzbDV1kyd",\n "object": "checkout.session",\n "billing_address_collection": null,\n "cancel_url": "https://httpbin.org/post",\n "client_reference_id": null,\n "customer": "cus_HP3xLqXMIwBfTg",\n "customer_email": null,\n "display_items": [\n {\n "amount": 1500,\n "currency": "usd",\n "custom": {\n "description": "comfortable cotton t-shirt",\n "images": null,\n "name": "t-shirt"\n },\n "quantity": 2,\n "type": "custom"\n }\n ],\n "livemode": false,\n "locale": null,\n "metadata": {\n },\n "mode": "payment",\n "payment_intent": "pi_1GqFpCAlCFm536g8HsBSvSEt",\n "payment_method_types": [\n "card"\n ],\n "setup_intent": null,\n "shipping": null,\n "shipping_address_collection": null,\n "submit_type": null,\n "subscription": null,\n "success_url": "https://httpbin.org/post"\n }\n },\n "livemode": false,\n "pending_webhooks": 2,\n "request": {\n "id": null,\n "idempotency_key": null\n },\n "type": "checkout.session.completed"\n}' - -checkout_session_object = {'billing_address_collection': None, - 'cancel_url': 'https://httpbin.org/post', - 'client_reference_id': "tx_ref_test_handle_checkout_webhook", - 'customer': 'cus_HOgyjnjdgY6pmY', - 'customer_email': None, - 'display_items': [{'amount': 1500, - 'currency': 'usd', - 'custom': {'description': 'comfortable ' - 'cotton ' - 't-shirt', - 'images': None, - 'name': 't-shirt'}, - 'quantity': 2, - 'type': 'custom'}], - 'id': 'cs_test_sbTG0yGwTszAqFUP8Ulecr1bUwEyQEo29M8taYvdP7UA6Qr37qX6uA6w', - 'livemode': False, - 'locale': None, - 'metadata': {}, - 'mode': 'payment', - 'object': 'checkout.session', - 'payment_intent': 'pi_1GptaRAlCFm536g8AfCF6Zi0', - 'payment_method_types': ['card'], - 'setup_intent': None, - 'shipping': None, - 'shipping_address_collection': None, - 'submit_type': None, - 'subscription': None, - 'success_url': 'https://httpbin.org/post'} diff --git a/addons/payment_stripe/tests/test_stripe.py b/addons/payment_stripe/tests/test_stripe.py index 2ac2819398d..86b768bc2d2 100644 --- a/addons/payment_stripe/tests/test_stripe.py +++ b/addons/payment_stripe/tests/test_stripe.py @@ -1,318 +1,32 @@ -# -*- coding: utf-8 -*- -import odoo -from odoo import fields -from odoo.exceptions import ValidationError -from odoo.addons.payment.tests.common import PaymentAcquirerCommon +# Part of Odoo. See LICENSE file for full copyright and licensing details. + from unittest.mock import patch -from . import stripe_mocks -from ..models.payment import STRIPE_SIGNATURE_AGE_TOLERANCE + +from odoo.tests import tagged from odoo.tools import mute_logger - -class StripeCommon(PaymentAcquirerCommon): - - @classmethod - def setUpClass(cls, chart_template_ref=None): - super().setUpClass(chart_template_ref=chart_template_ref) - cls.stripe = cls.env.ref('payment.payment_acquirer_stripe') - cls.stripe.write({ - 'stripe_secret_key': 'sk_test_KJtHgNwt2KS3xM7QJPr4O5E8', - 'stripe_publishable_key': 'pk_test_QSPnimmb4ZhtkEy3Uhdm4S6J', - 'stripe_webhook_secret': 'whsec_vG1fL6CMUouQ7cObF2VJprLVXT5jBLxB', - 'state': 'test', - }) - cls.token = cls.env['payment.token'].create({ - 'name': 'Test Card', - 'acquirer_id': cls.stripe.id, - 'acquirer_ref': 'cus_G27S7FqQ2w3fuH', - 'stripe_payment_method': 'pm_1FW3DdAlCFm536g8eQoSCejY', - 'partner_id': cls.buyer.id, - 'verified': True, - }) - cls.ideal_icon = cls.env.ref("payment.payment_icon_cc_ideal") - cls.bancontact_icon = cls.env.ref("payment.payment_icon_cc_bancontact") - cls.p24_icon = cls.env.ref("payment.payment_icon_cc_p24") - cls.eps_icon = cls.env.ref("payment.payment_icon_cc_eps") - cls.giropay_icon = cls.env.ref("payment.payment_icon_cc_giropay") - cls.all_icons = [cls.ideal_icon, cls.bancontact_icon, cls.p24_icon, cls.eps_icon, cls.giropay_icon] - cls.stripe.write({'payment_icon_ids': [(5, 0, 0)]}) +from .common import StripeCommon -@odoo.tests.tagged('post_install', '-at_install', '-standard', 'external') +@tagged('post_install', '-at_install') class StripeTest(StripeCommon): - def run(self, result=None): - with mute_logger('odoo.addons.payment.models.payment_acquirer', 'odoo.addons.payment_stripe.models.payment'): - StripeCommon.run(self, result) + 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. - def test_10_stripe_s2s(self): - self.assertEqual(self.stripe.state, 'test', 'test without test environment') - # Create transaction - tx = self.env['payment.transaction'].create({ - 'reference': 'stripe_test_10_%s' % fields.datetime.now().strftime('%Y%m%d_%H%M%S'), - 'currency_id': self.currency_euro.id, - 'acquirer_id': self.stripe.id, - 'partner_id': self.buyer_id, - 'payment_token_id': self.token.id, - 'type': 'server2server', - 'amount': 115.0 - }) - tx.with_context(off_session=True).stripe_s2s_do_transaction() + # 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} + with patch.object( + type(self.env['payment.transaction']), + '_stripe_create_checkout_session', + mock_stripe_create_checkout_session, + ), mute_logger('odoo.addons.payment.models.payment_transaction'): + processing_values = tx._get_processing_values() - # Check state - self.assertEqual(tx.state, 'done', 'Stripe: Transcation has been discarded.') + self.assertEqual(processing_values['publishable_key'], self.stripe.stripe_publishable_key) + self.assertEqual(processing_values['session_id'], dummy_session_id) - def test_20_stripe_form_render(self): - self.assertEqual(self.stripe.state, 'test', 'test without test environment') - - # ---------------------------------------- - # Test: button direct rendering - # ---------------------------------------- - - # render the button - self.stripe.render('SO404', 320.0, self.currency_euro.id, values=self.buyer_values).decode('utf-8') - - def test_30_stripe_form_management(self): - self.assertEqual(self.stripe.state, 'test', 'test without test environment') - ref = 'stripe_test_30_%s' % fields.datetime.now().strftime('%Y%m%d_%H%M%S') - tx = self.env['payment.transaction'].create({ - 'amount': 4700.0, - 'acquirer_id': self.stripe.id, - 'currency_id': self.currency_euro.id, - 'reference': ref, - 'partner_name': 'Norbert Buyer', - 'partner_country_id': self.country_france.id, - 'payment_token_id': self.token.id, - }) - res = tx.with_context(off_session=True)._stripe_create_payment_intent() - tx.stripe_payment_intent = res.get('payment_intent') - - # typical data posted by Stripe after client has successfully paid - stripe_post_data = {'reference': ref} - # validate it - tx.form_feedback(stripe_post_data, 'stripe') - self.assertEqual(tx.state, 'done', 'Stripe: validation did not put tx into done state') - self.assertEqual(tx.acquirer_reference, stripe_post_data.get('id'), 'Stripe: validation did not update tx id') - - def test_add_available_payment_method_types_local_enabled(self): - self.stripe.payment_icon_ids = [(6, 0, [i.id for i in self.all_icons])] - tx_values = { - 'billing_partner_country': self.env.ref('base.be'), - 'currency': self.env.ref('base.EUR'), - 'type': 'form' - } - stripe_session_data = {} - - self.stripe._add_available_payment_method_types(stripe_session_data, tx_values) - - actual = {pmt for key, pmt in stripe_session_data.items() if key.startswith('payment_method_types')} - self.assertEqual({'card', 'bancontact'}, actual) - - def test_add_available_payment_method_types_local_enabled_2(self): - self.stripe.payment_icon_ids = [(6, 0, [i.id for i in self.all_icons])] - tx_values = { - 'billing_partner_country': self.env.ref('base.pl'), - 'currency': self.env.ref('base.PLN'), - 'type': 'form' - } - stripe_session_data = {} - - self.stripe._add_available_payment_method_types(stripe_session_data, tx_values) - - actual = {pmt for key, pmt in stripe_session_data.items() if key.startswith('payment_method_types')} - self.assertEqual({'card', 'p24'}, actual) - - def test_add_available_payment_method_types_pmt_does_not_exist(self): - self.bancontact_icon.unlink() - tx_values = { - 'billing_partner_country': self.env.ref('base.be'), - 'currency': self.env.ref('base.EUR'), - 'type': 'form' - } - stripe_session_data = {} - - self.stripe._add_available_payment_method_types(stripe_session_data, tx_values) - - actual = {pmt for key, pmt in stripe_session_data.items() if key.startswith('payment_method_types')} - self.assertEqual({'card', 'bancontact'}, actual) - - def test_add_available_payment_method_types_local_disabled(self): - tx_values = { - 'billing_partner_country': self.env.ref('base.be'), - 'currency': self.env.ref('base.EUR'), - 'type': 'form' - } - stripe_session_data = {} - - self.stripe._add_available_payment_method_types(stripe_session_data, tx_values) - - actual = {pmt for key, pmt in stripe_session_data.items() if key.startswith('payment_method_types')} - self.assertEqual({'card'}, actual) - - def test_add_available_payment_method_types_local_all_but_bancontact(self): - self.stripe.payment_icon_ids = [(4, icon.id) for icon in self.all_icons if icon.name.lower() != 'bancontact'] - tx_values = { - 'billing_partner_country': self.env.ref('base.be'), - 'currency': self.env.ref('base.EUR'), - 'type': 'form' - } - stripe_session_data = {} - - self.stripe._add_available_payment_method_types(stripe_session_data, tx_values) - - actual = {pmt for key, pmt in stripe_session_data.items() if key.startswith('payment_method_types')} - self.assertEqual({'card'}, actual) - - def test_add_available_payment_method_types_recurrent(self): - tx_values = { - 'billing_partner_country': self.env.ref('base.be'), - 'currency': self.env.ref('base.EUR'), - 'type': 'form_save' - } - stripe_session_data = {} - - self.stripe._add_available_payment_method_types(stripe_session_data, tx_values) - - actual = {pmt for key, pmt in stripe_session_data.items() if key.startswith('payment_method_types')} - self.assertEqual({'card'}, actual) - - def test_discarded_webhook(self): - self.assertFalse(self.env['payment.acquirer']._handle_stripe_webhook(dict(type='payment.intent.succeeded'))) - - def test_handle_checkout_webhook_no_secret(self): - self.stripe.stripe_webhook_secret = None - - with self.assertRaises(ValidationError): - self.env['payment.acquirer']._handle_stripe_webhook(dict(type='checkout.session.completed')) - - @patch('odoo.addons.payment_stripe.models.payment.request') - @patch('odoo.addons.payment_stripe.models.payment.datetime') - def test_handle_checkout_webhook(self, dt, request): - # pass signature verification - dt.utcnow.return_value.timestamp.return_value = 1591264652 - request.httprequest.headers = {'Stripe-Signature': stripe_mocks.checkout_session_signature} - request.httprequest.data = stripe_mocks.checkout_session_body - # test setup - tx = self.env['payment.transaction'].create({ - 'reference': 'tx_ref_test_handle_checkout_webhook', - 'currency_id': self.currency_euro.id, - 'acquirer_id': self.stripe.id, - 'partner_id': self.buyer_id, - 'payment_token_id': self.token.id, - 'type': 'server2server', - 'amount': 30 - }) - res = tx.with_context(off_session=True)._stripe_create_payment_intent() - tx.stripe_payment_intent = res.get('payment_intent') - stripe_object = stripe_mocks.checkout_session_object - - actual = self.stripe._handle_checkout_webhook(stripe_object) - - self.assertTrue(actual) - - @patch('odoo.addons.payment_stripe.models.payment.request') - @patch('odoo.addons.payment_stripe.models.payment.datetime') - def test_handle_checkout_webhook_wrong_amount(self, dt, request): - # pass signature verification - dt.utcnow.return_value.timestamp.return_value = 1591264652 - request.httprequest.headers = {'Stripe-Signature': stripe_mocks.checkout_session_signature} - request.httprequest.data = stripe_mocks.checkout_session_body - # test setup - bad_tx = self.env['payment.transaction'].create({ - 'reference': 'tx_ref_test_handle_checkout_webhook_wrong_amount', - 'currency_id': self.currency_euro.id, - 'acquirer_id': self.stripe.id, - 'partner_id': self.buyer_id, - 'payment_token_id': self.token.id, - 'type': 'server2server', - 'amount': 10 - }) - wrong_amount_stripe_payment_intent = bad_tx.with_context(off_session=True)._stripe_create_payment_intent() - tx = self.env['payment.transaction'].create({ - 'reference': 'tx_ref_test_handle_checkout_webhook', - 'currency_id': self.currency_euro.id, - 'acquirer_id': self.stripe.id, - 'partner_id': self.buyer_id, - 'payment_token_id': self.token.id, - 'type': 'server2server', - 'amount': 30 - }) - tx.stripe_payment_intent = wrong_amount_stripe_payment_intent.get('payment_intent') - stripe_object = stripe_mocks.checkout_session_object - - actual = self.env['payment.acquirer']._handle_checkout_webhook(stripe_object) - - self.assertFalse(actual) - - def test_handle_checkout_webhook_no_odoo_tx(self): - stripe_object = stripe_mocks.checkout_session_object - - actual = self.stripe._handle_checkout_webhook(stripe_object) - - self.assertFalse(actual) - - @patch('odoo.addons.payment_stripe.models.payment.request') - @patch('odoo.addons.payment_stripe.models.payment.datetime') - def test_handle_checkout_webhook_no_stripe_tx(self, dt, request): - # pass signature verification - dt.utcnow.return_value.timestamp.return_value = 1591264652 - request.httprequest.headers = {'Stripe-Signature': stripe_mocks.checkout_session_signature} - request.httprequest.data = stripe_mocks.checkout_session_body - # test setup - self.env['payment.transaction'].create({ - 'reference': 'tx_ref_test_handle_checkout_webhook', - 'currency_id': self.currency_euro.id, - 'acquirer_id': self.stripe.id, - 'partner_id': self.buyer_id, - 'payment_token_id': self.token.id, - 'type': 'server2server', - 'amount': 30 - }) - stripe_object = stripe_mocks.checkout_session_object - - with self.assertRaises(ValidationError): - self.stripe._handle_checkout_webhook(stripe_object) - - @patch('odoo.addons.payment_stripe.models.payment.request') - @patch('odoo.addons.payment_stripe.models.payment.datetime') - def test_verify_stripe_signature(self, dt, request): - dt.utcnow.return_value.timestamp.return_value = 1591264652 - request.httprequest.headers = {'Stripe-Signature': stripe_mocks.checkout_session_signature} - request.httprequest.data = stripe_mocks.checkout_session_body - - actual = self.stripe._verify_stripe_signature() - - self.assertTrue(actual) - - @patch('odoo.addons.payment_stripe.models.payment.request') - @patch('odoo.addons.payment_stripe.models.payment.datetime') - def test_verify_stripe_signature_tampered_body(self, dt, request): - dt.utcnow.return_value.timestamp.return_value = 1591264652 - request.httprequest.headers = {'Stripe-Signature': stripe_mocks.checkout_session_signature} - request.httprequest.data = stripe_mocks.checkout_session_body.replace(b'1500', b'10') - - with self.assertRaises(ValidationError): - self.stripe._verify_stripe_signature() - - @patch('odoo.addons.payment_stripe.models.payment.request') - @patch('odoo.addons.payment_stripe.models.payment.datetime') - def test_verify_stripe_signature_wrong_secret(self, dt, request): - dt.utcnow.return_value.timestamp.return_value = 1591264652 - request.httprequest.headers = {'Stripe-Signature': stripe_mocks.checkout_session_signature} - request.httprequest.data = stripe_mocks.checkout_session_body - self.stripe.write({ - 'stripe_webhook_secret': 'whsec_vG1fL6CMUouQ7cObF2VJprL_TAMPERED', - }) - - with self.assertRaises(ValidationError): - self.stripe._verify_stripe_signature() - - @patch('odoo.addons.payment_stripe.models.payment.request') - @patch('odoo.addons.payment_stripe.models.payment.datetime') - def test_verify_stripe_signature_too_old(self, dt, request): - dt.utcnow.return_value.timestamp.return_value = 1591264652 + STRIPE_SIGNATURE_AGE_TOLERANCE + 1 - request.httprequest.headers = {'Stripe-Signature': stripe_mocks.checkout_session_signature} - request.httprequest.data = stripe_mocks.checkout_session_body - - with self.assertRaises(ValidationError): - self.stripe._verify_stripe_signature() + def test_validation_amount(self): + self.assertEqual(self.stripe._get_validation_amount(), 1.0) diff --git a/addons/payment_stripe/views/assets.xml b/addons/payment_stripe/views/assets.xml new file mode 100644 index 00000000000..29382996a5d --- /dev/null +++ b/addons/payment_stripe/views/assets.xml @@ -0,0 +1,11 @@ + + + + - - - - - - diff --git a/addons/payment_stripe/views/payment_views.xml b/addons/payment_stripe/views/payment_views.xml index 74d9af04a24..3bfaf0e9499 100644 --- a/addons/payment_stripe/views/payment_views.xml +++ b/addons/payment_stripe/views/payment_views.xml @@ -1,22 +1,19 @@ - - payment.acquirer.form.inherit + + + Stripe Acquirer Form payment.acquirer - + - + - - + + - - - - - +