diff --git a/addons/pos_online_payment_self_order/__init__.py b/addons/pos_online_payment_self_order/__init__.py new file mode 100644 index 00000000000..dc5e6b693d1 --- /dev/null +++ b/addons/pos_online_payment_self_order/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/pos_online_payment_self_order/__manifest__.py b/addons/pos_online_payment_self_order/__manifest__.py new file mode 100644 index 00000000000..38c5dc42cda --- /dev/null +++ b/addons/pos_online_payment_self_order/__manifest__.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + + +{ + 'name': 'POS Self-Order / Online Payment', + 'category': 'Sales/Point of Sale', + 'summary': 'Support online payment in self-order', + 'version': '1.0', + 'depends': ['pos_online_payment', 'pos_self_order'], + 'data': [ + 'views/res_config_settings_views.xml', + ], + 'installable': True, + 'auto_install': True, + 'assets': { + 'pos_self_order.assets_self_order': [ + 'pos_online_payment_self_order/static/src/**/*', + ], + 'pos_self_order.assets_tests': [ + 'pos_online_payment_self_order/static/tests/tours/**/*', + ], + }, + 'license': 'LGPL-3', +} diff --git a/addons/pos_online_payment_self_order/models/__init__.py b/addons/pos_online_payment_self_order/models/__init__.py new file mode 100644 index 00000000000..1ef21f4c978 --- /dev/null +++ b/addons/pos_online_payment_self_order/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import pos_config +from . import pos_order +from . import res_config_settings diff --git a/addons/pos_online_payment_self_order/models/pos_config.py b/addons/pos_online_payment_self_order/models/pos_config.py new file mode 100644 index 00000000000..21573bdfc6d --- /dev/null +++ b/addons/pos_online_payment_self_order/models/pos_config.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from typing import Dict +from odoo import models, fields, api, _ +from odoo.exceptions import ValidationError + + +class PosConfig(models.Model): + _inherit = 'pos.config' + + self_order_online_payment_method_id = fields.Many2one('pos.payment.method', string='Online payment method', help="The online payment method to use when a customer pays a self-order online.", domain=[('is_online_payment', '=', True)], compute='_compute_self_order_online_payment_method_id', store=True, readonly=False) + + @api.constrains('self_order_online_payment_method_id') + def _check_self_order_online_payment_method_id(self): + if any(config.self_order_online_payment_method_id and not config.self_order_online_payment_method_id._get_online_payment_providers(config.id, error_if_invalid=True) for config in self): + raise ValidationError(_("The online payment method used for self-order in a POS config must have at least one published payment provider supporting the currency of that POS config.")) + + @api.constrains('self_order_table_mode', 'self_order_pay_after', 'self_order_online_payment_method_id') + def _check_self_order_pay_after_each(self): + if any(config.self_order_table_mode and config.self_order_pay_after == 'each' and not config.self_order_online_payment_method_id for config in self): + raise ValidationError(_("The POS self-order mode with payment after each order requires an online payment method to be configured.")) + + @api.depends('company_id', 'self_order_table_mode', 'self_order_pay_after', 'self_order_online_payment_method_id') + def _compute_self_order_online_payment_method_id(self): + for config in self: + if not config.self_order_table_mode: + config.self_order_online_payment_method_id = False + elif config.self_order_pay_after == 'each' and (not config.self_order_online_payment_method_id or not config.self_order_online_payment_method_id.is_online_payment): + config.self_order_online_payment_method_id = self.env['pos.payment.method'].sudo()._get_or_create_online_payment_method(config.company_id.id, config.id) + + def _get_self_order_data(self) -> Dict: + res = super()._get_self_order_data() + res['has_self_order_online_payment_method'] = bool(self.self_order_online_payment_method_id) + return res diff --git a/addons/pos_online_payment_self_order/models/pos_order.py b/addons/pos_online_payment_self_order/models/pos_order.py new file mode 100644 index 00000000000..6dc2e325895 --- /dev/null +++ b/addons/pos_online_payment_self_order/models/pos_order.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, fields, api, tools +from odoo.osv import expression + + +class PosOrder(models.Model): + _inherit = 'pos.order' + + use_self_order_online_payment = fields.Boolean(compute='_compute_use_self_order_online_payment', store=True, readonly=True) + + @api.depends('config_id.self_order_online_payment_method_id') + def _compute_use_self_order_online_payment(self): + for order in self: + order.use_self_order_online_payment = bool(order.config_id.self_order_online_payment_method_id) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if 'use_self_order_online_payment' not in vals or vals['use_self_order_online_payment']: + session = self.env['pos.session'].browse(vals['session_id']) + config = session.config_id + vals['use_self_order_online_payment'] = bool(config.self_order_online_payment_method_id) + return super().create(vals_list) + + def write(self, vals): + # Because use_self_order_online_payment is not intended to be changed manually, + # avoid to raise an error. + if 'use_self_order_online_payment' not in vals: + return super().write(vals) + + can_change_self_order_domain = [('state', '=', 'draft')] + if vals['use_self_order_online_payment']: + can_change_self_order_domain = expression.AND([can_change_self_order_domain, [('config_id.self_order_online_payment_method_id', '!=', False)]]) + + can_change_self_order_orders = self.filtered_domain(can_change_self_order_domain) + cannot_change_self_order_orders = self - can_change_self_order_orders + + res = True + if can_change_self_order_orders: + res = super(PosOrder, can_change_self_order_orders).write(vals) and res + if cannot_change_self_order_orders: + clean_vals = vals.copy() + clean_vals.pop('use_self_order_online_payment', None) + res = super(PosOrder, cannot_change_self_order_orders).write(clean_vals) and res + + return res + + @api.depends('use_self_order_online_payment', 'config_id.self_order_online_payment_method_id', 'config_id.payment_method_ids') + def _compute_online_payment_method_id(self): + for order in self: + if order.use_self_order_online_payment: + # It is expected to use the self order online payment method. + # If for any reason it is not defined, then the online payment + # of the order is set to null to make the problem noticeable. + order.online_payment_method_id = order.config_id.self_order_online_payment_method_id + else: + super(PosOrder, order)._compute_online_payment_method_id() + + def get_and_set_online_payments_data(self, next_online_payment_amount=False): + res = super().get_and_set_online_payments_data(next_online_payment_amount) + if 'paid_order' not in res and not res.get('deleted', False) and not isinstance(next_online_payment_amount, bool): + # This method is only called in the POS frontend flow, not self order. + # If the next online payment is 0, then the online payment of the frontend + # flow is cancelled, and the default flow is self order if it is configured. + self.use_self_order_online_payment = tools.float_is_zero(next_online_payment_amount, precision_rounding=self.currency_id.rounding) and self.config_id.self_order_online_payment_method_id + return res diff --git a/addons/pos_online_payment_self_order/models/res_config_settings.py b/addons/pos_online_payment_self_order/models/res_config_settings.py new file mode 100644 index 00000000000..55ad58afa4d --- /dev/null +++ b/addons/pos_online_payment_self_order/models/res_config_settings.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + pos_self_order_online_payment_method_id = fields.Many2one(related='pos_config_id.self_order_online_payment_method_id', readonly=False) diff --git a/addons/pos_online_payment_self_order/static/src/pages/order_cart/order_cart.js b/addons/pos_online_payment_self_order/static/src/pages/order_cart/order_cart.js new file mode 100644 index 00000000000..0296b026359 --- /dev/null +++ b/addons/pos_online_payment_self_order/static/src/pages/order_cart/order_cart.js @@ -0,0 +1,71 @@ +/** @odoo-module */ +import { patch } from "@web/core/utils/patch"; +import { OrderCart } from "@pos_self_order/pages/order_cart/order_cart"; +import { _t } from "@web/core/l10n/translation"; + +patch(OrderCart.prototype, "pos_online_payment_self_order.OrderCart", { + get buttonToShow() { + if (this.selfOrder.self_order_mode === "each") { + return { label: _t("Pay"), disabled: false }; + } else if (this.selfOrder.self_order_mode === "meal") { + const order = this.selfOrder.currentOrder; + if (!order) { + return { label: "", disabled: true }; + } + if (!order.isSavedOnServer) { + return { label: _t("Order"), disabled: false }; + } else { + if (this.selfOrder.has_self_order_online_payment_method) { + return { label: _t("Pay"), disabled: false }; + } else { + return { label: _t("Pay at cashier"), disabled: true }; + } + } + } else { + return this._super(...arguments); + } + }, + async processOrder() { + if (this.sendInProgress) { + return; + } + + if (!this.selfOrder.table) { + this.state.selectTable = true; + return; + } + + if (this.selfOrder.self_order_mode === "meal" && this.selfOrder.has_self_order_online_payment_method) { + const order = this.selfOrder.currentOrder; + if (!order) { + this.selfOrder.notification.add(_t("The current order is invalid."), { type: "danger" }); + return; + } + if (!order.isSavedOnServer) { + this.sendInProgress = true; + await this.selfOrder.sendDraftOrderToServer(); + this.sendInProgress = false; + } else { + this.checkAndOpenPaymentPage(order); + } + } else if (this.selfOrder.self_order_mode === "each") { + this.sendInProgress = true; + const order = await this.selfOrder.sendDraftOrderToServer(); + this.sendInProgress = false; + this.checkAndOpenPaymentPage(order); + } else { + return this._super(...arguments); + } + }, + checkAndOpenPaymentPage(order) { + if (order) { + if (order.state === "draft") { + this.selfOrder.openOnlinePaymentPage(order); + } else { + this.selfOrder.notification.add(_t("The current order cannot be paid (maybe it is already paid)."), { type: "danger" }); + } + } else { + this.selfOrder.notification.add(_t("The order could not be saved, therefore its payment is unavailable."), { type: "danger" }); + } + }, +}); diff --git a/addons/pos_online_payment_self_order/static/src/self_order_service.js b/addons/pos_online_payment_self_order/static/src/self_order_service.js new file mode 100644 index 00000000000..c47f45b04a2 --- /dev/null +++ b/addons/pos_online_payment_self_order/static/src/self_order_service.js @@ -0,0 +1,17 @@ +/** @odoo-module */ +import { patch } from "@web/core/utils/patch"; +import { SelfOrder } from "@pos_self_order/self_order_service"; +import { session } from "@web/session"; + +patch(SelfOrder.prototype, "pos_online_payment_self_order.SelfOrder", { + openOnlinePaymentPage({ id: order_id, access_token: order_access_token, pos_config_id: order_pos_config_id }) { + const baseUrl = session.base_url; + let exitRouteUrl = baseUrl + "/menu/" + order_pos_config_id + "?access_token=" + this.access_token; + const tableIdentifier = this.table?.identifier; + if (tableIdentifier) { + exitRouteUrl += "&table_identifier=" + tableIdentifier; + } + const exitRoute = encodeURIComponent(exitRouteUrl); + window.open(baseUrl + `/pos/pay/${order_id}?access_token=${order_access_token}&exit_route=${exitRoute}`, "_self"); + }, +}); diff --git a/addons/pos_online_payment_self_order/static/tests/tours/self_order_after_each_cart_tour.js b/addons/pos_online_payment_self_order/static/tests/tours/self_order_after_each_cart_tour.js new file mode 100644 index 00000000000..952c7eb72ea --- /dev/null +++ b/addons/pos_online_payment_self_order/static/tests/tours/self_order_after_each_cart_tour.js @@ -0,0 +1,19 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { PosSelf } from "@pos_self_order/../tests/tours/tour_utils"; + +registry.category("web_tour.tours").add("self_order_after_each_cart_tour", { + test: true, + steps: () => [ + // Check that the self is open + PosSelf.check.isNotNotification(), + + PosSelf.action.clickPrimaryBtn("View Menu"), + ...PosSelf.action.addProduct("Office Chair Black", 1), + + PosSelf.action.clickPrimaryBtn("Review"), + PosSelf.check.isOrderline("Office Chair Black", "138.58", ""), + PosSelf.check.isPrimaryBtn("Pay"), // Not clicked on because it would open another page, losing the tour setup. + ], +}); diff --git a/addons/pos_online_payment_self_order/tests/__init__.py b/addons/pos_online_payment_self_order/tests/__init__.py new file mode 100644 index 00000000000..3796593f150 --- /dev/null +++ b/addons/pos_online_payment_self_order/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_self_order_frontend diff --git a/addons/pos_online_payment_self_order/tests/test_self_order_frontend.py b/addons/pos_online_payment_self_order/tests/test_self_order_frontend.py new file mode 100644 index 00000000000..92803a3aaef --- /dev/null +++ b/addons/pos_online_payment_self_order/tests/test_self_order_frontend.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from unittest.mock import patch + +import odoo.tests +from odoo.addons.pos_self_order.tests.self_order_common_test import SelfOrderCommonTest +from odoo.addons.pos_online_payment.models.pos_payment_method import PosPaymentMethod + + +@odoo.tests.tagged("post_install", "-at_install") +class TestSelfOrderFrontendMobile(SelfOrderCommonTest): + + def test_self_order_pay_after_each_tour(self): + self.pos_config.self_order_table_mode = True + self.self_order_online_payment_method_id = self.env['pos.payment.method'].sudo()._get_or_create_online_payment_method(self.pos_config.company_id.id, self.pos_config.id) + self.fake_provider = self.env['payment.provider'].create({ + 'name': 'SelfOrderTest', + }) + real_get_online_payment_providers = PosPaymentMethod._get_online_payment_providers + def _fake_get_online_payment_providers(method_self, pos_config_id=False, error_if_invalid=True): + if method_self.id == self.self_order_online_payment_method_id.id: + return self.fake_provider + else: + return real_get_online_payment_providers(method_self, pos_config_id, error_if_invalid) + + with patch.object(PosPaymentMethod, '_get_online_payment_providers', _fake_get_online_payment_providers): + self.pos_config.update({ + 'self_order_pay_after': 'each', + 'self_order_online_payment_method_id': self.self_order_online_payment_method_id, + }) + self.pos_config.open_ui() + + self.start_tour( + self.pos_config._get_self_order_route(), + "self_order_after_each_cart_tour", + login=None, + ) + + self.fake_provider.unlink() diff --git a/addons/pos_online_payment_self_order/views/res_config_settings_views.xml b/addons/pos_online_payment_self_order/views/res_config_settings_views.xml new file mode 100644 index 00000000000..1796f79909a --- /dev/null +++ b/addons/pos_online_payment_self_order/views/res_config_settings_views.xml @@ -0,0 +1,17 @@ + + + + res.config.settings.view.form.inherit.pos_online_payment.view + res.config.settings + + + +
+
+
+
+
+
diff --git a/addons/pos_self_order/controllers/entry.py b/addons/pos_self_order/controllers/entry.py index d558f491a72..bba59fd5c5a 100644 --- a/addons/pos_self_order/controllers/entry.py +++ b/addons/pos_self_order/controllers/entry.py @@ -67,6 +67,7 @@ class PosQRMenuController(http.Controller): 'access_token': pos_config_access_token, **pos_config_sudo._get_self_order_data(), }, + "base_url": request.env['pos.session'].get_base_url(), } } ) diff --git a/addons/pos_self_order/models/pos_config.py b/addons/pos_self_order/models/pos_config.py index 8d0fcc4a284..e9e1770ce98 100644 --- a/addons/pos_self_order/models/pos_config.py +++ b/addons/pos_self_order/models/pos_config.py @@ -34,7 +34,7 @@ class PosConfig(models.Model): help="Allow customers to Order from their phones", ) self_order_pay_after = fields.Selection( - [("each", "Each Order"), ("meal", "Meal")], + [("each", "Each Order (mobile payment only)"), ("meal", "Meal (mobile payment or cashier)")], string="Pay After:", default="meal", help="Choose when the customer will pay", diff --git a/addons/pos_self_order/models/res_config_settings.py b/addons/pos_self_order/models/res_config_settings.py index f0e4b1d918d..1b3933de7ac 100644 --- a/addons/pos_self_order/models/res_config_settings.py +++ b/addons/pos_self_order/models/res_config_settings.py @@ -14,7 +14,7 @@ class ResConfigSettings(models.TransientModel): compute="_compute_pos_module_pos_self_order", store=True, readonly=False ) pos_self_order_pay_after = fields.Selection( - [("each", "Each Order"), ("meal", "Meal")], + [("each", "Each Order (mobile payment only)"), ("meal", "Meal (mobile payment or cashier)")], string="Pay After:", default="meal", help="Choose when the customer will pay", @@ -40,7 +40,7 @@ class ResConfigSettings(models.TransientModel): { "pos_self_order_view_mode": False, "pos_self_order_table_mode": False, - "pos_self_order_pay_after": "each", + "pos_self_order_pay_after": "meal", "pos_self_order_image": False, "pos_self_order_image_name": False, } diff --git a/addons/pos_self_order/static/src/components/lines/lines.js b/addons/pos_self_order/static/src/components/lines/lines.js index 1faa18e030b..c2d296c5de8 100644 --- a/addons/pos_self_order/static/src/components/lines/lines.js +++ b/addons/pos_self_order/static/src/components/lines/lines.js @@ -20,10 +20,7 @@ export class Lines extends Component { clickOnLine(line) { const order = this.props.order; this.selfOrder.editedLine = line; - if ( - !order.access_token || - (order.state === "draft" && this.selfOrder.self_order_mode === "meal") - ) { + if (order.state === "draft") { this.selfOrder.editedOrder = order; this.router.navigate("product", { id: line.product_id }); } else { diff --git a/addons/pos_self_order/static/src/models/order.js b/addons/pos_self_order/static/src/models/order.js index 5dc3f29409d..b55f8a0b35b 100644 --- a/addons/pos_self_order/static/src/models/order.js +++ b/addons/pos_self_order/static/src/models/order.js @@ -67,7 +67,7 @@ export class Order extends Reactive { }, {}); } - isChange() { + hasNotAllLinesSent() { return this.lines.find((line) => { const lastSend = this.lastChangesSent[line.uuid]; @@ -78,4 +78,7 @@ export class Order extends Reactive { return lastSend.qty !== line.qty || line.isChange(lastSend); }); } + get isSavedOnServer() { + return this.isAlreadySent && !this.hasNotAllLinesSent(); + } } diff --git a/addons/pos_self_order/static/src/pages/order_cart/order_cart.js b/addons/pos_self_order/static/src/pages/order_cart/order_cart.js index 628dae9ad90..cd3661bd934 100644 --- a/addons/pos_self_order/static/src/pages/order_cart/order_cart.js +++ b/addons/pos_self_order/static/src/pages/order_cart/order_cart.js @@ -29,7 +29,10 @@ export class OrderCart extends Component { } get buttonToShow() { - return this.selfOrder.self_order_mode === "each" ? "Pay" : "Order"; + return { + label: this.selfOrder.self_order_mode === "each" ? _t("Pay") : _t("Order"), + disabled: false, + }; } get orderToPay() { diff --git a/addons/pos_self_order/static/src/pages/order_cart/order_cart.xml b/addons/pos_self_order/static/src/pages/order_cart/order_cart.xml index 0abbbf5abc1..b5535a3f6b1 100644 --- a/addons/pos_self_order/static/src/pages/order_cart/order_cart.xml +++ b/addons/pos_self_order/static/src/pages/order_cart/order_cart.xml @@ -13,7 +13,7 @@