summaryrefslogtreecommitdiff
path: root/addons/payment/static/src
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/payment/static/src
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/payment/static/src')
-rw-r--r--addons/payment/static/src/js/payment_form.js588
-rw-r--r--addons/payment/static/src/js/payment_portal.js65
-rw-r--r--addons/payment/static/src/js/payment_processing.js121
-rw-r--r--addons/payment/static/src/scss/payment_acquirer.scss62
-rw-r--r--addons/payment/static/src/scss/payment_form.scss56
-rw-r--r--addons/payment/static/src/scss/portal_payment.scss61
-rw-r--r--addons/payment/static/src/xml/payment_processing.xml150
7 files changed, 1103 insertions, 0 deletions
diff --git a/addons/payment/static/src/js/payment_form.js b/addons/payment/static/src/js/payment_form.js
new file mode 100644
index 00000000..e74dfe3e
--- /dev/null
+++ b/addons/payment/static/src/js/payment_form.js
@@ -0,0 +1,588 @@
+odoo.define('payment.payment_form', function (require) {
+"use strict";
+
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var publicWidget = require('web.public.widget');
+
+var _t = core._t;
+
+publicWidget.registry.PaymentForm = publicWidget.Widget.extend({
+ selector: '.o_payment_form',
+ events: {
+ 'submit': 'onSubmit',
+ 'click #o_payment_form_pay': 'payEvent',
+ 'click #o_payment_form_add_pm': 'addPmEvent',
+ 'click button[name="delete_pm"]': 'deletePmEvent',
+ 'click .o_payment_form_pay_icon_more': 'onClickMorePaymentIcon',
+ 'click .o_payment_acquirer_select': 'radioClickEvent',
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ this._adaptPayButton();
+ window.addEventListener('pageshow', function (event) {
+ if (event.persisted) {
+ window.location.reload();
+ }
+ });
+ 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();
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} title
+ * @param {string} message
+ */
+ displayError: function (title, message) {
+ var $checkedRadio = this.$('input[type="radio"]:checked'),
+ acquirerID = this.getAcquirerIdFromRadio($checkedRadio[0]);
+ var $acquirerForm;
+ if (this.isNewPaymentRadio($checkedRadio[0])) {
+ $acquirerForm = this.$('#o_payment_add_token_acq_' + acquirerID);
+ }
+ else if (this.isFormPaymentRadio($checkedRadio[0])) {
+ $acquirerForm = this.$('#o_payment_form_acq_' + acquirerID);
+ }
+
+ if ($checkedRadio.length === 0) {
+ return new Dialog(null, {
+ title: _t('Error: ') + _.str.escapeHTML(title),
+ size: 'medium',
+ $content: "<p>" + (_.str.escapeHTML(message) || "") + "</p>" ,
+ buttons: [
+ {text: _t('Ok'), close: true}]}).open();
+ } else {
+ // removed if exist error message
+ this.$('#payment_error').remove();
+ var messageResult = '<div class="alert alert-danger mb4" id="payment_error">';
+ if (title != '') {
+ messageResult = messageResult + '<b>' + _.str.escapeHTML(title) + ':</b><br/>';
+ }
+ messageResult = messageResult + _.str.escapeHTML(message) + '</div>';
+ $acquirerForm.append(messageResult);
+ }
+ },
+ hideError: function() {
+ this.$('#payment_error').remove();
+ },
+ /**
+ * @private
+ * @param {DOMElement} element
+ */
+ getAcquirerIdFromRadio: function (element) {
+ return $(element).data('acquirer-id');
+ },
+ /**
+ * @private
+ * @param {jQuery} $form
+ */
+ 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;
+ },
+ /**
+ * @private
+ * @param {DOMElement} element
+ */
+ isFormPaymentRadio: function (element) {
+ return $(element).data('form-payment') === 'True';
+ },
+ /**
+ * @private
+ * @param {DOMElement} element
+ */
+ isNewPaymentRadio: function (element) {
+ return $(element).data('s2s-payment') === 'True';
+ },
+ /**
+ * @private
+ */
+ updateNewPaymentDisplayStatus: function () {
+ var checked_radio = this.$('input[type="radio"]:checked');
+ // we hide all the acquirers form
+ this.$('[id*="o_payment_add_token_acq_"]').addClass('d-none');
+ this.$('[id*="o_payment_form_acq_"]').addClass('d-none');
+ if (checked_radio.length !== 1) {
+ return;
+ }
+ checked_radio = checked_radio[0];
+ var acquirer_id = this.getAcquirerIdFromRadio(checked_radio);
+
+ // if we clicked on an add new payment radio, display its form
+ if (this.isNewPaymentRadio(checked_radio)) {
+ this.$('#o_payment_add_token_acq_' + acquirer_id).removeClass('d-none');
+ }
+ else if (this.isFormPaymentRadio(checked_radio)) {
+ this.$('#o_payment_form_acq_' + acquirer_id).removeClass('d-none');
+ }
+ },
+
+ disableButton: function (button) {
+ $("body").block({overlayCSS: {backgroundColor: "#000", opacity: 0, zIndex: 1050}, message: false});
+ $(button).attr('disabled', true);
+ $(button).children('.fa-lock').removeClass('fa-lock');
+ $(button).prepend('<span class="o_loader"><i class="fa fa-refresh fa-spin"></i>&nbsp;</span>');
+ },
+
+ enableButton: function (button) {
+ $('body').unblock();
+ $(button).attr('disabled', false);
+ $(button).children('.fa').addClass('fa-lock');
+ $(button).find('span.o_loader').remove();
+ },
+ _parseError: function(e) {
+ if (e.message.data.arguments[1]) {
+ return e.message.data.arguments[0] + e.message.data.arguments[1];
+ }
+ return e.message.data.arguments[0];
+ },
+ _adaptPayButton: function () {
+ var $payButton = $("#o_payment_form_pay");
+ var disabledReasons = $payButton.data('disabled_reasons') || {};
+ $payButton.prop('disabled', _.contains(disabledReasons, true));
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ payEvent: function (ev) {
+ ev.preventDefault();
+ var form = this.el;
+ var checked_radio = this.$('input[type="radio"]:checked');
+ var self = this;
+ 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) {
+ checked_radio = checked_radio[0];
+
+ // we retrieve all the input inside the acquirer form and 'serialize' them to an indexed array
+ var acquirer_id = this.getAcquirerIdFromRadio(checked_radio);
+ var acquirer_form = false;
+ if (this.isNewPaymentRadio(checked_radio)) {
+ acquirer_form = this.$('#o_payment_add_token_acq_' + acquirer_id);
+ } else {
+ acquirer_form = this.$('#o_payment_form_acq_' + acquirer_id);
+ }
+ var inputs_form = $('input', acquirer_form);
+ var ds = $('input[name="data_set"]', acquirer_form)[0];
+
+ // if the user is adding a new payment
+ if (this.isNewPaymentRadio(checked_radio)) {
+ if (this.options.partnerId === undefined) {
+ console.warn('payment_form: unset partner_id when adding new token; things could go wrong');
+ }
+ var form_data = this.getFormData(inputs_form);
+ var wrong_input = false;
+
+ inputs_form.toArray().forEach(function (element) {
+ //skip the check of non visible inputs
+ if ($(element).attr('type') == 'hidden') {
+ return true;
+ }
+ $(element).closest('div.form-group').removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid');
+ $(element).siblings( ".o_invalid_field" ).remove();
+ //force check of forms validity (useful for Firefox that refill forms automatically on f5)
+ $(element).trigger("focusout");
+ if (element.dataset.isRequired && element.value.length === 0) {
+ $(element).closest('div.form-group').addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
+ $(element).closest('div.form-group').append('<div style="color: red" class="o_invalid_field" aria-invalid="true">' + _.str.escapeHTML("The value is invalid.") + '</div>');
+ wrong_input = true;
+ }
+ else if ($(element).closest('div.form-group').hasClass('o_has_error')) {
+ wrong_input = true;
+ $(element).closest('div.form-group').append('<div style="color: red" class="o_invalid_field" aria-invalid="true">' + _.str.escapeHTML("The value is invalid.") + '</div>');
+ }
+ });
+
+ if (wrong_input) {
+ return;
+ }
+
+ this.disableButton(button);
+ // do the call to the route stored in the 'data_set' input of the acquirer form, the data must be called 'create-route'
+ return this._rpc({
+ route: ds.dataset.createRoute,
+ params: 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();
+ return new Promise(function () {});
+ }
+ }
+ // if the server has returned false, we display an error
+ else {
+ if (data.error) {
+ self.displayError(
+ '',
+ data.error);
+ } else { // if the server doesn't provide an error message
+ self.displayError(
+ _t('Server Error'),
+ _t('e.g. Your credit card details are wrong. Please verify.'));
+ }
+ }
+ // here we remove the 'processing' icon from the 'add a new payment' button
+ self.enableButton(button);
+ }).guardedCatch(function (error) {
+ error.event.preventDefault();
+ // if the rpc fails, pretty obvious
+ self.enableButton(button);
+
+ self.displayError(
+ _t('Server Error'),
+ _t("We are not able to add your payment method at the moment.") +
+ self._parseError(error)
+ );
+ });
+ }
+ // if the user is going to pay with a form payment, then
+ else if (this.isFormPaymentRadio(checked_radio)) {
+ this.disableButton(button);
+ var $tx_url = this.$el.find('input[name="prepare_tx_url"]');
+ // if there's a prepare tx url set
+ if ($tx_url.length === 1) {
+ // if the user wants to save his credit card info
+ var form_save_token = acquirer_form.find('input[name="o_payment_form_save_token"]').prop('checked');
+ // then we call the route to prepare the transaction
+ return this._rpc({
+ route: $tx_url[0].value,
+ params: {
+ 'acquirer_id': parseInt(acquirer_id),
+ 'save_token': form_save_token,
+ 'access_token': self.options.accessToken,
+ 'success_url': self.options.successUrl,
+ 'error_url': self.options.errorUrl,
+ 'callback_method': self.options.callbackMethod,
+ 'order_id': self.options.orderId,
+ 'invoice_id': self.options.invoiceId,
+ },
+ }).then(function (result) {
+ if (result) {
+ // if the server sent us the html form, we create a form element
+ var newForm = document.createElement('form');
+ newForm.setAttribute("method", self._get_redirect_form_method());
+ newForm.setAttribute("provider", checked_radio.dataset.provider);
+ newForm.hidden = true; // hide it
+ newForm.innerHTML = result; // put the html sent by the server inside the form
+ var action_url = $(newForm).find('input[name="data_set"]').data('actionUrl');
+ newForm.setAttribute("action", action_url); // set the action url
+ $(document.getElementsByTagName('body')[0]).append(newForm); // append the form to the body
+ $(newForm).find('input[data-remove-me]').remove(); // remove all the input that should be removed
+ if(action_url) {
+ newForm.submit(); // and finally submit the form
+ return new Promise(function () {});
+ }
+ }
+ else {
+ self.displayError(
+ _t('Server Error'),
+ _t("We are not able to redirect you to the payment form.")
+ );
+ self.enableButton(button);
+ }
+ }).guardedCatch(function (error) {
+ error.event.preventDefault();
+ self.displayError(
+ _t('Server Error'),
+ _t("We are not able to redirect you to the payment form.") + " " +
+ self._parseError(error)
+ );
+ self.enableButton(button);
+ });
+ }
+ else {
+ // we append the form to the body and send it.
+ this.displayError(
+ _t("Cannot setup the payment"),
+ _t("We're unable to process your payment.")
+ );
+ self.enableButton(button);
+ }
+ }
+ else { // if the user is using an old payment then we just submit the form
+ this.disableButton(button);
+ form.submit();
+ return new Promise(function () {});
+ }
+ }
+ else {
+ this.displayError(
+ _t('No payment method selected'),
+ _t('Please select a payment method.')
+ );
+ this.enableButton(button);
+ }
+ },
+ /**
+ * Return the HTTP method to be used by the redirect form.
+ *
+ * @private
+ * @return {string} The HTTP method, "post" by default
+ */
+ _get_redirect_form_method: function () {
+ return "post";
+ },
+ /**
+ * Called when clicking on the button to add a new payment method.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ addPmEvent: function (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ var checked_radio = this.$('input[type="radio"]:checked');
+ var self = this;
+ 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])) {
+ // 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 inputs_form = $('input', acquirer_form);
+ var form_data = this.getFormData(inputs_form);
+ var ds = $('input[name="data_set"]', acquirer_form)[0];
+ var wrong_input = false;
+
+ inputs_form.toArray().forEach(function (element) {
+ //skip the check of non visible inputs
+ if ($(element).attr('type') == 'hidden') {
+ return true;
+ }
+ $(element).closest('div.form-group').removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid');
+ $(element).siblings( ".o_invalid_field" ).remove();
+ //force check of forms validity (useful for Firefox that refill forms automatically on f5)
+ $(element).trigger("focusout");
+ if (element.dataset.isRequired && element.value.length === 0) {
+ $(element).closest('div.form-group').addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
+ var message = '<div style="color: red" class="o_invalid_field" aria-invalid="true">' + _.str.escapeHTML("The value is invalid.") + '</div>';
+ $(element).closest('div.form-group').append(message);
+ wrong_input = true;
+ }
+ else if ($(element).closest('div.form-group').hasClass('o_has_error')) {
+ wrong_input = true;
+ var message = '<div style="color: red" class="o_invalid_field" aria-invalid="true">' + _.str.escapeHTML("The value is invalid.") + '</div>';
+ $(element).closest('div.form-group').append(message);
+ }
+ });
+
+ if (wrong_input) {
+ return;
+ }
+ // We add a 'processing' icon into the 'add a new payment' button
+ $(button).attr('disabled', true);
+ $(button).children('.fa-plus-circle').removeClass('fa-plus-circle');
+ $(button).prepend('<span class="o_loader"><i class="fa fa-refresh fa-spin"></i>&nbsp;</span>');
+
+ // do the call to the route stored in the 'data_set' input of the acquirer form, the data must be called 'create-route'
+ this._rpc({
+ route: ds.dataset.createRoute,
+ params: 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 {
+ if (data.error) {
+ self.displayError(
+ '',
+ data.error);
+ } else { // if the server doesn't provide an error message
+ self.displayError(
+ _t('Server Error'),
+ _t('e.g. Your credit card details are wrong. Please verify.'));
+ }
+ }
+ // here we remove the 'processing' icon from the 'add a new payment' button
+ $(button).attr('disabled', false);
+ $(button).children('.fa').addClass('fa-plus-circle');
+ $(button).find('span.o_loader').remove();
+ }).guardedCatch(function (error) {
+ error.event.preventDefault();
+ // if the rpc fails, pretty obvious
+ $(button).attr('disabled', false);
+ $(button).children('.fa').addClass('fa-plus-circle');
+ $(button).find('span.o_loader').remove();
+
+ self.displayError(
+ _t('Server error'),
+ _t("We are not able to add your payment method at the moment.") +
+ self._parseError(error)
+ );
+ });
+ }
+ else {
+ this.displayError(
+ _t('No payment method selected'),
+ _t('Please select the option to add a new payment method.')
+ );
+ }
+ },
+ /**
+ * Called when submitting the form (e.g. through the Return key).
+ * We need to check whether we are paying or adding a new pm and dispatch
+ * to the correct method.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ onSubmit: function(ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ var button = $(ev.target).find('*[type="submit"]')[0]
+ if (button.id === 'o_payment_form_pay') {
+ return this.payEvent(ev);
+ } else if (button.id === 'o_payment_form_add_pm') {
+ return this.addPmEvent(ev);
+ }
+ return;
+ },
+ /**
+ * Called when clicking on a button to delete a payment method.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ deletePmEvent: function (ev) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ var self = this;
+ var pm_id = parseInt(ev.target.value);
+
+ var tokenDelete = function () {
+ self._rpc({
+ model: 'payment.token',
+ method: 'unlink',
+ args: [pm_id],
+ }).then(function (result) {
+ if (result === true) {
+ ev.target.closest('div').remove();
+ }
+ }, function () {
+ self.displayError(
+ _t('Server Error'),
+ _t("We are not able to delete your payment method at the moment.")
+ );
+ });
+ };
+
+ this._rpc({
+ 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="' + sub.description + '">' + sub.name + '</a></p>';
+ });
+
+ content = $('<div>').html('<p>' + _t('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 payment method.
+ new Dialog(self, {
+ title: _t('Warning!'),
+ size: 'medium',
+ $content: content,
+ buttons: [
+ {text: _t('Confirm Deletion'), classes: 'btn-primary', close: true, click: tokenDelete},
+ {text: _t('Cancel'), close: true}]
+ }).open();
+ }
+ else {
+ // if there's no records linked to this payment method, then we delete it
+ tokenDelete();
+ }
+ }, function (err, event) {
+ self.displayError(
+ _t('Server Error'),
+ _t("We are not able to delete your payment method at the moment.") + err.data.message
+ );
+ });
+ },
+ /**
+ * Called when clicking on 'and more' to show more payment icon.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ onClickMorePaymentIcon: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ var $listItems = $(ev.currentTarget).parents('ul').children('li');
+ var $moreItem = $(ev.currentTarget).parents('li');
+ $listItems.removeClass('d-none');
+ $moreItem.addClass('d-none');
+ },
+ /**
+ * Called when clicking on a radio button.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ radioClickEvent: function (ev) {
+ // radio button checked when we click on entire zone(body) of the payment acquirer
+ $(ev.currentTarget).find('input[type="radio"]').prop("checked", true);
+ this.updateNewPaymentDisplayStatus();
+ },
+});
+return publicWidget.registry.PaymentForm;
+});
diff --git a/addons/payment/static/src/js/payment_portal.js b/addons/payment/static/src/js/payment_portal.js
new file mode 100644
index 00000000..82f8647f
--- /dev/null
+++ b/addons/payment/static/src/js/payment_portal.js
@@ -0,0 +1,65 @@
+$(function () {
+
+ $('input#cc_number').payment('formatCardNumber');
+ $('input#cc_cvc').payment('formatCardCVC');
+ $('input#cc_expiry').payment('formatCardExpiry')
+
+ $('input#cc_number').on('focusout', function (e) {
+ var valid_value = $.payment.validateCardNumber(this.value);
+ var card_type = $.payment.cardType(this.value);
+ if (card_type) {
+ $(this).parent('.form-group').children('.card_placeholder').removeClass().addClass('card_placeholder ' + card_type);
+ $(this).parent('.form-group').children('input[name="cc_brand"]').val(card_type)
+ }
+ else {
+ $(this).parent('.form-group').children('.card_placeholder').removeClass().addClass('card_placeholder');
+ }
+ if (valid_value) {
+ $(this).parent('.form-group').addClass('o_has_success').find('.form-control, .custom-select').addClass('is-valid');
+ $(this).parent('.form-group').removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid');
+ $(this).siblings('.o_invalid_field').remove();
+ }
+ else {
+ $(this).parent('.form-group').addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
+ $(this).parent('.form-group').removeClass('o_has_success').find('.form-control, .custom-select').removeClass('is-valid');
+ }
+ });
+
+ $('input#cc_cvc').on('focusout', function (e) {
+ var cc_nbr = $(this).parents('.oe_cc').find('#cc_number').val();
+ var card_type = $.payment.cardType(cc_nbr);
+ var valid_value = $.payment.validateCardCVC(this.value, card_type);
+ if (valid_value) {
+ $(this).parent('.form-group').addClass('o_has_success').find('.form-control, .custom-select').addClass('is-valid');
+ $(this).parent('.form-group').removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid');
+ $(this).siblings('.o_invalid_field').remove();
+ }
+ else {
+ $(this).parent('.form-group').addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
+ $(this).parent('.form-group').removeClass('o_has_success').find('.form-control, .custom-select').removeClass('is-valid');
+ }
+ });
+
+ $('input#cc_expiry').on('focusout', function (e) {
+ var expiry_value = $.payment.cardExpiryVal(this.value);
+ var month = expiry_value.month || '';
+ var year = expiry_value.year || '';
+ var valid_value = $.payment.validateCardExpiry(month, year);
+ if (valid_value) {
+ $(this).parent('.form-group').addClass('o_has_success').find('.form-control, .custom-select').addClass('is-valid');
+ $(this).parent('.form-group').removeClass('o_has_error').find('.form-control, .custom-select').removeClass('is-invalid');
+ $(this).siblings('.o_invalid_field').remove();
+ }
+ else {
+ $(this).parent('.form-group').addClass('o_has_error').find('.form-control, .custom-select').addClass('is-invalid');
+ $(this).parent('.form-group').removeClass('o_has_success').find('.form-control, .custom-select').removeClass('is-valid');
+ }
+ });
+
+ $('select[name="pm_acquirer_id"]').on('change', function() {
+ var acquirer_id = $(this).val();
+ $('.acquirer').addClass('d-none');
+ $('.acquirer[data-acquirer-id="'+acquirer_id+'"]').removeClass('d-none');
+ });
+
+});
diff --git a/addons/payment/static/src/js/payment_processing.js b/addons/payment/static/src/js/payment_processing.js
new file mode 100644
index 00000000..5ed13cf1
--- /dev/null
+++ b/addons/payment/static/src/js/payment_processing.js
@@ -0,0 +1,121 @@
+odoo.define('payment.processing', function (require) {
+ 'use strict';
+
+ var publicWidget = require('web.public.widget');
+ var ajax = require('web.ajax');
+ var core = require('web.core');
+
+ var _t = core._t;
+
+ $.blockUI.defaults.css.border = '0';
+ $.blockUI.defaults.css["background-color"] = '';
+ $.blockUI.defaults.overlayCSS["opacity"] = '0.9';
+
+ publicWidget.registry.PaymentProcessing = publicWidget.Widget.extend({
+ selector: '.o_payment_processing',
+ xmlDependencies: ['/payment/static/src/xml/payment_processing.xml'],
+
+ _pollCount: 0,
+
+ start: function() {
+ this.displayLoading();
+ this.poll();
+ return this._super.apply(this, arguments);
+ },
+ /* Methods */
+ startPolling: function () {
+ var timeout = 3000;
+ //
+ if(this._pollCount >= 10 && this._pollCount < 20) {
+ timeout = 10000;
+ }
+ else if(this._pollCount >= 20) {
+ timeout = 30000;
+ }
+ //
+ setTimeout(this.poll.bind(this), timeout);
+ this._pollCount ++;
+ },
+ poll: function () {
+ var self = this;
+ ajax.jsonRpc('/payment/process/poll', 'call', {}).then(function(data) {
+ if(data.success === true) {
+ self.processPolledData(data.transactions);
+ }
+ else {
+ switch(data.error) {
+ case "tx_process_retry":
+ break;
+ case "no_tx_found":
+ self.displayContent("payment.no_tx_found", {});
+ break;
+ default: // if an exception is raised
+ self.displayContent("payment.exception", {exception_msg: data.error});
+ break;
+ }
+ }
+ self.startPolling();
+
+ }).guardedCatch(function() {
+ self.displayContent("payment.rpc_error", {});
+ self.startPolling();
+ });
+ },
+ processPolledData: function (transactions) {
+ var render_values = {
+ 'tx_draft': [],
+ 'tx_pending': [],
+ 'tx_authorized': [],
+ 'tx_done': [],
+ 'tx_cancel': [],
+ 'tx_error': [],
+ };
+
+ if (transactions.length > 0 && ['transfer', 'sepa_direct_debit'].indexOf(transactions[0].acquirer_provider) >= 0) {
+ window.location = transactions[0].return_url;
+ return;
+ }
+
+ // group the transaction according to their state
+ transactions.forEach(function (tx) {
+ var key = 'tx_' + tx.state;
+ if(key in render_values) {
+ render_values[key].push(tx);
+ }
+ });
+
+ function countTxInState(states) {
+ var nbTx = 0;
+ for (var prop in render_values) {
+ if (states.indexOf(prop) > -1 && render_values.hasOwnProperty(prop)) {
+ nbTx += render_values[prop].length;
+ }
+ }
+ return nbTx;
+ }
+ // if there's only one tx to manage
+ if(countTxInState(['tx_done', 'tx_error', 'tx_pending', 'tx_authorized']) === 1) {
+ var tx = render_values['tx_done'][0] || render_values['tx_authorized'][0] || render_values['tx_error'][0];
+ if (tx) {
+ window.location = tx.return_url;
+ return;
+ }
+ }
+
+ this.displayContent("payment.display_tx_list", render_values);
+ },
+ displayContent: function (xmlid, render_values) {
+ var html = core.qweb.render(xmlid, render_values);
+ $.unblockUI();
+ this.$el.find('.o_payment_processing_content').html(html);
+ },
+ displayLoading: function () {
+ var msg = _t("We are processing your payment, please wait ...");
+ $.blockUI({
+ 'message': '<h2 class="text-white"><img src="/web/static/src/img/spin.png" class="fa-pulse"/>' +
+ ' <br />' + msg +
+ '</h2>'
+ });
+ },
+ });
+});
diff --git a/addons/payment/static/src/scss/payment_acquirer.scss b/addons/payment/static/src/scss/payment_acquirer.scss
new file mode 100644
index 00000000..e73582ec
--- /dev/null
+++ b/addons/payment/static/src/scss/payment_acquirer.scss
@@ -0,0 +1,62 @@
+.o_kanban_view.o_kanban_payment_acquirer {
+ &.o_kanban_ungrouped {
+ .o_kanban_record {
+ width: 500px;
+
+ .o_kanban_image {
+ float: right;
+
+ + div {
+ padding-left: 0;
+ padding-right: $o-kanban-image-width + $o-kanban-inside-hgutter;
+ }
+ }
+
+ .o_payment_acquirer_desc {
+ margin-bottom: 28px;
+
+ i.fa {
+ margin-right: 5px;
+
+ &.fa-check {
+ color: green;
+ }
+ }
+ }
+
+ .o_payment_acquirer_bottom {
+ > button {
+ position: absolute;
+ bottom: 8px;
+ right: 8px;
+ }
+ > .label {
+ position: absolute;
+ bottom: 8px;
+ left: 8px;
+ }
+ }
+ }
+ }
+}
+
+.o_form_view {
+ .o_payment_acquirer_desc {
+ margin-top: 10px;
+ ul {
+ list-style-type: none;
+ padding: 0;
+
+ i.fa {
+ margin-right: 5px;
+
+ &.fa-check {
+ color: green;
+ }
+ }
+ }
+ }
+ .o_warning_text {
+ color: #f0ad4e;
+ }
+}
diff --git a/addons/payment/static/src/scss/payment_form.scss b/addons/payment/static/src/scss/payment_form.scss
new file mode 100644
index 00000000..de1e0102
--- /dev/null
+++ b/addons/payment/static/src/scss/payment_form.scss
@@ -0,0 +1,56 @@
+.o_payment_form {
+ label > input[type="radio"], input[type="checkbox"]{
+ vertical-align: middle;
+ margin-right: 5px;
+ }
+
+ .payment_option_name {
+ font-size: 14px;
+ font-weight: normal !important;
+ font-family: Helvetica Neue, sans-serif;
+ line-height: 1.3em;
+ color: #4d4d4d;
+ }
+
+ label {
+ font-weight: normal;
+ margin-top: 5px;
+ }
+
+ .card-body:first-child {
+ border-top: 0px;
+ }
+
+ .card {
+ border-radius: 10px;
+ }
+
+ .card-footer:last-child {
+ border-bottom-right-radius: 10px !important;
+ border-bottom-left-radius: 10px !important;
+ }
+
+ .card-body {
+ border-top: 1px solid #ddd;
+ padding: 1.14em !important;
+ &.o_payment_acquirer_select:hover {
+ cursor: pointer;
+ }
+ }
+
+ .payment_icon_list {
+ position: relative;
+ li {
+ padding-left: 5px !important;
+ padding-right: 0px !important;
+ }
+
+ .more_option {
+ @include o-position-absolute($right: 10px);
+ font-size:10px;
+ }
+
+ margin-top: 0px !important;
+ margin-bottom: -5px !important;
+ }
+}
diff --git a/addons/payment/static/src/scss/portal_payment.scss b/addons/payment/static/src/scss/portal_payment.scss
new file mode 100644
index 00000000..4a28f596
--- /dev/null
+++ b/addons/payment/static/src/scss/portal_payment.scss
@@ -0,0 +1,61 @@
+input#cc_number {
+ background-repeat: no-repeat;
+ background-position: center right calc(2.7em);
+}
+
+div.card_placeholder {
+ background-image: url("/website_payment/static/src/img/placeholder.png");
+ background-repeat: no-repeat;
+ width: 32px;
+ height: 20px;
+ position: absolute;
+ 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);
+ pointer-events: none;
+}
+
+/* if s2s form not in bootstrap_formatting */
+div.o_card_brand_detail {
+ position: relative;
+
+ div.card_placeholder {
+ right: 5px;
+ }
+}
+
+div.amex {
+ background-image: url("/website_payment/static/src/img/amex.png");
+ background-repeat: no-repeat;
+}
+
+div.diners {
+ background-image: url("/website_payment/static/src/img/diners.png");
+ background-repeat: no-repeat;
+}
+
+div.discover {
+ background-image: url("/website_payment/static/src/img/discover.png");
+ background-repeat: no-repeat;
+}
+
+div.jcb {
+ background-image: url("/website_payment/static/src/img/jcb.png");
+ background-repeat: no-repeat;
+}
+
+div.mastercard {
+ background-image: url("/website_payment/static/src/img/mastercard.png");
+ background-repeat: no-repeat;
+}
+
+div.visa {
+ background-image: url("/website_payment/static/src/img/visa.png");
+ background-repeat: no-repeat;
+}
+
+ul.payment_method_list img.rounded {
+ max-width: 100px;
+ max-height: 40px;
+}
diff --git a/addons/payment/static/src/xml/payment_processing.xml b/addons/payment/static/src/xml/payment_processing.xml
new file mode 100644
index 00000000..86623047
--- /dev/null
+++ b/addons/payment/static/src/xml/payment_processing.xml
@@ -0,0 +1,150 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<templates id="payment" xml:space="preserve">
+ <!-- The templates here as rendered by 'payment_processing.js', you can also take
+ a look at payment_templates.xml (xmlid: payment_process_page) for more infos-->
+ <t t-name="payment.display_tx_list">
+ <div>
+ <!-- Error transactions -->
+ <div t-if="tx_error.length > 0">
+ <h1>Payments failed</h1>
+ <ul class="list-group">
+ <t t-foreach="tx_error" t-as="tx">
+ <li class="list-group-item">
+ <h4 class="list-group-item-heading mb5">
+ <t t-esc="tx['reference']"/>
+ <span class="badge pull-right"><t t-esc="tx['amount']"/> <t t-esc="tx['currency']"/></span>
+ </h4>
+ <small class="list-group-item-text">
+ An error occured during the processing of this payment.<br/>
+ <strong>Reason:</strong> <t t-esc="tx['state_message']"/>
+ </small>
+ </li>
+ </t>
+ </ul>
+ </div>
+ <div t-if="tx_done.length > 0 || tx_authorized.length > 0 || tx_pending.length > 0">
+ <h1>Payments received</h1>
+ <div class="list-group">
+ <!-- Done transactions -->
+ <t t-foreach="tx_done" t-as="tx">
+ <a t-att-href="tx['return_url']" class="list-group-item">
+ <h4 class="list-group-item-heading mb5">
+ <t t-esc="tx['reference']"/>
+ <span class="badge pull-right"><t t-esc="tx['amount']"/> <t t-esc="tx['currency']"/></span>
+ </h4>
+ <small class="list-group-item-text">
+ <t t-if="!tx['is_processed']">
+ Your order is being processed, please wait ... <i class="fa fa-cog fa-spin"/>
+ </t>
+ <t t-else="">
+ Your order has been processed.<br/>
+ Click here to be redirected to the confirmation page.
+ </t>
+ </small>
+ </a>
+ </t>
+ <!-- Pending transactions -->
+ <t t-foreach="tx_pending" t-as="tx">
+ <a t-att-href="tx['return_url']" class="list-group-item">
+ <h4 class="list-group-item-heading mb5">
+ <t t-esc="tx['reference']"/>
+ <span class="badge pull-right"><t t-esc="tx['amount']"/> <t t-esc="tx['currency']"/></span>
+ </h4>
+ <small class="list-group-item-text">
+ <t t-if="tx['message_to_display']">
+ <t t-raw="tx['message_to_display']"/>
+ </t>
+ <t t-else="">
+ Your payment is in pending state.<br/>
+ You will be notified when the payment is fully confirmed.<br/>
+ You can click here to be redirected to the confirmation page.
+ </t>
+ </small>
+ </a>
+ </t>
+ <!-- Authorized transactions -->
+ <t t-foreach="tx_authorized" t-as="tx">
+ <li class="list-group-item">
+ <h4 class="list-group-item-heading mb5">
+ <t t-esc="tx['reference']"/>
+ <span class="badge pull-right"><t t-esc="tx['amount']"/> <t t-esc="tx['currency']"/></span>
+ </h4>
+ <small class="list-group-item-text">
+ <t t-if="tx['message_to_display']">
+ <t t-raw="tx['message_to_display']"/>
+ </t>
+ <t t-else="">
+ Your payment has been received but need to be confirmed manually.<br/>
+ You will be notified when the payment is confirmed.
+ </t>
+ </small>
+ </li>
+ </t>
+ </div>
+ </div>
+ <!-- Draft transactions -->
+ <div t-if="tx_draft.length > 0">
+ <h1>Waiting for payment</h1>
+ <ul class="list-group">
+ <t t-foreach="tx_draft" t-as="tx">
+ <li class="list-group-item">
+ <h4 class="list-group-item-heading mb5">
+ <t t-esc="tx['reference']"/>
+ <span class="badge pull-right"><t t-esc="tx['amount']"/> <t t-esc="tx['currency']"/></span>
+ </h4>
+ <small class="list-group-item-text">
+ <t t-if="tx['message_to_display']">
+ <t t-raw="tx['message_to_display']"/>
+ </t>
+ <t t-else="">
+ We are waiting for the payment acquirer to confirm the payment.
+ </t>
+ </small>
+ </li>
+ </t>
+ </ul>
+ </div>
+ <!-- Cancel transactions -->
+ <div t-if="tx_cancel.length > 0">
+ <h1>Cancelled payments</h1>
+ <ul class="list-group">
+ <t t-foreach="tx_cancel" t-as="tx">
+ <li class="list-group-item">
+ <h4 class="list-group-item-heading mb5">
+ <t t-esc="tx['reference']"/>
+ <span class="badge pull-right"><t t-esc="tx['amount']"/> <t t-esc="tx['currency']"/></span>
+ </h4>
+ <small class="list-group-item-text">
+ This transaction has been cancelled.<br/>
+ No payment has been processed.
+ </small>
+ </li>
+ </t>
+ </ul>
+ </div>
+ </div>
+ </t>
+
+ <t t-name="payment.no_tx_found">
+ <div class="text-center">
+ <p>We are not able to find your payment, but don't worry.</p>
+ <p>You should receive an email confirming your payment in a few minutes.</p>
+ <p>If the payment hasn't been confirmed you can contact us.</p>
+ </div>
+ </t>
+
+ <t t-name="payment.rpc_error">
+ <div class="text-center">
+ <p><strong>Server error:</strong> Unable to contact the Odoo server.</p>
+ <p>Please wait ... <i class="fa fa-refresh fa-spin"></i></p>
+ </div>
+ </t>
+
+ <t t-name="payment.exception">
+ <div class="text-center">
+ <h2>Internal server error</h2>
+ <pre><t t-esc="exception_msg"/></pre>
+ </div>
+ </t>
+
+</templates>