[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:
“moda”
2023-10-23 20:07:21 +00:00
parent e15b75efb4
commit c0df2a08b0
16 changed files with 328 additions and 7 deletions
+4 -3
View File
@@ -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 = []
+1 -1
View File
@@ -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)
+4
View File
@@ -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>