diff --git a/pos_elavon/__init__.py b/pos_elavon/__init__.py
new file mode 100644
index 00000000..0650744f
--- /dev/null
+++ b/pos_elavon/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/pos_elavon/__manifest__.py b/pos_elavon/__manifest__.py
new file mode 100644
index 00000000..4ccb76ba
--- /dev/null
+++ b/pos_elavon/__manifest__.py
@@ -0,0 +1,41 @@
+{
+ 'name': 'Elavon Payment Services',
+ 'version': '11.0.1.0.0',
+ 'category': 'Point of Sale',
+ 'sequence': 6,
+ 'summary': 'Credit card support for Point Of Sale',
+ 'description': """
+Allow credit card POS payments
+==============================
+
+This module allows customers to pay for their orders with credit cards.
+The transactions are processed by Elavon.
+An Elavon merchant account is necessary. It allows the following:
+
+* Fast payment by just swiping a credit card while on the payment screen
+* Combining of cash payments and credit card payments
+* Cashback
+* Supported cards: Visa, MasterCard, American Express, Discover
+ """,
+ 'depends': [
+ 'web',
+ 'barcodes',
+ 'pos_sale',
+ ],
+ 'website': 'https://hibou.io',
+ 'data': [
+ 'data/pos_elavon_data.xml',
+ 'security/ir.model.access.csv',
+ 'views/pos_elavon_templates.xml',
+ 'views/pos_elavon_views.xml',
+ 'views/pos_config_setting_views.xml',
+ ],
+ 'demo': [
+ 'data/pos_elavon_demo.xml',
+ ],
+ 'qweb': [
+ 'static/src/xml/pos_elavon.xml',
+ ],
+ 'installable': True,
+ 'auto_install': False,
+}
diff --git a/pos_elavon/data/pos_elavon_data.xml b/pos_elavon/data/pos_elavon_data.xml
new file mode 100644
index 00000000..e7e2355d
--- /dev/null
+++ b/pos_elavon/data/pos_elavon_data.xml
@@ -0,0 +1,13 @@
+
+
+
+
+ Magnetic Credit Card
+
+ 85
+ credit
+ any
+ %.*
+
+
+
diff --git a/pos_elavon/data/pos_elavon_demo.xml b/pos_elavon/data/pos_elavon_demo.xml
new file mode 100644
index 00000000..eeca5b10
--- /dev/null
+++ b/pos_elavon/data/pos_elavon_demo.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ Elavon Demo
+ 123
+ POS
+
+
+
diff --git a/pos_elavon/models/__init__.py b/pos_elavon/models/__init__.py
new file mode 100644
index 00000000..2c26d257
--- /dev/null
+++ b/pos_elavon/models/__init__.py
@@ -0,0 +1,2 @@
+from . import pos_elavon
+from . import pos_elavon_transaction
diff --git a/pos_elavon/models/pos_elavon.py b/pos_elavon/models/pos_elavon.py
new file mode 100644
index 00000000..7a3fa6ba
--- /dev/null
+++ b/pos_elavon/models/pos_elavon.py
@@ -0,0 +1,82 @@
+from odoo import models, fields, api, _
+from odoo.tools.float_utils import float_compare
+
+
+class BarcodeRule(models.Model):
+ _inherit = 'barcode.rule'
+
+ type = fields.Selection(selection_add=[
+ ('credit', 'Credit Card')
+ ])
+
+
+class CRMTeam(models.Model):
+ _inherit = 'crm.team'
+
+ pos_elavon_merchant_pin = fields.Char(string='POS Elavon Merchant PIN')
+
+
+class PosElavonConfiguration(models.Model):
+ _name = 'pos_elavon.configuration'
+
+ name = fields.Char(required=True, help='Name of this Elavon configuration')
+ merchant_id = fields.Char(string='Merchant ID', required=True, help='ID of the merchant to authenticate him on the payment provider server')
+ merchant_user_id = fields.Char(string='Merchant User ID', required=True, help='User ID, e.g. POS')
+
+
+class AccountBankStatementLine(models.Model):
+ _inherit = "account.bank.statement.line"
+
+ elavon_card_number = fields.Char(string='Card Number', help='Masked credit card.')
+ elavon_txn_id = fields.Char(string='Elavon Transaction ID')
+
+
+class AccountJournal(models.Model):
+ _inherit = 'account.journal'
+
+ pos_elavon_config_id = fields.Many2one('pos_elavon.configuration', string='Elavon Credentials',
+ help='The configuration of Elavon that can be used with this journal.')
+
+
+class PosOrder(models.Model):
+ _inherit = "pos.order"
+
+ @api.model
+ def _payment_fields(self, ui_paymentline):
+ fields = super(PosOrder, self)._payment_fields(ui_paymentline)
+
+ fields.update({
+ 'elavon_card_number': ui_paymentline.get('elavon_card_number'),
+ 'elavon_txn_id': ui_paymentline.get('elavon_txn_id'),
+ })
+
+ return fields
+
+ def add_payment(self, data):
+ statement_id = super(PosOrder, self).add_payment(data)
+ statement_lines = self.env['account.bank.statement.line'].search([('statement_id', '=', statement_id),
+ ('pos_statement_id', '=', self.id),
+ ('journal_id', '=', data['journal'])])
+ statement_lines = statement_lines.filtered(lambda line: float_compare(line.amount, data['amount'],
+ precision_rounding=line.journal_currency_id.rounding) == 0)
+
+ # we can get multiple statement_lines when there are >1 credit
+ # card payments with the same amount. In that case it doesn't
+ # matter which statement line we pick, just pick one that
+ # isn't already used.
+ for line in statement_lines:
+ if not line.elavon_card_number:
+ line.elavon_card_number = data.get('elavon_card_number')
+ line.elavon_txn_id = data.get('elavon_txn_id')
+ break
+
+ return statement_id
+
+
+class AutoVacuum(models.AbstractModel):
+ _inherit = 'ir.autovacuum'
+
+ @api.model
+ def power_on(self, *args, **kwargs):
+ self.env['pos_elavon.elavon_transaction'].cleanup_old_tokens()
+ return super(AutoVacuum, self).power_on(*args, **kwargs)
diff --git a/pos_elavon/models/pos_elavon_transaction.py b/pos_elavon/models/pos_elavon_transaction.py
new file mode 100644
index 00000000..7f3d81f0
--- /dev/null
+++ b/pos_elavon/models/pos_elavon_transaction.py
@@ -0,0 +1,128 @@
+from datetime import date, timedelta
+
+import requests
+import werkzeug
+
+from odoo import models, api, service
+from odoo.tools.translate import _
+from odoo.exceptions import UserError
+from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT, misc
+
+
+class ElavonTransaction(models.Model):
+ _name = 'pos_elavon.elavon_transaction'
+
+ def _get_pos_session(self):
+ pos_session = self.env['pos.session'].search([('state', '=', 'opened'), ('user_id', '=', self.env.uid)], limit=1)
+ if not pos_session:
+ raise UserError(_("No opened point of sale session for user %s found") % self.env.user.name)
+
+ pos_session.login()
+
+ return pos_session
+
+ def _get_pos_elavon_config_id(self, config, journal_id):
+ journal = config.journal_ids.filtered(lambda r: r.id == journal_id)
+
+ if journal and journal.pos_elavon_config_id:
+ return journal.pos_elavon_config_id
+ else:
+ raise UserError(_("No Elavon configuration associated with the journal."))
+
+ def _setup_request(self, data):
+ # todo: in master make the client include the pos.session id and use that
+ pos_session = self._get_pos_session()
+
+ config = pos_session.config_id
+
+ pos_elavon_config = self._get_pos_elavon_config_id(config, data['journal_id'])
+
+ data['ssl_merchant_id'] = pos_elavon_config.sudo().merchant_id
+ data['ssl_user_id'] = pos_elavon_config.sudo().merchant_user_id
+ # Load from team
+ data['ssl_pin'] = config.sudo().crm_team_id.pos_elavon_merchant_pin
+ data['ssl_show_form'] = 'false'
+ data['ssl_result_format'] = 'ascii'
+
+
+ def _do_request(self, data):
+ if not data['ssl_merchant_id'] or not data['ssl_user_id'] or not data['ssl_pin']:
+ return "not setup"
+ response = ''
+
+ url = 'https://api.convergepay.com/VirtualMerchant/process.do'
+ if self.env['ir.config_parameter'].sudo().get_param('pos_elavon.enable_test_env'):
+ url = 'https://api.demo.convergepay.com/VirtualMerchantDemo/process.do'
+
+ try:
+ r = requests.post(url, data=data, timeout=500)
+ r.raise_for_status()
+ response = werkzeug.utils.unescape(r.content.decode())
+ except Exception:
+ response = "timeout"
+
+ return response
+
+ def _do_reversal_or_voidsale(self, data, is_voidsale):
+ try:
+ self._setup_request(data)
+ except UserError:
+ return "internal error"
+
+ # Can we voidsale?
+ #data['is_voidsale'] = is_voidsale
+ data['ssl_transaction_type'] = 'CCVOID'
+ response = self._do_request(data)
+ return response
+
+ @api.model
+ def do_payment(self, data):
+ try:
+ self._setup_request(data)
+ except UserError:
+ return "internal error"
+
+ data['ssl_transaction_type'] = 'CCSALE'
+ response = self._do_request(data)
+ return response
+
+ @api.model
+ def do_reversal(self, data):
+ return self._do_reversal_or_voidsale(data, False)
+
+ @api.model
+ def do_voidsale(self, data):
+ return self._do_reversal_or_voidsale(data, True)
+
+ @api.model
+ def do_return(self, data):
+ try:
+ self._setup_request(data)
+ except UserError:
+ return "internal error"
+
+ data['ssl_transaction_type'] = 'CCRETURN'
+ response = self._do_request(data)
+ return response
+
+ @api.model
+ def do_credit(self, data):
+ try:
+ self._setup_request(data)
+ except UserError:
+ return "internal error"
+
+ data['ssl_transaction_type'] = 'CCCREDIT'
+ response = self._do_request(data)
+ return response
+
+ # One time (the ones we use) Elavon tokens are required to be
+ # deleted after 6 months
+ # This is a from Mercury, probably not needed anymore.
+ @api.model
+ def cleanup_old_tokens(self):
+ expired_creation_date = (date.today() - timedelta(days=6 * 30)).strftime(DEFAULT_SERVER_DATETIME_FORMAT)
+
+ for order in self.env['pos.order'].search([('create_date', '<', expired_creation_date)]):
+ order.ref_no = ""
+ order.record_no = ""
diff --git a/pos_elavon/security/ir.model.access.csv b/pos_elavon/security/ir.model.access.csv
new file mode 100644
index 00000000..091e3f82
--- /dev/null
+++ b/pos_elavon/security/ir.model.access.csv
@@ -0,0 +1,3 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_pos_elavon_configuration,elavon.configuration,model_pos_elavon_configuration,point_of_sale.group_pos_manager,1,1,1,1
+access_pos_elavon_elavon_transaction,elavon.transaction,model_pos_elavon_elavon_transaction,,1,0,0,0
\ No newline at end of file
diff --git a/pos_elavon/static/src/css/pos_elavon.css b/pos_elavon/static/src/css/pos_elavon.css
new file mode 100644
index 00000000..96e0ffa8
--- /dev/null
+++ b/pos_elavon/static/src/css/pos_elavon.css
@@ -0,0 +1,25 @@
+.pos .paymentline.selected.o_pos_elavon_swipe_pending, .pos .paymentline.o_pos_elavon_swipe_pending {
+ background: rgb(239, 153, 65);
+}
+.pos .col-tendered.edit.o_pos_elavon_swipe_pending {
+ color: rgb(239, 153, 65);
+ box-shadow: 0px 0px 0px 3px rgb(239, 153, 65);
+}
+
+.pos .paymentline .elavon_manual_transaction {
+ cursor: pointer;
+ border: 1px solid rgba(255, 255, 255, 0.5);
+ background-color: rgba(255,255,255, 0.2);
+ display: block;
+ text-align: center;
+ margin: 6px 0 0 0;
+ padding: 2px 5px;
+}
+
+.pos .payment-manual-transaction label {
+ margin-top: 6px;
+ display: block;
+}
+ .pos .payment-manual-transaction label span {
+ font-size: 75%;
+ }
\ No newline at end of file
diff --git a/pos_elavon/static/src/js/pos_elavon.js b/pos_elavon/static/src/js/pos_elavon.js
new file mode 100644
index 00000000..7255ead7
--- /dev/null
+++ b/pos_elavon/static/src/js/pos_elavon.js
@@ -0,0 +1,693 @@
+odoo.define('pos_elavon.pos_elavon', function (require) {
+"use strict";
+
+var core = require('web.core');
+var rpc = require('web.rpc');
+var screens = require('point_of_sale.screens');
+var gui = require('point_of_sale.gui');
+var pos_model = require('point_of_sale.models');
+
+var _t = core._t;
+
+var BarcodeEvents = require('barcodes.BarcodeEvents').BarcodeEvents;
+var PopupWidget = require('point_of_sale.popups');
+var ScreenWidget = screens.ScreenWidget;
+var PaymentScreenWidget = screens.PaymentScreenWidget;
+
+pos_model.load_fields("account.journal", "pos_elavon_config_id");
+
+pos_model.PosModel = pos_model.PosModel.extend({
+
+ getOnlinePaymentJournals: function () {
+ var self = this;
+ var online_payment_journals = [];
+
+ $.each(this.journals, function (i, val) {
+ if (val.pos_elavon_config_id) {
+ online_payment_journals.push({label:self.getCashRegisterByJournalID(val.id).journal_id[1], item:val.id});
+ }
+ });
+ return online_payment_journals;
+ },
+
+ getCashRegisterByJournalID: function (journal_id) {
+ var cashregister_return;
+
+ $.each(this.cashregisters, function (index, cashregister) {
+ if (cashregister.journal_id[0] === journal_id) {
+ cashregister_return = cashregister;
+ }
+ });
+
+ return cashregister_return;
+ },
+
+ decodeMagtek: function (s) {
+ if (s.indexOf('%B') < 0) {
+ return 0;
+ }
+ var to_return = {};
+ to_return['ssl_card_number'] = s.substr(s.indexOf('%B') + 2, s.indexOf('^') - 2);
+ var temp = s.substr(s.indexOf('^') + 1, s.length);
+ name = temp.substr(0, temp.indexOf('/'));
+ var exp = temp.substr(temp.indexOf('^') + 1, 4);
+ to_return['ssl_exp_date'] = exp.substr(2, 2) + exp.substr(0, 2)
+ to_return['ssl_card_present'] = 'Y'
+ to_return['ssl_track_data'] = s
+ return to_return
+ },
+
+ decodeElavonResponse: function (data) {
+ var lines = data.split(/\r?\n/);
+ var to_return = {}
+
+ lines.forEach(function (line){
+ var eq = line.indexOf('=');
+ if (eq > 0) {
+ var name = line.substr(0, eq);
+ var value = line.substr(eq+1);
+ to_return[name] = value;
+ }
+ });
+ return to_return;
+ }
+});
+
+var _paylineproto = pos_model.Paymentline.prototype;
+pos_model.Paymentline = pos_model.Paymentline.extend({
+ init_from_JSON: function (json) {
+ _paylineproto.init_from_JSON.apply(this, arguments);
+
+ this.paid = true;
+ this.elavon_swipe_pending = json.elavon_swipe_pending;
+ this.elavon_card_number = json.elavon_card_number;
+ this.elavon_card_brand = json.elavon_card_brand;
+ this.elavon_txn_id = json.elavon_txn_id;
+
+ this.set_credit_card_name();
+ },
+ export_as_JSON: function () {
+ return _.extend(_paylineproto.export_as_JSON.apply(this, arguments), {
+ paid: this.paid,
+ elavon_swipe_pending: this.elavon_swipe_pending,
+ elavon_card_number: this.elavon_card_number,
+ elavon_card_brand: this.elavon_card_brand,
+ elavon_txn_id: this.elavon_txn_id,
+ });
+ },
+ set_credit_card_name: function () {
+ if (this.elavon_card_number) {
+ this.name = this.elavon_card_brand + ' ' + this.elavon_card_number;
+ }
+ }
+});
+
+// Popup to show all transaction state for the payment.
+var PaymentTransactionPopupWidget = PopupWidget.extend({
+ template: 'PaymentTransactionPopupWidget',
+ show: function (options) {
+ var self = this;
+ this._super(options);
+ options.transaction.then(function (data) {
+ if (data.auto_close) {
+ setTimeout(function () {
+ self.gui.close_popup();
+ }, 2000);
+ } else {
+ self.close();
+ self.$el.find('.popup').append('
');
+ }
+
+ self.$el.find('p.body').html(data.message);
+ }).progress(function (data) {
+ self.$el.find('p.body').html(data.message);
+ });
+ }
+});
+gui.define_popup({name:'payment-transaction', widget: PaymentTransactionPopupWidget});
+
+// Popup to record manual CC entry
+var PaymentManualTransactionPopupWidget = PopupWidget.extend({
+ template: 'PaymentManualTransactionPopupWidget',
+ show: function (options) {
+ this._super(options);
+ this.setup_transaction_callback();
+ },
+ setup_transaction_callback: function(){
+ var self = this;
+ this.options.transaction.then(function (data) {
+ if (data.auto_close) {
+ setTimeout(function () {
+ self.gui.close_popup();
+ }, 2000);
+ }
+ self.$el.find('p.body').html(data.message);
+ }).progress(function (data) {
+ self.$el.find('p.body').html(data.message);
+ });
+ },
+ click_confirm: function(){
+ var values = {
+ ssl_card_number: this.$('input[name="card_number"]').val(),
+ ssl_exp_date: this.$('input[name="exp_date"]').val(),
+ ssl_cvv2cvc2: this.$('input[name="cvv2cvc2"]').val(),
+ };
+ if ( this.options.transaction.state() != 'pending' ) {
+ this.options.transaction = $.Deferred();
+ this.setup_transaction_callback();
+ }
+ if( this.options.confirm ){
+ this.options.confirm.call(this, values, this.options.transaction);
+ }
+ },
+});
+gui.define_popup({name:'payment-manual-transaction', widget: PaymentManualTransactionPopupWidget});
+
+// On all screens, if a card is swipped, return a popup error.
+ScreenWidget.include({
+ credit_error_action: function () {
+ this.gui.show_popup('error-barcode',_t('Go to payment screen to use cards'));
+ },
+
+ show: function () {
+ this._super();
+ if(this.pos.getOnlinePaymentJournals().length !== 0) {
+ this.pos.barcode_reader.set_action_callback('credit', _.bind(this.credit_error_action, this));
+ }
+ }
+});
+
+// On Payment screen, allow electronic payments
+PaymentScreenWidget.include({
+ // Override init because it eats all keyboard input and we need it for popups...
+ init: function(parent, options) {
+ var self = this;
+ this._super(parent, options);
+
+ // This is a keydown handler that prevents backspace from
+ // doing a back navigation. It also makes sure that keys that
+ // do not generate a keypress in Chrom{e,ium} (eg. delete,
+ // backspace, ...) get passed to the keypress handler.
+ this.keyboard_keydown_handler = function(event){
+ if (event.keyCode === 8 || event.keyCode === 46) { // Backspace and Delete
+ // don't prevent delete if a popup is up
+ if (self.gui.has_popup()) {
+ return;
+ }
+ event.preventDefault();
+
+ // These do not generate keypress events in
+ // Chrom{e,ium}. Even if they did, we just called
+ // preventDefault which will cancel any keypress that
+ // would normally follow. So we call keyboard_handler
+ // explicitly with this keydown event.
+ self.keyboard_handler(event);
+ }
+ };
+
+ // This keyboard handler listens for keypress events. It is
+ // also called explicitly to handle some keydown events that
+ // do not generate keypress events.
+ this.keyboard_handler = function(event){
+ // On mobile Chrome BarcodeEvents relies on an invisible
+ // input being filled by a barcode device. Let events go
+ // through when this input is focused.
+ if (self.gui.has_popup()) {
+ return;
+ }
+ if (BarcodeEvents.$barcodeInput && BarcodeEvents.$barcodeInput.is(":focus")) {
+ return;
+ }
+
+ var key = '';
+
+ if (event.type === "keypress") {
+ if (event.keyCode === 13) { // Enter
+ self.validate_order();
+ } else if ( event.keyCode === 190 || // Dot
+ event.keyCode === 110 || // Decimal point (numpad)
+ event.keyCode === 188 || // Comma
+ event.keyCode === 46 ) { // Numpad dot
+ key = self.decimal_point;
+ } else if (event.keyCode >= 48 && event.keyCode <= 57) { // Numbers
+ key = '' + (event.keyCode - 48);
+ } else if (event.keyCode === 45) { // Minus
+ key = '-';
+ } else if (event.keyCode === 43) { // Plus
+ key = '+';
+ }
+ } else { // keyup/keydown
+ if (event.keyCode === 46) { // Delete
+ key = 'CLEAR';
+ } else if (event.keyCode === 8) { // Backspace
+ key = 'BACKSPACE';
+ }
+ }
+
+ self.payment_input(key);
+ event.preventDefault();
+ };
+ },
+ // end init override...
+
+ // How long we wait for the odoo server to deliver the response of
+ // a Elavon transaction
+ server_timeout_in_ms: 120000,
+
+ // How many Elavon transactions we send without receiving a
+ // response
+ server_retries: 3,
+
+ _get_swipe_pending_line: function () {
+ var i = 0;
+ var lines = this.pos.get_order().get_paymentlines();
+
+ for (i = 0; i < lines.length; i++) {
+ if (lines[i].elavon_swipe_pending) {
+ return lines[i];
+ }
+ }
+
+ return 0;
+ },
+
+ // Hunt around for an existing line by amount etc.
+ _does_credit_payment_line_exist: function (amount, card_number, card_brand, card_owner_name) {
+ var i = 0;
+ var lines = this.pos.get_order().get_paymentlines();
+
+ for (i = 0; i < lines.length; i++) {
+ if (lines[i].get_amount() === amount &&
+ lines[i].elavon_card_number === card_number &&
+ lines[i].elavon_card_brand === card_brand) {
+ return true;
+ }
+ }
+
+ return false;
+ },
+
+ retry_elavon_transaction: function (def, response, retry_nr, can_connect_to_server, callback, args) {
+ var self = this;
+ var message = "";
+
+ if (retry_nr < self.server_retries) {
+ if (response) {
+ message = "Retry #" + (retry_nr + 1) + "...
" + response.message;
+ } else {
+ message = "Retry #" + (retry_nr + 1) + "...";
+ }
+ def.notify({
+ message: message
+ });
+
+ setTimeout(function () {
+ callback.apply(self, args);
+ }, 1000);
+ } else {
+ if (response) {
+ // what?
+ //message = "Error " + response.error + ": " + lookUpCodeTransaction["TimeoutError"][response.error] + "
" + response.message;
+ } else {
+ if (can_connect_to_server) {
+ message = _t("No response from Elavon (Elavon down?)");
+ } else {
+ message = _t("No response from server (connected to network?)");
+ }
+ }
+ def.resolve({
+ message: message,
+ auto_close: false
+ });
+ }
+ },
+
+ // Handler to manage the card reader string
+ credit_code_transaction: function (parsed_result, old_deferred, retry_nr) {
+ var order = this.pos.get_order();
+
+ if(this.pos.getOnlinePaymentJournals().length === 0) {
+ return;
+ }
+
+ var self = this;
+ var transaction = {};
+ var swipe_pending_line = self._get_swipe_pending_line();
+ var purchase_amount = 0;
+
+ if (swipe_pending_line) {
+ purchase_amount = swipe_pending_line.get_amount();
+ } else {
+ purchase_amount = self.pos.get_order().get_due();
+ }
+
+ // handle manual or swiped
+ if (parsed_result.ssl_card_number) {
+ transaction = parsed_result;
+ } else {
+ var decodedMagtek = self.pos.decodeMagtek(parsed_result.code);
+ if (!decodedMagtek) {
+ this.gui.show_popup('error', {
+ 'title': _t('Could not read card'),
+ 'body': _t('This can be caused by a badly executed swipe or by not having your keyboard layout set to US QWERTY (not US International).'),
+ });
+ return;
+ }
+ transaction = decodedMagtek;
+ }
+
+ var endpoint = 'do_payment';
+ if (purchase_amount < 0.0) {
+ purchase_amount = -purchase_amount;
+ endpoint = 'do_credit';
+ }
+
+ transaction['ssl_amount'] = purchase_amount.toFixed(2); // This is the format and type that Elavon is expecting
+ transaction['ssl_invoice_number'] = self.pos.get_order().uid;
+ transaction['journal_id'] = parsed_result.journal_id;
+
+ var def = old_deferred || new $.Deferred();
+ retry_nr = retry_nr || 0;
+
+ // show the transaction popup.
+ // the transaction deferred is used to update transaction status
+ // if we have a previous deferred it indicates that this is a retry
+ if (! old_deferred) {
+ self.gui.show_popup('payment-transaction', {
+ transaction: def
+ });
+ def.notify({
+ message: _t('Handling transaction...'),
+ });
+ }
+
+ rpc.query({
+ model: 'pos_elavon.elavon_transaction',
+ method: endpoint,
+ args: [transaction],
+ }, {
+ timeout: self.server_timeout_in_ms,
+ })
+ .then(function (data) {
+ // if not receiving a response from Elavon, we should retry
+ if (data === "timeout") {
+ self.retry_elavon_transaction(def, null, retry_nr, true, self.credit_code_transaction, [parsed_result, def, retry_nr + 1]);
+ return;
+ }
+
+ if (data === "not setup") {
+ def.resolve({
+ message: _t("Please setup your Elavon merchant account.")
+ });
+ return;
+ }
+
+ if (data === "internal error") {
+ def.resolve({
+ message: _t("Odoo error while processing transaction.")
+ });
+ return;
+ }
+
+ var result = self.pos.decodeElavonResponse(data);
+ result.journal_id = parsed_result.journal_id;
+
+ // Example error data:
+ // errorCode=4025
+ // errorName=Invalid Credentials
+ // errorMessage=The credentials supplied in the authorization request are invalid.
+ var approval_code = result.ssl_approval_code;
+ if (endpoint == 'do_payment' && (!approval_code || !approval_code.trim())) {
+ def.resolve({
+ message: "Error (" + (result.ssl_result_message || result.errorName) + ")",
+ auto_close: false,
+ });
+ }
+
+ // handle ssl_approval_code (only seen empty ones so far)
+ if (false /* duplicate transaction detected */) {
+ def.resolve({
+ message: result.ssl_result_message,
+ auto_close: true,
+ });
+ }
+
+ // any other edge cases or failures?
+
+ if (result.ssl_result_message == 'APPROVAL' || result.ssl_result_message == 'PARTIAL APPROVAL') {
+ var order = self.pos.get_order();
+ if (swipe_pending_line) {
+ order.select_paymentline(swipe_pending_line);
+ } else {
+ order.add_paymentline(self.pos.getCashRegisterByJournalID(parsed_result.journal_id));
+ }
+ var amount = parseFloat(result.ssl_amount);
+ if (endpoint == 'do_credit') {
+ amount = -amount;
+ }
+ order.selected_paymentline.set_amount(amount);
+ order.selected_paymentline.paid = true;
+ order.selected_paymentline.elavon_swipe_pending = false;
+ order.selected_paymentline.elavon_card_number = result.ssl_card_number;
+ order.selected_paymentline.elavon_card_brand = result.ssl_card_short_description;
+ if (result.ssl_card_short_description) {
+ order.selected_paymentline.elavon_card_brand = result.ssl_card_short_description;
+ } else {
+ order.selected_paymentline.elavon_card_brand = result.ssl_card_type;
+ }
+ order.selected_paymentline.elavon_txn_id = result.ssl_txn_id;
+ // maybe approval code....
+ order.selected_paymentline.set_credit_card_name();
+
+ self.order_changes();
+ self.reset_input();
+ self.render_paymentlines();
+ order.trigger('change', order);
+ def.resolve({
+ message: result.ssl_result_message + ' : ' + order.selected_paymentline.elavon_txn_id,
+ auto_close: true,
+ });
+ }
+
+ }).fail(function (type, error) {
+ self.retry_elavon_transaction(def, null, retry_nr, false, self.credit_code_transaction, [parsed_result, def, retry_nr + 1]);
+ });
+ },
+
+ credit_code_cancel: function () {
+ return;
+ },
+
+ credit_code_action: function (parsed_result) {
+ var self = this;
+ var online_payment_journals = this.pos.getOnlinePaymentJournals();
+
+ if (online_payment_journals.length === 1) {
+ parsed_result.journal_id = online_payment_journals[0].item;
+ self.credit_code_transaction(parsed_result);
+ } else { // this is for supporting another payment system like elavon
+ this.gui.show_popup('selection',{
+ title: 'Pay ' + this.pos.get_order().get_due().toFixed(2) + ' with : ',
+ list: online_payment_journals,
+ confirm: function (item) {
+ parsed_result.journal_id = item;
+ self.credit_code_transaction(parsed_result);
+ },
+ cancel: self.credit_code_cancel,
+ });
+ }
+ },
+
+ remove_paymentline_by_ref: function (line) {
+ this.pos.get_order().remove_paymentline(line);
+ this.reset_input();
+ this.render_paymentlines();
+ },
+
+ do_reversal: function (line, is_voidsale, old_deferred, retry_nr) {
+ var def = old_deferred || new $.Deferred();
+ var self = this;
+ retry_nr = retry_nr || 0;
+
+ // show the transaction popup.
+ // the transaction deferred is used to update transaction status
+ this.gui.show_popup('payment-transaction', {
+ transaction: def
+ });
+
+ // TODO Maybe do this, as it might be convenient to store the data in json and then do updates to it
+ // var request_data = _.extend({
+ // 'transaction_type': 'Credit',
+ // 'transaction_code': 'VoidSaleByRecordNo',
+ // }, line.elavon_data);
+
+
+ // TODO Do we need these options?
+ // var message = "";
+ // var rpc_method = "";
+ //
+ // if (is_voidsale) {
+ // message = _t("Reversal failed, sending VoidSale...");
+ // rpc_method = "do_voidsale";
+ // } else {
+ // message = _t("Sending reversal...");
+ // rpc_method = "do_reversal";
+ // }
+
+ var request_data = {
+ 'ssl_txn_id': line.elavon_txn_id,
+ 'journal_id': line.cashregister.journal_id[0],
+ };
+
+ if (! old_deferred) {
+ def.notify({
+ message: 'Sending reversal...',
+ });
+ }
+
+ rpc.query({
+ model: 'pos_elavon.elavon_transaction',
+ method: 'do_reversal',
+ args: [request_data],
+ }, {
+ timeout: self.server_timeout_in_ms
+ })
+ .then(function (data) {
+ if (data === "timeout") {
+ self.retry_elavon_transaction(def, null, retry_nr, true, self.do_reversal, [line, is_voidsale, def, retry_nr + 1]);
+ return;
+ }
+
+ if (data === "internal error") {
+ def.resolve({
+ message: _t("Odoo error while processing transaction.")
+ });
+ return;
+ }
+
+ var result = self.pos.decodeElavonResponse(data);
+ if (result.ssl_result_message == 'APPROVAL') {
+ def.resolve({
+ message: 'Reversal succeeded.'
+ });
+ self.remove_paymentline_by_ref(line);
+ return;
+ }
+
+ if (result.errorCode == '5040') {
+ // Already removed.
+ def.resolve({
+ message: 'Invalid Transaction ID. This probably means that it was already reversed.',
+ });
+ self.remove_paymentline_by_ref(line);
+ return;
+ }
+
+ def.resolve({
+ message: 'Unknown message check console logs. ' + result.ssl_result_message,
+ });
+
+ }).fail(function (type, error) {
+ self.retry_elavon_transaction(def, null, retry_nr, false, self.do_reversal, [line, is_voidsale, def, retry_nr + 1]);
+ });
+ },
+
+ click_delete_paymentline: function (cid) {
+ var lines = this.pos.get_order().get_paymentlines();
+
+ for (var i = 0; i < lines.length; i++) {
+ if (lines[i].cid === cid && lines[i].elavon_txn_id) {
+ this.do_reversal(lines[i], false);
+ return;
+ }
+ }
+
+ this._super(cid);
+ },
+
+ // make sure there is only one paymentline waiting for a swipe
+ click_paymentmethods: function (id) {
+ var order = this.pos.get_order();
+ var cashregister = null;
+ for (var i = 0; i < this.pos.cashregisters.length; i++) {
+ if (this.pos.cashregisters[i].journal_id[0] === id){
+ cashregister = this.pos.cashregisters[i];
+ break;
+ }
+ }
+
+ if (cashregister.journal.pos_elavon_config_id) {
+ var pending_swipe_line = this._get_swipe_pending_line();
+
+ if (pending_swipe_line) {
+ this.gui.show_popup('error',{
+ 'title': _t('Error'),
+ 'body': _t('One credit card swipe already pending.'),
+ });
+ } else {
+ this._super(id);
+ order.selected_paymentline.elavon_swipe_pending = true;
+ this.render_paymentlines();
+ order.trigger('change', order); // needed so that export_to_JSON gets triggered
+ }
+ } else {
+ this._super(id);
+ }
+ },
+
+ click_elavon_manual_transaction: function (id) {
+ var self = this;
+ var def = new $.Deferred();
+ var pending_swipe_line = this._get_swipe_pending_line();
+ if (!pending_swipe_line) {
+ this.gui.show_popup('error',{
+ 'title': _t('Error'),
+ 'body': _t('No swipe pending payment line for manual transaction.'),
+ });
+ return;
+ }
+
+ this.gui.show_popup('payment-manual-transaction', {
+ transaction: def,
+ confirm: function(card_details, deffered) {
+ card_details.journal_id = pending_swipe_line.cashregister.journal.id;
+ self.credit_code_transaction(card_details, deffered);
+ def.notify({message: _t('Handling transaction...')});
+ },
+ });
+ },
+
+ show: function () {
+ this._super();
+ if (this.pos.getOnlinePaymentJournals().length !== 0) {
+ this.pos.barcode_reader.set_action_callback('credit', _.bind(this.credit_code_action, this));
+ }
+ },
+
+ render_paymentlines: function() {
+ this._super();
+ var self = this;
+ self.$('.paymentlines-container').on('click', '.elavon_manual_transaction', function(){
+ self.click_elavon_manual_transaction();
+ });
+ },
+
+ // before validating, get rid of any paymentlines that are waiting
+ // on a swipe.
+ validate_order: function(force_validation) {
+ if (this.pos.get_order().is_paid() && ! this.invoicing) {
+ var lines = this.pos.get_order().get_paymentlines();
+
+ for (var i = 0; i < lines.length; i++) {
+ if (lines[i].elavon_swipe_pending) {
+ this.pos.get_order().remove_paymentline(lines[i]);
+ this.render_paymentlines();
+ }
+ }
+ }
+
+ this._super(force_validation);
+ }
+});
+
+});
diff --git a/pos_elavon/static/src/xml/pos_elavon.xml b/pos_elavon/static/src/xml/pos_elavon.xml
new file mode 100644
index 00000000..d375b23d
--- /dev/null
+++ b/pos_elavon/static/src/xml/pos_elavon.xml
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ WAITING FOR SWIPE
+
+
+
+
+
+ Manual
+
+
+
+
+
+
+
+ this.removeAttr('class');
+ this.attr('t-attf-class', 'paymentline selected #{line.elavon_swipe_pending ? \'o_pos_elavon_swipe_pending\' : \'\'}');
+
+
+ this.removeAttr('class');
+ this.attr('t-attf-class', 'paymentline #{line.elavon_swipe_pending ? \'o_pos_elavon_swipe_pending\' : \'\'}');
+
+
+ this.removeAttr('class');
+ this.attr('t-attf-class', 'col-tendered edit #{line.elavon_swipe_pending ? \'o_pos_elavon_swipe_pending\' : \'\'}');
+
+
+
+
+
+
+
+ CARDHOLDER WILL PAY CARD ISSUER
+ ABOVE AMOUNT PURSUANT
+ TO CARDHOLDER AGREEMENT
+
+
+ X______________________________
+
+
+
+
+
+
+
+
+
+ APPROVAL CODE:
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+ APPROVAL CODE:
+
+ |
+
+
+
+
+
+
+
+
+
diff --git a/pos_elavon/views/pos_config_setting_views.xml b/pos_elavon/views/pos_config_setting_views.xml
new file mode 100644
index 00000000..1fd2b0d9
--- /dev/null
+++ b/pos_elavon/views/pos_config_setting_views.xml
@@ -0,0 +1,18 @@
+
+
+
+
+ pos.config.form.inherit.elavon
+ pos.config
+
+
+
+
+
+
+
diff --git a/pos_elavon/views/pos_elavon_templates.xml b/pos_elavon/views/pos_elavon_templates.xml
new file mode 100644
index 00000000..ad30955c
--- /dev/null
+++ b/pos_elavon/views/pos_elavon_templates.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pos_elavon/views/pos_elavon_views.xml b/pos_elavon/views/pos_elavon_views.xml
new file mode 100644
index 00000000..0cae9797
--- /dev/null
+++ b/pos_elavon/views/pos_elavon_views.xml
@@ -0,0 +1,117 @@
+
+
+
+ Elavon Configurations
+ pos_elavon.configuration
+
+
+
+
+
+
+ Elavon Configurations
+ pos_elavon.configuration
+
+
+
+
+
+
+
+
+
+ Elavon Configurations
+ ir.actions.act_window
+ pos_elavon.configuration
+ form
+ tree,kanban,form
+
+
+ Click to configure your card reader.
+
+
+
+
+
+ POS Journal
+ account.journal
+
+
+
+
+
+
+
+
+
+
+ account.bank.journal.form.inherited.pos.elavon
+ account.journal
+
+
+
+
+
+
+
+
+
+ crm.team.form.pos.elavon
+ crm.team
+
+
+
+
+
+
+
+
+
+ POS orders
+ pos.order
+
+
+
+
+
+
+
+
+
+
+
+