diff options
| author | Odoo's Mergebot <xmo+roboto@odoo.com> | 2019-08-28 10:57:36 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2019-08-28 10:57:36 +0200 |
| commit | 70a65b01323117c044e2edf1cce939417c4bb7df (patch) | |
| tree | 858ccf37198c06253602716f593808333d8aacad /addons | |
| parent | 555a2fd93e99f107e1d5fa62f5ed5fa65fc9d6ea (diff) | |
| parent | e6372c3cc6fef67c43768c212f1fab675c53f34c (diff) | |
[ADD] pos_adyen: generic payment interface and Adyen integration
This does two things:
- Makes it easy to implement new electronic payment methods in the POS by extracting a generic payment interface from the `pos_iot` enterprise module, and
- Uses this new interface to implement Adyen's Point of Sale Terminal API
For more info see individual commits.
Internal task: https://www.odoo.com/web#id=1981799&model=project.task&view_type=form&menu_id=
closes odoo/odoo#35702
Signed-off-by: pimodoo <pimodoo@users.noreply.github.com>
Diffstat (limited to 'addons')
29 files changed, 1103 insertions, 70 deletions
diff --git a/addons/point_of_sale/data/point_of_sale_data.xml b/addons/point_of_sale/data/point_of_sale_data.xml index 48ca0eb669b..58a70c4cc87 100644 --- a/addons/point_of_sale/data/point_of_sale_data.xml +++ b/addons/point_of_sale/data/point_of_sale_data.xml @@ -21,6 +21,7 @@ <field name="default_code">TIPS</field> <field name="weight">0.01</field> <field name="available_in_pos" eval="True"/> + <field name="taxes_id" eval="[(5,)]"/> </record> <record model="pos.config" id="pos_config_main"> diff --git a/addons/point_of_sale/models/pos_config.py b/addons/point_of_sale/models/pos_config.py index 522977fa113..dc3262d431c 100644 --- a/addons/point_of_sale/models/pos_config.py +++ b/addons/point_of_sale/models/pos_config.py @@ -331,6 +331,13 @@ class PosConfig(models.Model): if any(self.available_pricelist_ids.mapped(lambda pl: pl.company_id.id not in (False, self.company_id.id))): raise ValidationError(_("The selected pricelists must belong to no company or the company of the point of sale.")) + @api.onchange('iface_tipproduct') + def _onchange_tipproduct(self): + if self.iface_tipproduct: + self.tip_product_id = self.env.ref('point_of_sale.product_product_tip', False) + else: + self.tip_product_id = False + @api.onchange('iface_print_via_proxy') def _onchange_iface_print_via_proxy(self): self.iface_print_auto = self.iface_print_via_proxy diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index e1bf52d8e88..6a6e84d2104 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -60,6 +60,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..87852afa5dc 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): @@ -51,8 +59,11 @@ class PosPaymentMethod(models.Model): if not self.is_cash_count: self.cash_journal_id = False + def _is_write_forbidden(self, fields): + return bool(fields and self.open_session_ids) + def write(self, vals): - if self.open_session_ids: + if self._is_write_forbidden(set(vals.keys())): raise UserError('Kindly close and validate the following open PoS Sessions before modifying this payment method.\n' 'Open sessions: %s' % (' '.join(self.open_session_ids.mapped('name')),)) return super(PosPaymentMethod, self).write(vals) 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 37521c1ff36..77f65abd239 100644 --- a/addons/point_of_sale/static/src/js/models.js +++ b/addons/point_of_sale/static/src/js/models.js @@ -388,7 +388,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]]; }, @@ -403,10 +403,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', @@ -1174,8 +1179,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']) @@ -2072,6 +2094,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; @@ -2085,6 +2112,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){ @@ -2105,13 +2136,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 @@ -2624,6 +2687,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){ @@ -2655,6 +2729,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){ @@ -2710,7 +2810,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..6db6b3980cc 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,15 +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); - this.order_changes(); + paymentline.set_amount(amount); this.render_paymentlines(); this.$('.paymentline.selected .edit').text(this.format_currency_no_symbol(amount)); } @@ -1923,8 +1933,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 +1960,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 +2063,37 @@ 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(); + } }, render_paymentmethods: function() { var self = this; @@ -2017,7 +2128,6 @@ var PaymentScreenWidget = ScreenWidget.extend({ 'value': self.format_currency_no_symbol(value), 'confirm': function(value) { order.set_tip(field_utils.parse.float(value)); - self.order_changes(); self.render_paymentlines(); } }); @@ -2072,7 +2182,6 @@ var PaymentScreenWidget = ScreenWidget.extend({ this.pos.get_order().clean_empty_paymentlines(); this.reset_input(); this.render_paymentlines(); - this.order_changes(); // that one comes from BarcodeEvents $('body').keypress(this.keyboard_handler); // that one comes from the pos, but we prefer to cover all the basis @@ -2082,6 +2191,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,11 +2205,14 @@ 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(); }); + order.paymentlines.bind('all', self.order_changes.bind(self)); this.old_order = order; }, // called when the order is changed, used to show if diff --git a/addons/point_of_sale/static/src/xml/pos.xml b/addons/point_of_sale/static/src/xml/pos.xml index 7fffbcf30b2..713aace77af 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 @@ </div> </t> + <t t-name="PaymentScreenElectronicPayment"> + <tr class="send_request paymentline electronic_payment"/> + <tr class="paymentline electronic_payment"> + <t t-if="line.payment_status == 'pending'"> + <td colspan="3"> + Payment request pending + </td> + <td colspan="2" class='button send_payment_request highlight' title="Send Payment Request" t-att-data-cid='line.cid'> + Send + </td> + </t> + <t t-elif="line.payment_status == 'retry'"> + <td colspan="3"> + Transaction cancelled + </td> + <td colspan="2" class='button send_payment_request highlight' title="Send Payment Request" t-att-data-cid='line.cid'> + Retry + </td> + </t> + <t t-elif="line.payment_status == 'force_done'"> + <td colspan="3"> + Connection error + </td> + <td colspan="2" class='button send_force_done' title="Force Done" t-att-data-cid='line.cid'> + Force done + </td> + </t> + <t t-elif="line.payment_status == 'waitingCard'"> + <td colspan="3"> + Waiting for card + </td> + <td colspan="2" class='button send_payment_cancel' title="Cancel Payment Request" t-att-data-cid='line.cid'> + Cancel + </td> + </t> + <t t-elif="['waiting', 'waitingCancel'].includes(line.payment_status)"> + <td colspan="3"> + Request sent + </td> + <td colspan="2"> + <i class='fa fa-spinner fa-spin' role='img'/> + </td> + </t> + <t t-elif="line.payment_status == 'reversing'"> + <td colspan="3"> + Reversal request sent to terminal + </td> + <td colspan="2"> + <i class='fa fa-spinner fa-spin' role='img'/> + </td> + </t> + <t t-elif="line.payment_status == 'done'"> + <t t-if="line.can_be_reversed"> + <td colspan="3"> + Payment Successful + </td> + <td colspan="2" class='button send_payment_reversal' title="Reverse Payment" t-att-data-cid='line.cid'> + Reverse + </td> + </t> + <t t-else=""> + <td colspan="5"> + Payment Successful + </td> + </t> + </t> + <t t-elif="line.payment_status == 'reversed'"> + <tr class="paymentline electronic_payment"> + <td colspan="5"> + Payment reversed + </td> + </tr> + </t> + </tr> + </t> + <t t-name="PaymentScreen-Paymentlines"> <t t-if="!paymentlines.length"> <div class='paymentlines-empty'> @@ -599,10 +675,16 @@ <t t-if='line.selected'> <tr class='paymentline selected'> <td class='col-due'> <t t-esc='widget.format_currency_no_symbol(order.get_due(line))' /> </td> - <td class='col-tendered edit'> - <t t-esc='widget.inputbuffer' /> - <!-- <t t-esc='line.get_amount()' /> --> - </td> + <t t-if="line && line.payment_status && ['done', 'waitingCard', 'waiting', 'reversing', 'reversed'].includes(line.payment_status)"> + <td class='col-tendered'> + <t t-esc='widget.format_currency_no_symbol(line.get_amount())' /> + </td> + </t> + <t t-else=""> + <td class='col-tendered edit'> + <t t-esc='widget.inputbuffer' /> + </td> + </t> <t t-if='order.get_change(line)'> <td class='col-change highlight' > <t t-esc='widget.format_currency_no_symbol(order.get_change(line))' /> @@ -613,7 +695,18 @@ </t> <td class='col-name' > <t t-esc='line.name' /> </td> - <td class='delete-button' t-att-data-cid='line.cid' aria-label="Delete" title="Delete"> <i class='fa fa-times-circle' /> </td> + <t t-if="line && line.payment_status && ['done', 'reversed', 'reversing'].includes(line.payment_status)"> + <td/> + </t> + <t t-else=''> + <td class='delete-button' t-att-data-cid='line.cid' aria-label="Delete" title="Delete"> + <i class='fa fa-times-circle'/> + </td> + </t> + + <t t-if="line && line.payment_status"> + <t t-call="PaymentScreenElectronicPayment"/> + </t> </tr> </t> <t t-if='!line.selected'> @@ -626,7 +719,14 @@ </t> </td> <td class='col-name'> <t t-esc='line.name' /> </td> - <td class='delete-button' t-att-data-cid='line.cid' aria-label="Delete" title="Delete"> <i class='fa fa-times-circle' /> </td> + <t t-if="line && line.payment_status && ['done', 'reversed', 'reversing'].includes(line.payment_status)"> + <td/> + </t> + <t t-else=''> + <td class='delete-button' t-att-data-cid='line.cid' aria-label="Delete" title="Delete"> + <i class='fa fa-times-circle'/> + </td> + </t> </tr> </t> </t> @@ -938,7 +1038,16 @@ <br/> </div> - <div class='after-footer' /> + <div class='after-footer'> + <t t-foreach='paymentlines' t-as='line'> + <t t-if='line.ticket'> + <br /> + <div class="pos-payment-terminal-receipt"> + <t t-raw='line.ticket'/> + </div> + </t> + </t> + </div> <br/> <div class="pos-receipt-order-data"> diff --git a/addons/point_of_sale/views/account_statement_view.xml b/addons/point_of_sale/views/account_statement_view.xml index bc693d06aa6..fb31b360d25 100644 --- a/addons/point_of_sale/views/account_statement_view.xml +++ b/addons/point_of_sale/views/account_statement_view.xml @@ -33,16 +33,16 @@ <button name="set_default_cashbox" type="object" string="Set Default Cash Opening" /> </xpath> </field> - </record> + </record> - <record id="view_account_journal_pos_user_form" model="ir.ui.view"> - <field name="name">account.journal.pos.user.form.inherit</field> - <field name="model">account.journal</field> - <field name="inherit_id" ref="account.view_account_journal_form"/> - <field name="arch" type="xml"> - <xpath expr="//notebook[last()]" position="inside"> - <page string="Point of Sale" name="point_of_sale" invisible="1"/> - </xpath> - </field> - </record> + <record id="view_account_journal_pos_user_form" model="ir.ui.view"> + <field name="name">account.journal.pos.user.form.inherit</field> + <field name="model">account.journal</field> + <field name="inherit_id" ref="account.view_account_journal_form"/> + <field name="arch" type="xml"> + <xpath expr="//notebook[last()]" position="inside"> + <page string="Point of Sale" name="point_of_sale" invisible="1"/> + </xpath> + </field> + </record> </odoo> diff --git a/addons/point_of_sale/views/point_of_sale.xml b/addons/point_of_sale/views/point_of_sale.xml index 640f375c9c7..7437377e3df 100644 --- a/addons/point_of_sale/views/point_of_sale.xml +++ b/addons/point_of_sale/views/point_of_sale.xml @@ -6,7 +6,6 @@ <link rel="stylesheet" href="/point_of_sale/static/src/css/pos.css" id="pos-stylesheet"/> <link rel="stylesheet" href="/point_of_sale/static/src/css/keyboard.css"/> <link rel="stylesheet" href="/point_of_sale/static/src/css/pos_receipts.css"/> - <link rel="stylesheet" type="text/less" href="/web/static/src/scss/fontawesome_overridden.scss"/> <script type="text/javascript" src="/point_of_sale/static/lib/backbone/backbone.js"></script> @@ -23,6 +22,7 @@ <script type="text/javascript" src="/point_of_sale/static/src/js/gui.js"></script> <script type="text/javascript" src="/point_of_sale/static/src/js/popups.js"></script> <script type="text/javascript" src="/point_of_sale/static/src/js/screens.js"></script> + <script type="text/javascript" src="/point_of_sale/static/src/js/payment.js"></script> </template> <template id="customer_facing_display_html" > diff --git a/addons/point_of_sale/views/pos_config_view.xml b/addons/point_of_sale/views/pos_config_view.xml index 4cd6dd58c18..248aa30465e 100644 --- a/addons/point_of_sale/views/pos_config_view.xml +++ b/addons/point_of_sale/views/pos_config_view.xml @@ -373,6 +373,27 @@ </div> </div> </div> + <div class="col-12 col-lg-6 o_setting_box" + id="iface_tipproduct" + title="This product is used as reference on customer receipts."> + <div class="o_setting_left_pane"> + <field name="iface_tipproduct"/> + </div> + <div class="o_setting_right_pane"> + <label for="tip_product_id" string="Tips"/> + <div class="text-muted"> + Accept customer tips or convert their change to a tip + </div> + <div class="content-group" attrs="{'invisible': [('iface_tipproduct', '=', False)]}"> + <div class="mt16"> + <label string="Tip Product" for="tip_product_id" class="o_light_label"/> + <field name="tip_product_id" + domain="[('available_in_pos', '=', True)]" + context="{'default_available_in_pos': True}"/> + </div> + </div> + </div> + </div> </div> <h2>Bills & Receipts</h2> <div class="row mt16 o_settings_container" id="receipt"> diff --git a/addons/point_of_sale/views/pos_payment_method_views.xml b/addons/point_of_sale/views/pos_payment_method_views.xml index 73d122a7a31..7093c7c3da5 100644 --- a/addons/point_of_sale/views/pos_payment_method_views.xml +++ b/addons/point_of_sale/views/pos_payment_method_views.xml @@ -12,10 +12,12 @@ </div> <group name="Payment methods"> <group> + <field name="hide_use_payment_terminal" invisible="1"/> <field name="receivable_account_id" groups="account.group_account_user" /> <field name="is_cash_count" /> <field name="cash_journal_id" attrs="{'invisible': [('is_cash_count', '=', False)], 'required': [('is_cash_count', '=', True)]}" /> <field name="split_transactions" groups="base.group_no_one" /> + <field name="use_payment_terminal" attrs="{'invisible': ['|', ('is_cash_count', '=', True), ('hide_use_payment_terminal', '=', True)]}"/> <field name="company_id" readonly="1" groups="base.group_multi_company" /> </group> <group> diff --git a/addons/point_of_sale/views/pos_payment_views.xml b/addons/point_of_sale/views/pos_payment_views.xml index aed337ca22f..c9de5171932 100644 --- a/addons/point_of_sale/views/pos_payment_views.xml +++ b/addons/point_of_sale/views/pos_payment_views.xml @@ -12,6 +12,8 @@ <field name="amount" /> <field name="pos_order_id" /> <field name="payment_method_id" /> + <field name="card_type" attrs="{'invisible': [('card_type', '=', False)]}"/> + <field name="transaction_id" attrs="{'invisible': [('transaction_id', '=', False)]}"/> <field name="session_id" /> </group> </sheet> diff --git a/addons/pos_adyen/__init__.py b/addons/pos_adyen/__init__.py new file mode 100644 index 00000000000..d26611156a6 --- /dev/null +++ b/addons/pos_adyen/__init__.py @@ -0,0 +1,5 @@ +# coding: utf-8 +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models +from . import controllers diff --git a/addons/pos_adyen/__manifest__.py b/addons/pos_adyen/__manifest__.py new file mode 100644 index 00000000000..e79fb5ca105 --- /dev/null +++ b/addons/pos_adyen/__manifest__.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + 'name': 'POS Adyen', + 'version': '1.0', + 'category': 'Point of Sale', + 'sequence': 6, + 'summary': 'Integrate your POS with an Adyen payment terminal', + 'description': '', + 'data': [ + 'views/pos_config_views.xml', + 'views/pos_payment_method_views.xml', + 'views/point_of_sale_assets.xml', + ], + 'depends': ['point_of_sale'], + 'qweb': ['static/src/xml/pos.xml'], + 'installable': True, + 'license': 'OEEL-1', +} diff --git a/addons/pos_adyen/controllers/__init__.py b/addons/pos_adyen/controllers/__init__.py new file mode 100644 index 00000000000..a7ee9d9fbd1 --- /dev/null +++ b/addons/pos_adyen/controllers/__init__.py @@ -0,0 +1,2 @@ +# coding: utf-8 +from . import main diff --git a/addons/pos_adyen/controllers/main.py b/addons/pos_adyen/controllers/main.py new file mode 100644 index 00000000000..ec988da38d6 --- /dev/null +++ b/addons/pos_adyen/controllers/main.py @@ -0,0 +1,32 @@ +# coding: utf-8 +import logging +import pprint +import json +from odoo import fields, http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class PosAdyenController(http.Controller): + @http.route('/pos_adyen/notification', type='json', methods=['POST'], auth='none', csrf=False) + def notification(self): + data = json.loads(request.httprequest.data) + + # ignore if it's not a response to a sales request + if not data.get('SaleToPOIResponse'): + return + + _logger.info('notification received from adyen:\n%s', pprint.pformat(data)) + terminal_identifier = data['SaleToPOIResponse']['MessageHeader']['POIID'] + payment_method = request.env['pos.payment.method'].sudo().search([('adyen_terminal_identifier', '=', terminal_identifier)], limit=1) + + if payment_method: + # These are only used to see if the terminal is reachable, + # store the most recent ID we received. + if data['SaleToPOIResponse'].get('DiagnosisResponse'): + payment_method.adyen_latest_diagnosis = data['SaleToPOIResponse']['MessageHeader']['ServiceID'] + else: + payment_method.adyen_latest_response = json.dumps(data) + else: + _logger.error('received a message for a terminal not registered in Odoo: %s', terminal_identifier) diff --git a/addons/pos_adyen/models/__init__.py b/addons/pos_adyen/models/__init__.py new file mode 100644 index 00000000000..77e4db1fdaf --- /dev/null +++ b/addons/pos_adyen/models/__init__.py @@ -0,0 +1,5 @@ +# coding: utf-8 +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import pos_config +from . import pos_payment_method diff --git a/addons/pos_adyen/models/pos_config.py b/addons/pos_adyen/models/pos_config.py new file mode 100644 index 00000000000..b0d9176c628 --- /dev/null +++ b/addons/pos_adyen/models/pos_config.py @@ -0,0 +1,26 @@ +# coding: utf-8 +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import logging + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class PosConfig(models.Model): + _inherit = 'pos.config' + + adyen_ask_customer_for_tip = fields.Boolean('Ask Customers For Tip', help='Prompt the customer to tip.') + + @api.constrains('adyen_ask_customer_for_tip', 'iface_tipproduct', 'tip_product_id') + def _check_adyen_ask_customer_for_tip(self): + for config in self: + if config.adyen_ask_customer_for_tip and (not config.tip_product_id or not config.iface_tipproduct): + raise ValidationError(_("Please configure a tip product for POS %s to support tipping with Adyen.") % config.name) + + @api.onchange('adyen_ask_customer_for_tip') + def _onchange_adyen_ask_customer_for_tip(self): + for config in self: + if config.adyen_ask_customer_for_tip: + config.iface_tipproduct = True diff --git a/addons/pos_adyen/models/pos_payment_method.py b/addons/pos_adyen/models/pos_payment_method.py new file mode 100644 index 00000000000..c3489797173 --- /dev/null +++ b/addons/pos_adyen/models/pos_payment_method.py @@ -0,0 +1,118 @@ +# coding: utf-8 +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import json +import logging +import pprint +import random +import requests +import string + +from odoo import fields, models, api, _ +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + +class PosPaymentMethod(models.Model): + _inherit = 'pos.payment.method' + + use_payment_terminal = fields.Selection(selection_add=[('adyen', 'Adyen')]) + adyen_api_key = fields.Char(string="Adyen API key", help='Used when connecting to Adyen: https://docs.adyen.com/user-management/how-to-get-the-api-key/#description', copy=False) + adyen_terminal_identifier = fields.Char(help='[Terminal model]-[Serial number], for example: P400Plus-123456789', copy=False) + adyen_test_mode = fields.Boolean(help='Run transactions in the test environment.') + adyen_latest_response = fields.Char(help='Technical field used to buffer the latest asynchronous notification from Adyen.', copy=False, groups='base.group_erp_manager') + adyen_latest_diagnosis = fields.Char(help='Technical field used to determine if the terminal is still connected.', copy=False, groups='base.group_erp_manager') + + @api.constrains('adyen_terminal_identifier') + def _check_adyen_terminal_identifier(self): + for payment_method in self: + existing_payment_method = self.search([('id', '!=', payment_method.id), + ('adyen_terminal_identifier', '=', payment_method.adyen_terminal_identifier)], + limit=1) + if existing_payment_method: + raise ValidationError(_('Terminal %s is already used on payment method %s.') + % (payment_method.adyen_terminal_identifier, existing_payment_method.display_name)) + + def _is_write_forbidden(self, fields): + whitelisted_fields = set(('adyen_latest_response', 'adyen_latest_diagnosis')) + return super(PosPaymentMethod, self)._is_write_forbidden(fields - whitelisted_fields) + + def _adyen_diagnosis_request_data(self, pos_config_name, terminal_identifier): + service_id = ''.join(random.choices(string.ascii_letters + string.digits, k=10)) + return { + "SaleToPOIRequest": { + "MessageHeader": { + "ProtocolVersion": "3.0", + "MessageClass": "Service", + "MessageCategory": "Diagnosis", + "MessageType": "Request", + "ServiceID": service_id, + "SaleID": pos_config_name, + "POIID": terminal_identifier, + }, + "DiagnosisRequest": { + "HostDiagnosisFlag": False + } + } + } + + @api.model + def get_latest_adyen_status(self, payment_method_id, pos_config_name, terminal_identifier, test_mode, api_key): + '''See the description of proxy_adyen_request as to why this is an + @api.model function. + ''' + + # Poll the status of the terminal if there's no new + # notification we received. This is done so we can quickly + # notify the user if the terminal is no longer reachable due + # to connectivity issues. + self.proxy_adyen_request(self._adyen_diagnosis_request_data(pos_config_name, terminal_identifier), + test_mode, + api_key) + + payment_method = self.sudo().browse(payment_method_id) + latest_response = payment_method.adyen_latest_response + latest_response = json.loads(latest_response) if latest_response else False + payment_method.adyen_latest_response = '' # avoid handling old responses multiple times + + return { + 'latest_response': latest_response, + 'last_received_diagnosis_id': payment_method.adyen_latest_diagnosis, + } + + @api.model + def proxy_adyen_request(self, data, test_mode, api_key): + '''Necessary because Adyen's endpoints don't have CORS enabled. This is an + @api.model function to avoid concurrent update errors. Adyen's + async endpoint can still take well over a second to complete a + request. By using @api.model and passing in all data we need from + the POS we avoid locking the pos_payment_method table. This way we + avoid concurrent update errors when Adyen calls us back on + /pos_adyen/notification which will need to write on + pos.payment.method. + ''' + TIMEOUT = 10 + endpoint = 'https://terminal-api-live.adyen.com/async' + if test_mode: + endpoint = 'https://terminal-api-test.adyen.com/async' + + _logger.info('request to adyen\n%s', pprint.pformat(data)) + headers = { + 'x-api-key': api_key, + 'Content-Type': 'application/json' + } + req = requests.post(endpoint, data=json.dumps(data), headers=headers, timeout=TIMEOUT) + _logger.info('response from adyen (HTTP status %s):\n%s', req.status_code, req.text) + + # Authentication error doesn't return JSON + if req.status_code == 401: + return { + 'error': { + 'status_code': req.status_code, + 'message': req.text + } + } + + if req.text == 'ok': + return True + + return req.json() diff --git a/addons/pos_adyen/static/src/js/models.js b/addons/pos_adyen/static/src/js/models.js new file mode 100644 index 00000000000..6865f795733 --- /dev/null +++ b/addons/pos_adyen/static/src/js/models.js @@ -0,0 +1,7 @@ +odoo.define('pos_adyen.models', function (require) { +var models = require('point_of_sale.models'); +var PaymentAdyen = require('pos_adyen.payment'); + +models.register_payment_method('adyen', PaymentAdyen); +models.load_fields('pos.payment.method', ['adyen_terminal_identifier', 'adyen_test_mode', 'adyen_api_key']); +}); diff --git a/addons/pos_adyen/static/src/js/payment_adyen.js b/addons/pos_adyen/static/src/js/payment_adyen.js new file mode 100644 index 00000000000..b16f0b70666 --- /dev/null +++ b/addons/pos_adyen/static/src/js/payment_adyen.js @@ -0,0 +1,312 @@ +odoo.define('pos_adyen.payment', function (require) { +"use strict"; + +var core = require('web.core'); +var rpc = require('web.rpc'); +var PaymentInterface = require('point_of_sale.PaymentInterface'); + +var _t = core._t; + +var PaymentAdyen = PaymentInterface.extend({ + send_payment_request: function (cid) { + this._super.apply(this, arguments); + this._reset_state(); + return this._adyen_pay(); + }, + send_payment_cancel: function (order, cid) { + this._super.apply(this, arguments); + // set only if we are polling + this.was_cancelled = !!this.polling; + return this._adyen_cancel(); + }, + close: function () { + this._super.apply(this, arguments); + }, + + // private methods + _reset_state: function () { + this.was_cancelled = false; + this.last_diagnosis_service_id = false; + this.remaining_polls = 2; + clearTimeout(this.polling); + }, + + _handle_odoo_connection_failure: function (data) { + // handle timeout + var line = this.pos.get_order().selected_paymentline; + if (line) { + line.set_payment_status('retry'); + } + this._show_error(_('Could not connect to the Odoo server, please check your internet connection and try again.')); + + return Promise.reject(data); // prevent subsequent onFullFilled's from being called + }, + + _call_adyen: function (data) { + var self = this; + return rpc.query({ + model: 'pos.payment.method', + method: 'proxy_adyen_request', + args: [data, this.payment_method.adyen_test_mode, this.payment_method.adyen_api_key], + }, { + // When a payment terminal is disconnected it takes Adyen + // a while to return an error (~6s). So wait 10 seconds + // before concluding Odoo is unreachable. + timeout: 10000, + shadow: true, + }).catch(this._handle_odoo_connection_failure.bind(this)); + }, + + _adyen_get_sale_id: function () { + var config = this.pos.config; + return _.str.sprintf('%s (ID: %s)', config.display_name, config.id); + }, + + _adyen_common_message_header: function () { + var config = this.pos.config; + this.most_recent_service_id = Math.floor(Math.random() * Math.pow(2, 64)).toString(); // random ID to identify request/response pairs + this.most_recent_service_id = this.most_recent_service_id.substring(0, 10); // max length is 10 + + return { + 'ProtocolVersion': '3.0', + 'MessageClass': 'Service', + 'MessageType': 'Request', + 'SaleID': this._adyen_get_sale_id(config), + 'ServiceID': this.most_recent_service_id, + 'POIID': this.payment_method.adyen_terminal_identifier + }; + }, + + _adyen_pay_data: function () { + var order = this.pos.get_order(); + var config = this.pos.config; + var line = order.selected_paymentline; + var data = { + 'SaleToPOIRequest': { + 'MessageHeader': _.extend(this._adyen_common_message_header(), { + 'MessageCategory': 'Payment', + }), + 'PaymentRequest': { + 'SaleData': { + 'SaleTransactionID': { + 'TransactionID': order.uid, + 'TimeStamp': moment().format(), // iso format: '2018-01-10T11:30:15+00:00' + } + }, + 'PaymentTransaction': { + 'AmountsReq': { + 'Currency': this.pos.currency.name, + 'RequestedAmount': line.amount, + } + } + } + } + }; + + if (config.adyen_ask_customer_for_tip) { + data.SaleToPOIRequest.PaymentRequest.SaleData.SaleToAcquirerData = "tenderOption=AskGratuity"; + } + + return data; + }, + + _adyen_pay: function () { + var self = this; + var data = this._adyen_pay_data(); + + return this._call_adyen(data).then(function (data) { + return self._adyen_handle_response(data); + }); + }, + + _adyen_cancel: function (ignore_error) { + var previous_service_id = this.most_recent_service_id; + var header = _.extend(this._adyen_common_message_header(), { + 'MessageCategory': 'Abort', + }); + + var data = { + 'SaleToPOIRequest': { + 'MessageHeader': header, + 'AbortRequest': { + 'AbortReason': 'MerchantAbort', + 'MessageReference': { + 'MessageCategory': 'Payment', + 'SaleID': header.SaleID, + 'ServiceID': previous_service_id, + } + }, + } + }; + + return this._call_adyen(data).then(function (data) { + + // Only valid response is a 200 OK HTTP response which is + // represented by true. + if (! ignore_error && data !== true) { + self._show_error(_('Cancelling the payment failed. Please cancel it manually on the payment terminal.')); + } + }); + }, + + _convert_receipt_info: function (output_text) { + return output_text.reduce(function (acc, entry) { + var params = new URLSearchParams(entry.Text); + + if (params.get('name') && !params.get('value')) { + return acc + _.str.sprintf('<br/>%s', params.get('name')); + } else if (params.get('name') && params.get('value')) { + return acc + _.str.sprintf('<br/>%s: %s', params.get('name'), params.get('value')); + } + + return acc; + }, ''); + }, + + _poll_for_response: function (resolve, reject) { + var self = this; + if (this.was_cancelled) { + resolve(false); + return Promise.resolve(); + } + + return rpc.query({ + model: 'pos.payment.method', + method: 'get_latest_adyen_status', + args: [this.payment_method.id, + this._adyen_get_sale_id(), + this.payment_method.adyen_terminal_identifier, + this.payment_method.adyen_test_mode, + this.payment_method.adyen_api_key], + }, { + timeout: 5000, + shadow: true, + }).catch(function (data) { + reject(); + return self._handle_odoo_connection_failure(data); + }).then(function (status) { + var notification = status.latest_response; + var last_diagnosis_service_id = status.last_received_diagnosis_id; + var order = self.pos.get_order(); + var line = order.selected_paymentline; + + + if (self.last_diagnosis_service_id != last_diagnosis_service_id) { + self.last_diagnosis_service_id = last_diagnosis_service_id; + self.remaining_polls = 2; + } else { + self.remaining_polls--; + } + + if (notification && notification.SaleToPOIResponse.MessageHeader.ServiceID == self.most_recent_service_id) { + var response = notification.SaleToPOIResponse.PaymentResponse.Response; + var additional_response = new URLSearchParams(response.AdditionalResponse); + + if (response.Result == 'Success') { + var config = self.pos.config; + var payment_response = notification.SaleToPOIResponse.PaymentResponse; + var payment_result = payment_response.PaymentResult; + var customer_receipt = payment_response.PaymentReceipt.find(function (receipt) { + return receipt.DocumentQualifier == 'CustomerReceipt'; + }); + + if (customer_receipt) { + line.set_receipt_info(self._convert_receipt_info(customer_receipt.OutputContent.OutputText)); + } + + var tip_amount = payment_result.AmountsResp.TipAmount; + if (config.adyen_ask_customer_for_tip && tip_amount > 0) { + order.set_tip(tip_amount); + line.set_amount(payment_result.AmountsResp.AuthorizedAmount); + } + + line.transaction_id = additional_response.get('pspReference'); + line.card_type = additional_response.get('cardType'); + resolve(true); + } else { + var message = additional_response.get('message'); + self._show_error(_.str.sprintf(_t('Message from Adyen: %s'), message)); + + // this means the transaction was cancelled by pressing the cancel button on the device + if (message.startsWith('108 ')) { + resolve(false); + } else { + line.set_payment_status('force_done'); + reject(); + } + } + } else if (self.remaining_polls <= 0) { + self._show_error(_t('The connection to your payment terminal failed. Please check if it is still connected to the internet.')); + self._adyen_cancel(); + resolve(false); + } + }); + }, + + _adyen_handle_response: function (response) { + var line = this.pos.get_order().selected_paymentline; + + if (response.error && response.error.status_code == 401) { + this._show_error(_t('Authentication failed. Please check your Adyen credentials.')); + line.set_payment_status('force_done'); + return Promise.resolve(); + } + + response = response.SaleToPOIRequest; + if (response && response.EventNotification && response.EventNotification.EventToNotify == 'Reject') { + console.error('error from Adyen', response); + + var msg = ''; + if (response.EventNotification) { + var params = new URLSearchParams(response.EventNotification.EventDetails); + msg = params.get('message'); + } + + this._show_error(_.str.sprintf(_t('An unexpected error occured. Message from Adyen: %s'), msg)); + if (line) { + line.set_payment_status('force_done'); + } + + return Promise.resolve(); + } else { + line.set_payment_status('waitingCard'); + + // This is not great, the payment screen should be + // refactored so it calls render_paymentlines whenever a + // paymentline changes. This way the call to + // set_payment_status would re-render it automatically. + this.pos.chrome.gui.current_screen.render_paymentlines(); + + var self = this; + var res = new Promise(function (resolve, reject) { + // clear previous intervals just in case, otherwise + // it'll run forever + clearTimeout(self.polling); + + self.polling = setInterval(function () { + self._poll_for_response(resolve, reject); + }, 3000); + }); + + // make sure to stop polling when we're done + res.finally(function () { + self._reset_state(); + }); + + return res; + } + }, + + _show_error: function (msg, title) { + if (!title) { + title = _t('Adyen Error'); + } + this.pos.gui.show_popup('error',{ + 'title': title, + 'body': msg, + }); + }, +}); + +return PaymentAdyen; +}); diff --git a/addons/pos_adyen/views/point_of_sale_assets.xml b/addons/pos_adyen/views/point_of_sale_assets.xml new file mode 100644 index 00000000000..bd738dcfccc --- /dev/null +++ b/addons/pos_adyen/views/point_of_sale_assets.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <template id="assets" inherit_id="point_of_sale.assets"> + <xpath expr="." position="inside"> + <script type="text/javascript" src="/pos_adyen/static/src/js/payment_adyen.js"></script> + <script type="text/javascript" src="/pos_adyen/static/src/js/models.js"></script> + </xpath> + </template> +</odoo> diff --git a/addons/pos_adyen/views/pos_config_views.xml b/addons/pos_adyen/views/pos_config_views.xml new file mode 100644 index 00000000000..72d6b395bda --- /dev/null +++ b/addons/pos_adyen/views/pos_config_views.xml @@ -0,0 +1,26 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="pos_config_view_form" model="ir.ui.view"> + <field name="name">pos.config.form.view.inherit.pos_adyen</field> + <field name="model">pos.config</field> + <field name="inherit_id" ref="point_of_sale.pos_config_view_form"/> + <field name="arch" type="xml"> + <xpath expr="//div[@id='payment_methods_new']/.." position="after"> + <h2>Adyen</h2> + <div class="row mt16 o_settings_container" id="adyen"> + <div class="col-12 col-lg-6 o_setting_box"> + <div class="o_setting_left_pane"> + <field name="adyen_ask_customer_for_tip"/> + </div> + <div class="o_setting_right_pane"> + <label for="adyen_ask_customer_for_tip"/> + <div class="text-muted"> + Ask customers to tip before paying. + </div> + </div> + </div> + </div> + </xpath> + </field> + </record> +</odoo> diff --git a/addons/pos_adyen/views/pos_payment_method_views.xml b/addons/pos_adyen/views/pos_payment_method_views.xml new file mode 100644 index 00000000000..50aa6b39cb0 --- /dev/null +++ b/addons/pos_adyen/views/pos_payment_method_views.xml @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<odoo> + <record id="pos_payment_method_view_form_inherit_pos_adyen" model="ir.ui.view"> + <field name="name">pos.payment.method.form.inherit.adyen</field> + <field name="model">pos.payment.method</field> + <field name="inherit_id" ref="point_of_sale.pos_payment_method_view_form"/> + <field name="arch" type="xml"> + <group name="Payment methods" position="inside"> + <group> + <field name="adyen_api_key" + attrs="{'invisible': [('use_payment_terminal', '!=', 'adyen')], 'required': [('use_payment_terminal', '=', 'adyen')]}"/> + <field name="adyen_terminal_identifier" attrs="{'invisible': [('use_payment_terminal', '!=', 'adyen')], 'required': [('use_payment_terminal', '=', 'adyen')]}"/> + <field name="adyen_test_mode" attrs="{'invisible': [('use_payment_terminal', '!=', 'adyen')]}"/> + </group> + </group> + </field> + </record> +</odoo> diff --git a/addons/pos_restaurant/models/pos_config.py b/addons/pos_restaurant/models/pos_config.py index 0dfe375ed9b..20d37ec1338 100644 --- a/addons/pos_restaurant/models/pos_config.py +++ b/addons/pos_restaurant/models/pos_config.py @@ -16,19 +16,11 @@ class PosConfig(models.Model): is_order_printer = fields.Boolean('Order Printer') module_pos_restaurant = fields.Boolean(default=True) - @api.onchange('iface_tipproduct') - def _onchange_tipproduct(self): - if self.iface_tipproduct: - self.tip_product_id = self.env.ref('point_of_sale.product_product_tip', False) - else: - self.tip_product_id = False - @api.onchange('module_pos_restaurant') def _onchange_module_pos_restaurant(self): if not self.module_pos_restaurant: self.update({'iface_printbill': False, 'iface_splitbill': False, - 'iface_tipproduct': False, 'is_order_printer': False, 'is_table_management': False, 'iface_orderline_notes': False}) diff --git a/addons/pos_restaurant/views/pos_config_views.xml b/addons/pos_restaurant/views/pos_config_views.xml index 124b60cff53..77249d6647d 100644 --- a/addons/pos_restaurant/views/pos_config_views.xml +++ b/addons/pos_restaurant/views/pos_config_views.xml @@ -36,29 +36,6 @@ </div> </div> </div> - <div class="col-12 col-lg-6 o_setting_box" - id="iface_tipproduct" - title="This product is used as reference on customer receipts." - attrs="{'invisible': [('module_pos_restaurant', '=', False)]}"> - <div class="o_setting_left_pane"> - <field name="iface_tipproduct"/> - </div> - <div class="o_setting_right_pane"> - <label for="tip_product_id" string="Tips"/> - <span class="fa fa-lg fa-cutlery" title="For bars and restaurants" role="img" aria-label="For bars and restaurants"/> - <div class="text-muted"> - Accept customer tips or convert their change to a tip - </div> - <div class="content-group" attrs="{'invisible': [('iface_tipproduct', '=', False)]}"> - <div class="mt16"> - <label string="Tip Product" for="tip_product_id" class="o_light_label"/> - <field name="tip_product_id" - domain="[('available_in_pos', '=', True)]" - context="{'default_available_in_pos': True}"/> - </div> - </div> - </div> - </div> </div> <div id="barcode_scanner" position="after"> <div class="col-12 col-lg-6 o_setting_box" |
