From c0df2a08b099007661d8ccac4fbb37094bc09350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Cmoda=E2=80=9D?= Date: Thu, 12 Oct 2023 16:31:45 +0200 Subject: [PATCH] [ADD] pos_self_order_stripe: add stripe payment method to self order When implementing the Stripe terminal in a device that will be used by end-users, we need to ensure that it is the server that will validate the data and not Javascript as is the case in Point of Sale. Nevertheless, we're using the Javascript SDK for the connection to the terminal, as we don't want to add a Python dependency to the Odoo server How the SDK works: During initialization, we create a terminal via the `create` function. This function will receive as argument a function that generates a unique token with a precise lifetime managed by the SDK. These tokens must be supplied only to trusted devices, such as the kiosk, accessible only if we have `access_token` from the `pos_config`. Then, when making a payment, we make a request to the Odoo server to create a Stripe payment request from the backend, connect the terminal to the frontend via the `connectReader` function and send it the payment request via the `collectPaymentMethod` function, passing it the `client_secret` sent by the Odoo server. We then use the `processPayment` function once the customer has presented his payment card to finalize the payment on the terminal. Finally, we call the `capturePayment` function, which contacts the Odoo server to validate the amount and payment. If this is the case, it will mark the order as paid and send a message via websocket to the kiosk to display the confirmation page. During these various stages, trust is placed in the server and not in the frontend. Stripe Javascript SDK documentation: https://stripe.com/docs/terminal/references/api/js-sdk closes odoo/odoo#138646 Signed-off-by: Joseph Caburnay (jcb) --- addons/pos_self_order/controllers/orders.py | 7 +- addons/pos_self_order/models/pos_config.py | 2 +- .../app/pages/payment_page/payment_page.js | 3 +- .../app/pages/payment_page/payment_page.xml | 2 +- .../static/src/app/self_order_service.js | 10 ++ .../models/pos_payment_method.py | 2 +- addons/pos_self_order_stripe/__init__.py | 4 + addons/pos_self_order_stripe/__manifest__.py | 17 +++ .../controllers/__init__.py | 2 + .../controllers/orders.py | 55 +++++++ .../pos_self_order_stripe/models/__init__.py | 3 + .../models/pos_payment_method.py | 11 ++ .../app/pages/payment_page/payment_page.js | 19 +++ .../static/src/app/self_order_service.js | 49 ++++++ .../static/src/app/stripe.js | 139 ++++++++++++++++++ .../views/assets_stripe.xml | 10 ++ 16 files changed, 328 insertions(+), 7 deletions(-) create mode 100644 addons/pos_self_order_stripe/__init__.py create mode 100644 addons/pos_self_order_stripe/__manifest__.py create mode 100644 addons/pos_self_order_stripe/controllers/__init__.py create mode 100644 addons/pos_self_order_stripe/controllers/orders.py create mode 100644 addons/pos_self_order_stripe/models/__init__.py create mode 100644 addons/pos_self_order_stripe/models/pos_payment_method.py create mode 100644 addons/pos_self_order_stripe/static/src/app/pages/payment_page/payment_page.js create mode 100644 addons/pos_self_order_stripe/static/src/app/self_order_service.js create mode 100644 addons/pos_self_order_stripe/static/src/app/stripe.js create mode 100644 addons/pos_self_order_stripe/views/assets_stripe.xml diff --git a/addons/pos_self_order/controllers/orders.py b/addons/pos_self_order/controllers/orders.py index 59020a99245..20ae52674f9 100644 --- a/addons/pos_self_order/controllers/orders.py +++ b/addons/pos_self_order/controllers/orders.py @@ -177,14 +177,15 @@ class PosSelfOrderController(http.Controller): @http.route('/kiosk/payment//', auth='public', type='json', website=True) def pos_self_order_kiosk_payment(self, pos_config_id, order, payment_method_id, access_token, device_type): + pos_config = self._verify_pos_config(access_token) order_dict = self.process_new_order(order, access_token, None, device_type) if not order_dict.get('id'): raise BadRequest("Something went wrong") # access_token verified in process_new_order - order_sudo = request.env['pos.order'].sudo().browse(order_dict.get('id')) - payment_method_sudo = request.env["pos.payment.method"].sudo().browse(payment_method_id) + order_sudo = pos_config.env['pos.order'].browse(order_dict.get('id')) + payment_method_sudo = pos_config.env["pos.payment.method"].browse(payment_method_id) if not order_sudo or not payment_method_sudo or payment_method_sudo not in order_sudo.config_id.payment_method_ids: raise NotFound("Order or payment method not found") @@ -193,7 +194,7 @@ class PosSelfOrderController(http.Controller): if not status: raise BadRequest("Something went wrong") - return order_sudo._export_for_self_order() + return {'order': order_sudo._export_for_self_order(), 'payment_status': status} def _process_lines(self, lines, pos_config, pos_order_id, take_away=False): appended_uuid = [] diff --git a/addons/pos_self_order/models/pos_config.py b/addons/pos_self_order/models/pos_config.py index 2159de3239d..206c9d3d831 100644 --- a/addons/pos_self_order/models/pos_config.py +++ b/addons/pos_self_order/models/pos_config.py @@ -333,7 +333,7 @@ class PosConfig(models.Model): def _get_self_ordering_data(self): self.ensure_one() payment_search_params = self.current_session_id._loader_params_pos_payment_method() - payment_methods = self.payment_method_ids.filtered(lambda p: p.use_payment_terminal == 'adyen' or p.is_online_payment).read(payment_search_params['search_params']['fields']) + payment_methods = self.payment_method_ids.filtered(lambda p: p.use_payment_terminal in ['adyen', 'stripe'] or p.is_online_payment).read(payment_search_params['search_params']['fields']) default_language = self.self_ordering_default_language_id.read(["code", "name", "iso_code", "flag_image_url"]) return { diff --git a/addons/pos_self_order/static/src/app/pages/payment_page/payment_page.js b/addons/pos_self_order/static/src/app/pages/payment_page/payment_page.js index dbac75f586f..53dbfec326b 100644 --- a/addons/pos_self_order/static/src/app/pages/payment_page/payment_page.js +++ b/addons/pos_self_order/static/src/app/pages/payment_page/payment_page.js @@ -55,11 +55,12 @@ export class PaymentPage extends Component { async startPayment() { this.selfOrder.paymentError = false; try { - const order = await this.rpc(`/kiosk/payment/${this.selfOrder.pos_config_id}/kiosk`, { + const result = await this.rpc(`/kiosk/payment/${this.selfOrder.pos_config_id}/kiosk`, { order: this.selfOrder.currentOrder, access_token: this.selfOrder.access_token, payment_method_id: this.state.paymentMethodId, }); + const order = result.order; this.selfOrder.updateOrderFromServer(order); } catch (error) { this.selfOrder.handleErrorNotification(error); diff --git a/addons/pos_self_order/static/src/app/pages/payment_page/payment_page.xml b/addons/pos_self_order/static/src/app/pages/payment_page/payment_page.xml index 2527e56510a..c1e135ad4d3 100644 --- a/addons/pos_self_order/static/src/app/pages/payment_page/payment_page.xml +++ b/addons/pos_self_order/static/src/app/pages/payment_page/payment_page.xml @@ -23,7 +23,7 @@
- +
diff --git a/addons/pos_self_order/static/src/app/self_order_service.js b/addons/pos_self_order/static/src/app/self_order_service.js index 30161e6e5b9..db33bb46c77 100644 --- a/addons/pos_self_order/static/src/app/self_order_service.js +++ b/addons/pos_self_order/static/src/app/self_order_service.js @@ -154,6 +154,16 @@ export class SelfOrder extends Reactive { initKioskData() { this.ordering = true; + this.idleTimout = false; + + window.addEventListener("click", (event) => { + this.idleTimout && clearTimeout(this.idleTimout); + this.idleTimout = setTimeout(() => { + if (this.router.activeSlot !== "payment") { + this.router.navigate("default"); + } + }, 5 * 1000 * 60); + }); } async initMobileData() { diff --git a/addons/pos_self_order_adyen/models/pos_payment_method.py b/addons/pos_self_order_adyen/models/pos_payment_method.py index f7d68a33625..2058ae8d9aa 100644 --- a/addons/pos_self_order_adyen/models/pos_payment_method.py +++ b/addons/pos_self_order_adyen/models/pos_payment_method.py @@ -8,7 +8,7 @@ class PosPaymentMethod(models.Model): def payment_request_from_kiosk(self, order): if self.use_payment_terminal != 'adyen': - super().payment_request_from_kiosk(order) + return super().payment_request_from_kiosk(order) else: pos_config = order.session_id.config_id random_number = random.randrange(10**9, 10**10 - 1) diff --git a/addons/pos_self_order_stripe/__init__.py b/addons/pos_self_order_stripe/__init__.py new file mode 100644 index 00000000000..c3d410ea160 --- /dev/null +++ b/addons/pos_self_order_stripe/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import models +from . import controllers diff --git a/addons/pos_self_order_stripe/__manifest__.py b/addons/pos_self_order_stripe/__manifest__.py new file mode 100644 index 00000000000..7cca54ad885 --- /dev/null +++ b/addons/pos_self_order_stripe/__manifest__.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +{ + "name": "POS Self Order Stripe", + "summary": "Addon for the Self Order App that allows customers to pay by Stripe.", + "category": "Sales/Point Of Sale", + "depends": ["pos_stripe", "pos_self_order"], + "auto_install": True, + 'data': [ + 'views/assets_stripe.xml', + ], + 'assets': { + 'pos_self_order.assets': [ + 'pos_self_order_stripe/static/**/*', + ], + }, + "license": "LGPL-3", +} diff --git a/addons/pos_self_order_stripe/controllers/__init__.py b/addons/pos_self_order_stripe/controllers/__init__.py new file mode 100644 index 00000000000..4d7317075a3 --- /dev/null +++ b/addons/pos_self_order_stripe/controllers/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import orders diff --git a/addons/pos_self_order_stripe/controllers/orders.py b/addons/pos_self_order_stripe/controllers/orders.py new file mode 100644 index 00000000000..e7c3d3301d2 --- /dev/null +++ b/addons/pos_self_order_stripe/controllers/orders.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +from odoo import http, fields +from odoo.http import request +from odoo.tools import float_is_zero +from odoo.addons.pos_self_order.controllers.orders import PosSelfOrderController +from werkzeug.exceptions import Unauthorized + +class PosSelfOrderControllerStripe(PosSelfOrderController): + @http.route("/pos-self-order/stripe-connection-token/", auth="public", type="json", website=True) + def get_stripe_creditentials(self, access_token, payment_method_id): + # stripe_connection_token + pos_config, _ = self._verify_authorization(access_token, "", False) + payment_method = pos_config.payment_method_ids.filtered(lambda p: p.id == payment_method_id) + return payment_method.stripe_connection_token() + + @http.route("/pos-self-order/stripe-capture-payment/", auth="public", type="json", website=True) + def stripe_capture_payment(self, access_token, order_access_token, payment_intent_id, payment_method_id): + pos_config, _ = self._verify_authorization(access_token, "", False) + stripe_confirmation = pos_config.env['pos.payment.method'].stripe_capture_payment(payment_intent_id) + order = pos_config.env['pos.order'].search([('access_token', '=', order_access_token), ('config_id', '=', pos_config.id)]) + + if not order: + raise Unauthorized() + + payment_method = pos_config.payment_method_ids.filtered(lambda p: p.id == payment_method_id) + stripe_order_amount = payment_method._stripe_calculate_amount(order.amount_total) + + if float_is_zero(stripe_order_amount - stripe_confirmation['amount'], precision_rounding=pos_config.currency_id.rounding) and stripe_confirmation['status'] == 'succeeded': + transaction_id = stripe_confirmation['id'] + payment_result = stripe_confirmation['status'] + + order.add_payment({ + 'amount': order.amount_total, + 'payment_date': fields.Datetime.now(), + 'payment_method_id': payment_method.id, + 'card_type': False, + 'cardholder_name': '', + 'transaction_id': transaction_id, + 'payment_status': payment_result, + 'ticket': '', + 'pos_order_id': order.id + }) + + order.action_pos_order_paid() + + if order.config_id.self_ordering_mode == 'kiosk': + request.env['bus.bus']._sendone(f'pos_config-{order.config_id.access_token}', 'PAYMENT_STATUS', { + 'payment_result': 'Success', + 'order': order._export_for_self_order(), + }) + else: + request.env['bus.bus']._sendone(f'pos_config-{order.config_id.access_token}', 'PAYMENT_STATUS', { + 'payment_result': 'fail', + 'order': order._export_for_self_order(), + }) diff --git a/addons/pos_self_order_stripe/models/__init__.py b/addons/pos_self_order_stripe/models/__init__.py new file mode 100644 index 00000000000..7f48bb56cba --- /dev/null +++ b/addons/pos_self_order_stripe/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import pos_payment_method diff --git a/addons/pos_self_order_stripe/models/pos_payment_method.py b/addons/pos_self_order_stripe/models/pos_payment_method.py new file mode 100644 index 00000000000..47364852e61 --- /dev/null +++ b/addons/pos_self_order_stripe/models/pos_payment_method.py @@ -0,0 +1,11 @@ +from odoo import models + + +class PosPaymentMethod(models.Model): + _inherit = "pos.payment.method" + + def payment_request_from_kiosk(self, order): + if self.use_payment_terminal != 'stripe': + return super().payment_request_from_kiosk(order) + else: + return self.stripe_payment_intent(order.amount_total) diff --git a/addons/pos_self_order_stripe/static/src/app/pages/payment_page/payment_page.js b/addons/pos_self_order_stripe/static/src/app/pages/payment_page/payment_page.js new file mode 100644 index 00000000000..e51e5fdd688 --- /dev/null +++ b/addons/pos_self_order_stripe/static/src/app/pages/payment_page/payment_page.js @@ -0,0 +1,19 @@ +/** @odoo-module */ + +import { patch } from "@web/core/utils/patch"; +import { PaymentPage } from "@pos_self_order/app/pages/payment_page/payment_page"; + +patch(PaymentPage.prototype, { + async startPayment() { + this.selfOrder.paymentError = false; + const paymentMethod = this.selfOrder.pos_payment_methods.find( + (p) => p.id === this.state.paymentMethodId + ); + + if (paymentMethod.use_payment_terminal === "stripe") { + await this.selfOrder.stripe.startPayment(this.selfOrder.currentOrder); + } else { + await super.startPayment(...arguments); + } + }, +}); diff --git a/addons/pos_self_order_stripe/static/src/app/self_order_service.js b/addons/pos_self_order_stripe/static/src/app/self_order_service.js new file mode 100644 index 00000000000..e51c1dac271 --- /dev/null +++ b/addons/pos_self_order_stripe/static/src/app/self_order_service.js @@ -0,0 +1,49 @@ +/** @odoo-module */ +import { patch } from "@web/core/utils/patch"; +import { SelfOrder } from "@pos_self_order/app/self_order_service"; +import { Stripe, StripeError } from "@pos_self_order_stripe/app/stripe"; + +patch(SelfOrder.prototype, { + async setup() { + await super.setup(...arguments); + this.stripeState = "not_connected"; + + const stripePaymentMethod = this.pos_payment_methods.find( + (p) => p.use_payment_terminal === "stripe" + ); + + if (stripePaymentMethod) { + this.stripe = new Stripe( + this.env, + stripePaymentMethod, + this.access_token, + this.pos_config_id, + this.handleStripeError.bind(this), + this.handleReaderConnection.bind(this) + ); + } + }, + handleReaderConnection(state) { + this.stripeState = state.status; + }, + handleStripeError(error) { + this.paymentError = true; + this.handleErrorNotification(error); + }, + handleErrorNotification(error) { + let message = ""; + + if (error.code) { + message = `Error: ${error.code}`; + } else if (error instanceof StripeError) { + message = `Stripe: ${error.message}`; + } else { + super.handleErrorNotification(...arguments); + return; + } + + this.notification.add(message, { + type: "danger", + }); + }, +}); diff --git a/addons/pos_self_order_stripe/static/src/app/stripe.js b/addons/pos_self_order_stripe/static/src/app/stripe.js new file mode 100644 index 00000000000..2e90f2600aa --- /dev/null +++ b/addons/pos_self_order_stripe/static/src/app/stripe.js @@ -0,0 +1,139 @@ +/** @odoo-module **/ +/* global StripeTerminal */ + +export class StripeError extends Error {} + +export class Stripe { + constructor(...args) { + this.setup(...args); + } + + setup( + env, + stripePaymentMethod, + access_token, + pos_config_id, + errorCallback, + handleReaderConnection + ) { + this.env = env; + this.terminal = null; + this.access_token = access_token; + this.stripePaymentMethod = stripePaymentMethod; + this.pos_config_id = pos_config_id; + this.errorCallback = errorCallback; + this.handleReaderConnection = handleReaderConnection; + + this.createTerminal(); + } + + get connectionStatus() { + return this.terminal.getConnectionStatus(); + } + + createTerminal() { + this.terminal = StripeTerminal.create({ + onFetchConnectionToken: this.getBackendConnectionToken.bind(this), + onConnectionStatusChange: this.handleReaderConnection.bind(this), + onUnexpectedReaderDisconnect: () => { + this.handleReaderConnection({ + status: "not_connected", + }); + }, + }); + } + + async startPayment(order) { + try { + const result = await this.env.services.rpc( + `/kiosk/payment/${this.pos_config_id}/kiosk`, + { + order: order, + access_token: this.access_token, + payment_method_id: this.stripePaymentMethod.id, + } + ); + const paymentStatus = result.payment_status; + const savedOrder = result.order; + await this.connectReader(); + const clientSecret = paymentStatus.client_secret; + const paymentMethod = await this.collectPaymentMethod(clientSecret); + const processPayment = await this.processPayment(paymentMethod.paymentIntent); + await this.capturePayment(processPayment.paymentIntent.id, savedOrder); + } catch (error) { + this.errorCallback(error); + } + } + + async processPayment(paymentIntent) { + const result = await this.terminal.processPayment(paymentIntent); + + if (result.error) { + throw new StripeError(result.error.code); + } + + return result; + } + + async getBackendConnectionToken() { + const data = await this.env.services.rpc("/pos-self-order/stripe-connection-token", { + access_token: this.access_token, + payment_method_id: this.stripePaymentMethod.id, + }); + + return data.secret; + } + + async capturePayment(paymentIntentId, order) { + return await this.env.services.rpc("/pos-self-order/stripe-capture-payment", { + access_token: this.access_token, + order_access_token: order.access_token, + payment_intent_id: paymentIntentId, + payment_method_id: this.stripePaymentMethod.id, + }); + } + + async discoverReaders() { + const result = await this.terminal.discoverReaders({ + allowCustomerCancel: true, + }); + + if (result.error) { + throw new StripeError(result.error.code); + } + + return result; + } + + async connectReader() { + if (this.connectionStatus !== "not_connected") { + return; + } + + const discoverReaders = await this.discoverReaders(); + const discoveredReaders = discoverReaders.discoveredReaders; + const findLinkedReader = discoveredReaders.find( + (reader) => reader.serial_number == this.stripePaymentMethod.stripe_serial_number + ); + + const result = await this.terminal.connectReader(findLinkedReader, { + fail_if_in_use: true, + }); + + if (result.error) { + throw new StripeError(result.error.code); + } + + return result; + } + + async collectPaymentMethod(clientSecret) { + const result = await this.terminal.collectPaymentMethod(clientSecret); + + if (result.error) { + throw new StripeError(result.error.code); + } + + return result; + } +} diff --git a/addons/pos_self_order_stripe/views/assets_stripe.xml b/addons/pos_self_order_stripe/views/assets_stripe.xml new file mode 100644 index 00000000000..8ca735a9e98 --- /dev/null +++ b/addons/pos_self_order_stripe/views/assets_stripe.xml @@ -0,0 +1,10 @@ + + + +