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:
+
+
+
+ +
+ +
+
+
+ + + + + + + &nbsp;&nbsp;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 define what Elavon account will be used when + processing credit card transactions in the Point Of Sale. Setting up a Elavon + configuration will enable you to allow payments with various credit cards + (eg. Visa, MasterCard, Discovery, American Express, ...). After setting up this + configuration you should associate it with a Point Of Sale payment method. +

+ We currently support standard card reader devices. It can be connected + directly to the Point Of Sale device or it can be connected to the POSBox. +

+ Using the Elavon integration in the Point Of Sale is easy: just press the + associated payment method. After that the amount can be adjusted (eg. for cashback) + just like on any other payment line. Whenever the payment line is set up, a card + can be swiped through the card reader device. +

+ For quickly handling orders: just swiping a credit card when on the payment screen + (without having pressed anything else) will charge the full amount of the order to + the card. +

+ Note that you will need to setup a 'PIN' number on POS Teams. +

+
+ + + + +
+
+
+
+ + + 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 + + + + + + + + + + + +