diff options
| author | Damien Bouvy <dbo@odoo.com> | 2019-07-11 13:08:33 +0000 |
|---|---|---|
| committer | Damien Bouvy <dbo@odoo.com> | 2019-07-18 06:55:59 +0000 |
| commit | ad5472b8c5afcfae3cb34b3daa15c3c642ce1d27 (patch) | |
| tree | c69849fd5005581180c24d39aeab06fbd7fedd50 | |
| parent | 55548931fdd3bb9c5f5937ec74851899b8ea4070 (diff) | |
[IMP] payment_stripe: flow changes
The previous commit introduced some changes for SCA/PSD2 but some things
were missing; most notably, registering payment cards in a s2s flow used
the old Charge API that is not SCA-ready.
This commit uses the SetupIntent API instead, allowing to register the
card prior to payment. The global flow is described in the Stripe
documentation in more details: https://stripe.com/docs/payments/cards/saving-cards
Unfortunately, given the complete flow of payments, it is not possible
to use the PaymentIntents API since this would require having the
reference of the transaction before it gets created (the TX is created
after the form submission, but the token must already exist at that
point). Since this is meant to be backported, this changes are kept
minimal in this feature branch. The PaymentIntents API would have been
better since registering a card during a payment makes it more likely
that future off-session transactions will not require an authentication
step. We'll have to live with that for now; it's difficult to know
exactly how this will affect customers before PSD2 goes into effect on
september 14.
Another branch might completely change the flow soon-ish to a more
asynchroneous model, although it may miss the v13 freeze date -_-
| -rw-r--r-- | addons/payment/static/src/js/payment_form.js | 27 | ||||
| -rw-r--r-- | addons/payment/views/payment_templates.xml | 3 | ||||
| -rw-r--r-- | addons/payment_stripe/controllers/main.py | 6 | ||||
| -rw-r--r-- | addons/payment_stripe/models/payment.py | 62 | ||||
| -rw-r--r-- | addons/payment_stripe/static/src/js/payment_form.js | 177 | ||||
| -rw-r--r-- | addons/payment_stripe/static/src/xml/stripe_templates.xml | 7 | ||||
| -rw-r--r-- | addons/payment_stripe/views/payment_stripe_templates.xml | 5 |
7 files changed, 186 insertions, 101 deletions
diff --git a/addons/payment/static/src/js/payment_form.js b/addons/payment/static/src/js/payment_form.js index 07432e741b1..884aee83640 100644 --- a/addons/payment/static/src/js/payment_form.js +++ b/addons/payment/static/src/js/payment_form.js @@ -10,7 +10,7 @@ var _t = core._t; publicWidget.registry.PaymentForm = publicWidget.Widget.extend({ selector: '.o_payment_form', events: { - 'submit': 'async payEvent', + 'submit': 'async onSubmit', 'click #o_payment_form_pay': 'async payEvent', 'click #o_payment_form_add_pm': 'addPmEvent', 'click button[name="delete_pm"]': 'deletePmEvent', @@ -329,7 +329,11 @@ publicWidget.registry.PaymentForm = publicWidget.Widget.extend({ ev.preventDefault(); var checked_radio = this.$('input[type="radio"]:checked'); var self = this; - var button = ev.target; + if (ev.type === 'submit') { + var button = $(ev.target).find('*[type="submit"]')[0] + } else { + var button = ev.target; + } // we check if the user has selected a 'add a new payment' option if (checked_radio.length === 1 && this.isNewPaymentRadio(checked_radio[0])) { @@ -433,6 +437,25 @@ publicWidget.registry.PaymentForm = publicWidget.Widget.extend({ } }, /** + * Called when submitting the form (e.g. through the Return key). + * We need to check whether we are paying or adding a new pm and dispatch + * to the correct method. + * + * @private + * @param {Event} ev + */ + onSubmit: function(ev) { + ev.stopPropagation(); + ev.preventDefault(); + var button = $(ev.target).find('*[type="submit"]')[0] + if (button.id === 'o_payment_form_pay') { + return this.payEvent(ev); + } else if (button.id === 'o_payment_form_add_pm') { + return this.addPmEvent(ev); + } + return; + }, + /** * Called when clicking on a button to delete a payment method. * * @private diff --git a/addons/payment/views/payment_templates.xml b/addons/payment/views/payment_templates.xml index 2de3d8a1faf..8d2c1916d0d 100644 --- a/addons/payment/views/payment_templates.xml +++ b/addons/payment/views/payment_templates.xml @@ -29,7 +29,8 @@ t-att-data-access-token="access_token or ''" t-att-data-partner-id="partner_id" t-att-data-callback-method="callback_method or ''" - t-att-data-order-id="order_id or ''"> + t-att-data-order-id="order_id or ''" + t-att-data-mode="mode"> <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/> <input type="hidden" t-if="prepare_tx_url" name="prepare_tx_url" t-att-value="prepare_tx_url"/> <input type="hidden" t-if="order_id" name="order_id" t-att-value="order_id"/> diff --git a/addons/payment_stripe/controllers/main.py b/addons/payment_stripe/controllers/main.py index be915f49080..d506621e5a3 100644 --- a/addons/payment_stripe/controllers/main.py +++ b/addons/payment_stripe/controllers/main.py @@ -42,3 +42,9 @@ class StripeController(http.Controller): res['verified'] = token.verified return res + + @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._create_setup_intent(kwargs) + return res.get('client_secret') diff --git a/addons/payment_stripe/models/payment.py b/addons/payment_stripe/models/payment.py index efde9123b8c..9272f194cbf 100644 --- a/addons/payment_stripe/models/payment.py +++ b/addons/payment_stripe/models/payment.py @@ -51,12 +51,19 @@ class PaymentAcquirerStripe(models.Model): return tx_values - def _stripe_request(self, url, data=False): + 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} - resp = requests.post(url, data, headers=headers) - resp.raise_for_status() + 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): @@ -67,17 +74,38 @@ class PaymentAcquirerStripe(models.Model): 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('id'), - 'name': 'XXXXXXXXXXXX%s' % data.get('card', {}).get('last4'), + 'stripe_payment_method': data.get('payment_method'), + 'name': 'XXXXXXXXXXXX%s' % last4, 'acquirer_ref': data.get('customer') }) return payment_token @@ -113,29 +141,27 @@ class PaymentTransactionStripe(models.Model): _logger.info('Stripe: entering form_feedback with post data %s' % pprint.pformat(data)) return super(PaymentTransactionStripe, self).form_feedback(data, acquirer_name) - def _create_stripe_charge(self, acquirer_ref=None, email=None): - + def _stripe_create_payment_intent(self, acquirer_ref=None, email=None): 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, - 'payment_method_types[]': 'card', - 'customer': acquirer_ref, - 'off_session': 'recurring', + 'currency': self.currency_id.name.lower(), + 'setup_future_usage': 'off_session', 'confirm': True, - 'payment_method': self.payment_token_id.stripe_payment_method + 'payment_method': self.payment_token_id.stripe_payment_method, + 'customer': self.payment_token_id.acquirer_ref, } - _logger.info('_create_stripe_charge: Sending values to stripe, values:\n%s', pprint.pformat(charge_params)) + _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('_create_stripe_charge: Values received:\n%s', pprint.pformat(res)) + _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._create_stripe_charge(acquirer_ref=self.payment_token_id.acquirer_ref, email=self.partner_email) + 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): @@ -204,7 +230,7 @@ class PaymentTransactionStripe(models.Model): if self.type == 'form_save': s2s_data = { 'customer': tree.get('customer'), - 'id': tree.get('payment_method'), + '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 @@ -214,7 +240,7 @@ class PaymentTransactionStripe(models.Model): if self.payment_token_id: self.payment_token_id.verified = True return True - if status == 'processing': + if status in ('processing', 'requires_action'): self.write(vals) self._set_transaction_pending() return True diff --git a/addons/payment_stripe/static/src/js/payment_form.js b/addons/payment_stripe/static/src/js/payment_form.js index 43227941d56..c3d77bf4b7a 100644 --- a/addons/payment_stripe/static/src/js/payment_form.js +++ b/addons/payment_stripe/static/src/js/payment_form.js @@ -13,6 +13,12 @@ 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 //-------------------------------------------------------------------------- @@ -27,6 +33,12 @@ PaymentForm.include({ */ _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); @@ -34,70 +46,87 @@ PaymentForm.include({ console.warn('payment_form: unset partner_id when adding new token; things could go wrong'); } - ajax.loadJS("https://js.stripe.com/v3/").then(function () { - var formData = self.getFormData(inputsForm); - var stripe = Stripe(formData.stripe_publishable_key); - var element = stripe.elements(); - var card = element.create('card', {hidePostalCode: true}); - var dialog = new Dialog(self, { - title: _t('Card Details'), - size: 'medium', - technical: false, - buttons: [{text: _t('Submit'), classes: 'btn-primary', click: function (ev) { - stripe.createPaymentMethod('card', card).then(function (result) { - if (result.error) { - // Inform the user if there was an error. - var errorElement = document.getElementById('card-errors'); - errorElement.textContent = result.error.message; - } else { - ev.currentTarget.disabled = true; - _.extend(formData, result.paymentMethod); - // Send the token to server. - self._rpc({ - route: formData.data_set, - params: formData - }).then (function (data) { - if (addPmEvent) { - if (formData.return_url) { - window.location = formData.return_url; - } else { - window.location.reload(); - } - } else { - $checkedRadio.val(data.id); - self.$el.submit(); - } - }).guardedCatch(function (error) { - // if the rpc fails, pretty obvious - self.enableButton(ev.target); - - self.displayError( - _t('Server Error'), - _t("We are not able to add your payment method at the moment.") + - error.message.data.message - ); - }); - } - }); - }}], - $content: $(qweb.render('stripe.payment.element')), - }); - dialog.opened().then(function () { - self.disableButton(ev.target); - card.mount('#card-element'); - card.addEventListener('change', function (event) { - var displayError = document.getElementById('card-errors'); - displayError.textContent = ''; - if (event.error) { - displayError.textContent = event.error.message; - } - }); - }); - dialog.on('closed', self, function () { - self.enableButton(ev.target); - }); - dialog.open(); + 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": { "message": result.error.message}}}); + } else { + _.extend(formData, {"payment_method": result.setupIntent.payment_method}); + return self._rpc({ + 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(); + } + }).guardedCatch(function (error) { + // 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 @@ -108,13 +137,15 @@ PaymentForm.include({ if ($checkedRadio.length !== 1) { return; } - - // hide add token form for stripe - if ($checkedRadio.data('provider') === 'stripe' && this.isNewPaymentRadio($checkedRadio)) { - this.$('[id*="o_payment_add_token_acq_"]').addClass('d-none'); - } else { - this._super.apply(this, arguments); + 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); }, //-------------------------------------------------------------------------- @@ -130,9 +161,9 @@ PaymentForm.include({ // 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') { - this._createStripeToken(ev, $checkedRadio); + return this._createStripeToken(ev, $checkedRadio); } else { - this._super.apply(this, arguments); + return this._super.apply(this, arguments); } }, /** @@ -145,9 +176,9 @@ PaymentForm.include({ // 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') { - this._createStripeToken(ev, $checkedRadio, true); + return this._createStripeToken(ev, $checkedRadio, true); } else { - this._super.apply(this, arguments); + return this._super.apply(this, arguments); } }, }); diff --git a/addons/payment_stripe/static/src/xml/stripe_templates.xml b/addons/payment_stripe/static/src/xml/stripe_templates.xml index e0ab901a825..8272aea8532 100644 --- a/addons/payment_stripe/static/src/xml/stripe_templates.xml +++ b/addons/payment_stripe/static/src/xml/stripe_templates.xml @@ -18,11 +18,4 @@ </div> </div> </t> - - <div t-name="stripe.payment.element"> - <div id="payment-form"> - <div id="card-errors" class="m-3 text-danger text-center"/> - <div id="card-element" class="m-3"/> - </div> - </div> </templates> diff --git a/addons/payment_stripe/views/payment_stripe_templates.xml b/addons/payment_stripe/views/payment_stripe_templates.xml index 56c89289fdf..a980edbc33c 100644 --- a/addons/payment_stripe/views/payment_stripe_templates.xml +++ b/addons/payment_stripe/views/payment_stripe_templates.xml @@ -17,9 +17,14 @@ <input type="hidden" name="data_set" value="/payment/stripe/s2s/create_json_3ds"/> <input type="hidden" name="acquirer_id" t-att-value="id"/> <input type="hidden" name="stripe_publishable_key" t-att-value="acq.sudo().stripe_publishable_key"/> + <input type="hidden" name="currency_id" t-att-value="currency_id"/> <input t-if="return_url" type="hidden" name="return_url" t-att-value="return_url"/> <input t-if="partner_id" type="hidden" name="partner_id" t-att-value="partner_id"/> <input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/> + <div id="payment-form"> + <div id="card-element" class="m-3"/> + <div id="card-errors" class="m-3 text-danger"/> + </div> </template> <template id="assets_frontend" inherit_id="web.assets_frontend"> |
