diff --git a/addons/point_of_sale/models/pos_order.py b/addons/point_of_sale/models/pos_order.py index fb485cf16d9..0cd7aa4f04d 100644 --- a/addons/point_of_sale/models/pos_order.py +++ b/addons/point_of_sale/models/pos_order.py @@ -148,23 +148,29 @@ class PosOrder(models.Model): pos_order = pos_order.with_company(pos_order.company_id) self = self.with_company(pos_order.company_id) self._process_payment_lines(order, pos_order, pos_session, draft) + return pos_order._process_saved_order(draft) + def _process_saved_order(self, draft): + self.ensure_one() if not draft: try: - pos_order.action_pos_order_paid() + self.action_pos_order_paid() except psycopg2.DatabaseError: # do not hide transactional errors, the order(s) won't be saved! raise except Exception as e: _logger.error('Could not fully process the POS Order: %s', tools.ustr(e)) - pos_order._create_order_picking() - pos_order._compute_total_cost_in_real_time() + self._create_order_picking() + self._compute_total_cost_in_real_time() - if pos_order.to_invoice and pos_order.state == 'paid': - pos_order._generate_pos_order_invoice() + if self.to_invoice and self.state == 'paid': + self._generate_pos_order_invoice() - return pos_order.id + return self.id + def _clean_payment_lines(self): + self.ensure_one() + self.payment_ids.unlink() def _process_payment_lines(self, pos_order, order, pos_session, draft): """Create account.bank.statement.lines from the dictionary given to the parent function. @@ -182,8 +188,7 @@ class PosOrder(models.Model): """ prec_acc = order.currency_id.decimal_places - order_bank_statement_lines= self.env['pos.payment'].search([('pos_order_id', '=', order.id)]) - order_bank_statement_lines.unlink() + order._clean_payment_lines() for payments in pos_order['statement_ids']: order.add_payment(self._payment_fields(order, payments[2])) @@ -494,7 +499,10 @@ class PosOrder(models.Model): return float_is_zero(self._get_rounded_amount(self.amount_total) - self.amount_paid, precision_rounding=self.currency_id.rounding) def _get_rounded_amount(self, amount): - if self.config_id.cash_rounding: + # TODO: add support for mix of cash and non-cash payments when both cash_rounding and only_round_cash_method are True + if self.config_id.cash_rounding \ + and (not self.config_id.only_round_cash_method \ + or any(p.payment_method_id.is_cash_count for p in self.payment_ids)): amount = float_round(amount, precision_rounding=self.config_id.rounding_method.rounding, rounding_method=self.config_id.rounding_method.rounding_method) currency = self.currency_id return currency.round(amount) if currency else amount diff --git a/addons/point_of_sale/models/pos_session.py b/addons/point_of_sale/models/pos_session.py index 6db8ed7a5b9..11b048a5384 100644 --- a/addons/point_of_sale/models/pos_session.py +++ b/addons/point_of_sale/models/pos_session.py @@ -1761,7 +1761,6 @@ class PosSession(models.Model): def _ensure_access_token(self): # Code taken from addons/portal/models/portal_mixin.py if not self.access_token: - # we use a `write` to force the cache clearing otherwise `return self.access_token` will return False self.sudo().write({'access_token': secrets.token_hex(16)}) return self.access_token diff --git a/addons/point_of_sale/static/src/app/customer_display/customer_display_template.xml b/addons/point_of_sale/static/src/app/customer_display/customer_display_template.xml index 2005dbb100b..005dc2140c1 100644 --- a/addons/point_of_sale/static/src/app/customer_display/customer_display_template.xml +++ b/addons/point_of_sale/static/src/app/customer_display/customer_display_template.xml @@ -54,7 +54,7 @@
- +
@@ -87,71 +87,11 @@
- +
- -
- Your Order -
-
- - - - - - - - - - - - - - - - -
- Product - QuantityPrice
- - -
    -
  • - -
  • - - % discount -
  • - - -
  • - - -
  • -
    -
-
- - - -
-
-
- -
- -
- -

This order is empty

-
-
+
@@ -162,6 +102,70 @@
+ + +
+ Your Order +
+
+ + + + + + + + + + + + + + + + +
+ Product + QuantityPrice
+ + +
    +
  • + +
  • + + % discount +
  • + + +
  • + + +
  • +
    +
+
+ + + +
+
+
+ +
+ +
+ +

This order is empty

+
+
+
+
diff --git a/addons/point_of_sale/static/src/app/screens/payment_screen/payment_screen.js b/addons/point_of_sale/static/src/app/screens/payment_screen/payment_screen.js index 5d4fd38737a..109ab599a26 100644 --- a/addons/point_of_sale/static/src/app/screens/payment_screen/payment_screen.js +++ b/addons/point_of_sale/static/src/app/screens/payment_screen/payment_screen.js @@ -270,26 +270,32 @@ export class PaymentScreen extends Component { } // 3. Post process. - if (syncOrderResult.length && this.currentOrder.wait_for_push_order()) { - const postPushResult = await this._postPushOrderResolve( - this.currentOrder, - syncOrderResult.map((res) => res.id) - ); - if (!postPushResult) { - this.popup.add(ErrorPopup, { - title: this.env._t("Error: no internet connection."), - body: this.env._t( - "Some, if not all, post-processing after syncing order failed." - ), - }); - } + if ( + syncOrderResult && + syncOrderResult.length > 0 && + this.currentOrder.wait_for_push_order() + ) { + await this.postPushOrderResolve(syncOrderResult.map((res) => res.id)); } + this.afterOrderValidation(!!syncOrderResult && syncOrderResult.length > 0); + } + async postPushOrderResolve(ordersServerId) { + const postPushResult = await this._postPushOrderResolve(this.currentOrder, ordersServerId); + if (!postPushResult) { + this.popup.add(ErrorPopup, { + title: this.env._t("Error: no internet connection."), + body: this.env._t("Some, if not all, post-processing after syncing order failed."), + }); + } + } + async afterOrderValidation(suggestToSync = true) { // Remove the order from the local storage so that when we refresh the page, the order // won't be there this.pos.db.remove_unpaid_order(this.currentOrder); + // Ask the user to sync the remaining unsynced orders. - if (syncOrderResult && this.pos.db.get_orders().length) { + if (suggestToSync && this.pos.db.get_orders().length) { const { confirmed } = await this.popup.add(ConfirmPopup, { title: this.env._t("Remaining unsynced orders"), body: this.env._t("There are unsynced orders. Do you want to sync these orders?"), @@ -301,6 +307,8 @@ export class PaymentScreen extends Component { this.pos.push_orders(); } } + // Always show the next screen regardless of error since pos has to + // continue working even offline. this.pos.showScreen(this.nextScreen); } get nextScreen() { diff --git a/addons/point_of_sale/static/src/app/store/models.js b/addons/point_of_sale/static/src/app/store/models.js index e0bfebbdaf7..066eb51595e 100644 --- a/addons/point_of_sale/static/src/app/store/models.js +++ b/addons/point_of_sale/static/src/app/store/models.js @@ -1431,7 +1431,10 @@ export class Order extends PosModel { }); paymentLines = []; this.paymentlines.forEach((item) => { - return paymentLines.push([0, 0, item.export_as_JSON()]); + const itemAsJson = item.export_as_JSON(); + if (itemAsJson) { + return paymentLines.push([0, 0, itemAsJson]); + } }); var json = { name: this.get_name(), @@ -2607,17 +2610,19 @@ export class Order extends PosModel { } _get_qr_code_data() { if (this.pos.company.point_of_sale_use_ticket_qr_code) { - const codeWriter = new window.ZXing.BrowserQRCodeSvgWriter(); // Use the unique access token to ensure the authenticity of the request. Use the order reference as a second check just in case. - const address = `${this.pos.base_url}/pos/ticket/validate?access_token=${this.access_token}`; - const qr_code_svg = new XMLSerializer().serializeToString( - codeWriter.write(address, 150, 150) + return this._make_qr_code_data( + `${this.pos.base_url}/pos/ticket/validate?access_token=${this.access_token}` ); - return "data:image/svg+xml;base64," + window.btoa(qr_code_svg); } else { return false; } } + _make_qr_code_data(url) { + const codeWriter = new window.ZXing.BrowserQRCodeSvgWriter(); + const qr_code_svg = new XMLSerializer().serializeToString(codeWriter.write(url, 150, 150)); + return "data:image/svg+xml;base64," + window.btoa(qr_code_svg); + } /** * Returns a random 5 digits alphanumeric code * @returns {string} diff --git a/addons/point_of_sale/static/tests/tours/PaymentScreen.tour.js b/addons/point_of_sale/static/tests/tours/PaymentScreen.tour.js index e4c2ad5e10b..3e3a88a231b 100644 --- a/addons/point_of_sale/static/tests/tours/PaymentScreen.tour.js +++ b/addons/point_of_sale/static/tests/tours/PaymentScreen.tour.js @@ -25,7 +25,6 @@ PaymentScreen.check.validateButtonIsHighlighted(false); PaymentScreen.do.pressNumpad("Backspace Backspace"); PaymentScreen.do.fillPaymentLineAmountMobile("Cash", "0"); PaymentScreen.check.selectedPaymentlineHas("Cash", "0.00"); -PaymentScreen.do.pressNumpad("Backspace"); PaymentScreen.do.clickPaymentlineDelButton("Cash", "0", true); PaymentScreen.check.emptyPaymentlines("52.8"); @@ -190,16 +189,16 @@ Chrome.do.clickMenuButton(); Chrome.do.clickTicketButton(); TicketScreen.do.clickNewTicket(); -ProductScreen.exec.addOrderline('Product Test 1.2', '1'); +ProductScreen.exec.addOrderline("Product Test 1.2", "1"); ProductScreen.do.clickPayButton(); -PaymentScreen.check.totalIs('1.00'); -PaymentScreen.do.clickPaymentMethod('Cash'); -PaymentScreen.do.pressNumpad('2'); -PaymentScreen.do.fillPaymentLineAmountMobile('Cash', '2'); +PaymentScreen.check.totalIs("1.00"); +PaymentScreen.do.clickPaymentMethod("Cash"); +PaymentScreen.do.pressNumpad("2"); +PaymentScreen.do.fillPaymentLineAmountMobile("Cash", "2"); -PaymentScreen.check.remainingIs('0.0'); -PaymentScreen.check.changeIs('1.0'); +PaymentScreen.check.remainingIs("0.0"); +PaymentScreen.check.changeIs("1.0"); registry .category("web_tour.tours") @@ -209,20 +208,20 @@ startSteps(); ProductScreen.do.confirmOpeningPopup(); ProductScreen.do.clickHomeCategory(); -ProductScreen.exec.addOrderline('Product Test 40', '1'); +ProductScreen.exec.addOrderline("Product Test 40", "1"); ProductScreen.do.clickPartnerButton(); -ProductScreen.do.clickCustomer('Nicole Ford'); +ProductScreen.do.clickCustomer("Nicole Ford"); ProductScreen.do.clickPayButton(); -PaymentScreen.check.totalIs('40.00'); -PaymentScreen.do.clickPaymentMethod('Bank'); -PaymentScreen.do.pressNumpad('3 8'); -PaymentScreen.do.fillPaymentLineAmountMobile('Bank', '38'); -PaymentScreen.check.remainingIs('2.0'); -PaymentScreen.do.clickPaymentMethod('Cash'); +PaymentScreen.check.totalIs("40.00"); +PaymentScreen.do.clickPaymentMethod("Bank"); +PaymentScreen.do.pressNumpad("3 8"); +PaymentScreen.do.fillPaymentLineAmountMobile("Bank", "38"); +PaymentScreen.check.remainingIs("2.0"); +PaymentScreen.do.clickPaymentMethod("Cash"); -PaymentScreen.check.remainingIs('0.0'); -PaymentScreen.check.changeIs('0.0'); +PaymentScreen.check.remainingIs("0.0"); +PaymentScreen.check.changeIs("0.0"); PaymentScreen.do.clickInvoiceButton(); PaymentScreen.do.clickValidate(); @@ -230,29 +229,30 @@ ReceiptScreen.check.receiptIsThere(); ReceiptScreen.do.clickNextOrder(); ProductScreen.do.clickHomeCategory(); -ProductScreen.exec.addOrderline('Product Test 41', '1'); +ProductScreen.exec.addOrderline("Product Test 41", "1"); ProductScreen.do.clickPartnerButton(); -ProductScreen.do.clickCustomer('Nicole Ford'); +ProductScreen.do.clickCustomer("Nicole Ford"); ProductScreen.do.clickPayButton(); -PaymentScreen.check.totalIs('41.00'); -PaymentScreen.do.clickPaymentMethod('Bank'); -PaymentScreen.do.pressNumpad('3 8'); -PaymentScreen.do.fillPaymentLineAmountMobile('Bank', '38'); -PaymentScreen.check.remainingIs('3.0'); -PaymentScreen.do.clickPaymentMethod('Cash'); +PaymentScreen.check.totalIs("41.00"); +PaymentScreen.do.clickPaymentMethod("Bank"); +PaymentScreen.do.pressNumpad("3 8"); +PaymentScreen.do.fillPaymentLineAmountMobile("Bank", "38"); +PaymentScreen.check.remainingIs("3.0"); +PaymentScreen.do.clickPaymentMethod("Cash"); -PaymentScreen.check.remainingIs('0.0'); -PaymentScreen.check.changeIs('0.0'); +PaymentScreen.check.remainingIs("0.0"); +PaymentScreen.check.changeIs("0.0"); PaymentScreen.do.clickInvoiceButton(); PaymentScreen.do.clickValidate(); ReceiptScreen.check.receiptIsThere(); - -registry - .category("web_tour.tours") - .add("PaymentScreenRoundingHalfUpCashAndBank", { test: true, url: "/pos/ui", steps: getSteps() }); +registry.category("web_tour.tours").add("PaymentScreenRoundingHalfUpCashAndBank", { + test: true, + url: "/pos/ui", + steps: getSteps(), +}); startSteps(); diff --git a/addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js index 6e28cc8e54e..7f7a770d68d 100644 --- a/addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js +++ b/addons/point_of_sale/static/tests/tours/helpers/ChromeTourMethods.js @@ -27,7 +27,25 @@ class Do { { content: "Click on the menu button", trigger: ".menu-button", - } + }, + ]; + } + closeSession() { + return [ + ...this.clickMenuButton(), + { + content: "click on the close session menu button", + trigger: ".close-button", + }, + { + content: "click on the close session popup button", + trigger: ".close-pos-popup .footer .button.highlight", + }, + { + content: "check that the session is closed without error", + trigger: ".o_web_client", + isCheck: true, + }, ]; } } diff --git a/addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js b/addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js index 2575ab03ddf..2c106a9f0f4 100644 --- a/addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js +++ b/addons/point_of_sale/static/tests/tours/helpers/PaymentScreenTourMethods.js @@ -17,20 +17,25 @@ class Do { * @param {String} name payment method * @param {String} amount */ - clickPaymentlineDelButton(name, amount, mobile=false) { - if (mobile) { - return [ - { - content: `delete ${name} paymentline with ${amount} amount`, - trigger: `.paymentlines .paymentline .payment-infos:contains("${name}") ~ .delete-button`, - mobile: true, - }, - ]; - } + clickPaymentlineDelButton(name, amount, mobile = false) { return [ { content: `delete ${name} paymentline with ${amount} amount`, - trigger: `.paymentlines .paymentline .payment-infos:contains("${name}") ~ .delete-button`, + trigger: `.paymentlines .paymentline .payment-infos:contains("${name}"):has(.payment-amount:contains("${amount}")) ~ .delete-button`, + }, + ]; + } + + /** + * Click the paymentline having the given payment method name and amount. + * @param {String} name payment method + * @param {String} amount + */ + clickPaymentline(name, amount) { + return [ + { + content: `click ${name} paymentline with ${amount} amount`, + trigger: `.paymentlines .paymentline .payment-infos:contains("${name}"):has(.payment-amount:contains("${amount}"))`, }, ]; } @@ -69,8 +74,8 @@ class Do { * fast inputs. Fast inputs is the case in tours. This method is only for the * desktop environment. The mobile environment doesn't work exactly the same way * so we have to call fillPaymentLineAmountMobile to have the same behaviour. - * - * e.g. : + * + * e.g. : * PaymentScreen.do.enterPaymentLineAmount("Cash", "70"); * PaymentScreen.check.remainingIs("2.0"); * PaymentScreen.do.pressNumpad("0"); <- desktop: add a 0 @@ -98,7 +103,7 @@ class Do { mobile: false, }; } - return keys.split(' ').map(generateStep); + return keys.split(" ").map(generateStep); } clickBack() { @@ -119,10 +124,10 @@ class Do { } enterPaymentLineAmount(lineName, keys) { - const numpadKeys = keys.split('').join(' '); + const numpadKeys = keys.split("").join(" "); return [ ...this.pressNumpad(numpadKeys), - ...this.fillPaymentLineAmountMobile(lineName, keys) + ...this.fillPaymentLineAmountMobile(lineName, keys), ]; } @@ -143,7 +148,7 @@ class Do { content: "click confirm button", trigger: ".popup .footer .confirm", mobile: true, - } + }, ]; } } 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 ebe6e30974e..1b0249721aa 100644 --- a/addons/point_of_sale/views/pos_payment_method_views.xml +++ b/addons/point_of_sale/views/pos_payment_method_views.xml @@ -23,7 +23,7 @@ - +

Tips:

diff --git a/addons/pos_adyen/static/src/js/models.js b/addons/pos_adyen/static/src/js/models.js index 8cff0f1c6d4..95f07a7bf7f 100644 --- a/addons/pos_adyen/static/src/js/models.js +++ b/addons/pos_adyen/static/src/js/models.js @@ -14,7 +14,9 @@ patch(Payment.prototype, "pos_adyen.Payment", { //@override export_as_JSON() { const json = this._super(...arguments); - json.terminal_service_id = this.terminalServiceId; + if (json) { + json.terminal_service_id = this.terminalServiceId; + } return json; }, //@override diff --git a/addons/pos_hr/static/src/js/PaymentScreen.js b/addons/pos_hr/static/src/js/PaymentScreen.js index 8328c3b009a..52a484f3956 100644 --- a/addons/pos_hr/static/src/js/PaymentScreen.js +++ b/addons/pos_hr/static/src/js/PaymentScreen.js @@ -4,7 +4,7 @@ import { PaymentScreen } from "@point_of_sale/app/screens/payment_screen/payment import { patch } from "@web/core/utils/patch"; patch(PaymentScreen.prototype, "pos_hr.PaymentScreen", { - async _finalizeValidation() { + async validateOrder(isForceValidate) { this.currentOrder.cashier = this.pos.get_cashier(); await this._super(...arguments); }, diff --git a/addons/pos_mercury/static/src/js/pos_mercury.js b/addons/pos_mercury/static/src/js/pos_mercury.js index d6c74f3c0de..c539a0d268f 100644 --- a/addons/pos_mercury/static/src/js/pos_mercury.js +++ b/addons/pos_mercury/static/src/js/pos_mercury.js @@ -103,7 +103,11 @@ patch(Payment.prototype, "pos_mercury.Payment", { this.set_credit_card_name(); }, export_as_JSON() { - return Object.assign(this._super(...arguments), { + const result = this._super(...arguments); + if (!result) { + return result; + } + return Object.assign(result, { paid: this.paid, mercury_card_number: this.mercury_card_number, mercury_card_brand: this.mercury_card_brand, diff --git a/addons/pos_online_payment/__init__.py b/addons/pos_online_payment/__init__.py new file mode 100644 index 00000000000..7d34c7c054a --- /dev/null +++ b/addons/pos_online_payment/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers +from . import models diff --git a/addons/pos_online_payment/__manifest__.py b/addons/pos_online_payment/__manifest__.py new file mode 100644 index 00000000000..ed4df2632f5 --- /dev/null +++ b/addons/pos_online_payment/__manifest__.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + + +{ + 'name': 'Point of Sale online payment', + 'depends': ['point_of_sale', 'account_payment'], + 'data': [ + 'views/payment_transaction_views.xml', + 'views/pos_payment_views.xml', + 'views/pos_payment_method_views.xml', + 'views/payment_portal_templates.xml', + 'views/account_payment_views.xml', + ], + 'auto_install': True, + 'installable': True, + 'assets': { + 'point_of_sale.assets_prod': [ + 'pos_online_payment/static/src/app/**/*', + 'pos_online_payment/static/src/css/**/*', + ], + 'web.assets_tests': [ + 'pos_online_payment/static/tests/tours/**/*', + ], + }, + 'license': 'LGPL-3', +} diff --git a/addons/pos_online_payment/controllers/__init__.py b/addons/pos_online_payment/controllers/__init__.py new file mode 100644 index 00000000000..38f2f6a7f0f --- /dev/null +++ b/addons/pos_online_payment/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import payment_portal diff --git a/addons/pos_online_payment/controllers/payment_portal.py b/addons/pos_online_payment/controllers/payment_portal.py new file mode 100644 index 00000000000..03e4e8b45e6 --- /dev/null +++ b/addons/pos_online_payment/controllers/payment_portal.py @@ -0,0 +1,291 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from werkzeug.urls import url_encode + +from odoo import _, http, tools +from odoo.http import request +from odoo.exceptions import AccessError, ValidationError, UserError +from odoo.addons.payment.controllers.post_processing import PaymentPostProcessing +from odoo.addons.payment.controllers import portal as payment_portal + + +class PaymentPortal(payment_portal.PaymentPortal): + + def _check_order_access(self, pos_order_id, access_token): + try: + order_sudo = self._document_check_access( + 'pos.order', pos_order_id, access_token) + except: + raise AccessError( + _("The provided order or access token is invalid.")) + + if order_sudo.state == "cancel": + raise ValidationError(_("The order has been canceled.")) + return order_sudo + + @staticmethod + def _ensure_session_open(pos_order_sudo): + if pos_order_sudo.session_id.state != 'opened': + raise AccessError(_("The POS session is not opened.")) + + def _get_partner_sudo(self, user_sudo): + partner_sudo = user_sudo.partner_id + if not partner_sudo and user_sudo._is_public(): + partner_sudo = self.env.ref('base.public_user') + return partner_sudo + + def _redirect_login(self): + return request.redirect('/web/login?' + url_encode({'redirect': request.httprequest.full_path})) + + @staticmethod + def _get_amount_to_pay(order_to_pay_sudo): + if order_to_pay_sudo.state in ('paid', 'done', 'invoiced'): + return 0.0 + amount = order_to_pay_sudo._get_checked_next_online_payment_amount() + if amount and PaymentPortal._is_valid_amount(amount, order_to_pay_sudo.currency_id): + return amount + else: + return order_to_pay_sudo.get_amount_unpaid() + + @staticmethod + def _is_valid_amount(amount, currency): + return isinstance(amount, float) and tools.float_compare(amount, 0.0, precision_rounding=currency.rounding) > 0 + + def _get_allowed_providers_sudo(self, pos_order_sudo, partner_id, amount_to_pay): + payment_method = pos_order_sudo.online_payment_method_id + if not payment_method: + raise UserError(_("There is no online payment method configured for this Point of Sale order.")) + compatible_providers_sudo = request.env['payment.provider'].sudo()._get_compatible_providers( + pos_order_sudo.company_id.id, partner_id, amount_to_pay, currency_id=pos_order_sudo.currency_id.id) + # In sudo mode to read the fields of providers and partner (if not logged in) + # Return the payment providers configured in the pos.payment.method that are compatible for the payment API + return compatible_providers_sudo & payment_method._get_online_payment_providers(pos_order_sudo.config_id.id, error_if_invalid=False) + + @staticmethod + def _get_exit_route_arg(exit_route): + return '&' + url_encode({'exit_route': exit_route}) if exit_route else '' + + @staticmethod + def _get_pay_route(pos_order_id, access_token, exit_route=None): + exit_route_arg = PaymentPortal._get_exit_route_arg(exit_route) + return f'/pos/pay/{pos_order_id}?access_token={access_token}{exit_route_arg}' + + @staticmethod + def _get_landing_route(pos_order_id, access_token, exit_route_arg=None, tx_id=None): + tx_id_arg = f'&tx_id={tx_id}' if tx_id else '' + return f'/pos/pay/confirmation/{pos_order_id}?access_token={access_token}{exit_route_arg}{tx_id_arg}' + + @http.route('/pos/pay/', type='http', methods=['GET'], auth='public', website=True, sitemap=False) + def pos_order_pay(self, pos_order_id, access_token=None, exit_route=None): + """ Behaves like payment.PaymentPortal.payment_pay but for POS online payment. + + :param int pos_order_id: The POS order to pay, as a `pos.order` id + :param str access_token: The access token used to verify the user + :param str exit_route: The URL to open to leave the POS online payment flow + + :return: The rendered payment form + :rtype: str + :raise: AccessError if the provided order or access token is invalid + :raise: ValidationError if data on the server prevents the payment + """ + pos_order_sudo = self._check_order_access(pos_order_id, access_token) + self._ensure_session_open(pos_order_sudo) + + user_sudo = request.env.user + logged_in = not user_sudo._is_public() + partner_sudo = self._get_partner_sudo(user_sudo) + if not partner_sudo: + return self._redirect_login() + + kwargs = { + 'pos_order_id': pos_order_sudo.id, + } + exit_route_arg = self._get_exit_route_arg(exit_route) + rendering_context = { + **kwargs, + 'exit_route': exit_route, + 'reference_prefix': request.env['payment.transaction'].sudo()._compute_reference_prefix(provider_code=None, separator='-', **kwargs), + 'partner_id': partner_sudo.id, + 'access_token': access_token, + 'transaction_route': f'/pos/pay/transaction/{pos_order_sudo.id}?access_token={access_token}{exit_route_arg}', + 'landing_route': self._get_landing_route(pos_order_sudo.id, access_token, exit_route_arg=exit_route_arg), + **self._get_custom_rendering_context_values(**kwargs), + } + + currency_id = pos_order_sudo.currency_id + + if not currency_id.active: + rendering_context['currency'] = False + return self._render_pay(rendering_context) + rendering_context['currency'] = currency_id + + amount_to_pay = self._get_amount_to_pay(pos_order_sudo) + if not self._is_valid_amount(amount_to_pay, currency_id): + rendering_context['amount'] = False + return self._render_pay(rendering_context) + rendering_context['amount'] = amount_to_pay + + providers_sudo = self._get_allowed_providers_sudo(pos_order_sudo, partner_sudo.id, amount_to_pay) + + # Compute the fees taken by providers supporting the feature + fees_by_provider = { + provider_sudo: provider_sudo._compute_fees( + amount_to_pay, currency_id, partner_sudo.country_id) + for provider_sudo in providers_sudo.filtered('fees_active') + } + + if logged_in: + tokens_sudo = request.env['payment.token']._get_available_tokens( + providers_sudo.ids, partner_sudo.id) + show_tokenize_input = self._compute_show_tokenize_input_mapping( + providers_sudo, **kwargs) + else: + tokens_sudo = False + show_tokenize_input = dict.fromkeys(providers_sudo.ids, False) + + rendering_context.update({ + 'providers': providers_sudo, + 'tokens': tokens_sudo, + 'fees_by_provider': fees_by_provider, + 'show_tokenize_input': show_tokenize_input, + **self._get_custom_rendering_context_values(**kwargs), + }) + return self._render_pay(rendering_context) + + def _render_pay(self, rendering_context): + return request.render('pos_online_payment.pay', rendering_context) + + @http.route('/pos/pay/transaction/', type='json', auth='public', website=True, sitemap=False) + def pos_order_pay_transaction(self, pos_order_id, access_token=None, **kwargs): + """ Behaves like payment.PaymentPortal.payment_transaction but for POS online payment. + + :param int pos_order_id: The POS order to pay, as a `pos.order` id + :param str access_token: The access token used to verify the user + :param str exit_route: The URL to open to leave the POS online payment flow + :param dict kwargs: Data from payment module + + :return: The mandatory values for the processing of the transaction + :rtype: dict + :raise: AccessError if the provided order or access token is invalid + :raise: ValidationError if data on the server prevents the payment + :raise: UserError if data provided by the user is invalid/missing + """ + pos_order_sudo = self._check_order_access(pos_order_id, access_token) + self._ensure_session_open(pos_order_sudo) + exit_route = request.httprequest.args.get('exit_route') + user_sudo = request.env.user + logged_in = not user_sudo._is_public() + partner_sudo = self._get_partner_sudo(user_sudo) + if not partner_sudo: + return self._redirect_login() + + if kwargs.get('is_validation'): + raise UserError( + _("A validation payment cannot be used for a Point of Sale online payment.")) + + if 'partner_id' in kwargs and kwargs['partner_id'] != partner_sudo.id: + raise UserError( + _("The provided partner_id is different than expected.")) + + # Don't allow passing arbitrary create values and avoid tokenization for + # the public user. + kwargs['custom_create_values'] = { + 'pos_order_id': pos_order_sudo.id + } + if not logged_in: + if kwargs.get('tokenization_requested') or kwargs.get('flow') == 'token': + raise UserError( + _("Tokenization is not available for logged off customers.")) + kwargs['custom_create_values']['tokenize'] = False + + currency_id = pos_order_sudo.currency_id + if not currency_id.active: + raise ValidationError(_("The currency is invalid.")) + # Ignore the currency provided by the customer + kwargs['currency_id'] = currency_id.id + + amount_to_pay = self._get_amount_to_pay(pos_order_sudo) + if not self._is_valid_amount(amount_to_pay, currency_id): + raise ValidationError(_("There is nothing to pay for this order.")) + if tools.float_compare(kwargs['amount'], amount_to_pay, precision_rounding=currency_id.rounding) != 0: + raise ValidationError( + _("The amount to pay has changed. Please refresh the page.")) + + payment_option_id = kwargs.get('payment_option_id', None) + if not payment_option_id: + raise UserError(_("A payment option must be specified.")) + flow = kwargs.get('flow') + if not (flow and flow in ['redirect', 'direct', 'token']): + raise UserError(_("The payment should either be direct, with redirection, or made by a token.")) + providers_sudo = self._get_allowed_providers_sudo(pos_order_sudo, partner_sudo.id, amount_to_pay) + if flow == 'token': + tokens_sudo = request.env['payment.token']._get_available_tokens( + providers_sudo.ids, partner_sudo.id) + if payment_option_id not in tokens_sudo.ids: + raise UserError(_("The payment token is invalid.")) + else: + if payment_option_id not in providers_sudo.ids: + raise UserError(_("The payment provider is invalid.")) + + kwargs['reference_prefix'] = None # Computed with pos_order_id + kwargs.pop('pos_order_id', None) # _create_transaction kwargs keys must be different than custom_create_values keys + + tx_sudo = self._create_transaction(**kwargs) + tx_sudo.landing_route = self._get_landing_route(pos_order_sudo.id, access_token, exit_route_arg=self._get_exit_route_arg(exit_route), tx_id=tx_sudo.id) + + return tx_sudo._get_processing_values() + + @http.route('/pos/pay/confirmation/', type='http', methods=['GET'], auth='public', website=True, sitemap=False) + def pos_order_pay_confirmation(self, pos_order_id, tx_id=None, access_token=None, exit_route=None, **kwargs): + """ Behaves like payment.PaymentPortal.payment_confirm but for POS online payment. + + :param int pos_order_id: The POS order to confirm, as a `pos.order` id + :param str tx_id: The transaction to confirm, as a `payment.transaction` id + :param str access_token: The access token used to verify the user + :param str exit_route: The URL to open to leave the POS online payment flow + :param dict kwargs: Data from payment module + + :return: The rendered confirmation page + :rtype: str + :raise: AccessError if the provided order or access token is invalid + """ + tx_id = self._cast_as_int(tx_id) + rendering_context = { + 'state': 'error', + 'exit_route': exit_route, + 'pay_route': self._get_pay_route(pos_order_id, access_token, exit_route) + } + if not tx_id or not pos_order_id: + return self._render_pay_confirmation(rendering_context) + + pos_order_sudo = self._check_order_access(pos_order_id, access_token) + + tx_sudo = request.env['payment.transaction'].sudo().search([('id', '=', tx_id)]) + if tx_sudo.pos_order_id.id != pos_order_sudo.id: + return self._render_pay_confirmation(rendering_context) + + rendering_context.update( + pos_order_id=pos_order_sudo.id, + order_reference=pos_order_sudo.pos_reference, + tx_reference=tx_sudo.reference, + amount=tx_sudo.amount, + currency=tx_sudo.currency_id, + provider_name=tx_sudo.provider_id.name, + tx=tx_sudo, # for the payment.transaction_status template + ) + + # Stop monitoring the transaction now that it reached a final state. + PaymentPostProcessing.remove_transactions(tx_sudo) + + if tx_sudo.state not in ('authorized', 'done'): + rendering_context['state'] = 'tx_error' + return self._render_pay_confirmation(rendering_context) + + tx_sudo._process_pos_online_payment() + + rendering_context['state'] = 'success' + return self._render_pay_confirmation(rendering_context) + + def _render_pay_confirmation(self, rendering_context): + return request.render('pos_online_payment.pay_confirmation', rendering_context) diff --git a/addons/pos_online_payment/models/__init__.py b/addons/pos_online_payment/models/__init__.py new file mode 100644 index 00000000000..b7b6a1abbd9 --- /dev/null +++ b/addons/pos_online_payment/models/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import pos_payment_method +from . import pos_payment +from . import account_payment +from . import payment_transaction +from . import pos_config +from . import pos_order +from . import pos_session diff --git a/addons/pos_online_payment/models/account_payment.py b/addons/pos_online_payment/models/account_payment.py new file mode 100644 index 00000000000..a5af8a2654e --- /dev/null +++ b/addons/pos_online_payment/models/account_payment.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, fields, models + + +class AccountPayment(models.Model): + _inherit = 'account.payment' + + pos_order_id = fields.Many2one('pos.order', string='POS Order', help='The Point of Sale order linked to this payment', readonly=True) + + def action_view_pos_order(self): + """ Return the action for the view of the pos order linked to the payment. + """ + self.ensure_one() + + action = { + 'name': _("POS Order"), + 'type': 'ir.actions.act_window', + 'res_model': 'pos.order', + 'target': 'current', + 'res_id': self.pos_order_id.id, + 'view_mode': 'form' + } + return action diff --git a/addons/pos_online_payment/models/payment_transaction.py b/addons/pos_online_payment/models/payment_transaction.py new file mode 100644 index 00000000000..7be95a6eed7 --- /dev/null +++ b/addons/pos_online_payment/models/payment_transaction.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, api, fields, models, tools +from odoo.exceptions import ValidationError + + +class PaymentTransaction(models.Model): + _inherit = 'payment.transaction' + + pos_order_id = fields.Many2one('pos.order', string='POS Order', help='The Point of Sale order linked to the payment transaction', readonly=True) + + @api.model + def _compute_reference_prefix(self, provider_code, separator, **values): + """ Override of payment to compute the reference prefix based on POS-specific values. + + :return: The computed reference prefix if POS order id is found, the one of `super` otherwise + :rtype: str + """ + pos_order_id = values.get('pos_order_id') + if pos_order_id: + pos_order = self.env['pos.order'].sudo().browse(pos_order_id).exists() + if pos_order: + return pos_order.pos_reference + return super()._compute_reference_prefix(provider_code, separator, **values) + + def _set_authorized(self, state_message=None, **kwargs): + """ Override of payment to process POS online payments automatically. """ + super()._set_authorized(state_message=state_message, **kwargs) + self._process_pos_online_payment() + + def _reconcile_after_done(self): + """ Override of payment to process POS online payments automatically. """ + super()._reconcile_after_done() + self._process_pos_online_payment() + + def _process_pos_online_payment(self): + for tx in self: + if tx and tx.pos_order_id and tx.state in ('authorized', 'done') and not tx.payment_id.pos_order_id: + pos_order = tx.pos_order_id + if tools.float_compare(tx.amount, 0.0, precision_rounding=pos_order.currency_id.rounding) <= 0: + raise ValidationError(_('The payment transaction (%d) has a negative amount.', tx.id)) + + if not tx.payment_id: # the payment could already have been created by account_payment module + tx._create_payment() + if not tx.payment_id: + raise ValidationError(_('The POS online payment (tx.id=%d) could not be saved correctly', tx.id)) + + payment_method = pos_order.online_payment_method_id + if not payment_method: + pos_config = pos_order.config_id + payment_method = self.env['pos.payment.method'].sudo()._get_or_create_online_payment_method(pos_config.company_id.id, pos_config.id) + if not payment_method: + raise ValidationError(_('The POS online payment (tx.id=%d) could not be saved correctly because the online payment method could not be found', tx.id)) + + pos_order.add_payment({ + 'amount': tx.amount, + 'payment_date': tx.last_state_change, + 'payment_method_id': payment_method.id, + 'online_account_payment_id': tx.payment_id.id, + 'pos_order_id': pos_order.id, + }) + tx.payment_id.update({ + 'pos_payment_method_id': payment_method.id, + 'pos_order_id': pos_order.id, + 'pos_session_id': pos_order.session_id.id, + }) + if pos_order.state == 'draft' and pos_order._is_pos_order_paid(): + pos_order._process_saved_order(False) + pos_order._send_online_payments_notification_via_bus() + + def action_view_pos_order(self): + """ Return the action for the view of the pos order linked to the transaction. + """ + self.ensure_one() + + action = { + 'name': _("POS Order"), + 'type': 'ir.actions.act_window', + 'res_model': 'pos.order', + 'target': 'current', + 'res_id': self.pos_order_id.id, + 'view_mode': 'form' + } + return action diff --git a/addons/pos_online_payment/models/pos_config.py b/addons/pos_online_payment/models/pos_config.py new file mode 100644 index 00000000000..7cbee7b8161 --- /dev/null +++ b/addons/pos_online_payment/models/pos_config.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, api, _ +from odoo.exceptions import ValidationError + + +class PosConfig(models.Model): + _inherit = 'pos.config' + + @api.constrains('payment_method_ids') + def _check_online_payment_methods(self): + """ Checks the journal currency with _get_online_payment_providers(..., error_if_invalid=True)""" + for config in self: + opm_amount = 0 + for pm in config.payment_method_ids: + if pm.is_online_payment: + opm_amount += 1 + if opm_amount > 1: + raise ValidationError(_("A POS config cannot have more than one online payment method.")) + if not pm._get_online_payment_providers(config.id, error_if_invalid=True): + raise ValidationError(_("To use an online payment method in a POS config, it must have at least one published payment provider supporting the currency of that POS config.")) + + def _get_cashier_online_payment_method(self): + self.ensure_one() + return self.payment_method_ids.filtered('is_online_payment')[:1] diff --git a/addons/pos_online_payment/models/pos_order.py b/addons/pos_online_payment/models/pos_order.py new file mode 100644 index 00000000000..b5d1810f67a --- /dev/null +++ b/addons/pos_online_payment/models/pos_order.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields, api, tools + + +class PosOrder(models.Model): + _inherit = 'pos.order' + + online_payment_method_id = fields.Many2one('pos.payment.method', compute="_compute_online_payment_method_id") + next_online_payment_amount = fields.Float(string='Next online payment amount to pay', states={'draft': [('readonly', False)]}, readonly=True, digits=0, required=False) # unlimited precision + + @api.depends('config_id.payment_method_ids') + def _compute_online_payment_method_id(self): + for order in self: + order.online_payment_method_id = order.config_id._get_cashier_online_payment_method() + + def get_amount_unpaid(self): + self.ensure_one() + return self.currency_id.round(self._get_rounded_amount(self.amount_total) - self.amount_paid) + + def _clean_payment_lines(self): + self.ensure_one() + order_payments = self.env['pos.payment'].search(['&', ('pos_order_id', '=', self.id), ('online_account_payment_id', '=', False)]) + order_payments.unlink() + + def get_and_set_online_payments_data(self, next_online_payment_amount=False): + """ Allows to update the amount to pay for the next online payment and + get online payments already made and how much remains to be paid. + If next_online_payment_amount is different than False, updates the + next online payment amount, otherwise, the next online payment amount + is unchanged. + If next_online_payment_amount is 0 and the order has no successful + online payment, is in draft state, is not a restaurant order and the + pos.config has no trusted config, then the order is deleted from the + database, because it was probably added for the online payment flow. + """ + self.ensure_one() + is_paid = self.state in ('paid', 'done', 'invoiced') + if is_paid: + return { + 'id': self.id, + 'paid_order': self._export_for_ui(self) + } + + online_payments = self.sudo().env['pos.payment'].search_read(domain=['&', ('pos_order_id', '=', self.id), ('online_account_payment_id', '!=', False)], fields=['payment_method_id', 'amount'], load=False) + return_data = { + 'id': self.id, + 'online_payments': online_payments, + 'amount_unpaid': self.get_amount_unpaid(), + } + if not isinstance(next_online_payment_amount, bool): + if tools.float_is_zero(next_online_payment_amount, precision_rounding=self.currency_id.rounding) and len(online_payments) == 0 and self.state == 'draft' and not self.config_id.module_pos_restaurant and len(self.config_id.trusted_config_ids) == 0: + self.sudo()._clean_payment_lines() # Needed to delete the order + self.sudo().unlink() + return_data['deleted'] = True + elif self._check_next_online_payment_amount(next_online_payment_amount): + self.next_online_payment_amount = next_online_payment_amount + + return return_data + + def _send_online_payments_notification_via_bus(self): + self.ensure_one() + # The bus communication is only protected by the name of the channel. + # Therefore, no sensitive information is sent through it, only a + # notification to invite the local browser to do a safe RPC to + # the server to check the new state of the order. + self.env['bus.bus']._sendone(self.session_id._get_bus_channel_name(), 'ONLINE_PAYMENTS_NOTIFICATION', {'id': self.id}) + + def _check_next_online_payment_amount(self, amount): + self.ensure_one() + return tools.float_compare(amount, 0.0, precision_rounding=self.currency_id.rounding) >= 0 and tools.float_compare(amount, self.get_amount_unpaid(), precision_rounding=self.currency_id.rounding) <= 0 + + def _get_checked_next_online_payment_amount(self): + self.ensure_one() + amount = self.next_online_payment_amount + return amount if self._check_next_online_payment_amount(amount) else False diff --git a/addons/pos_online_payment/models/pos_payment.py b/addons/pos_online_payment/models/pos_payment.py new file mode 100644 index 00000000000..230c520a939 --- /dev/null +++ b/addons/pos_online_payment/models/pos_payment.py @@ -0,0 +1,57 @@ +# -*- 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 UserError + +_logger = logging.getLogger(__name__) + + +class PosPayment(models.Model): + _inherit = 'pos.payment' + + online_account_payment_id = fields.Many2one('account.payment', string='Online accounting payment', readonly=True) # One2one + + @api.model_create_multi + def create(self, vals_list): + online_account_payments_by_pm = {} + for vals in vals_list: + pm_id = vals['payment_method_id'] + if pm_id not in online_account_payments_by_pm: + online_account_payments_by_pm[pm_id] = set() + online_account_payments_by_pm[pm_id].add(vals.get('online_account_payment_id')) + + opms_read_id = self.env['pos.payment.method'].search_read(['&', ('id', 'in', list(online_account_payments_by_pm.keys())), ('is_online_payment', '=', True)], ["id"]) + opms_id = {opm_read_id['id'] for opm_read_id in opms_read_id} + online_account_payments_to_check_id = set() + + for pm_id, oaps_id in online_account_payments_by_pm.items(): + if pm_id in opms_id: + if None in oaps_id: + raise UserError("Cannot create a POS online payment without an accounting payment.") + else: + online_account_payments_to_check_id.update(oaps_id) + elif any(oaps_id): + raise UserError("Cannot create a POS payment with a not online payment method and an online accounting payment.") + + if online_account_payments_to_check_id: + valid_oap_amount = self.env['account.payment'].search_count([('id', 'in', list(online_account_payments_to_check_id))]) + if valid_oap_amount != len(online_account_payments_to_check_id): + raise UserError("Cannot create a POS online payment without an accounting payment.") + + return super().create(vals_list) + + def write(self, vals): + if vals.keys() & ('amount', 'payment_date', 'payment_method_id', 'online_account_payment_id', 'pos_order_id') and any(payment.online_account_payment_id or payment.payment_method_id.is_online_payment for payment in self): + raise UserError("Cannot edit a POS online payment essential data.") + return super().write(vals) + + @api.constrains('payment_method_id') + def _check_payment_method_id(self): + bypass_check_payments = self.filtered('payment_method_id.is_online_payment') + if any(payment.payment_method_id != payment.pos_order_id.online_payment_method_id for payment in bypass_check_payments): + # An online payment must always be saved for the POS, even if the online payment method is no longer configured/allowed in the pos.config, because in any case it is saved by account_payment and payment modules. + _logger.warning("Allow to save a POS online payment with an unexpected online payment method") + + super(PosPayment, self - bypass_check_payments)._check_payment_method_id() diff --git a/addons/pos_online_payment/models/pos_payment_method.py b/addons/pos_online_payment/models/pos_payment_method.py new file mode 100644 index 00000000000..cd8841b902e --- /dev/null +++ b/addons/pos_online_payment/models/pos_payment_method.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + + +class PosPaymentMethod(models.Model): + _inherit = "pos.payment.method" + + is_online_payment = fields.Boolean(string="Online Payment", help="Use this payment method for online payments (payments made on a web page with online payment providers)", default=False) + online_payment_provider_ids = fields.Many2many('payment.provider', string="Allowed Providers", domain="[('is_published', '=', True), ('state', 'in', ['enabled', 'test'])]") + has_an_online_payment_provider = fields.Boolean(compute='_compute_has_an_online_payment_provider', readonly=True) + type = fields.Selection(selection_add=[('online', 'Online')]) + + @api.depends('is_online_payment') + def _compute_type(self): + opm = self.filtered('is_online_payment') + if opm: + opm.type = 'online' + + super(PosPaymentMethod, self - opm)._compute_type() + + def _get_online_payment_providers(self, pos_config_id=False, error_if_invalid=True): + self.ensure_one() + providers_sudo = self.sudo().online_payment_provider_ids + if not providers_sudo: # Empty = all published providers + providers_sudo = self.sudo().env['payment.provider'].search([('is_published', '=', True), ('state', 'in', ['enabled', 'test'])]) + + if not pos_config_id: + return providers_sudo + + config_currency = self.sudo().env['pos.config'].browse(pos_config_id).currency_id + valid_providers = providers_sudo.filtered(lambda p: not p.journal_id.currency_id or p.journal_id.currency_id == config_currency) + if error_if_invalid and len(providers_sudo) != len(valid_providers): + raise ValidationError(_("All payment providers configured for an online payment method must use the same currency as the Sales Journal, or the company currency if that is not set, of the POS config.")) + return valid_providers + + @api.depends('is_online_payment', 'online_payment_provider_ids') + def _compute_has_an_online_payment_provider(self): + for pm in self: + if pm.is_online_payment: + pm.has_an_online_payment_provider = bool(pm._get_online_payment_providers()) + else: + pm.has_an_online_payment_provider = False + + def _is_write_forbidden(self, fields): + return super(PosPaymentMethod, self)._is_write_forbidden(fields - {'online_payment_provider_ids'}) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get('is_online_payment', False): + self._force_online_payment_values(vals) + return super().create(vals_list) + + def write(self, vals): + if 'is_online_payment' in vals: + if vals['is_online_payment']: + self._force_online_payment_values(vals) + return super().write(vals) + + opm = self.filtered('is_online_payment') + not_opm = self - opm + + res = True + if opm: + forced_vals = vals.copy() + self._force_online_payment_values(forced_vals, True) + res = super(PosPaymentMethod, opm).write(forced_vals) and res + if not_opm: + res = super(PosPaymentMethod, not_opm).write(vals) and res + + return res + + @staticmethod + def _force_online_payment_values(vals, if_present=False): + if 'type' in vals: + vals['type'] = 'online' + + disabled_fields_name = ('split_transactions', 'receivable_account_id', 'outstanding_account_id', 'journal_id', 'is_cash_count', 'use_payment_terminal') + if if_present: + for name in disabled_fields_name: + if name in vals: + vals[name] = False + else: + for name in disabled_fields_name: + vals[name] = False + + def _get_payment_terminal_selection(self): + return super(PosPaymentMethod, self)._get_payment_terminal_selection() if not self.is_online_payment else [] + + @api.depends('type') + def _compute_hide_use_payment_terminal(self): + opm = self.filtered(lambda pm: pm.type == 'online') + if opm: + opm.hide_use_payment_terminal = True + super(PosPaymentMethod, self - opm)._compute_hide_use_payment_terminal() + + @api.model + def _get_or_create_online_payment_method(self, company_id, pos_config_id): + """ Get the first online payment method compatible with the provided pos.config. + If there isn't any, try to find an existing one in the same company and return it without adding the pos.config to it. + If there is not, create a new one for the company and return it without adding the pos.config to it. + """ + # Parameters are ids instead of a pos.config record because this method can be called from a web controller or internally + payment_method_id = self.env['pos.payment.method'].search([('is_online_payment', '=', True), ('company_id', '=', company_id), ('config_ids', 'in', pos_config_id)], limit=1).exists() + if not payment_method_id: + payment_method_id = self.env['pos.payment.method'].search([('is_online_payment', '=', True), ('company_id', '=', company_id)], limit=1).exists() + if not payment_method_id: + payment_method_id = self.env['pos.payment.method'].create({ + 'name': 'Online Payment', + 'is_online_payment': True, + 'company_id': company_id, + }) + if not payment_method_id: + raise ValidationError('Could not create an online payment method (company_id=%d, pos_config_id=%d)' % (company_id, pos_config_id)) + return payment_method_id diff --git a/addons/pos_online_payment/models/pos_session.py b/addons/pos_online_payment/models/pos_session.py new file mode 100644 index 00000000000..2c8d015566f --- /dev/null +++ b/addons/pos_online_payment/models/pos_session.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from collections import defaultdict +from odoo import models, tools, _ +from odoo.exceptions import UserError + + +class PosSession(models.Model): + _inherit = 'pos.session' + + def _loader_params_pos_payment_method(self): + result = super()._loader_params_pos_payment_method() + result['search_params']['fields'].append('is_online_payment') + return result + + def _accumulate_amounts(self, data): + data = super()._accumulate_amounts(data) + amounts = lambda: {'amount': 0.0, 'amount_converted': 0.0} + + split_receivables_online = defaultdict(amounts) + currency_rounding = self.currency_id.rounding + for order in self.order_ids: + for payment in order.payment_ids: + amount = payment.amount + if tools.float_is_zero(amount, precision_rounding=currency_rounding): + continue + date = payment.payment_date + payment_method = payment.payment_method_id + payment_type = payment_method.type + + if payment_type == 'online': + split_receivables_online[payment] = self._update_amounts(split_receivables_online[payment], {'amount': amount}, date) + + data.update({'split_receivables_online': split_receivables_online,}) + return data + + def _create_bank_payment_moves(self, data): + data = super()._create_bank_payment_moves(data) + + split_receivables_online = data.get('split_receivables_online') + MoveLine = data.get('MoveLine') + + online_payment_to_receivable_lines = {} + + for payment, amounts in split_receivables_online.items(): + split_receivable_line = MoveLine.create(self._get_split_receivable_op_vals(payment, amounts['amount'], amounts['amount_converted'])) + account_payment = payment.online_account_payment_id + payment_receivable_line = account_payment.move_id.line_ids.filtered(lambda line: line.account_id == account_payment.destination_account_id) + online_payment_to_receivable_lines[payment] = split_receivable_line | payment_receivable_line + + data['online_payment_to_receivable_lines'] = online_payment_to_receivable_lines + return data + + def _get_split_receivable_op_vals(self, payment, amount, amount_converted): + partner = payment.online_account_payment_id.partner_id + accounting_partner = self.env["res.partner"]._find_accounting_partner(partner) + if not accounting_partner: + raise UserError(_("The partner of the POS online payment (id=%d) could not be found") % (payment.id)) + partial_vals = { + 'account_id': accounting_partner.property_account_receivable_id.id, + 'move_id': self.move_id.id, + 'partner_id': accounting_partner.id, + 'name': '%s - %s (%s)' % (self.name, payment.payment_method_id.name, payment.online_account_payment_id.payment_method_line_id.payment_provider_id.name), + } + return self._debit_amounts(partial_vals, amount, amount_converted) + + def _reconcile_account_move_lines(self, data): + data = super()._reconcile_account_move_lines(data) + online_payment_to_receivable_lines = data.get('online_payment_to_receivable_lines') + + for payment, lines in online_payment_to_receivable_lines.items(): + if payment.online_account_payment_id.partner_id.property_account_receivable_id.reconcile: + lines.filtered(lambda line: not line.reconciled).reconcile() + + return data diff --git a/addons/pos_online_payment/static/src/app/bus/pos_bus_service.js b/addons/pos_online_payment/static/src/app/bus/pos_bus_service.js new file mode 100644 index 00000000000..2bedd17b6a2 --- /dev/null +++ b/addons/pos_online_payment/static/src/app/bus/pos_bus_service.js @@ -0,0 +1,22 @@ +/** @odoo-module */ + +import { patch } from "@web/core/utils/patch"; +import { PosBus } from "@point_of_sale/app/bus/pos_bus_service"; + +patch(PosBus.prototype, "pos_online_payment.PosBus", { + //@override + dispatch(message) { + this._super(...arguments); + + if (message.type === "ONLINE_PAYMENTS_NOTIFICATION") { + // The bus communication is only protected by the name of the channel. + // Therefore, no sensitive information is sent through it, only a + // notification to invite the local browser to do a safe RPC to + // the server to check the new state of the order. + const currentOrder = this.pos.get_order(); + if (currentOrder && currentOrder.server_id === message.payload.id) { + currentOrder.update_online_payments_data_with_server(this.orm, false); + } + } + }, +}); diff --git a/addons/pos_online_payment/static/src/app/customer_display/customer_display_template.xml b/addons/pos_online_payment/static/src/app/customer_display/customer_display_template.xml new file mode 100644 index 00000000000..8afa0fdc73f --- /dev/null +++ b/addons/pos_online_payment/static/src/app/customer_display/customer_display_template.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + +

+
+

Please scan the QR code to open the payment page

+
+
+
+
+
+
+ Amount: + +
+
+ Order reference: + +
+
+ Order id: + +
+
+
+ + + + + + + + diff --git a/addons/pos_online_payment/static/src/app/screens/payment_screen/payment_screen.js b/addons/pos_online_payment/static/src/app/screens/payment_screen/payment_screen.js new file mode 100644 index 00000000000..47d36c2db27 --- /dev/null +++ b/addons/pos_online_payment/static/src/app/screens/payment_screen/payment_screen.js @@ -0,0 +1,263 @@ +/** @odoo-module */ + +import { PaymentScreen } from "@point_of_sale/app/screens/payment_screen/payment_screen"; +import { patch } from "@web/core/utils/patch"; +import { OnlinePaymentPopup } from "@pos_online_payment/app/utils/online_payment_popup/online_payment_popup"; +import { ConfirmPopup } from "@point_of_sale/app/utils/confirm_popup/confirm_popup"; +import { ErrorPopup } from "@point_of_sale/app/errors/popups/error_popup"; +import { floatIsZero } from "@web/core/utils/numbers"; +import { sprintf } from "@web/core/utils/strings"; + +patch(PaymentScreen.prototype, "pos_online_payment.PaymentScreen", { + getRemainingOnlinePaymentLines() { + return this.paymentLines.filter( + (line) => line.payment_method.is_online_payment && line.get_payment_status() !== "done" + ); + }, + checkRemainingOnlinePaymentLines(unpaidAmount) { + const remainingLines = this.getRemainingOnlinePaymentLines(); + let remainingAmount = 0; + let amount = 0; + for (const line of remainingLines) { + amount = line.get_amount(); + if (amount <= 0) { + this.popup.add(ErrorPopup, { + title: this.env._t("Invalid online payment"), + body: sprintf(this.env._t("Online payments cannot have a negative amount (%s: %s)."), line.payment_method.name, this.env.utils.formatCurrency(amount)), + }); + return false; + } + remainingAmount += amount; + } + if (!floatIsZero(unpaidAmount - remainingAmount, this.pos.currency.decimal_places)) { + this.popup.add(ErrorPopup, { + title: this.env._t("Invalid online payments"), + body: sprintf(this.env._t("The total amount of remaining online payments to execute (%s) doesn't correspond to the remaining unpaid amount of the order (%s)."), this.env.utils.formatCurrency(remainingAmount), this.env.utils.formatCurrency(unpaidAmount)), + }); + return false; + } + return true; + }, + //@override + async _isOrderValid(isForceValidate) { + if (!await this._super(...arguments)) { + return false; + } + + if (!this.payment_methods_from_config.some((pm) => pm.is_online_payment)) { + return true; + } + + if (this.currentOrder.finalized) { + this.afterOrderValidation(false); + return false; + } + + const onlinePaymentLines = this.getRemainingOnlinePaymentLines(); + if (onlinePaymentLines.length > 0) { + // Send the order to the server everytime before the online payments process to + // allow the server to get the data for online payments and link the successful + // online payments to the order. + // The validation process will be done by the server directly after a successful + // online payment that makes the order fully paid. + this.currentOrder.initialize_validation_date(); + this.currentOrder.save_to_db(); + this.pos.addOrderToUpdateSet(); + + try { + await this.pos.sendDraftToServer(); + } catch (error) { + // Code from _finalizeValidation(): + if (error.code == 700 || error.code == 701) { + this.error = true; + } + + if ("code" in error) { + // We started putting `code` in the rejected object for invoicing error. + // We can continue with that convention such that when the error has `code`, + // then it is an error when invoicing. Besides, _handlePushOrderError was + // introduce to handle invoicing error logic. + await this._handlePushOrderError(error); + } + this.showSaveOrderOnServerErrorPopup(); + return false; + } + + if (!this.currentOrder.server_id) { + this.showSaveOrderOnServerErrorPopup(); + return false; + } + + const qrCodeData = this.currentOrder._get_online_payment_qr_code_data(); + if (!qrCodeData || qrCodeData.length == 0) { + this.cancelOnlinePayment(this.currentOrder); + this.popup.add(ErrorPopup, { + title: this.env._t("Online payment unavailable"), + body: this.env._t("The QR Code for paying could not be generated."), + }); + return false; + } + + let prevOnlinePaymentLine = null; + let lastOrderServerOPData = null; + for (const onlinePaymentLine of onlinePaymentLines) { + const onlinePaymentLineAmount = onlinePaymentLine.get_amount(); + // The local state is not aware if the online payment has already been done. + lastOrderServerOPData = await this.currentOrder.update_online_payments_data_with_server(this.pos.orm, onlinePaymentLineAmount); + if (!lastOrderServerOPData) { + this.popup.add(ErrorPopup, { + title: this.env._t("Online payment unavailable"), + body: this.env._t("There is a problem with the server. The order online payment status cannot be retrieved."), + }); + return false; + } + if (!lastOrderServerOPData.is_paid) { + if (lastOrderServerOPData.modified_payment_lines) { + this.cancelOnlinePayment(this.currentOrder); + this.showModifiedOnlinePaymentsPopup(); + return false; + } + if ((prevOnlinePaymentLine && prevOnlinePaymentLine.get_payment_status() !== "done") || !this.checkRemainingOnlinePaymentLines(lastOrderServerOPData.amount_unpaid)) { + this.cancelOnlinePayment(this.currentOrder); + return false; + } + + onlinePaymentLine.set_payment_status("waiting"); + this.currentOrder.select_paymentline(onlinePaymentLine); + lastOrderServerOPData = await this.showOnlinePaymentQrCode(qrCodeData, onlinePaymentLineAmount); + if (onlinePaymentLine.get_payment_status() === "waiting") { + onlinePaymentLine.set_payment_status(undefined); + } + prevOnlinePaymentLine = onlinePaymentLine; + } + } + + if (!lastOrderServerOPData || !lastOrderServerOPData.is_paid) { + lastOrderServerOPData = await this.currentOrder.update_online_payments_data_with_server(this.pos.orm, 0); + } + if (!lastOrderServerOPData || !lastOrderServerOPData.is_paid) { + return false; + } + + await this.afterPaidOrderSavedOnServer(lastOrderServerOPData.paid_order); + return false; // Cancel normal flow because the current order is already saved on the server. + } else if (this.currentOrder.server_id) { + const orderServerOPData = await this.currentOrder.update_online_payments_data_with_server(this.pos.orm, 0); + if (!orderServerOPData) { + const { confirmed } = await this.popup.add(ConfirmPopup, { + title: this.env._t("Online payment unavailable"), + body: this.env._t("There is a problem with the server. The order online payment status cannot be retrieved. Are you sure there is no online payment for this order ?"), + confirmText: this.env._t("Yes"), + }); + if (!confirmed) { + return false; + } + } + if (orderServerOPData.is_paid) { + await this.afterPaidOrderSavedOnServer(orderServerOPData.paid_order); + return false; // Cancel normal flow because the current order is already saved on the server. + } + if (orderServerOPData.modified_payment_lines) { + this.showModifiedOnlinePaymentsPopup(); + return false; + } + } + + return true; + }, + cancelOnlinePayment(order) { + // Remove the draft order from the server if there is no done online payment + order.update_online_payments_data_with_server(this.pos.orm, 0); + }, + showSaveOrderOnServerErrorPopup() { + this.popup.add(ErrorPopup, { + title: this.env._t("Online payment unavailable"), + body: this.env._t("There is a problem with the server. The order cannot be saved and therefore the online payment is not possible."), + }); + }, + showModifiedOnlinePaymentsPopup() { + this.popup.add(ErrorPopup, { + title: this.env._t("Updated online payments"), + body: this.env._t("There are online payments that were missing in your view."), + }); + }, + async showOnlinePaymentQrCode(qrCodeData, amount) { + if (!this.currentOrder.uiState.PaymentScreen) { + this.currentOrder.uiState.PaymentScreen = {}; + } + this.currentOrder.uiState.PaymentScreen.onlinePaymentData = { + amount: amount, + qrCode: qrCodeData, + order: this.currentOrder, + }; + + const { confirmed, payload: orderServerOPData } = await this.popup.add(OnlinePaymentPopup, this.currentOrder.uiState.PaymentScreen.onlinePaymentData); + + if (this.currentOrder.uiState.PaymentScreen) { + delete this.currentOrder.uiState.PaymentScreen.onlinePaymentData; + if (Object.keys(this.currentOrder.uiState.PaymentScreen).length === 0) { + delete this.currentOrder.uiState.PaymentScreen; + } + } + + return confirmed ? orderServerOPData : null; + }, + async afterPaidOrderSavedOnServer(orderJSON) { + if (!orderJSON) { + this.popup.add(ErrorPopup, { + title: this.env._t("Server error"), + body: this.env._t("The saved order could not be retrieved."), + }); + return; + } + + // Update the local order with the data from the server, because it's the server + // that is responsible for saving the final state of an order when there is an + // online payment in it. + // This prevents the case where the cashier changes the payment lines after the + // order is paid with an online payment and the server saves the order as paid. + // Without that update, the payment lines printed on the receipt ticket would + // be invalid. + const isInvoiceRequested = this.currentOrder.is_to_invoice(); + const orderJSONInArray = [orderJSON]; + await this.pos._loadMissingProducts(orderJSONInArray); + await this.pos._loadMissingPartners(orderJSONInArray); + const updatedOrder = this.pos.createReactiveOrder(orderJSON); + if (!updatedOrder || this.currentOrder.server_id !== updatedOrder.backendId) { + this.popup.add(ErrorPopup, { + title: this.env._t("Order saving issue"), + body: this.env._t("The order has not been saved correctly on the server."), + }); + return; + } + this.pos.orders.add(updatedOrder); + const oldLocalOrder = this.currentOrder; + this.pos.set_order(updatedOrder); + this.pos.removeOrder(oldLocalOrder, false); + this.pos.validated_orders_name_server_id_map[this.currentOrder.name] = this.currentOrder.id; + + // Now, do practically the normal flow + if ((this.currentOrder.is_paid_with_cash() || this.currentOrder.get_change()) && this.pos.config.iface_cashdrawer) { + this.hardwareProxy.printer.openCashbox(); + } + + this.currentOrder.finalized = true; + + if (isInvoiceRequested) { + if (!this.currentOrder.account_move) { + this.popup.add(ErrorPopup, { + title: this.env._t("Invoice could not be generated"), + body: this.env._t("The invoice could not be generated."), + }); + } else { + await this.report.download("account.account_invoices", [ + this.currentOrder.account_move, + ]); + } + } + + await this.postPushOrderResolve([this.currentOrder.server_id]); + + this.afterOrderValidation(true); + }, +}); diff --git a/addons/pos_online_payment/static/src/app/store/models.js b/addons/pos_online_payment/static/src/app/store/models.js new file mode 100644 index 00000000000..494b4f77152 --- /dev/null +++ b/addons/pos_online_payment/static/src/app/store/models.js @@ -0,0 +1,111 @@ +/** @odoo-module */ +import { patch } from "@web/core/utils/patch"; +import { Order, Payment } from "@point_of_sale/app/store/models"; +import { floatIsZero } from "@web/core/utils/numbers"; + +patch(Order.prototype, "pos_online_payment.Order", { + _get_online_payment_url() { + return `${this.pos.base_url}/pos/pay/${this.server_id}?access_token=${this.access_token}`; + }, + _get_online_payment_qr_code_data() { + return this.server_id ? this._make_qr_code_data(this._get_online_payment_url()) : false; + }, + async update_online_payments_data_with_server(orm, next_online_payment_amount) { + if (!this.server_id) { + return false; + } + try { + const opData = await orm.call("pos.order", "get_and_set_online_payments_data", [this.server_id, next_online_payment_amount]); + return this.process_online_payments_data_from_server(opData); + } catch (ex) { + console.error("update_online_payments_data_with_server failed: ", ex); + return null; + } + }, + async process_online_payments_data_from_server(opData) { + if (!opData) { + return false; + } + if (opData.id !== this.server_id) { + console.error("Called process_online_payments_data_from_server on the wrong order."); + } + + if ("paid_order" in opData) { + opData.is_paid = true; + this.uiState.PaymentScreen?.onlinePaymentPopup?.setReceivedOrderServerOPData(opData); + return opData; + } else { + opData.is_paid = false; + } + + if ("deleted" in opData && opData["deleted"]) { + // The current order was previously saved on the server in the draft state, and has been deleted. + this.server_id = false; + } + + let newDoneOnlinePayment = false; + + const opLinesToUpdate = this.paymentlines.filter( + (line) => line.payment_method.is_online_payment && ["waiting", "done"].includes(line.get_payment_status()) + ); + for (const op of opData.online_payments) { + const matchingLineIndex = opLinesToUpdate.findIndex( + (pl) => pl.payment_method.id === op.payment_method_id && floatIsZero(pl.amount - op.amount, this.pos.currency.decimal_places) + ); + let opLine = null; + if (matchingLineIndex > -1) { + opLine = opLinesToUpdate[matchingLineIndex]; + + opLinesToUpdate.splice(matchingLineIndex, 1); + } + if (!opLine) { + opLine = new Payment( + {}, + { + order: this, + payment_method: this.pos.payment_methods_by_id[op.payment_method_id], + pos: this.pos, + } + ); + this.paymentlines.add(opLine); + opData['modified_payment_lines'] = true; + } + opLine.set_amount(op.amount); + opLine.can_be_reversed = false; + if (opLine.get_payment_status() !== "done") { + newDoneOnlinePayment = true; + } + opLine.set_payment_status("done"); + } + for (const missingInServerLine of opLinesToUpdate) { + if (missingInServerLine.get_payment_status() === "done") { + this.paymentlines.remove(missingInServerLine); + opData['modified_payment_lines'] = true; + } + } + if (newDoneOnlinePayment || opData['modified_payment_lines']) { + this.uiState.PaymentScreen?.onlinePaymentPopup?.cancel(); + } + + return opData; + }, +}); + +patch(Payment.prototype, "pos_online_payment.Payment", { + //@override + export_as_JSON() { + if (this.payment_method.is_online_payment) { + return null; // It is the role of the server to save the online payment, not the role of the POS session. + } else { + return this._super(); + } + }, + //@override + canBeAdjusted() { + if (this.payment_method.is_online_payment) { + return false; + } else { + return this._super(); + } + }, +}); diff --git a/addons/pos_online_payment/static/src/app/utils/online_payment_popup/online_payment_popup.js b/addons/pos_online_payment/static/src/app/utils/online_payment_popup/online_payment_popup.js new file mode 100644 index 00000000000..f2043daea37 --- /dev/null +++ b/addons/pos_online_payment/static/src/app/utils/online_payment_popup/online_payment_popup.js @@ -0,0 +1,29 @@ +/** @odoo-module */ + +import { AbstractAwaitablePopup } from "@point_of_sale/app/popup/abstract_awaitable_popup"; + +export class OnlinePaymentPopup extends AbstractAwaitablePopup { + static template = "pos_online_payment.OnlinePaymentPopup"; + + setup() { + super.setup(); + if (this.props.order.uiState.PaymentScreen) { + this.props.order.uiState.PaymentScreen.onlinePaymentPopup = this; + } + } + setReceivedOrderServerOPData(opData) { + this.opData = opData; + this.confirm(); + } + async confirm() { + super.confirm(); + delete this.props.order.uiState.PaymentScreen?.onlinePaymentPopup; + } + cancel() { + super.cancel(); + delete this.props.order.uiState.PaymentScreen?.onlinePaymentPopup; + } + async getPayload() { + return this.opData; + } +} diff --git a/addons/pos_online_payment/static/src/app/utils/online_payment_popup/online_payment_popup.xml b/addons/pos_online_payment/static/src/app/utils/online_payment_popup/online_payment_popup.xml new file mode 100644 index 00000000000..618ef5f2ea2 --- /dev/null +++ b/addons/pos_online_payment/static/src/app/utils/online_payment_popup/online_payment_popup.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/addons/pos_online_payment/static/src/css/customer_facing_display.css b/addons/pos_online_payment/static/src/css/customer_facing_display.css new file mode 100644 index 00000000000..65ddbe18eae --- /dev/null +++ b/addons/pos_online_payment/static/src/css/customer_facing_display.css @@ -0,0 +1,54 @@ +.pos-customer_facing_display .online-payment { + height: 100%; + font-size: min(max(2vw, .9rem), 1.6rem); + overflow: hidden; + -webkit-display: flex; + -moz-display: flex; + -ms-display: flex; + -o-display: flex; + display: flex; + -webkit-flex-direction: column; + -moz-flex-direction: column; + -ms-flex-direction: column; + -o-flex-direction: column; + flex-direction: column; +} + +.pos-customer_facing_display .online-payment .instructions { + -webkit-box-flex: 1 0 75%; + -webkit-flex: 1 0 75%; + -moz-box-flex: 1 0 75%; + -ms-flex: 1 0 75%; + flex: 1 0 75%; + justify-content: center; + text-align: center; + align-items: center; +} + +.pos-customer_facing_display .online-payment .instructions p { + padding-top: .3rem; + margin-bottom: 0; + font-size: min(max(2.9vw, 0.4rem), 1.6rem); +} + +.pos-customer_facing_display .online-payment .instructions .qr-code { + height: max(min(min(75vh, 75vw), 60%), 5rem); + width: auto; + overflow: visible; + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + +.pos-customer_facing_display .online-payment .instructions .spacer { + height: 10%; +} + +.pos-customer_facing_display .online-payment .info { + padding: 0 2%; + font-size: min(max(3vw, 0.6rem), 1.6rem); + max-height: 25%; + height: unset; + justify-content: left; + text-align: left; +} diff --git a/addons/pos_online_payment/static/src/css/popups/online_payment_popup.css b/addons/pos_online_payment/static/src/css/popups/online_payment_popup.css new file mode 100644 index 00000000000..861739bb081 --- /dev/null +++ b/addons/pos_online_payment/static/src/css/popups/online_payment_popup.css @@ -0,0 +1,24 @@ +.pos .online-payment-popup .title { + font-weight: bold; +} + +.pos .online-payment-popup .instructions p { + margin-bottom: 0; +} + +.pos .online-payment-popup .instructions .qr-code { + width: min(max(50%, 15rem), 75%); + height: auto; + margin: .3rem auto .3rem auto; +} + +.pos .online-payment-popup .info { + border-top: solid 1px rgba(60, 60, 60, 0.1); + padding-top: .6rem; + justify-content: left; + text-align: left; +} + +.pos .online-payment-popup .footer .button { + margin: auto; +} diff --git a/addons/pos_online_payment/static/tests/tours/OnlinePayment.tour.js b/addons/pos_online_payment/static/tests/tours/OnlinePayment.tour.js new file mode 100644 index 00000000000..85ad18ce414 --- /dev/null +++ b/addons/pos_online_payment/static/tests/tours/OnlinePayment.tour.js @@ -0,0 +1,119 @@ +/** @odoo-module */ + +import { Chrome } from "@point_of_sale/../tests/tours/helpers/ChromeTourMethods"; +import { ProductScreen } from "@point_of_sale/../tests/tours/helpers/ProductScreenTourMethods"; +import { PaymentScreen } from "@point_of_sale/../tests/tours/helpers/PaymentScreenTourMethods"; +import { OnlinePaymentPopup } from "@pos_online_payment/../tests/tours/helpers/OnlinePaymentPopupTourMethods"; +import { ReceiptScreen } from "@point_of_sale/../tests/tours/helpers/ReceiptScreenTourMethods"; +import { ErrorPopup } from "@point_of_sale/../tests/tours/helpers/ErrorPopupTourMethods"; +import { getSteps, startSteps } from "@point_of_sale/../tests/tours/helpers/utils"; +import { registry } from "@web/core/registry"; + +startSteps(); + +ProductScreen.do.confirmOpeningPopup(); +ProductScreen.exec.addOrderline("Letter Tray", "10"); +ProductScreen.check.selectedOrderlineHas("Letter Tray", "10.0"); +ProductScreen.do.clickPayButton(); +PaymentScreen.check.totalIs("48.0"); +PaymentScreen.check.emptyPaymentlines("48.0"); + +PaymentScreen.do.clickPaymentMethod("Online payment"); +PaymentScreen.do.enterPaymentLineAmount("Online payment", "48"); +PaymentScreen.check.selectedPaymentlineHas("Online payment", "48.0"); +PaymentScreen.check.remainingIs("0.0"); +PaymentScreen.check.changeIs("0.0"); +PaymentScreen.check.validateButtonIsHighlighted(true); +PaymentScreen.do.clickValidate(); +OnlinePaymentPopup.check.isShown(); +OnlinePaymentPopup.check.amountIs("48.0"); +OnlinePaymentPopup.do.fakeOnlinePaymentPaidData(); +OnlinePaymentPopup.check.isNotShown(); +ReceiptScreen.check.isShown(); +ReceiptScreen.check.receiptIsThere(); + +registry + .category("web_tour.tours") + .add("OnlinePaymentLocalFakePaidDataTour", { test: true, url: "/pos/ui", steps: getSteps() }); + +startSteps(); + +ProductScreen.do.confirmOpeningPopup(); +ProductScreen.exec.addOrderline("Letter Tray", "10"); +ProductScreen.check.selectedOrderlineHas("Letter Tray", "10.0"); +ProductScreen.do.clickPayButton(); +PaymentScreen.check.totalIs("48.0"); +PaymentScreen.check.emptyPaymentlines("48.0"); + +PaymentScreen.do.clickPaymentMethod("Online payment"); +PaymentScreen.do.enterPaymentLineAmount("Online payment", "47"); +PaymentScreen.check.selectedPaymentlineHas("Online payment", "47.0"); +PaymentScreen.check.remainingIs("1.0"); +PaymentScreen.check.changeIs("0.0"); +PaymentScreen.check.validateButtonIsHighlighted(false); +PaymentScreen.do.clickPaymentMethod("Cash"); +PaymentScreen.do.enterPaymentLineAmount("Cash", "2"); +PaymentScreen.check.selectedPaymentlineHas("Cash", "2.0"); +PaymentScreen.check.remainingIs("0.0"); +PaymentScreen.check.changeIs("1.0"); +PaymentScreen.check.validateButtonIsHighlighted(true); +PaymentScreen.do.clickValidate(); +ErrorPopup.check.isShown(); +ErrorPopup.do.clickConfirm(); +PaymentScreen.do.clickPaymentline("Online payment", "47.0"); +PaymentScreen.do.clickPaymentlineDelButton("Online payment", "47.0"); +PaymentScreen.do.clickPaymentMethod("Online payment"); +PaymentScreen.check.selectedPaymentlineHas("Online payment", "46.0"); +PaymentScreen.do.clickPaymentMethod("Online payment"); +PaymentScreen.check.selectedPaymentlineHas("Online payment", "0.0"); +PaymentScreen.check.remainingIs("0.0"); +PaymentScreen.check.changeIs("0.0"); +PaymentScreen.check.validateButtonIsHighlighted(true); +PaymentScreen.do.clickValidate(); +ErrorPopup.check.isShown(); +ErrorPopup.do.clickConfirm(); +PaymentScreen.do.clickPaymentline("Online payment", "0.0"); +PaymentScreen.do.clickPaymentlineDelButton("Online payment", "0.0"); +PaymentScreen.do.clickPaymentline("Cash", "2.0"); +PaymentScreen.do.enterPaymentLineAmount("Cash", "3"); +PaymentScreen.check.selectedPaymentlineHas("Cash", "3.0"); +PaymentScreen.do.clickPaymentMethod("Online payment"); +PaymentScreen.check.selectedPaymentlineHas("Online payment", "-1.0"); +PaymentScreen.check.remainingIs("0.0"); +PaymentScreen.check.changeIs("0.0"); +PaymentScreen.check.validateButtonIsHighlighted(true); +PaymentScreen.do.clickValidate(); +ErrorPopup.check.isShown(); +ErrorPopup.do.clickConfirm(); + +registry + .category("web_tour.tours") + .add("OnlinePaymentErrorsTour", { test: true, url: "/pos/ui", steps: getSteps() }); + +startSteps(); + +ProductScreen.do.confirmOpeningPopup(); +ProductScreen.exec.addOrderline("Letter Tray", "10"); +ProductScreen.check.selectedOrderlineHas("Letter Tray", "10.0"); +ProductScreen.do.clickPayButton(); +PaymentScreen.check.totalIs("48.0"); +PaymentScreen.check.emptyPaymentlines("48.0"); + +PaymentScreen.do.clickPaymentMethod("Online payment"); +PaymentScreen.do.enterPaymentLineAmount("Online payment", "48"); +PaymentScreen.check.selectedPaymentlineHas("Online payment", "48.0"); +PaymentScreen.check.remainingIs("0.0"); +PaymentScreen.check.changeIs("0.0"); +PaymentScreen.check.validateButtonIsHighlighted(true); +PaymentScreen.do.clickValidate(); +OnlinePaymentPopup.check.isShown(); +OnlinePaymentPopup.check.amountIs("48.0"); +OnlinePaymentPopup.check.waitForOnlinePayment(); +OnlinePaymentPopup.check.isNotShown(); +ReceiptScreen.check.isShown(); +ReceiptScreen.check.receiptIsThere(); +Chrome.do.closeSession(); + +registry + .category("web_tour.tours") + .add("OnlinePaymentServerFakePaymentTour", { test: true, url: "/pos/ui", steps: getSteps() }); diff --git a/addons/pos_online_payment/static/tests/tours/helpers/OnlinePaymentPopupTourMethods.js b/addons/pos_online_payment/static/tests/tours/helpers/OnlinePaymentPopupTourMethods.js new file mode 100644 index 00000000000..466e227ae41 --- /dev/null +++ b/addons/pos_online_payment/static/tests/tours/helpers/OnlinePaymentPopupTourMethods.js @@ -0,0 +1,112 @@ +/** @odoo-module */ + +import { createTourMethods } from "@point_of_sale/../tests/tours/helpers/utils"; + +class Do { + clickCancel() { + return [ + { + content: "click cancel button", + trigger: ".online-payment-popup .footer .cancel", + }, + ]; + } + + fakeOnlinePaymentPaidData() { + return [ + { + content: "fake online payment paid data", + trigger: ".online-payment-popup", + run: () => { + const currentOrder = odoo.__WOWL_DEBUG__.root.env.services.pos.get_order(); + + const fakePaidOrder = currentOrder.export_as_JSON(); + fakePaidOrder.id = currentOrder.server_id; + + currentOrder.process_online_payments_data_from_server({ + "id": currentOrder.server_id, + "paid_order": fakePaidOrder, + }); + }, + }, + ]; + } +} + +class Check { + isShown() { + return [ + { + content: "online payment popup is shown", + trigger: ".modal-dialog .online-payment-popup", + isCheck: true, + }, + ]; + } + + isNotShown() { + return [ + { + content: "online payment popup is not shown", + trigger: "body:not(:has(.online-payment-popup))", + isCheck: true, + }, + ]; + } + + /** + * Check if the displayed amount to pay is the provided amount. + * @param {String} amount + */ + amountIs(amount) { + return [ + { + content: `displayed amount is ${amount}`, + trigger: `.online-payment-popup .body .info .amount:contains("${amount}")`, + isCheck: true, + }, + ]; + } + + /** + * Used to replace the POS bus web socket communication that doesn't seem to work when executing a test tour. + * The server is regularly checked to see if the fake online payment has been done. + * + * @param {integer} checksAmount + * @param {integer} delayBetweenChecks + * @returns + */ + waitForOnlinePayment(checksAmount = 10, delayBetweenChecks = 3000) { + const waitingStep = this.isNotShown()[0]; + waitingStep.content = "wait for online payment"; + waitingStep.timeout = checksAmount * delayBetweenChecks + 3000; + return [ + { + content: "start checks for online payment", + trigger: ".online-payment-popup", + run: () => { + let checkIndex = 0; + const checkFunc = async () => { + const currentOrder = odoo.__WOWL_DEBUG__.root.env.services.pos.get_order(); + let opData; + if (currentOrder) { + opData = await currentOrder.update_online_payments_data_with_server(odoo.__WOWL_DEBUG__.root.env.services.orm, false); + } + const isMaxChecksReached = checkIndex >= checksAmount - 1; + const isOrderPaid = opData && opData.is_paid; + if (!isOrderPaid && !isMaxChecksReached) { + checkIndex++; + setTimeout(checkFunc, delayBetweenChecks); + } + }; + setTimeout(checkFunc, delayBetweenChecks); + }, + }, + waitingStep, + ]; + } +} + +// FIXME: this is a horrible hack to export an object as named exports. +// eslint-disable-next-line no-undef +Object.assign(__exports, createTourMethods("OnlinePaymentPopup", Do, Check)); diff --git a/addons/pos_online_payment/tests/__init__.py b/addons/pos_online_payment/tests/__init__.py new file mode 100644 index 00000000000..6f4bae0c7a2 --- /dev/null +++ b/addons/pos_online_payment/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_frontend diff --git a/addons/pos_online_payment/tests/online_payment_common.py b/addons/pos_online_payment/tests/online_payment_common.py new file mode 100644 index 00000000000..15d9beb154c --- /dev/null +++ b/addons/pos_online_payment/tests/online_payment_common.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json + +from odoo.tools import mute_logger +from odoo.addons.payment.tests.http_common import PaymentHttpCommon +from odoo.addons.pos_online_payment.controllers.payment_portal import PaymentPortal + + +class OnlinePaymentCommon(PaymentHttpCommon): + + def _fake_http_get_request(self, route): + url = self._build_url(route) + response = self._make_http_get_request(url) + self.assertEqual(response.status_code, 200) + return response + + def _fake_open_pos_order_pay_page(self, pos_order_id, access_token): + response = self._fake_http_get_request(PaymentPortal._get_pay_route(pos_order_id, access_token)) + return self._get_tx_context(response, 'o_payment_checkout') + + def _fake_request_pos_order_pay_transaction_page(self, pos_order_id, route_values): + uri = f'/pos/pay/transaction/{pos_order_id}' + url = self._build_url(uri) + response = self._make_json_rpc_request(url, route_values) + self.assertEqual(response.status_code, 200) + resp_content = json.loads(response.content) + return resp_content['result'] + + def _fake_open_pos_order_pay_confirmation_page(self, pos_order_id, access_token, tx_id): + self._fake_http_get_request(PaymentPortal._get_landing_route(pos_order_id, access_token, tx_id=tx_id)) + + def _fake_online_payment(self, pos_order_id, access_token, expected_payment_provider_id): + tx_context = self._fake_open_pos_order_pay_page(pos_order_id, access_token) + + # Code inspired by addons/payment/tests/test_flows.py + # Route values are taken from tx_context result of /pay route to correctly simulate the flow + route_values = { + k: tx_context[k] + for k in [ + 'amount', + 'currency_id', + 'reference_prefix', + 'partner_id', + 'access_token', + 'landing_route', + ] + } + provider_id = tx_context['provider_ids'][0] + self.assertEqual(expected_payment_provider_id, provider_id, 'The available provider is unexpected') + route_values.update({ + 'flow': 'direct', + 'payment_option_id': provider_id, + 'tokenization_requested': False, + }) + + with mute_logger('odoo.addons.payment.models.payment_transaction'): + processing_values = self._fake_request_pos_order_pay_transaction_page(pos_order_id, route_values) + tx_sudo = self._get_tx(processing_values['reference']) + tx_sudo._set_done() + self._fake_open_pos_order_pay_confirmation_page(pos_order_id, access_token, tx_sudo.id) diff --git a/addons/pos_online_payment/tests/test_frontend.py b/addons/pos_online_payment/tests/test_frontend.py new file mode 100644 index 00000000000..1bcba4722c5 --- /dev/null +++ b/addons/pos_online_payment/tests/test_frontend.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from unittest.mock import patch +import threading + +from odoo import tools, Command, fields +from odoo.addons.account.tests.common import AccountTestInvoicingCommon +from odoo.addons.pos_online_payment.models.pos_order import PosOrder +from odoo.addons.pos_online_payment.tests.online_payment_common import OnlinePaymentCommon +from odoo.addons.account.models.account_payment_method import AccountPaymentMethod +from odoo.osv.expression import AND + +import odoo.tests + + +@odoo.tests.tagged('post_install', '-at_install') +class TestUi(AccountTestInvoicingCommon, OnlinePaymentCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + # Code from addons/account_payment/tests/common.py: + Method_get_payment_method_information = AccountPaymentMethod._get_payment_method_information + + def _get_payment_method_information(self): + res = Method_get_payment_method_information(self) + res['none'] = {'mode': 'multi', 'domain': [('type', '=', 'bank')]} + return res + + with patch.object(AccountPaymentMethod, '_get_payment_method_information', _get_payment_method_information): + cls.env['account.payment.method'].sudo().create({ + 'name': 'Dummy method', + 'code': 'none', + 'payment_type': 'inbound' + }) + # End of code from addons/account_payment/tests/common.py + + # Code inspired by addons/point_of_sale/tests/common.py: + cls.company = cls.company_data['company'] + cls.cash_journal = cls.env['account.journal'].create({ + 'name': 'Cash Journal for POS OP Test', + 'type': 'cash', + 'company_id': cls.company.id, + 'code': 'POPCH', + 'sequence': 10, + }) + + cls.old_account_default_pos_receivable_account_id = cls.company.account_default_pos_receivable_account_id + cls.account_default_pos_receivable_account_id = cls.env['account.account'].create({ + 'code': 'X1012.POSOP', + 'name': 'Debtors - (POSOP)', + 'account_type': 'asset_receivable', + 'reconcile': True, + }) + cls.company.account_default_pos_receivable_account_id = cls.account_default_pos_receivable_account_id + cls.receivable_cash_account = cls.copy_account(cls.company.account_default_pos_receivable_account_id, {'name': 'POS OP Test Receivable Cash'}) + + cls.cash_payment_method = cls.env['pos.payment.method'].create({ + 'name': 'Cash', + 'journal_id': cls.cash_journal.id, + 'receivable_account_id': cls.receivable_cash_account.id, + 'company_id': cls.company.id, + }) + # End of code inspired by addons/point_of_sale/tests/common.py + + cls.payment_provider = cls.provider # The dummy_provider used by the tests of the 'payment' module. + + cls.payment_provider_old_company_id = cls.payment_provider.company_id.id + cls.payment_provider_old_journal_id = cls.payment_provider.journal_id.id + cls.payment_provider.company_id = cls.company.id + cls.payment_provider.journal_id = cls.company_data['default_journal_bank'].id + + cls.online_payment_method = cls.env['pos.payment.method'].create({ + 'name': 'Online payment', + 'is_online_payment': True, + 'online_payment_provider_ids': [Command.set([cls.payment_provider.id])], + }) + + cls.sales_journal = cls.env['account.journal'].create({ + 'name': 'Sales Journal for POS OP Test', + 'code': 'POPSJ', + 'type': 'sale', + 'company_id': cls.company.id + }) + + cls.pos_config = cls.env['pos.config'].create({ + 'name': 'POS OP Test Shop', + 'module_pos_restaurant': False, + 'invoice_journal_id': cls.sales_journal.id, + 'journal_id': cls.sales_journal.id, + 'payment_method_ids': [Command.link(cls.cash_payment_method.id), Command.link(cls.online_payment_method.id)], + }) + + # Code from addons/point_of_sale/tests/test_frontend.py: + cls.pos_user = cls.env['res.users'].create({ + 'name': 'A simple PoS man!', + 'login': 'pos_op_user', + 'password': 'pos_op_user', + 'groups_id': [ + (4, cls.env.ref('base.group_user').id), + (4, cls.env.ref('point_of_sale.group_pos_user').id), + ], + }) + cls.pos_user.partner_id.email = 'pos_op_user@test.com' + # End of code from addons/point_of_sale/tests/test_frontend.py + + # Code from addons/account_payment/tests/common.py + @classmethod + def _prepare_provider(cls, provider_code='none', company=None, update_values=None): + """ Override of `payment` to prepare and return the first provider matching the given + provider and company. + + If no provider is found in the given company, we duplicate the one from the base company. + All other providers belonging to the same company are disabled to avoid any interferences. + + :param str provider_code: The code of the provider to prepare. + :param recordset company: The company of the provider to prepare, as a `res.company` record. + :param dict update_values: The values used to update the provider. + :return: The provider to prepare, if found. + :rtype: recordset of `payment.provider` + """ + provider = super()._prepare_provider(provider_code, company, update_values) + if not provider.journal_id: + provider.journal_id = cls.env['account.journal'].search( + [('company_id', '=', provider.company_id.id), ('type', '=', 'bank')], + limit=1, + ) + return provider + # End of code from addons/account_payment/tests/common.py + + def setUp(self): + self.enable_reconcile_after_done_patcher = False + + real_get_and_set_online_payments_data = PosOrder.get_and_set_online_payments_data + + def _fake_get_and_set_online_payments_data(method_self, next_online_payment_amount=False): + pos_order_id = method_self.id + order_access_token = method_self.access_token + expected_payment_provider_id = self.payment_provider.id + if not isinstance(next_online_payment_amount, bool) and not tools.float_is_zero(next_online_payment_amount, precision_rounding=method_self.currency_id.rounding) and next_online_payment_amount > 0: + # Delay must be long enough to execute _fake_online_payment after the current RPC call is answered + t = threading.Timer(5, self._fake_online_payment, args=(pos_order_id, order_access_token, expected_payment_provider_id)) + t.start() + + return real_get_and_set_online_payments_data(method_self, next_online_payment_amount) + + self._fake_get_and_set_online_payments_data = _fake_get_and_set_online_payments_data + + super(TestUi, self).setUp() + + self.assertTrue(self.company) + self.assertTrue(self.cash_journal) + self.assertTrue(self.account_default_pos_receivable_account_id) + self.assertTrue(self.receivable_cash_account) + self.assertTrue(self.sales_journal) + self.assertTrue(self.cash_payment_method) + self.assertTrue(self.payment_provider) + self.assertTrue(self.online_payment_method) + self.assertTrue(self.pos_config) + self.assertTrue(self.pos_user) + + def _open_session_ui(self): + self.pos_config.with_user(self.pos_user).open_ui() + + # Checks that the products used in the tours are available in this pos_config. + # This code is executed here because _loader_params_product_product is defined in pos.session + # and not in pos.config. + params = self.pos_config.current_session_id._loader_params_product_product() + self.assertTrue(params) + pos_config_products_domain = params['search_params']['domain'] + self.assertTrue(pos_config_products_domain) + tests_products_domain = AND([pos_config_products_domain, ['&', '&', ('name', '=', 'Letter Tray'), ('list_price', '=', 4.8), ('available_in_pos', '=', True)]]) + # active_test=False to follow pos.config:get_pos_ui_product_product_by_params + self.assertEqual(self.env['product.product'].with_context(active_test=False).search_count(tests_products_domain, limit=1), 1) + + def _start_tour(self, tour_name): + self.start_tour("/pos/ui?config_id=%d" % self.pos_config.id, tour_name, login="pos_op_user") + + def test_server_fake_payment_tour(self): + self._open_session_ui() + + before_tour_datetime = fields.Datetime.now() + with patch.object(PosOrder, 'get_and_set_online_payments_data', self._fake_get_and_set_online_payments_data): + self._start_tour('OnlinePaymentServerFakePaymentTour') + + test_orders = self.env['pos.order'].search(['&', ('config_id', '=', self.pos_config.id), ('date_order', '>=', before_tour_datetime)]) + self.assertEqual(len(test_orders), 1) + for order in test_orders: + self.assertEqual(order.state, 'done', "Validated order has payment of " + str(order.amount_paid) + " and total of " + str(order.amount_total)) + + def test_local_fake_paid_data_tour(self): + self._open_session_ui() + self._start_tour('OnlinePaymentLocalFakePaidDataTour') + + def test_errors_tour(self): + self._open_session_ui() + self._start_tour('OnlinePaymentErrorsTour') + + @classmethod + def tearDownClass(cls): + # Restore company values after the tests + cls.company.account_default_pos_receivable_account_id = cls.old_account_default_pos_receivable_account_id + + # Restore dummy_provider values after the tests + cls.payment_provider.company_id = cls.payment_provider_old_company_id + cls.payment_provider.journal_id = cls.payment_provider_old_journal_id + + # The online payment method cannot be deleted because it is used by a payment in the database. + # It would require to delete the paid orders of the tests, the corresponding accounting, the session data... + cls.pos_config.payment_method_ids = [Command.unlink(cls.online_payment_method.id)] + cls.cash_payment_method.unlink() + cls.receivable_cash_account.unlink() + cls.cash_journal.unlink() + cls.account_default_pos_receivable_account_id.unlink() diff --git a/addons/pos_online_payment/views/account_payment_views.xml b/addons/pos_online_payment/views/account_payment_views.xml new file mode 100644 index 00000000000..a9ae71305e9 --- /dev/null +++ b/addons/pos_online_payment/views/account_payment_views.xml @@ -0,0 +1,20 @@ + + + + + view.account.payment.form.inherit.pos_online_payment + account.payment + + + + + + + + + + diff --git a/addons/pos_online_payment/views/payment_portal_templates.xml b/addons/pos_online_payment/views/payment_portal_templates.xml new file mode 100644 index 00000000000..fa6ce0d354a --- /dev/null +++ b/addons/pos_online_payment/views/payment_portal_templates.xml @@ -0,0 +1,119 @@ + + + + + + + + + + diff --git a/addons/pos_online_payment/views/payment_transaction_views.xml b/addons/pos_online_payment/views/payment_transaction_views.xml new file mode 100644 index 00000000000..153f3369076 --- /dev/null +++ b/addons/pos_online_payment/views/payment_transaction_views.xml @@ -0,0 +1,18 @@ + + + + payment.transaction.form + payment.transaction + + + + + + + diff --git a/addons/pos_online_payment/views/pos_payment_method_views.xml b/addons/pos_online_payment/views/pos_payment_method_views.xml new file mode 100644 index 00000000000..4d5beab31b6 --- /dev/null +++ b/addons/pos_online_payment/views/pos_payment_method_views.xml @@ -0,0 +1,52 @@ + + + + pos.payment.method.form.inherit.pos_online_payment + pos.payment.method + + + + + + + + + + + {'invisible': [('is_online_payment', '=', True)]} + + + {'invisible': [('is_online_payment', '=', True)], 'required': ['&', ('is_online_payment', '=', False), ('split_transactions', '=', False)]} + + + {'invisible': ['|', ('is_online_payment', '=', True), ('split_transactions', '=', True)]} + + + +
+
+
+
+
+
+ + + pos.payment.method.tree.inherit.pos_online_payment + pos.payment.method + + + + is_online_payment and not has_an_online_payment_provider + + + + + + + +
diff --git a/addons/pos_online_payment/views/pos_payment_views.xml b/addons/pos_online_payment/views/pos_payment_views.xml new file mode 100644 index 00000000000..e9ad30217f8 --- /dev/null +++ b/addons/pos_online_payment/views/pos_payment_views.xml @@ -0,0 +1,13 @@ + + + + pos.payment.form + pos.payment + + + + + + + + diff --git a/addons/pos_restaurant/models/pos_order.py b/addons/pos_restaurant/models/pos_order.py index 372acd8af2f..9ab46c6289b 100644 --- a/addons/pos_restaurant/models/pos_order.py +++ b/addons/pos_restaurant/models/pos_order.py @@ -61,18 +61,15 @@ class PosOrder(models.Model): self.send_table_count_notification(tables) return order_ids - @api.model - def create_from_ui(self, orders, draft=False): - orders = super().create_from_ui(orders, draft) - order_ids = self.env['pos.order'].browse([order['id'] for order in orders]) - self.send_table_count_notification(order_ids.table_id) - return orders + def _process_saved_order(self, draft): + order_id = super()._process_saved_order(draft) + self.send_table_count_notification(self.table_id) + return order_id def send_table_count_notification(self, table_ids): messages = [] for config in self.env['pos.config'].search([('floor_ids', 'in', table_ids.floor_id.ids)]): config_cur_session = config.current_session_id - if config_cur_session: order_count = config.get_tables_order_count_and_printing_changes() messages.append((config_cur_session._get_bus_channel_name(), 'TABLE_ORDER_COUNT', order_count))