[IMP] payment, website_payment: generic mechanism to verify payment tokens on registration

When registering a payment token, validating it using a payment of a small amount followed by a refund
allows ensuring that the method is valid (i.e. checksumming the card number simple ensure the number
is valid but not that the card exists). This commit introduces a generic approach that must be implemented
for each acquirer that has tokenization support. This commit also introduces a generic payment token registration/usage template that can be adapted according to one's need.

payment_ogone: add support for tokens validation
This commit is contained in:
tbe-odoo
2017-07-14 11:57:19 +02:00
committed by Damien Bouvy
parent 110c895368
commit 7d428c871b
8 changed files with 313 additions and 27 deletions
+87
View File
@@ -2,6 +2,7 @@
import hashlib
import hmac
import logging
import datetime
from odoo import api, exceptions, fields, models, _
from odoo.tools import consteq, float_round, image_resize_images, ustr, pycompat
@@ -428,6 +429,7 @@ class PaymentTransaction(models.Model):
acquirer_id = fields.Many2one('payment.acquirer', 'Acquirer', required=True)
provider = fields.Selection(string='Provider', related='acquirer_id.provider')
type = fields.Selection([
('validation', 'Validation of the bank card'),
('server2server', 'Server To Server'),
('form', 'Form'),
('form_save', 'Form with tokenization')], 'Type',
@@ -437,6 +439,8 @@ class PaymentTransaction(models.Model):
('pending', 'Pending'),
('authorized', 'Authorized'),
('done', 'Done'),
('refunding', 'Refunding'),
('refunded', 'Refunded'),
('error', 'Error'),
('cancel', 'Canceled')], 'Status',
copy=False, default='draft', required=True, track_visibility='onchange')
@@ -643,6 +647,12 @@ class PaymentTransaction(models.Model):
if hasattr(self, custom_method_name):
return getattr(self, custom_method_name)(**kwargs)
@api.multi
def s2s_do_refund(self, **kwargs):
custom_method_name = '%s_s2s_do_refund' % self.acquirer_id.provider
if hasattr(self, custom_method_name):
return getattr(self, custom_method_name)(**kwargs)
@api.multi
def s2s_capture_transaction(self, **kwargs):
custom_method_name = '%s_s2s_capture_transaction' % self.acquirer_id.provider
@@ -711,6 +721,7 @@ class PaymentToken(models.Model):
acquirer_ref = fields.Char('Acquirer Ref.', required=True)
active = fields.Boolean('Active', default=True)
payment_ids = fields.One2many('payment.transaction', 'payment_token_id', 'Payment Transactions')
verified = fields.Boolean(string='Verified', default=False)
@api.model
def create(self, values):
@@ -726,9 +737,85 @@ class PaymentToken(models.Model):
fields_wl = set(self._fields) & set(values)
values = {field: values[field] for field in fields_wl}
return super(PaymentToken, self).create(values)
"""
@TBE: stolen shamelessly from there https://www.paypal.com/us/selfhelp/article/why-is-there-a-$1.95-charge-on-my-card-statement-faq554
Most of them are ~1.50€s
TODO: See this with @AL & @DBO
"""
VALIDATION_AMOUNTS = {
'CAD': 2.45,
'EUR': 1.50,
'GBP': 1.00,
'JPY': 200,
'AUD': 2.00,
'NZD': 3.00,
'CHF': 3.00,
'HKD': 15.00,
'SEK': 15.00,
'DKK': 12.50,
'PLN': 6.50,
'NOK': 15.00,
'HUF': 400.00,
'CZK': 50.00,
'BRL': 4.00,
'MYR': 10.00,
'MXN': 20.00,
'ILS': 8.00,
'PHP': 100.00,
'TWD': 70.00,
'THB': 70.00
}
@api.model
def validate(self, **kwargs):
"""
This method allow to verify if this payment method is valid or not.
It does this by withdrawing a certain amount and then refund it right after.
"""
currency = self.partner_id.currency_id
if self.VALIDATION_AMOUNTS.get(currency.name):
amount = self.VALIDATION_AMOUNTS.get(currency.name)
else:
# If we don't find the user's currency, then we set the currency to EUR and the amount to 1€50.
currency = self.env['res.currency'].search([('name', '=', 'EUR')])
amount = 1.5
if len(currency) != 1:
_logger.error("Error 'EUR' currency not found for payment method validation!")
return False
reference = "VALIDATION-%s-%s" % (self.id, datetime.datetime.now().strftime('%y%m%d_%H%M%S'))
tx = self.env['payment.transaction'].create({
'amount': amount,
'acquirer_id': self.acquirer_id.id,
'type': 'validation',
'currency_id': currency.id,
'reference': reference,
'payment_token_id': self.id,
'partner_id': self.partner_id.id,
'partner_country_id': self.partner_id.country_id.id,
})
try:
kwargs.update({'3d_secure': True})
tx.s2s_do_transaction(**kwargs)
# if 3D secure is called, then we do not refund right now
if tx.html_3ds:
return tx
except:
_logger.error('Error while validating a payment method')
finally:
tx.s2s_do_refund()
return tx
@api.multi
@api.depends('name')
def _compute_short_name(self):
for token in self:
token.short_name = token.name.replace('XXXXXXXXXXXX', '***')
@api.multi
def _get_linked_records(self):
""" Returns the list of records currently using the records in self as payment token. """
return {}
@@ -5,4 +5,63 @@
<link rel="stylesheet" href="/payment/static/src/less/payment_acquirer.less"/>
</xpath>
</template>
<template id="payment_tokens_list" name="Payment Tokens list">
<!--
Variables description:
- 'payment' set this variable when you're doing a payment, this will allow to display the radio buttons for each tokens and show the submit button
- 'show_delete' when you want to display the delete button for each token
- 'show_radio' when you want to display the radio button for each token
- 'show_submit' when you want to display the submit button at the bottom of the form
- 'submit_txt' the text displayed inside the submit button
- 'submit_class' the css classes to style the submit button
- 'form_action' the URI to the page that will handle the form values
- 'pms' the tokens
- 'checked_pm_id' the payment token that should be checked (for radio buttons)
-->
<form id="payment_tokens_list" method="post" t-att-action="form_action if form_action else '#'">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<table class="table table-hover table-striped table-condensed">
<thead>
<tr>
<th>Payment Methods</th>
<th t-if="show_verification_status">Status</th>
<th t-if="show_delete"></th>
</tr>
</thead>
<tbody>
<t t-foreach="pms" t-as="pm">
<tr>
<td>
<t t-if="show_radio or payment">
<div class="radio">
<label><input type="radio" name="pm_id" t-att-value="pm.id" t-att-checked="checked_pm_id == pm.id"/><t t-esc="pm.short_name"/></label>
</div>
</t>
<t t-else="1">
<p><t t-esc="pm.short_name"/></p>
</t>
</td>
<td t-if="show_verification_status"><t t-esc="'Verified' if pm.verified else 'Not Verified'"/></td>
<td t-if="show_delete"><button type="submit" name="delete_pm_id" t-att-value="pm.id" class="btn btn-xs btn-primary pull-right"><i class="fa fa-trash"> Delete</i></button></td>
</tr>
</t>
<tr t-if="(payment or show_submit) and not delete">
<th t-if="show_verification_status"></th>
<th t-if="show_delete"></th>
<th>
<button type="submit" t-att-class="submit_class if submit_class else 'btn btn-primary btn-sm mb8 mt8 pull-right'">
<t t-if="submit_txt">
<t t-raw="submit_txt"/>
</t>
<t t-else="1">
<span class="fa fa-lock"></span> Pay
</t>
</button>
</th>
</tr>
</tbody>
</table>
</form>
</template>
</odoo>
+23 -1
View File
@@ -38,12 +38,34 @@ class OgoneController(http.Controller):
error = ''
acq = request.env['payment.acquirer'].browse(int(post.get('acquirer_id')))
try:
acq.s2s_process(post)
token = acq.s2s_process(post)
except Exception as e:
# synthax error: 'CHECK ERROR: |Not a valid date\n\n50001111: None'
error = str(e).splitlines()[0].split('|')[-1] or ''
if token and post.get('verify_validity'):
baseurl = request.env['ir.config_parameter'].sudo().get_param('web.base.url')
params = {
'accept_url': baseurl + '/payment/ogone/validate/accept',
'decline_url': baseurl + '/payment/ogone/validate/decline',
'exception_url': baseurl + '/payment/ogone/validate/exception',
'return_url': post.get('return_url', baseurl)
}
tx = token.validate(**params)
if tx and tx.html_3ds:
return tx.html_3ds
return werkzeug.utils.redirect(post.get('return_url', '/') + (error and '#error=%s' % werkzeug.url_quote(error) or ''))
@http.route([
'/payment/ogone/validate/accept',
'/payment/ogone/validate/decline',
'/payment/ogone/validate/exception',
], type='http', auth='none')
def ogone_validation_form_feedback(self, **post):
""" Feedback from 3d secure for a bank card validation """
request.env['payment.transaction'].sudo().form_feedback(post, 'ogone')
return werkzeug.utils.redirect(werkzeug.url_unquote(post.pop('return_url', '/')))
@http.route(['/payment/ogone/s2s/feedback'], auth='none', csrf=False)
def feedback(self, **kwargs):
try:
+57 -7
View File
@@ -8,7 +8,7 @@ from unicodedata import normalize
import requests
from lxml import etree, objectify
from werkzeug import urls
from werkzeug import urls, url_encode
from odoo import api, fields, models, _
from odoo.addons.payment.models.payment_acquirer import ValidationError
@@ -280,7 +280,7 @@ class PaymentTxOgone(models.Model):
return invalid_parameters
def _ogone_form_validate(self, data):
if self.state == 'done':
if self.state in ['done', 'refunded']:
_logger.info('Ogone: trying to validate an already validated tx (ref %s)', self.reference)
return True
@@ -302,7 +302,12 @@ class PaymentTxOgone(models.Model):
})
vals.update(payment_token_id=pm.id)
self.write(vals)
self.payment_token_id.verified = True
self.execute_callback()
# if this transaction is a validation one, then we refund the money we just withdrawn
if self.type == 'verification':
self.s2s_do_refund()
return True
elif status in self._ogone_cancel_tx_status:
self.write({
@@ -331,12 +336,15 @@ class PaymentTxOgone(models.Model):
# --------------------------------------------------
# S2S RELATED METHODS
# --------------------------------------------------
def ogone_s2s_do_transaction(self, **kwargs):
# TODO: create tx with s2s type
account = self.acquirer_id
reference = self.reference or "ODOO-%s-%s" % (datetime.datetime.now().strftime('%y%m%d_%H%M%S'), self.partner_id.id)
param_plus = {
'return_url': kwargs.get('return_url', False)
}
data = {
'PSPID': account.ogone_pspid,
'USERID': account.ogone_userid,
@@ -348,6 +356,7 @@ class PaymentTxOgone(models.Model):
'ECI': 2, # Recurring (from MOTO)
'ALIAS': self.payment_token_id.acquirer_ref,
'RTIMEOUT': 30,
'PARAMPLUS' : url_encode(param_plus)
}
if kwargs.get('3d_secure'):
@@ -380,19 +389,58 @@ class PaymentTxOgone(models.Model):
return self._ogone_s2s_validate_tree(tree)
def ogone_s2s_do_refund(self, **kwargs):
# we refund only if this transaction hasn't been already refunded and was paid.
if self.state != 'done':
return False
self.state = 'refunding'
account = self.acquirer_id
reference = self.reference or "ODOO-%s-%s" % (datetime.datetime.now().strftime('%y%m%d_%H%M%S'), self.partner_id.id)
data = {
'PSPID': account.ogone_pspid,
'USERID': account.ogone_userid,
'PSWD': account.ogone_password,
'ORDERID': reference,
'AMOUNT': int(self.amount * 100),
'CURRENCY': self.currency_id.name,
'OPERATION': 'RFD',
'ECI': 2, # Recurring (from MOTO)
'ALIAS': self.payment_token_id.acquirer_ref,
'RTIMEOUT': 30,
}
direct_order_url = 'https://secure.ogone.com/ncol/%s/orderdirect.asp' % (self.acquirer_id.environment)
_logger.debug("Ogone data %s", pformat(data))
result = requests.post(direct_order_url, data=data).content
_logger.debug('Ogone response = %s', result)
try:
tree = objectify.fromstring(result)
except etree.XMLSyntaxError:
# invalid response from ogone
_logger.exception('Invalid xml response from ogone')
raise
return self._ogone_s2s_validate_tree(tree)
def _ogone_s2s_validate(self):
tree = self._ogone_s2s_get_tx_status()
return self._ogone_s2s_validate_tree(tree)
def _ogone_s2s_validate_tree(self, tree, tries=2):
if self.state not in ('draft', 'pending'):
if self.state not in ('draft', 'pending', 'refunding'):
_logger.info('Ogone: trying to validate an already validated tx (ref %s)', self.reference)
return True
status = int(tree.get('STATUS') or 0)
if status in self._ogone_valid_tx_status:
new_state = 'refunded' if self.state == 'refunding' else 'done'
self.write({
'state': 'done',
'state': new_state,
'date_validate': datetime.date.today().strftime(DEFAULT_SERVER_DATE_FORMAT),
'acquirer_reference': tree.get('PAYID'),
})
@@ -406,6 +454,7 @@ class PaymentTxOgone(models.Model):
'name': tree.get('CARDNO'),
})
self.write({'payment_token_id': pm.id})
self.payment_token_id.verified = True
self.execute_callback()
return True
elif status in self._ogone_cancel_tx_status:
@@ -414,8 +463,9 @@ class PaymentTxOgone(models.Model):
'acquirer_reference': tree.get('PAYID'),
})
elif status in self._ogone_pending_tx_status:
new_state = 'refunding' if self.type == 'validation' else 'pending'
self.write({
'state': 'pending',
'state': new_state,
'acquirer_reference': tree.get('PAYID'),
'html_3ds': str(tree.HTML_ANSWER).decode('base64')
})
@@ -522,4 +572,4 @@ class PaymentToken(models.Model):
'acquirer_ref': alias,
'name': 'XXXXXXXXXXXX%s - %s' % (values['cc_number'][-4:], values['cc_holder_name'])
}
return {}
return {}
@@ -56,6 +56,7 @@
<template id="ogone_s2s_form">
<form method="post" t-att-action="'/payment/ogone/s2s/create' if not json else '/payment/ogone/s2s/create_json'">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input t-if="verify_validity==True" type="hidden" name="verify_validity" value="1"/>
<div t-att-class="'row' if bootstrap_formatting else None">
<div t-att-class="'form-group col-md-8' if bootstrap_formatting else 'form-group'">
<label class="control-label" for="cc_number">Card number</label>
+23 -1
View File
@@ -18,7 +18,7 @@ class WebsitePayment(http.Controller):
}
return_url = request.params.get('redirect', '/my/payment_method')
for acquirer in acquirers:
acquirer.form = acquirer.sudo()._registration_render(request.env.user.partner_id.id, {'error': {}, 'error_message': [], 'return_url': return_url, 'json': False, 'bootstrap_formatting': True})
acquirer.form = acquirer.sudo()._registration_render(request.env.user.partner_id.id, {'error': {}, 'error_message': [], 'return_url': return_url, 'json': False, 'bootstrap_formatting': True, 'verify_validity': True})
return request.render("website_payment.pay_methods", values)
@http.route(['/website_payment/delete/'], methods=['POST'], type='http', auth="user", website=True)
@@ -90,3 +90,25 @@ class WebsitePayment(http.Controller):
return request.render('website_payment.confirm', {'tx': tx, 'status': status, 'message': message})
else:
return request.redirect('/my/home')
@http.route(['/website_payment/get_linked_records'], type='json', auth='user', website=True)
def get_linked_records(self, payment_token_id):
payment_token_id = int(payment_token_id)
res = []
token = request.env['payment.token'].search([('id','=',payment_token_id)])
# if the payment token is valid
if token:
# then we retrieve the description for the model sale.subscription
subscription_description = request.env['sale.subscription']._description
# we retrieve all the linked records for the token
records = token._get_linked_records()
# transform the list of tuple into a dict so we can search easily
records = dict(records[payment_token_id])
# if there's subscriptions linked to that token
if records.get(subscription_description):
subscriptions = records[subscription_description]
# we create our json with all the info needed
for r in subscriptions:
res.append({'id': r.id, 'name': r.name})
return res
@@ -60,3 +60,58 @@ $(document).ready(function () {
});
});
odoo.define('website_payment.payment_acquirer', function (require) {
"use strict";
var website = require('website.website');
var ajax = require('web.ajax');
var Dialog = require('web.Dialog');
var payment_methods = $('#payment_tokens_list');
payment_methods.find('button[name="delete_pm_id"]').on('click', function(ev){
ev.stopPropagation();
ev.preventDefault();
var self = this;
// we retrieve the selected payment method id
var payment_method_id = ev.target.value;
var Delete = function()
{
payment_methods.find('button[name="delete_pm_id"]').off("click");
ev.target.click();
};
ajax.jsonRpc('/website_payment/get_linked_records', 'call', {
'payment_token_id': payment_method_id,
}).done(function (response) {
// if there's records linked to the payment method we're trying to delete
if(response.length > 0)
{
var content = '';
response.forEach(function(sub) {
content += '<p><a href="/my/subscription/' + sub.id + '">' + sub.name + '</a><p/>';
});
content = $('<div>').html('<p>This card is currently linked to the following records:<p/>' + content);
// Then we display the list of the records and ask the user if he really want to remove the ppayment method.
new Dialog(self, {
title: 'Warning!',
size: 'medium',
$content: content,
buttons: [
{text: 'Confirm Deletion', classes: 'btn-primary', close: true, click: Delete},
{text: 'Cancel', close: true}]}).open();
}
// otherwise if the user has no records linked to this payment method, then we just delete it.
else
{
Delete();
}
});
});
});
@@ -40,11 +40,11 @@
<h1>Payment Acquirers</h1>
<h4 t-if="acquirers">Add a new payment acquirer:</h4>
<div t-if="acquirers" class="row">
<div class="col-md-6">
<div class="col-sm-8">
<div t-if="error_message" class="alert alert-danger">
<t t-foreach="error_message" t-as="err"><t t-esc="err"/><br /></t>
</div>
<div t-if='len(acquirers)>1'>
<div t-if='len(acquirers)&gt;1'>
<label class="control-label" for="pm_acquirer_id">Acquirer</label>
<select name="pm_acquirer_id" class="form-control">
<t t-foreach="acquirers" t-as="acquirer">
@@ -57,22 +57,12 @@
</t>
</div>
</div>
<div class="clearfix"></div>
<h4>Delete a payment acquirer:</h4>
<div class="row">
<div class="col-md-6">
<form method="post" action="/website_payment/delete/">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="form-group">
<select name="delete_pm_id" class="form-control" >
<t t-foreach="pms" t-as="pm">
<option t-att-value="pm.id" t-esc="pm.name"/>
</t>
</select>
</div>
<div class="clearfix"></div>
<button class="btn btn-primary">Delete <span class="fa fa-long-arrow-right"></span></button>
</form>
<div t-if="pms" class="clearfix">
<h4>Your payment Methods</h4>
<t t-set="show_delete" t-value="True"/>
<t t-set="form_action" t-value="'/website_payment/delete/'"/>
<div class="col-sm-8">
<t t-call="payment.payment_tokens_list"/>
</div>
</div>
</div>