[IMP] Payments & subscriptions: Improved Payments

- When registering a payment token, validating it using a payment of a small amount (~1.50€) followed by a refund allows ensuring
    that the payment 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.
- Introducing a new payment form that handles payment, deletion and adding payment method (only for server2server for the moment).
- On /my/payment_method, changed strings 'Payment Acquirers' to 'Payment Methods' which is more clear.
- Stripe can now be used to pay subscriptions.
This commit is contained in:
tbe-odoo
2017-08-14 08:30:59 +02:00
committed by Damien Bouvy
parent c089d39a66
commit 2df9c22d80
20 changed files with 639 additions and 279 deletions
+15 -4
View File
@@ -786,7 +786,7 @@ class PaymentToken(models.Model):
return False
reference = "VALIDATION-%s-%s" % (self.id, datetime.datetime.now().strftime('%y%m%d_%H%M%S'))
tx = self.env['payment.transaction'].create({
tx = self.env['payment.transaction'].sudo().create({
'amount': amount,
'acquirer_id': self.acquirer_id.id,
'type': 'validation',
@@ -816,6 +816,17 @@ class PaymentToken(models.Model):
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 {}
def get_linked_records(self):
""" This method returns a dict containing all the records linked to the payment.token (e.g Subscriptions),
the key is the id of the payment.token and the value is an array that must follow the scheme below.
{
token_id: [
'description': The model description (e.g 'Sale Subscription'),
'id': The id of the record,
'name': The name of the record,
'url': The url to access to this record.
]
}
"""
return {r.id:[] for r in self}
@@ -0,0 +1,261 @@
odoo.define('payment.payment_form', function (require){
"use strict";
var ajax = require('web.ajax');
var core = require('web.core');
var Dialog = require("web.Dialog");
var Widget = require("web.Widget");
var rpc = require("web.rpc");
var _t = core._t;
var PaymentForm = Widget.extend({
events: {
'click #o_payment_form_pay': 'payEvent',
'click #o_payment_form_add_pm': 'addPmEvent',
'click button[name="delete_pm"]': 'deletePmEvent',
'click input[type="radio"]': 'radioClickEvent'
},
payEvent: function(ev) {
ev.stopPropagation();
ev.preventDefault();
var form = this.el;
var checked_radio = this.$('input[type="radio"]:checked');
var self = this;
var button = ev.target;
// first we check that the user has selected a payment method
if(checked_radio.length == 1) {
checked_radio = checked_radio[0];
// if the user is adding a new payment
if(this.isNewPaymentRadio(checked_radio)) {
var acquirer_id = this.getAcquirerIdFromRadio(checked_radio);
var acquirer_form = this.$('#o_payment_add_token_acq_' + acquirer_id);
// we retrieve all the input inside the acquirer form and 'serialize' them to an indexed array
var form_data = this.getFormData($('input', acquirer_form));
var ds = $('input[name="data_set"]', acquirer_form)[0];
$(button).attr('disabled', true);
$(button).prepend('<span class="o_loader"><i class="fa fa-refresh fa-spin"></i>&nbsp;</span>');
var verify_validity = this.$el.find('input[name="verify_validity"]');
if(verify_validity.length>0)
form_data.verify_validity = verify_validity[0].value === "1";
// do the call to the route stored in the 'data_set' input of the acquirer form, the data must be called 'create-route'
ajax.jsonRpc(ds.dataset.createRoute, 'call', form_data).then(function(data) {
// if the server has returned true
if(data.result) {
// and it need a 3DS authentication
if(data['3d_secure'] !== false) {
// then we display the 3DS page to the user
$("body").html(data['3d_secure']);
}
else {
checked_radio.value = data.id; // set the radio value to the new card id
form.submit();
}
}
// if the server has returned false, we display an error
else {
self.error(_t('Error'),
_t("<p>We are not able to add your payment method at the moment.</p>"));
}
// here we remove the 'processing' icon from the 'add a new payment' button
$(button).attr('disabled', false);
$(button).find('span.o_loader').remove();
}).fail(function(message, data) {
// if the rpc fails, pretty obvious
$(button).attr('disabled', false);
$(button).find('span.o_loader').remove();
self.error(_t('Error'),
_t("<p>We are not able to add your payment method at the moment.</p>") + (core.debug ? data.data.message : ''));
});
}
// if the user is using an old payment
else {
// then we just submit the form
form.submit();
}
}
else {
this.error(_t('Error'),
_t('<p>Please select a payment method</p>'));
}
},
// event handler when clicking on the button to add a new payment method
addPmEvent: function(ev) {
ev.stopPropagation();
ev.preventDefault();
var form = this.el;
var checked_radio = this.$('input[type="radio"]:checked');
var self = this;
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])) {
// then we add a 'processing' icon into the 'add a new payment' button
$(button).attr('disabled', true);
$(button).prepend('<span class="o_loader"><i class="fa fa-refresh fa-spin"></i>&nbsp;</span>');
// we retrieve which acquirer is used
checked_radio = checked_radio[0];
var acquirer_id = this.getAcquirerIdFromRadio(checked_radio);
var acquirer_form = this.$('#o_payment_add_token_acq_' + acquirer_id);
// we retrieve all the input inside the acquirer form and 'serialize' them to an indexed array
var form_data = this.getFormData($('input', acquirer_form));
var ds = $('input[name="data_set"]', acquirer_form)[0];
// we force the check when adding a card trough here
form_data.verify_validity = true;
// do the call to the route stored in the 'data_set' input of the acquirer form, the data must be called 'create-route'
ajax.jsonRpc(ds.dataset.createRoute, 'call', form_data).then(function(data) {
// if the server has returned true
if(data.result) {
// and it need a 3DS authentication
if(data['3d_secure'] !== false) {
// then we display the 3DS page to the user
$("body").html(data['3d_secure']);
}
// if it doesn't require 3DS
else {
// we just go to the return_url or reload the page
if(form_data.return_url) {
window.location = form_data.return_url;
}
else {
window.location.reload();
}
}
}
// if the server has returned false, we display an error
else {
self.error(_t('Error'),
_t("<p>We are not able to add your payment method at the moment.</p>"));
}
// here we remove the 'processing' icon from the 'add a new payment' button
$(button).attr('disabled', false);
$(button).find('span.o_loader').remove();
}).fail(function(message, data) {
// if the rpc fails, pretty obvious
$(button).attr('disabled', false);
$(button).find('span.o_loader').remove();
self.error(_t('Error'),
_t("<p>We are not able to add your payment method at the moment.</p>") + (core.debug ? data.data.message : ''));
});
}
else {
this.error(_t('Error'),
_t('<p>Please select the option to add a new payment method</p>'));
}
},
// event handler when clicking on a button to delete a payment method
deletePmEvent: function (ev) {
ev.stopPropagation();
ev.preventDefault();
var self = this;
var pm_id = parseInt(ev.target.value);
var Delete = function() {
rpc.query({
model: 'payment.token',
method: 'unlink',
args: [pm_id],
})
.then(function(result){
if(result === true) {
ev.target.closest('div').remove();
}
},function(type,err){
self.error(_t('Error'),
_t("<p>We are not able to delete your payment method at the moment.</p>"));
});
};
rpc.query({
model: 'payment.token',
method: 'get_linked_records',
args: [pm_id],
}).then(function(result){
if(result[pm_id].length > 0) {
// if there's records linked to this payment method
var content = '';
result[pm_id].forEach(function(sub) {
content += '<p><a href="' + sub.url + '" title="' + _.str.escapeHTML(sub.description) + '">' + _.str.escapeHTML(sub.name) + '</a><p/>';
});
content = $('<div>').html(_t('<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: _t('Warning!'),
size: 'medium',
$content: content,
buttons: [
{text: _t('Confirm Deletion'), classes: 'btn-primary', close: true, click: Delete},
{text: _t('Cancel'), close: true}]}).open();
}
else {
// if there's no records linked to this payment method, then we delete it
Delete();
}
}, function(type, err){
self.error(_t('Error'),
_t("<p>We are not able to delete your payment method at the moment.</p>") + (core.debug ? err.data.message : ''));
});
},
// event handler when clicking on a radio button
radioClickEvent: function(ev) {
this.updateNewPaymentDisplayStatus();
},
updateNewPaymentDisplayStatus: function()
{
var checked_radio = this.$('input[type="radio"]:checked');
// we hide all the acquirers form
this.$('[id*="o_payment_add_token_acq_"]').addClass('hidden');
// if we clicked on an add new payment radio
if(checked_radio.length == 1 && this.isNewPaymentRadio(checked_radio[0]))
{
// then we retrieve the acquirer name
var acquirer_id = this.getAcquirerIdFromRadio(checked_radio[0]);
// and display its form
this.$('#o_payment_add_token_acq_' + acquirer_id).removeClass('hidden');
}
},
isNewPaymentRadio: function(element) {
return $(element).data('acquirer-id') !== undefined;
},
getAcquirerIdFromRadio: function(element) {
return $(element).data('acquirer-id');
},
error: function(title, message, url) {
return new Dialog(null, {
title: title || "",
$content: $(core.qweb.render('website.error_dialog', {
message: message || "",
})),
}).open();
},
getFormData: function($form){
var unindexed_array = $form.serializeArray();
var indexed_array = {};
$.map(unindexed_array, function(n, i){
indexed_array[n.name] = n.value;
});
return indexed_array;
},
start: function() {
this.updateNewPaymentDisplayStatus();
},
});
$(document).ready(function (){
var form = new PaymentForm();
form.attachTo($('.o_payment_form'));
});
});
@@ -0,0 +1,32 @@
.o_payment_form {
label > input[type="radio"]{
vertical-align: bottom;
margin-right: 5px;
}
label {
font-size: 14px;
font-weight: normal !important;
font-family: Helvetica Neue, sans-serif;
line-height: 1.3em;
color: #4d4d4d;
}
.panel-body:first-child {
border-top: 0px;
}
.panel {
border-radius: 10px;
}
.panel-footer:last-child {
border-bottom-right-radius: 10px !important;
border-bottom-left-radius: 10px !important;
}
.panel-body {
border-top: 1px solid #ddd;
padding: 1.14em !important;
}
}
+2
View File
@@ -3,6 +3,8 @@
<template id="assets_frontend" inherit_id="web.assets_frontend" name="Payment Tx Assets">
<xpath expr="." position="inside">
<script type="text/javascript" src="/payment/static/src/js/payment_transaction.js"></script>
<script type="text/javascript" src="/payment/static/src/js/payment_form.js"></script>
<link rel="stylesheet" href="/payment/static/src/less/payment_form.less"/>
</xpath>
</template>
</odoo>
+57 -45
View File
@@ -9,59 +9,71 @@
<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
- 'icon_class' font awesome class (e.g. 'fa-trash', 'fa-lock')
- '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)
- 'mode' can take two values, either 'payment' or 'manage'. 'manage' displays the add a new card and delete buttons. 'payment'
display a form that is used to pay and send the information to the form action url.
- 'acquirers' the list of the acquirers
- 'verify_validity' if we need to verify if the payment method is valid when adding a new one
-->
<form id="payment_tokens_list" method="post" t-att-action="form_action if form_action else '#'">
<form method="post" t-att-action="form_action if form_action else '#'" class="o_payment_form">
<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>
<input type="hidden" t-if="verify_validity==True" name="verify_validity" value="1"/>
<div class="panel panel-default">
<t t-foreach="acquirers" t-as="acq">
<div class="panel-body">
<label>
<t t-if="mode == 'manage'">
<input type="radio" t-att-data-acquirer-id="acq.id" name="pm_id" t-attf-value="new_{{acq.id}}" t-att-class="'hidden' if (len(acquirers)==1) else ''" t-att-checked="len(acquirers) == 1"/>
</t>
<t t-else="">
<input type="radio" t-att-data-acquirer-id="acq.id" name="pm_id" t-attf-value="new_{{acq.id}}" t-att-class="'hidden' if (len(acquirers)==1 and len(pms)==0) else ''" t-att-checked="len(acquirers)==1 and len(pms)==0"/>
</t>
<t t-esc="acq.name"/>
</label>
</div>
<div t-attf-id="o_payment_add_token_acq_{{acq.id}}" t-attf-class="panel-footer {{'hidden' if (len(acquirers)&gt;1 and len(pms)==0 and acquirers[0]!=acq) else 'hidden' if len(pms)&gt;0 else ''}}">
<div class="clearfix">
<t t-raw="acq.form" t-att-data-acquirer-id="acquirer.id"/>
</div>
</div>
</t>
<t t-foreach="pms" t-as="pm">
<div class="panel-body">
<label>
<input t-if="mode == 'payment'" type="radio" name="pm_id" t-att-value="pm.id" t-att-checked="checked_pm_id == pm.id"/>
<t t-esc="pm.name"/>
<t t-if="pm.verified">
<i class="fa fa-check text-success" title="This payment method is verified by our system."></i>
</t>
<t t-else="">
<i class="fa fa-close text-danger" title="This payment method has not been verified by our system."></i>
</t>
</label>
<button t-if="mode == 'manage'" name="delete_pm" t-att-value="pm.id" class="btn btn-primary btn-xs pull-right">
<i class="fa fa-trash"></i> Delete
</button>
</div>
</t>
</div>
<div class="pull-right">
<button t-if="mode == 'payment'" id="o_payment_form_pay" type="submit" t-att-class="submit_class if submit_class else 'btn btn-primary btn-lg mb8 mt8'">
<t t-if="submit_txt">
<i t-if="icon_class" t-attf-class="fa {{icon_class}}"/>
<t t-esc="submit_txt"/>
</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>
<t t-else="">
<i class="fa fa-lock"> Pay</i>
</t>
</button>
<button t-if="mode == 'manage'" id="o_payment_form_add_pm" class="btn btn-primary btn-lg mb8 mt8">
<i class="fa fa-plus-circle"> Add new card</i>
</button>
</div>
</form>
</template>
</odoo>
+25 -1
View File
@@ -35,7 +35,31 @@ class AuthorizeController(http.Controller):
def authorize_s2s_create_json(self, **kwargs):
acquirer_id = int(kwargs.get('acquirer_id'))
acquirer = request.env['payment.acquirer'].browse(acquirer_id)
return acquirer.s2s_process(kwargs)
return acquirer.s2s_process(kwargs).id
@http.route(['/payment/authorize/s2s/create_json_3ds'], type='json', auth='public', csrf=False)
def authorize_s2s_create_json_3ds(self, verify_validity=False, **kwargs):
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
@http.route(['/payment/authorize/s2s/create'], type='http', auth='public')
def authorize_s2s_create(self, **post):
@@ -163,6 +163,33 @@ class AuthorizeAPI():
res['name'] = response_profile.find('paymentProfile/payment/creditCard/cardNumber').text
return res
def credit(self, token, amount, transaction_id):
""" Refund a payment for the given amount.
:param record token: the payment.token record that must be refunded.
:param str amount: transaction amount
:param str transaction_id: the reference of the transacation that is going to be refunded.
:return: a dict containing the response code, transaction id and transaction type
:rtype: dict
"""
root = self._base_tree('createTransactionRequest')
tx = etree.SubElement(root, "transactionRequest")
etree.SubElement(tx, "transactionType").text = "refundTransaction"
etree.SubElement(tx, "amount").text = str(amount)
payment = etree.SubElement(tx, "payment")
credit_card = etree.SubElement(payment, "creditCard")
idx = token.name.find(' - ')
etree.SubElement(credit_card, "cardNumber").text = token.name[idx-4:idx] # shitty hack, but that's the only way to get the 4 last digits
etree.SubElement(credit_card, "expirationDate").text = "XXXX"
etree.SubElement(tx, "refTransId").text = transaction_id
response = self._authorize_request(root)
res = dict()
res['x_response_code'] = response.find('transactionResponse/responseCode').text
res['x_trans_id'] = transaction_id
res['x_type'] = 'refund'
return res
# Transaction management
def auth_and_capture(self, token, amount, reference):
"""Authorize and capture a payment for the given amount.
+31 -8
View File
@@ -116,7 +116,7 @@ class PaymentAcquirerAuthorize(models.Model):
'partner_id': int(data.get('partner_id'))
}
PaymentMethod = self.env['payment.token'].sudo().create(values)
return PaymentMethod.id
return PaymentMethod
@api.multi
def authorize_s2s_form_validate(self, data):
@@ -191,7 +191,7 @@ class TxAuthorize(models.Model):
@api.multi
def _authorize_form_validate(self, data):
if self.state == 'done':
if self.state in ['done', 'refunded']:
_logger.warning('Authorize: trying to validate an already validated tx (ref %s)' % self.reference)
return True
status_code = int(data.get('x_response_code', '0'))
@@ -219,6 +219,9 @@ class TxAuthorize(models.Model):
'partner_id': self.partner_id.id,
})
self.payment_token_id = token_id
if self.payment_token_id:
self.payment_token_id.verified = True
return True
elif status_code == self._authorize_pending_tx_status:
self.write({
@@ -252,6 +255,17 @@ class TxAuthorize(models.Model):
res = transaction.authorize(self.payment_token_id, self.amount, self.reference)
return self._authorize_s2s_validate_tree(res)
@api.multi
def authorize_s2s_do_refund(self):
self.ensure_one()
transaction = AuthorizeAPI(self.acquirer_id)
self.state = 'refunding'
if self.type == 'validation':
res = transaction.void(self.acquirer_reference)
else:
res = transaction.credit(self.payment_token_id, self.amount, self.acquirer_reference)
return self._authorize_s2s_validate_tree(res)
@api.multi
def authorize_s2s_capture_transaction(self):
self.ensure_one()
@@ -272,8 +286,7 @@ class TxAuthorize(models.Model):
@api.multi
def _authorize_s2s_validate(self, tree):
self.ensure_one()
if self.state == 'done':
if self.state in ['done', 'refunded']:
_logger.warning('Authorize: trying to validate an already validated tx (ref %s)' % self.reference)
return True
status_code = int(tree.get('x_response_code', '0'))
@@ -287,6 +300,10 @@ class TxAuthorize(models.Model):
})
if init_state != 'authorized':
self.execute_callback()
if self.payment_token_id:
self.payment_token_id.verified = True
if tree.get('x_type').lower() == 'auth_only':
self.write({
'state': 'authorized',
@@ -294,13 +311,19 @@ class TxAuthorize(models.Model):
})
self.execute_callback()
if tree.get('x_type').lower() == 'void':
self.write({
'state': 'cancel',
})
if self.type == 'validation' and self.state == 'refunding':
self.write({
'state': 'refunded',
})
else:
self.write({
'state': 'cancel',
})
return True
elif status_code == self._authorize_pending_tx_status:
new_state = 'refunding' if self.state == 'refunding' else 'pending'
self.write({
'state': 'pending',
'state': new_state,
'acquirer_reference': tree.get('x_trans_id'),
})
return True
@@ -54,38 +54,28 @@
</template>
<template id="authorize_s2s_form">
<form method="post" t-att-action="'/payment/authorize/s2s/create' if not json else '/payment/authorize/s2s/create_json'">
<div t-att-class="'row' if bootstrap_formatting else ''">
<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>
<input type="tel" name="cc_number" id="cc_number" class="form-control" placeholder="&#8226;&#8226;&#8226;&#8226;&#32;&#8226;&#8226;&#8226;&#8226;&#32;&#8226;&#8226;&#8226;&#8226;&#32;&#8226;&#8226;&#8226;&#8226;&#32;"/>
<div class="card_placeholder"></div>
<div class="visa"></div>
<input type="hidden" name="cc_brand" value=""/>
</div>
<div t-att-class="'form-group col-md-4' if bootstrap_formatting else 'form-group'">
<label class="control-label" for="cc_cvc">CVC</label>
<input type="text" name="cc_cvc" id="cc_cvc" class="form-control" maxlength="4" placeholder="CVC"/>
</div>
<div t-att-class="'form-group col-md-7' if bootstrap_formatting else 'form-group'">
<label class="control-label" for="cc_holder_name">Name</label>
<input type="text" name="cc_holder_name" id="cc_holder_name" class="form-control"/>
</div>
<div t-att-class="'form-group col-md-5' if bootstrap_formatting else 'form-group'">
<label class="control-label" for="cc_expires_mm">Expires</label>
<input type="text" name="cc_expiry" id="cc_expiry" class="form-control" maxlength="7" placeholder="MM / YY"/>
</div>
<input type="hidden" name="acquirer_id" t-att-value="id"/>
<input class="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<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"/>
<div class="clearfix"/>
<input type="hidden" name="data_set" data-create-route="/payment/authorize/s2s/create_json_3ds"/>
<div class="mt8">
<div t-att-class="'form-group col-md-12' if bootstrap_formatting else 'form-group'">
<input type="tel" name="cc_number" id="cc_number" class="form-control" placeholder="Card number"/>
<div class="card_placeholder"></div>
<div class="visa"></div>
<input type="hidden" name="cc_brand" value=""/>
</div>
<button t-att-class="submit_class" class="btn btn-primary" type="submit">
<span t-if="not submit_txt">Confirm <span class="fa fa-long-arrow-right"></span></span>
<span t-if="submit_txt"><t t-esc="submit_txt"/></span>
</button>
</form>
</template>
<div t-att-class="'form-group col-md-5' if bootstrap_formatting else 'form-group'">
<input type="text" name="cc_holder_name" id="cc_holder_name" class="form-control" placeholder="Cardholder name"/>
</div>
<div t-att-class="'form-group col-md-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)"/>
</div>
<div t-att-class="'form-group col-md-4' if bootstrap_formatting else 'form-group'">
<input type="text" name="cc_cvc" id="cc_cvc" class="form-control" maxlength="4" placeholder="CVC"/>
</div>
</div>
<input type="hidden" name="acquirer_id" t-att-value="id"/>
<input class="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<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"/>
</template>
</data>
</odoo>
+34
View File
@@ -33,6 +33,40 @@ class OgoneController(http.Controller):
new_id = request.env['payment.acquirer'].browse(int(kwargs.get('acquirer_id'))).s2s_process(kwargs)
return new_id.id
@http.route(['/payment/ogone/s2s/create_json_3ds'], type='json', auth='public', csrf=False)
def ogone_s2s_create_json_3ds(self, verify_validity=False, **kwargs):
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:
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': kwargs.get('return_url', baseurl)
}
tx = token.validate(**params)
res['verified'] = token.verified
if tx and tx.html_3ds:
res['3d_secure'] = tx.html_3ds
return res
@http.route(['/payment/ogone/s2s/create'], type='http', auth='public', methods=["POST"], csrf=False)
def ogone_s2s_create(self, **post):
error = ''
+6 -4
View File
@@ -302,10 +302,11 @@ class PaymentTxOgone(models.Model):
})
vals.update(payment_token_id=pm.id)
self.write(vals)
self.payment_token_id.verified = True
if self.payment_token_id:
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':
if self.type == 'validation':
self.s2s_do_refund()
return True
@@ -454,7 +455,8 @@ class PaymentTxOgone(models.Model):
'name': tree.get('CARDNO'),
})
self.write({'payment_token_id': pm.id})
self.payment_token_id.verified = True
if self.payment_token_id:
self.payment_token_id.verified = True
self.execute_callback()
return True
elif status in self._ogone_cancel_tx_status:
@@ -463,7 +465,7 @@ 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'
new_state = 'refunding' if self.state == 'refunding' else 'pending'
self.write({
'state': new_state,
'acquirer_reference': tree.get('PAYID'),
@@ -54,40 +54,27 @@
</template>
<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>
<input type="tel" name="cc_number" id="cc_number" class="form-control" placeholder="&#8226;&#8226;&#8226;&#8226;&#32;&#8226;&#8226;&#8226;&#8226;&#32;&#8226;&#8226;&#8226;&#8226;&#32;&#8226;&#8226;&#8226;&#8226;&#32;"/>
<div class="card_placeholder"></div>
<div class="visa"></div>
<input type="hidden" name="cc_brand" value=""/>
</div>
<div t-att-class="'form-group col-md-4' if bootstrap_formatting else 'form-group'">
<label class="control-label" for="cc_cvc">CVC</label>
<input type="text" name="cc_cvc" id="cc_cvc" class="form-control" maxlength="4" placeholder="CVC"/>
</div>
<div t-att-class="'form-group col-md-7' if bootstrap_formatting else 'form-group'">
<label class="control-label" for="cc_holder_name">Name</label>
<input type="text" name="cc_holder_name" id="cc_holder_name" class="form-control"/>
</div>
<div t-att-class="'form-group col-md-5' if bootstrap_formatting else 'form-group'">
<label class="control-label" for="cc_expires_mm">Expires</label>
<input type="text" name="cc_expiry" id="cc_expiry" class="form-control" maxlength="7" placeholder="MM / YY"/>
</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"/>
<div class="clearfix"/>
<input type="hidden" name="data_set" data-create-route="/payment/ogone/s2s/create_json_3ds"/>
<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"/>
<div class="mt8">
<div t-att-class="'form-group col-md-12' if bootstrap_formatting else 'form-group'">
<input type="tel" name="cc_number" id="cc_number" class="form-control" placeholder="Card number"/>
<div class="card_placeholder"></div>
<div class="visa"></div>
<input type="hidden" name="cc_brand" value=""/>
</div>
<button t-att-class="submit_class" class="btn btn-primary" type="submit">
<span t-if="not submit_txt">Confirm <span class="fa fa-long-arrow-right"></span></span>
<span t-if="submit_txt"><t t-esc="submit_txt"/></span>
</button>
</form>
<div t-att-class="'form-group col-md-5' if bootstrap_formatting else 'form-group'">
<input type="text" name="cc_holder_name" id="cc_holder_name" class="form-control" placeholder="Cardholder name"/>
</div>
<div t-att-class="'form-group col-md-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)"/>
</div>
<div t-att-class="'form-group col-md-4' if bootstrap_formatting else 'form-group'">
<input type="text" name="cc_cvc" id="cc_cvc" class="form-control" maxlength="4" placeholder="CVC"/>
</div>
</div>
</template>
</data>
</odoo>
+25 -1
View File
@@ -15,7 +15,7 @@ class StripeController(http.Controller):
def stripe_s2s_create_json(self, **kwargs):
acquirer_id = int(kwargs.get('acquirer_id'))
acquirer = request.env['payment.acquirer'].browse(acquirer_id)
return acquirer.s2s_process(kwargs)
return acquirer.s2s_process(kwargs).id
@http.route(['/payment/stripe/s2s/create'], type='http', auth='public')
def stripe_s2s_create(self, **post):
@@ -24,6 +24,30 @@ class StripeController(http.Controller):
acquirer.s2s_process(post)
return werkzeug.utils.redirect(post.get('return_url', '/'))
@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):
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
@http.route(['/payment/stripe/create_charge'], type='json', auth='public')
def stripe_create_charge(self, **post):
""" Create a payment transaction
+44 -3
View File
@@ -71,7 +71,7 @@ class PaymentAcquirerStripe(models.Model):
'acquirer_id': int(data['acquirer_id']),
'partner_id': int(data['partner_id'])
})
return payment_token.id
return payment_token
@api.multi
def stripe_s2s_form_validate(self, data):
@@ -83,6 +83,21 @@ class PaymentAcquirerStripe(models.Model):
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
"""
res = super(PaymentAcquirerStripe, self)._get_feature_support()
res['tokenize'].append('stripe')
return res
class PaymentTransactionStripe(models.Model):
_inherit = 'payment.transaction'
@@ -112,6 +127,29 @@ class PaymentTransactionStripe(models.Model):
result = self._create_stripe_charge(acquirer_ref=self.payment_token_id.acquirer_ref)
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,
'amount': int(self.amount*100), # by default, stripe refund the full amount (we don't really need to specify the value)
'metadata[reference]': self.reference,
}
r = requests.post(api_url_refund,
auth=(self.acquirer_id.stripe_secret_key, ''),
params=refund_params,
headers=STRIPE_HEADERS)
return r.json()
@api.multi
def stripe_s2s_do_refund(self, **kwargs):
self.ensure_one()
self.state = 'refunding'
result = self._create_stripe_refund()
return self._stripe_s2s_validate_tree(result)
@api.model
def _stripe_form_get_tx_from_data(self, data):
""" Given a data dict coming from stripe, verify it and find the related
@@ -135,18 +173,21 @@ class PaymentTransactionStripe(models.Model):
@api.multi
def _stripe_s2s_validate_tree(self, tree):
self.ensure_one()
if self.state not in ('draft', 'pending'):
if self.state not in ('draft', 'pending', 'refunding'):
_logger.info('Stripe: trying to validate an already validated tx (ref %s)', self.reference)
return True
status = tree.get('status')
if status == 'succeeded':
new_state = 'refunded' if self.state == 'refunding' else 'done'
self.write({
'state': 'done',
'state': new_state,
'date_validate': fields.datetime.now(),
'acquirer_reference': tree.get('id'),
})
self.execute_callback()
if self.payment_token_id:
self.payment_token_id.verified = True
return True
else:
error = tree['error']['message']
@@ -32,38 +32,29 @@
</template>
<template id="stripe_s2s_form">
<form method="post" t-att-action="'/payment/stripe/s2s/create' if not json else '/payment/stripe/s2s/create_json'">
<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>
<input type="tel" name="cc_number" id="cc_number" class="form-control" placeholder="&#8226;&#8226;&#8226;&#8226;&#32;&#8226;&#8226;&#8226;&#8226;&#32;&#8226;&#8226;&#8226;&#8226;&#32;&#8226;&#8226;&#8226;&#8226;&#32;"/>
<div class="card_placeholder"></div>
<div class="visa"></div>
<input type="hidden" name="cc_brand" value=""/>
</div>
<div t-att-class="'form-group col-md-4' if bootstrap_formatting else 'form-group'">
<label class="control-label" for="cvc">CVC</label>
<input type="text" name="cvc" id="cvc" class="form-control" maxlength="4" placeholder="CVC"/>
</div>
<div t-att-class="'form-group col-md-7' if bootstrap_formatting else 'form-group'">
<label class="control-label" for="cc_holder_name">Card Holder Name</label>
<input type="text" name="cc_holder_name" id="cc_holder_name" class="form-control"/>
</div>
<div t-att-class="'form-group col-md-5' if bootstrap_formatting else 'form-group'">
<label class="control-label" for="cc_expires_mm">Expiration Date</label>
<input type="text" name="cc_expiry" id="cc_expiry" class="form-control" maxlength="7" placeholder="MM / YY"/>
</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" data-create-route="/payment/stripe/s2s/create_json_3ds"/>
<div class="mt8">
<div t-att-class="'form-group col-md-12' if bootstrap_formatting else 'form-group'">
<input type="tel" name="cc_number" id="cc_number" class="form-control" placeholder="Card number"/>
<div class="card_placeholder"></div>
<div class="visa"></div>
<input type="hidden" name="cc_brand" value=""/>
</div>
<button t-att-class="submit_class" class="btn btn-primary" type="submit">
<span t-if="not submit_txt">Confirm <span class="fa fa-long-arrow-right"></span></span>
<span t-if="submit_txt"><t t-esc="submit_txt"/></span>
</button>
</form>
<div t-att-class="'form-group col-md-5' if bootstrap_formatting else 'form-group'">
<input type="text" name="cc_holder_name" id="cc_holder_name" class="form-control" placeholder="Cardholder name"/>
</div>
<div t-att-class="'form-group col-md-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)"/>
</div>
<div t-att-class="'form-group col-md-4' if bootstrap_formatting else 'form-group'">
<input type="text" name="cvc" id="cvc" class="form-control" maxlength="4" placeholder="CVC"/>
</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"/>
</div>
</template>
<template id="payment_stripe_redirect" name="Payment Stripe">
+1 -30
View File
@@ -18,16 +18,9 @@ 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, 'verify_validity': 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})
return request.render("website_payment.pay_methods", values)
@http.route(['/website_payment/delete/'], methods=['POST'], type='http', auth="user", website=True)
def delete(self, delete_pm_id=None):
if delete_pm_id:
pay_meth = request.env['payment.token'].browse(int(delete_pm_id))
pay_meth.unlink()
return request.redirect('/my/payment_method')
@http.route(['/website_payment/pay'], type='http', auth='public', website=True)
def pay(self, reference='', amount=False, currency_id=None, acquirer_id=None, **kw):
env = request.env
@@ -90,25 +83,3 @@ 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
@@ -8,7 +8,7 @@ div.card_placeholder {
width: 32px;
height: 20px;
position: absolute;
top: 34px;
top: 8px;
right: 20px;
-webkit-transition: 0.4s cubic-bezier(0.455, 0.03, 0.515, 0.955);
transition: 0.4s cubic-bezier(0.455, 0.03, 0.515, 0.955);
@@ -10,7 +10,7 @@ div.card_placeholder
width: 32px
height: 20px
position: absolute
top: 34px
top: 8px
right: 20px
-webkit-transition: .4s cubic-bezier(0.455,0.03,0.515,0.955)
transition: .4s cubic-bezier(0.455,0.03,0.515,0.955)
@@ -60,58 +60,3 @@ $(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();
}
});
});
});
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="header_footer_custom_payment" inherit_id="website.footer_default" customize_show="True" active="False" name="Payment Acquirers">
<template id="header_footer_custom_payment" inherit_id="website.footer_default" customize_show="True" active="False" name="Payment Methods">
<xpath expr="//div[@id='info']" position="inside">
<h4 class="payment_method_title">Payment Acquirer</h4>
<ul class="payment_method_list list-inline">
@@ -24,45 +24,28 @@
</xpath>
</template>
<template id="pay_methods" name="Payment Acquirers">
<template id="pay_methods" name="Payment Methods">
<t t-call="website.layout">
<t t-set="additional_title">Payment Acquirers</t>
<t t-set="additional_title">Payment Methods</t>
<div class="wrap">
<div class="container">
<div class="row">
<div class="col-sm-6">
<ol class="breadcrumb mt8">
<li><a href="/my/home"><i class="fa fa-home"/></a></li>
<li>Payment Acquirers</li>
<li>Payment Methods</li>
</ol>
</div>
</div>
<h1>Payment Acquirers</h1>
<h4 t-if="acquirers">Add a new payment acquirer:</h4>
<div t-if="acquirers" class="row">
<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 class="clearfix">
<div class="row">
<div class="col-sm-8">
<h3>Manage your Payment Methods</h3>
<t t-call="payment.payment_tokens_list">
<t t-set="mode" t-value="'manage'"/>
<t t-set="verify_validity" t-value="True"/>
</t>
</div>
<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">
<option t-att-value="acquirer.id" t-esc="acquirer.name"/>
</t>
</select>
</div>
<t t-foreach="acquirers" t-as="acquirer">
<div t-raw="acquirer.form" t-att-data-acquirer-id="acquirer.id" t-attf-class="acquirer #{'hidden' if acquirer!=acquirers[0] else ''}"/>
</t>
</div>
</div>
<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>
@@ -73,7 +56,7 @@
<template id="pay_meth_link" inherit_id="website_portal.portal_layout" customize_show="True">
<xpath expr="//div[@class='o_my_details']" position="inside">
<t t-if="request.env['payment.acquirer'].search([('website_published', '=', True), ('registration_view_template_id', '!=', False)])">
<div class='manage_payment_method mt16'><a href="/my/payment_method">Manage your payment acquirers</a></div>
<div class='manage_payment_method mt16'><a href="/my/payment_method">Manage your Payment Methods</a></div>
</t>
</xpath>
</template>