diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..c3d410e --- /dev/null +++ b/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- + +from . import models +from . import controllers diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..7cca54a --- /dev/null +++ b/__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/controllers/__init__.py b/controllers/__init__.py new file mode 100644 index 0000000..4d73170 --- /dev/null +++ b/controllers/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import orders diff --git a/controllers/orders.py b/controllers/orders.py new file mode 100644 index 0000000..e7c3d33 --- /dev/null +++ b/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/i18n/pos_self_order_stripe.pot b/i18n/pos_self_order_stripe.pot new file mode 100644 index 0000000..b5a04cc --- /dev/null +++ b/i18n/pos_self_order_stripe.pot @@ -0,0 +1,21 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * pos_self_order_stripe +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-10-26 21:55+0000\n" +"PO-Revision-Date: 2023-10-26 21:55+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: pos_self_order_stripe +#: model:ir.model,name:pos_self_order_stripe.model_pos_payment_method +msgid "Point of Sale Payment Methods" +msgstr "" diff --git a/i18n/ru.po b/i18n/ru.po new file mode 100644 index 0000000..94b34b8 --- /dev/null +++ b/i18n/ru.po @@ -0,0 +1,23 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * pos_self_order_stripe +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-10-26 21:55+0000\n" +"PO-Revision-Date: 2024-01-30 15:14+0400\n" +"Last-Translator: \n" +"Language-Team: Russian (https://app.transifex.com/odoo/teams/41243/ru/)\n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);\n" + +#. module: pos_self_order_stripe +#: model:ir.model,name:pos_self_order_stripe.model_pos_payment_method +msgid "Point of Sale Payment Methods" +msgstr "Способы оплаты в торговых точках" diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..7f48bb5 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import pos_payment_method diff --git a/models/pos_payment_method.py b/models/pos_payment_method.py new file mode 100644 index 0000000..17af357 --- /dev/null +++ b/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/static/src/app/pages/payment_page/payment_page.js b/static/src/app/pages/payment_page/payment_page.js new file mode 100644 index 0000000..e51e5fd --- /dev/null +++ b/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/static/src/app/self_order_service.js b/static/src/app/self_order_service.js new file mode 100644 index 0000000..e51c1da --- /dev/null +++ b/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/static/src/app/stripe.js b/static/src/app/stripe.js new file mode 100644 index 0000000..2e90f26 --- /dev/null +++ b/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/views/assets_stripe.xml b/views/assets_stripe.xml new file mode 100644 index 0000000..8ca735a --- /dev/null +++ b/views/assets_stripe.xml @@ -0,0 +1,10 @@ + + + +