[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>
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 */
|
||||
|
||||
|
||||
@@ -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(){
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" >
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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',
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# coding: utf-8
|
||||
from . import main
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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})
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user