[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) <jcb@odoo.com>
This commit is contained in:
@@ -177,14 +177,15 @@ class PosSelfOrderController(http.Controller):
|
||||
|
||||
@http.route('/kiosk/payment/<int:pos_config_id>/<device_type>', 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 = []
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-3 py-4 d-flex gap-1 justify-content-center">
|
||||
<button class="btn btn-primary btn-lg" t-if="state.selection || selectedPaymentMethod.is_online_payment" t-on-click="() => this.router.back()">Back</button>
|
||||
<button class="btn btn-primary btn-lg" t-if="state.selection || selectedPaymentMethod.is_online_payment || this.selfOrder.paymentError" t-on-click="() => this.router.back()">Back</button>
|
||||
<button class="btn btn-info btn-lg" t-if="!state.selection and selfOrder.paymentError" t-on-click="startPayment">Retry</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
@@ -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",
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from . import orders
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import pos_payment_method
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="stripe" inherit_id="pos_self_order.index">
|
||||
<xpath expr="//head" position="inside">
|
||||
<!-- As the following link does not end with '.js', it's not loaded when
|
||||
placed in __manifest__.py. The following declaration fix this problem -->
|
||||
<script src="https://js.stripe.com/terminal/v1/"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user