diff --git a/addons/payment_stripe_sca/__init__.py b/addons/payment_stripe_sca/__init__.py new file mode 100644 index 00000000000..bb0aae7ee63 --- /dev/null +++ b/addons/payment_stripe_sca/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import controllers +from . import models diff --git a/addons/payment_stripe_sca/__manifest__.py b/addons/payment_stripe_sca/__manifest__.py new file mode 100644 index 00000000000..6a2a0cac556 --- /dev/null +++ b/addons/payment_stripe_sca/__manifest__.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +# ____________________ +# / \ +# | DO NOT FORWARD | +# | PORT FURTHER | +# | THAN 12.4 | +# \____________________/ +# ! ! +# ! ! +# L_ ! +# / _)! +# / /__L +# _____/ (____) +# (____) +# _____ (____) +# \_(____) +# ! ! +# ! ! +# \__/ +# +# Starting with 12.5, the payment_stripe module contains these changes OoB + +{ + 'name': 'Stripe Payment Acquirer - Strong Customer Authentication Update', + 'category': 'Hidden', + 'summary': 'Payment Acquirer: Stripe Implementation for the EU PSD2', + 'version': '1.0', + 'description': """Stripe Payment Acquirer - Strong Customer Authentication Update""", + 'depends': ['payment_stripe'], + 'auto_install': True, + 'data': [ + 'views/assets.xml', + 'views/payment_templates.xml', + ], + 'images': ['static/description/icon.png'], +} diff --git a/addons/payment_stripe_sca/controllers/__init__.py b/addons/payment_stripe_sca/controllers/__init__.py new file mode 100644 index 00000000000..7036966b1ce --- /dev/null +++ b/addons/payment_stripe_sca/controllers/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import main \ No newline at end of file diff --git a/addons/payment_stripe_sca/controllers/main.py b/addons/payment_stripe_sca/controllers/main.py new file mode 100644 index 00000000000..a3037125301 --- /dev/null +++ b/addons/payment_stripe_sca/controllers/main.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import logging +import pprint +import werkzeug + +from odoo.http import route, request +from odoo.addons.payment_stripe.controllers.main import StripeController + +_logger = logging.getLogger(__name__) + + +class StripeControllerSCA(StripeController): + _success_url = '/payment/stripe/success' + _cancel_url = '/payment/stripe/cancel' + + @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') + + @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'))).s2s_process(kwargs) + + if not token: + res = { + 'result': False, + } + return res + + res = { + 'result': True, + 'id': token.id, + 'short_name': token.short_name, + '3d_secure': False, + 'verified': False, + } + + if verify_validity != False: + token.validate() + res['verified'] = token.verified + + return res + + @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._create_setup_intent(kwargs) + return res.get('client_secret') + + + @route('/payment/stripe/s2s/process_payment_intent', type='json') + def stripe_s2s_process_payment_intent(self, **post): + return request.env['payment.transaction'].sudo().form_feedback(post, 'stripe') + + + # These routes are deprecated, let's remove them for security's sake + def stripe_s2s_create_json(self, **post): + raise werkzeug.exceptions.NotFound() + + def stripe_s2s_create(self, **post): + raise werkzeug.exceptions.NotFound() + + def stripe_create_charge(self, **post): + raise werkzeug.exceptions.NotFound() diff --git a/addons/payment_stripe_sca/models/__init__.py b/addons/payment_stripe_sca/models/__init__.py new file mode 100644 index 00000000000..df1092a4fa3 --- /dev/null +++ b/addons/payment_stripe_sca/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import payment \ No newline at end of file diff --git a/addons/payment_stripe_sca/models/payment.py b/addons/payment_stripe_sca/models/payment.py new file mode 100644 index 00000000000..453f3a2678b --- /dev/null +++ b/addons/payment_stripe_sca/models/payment.py @@ -0,0 +1,372 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import logging +import requests +import pprint +from werkzeug import urls + +from odoo import api, models, fields, _ +from odoo.exceptions import ValidationError +from odoo.addons.payment_stripe_sca.controllers.main import StripeControllerSCA as StripeController +from odoo.addons.payment_stripe.models.payment import INT_CURRENCIES +from odoo.tools.float_utils import float_round + +_logger = logging.getLogger(__name__) + + +class PaymentAcquirerStripeSCA(models.Model): + _inherit = "payment.acquirer" + + def stripe_form_generate_values(self, tx_values): + self.ensure_one() + + base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + stripe_session_data = { + "payment_method_types[]": "card", + "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"], + "customer_email": tx_values["partner_email"], + } + tx_values["session_id"] = self._create_stripe_session(stripe_session_data) + + return tx_values + + 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) + try: + resp.raise_for_status() + except: + _logger.error(resp.text) + raise + 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"] + 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): + 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 stripe_s2s_form_validate(self, data): + return True + + +class PaymentTransactionStripeSCA(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_json_fields(self): + res = super()._get_json_fields() + res.append('stripe_payment_intent_secret') + return res + + 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 _create_stripe_charge(self, acquirer_ref=None, tokenid=None, email=None): + raise NotImplementedError( + "This method can no longer be used with the payment_stripe_sca module." + ) + + 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) + ) + # note: luckily, the base stripe module did not override this method, avoiding + # me from using a context key to avoid this call in the parent model + return super(PaymentTransactionStripeSCA, self).form_feedback(data, acquirer_name) + + def _stripe_create_payment_intent(self, acquirer_ref=None, email=None): + if self.stripe_payment_intent: + _logger.info( + "_stripe_create_payment_intent: trying to create an intent when one already exists (tx #%s), refetching values for intent %s", + self.id, self.stripe_payment_intent + ) + res = self.acquirer_id._stripe_request("payment_intents/%s" % self.stripe_payment_intent, method="GET") + _logger.info( + "_stripe_create_payment_intent: Values received:\n%s", pprint.pformat(res) + ) + return res + 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(), + "confirm": True, + "off_session": 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 + + @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: %s orders found for reference %s") % ( + len(tx), + 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") + vals = {"date": fields.datetime.now(), "acquirer_reference": tx_id, "stripe_payment_intent": 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 + else: + error = tree.get("failure_message") + _logger.warn(error) + vals.update({"state_message": error}) + self.write(vals) + self._set_transaction_cancel() + 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").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 + + +class PaymentTokenStripeSCA(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_create_customer(self, token, description=None, acquirer_id=None): + raise NotImplementedError( + "This method can no longer be used with the payment_stripe_sca module." + ) + diff --git a/addons/payment_stripe_sca/static/description/icon.png b/addons/payment_stripe_sca/static/description/icon.png new file mode 100644 index 00000000000..497b2d65bd9 Binary files /dev/null and b/addons/payment_stripe_sca/static/description/icon.png differ diff --git a/addons/payment_stripe_sca/static/description/icon.svg b/addons/payment_stripe_sca/static/description/icon.svg new file mode 100644 index 00000000000..f169bb0d45e --- /dev/null +++ b/addons/payment_stripe_sca/static/description/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addons/payment_stripe_sca/static/description/index.html b/addons/payment_stripe_sca/static/description/index.html new file mode 100644 index 00000000000..284cee7c413 --- /dev/null +++ b/addons/payment_stripe_sca/static/description/index.html @@ -0,0 +1,64 @@ +
+
+
+

Stripe - Strong Customer Authentication

+

Add support for strong customer authentication as part of the EU PSD2

+
+
+

+ The PSD2 - the second edition of the Payment Services Directive - is coming into full effect in + the European Union (EU) and the European Economic Area (EEA) on September 14th, 2019. This + directive may indirectly impact your business by requiring stronger authentication mechanisms + for online payments. +

+

Am I impacted by the PSD2 directive?

+

You are impacted by this change if all of these statements applies to your business: +

    +
  • You are legally based in the EU/EEA
  • +
  • You accept payments from customers based in the EU/EEA
  • +
  • You accept payments via cards (credit/debit)
  • +
+

+

What should I do if I am impacted?

+

+ This directive does not bind you legally to do anything in particular regarding + your business processes - it applies to payment processors, acquirers and banks. + Legally, you don't have to do anything to comply with this directive - it does not + apply to you. +

+

+ You might however be indirectly impacted because some online payment flows will + change for customers that pay online - they might need to authenticate themselves during + a transaction that previously didn't require it. This is why Odoo is adapting some of + its integration with payment acquirers to avoid disruptions in your business flows. +

+

+ If you use Stripe and are impacted by the PSD2 directive, then simply installing this + module will avoid problems for your online customers during their payment flow. No extra + configuration is needed once the module is installed. +

+

What happens if I don't install this update?

+

If you don't install this update, some of your customers may be unable to pay you. + There is no reason not to install it if you are impacted by the directive.

+

I'm not impacted by the PSD2, should I install this module anyway?

+

While not required, installing this module remains a good idea nonetheless since + it will switch your instance to newer - and thus more future-proof - APIs of the Stripe + payment acquirer. +

+

What will changes for my online customers

+

The interface displayed to your customers will change when they pay you online.

+

If your Stripe acquirer is configured with the payment flow 'Redirection to the acquirer website' + in Odoo, the small payment overlay will be replaced by a full-screen interface. +

+ +

If your Stripe acquirer is configured with the payment flow 'Payment from Odoo' + in Odoo, the credit card input form will look slightly different. +

+ +

The rest of the payment experience remains the same.

+
+
+
+ + + diff --git a/addons/payment_stripe_sca/static/description/redirection_flow.png b/addons/payment_stripe_sca/static/description/redirection_flow.png new file mode 100644 index 00000000000..593fb5e1aab Binary files /dev/null and b/addons/payment_stripe_sca/static/description/redirection_flow.png differ diff --git a/addons/payment_stripe_sca/static/description/s2s_flow.png b/addons/payment_stripe_sca/static/description/s2s_flow.png new file mode 100644 index 00000000000..1211a8deb23 Binary files /dev/null and b/addons/payment_stripe_sca/static/description/s2s_flow.png differ diff --git a/addons/payment_stripe_sca/static/src/js/payment_form.js b/addons/payment_stripe_sca/static/src/js/payment_form.js new file mode 100644 index 00000000000..e4f7b8f3257 --- /dev/null +++ b/addons/payment_stripe_sca/static/src/js/payment_form.js @@ -0,0 +1,188 @@ +odoo.define('payment_stripe.payment_form', function (require) { + "use strict"; + + var ajax = require('web.ajax'); + var core = require('web.core'); + var rpc = require('web.rpc'); + var Dialog = require('web.Dialog'); + var PaymentForm = require('payment.payment_form'); + + var _t = core._t; + + 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 rpc.query({ + route: '/payment/stripe/s2s/create_setup_intent', + params: {'acquirer_id': formData.acquirer_id} + }).then(function(intent_secret){ + // need to convert between ES6 promises and jQuery 2 deferred \o/ + return $.Deferred(function(defer) { + stripe.handleCardSetup(intent_secret, card) + .then(function(result) {defer.resolve(result)}) + }); + }).then(function(result) { + if (result.error) { + return $.Deferred().reject({"message": {"data": { "message": result.error.message}}}); + } else { + _.extend(formData, {"payment_method": result.setupIntent.payment_method}); + return rpc.query({ + route: formData.data_set, + params: formData, + }) + } + }).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(); + } + }).fail(function (error, event) { + // 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. ") + + error.message.data.message + ); + }); + }, + /** + * 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); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @override + */ + payEvent: function (ev) { + ev.preventDefault(); + var $checkedRadio = this.$('input[type="radio"]:checked'); + + // first we check that the user has selected a stripe as s2s payment method + if ($checkedRadio.length === 1 && $checkedRadio.data('provider') === 'stripe' && this.isNewPaymentRadio($checkedRadio)) { + 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); + } + }, + }); +}); + \ No newline at end of file diff --git a/addons/payment_stripe_sca/static/src/js/payment_processing.js b/addons/payment_stripe_sca/static/src/js/payment_processing.js new file mode 100644 index 00000000000..adb57e6221a --- /dev/null +++ b/addons/payment_stripe_sca/static/src/js/payment_processing.js @@ -0,0 +1,48 @@ +odoo.define('payment_stripe_sca.processing', function (require) { + 'use strict'; + + var ajax = require('web.ajax'); + var rpc = require('web.rpc') + var PaymentProcessing = require('payment.processing'); + + 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 $.Deferred(function(defer) { + stripe.handleCardPayment(tx.stripe_payment_intent_secret) + .then(function(result) {defer.resolve(result)}) + }).then(function(result) { + if (result.error) { + return $.Deferred().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(result) { + window.location = '/payment/process'; + }).fail(function (error) { + 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_sca/static/src/js/stripe_sca.js b/addons/payment_stripe_sca/static/src/js/stripe_sca.js new file mode 100644 index 00000000000..74aac900006 --- /dev/null +++ b/addons/payment_stripe_sca/static/src/js/stripe_sca.js @@ -0,0 +1,82 @@ +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"]')); + }); + }); + \ No newline at end of file diff --git a/addons/payment_stripe_sca/views/assets.xml b/addons/payment_stripe_sca/views/assets.xml new file mode 100644 index 00000000000..6dd7fbab536 --- /dev/null +++ b/addons/payment_stripe_sca/views/assets.xml @@ -0,0 +1,8 @@ + + + diff --git a/addons/payment_stripe_sca/views/payment_templates.xml b/addons/payment_stripe_sca/views/payment_templates.xml new file mode 100644 index 00000000000..0590c2aae22 --- /dev/null +++ b/addons/payment_stripe_sca/views/payment_templates.xml @@ -0,0 +1,42 @@ + + + + + + + + + + \ No newline at end of file