[IMP] payment_stripe: strong customer authentication

With the PSD2 directive coming into effect on September 14th, it was time to switch our Stripe integration to use newer APIs that support Strong Customer Authentication (SCA for short).

This meant switching from the old stripe.js implementation for 'redirection' flow to the Stripe Checkout API and from the Charge API to the newer SetupIntent API for s2s flows.

This feature branch introduce these changes as well as some slight changes in the payment module (mainly functional improvement that were noticed when developing this, nothing major and no change in the flow/model of the basic payment models).

Note that the way our checkout flow is built for s2s transactions prevents us from using the PaymentIntent API (which would be a better fit that the SetupIntent in some cases) - to use that API, we would need the payment transaction to be created before the token, which is not the case in our current flows. This will probably be changed in a future task with a more flexible checkout flow.

In the mean time, the downside of using SetupIntent instead of PaymentIntent means that from the bank's point of view, the authorization of the card and the payment are 2 different requests - this could lead to a higher rejection rate in SCA flows and possibly to payments that stay 'pending'. There is no way to know for sure before PSD2 goes into full effect though, and this implementation should normally suffice.I would advise using the 'redirection' flow most of the time though, since it is clearly the best fit between Odoo and the Stripe APIs at the moment.

Task 1986267

closes odoo/odoo#33978

Signed-off-by: Damien Bouvy (dbo) <dbo@odoo.com>
This commit is contained in:
Robot Odoo
2019-07-18 10:57:35 +02:00
committed by GitHub
9 changed files with 489 additions and 406 deletions
+4 -2
View File
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from unicodedata import normalize
import psycopg2
from odoo import http, _
@@ -131,6 +132,7 @@ class WebsitePayment(http.Controller):
def pay(self, reference='', order_id=None, amount=False, currency_id=None, acquirer_id=None, **kw):
env = request.env
user = env.user.sudo()
reference = normalize('NFKD', reference).encode('ascii','ignore').decode('utf-8')
# Default values
values = {
@@ -188,7 +190,7 @@ class WebsitePayment(http.Controller):
})
values['acquirers'] = [acq for acq in acquirers if acq.payment_flow in ['form', 's2s']]
values['pms'] = request.env['payment.token'].search([('acquirer_id', 'in', acquirers.filtered(lambda x: x.payment_flow == 's2s').ids)])
values['pms'] = request.env['payment.token'].search([('acquirer_id', 'in', acquirers.ids)])
return request.render('payment.pay', values)
@@ -246,7 +248,7 @@ class WebsitePayment(http.Controller):
'currency_id': int(currency_id),
'partner_id': partner_id,
'payment_token_id': pm_id,
'type': 'form_save' if token.acquirer_id.save_token != 'none' and partner_id else 'form',
'type': 'server2server',
'return_url': return_url,
}
+36 -6
View File
@@ -10,6 +10,7 @@ var _t = core._t;
publicWidget.registry.PaymentForm = publicWidget.Widget.extend({
selector: '.o_payment_form',
events: {
'submit': 'async onSubmit',
'click #o_payment_form_pay': 'async payEvent',
'click #o_payment_form_add_pm': 'addPmEvent',
'click button[name="delete_pm"]': 'deletePmEvent',
@@ -21,10 +22,12 @@ publicWidget.registry.PaymentForm = publicWidget.Widget.extend({
* @override
*/
start: function () {
this.options = _.extend(this.$el.data(), this.options);
this.updateNewPaymentDisplayStatus();
$('[data-toggle="tooltip"]').tooltip();
return this._super.apply(this, arguments);
var self = this;
return this._super.apply(this, arguments).then(function () {
self.options = _.extend(self.$el.data(), self.options);
self.updateNewPaymentDisplayStatus();
$('[data-toggle="tooltip"]').tooltip();
});
},
//--------------------------------------------------------------------------
@@ -147,7 +150,11 @@ publicWidget.registry.PaymentForm = publicWidget.Widget.extend({
var form = this.el;
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;
}
// first we check that the user has selected a payment method
if (checked_radio.length === 1) {
@@ -322,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])) {
@@ -425,6 +436,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.
*
+3 -2
View File
@@ -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"/>
@@ -152,7 +153,7 @@
<t t-if="show_manage_btn">
<a class="btn btn-link mb8 mt8" href="/my/payment_method">Manage your payment methods</a>
</t>
<button t-if="mode == 'manage' and list(filter(lambda x: x.payment_flow == 's2s', acquirers))" id="o_payment_form_add_pm" class="btn btn-primary btn-lg mb8 mt8">
<button t-if="mode == 'manage' and list(filter(lambda x: x.payment_flow == 's2s', acquirers))" type="submit" id="o_payment_form_add_pm" class="btn btn-primary btn-lg mb8 mt8">
<i class="fa fa-plus-circle"/> Add new card
</button>
</div>
+11 -60
View File
@@ -1,41 +1,21 @@
# -*- coding: utf-8 -*-
import logging
import pprint
import werkzeug
from odoo import http
from odoo.http import request
from odoo.addons.payment.controllers.portal import PaymentProcessing
_logger = logging.getLogger(__name__)
class StripeController(http.Controller):
_success_url = '/payment/stripe/success'
_cancel_url = '/payment/stripe/cancel'
@http.route(['/payment/stripe/s2s/create_json'], type='json', auth='public')
def stripe_s2s_create_json(self, **kwargs):
acquirer_id = int(kwargs.get('acquirer_id'))
acquirer = request.env['payment.acquirer'].browse(acquirer_id)
if not kwargs.get('partner_id'):
kwargs = dict(kwargs, partner_id=request.env.user.partner_id.id)
return acquirer.s2s_process(kwargs).id
@http.route(['/payment/stripe/s2s/create'], type='http', auth='public')
def stripe_s2s_create(self, **post):
acquirer_id = int(post.get('acquirer_id'))
acquirer = request.env['payment.acquirer'].browse(acquirer_id)
error = None
try:
acquirer.s2s_process(post)
except Exception as e:
error = str(e)
return_url = post.get('return_url', '/')
if error:
separator = '?' if werkzeug.urls.url_parse(return_url).query == '' else '&'
return_url += '{}{}'.format(separator, werkzeug.urls.url_encode({'error': error}))
return werkzeug.utils.redirect(return_url)
@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(['/payment/stripe/s2s/create_json_3ds'], type='json', auth='public', csrf=False)
def stripe_s2s_create_json_3ds(self, verify_validity=False, **kwargs):
@@ -63,37 +43,8 @@ class StripeController(http.Controller):
return res
@http.route(['/payment/stripe/create_charge'], type='json', auth='public')
def stripe_create_charge(self, **post):
""" Create a payment transaction
Expects the result from the user input from checkout.js popup"""
TX = request.env['payment.transaction']
tx = None
if post.get('tx_ref'):
tx = TX.sudo().search([('reference', '=', post['tx_ref'])])
if not tx:
tx_id = (post.get('tx_id') or request.session.get('sale_transaction_id') or
request.session.get('website_payment_tx_id'))
tx = TX.sudo().browse(int(tx_id))
if not tx:
raise werkzeug.exceptions.NotFound()
stripe_token = post['token']
response = None
if tx.type == 'form_save' and tx.partner_id:
payment_token_id = request.env['payment.token'].sudo().create({
'acquirer_id': tx.acquirer_id.id,
'partner_id': tx.partner_id.id,
'stripe_token': stripe_token
})
tx.payment_token_id = payment_token_id
response = tx._create_stripe_charge(acquirer_ref=payment_token_id.acquirer_ref, email=stripe_token['email'])
else:
response = tx._create_stripe_charge(tokenid=stripe_token['id'], email=stripe_token['email'])
_logger.info('Stripe: entering form_feedback with post data %s', pprint.pformat(response))
if response:
request.env['payment.transaction'].sudo().with_context(lang=None).form_feedback(response, 'stripe')
# add the payment transaction into the session to let the page /payment/process to handle it
PaymentProcessing.add_payment_transaction(tx)
return "/payment/process"
@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')
+150 -154
View File
@@ -3,19 +3,15 @@
import logging
import requests
import pprint
from werkzeug import urls
from odoo import api, fields, models, _
from odoo.addons.payment.models.payment_acquirer import ValidationError
from odoo.exceptions import UserError
from odoo.tools.safe_eval import safe_eval
from odoo.tools.float_utils import float_round
_logger = logging.getLogger(__name__)
from odoo.addons.payment.models.payment_acquirer import ValidationError
from odoo.addons.payment_stripe.controllers.main import StripeController
# Force the API version to avoid breaking in case of update on Stripe side
# cf https://stripe.com/docs/api#versioning
# changelog https://stripe.com/docs/upgrades#api-changelog
STRIPE_HEADERS = {'Stripe-Version': '2016-03-07'}
_logger = logging.getLogger(__name__)
# The following currencies are integer only, see https://stripe.com/docs/currencies#zero-decimal
INT_CURRENCIES = [
@@ -38,58 +34,87 @@ class PaymentAcquirerStripe(models.Model):
def stripe_form_generate_values(self, tx_values):
self.ensure_one()
stripe_tx_values = dict(tx_values)
temp_stripe_tx_values = {
'company': self.company_id.name,
'amount': tx_values['amount'], # Mandatory
'currency': tx_values['currency'].name, # Mandatory anyway
'currency_id': tx_values['currency'].id, # same here
'address_line1': tx_values.get('partner_address'), # Any info of the partner is not mandatory
'address_city': tx_values.get('partner_city'),
'address_country': tx_values.get('partner_country') and tx_values.get('partner_country').name or '',
'email': tx_values.get('partner_email'),
'address_zip': tx_values.get('partner_zip'),
'name': tx_values.get('partner_name'),
'phone': tx_values.get('partner_phone'),
}
stripe_tx_values.update(temp_stripe_tx_values)
return stripe_tx_values
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 'api.stripe.com/v1'
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({
'cc_number': data['cc_number'],
'cc_holder_name': data['cc_holder_name'],
'cc_expiry': data['cc_expiry'],
'cc_brand': data['cc_brand'],
'cvc': data['cvc'],
'acquirer_id': int(data['acquirer_id']),
'partner_id': int(data['partner_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):
self.ensure_one()
# mandatory fields
for field_name in ["cc_number", "cvc", "cc_holder_name", "cc_expiry", "cc_brand"]:
if not data.get(field_name):
return False
return True
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:
* fees: support payment fees computations
* authorize: support authorizing payment (separates
authorization and capture)
* tokenize: support saving payment data in a payment.tokenize
object
"""
@@ -101,38 +126,45 @@ class PaymentAcquirerStripe(models.Model):
class PaymentTransactionStripe(models.Model):
_inherit = 'payment.transaction'
def _create_stripe_charge(self, acquirer_ref=None, tokenid=None, email=None):
api_url_charge = 'https://%s/charges' % (self.acquirer_id._get_stripe_api_url())
stripe_payment_intent = fields.Char(string='Stripe Payment Intent ID', readonly=True)
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):
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,
'metadata[reference]': self.reference,
'description': self.reference,
'currency': self.currency_id.name.lower(),
'setup_future_usage': 'off_session',
'confirm': True,
'payment_method': self.payment_token_id.stripe_payment_method,
'customer': self.payment_token_id.acquirer_ref,
}
if acquirer_ref:
charge_params['customer'] = acquirer_ref
if tokenid:
charge_params['card'] = str(tokenid)
if email:
charge_params['receipt_email'] = email.strip()
_logger.info('_stripe_create_payment_intent: Sending values to stripe, values:\n%s', pprint.pformat(charge_params))
_logger.info('_create_stripe_charge: Sending values to URL %s, values:\n%s', api_url_charge, pprint.pformat(charge_params))
r = requests.post(api_url_charge,
auth=(self.acquirer_id.stripe_secret_key, ''),
params=charge_params,
headers=STRIPE_HEADERS)
res = r.json()
_logger.info('_create_stripe_charge: Values received:\n%s', pprint.pformat(res))
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._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):
api_url_refund = 'https://%s/refunds' % (self.acquirer_id._get_stripe_api_url())
refund_params = {
'charge': self.acquirer_reference,
@@ -140,13 +172,10 @@ class PaymentTransactionStripe(models.Model):
'metadata[reference]': self.reference,
}
_logger.info('_create_stripe_refund: Sending values to URL %s, values:\n%s', api_url_refund, pprint.pformat(refund_params))
r = requests.post(api_url_refund,
auth=(self.acquirer_id.stripe_secret_key, ''),
params=refund_params,
headers=STRIPE_HEADERS)
res = r.json()
_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):
@@ -158,11 +187,11 @@ class PaymentTransactionStripe(models.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('metadata', {}).get('reference')
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')
'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'") %
@@ -189,111 +218,78 @@ class PaymentTransactionStripe(models.Model):
return True
status = tree.get('status')
tx_id = tree.get('id')
vals = {
'date': fields.datetime.now(),
'acquirer_reference': tx_id,
}
if status == 'succeeded':
self.write({
'date': fields.datetime.now(),
'acquirer_reference': tree.get('id'),
})
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['error']['message']
error = tree.get('failure_message')
_logger.warn(error)
self.sudo().write({
'state_message': error,
'acquirer_reference': tree.get('id'),
'date': fields.datetime.now(),
})
vals.update({'state_message': error})
self.write(vals)
self._set_transaction_cancel()
return False
def _stripe_form_get_invalid_parameters(self, data):
invalid_parameters = []
reference = data['metadata']['reference']
if reference != self.reference:
invalid_parameters.append(('Reference', reference, self.reference))
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
def _stripe_form_validate(self, data):
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):
token = values.get('stripe_token')
description = None
payment_acquirer = self.env['payment.acquirer'].browse(values.get('acquirer_id'))
# when asking to create a token on Stripe servers
if values.get('cc_number'):
url_token = 'https://%s/tokens' % payment_acquirer._get_stripe_api_url()
payment_params = {
'card[number]': values['cc_number'].replace(' ', ''),
'card[exp_month]': str(values['cc_expiry'][:2]),
'card[exp_year]': str(values['cc_expiry'][-2:]),
'card[cvc]': values['cvc'],
'card[name]': values['cc_holder_name'],
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
}
r = requests.post(url_token,
auth=(payment_acquirer.stripe_secret_key, ''),
params=payment_params,
headers=STRIPE_HEADERS)
token = r.json()
description = values['cc_holder_name']
else:
partner_id = self.env['res.partner'].browse(values['partner_id'])
description = 'Partner: %s (id: %s)' % (partner_id.name, partner_id.id)
if not token:
raise UserError(_("Stripe: no payment token was provided or the token creation failed."))
res = self._stripe_create_customer(token, description, payment_acquirer.id)
# pop credit card info to info sent to create
for field_name in ["cc_number", "cvc", "cc_holder_name", "cc_expiry", "cc_brand", "stripe_token"]:
values.pop(field_name, None)
return res
def _stripe_create_customer(self, token, description=None, acquirer_id=None):
if token.get('error'):
_logger.error('payment.token.stripe_create_customer: Token error:\n%s', pprint.pformat(token['error']))
raise Exception(token['error']['message'])
if token['object'] != 'token':
_logger.error('payment.token.stripe_create_customer: Cannot create a customer for object type "%s"', token.get('object'))
raise Exception('We are unable to process your credit card information.')
if token['type'] != 'card':
_logger.error('payment.token.stripe_create_customer: Cannot create a customer for token type "%s"', token.get('type'))
raise Exception('We are unable to process your credit card information.')
payment_acquirer = self.env['payment.acquirer'].browse(acquirer_id or self.acquirer_id.id)
url_customer = 'https://%s/customers' % payment_acquirer._get_stripe_api_url()
customer_params = {
'source': token['id'],
'description': description or token["card"]["name"]
}
r = requests.post(url_customer,
auth=(payment_acquirer.stripe_secret_key, ''),
params=customer_params,
headers=STRIPE_HEADERS)
customer = r.json()
if customer.get('error'):
_logger.error('payment.token.stripe_create_customer: Customer error:\n%s', pprint.pformat(customer['error']))
raise Exception(customer['error']['message'])
values = {
'acquirer_ref': customer['id'],
'name': 'XXXXXXXXXXXX%s - %s' % (token['card']['last4'], customer_params["description"])
}
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
@@ -0,0 +1,185 @@
odoo.define('payment_stripe.payment_form', function (require) {
"use strict";
var ajax = require('web.ajax');
var core = require('web.core');
var Dialog = require('web.Dialog');
var PaymentForm = require('payment.payment_form');
var qweb = core.qweb;
var _t = core._t;
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": { "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
*/
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 && 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);
}
},
});
});
+67 -127
View File
@@ -1,141 +1,81 @@
odoo.define('payment_stripe.stripe', function(require) {
"use strict";
odoo.define('payment_stripe.stripe', function (require) {
"use strict";
var ajax = require('web.ajax');
var core = require('web.core');
var _t = core._t;
var qweb = core.qweb;
ajax.loadXML('/payment_stripe/static/src/xml/stripe_templates.xml', qweb);
var ajax = require('web.ajax');
var core = require('web.core');
// The following currencies are integer only, see
// https://stripe.com/docs/currencies#zero-decimal
var int_currencies = [
'BIF', 'XAF', 'XPF', 'CLP', 'KMF', 'DJF', 'GNF', 'JPY', 'MGA', 'PYG',
'RWF', 'KRW', 'VUV', 'VND', 'XOF'
];
var qweb = core.qweb;
var _t = core._t;
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';
}
var stripeHandler;
function getStripeHandler()
{
if (stripeHandler) {
return stripeHandler;
}
var handler = stripeHandler = StripeCheckout.configure({
key: $("input[name='stripe_key']").val(),
image: $("input[name='stripe_image']").val(),
locale: 'auto',
token: function(token, args) {
handler.isTokenGenerate = true;
if ($.blockUI) {
var msg = _t("Just one more second, confirming your payment...");
$.blockUI({
'message': '<h2 class="text-white"><img src="/web/static/src/img/spin.png" class="fa-pulse"/>' +
' <br />' + msg +
'</h2>'
});
}
ajax.jsonRpc("/payment/stripe/create_charge", 'call', {
tokenid: token.id, // TBE TODO: for backward compatibility, remove on master
email: token.email, // TBE TODO: for backward compatibility, remove on master
token: token,
amount: $("input[name='amount']").val(),
acquirer_id: $("#acquirer_stripe").val(),
currency: $("input[name='currency']").val(),
invoice_num: $("input[name='invoice_num']").val(),
tx_ref: $("input[name='invoice_num']").val(),
return_url: $("input[name='return_url']").val()
}).then(function(data){
handler.isTokenGenerate = false;
window.location.href = data;
if ($.blockUI) {
$.unblockUI();
}
}).guardedCatch(function(data){
var msg = data && data.data && data.data.message;
var wizard = $(qweb.render('stripe.error', {'msg': msg || _t('Payment error')}));
wizard.appendTo($('body')).modal({'keyboard': true});
if ($.blockUI) {
$.unblockUI();
}
});
},
});
return handler;
}
ajax.loadXML('/payment_stripe/static/src/xml/stripe_templates.xml', qweb);
require('web.dom_ready');
if (!$('.o_payment_form').length) {
return Promise.reject("DOM doesn't contain '.o_payment_form'");
}
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';
}
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') {
display_stripe_form($(mutations[i].addedNodes[j]));
}
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 display_stripe_form(provider_form) {
// Open Checkout with further options
var payment_form = $('.o_payment_form');
if(!payment_form.find('i').length)
payment_form.append('<i class="fa fa-spinner fa-spin"/>');
payment_form.attr('disabled','disabled');
var payment_tx_url = payment_form.find('input[name="prepare_tx_url"]').val();
var access_token = $("input[name='access_token']").val() || $("input[name='token']").val() || '';
var get_input_value = function(name) {
return provider_form.find('input[name="' + name + '"]').val();
}
var acquirer_id = parseInt(provider_form.find('#acquirer_stripe').val());
var amount = parseFloat(get_input_value("amount") || '0.0');
var currency = get_input_value("currency");
var email = get_input_value("email");
var invoice_num = get_input_value("invoice_num");
var merchant = get_input_value("merchant");
// Search if the user wants to save the credit card information
var form_save_token = false;
var acquirer_form = $('#o_payment_form_acq_' + acquirer_id);
if (acquirer_form.length) {
form_save_token = acquirer_form.find('input[name="o_payment_form_save_token"]').prop('checked');
}
ajax.jsonRpc(payment_tx_url, 'call', {
acquirer_id: acquirer_id,
access_token: access_token,
save_token: form_save_token,
}).then(function(data) {
var $pay_stripe = $('#pay_stripe').detach();
try { provider_form[0].innerHTML = data; } catch (e) {}
// Restore 'Pay Now' button HTML since data might have changed it.
$(provider_form[0]).find('#pay_stripe').replaceWith($pay_stripe);
}).then(function () {
getStripeHandler().open({
name: merchant,
description: invoice_num,
email: email,
currency: currency,
amount: _.contains(int_currencies, currency) ? amount : amount * 100,
});
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': '<h2 class="text-white"><img src="/web/static/src/img/spin.png" class="fa-pulse"/>' +
' <br />' + msg +
'</h2>'
});
}
$.getScript("https://checkout.stripe.com/checkout.js", function(data, textStatus, jqxhr) {
observer.observe(document.body, {childList: true});
display_stripe_form($('form[provider="stripe"]'));
var paymentForm = $('.o_payment_form');
if (!paymentForm.find('i').length) {
paymentForm.append('<i class="fa fa-spinner fa-spin"/>');
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"]'));
});
});
@@ -1,21 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="stripe.error">
<div role="dialog" class="modal fade">
<div class="modal-dialog">
<div class="modal-content">
<header class="modal-header">
<h4 class="modal-title">Error</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">&amp;times;</button>
</header>
<main class="modal-body">
<t t-esc="msg"></t>
</main>
<footer class="modal-footer">
<a role="button" href="#" class="btn btn-link btn-sm" data-dismiss="modal">Close</a>
</footer>
<t t-name="stripe.error">
<div role="dialog" class="modal fade">
<div class="modal-dialog">
<div class="modal-content">
<header class="modal-header">
<h4 class="modal-title">Error</h4>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">&amp;times;</button>
</header>
<main class="modal-body">
<t t-esc="msg"></t>
</main>
<footer class="modal-footer">
<a role="button" href="#" class="btn btn-link btn-sm" data-dismiss="modal">Close</a>
</footer>
</div>
</div>
</div>
</div>
</t>
</t>
</templates>
@@ -3,23 +3,7 @@
<data>
<template id="stripe_form">
<input type="hidden" name="data_set" t-att-data-action-url="tx_url" data-remove-me=""/>
<input type="hidden" name='stripe_image' t-att-value="acquirer.stripe_image_url"/>
<input type="hidden" name='merchant' t-att-value='acquirer.company_id.name'/>
<input type="hidden" name='amount' t-att-value="amount"/>
<input type="hidden" name='return_url' t-att-value='returndata'/>
<!--Order Information -->
<input type="hidden" name='invoice_num' t-att-value='reference'/>
<input type="hidden" name='currency' t-att-value='currency'/>
<input type="hidden" name='currency_id' t-att-value='currency_id'/>
<!-- Billing Information-->
<input type="hidden" name='name' t-att-value='name'/>
<input type="hidden" name='address_line1' t-att-value='address_line1'/>
<input type="hidden" name='address_city' t-att-value='address_city'/>
<input type="hidden" name='address_zip' t-att-value='address_zip'/>
<input type="hidden" name='address_country' t-att-value='address_country'/>
<input type="hidden" name='phone' t-att-value='phone'/>
<input type="hidden" name='email' t-att-value='email'/>
<input id="acquirer_stripe" type="hidden" name="acquirer" t-att-value="acquirer.id"/>
<input type='hidden' name='session_id' t-att-value='session_id'/>
<input type="hidden" name="stripe_key" t-att-value="acquirer.stripe_publishable_key"/>
<script type="text/javascript">
odoo.define(function (require) {
@@ -30,29 +14,23 @@
</template>
<template id="stripe_s2s_form">
<input type="hidden" name="data_set" data-create-route="/payment/stripe/s2s/create_json_3ds"/>
<div t-attf-class="row mt8 #{'' if bootstrap_formatting else 'o_card_brand_detail'}">
<div t-att-class="'form-group col-lg-12' if bootstrap_formatting else 'form-group'">
<input type="tel" name="cc_number" id="cc_number" class="form-control" placeholder="Card number" data-is-required="true"/>
<div class="card_placeholder"></div>
<div class="visa"></div>
<input type="hidden" name="cc_brand" value=""/>
</div>
<div t-att-class="'form-group col-lg-5' if bootstrap_formatting else 'form-group'">
<input type="text" name="cc_holder_name" id="cc_holder_name" class="form-control" placeholder="Cardholder name" data-is-required="true"/>
</div>
<div t-att-class="'form-group col-lg-3' if bootstrap_formatting else 'form-group'">
<input type="text" name="cc_expiry" id="cc_expiry" class="form-control" maxlength="7" placeholder="Expires (MM / YY)" data-is-required="true"/>
</div>
<div t-att-class="'form-group col-lg-4' if bootstrap_formatting else 'form-group'">
<input type="text" name="cvc" id="cvc" class="form-control" maxlength="4" placeholder="CVC" data-is-required="true"/>
</div>
<input type="hidden" name="acquirer_id" t-att-value="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 class="clearfix"/>
<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">
<xpath expr="script[last()]" position="after">
<script type="text/javascript" src="/payment_stripe/static/src/js/payment_form.js"></script>
</xpath>
</template>
</data>
</odoo>