From cd4b10b004f69098b6cf8193a171bb3c14f22b97 Mon Sep 17 00:00:00 2001 From: Joren Van Onder Date: Mon, 19 Aug 2019 12:30:39 -0700 Subject: [PATCH 1/8] [IMP] point_of_sale: generic electronic payment interface This is an interface that can be implemented to support some electronic payment method in the POS. It's extracted from the existing pos_iot code. To implement a payment method you: - Python: add a selection to the use_payment_terminal field in Python - JS: implement the PaymentInterface class - JS: register your PaymentInterface in point_of_sale.models: models.register_payment_method( 'use_payment_terminal_selection_value', ImplementedPaymentInterface ); See the documentation of PaymentInterface for details on what each method does and when it is called. Co-authored-by: Antoine Prieels --- addons/point_of_sale/models/pos_order.py | 2 + addons/point_of_sale/models/pos_payment.py | 2 + .../models/pos_payment_method.py | 8 + addons/point_of_sale/static/src/css/pos.css | 10 ++ addons/point_of_sale/static/src/js/models.js | 119 ++++++++++++++- addons/point_of_sale/static/src/js/payment.js | 95 ++++++++++++ addons/point_of_sale/static/src/js/screens.js | 139 ++++++++++++++++-- addons/point_of_sale/static/src/xml/pos.xml | 123 +++++++++++++++- .../views/account_statement_view.xml | 22 +-- addons/point_of_sale/views/point_of_sale.xml | 2 +- .../views/pos_payment_method_views.xml | 2 + .../point_of_sale/views/pos_payment_views.xml | 2 + 12 files changed, 491 insertions(+), 35 deletions(-) create mode 100644 addons/point_of_sale/static/src/js/payment.js diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index 7d593f7a25e..f693326ef54 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -59,6 +59,8 @@ class PosOrder(models.Model): 'amount': ui_paymentline['amount'] or 0.0, 'payment_date': payment_date, 'payment_method_id': ui_paymentline['payment_method_id'], + 'card_type': ui_paymentline.get('card_type'), + 'transaction_id': ui_paymentline.get('transaction_id'), 'pos_order_id': order.id, } diff --git a/addons/point_of_sale/models/pos_payment.py b/addons/point_of_sale/models/pos_payment.py index e37e0d44396..28913dd8a55 100644 --- a/addons/point_of_sale/models/pos_payment.py +++ b/addons/point_of_sale/models/pos_payment.py @@ -23,3 +23,5 @@ class PosPayment(models.Model): partner_id = fields.Many2one('res.partner', string='Customer', related='pos_order_id.partner_id') session_id = fields.Many2one('pos.session', string='Session', related='pos_order_id.session_id') company_id = fields.Many2one('res.company', string='Company', related='pos_order_id.company_id') + card_type = fields.Char('Type of card used') + transaction_id = fields.Char('Payment Transaction ID') diff --git a/addons/point_of_sale/models/pos_payment_method.py b/addons/point_of_sale/models/pos_payment_method.py index be2197244c5..997afa31541 100644 --- a/addons/point_of_sale/models/pos_payment_method.py +++ b/addons/point_of_sale/models/pos_payment_method.py @@ -40,6 +40,14 @@ class PosPaymentMethod(models.Model): open_session_ids = fields.Many2many('pos.session', string='Pos Sessions', compute='_compute_open_session_ids', help='Open PoS sessions that are using this payment method.') config_ids = fields.Many2many('pos.config', string='Point of Sale Configurations') company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.company) + use_payment_terminal = fields.Selection([], 'Use a Payment Terminal', help='Record payments with a terminal on this journal.') + hide_use_payment_terminal = fields.Boolean(compute='_compute_hide_use_payment_terminal', help='Technical field which is used to ' + 'hide use_payment_terminal when no payment interfaces are installed.') + + def _compute_hide_use_payment_terminal(self): + no_terminals = not bool(self._fields['use_payment_terminal'].selection) + for payment_method in self: + payment_method.hide_use_payment_terminal = no_terminals @api.depends('config_ids') def _compute_open_session_ids(self): diff --git a/addons/point_of_sale/static/src/css/pos.css b/addons/point_of_sale/static/src/css/pos.css index 9addf1edde5..ffea4f8e133 100644 --- a/addons/point_of_sale/static/src/css/pos.css +++ b/addons/point_of_sale/static/src/css/pos.css @@ -1428,6 +1428,16 @@ td { text-align: center; } +.paymentlines .button { + cursor: pointer; + border: 1px solid #cacaca; +} +.paymentlines .electronic_payment { + background: #e2e2e2; + border-collapse: unset; + text-align: center; + font-size: 16px; +} /* c) The receipt screen */ diff --git a/addons/point_of_sale/static/src/js/models.js b/addons/point_of_sale/static/src/js/models.js index c976c76c594..bd78fb1dee7 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -380,7 +380,7 @@ exports.PosModel = Backbone.Model.extend({ }, },{ model: 'pos.payment.method', - fields: ['name', 'is_cash_count'], + fields: ['name', 'is_cash_count', 'use_payment_terminal'], domain: function(self, tmp) { return [['id', 'in', tmp.payment_method_ids]]; }, @@ -395,10 +395,15 @@ exports.PosModel = Backbone.Model.extend({ return a.id - b.id; } }); - self.payment_methods_by_id = {} + self.payment_methods_by_id = {}; _.each(self.payment_methods, function(payment_method) { - self.payment_methods_by_id[payment_method.id] = payment_method - }) + self.payment_methods_by_id[payment_method.id] = payment_method; + + var PaymentInterface = self.electronic_payment_interfaces[payment_method.use_payment_terminal]; + if (PaymentInterface) { + payment_method.payment_terminal = new PaymentInterface(self, payment_method); + } + }); } },{ model: 'account.fiscal.position', @@ -1166,8 +1171,25 @@ exports.PosModel = Backbone.Model.extend({ } }, + electronic_payment_interfaces: {}, }); +/** + * Call this function to map your PaymentInterface implementation to + * the use_payment_terminal field. When the POS loads it will take + * care of instantiating your interface and setting it on the right + * payment methods. + * + * @param {string} use_payment_terminal - value used in the + * use_payment_terminal selection field + * + * @param {Object} ImplementedPaymentInterface - implemented + * PaymentInterface + */ +exports.register_payment_method = function(use_payment_terminal, ImplementedPaymentInterface) { + exports.PosModel.prototype.electronic_payment_interfaces[use_payment_terminal] = ImplementedPaymentInterface; +}; + // Add fields to the list of read fields when a model is loaded // by the point of sale. // e.g: module.load_fields("product.product",['price','category']) @@ -2064,6 +2086,11 @@ exports.Paymentline = Backbone.Model.extend({ this.order = options.order; this.amount = 0; this.selected = false; + this.ticket = ''; + this.payment_status = ''; + this.card_type = ''; + this.transaction_id = ''; + if (options.json) { this.init_from_JSON(options.json); return; @@ -2077,6 +2104,10 @@ exports.Paymentline = Backbone.Model.extend({ init_from_JSON: function(json){ this.amount = json.amount; this.payment_method = this.pos.payment_methods_by_id[json.payment_method_id]; + this.payment_status = json.payment_status; + this.ticket = json.ticket; + this.card_type = json.card_type; + this.transaction_id = json.transaction_id; }, //sets the amount of money on this payment line set_amount: function(value){ @@ -2097,13 +2128,45 @@ exports.Paymentline = Backbone.Model.extend({ this.trigger('change',this); } }, + /** + * returns {string} payment status. + */ + get_payment_status: function() { + return this.payment_status; + }, + + /** + * Set the new payment status. + * + * @param {string} value - new status. + */ + set_payment_status: function(value) { + this.payment_status = value; + this.trigger('change', this); + }, + + /** + * Set additional info to be printed on the receipts. value should + * be compatible with both the QWeb and ESC/POS receipts. + * + * @param {string} value - receipt info + */ + set_receipt_info: function(value) { + this.ticket += value; + this.trigger('change', this); + }, + // returns the associated cashregister //exports as JSON for server communication export_as_JSON: function(){ return { name: time.datetime_to_str(new Date()), payment_method_id: this.payment_method.id, - amount: this.get_amount() + amount: this.get_amount(), + payment_status: this.payment_status, + ticket: this.ticket, + card_type: this.card_type, + transaction_id: this.transaction_id, }; }, //exports as JSON for receipt printing @@ -2616,6 +2679,17 @@ exports.Order = Backbone.Model.extend({ get_paymentlines: function(){ return this.paymentlines.models; }, + /** + * Retrieve the paymentline with the specified cid + * + * @param {String} cid + */ + get_paymentline: function (cid) { + var lines = this.get_paymentlines(); + return lines.find(function (line) { + return line.cid === cid; + }); + }, remove_paymentline: function(line){ this.assert_editable(); if(this.selected_paymentline === line){ @@ -2647,6 +2721,32 @@ exports.Order = Backbone.Model.extend({ this.trigger('change:selected_paymentline',this.selected_paymentline); } }, + electronic_payment_in_progress: function() { + return this.get_paymentlines() + .some(function(pl) { + if (pl.payment_status) { + return !['done', 'reversed'].includes(pl.payment_status); + } else { + return false; + } + }); + }, + /** + * Stops a payment on the terminal if one is running + */ + stop_electronic_payment: function () { + var lines = this.get_paymentlines(); + var line = lines.find(function (line) { + var status = line.get_payment_status(); + return status && !['done', 'reversed', 'reversing', 'pending', 'retry'].includes(status); + }); + if (line) { + line.set_payment_status('waitingCancel'); + line.payment_method.payment_terminal.send_payment_cancel(this, line.cid).finally(function () { + line.set_payment_status('retry'); + }); + } + }, /* ---- Payment Status --- */ get_subtotal : function(){ return round_pr(this.orderlines.reduce((function(sum, orderLine){ @@ -2702,7 +2802,14 @@ exports.Order = Backbone.Model.extend({ }, get_total_paid: function() { return round_pr(this.paymentlines.reduce((function(sum, paymentLine) { - return sum + paymentLine.get_amount(); + if (paymentLine.get_payment_status()) { + if (paymentLine.get_payment_status() == 'done') { + sum += paymentLine.get_amount(); + } + } else { + sum += paymentLine.get_amount(); + } + return sum; }), 0), this.pos.currency.rounding); }, get_tax_details: function(){ diff --git a/addons/point_of_sale/static/src/js/payment.js b/addons/point_of_sale/static/src/js/payment.js new file mode 100644 index 00000000000..ae73f552604 --- /dev/null +++ b/addons/point_of_sale/static/src/js/payment.js @@ -0,0 +1,95 @@ +odoo.define('point_of_sale.PaymentInterface', function (require) { +"use strict"; + +var core = require('web.core'); + +/** + * Implement this interface to support a new payment method in the POS: + * + * var PaymentInterface = require('point_of_sale.PaymentInterface'); + * var MyPayment = PaymentInterface.extend({ + * ... + * }) + * + * To connect the interface to the right payment methods register it: + * + * var models = require('point_of_sale.models'); + * models.register_payment_method('my_payment', MyPayment); + * + * my_payment is the technical name of the added selection in + * use_payment_terminal. + * + * If necessary new fields can be loaded on any model: + * + * models.load_fields('pos.payment.method', ['new_field1', 'new_field2']); + */ +var PaymentInterface = core.Class.extend({ + init: function (pos, payment_method) { + this.pos = pos; + this.payment_method = payment_method; + this.supports_reversals = false; + }, + + /** + * Call this function to enable UI elements that allow a user to + * reverse a payment. This requires that you implement + * send_payment_reversal. + */ + enable_reversals: function () { + this.supports_reversals = true; + }, + + /** + * Called when a user clicks the "Send" button in the + * interface. This should initiate a payment request and return a + * Promise that resolves when the final status of the payment line + * is set with set_payment_status. + * + * For successful transactions set_receipt_info() should be used + * to set info that should to be printed on the receipt. You + * should also set card_type and transaction_id on the line for + * successful transactions. + * + * @param {string} cid - The id of the paymentline + * @returns {Promise} resolved with a boolean that is false when + * the payment should be retried. Rejected when the status of the + * paymentline will be manually updated. + */ + send_payment_request: function (cid) {}, + + /** + * Called when a user removes a payment line that's still waiting + * on send_payment_request to complete. Should execute some + * request to ensure the current payment request is + * cancelled. This is not to refund payments, only to cancel + * them. The payment line being cancelled will be deleted + * automatically after the returned promise resolves. + * + * @param {} order - The order of the paymentline + * @param {string} cid - The id of the paymentline + * @returns {Promise} + */ + send_payment_cancel: function (order, cid) {}, + + /** + * This is an optional method. When implementing this make sure to + * call enable_reversals() in the constructor of your + * interface. This should reverse a previous payment with status + * 'done'. The paymentline will be removed based on returned + * Promise. + * + * @param {string} cid - The id of the paymentline + * @returns {Promise} returns true if the reversal was successful. + */ + send_payment_reversal: function (cid) {}, + + /** + * Called when the payment screen in the POS is closed (by + * e.g. clicking the "Back" button). Could be used to cancel in + * progress payments. + */ + close: function () {}, +}); + +return PaymentInterface; +}); diff --git a/addons/point_of_sale/static/src/js/screens.js b/addons/point_of_sale/static/src/js/screens.js index d56e2f1fa27..b625a4f2bb0 100644 --- a/addons/point_of_sale/static/src/js/screens.js +++ b/addons/point_of_sale/static/src/js/screens.js @@ -131,6 +131,10 @@ var ScreenWidget = PosBaseWidget.extend({ if(this.pos.barcode_reader){ this.pos.barcode_reader.reset_action_callbacks(); } + + if(this.payment_interface){ + this.payment_interface.close(); + } }, // this methods hides the screen. It's not a good place to put your cleanup stuff as it is called on the @@ -1869,6 +1873,13 @@ var PaymentScreenWidget = ScreenWidget.extend({ // handle both keyboard and numpad input. Accepts // a string that represents the key pressed. payment_input: function(input) { + var paymentline = this.pos.get_order().selected_paymentline; + + // disable changing amount on paymentlines with running or done payments on a payment terminal + if (this.payment_interface && !['pending', 'retry'].includes(paymentline.get_payment_status())) { + return; + } + var newbuf = this.gui.numpad_input(this.inputbuffer, input, {'firstinput': this.firstinput}); this.firstinput = (newbuf.length === 0); @@ -1881,14 +1892,14 @@ var PaymentScreenWidget = ScreenWidget.extend({ if (newbuf !== this.inputbuffer) { this.inputbuffer = newbuf; var order = this.pos.get_order(); - if (order.selected_paymentline) { + if (paymentline) { var amount = this.inputbuffer; if (this.inputbuffer !== "-") { amount = field_utils.parse.float(this.inputbuffer); } - order.selected_paymentline.set_amount(amount); + paymentline.set_amount(amount); this.order_changes(); this.render_paymentlines(); this.$('.paymentline.selected .edit').text(this.format_currency_no_symbol(amount)); @@ -1923,8 +1934,16 @@ var PaymentScreenWidget = ScreenWidget.extend({ 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) { - this.pos.get_order().remove_paymentline(lines[i]); + var line = lines[i]; + if (line.cid === cid) { + // If a paymentline with a payment terminal linked to + // it is removed, the terminal should get a cancel + // request. + if (['waiting', 'waitingCard', 'timeout'].includes(lines[i].get_payment_status())) { + line.payment_method.payment_terminal.send_payment_cancel(this.pos.get_order(), cid); + } + + this.pos.get_order().remove_paymentline(line); this.reset_input(); this.render_paymentlines(); return; @@ -1942,6 +1961,82 @@ var PaymentScreenWidget = ScreenWidget.extend({ } } }, + /** + * link the proper functions to buttons for payment terminals + * send_payment_request, force_payment_done and cancel_payment. + */ + render_payment_terminal: function() { + var self = this; + var order = this.pos.get_order(); + if (!order) { + return; + } + + this.$el.find('.send_payment_request').click(function () { + var cid = $(this).data('cid'); + // Other payment lines can not be reversed anymore + order.get_paymentlines().forEach(function (line) { + line.can_be_reversed = false; + }); + + var line = self.pos.get_order().get_paymentline(cid); + var payment_terminal = line.payment_method.payment_terminal; + line.set_payment_status('waiting'); + self.render_paymentlines(); + + payment_terminal.send_payment_request(cid).then(function (payment_successful) { + if (payment_successful) { + line.set_payment_status('done'); + line.can_be_reversed = self.payment_interface.supports_reversals; + self.reset_input(); // in case somebody entered a tip the amount tendered should be updated + } else { + line.set_payment_status('retry'); + } + }).finally(function () { + self.render_paymentlines(); + }); + + self.render_paymentlines(); + }); + this.$el.find('.send_payment_cancel').click(function () { + var cid = $(this).data('cid'); + var line = self.pos.get_order().get_paymentline($(this).data('cid')); + var payment_terminal = line.payment_method.payment_terminal; + line.set_payment_status('waitingCancel'); + self.render_paymentlines(); + + payment_terminal.send_payment_cancel(self.pos.get_order(), cid).finally(function () { + line.set_payment_status('retry'); + self.render_paymentlines(); + }); + + self.render_paymentlines(); + }); + this.$el.find('.send_payment_reversal').click(function () { + var cid = $(this).data('cid'); + var line = self.pos.get_order().get_paymentline($(this).data('cid')); + var payment_terminal = line.payment_method.payment_terminal; + line.set_payment_status('reversing'); + self.render_paymentlines(); + + payment_terminal.send_payment_reversal(cid).then(function (reversal_successful) { + if (reversal_successful) { + line.set_amount(0); + line.set_payment_status('reversed'); + } else { + line.set_payment_status('done'); + } + self.render_paymentlines(); + }); + }); + + this.$el.find('.send_force_done').click(function () { + var line = self.pos.get_order().get_paymentline($(this).data('cid')); + var payment_terminal = line.payment_method.payment_terminal; + line.set_payment_status('done'); + self.render_paymentlines(); + }); + }, render_paymentlines: function() { var self = this; var order = this.pos.get_order(); @@ -1969,20 +2064,38 @@ var PaymentScreenWidget = ScreenWidget.extend({ }); lines.appendTo(this.$('.paymentlines-container')); + + this.render_payment_terminal(); }, compute_extradue: function (order) { var lines = order.get_paymentlines(); var due = order.get_due(); - if (due && lines.length && due !== order.get_due(lines[lines.length-1])) { + if (due && lines.length && (due !== order.get_due(lines[lines.length-1]) || lines[lines.length - 1].payment_status === 'reversed')) { return due; } return 0; }, click_paymentmethods: function(id) { - var payment_method = this.pos.payment_methods_by_id[id] - this.pos.get_order().add_paymentline(payment_method); - this.reset_input(); - this.render_paymentlines(); + var payment_method = this.pos.payment_methods_by_id[id]; + var order = this.pos.get_order(); + + if (order.electronic_payment_in_progress()) { + this.gui.show_popup('error',{ + 'title': _t('Error'), + 'body': _t('There is already an electronic payment in progress.'), + }); + } else { + order.add_paymentline(payment_method); + this.reset_input(); + + this.payment_interface = payment_method.payment_terminal; + if (this.payment_interface) { + order.selected_paymentline.set_payment_status('pending'); + } + + this.render_paymentlines(); + this.order_changes(); + } }, render_paymentmethods: function() { var self = this; @@ -2082,6 +2195,10 @@ var PaymentScreenWidget = ScreenWidget.extend({ hide: function(){ $('body').off('keypress', this.keyboard_handler); $('body').off('keydown', this.keyboard_keydown_handler); + var order = this.pos.get_order(); + if (order) { + order.stop_electronic_payment(); + } this._super(); }, // sets up listeners to watch for order changes @@ -2092,7 +2209,9 @@ var PaymentScreenWidget = ScreenWidget.extend({ return; } if(this.old_order){ - this.old_order.unbind(null,null,this); + this.old_order.stop_electronic_payment(); + this.old_order.unbind(null, null, this); + this.old_order.paymentlines.unbind(null, null, this); } order.bind('all',function(){ self.order_changes(); diff --git a/addons/point_of_sale/static/src/xml/pos.xml b/addons/point_of_sale/static/src/xml/pos.xml index 26fe124b55e..6ee22ba1490 100644 --- a/addons/point_of_sale/static/src/xml/pos.xml +++ b/addons/point_of_sale/static/src/xml/pos.xml @@ -564,6 +564,82 @@ + + + + + + Payment request pending + + + Send + + + + + Transaction cancelled + + + Retry + + + + + Connection error + + + Force done + + + + + Waiting for card + + + Cancel + + + + + Request sent + + + + + + + + Reversal request sent to terminal + + + + + + + + + Payment Successful + + + Reverse + + + + + Payment Successful + + + + + + + Payment reversed + + + + + +
@@ -599,10 +675,16 @@ - - - - + + + + + + + + + + @@ -613,7 +695,18 @@ - + + + + + + + + + + + + @@ -626,7 +719,14 @@ - + + + + + + + + @@ -938,7 +1038,16 @@
-