Initial commit: Pos packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:50 +02:00
commit 95dfb9edb0
1301 changed files with 264148 additions and 0 deletions

View file

@ -0,0 +1,6 @@
odoo.define('pos_stripe.models', function (require) {
var models = require('point_of_sale.models');
var PaymentStripe = require('pos_stripe.payment');
models.register_payment_method('stripe', PaymentStripe);
});

View file

@ -0,0 +1,319 @@
/* global StripeTerminal */
odoo.define('pos_stripe.payment', function (require) {
"use strict";
const core = require('web.core');
const rpc = require('web.rpc');
const PaymentInterface = require('point_of_sale.PaymentInterface');
const { Gui } = require('point_of_sale.Gui');
const _t = core._t;
let PaymentStripe = PaymentInterface.extend({
init: function (pos, payment_method) {
this._super(...arguments);
this.createStripeTerminal();
},
stripeUnexpectedDisconnect: function () {
// Include a way to attempt to reconnect to a reader ?
this._showError(_t('Reader disconnected'));
},
stripeFetchConnectionToken: async function () {
// Do not cache or hardcode the ConnectionToken.
try {
let data = await rpc.query({
model: 'pos.payment.method',
method: 'stripe_connection_token',
kwargs: { context: this.pos.env.session.user_context },
}, {
silent: true,
});
if (data.error) {
throw data.error;
}
return data.secret;
} catch (error) {
const message = error.message.code === 200 ? error.message.data.message : error.message.message;
this._showError(message, 'Fetch Token');
this.terminal = false;
};
},
discoverReaders: async function () {
let discoverResult = await this.terminal.discoverReaders({});
if (discoverResult.error) {
this._showError(_.str.sprintf(_t('Failed to discover: %s'), discoverResult.error));
} else if (discoverResult.discoveredReaders.length === 0) {
this._showError(_t('No available Stripe readers.'));
} else {
// Need to stringify all Readers to avoid to put the array into a proxy Object not interpretable
// for the Stripe SDK
this.pos.discoveredReaders = JSON.stringify(discoverResult.discoveredReaders);
}
},
checkReader: async function () {
try {
if ( !this.terminal ) {
let createStripeTerminal = this.createStripeTerminal();
if ( !createStripeTerminal ) {
throw _t('Failed to load resource: net::ERR_INTERNET_DISCONNECTED.');
}
}
} catch (error) {
this._showError(error);
return false;
}
let line = this.pos.get_order().selected_paymentline;
// Because the reader can only connect to one instance of the SDK at a time.
// We need the disconnect this reader if we want to use another one
if (
this.pos.connectedReader != this.payment_method.stripe_serial_number &&
this.terminal.getConnectionStatus() == 'connected'
) {
let disconnectResult = await this.terminal.disconnectReader();
if (disconnectResult.error) {
this._showError(disconnectResult.error.message, disconnectResult.error.code);
line.set_payment_status('retry');
return false;
} else {
return await this.connectReader();
}
} else if (this.terminal.getConnectionStatus() == 'not_connected') {
return await this.connectReader();
} else {
return true;
}
},
connectReader: async function () {
let line = this.pos.get_order().selected_paymentline;
let discoveredReaders = JSON.parse(this.pos.discoveredReaders);
for (const selectedReader of discoveredReaders) {
if (selectedReader.serial_number == this.payment_method.stripe_serial_number) {
try {
let connectResult = await this.terminal.connectReader(selectedReader, {fail_if_in_use: true});
if (connectResult.error) {
throw connectResult;
}
this.pos.connectedReader = this.payment_method.stripe_serial_number;
return true;
} catch (error) {
if (error.error) {
this._showError(error.error.message, error.code);
} else {
this._showError(error);
}
line.set_payment_status('retry');
return false;
}
}
}
this._showError(_.str.sprintf(
_t('Stripe readers %s not listed in your account'),
this.payment_method.stripe_serial_number
));
},
_getCapturedCardAndTransactionId: function (processPayment) {
const charges = processPayment.paymentIntent.charges;
if (!charges) {
return [false, false];
}
const intentCharge = charges.data[0];
const processPaymentDetails = intentCharge.payment_method_details;
if (processPaymentDetails.type === 'interac_present') {
// Canadian interac payments should not be captured:
// https://stripe.com/docs/terminal/payments/regional?integration-country=CA#create-a-paymentintent
return ['interac', intentCharge.id];
}
const cardPresentBrand = this.getCardBrandFromPaymentMethodDetails(processPaymentDetails);
if (cardPresentBrand.includes('eftpos')) {
// Australian eftpos should not be captured:
// https://stripe.com/docs/terminal/payments/regional?integration-country=AU
return [cardPresentBrand, intentCharge.id];
}
return [false, false];
},
collectPayment: async function (amount) {
let line = this.pos.get_order().selected_paymentline;
let clientSecret = await this.fetchPaymentIntentClientSecret(line.payment_method, amount);
if (!clientSecret) {
line.set_payment_status('retry');
return false;
}
line.set_payment_status('waitingCard');
let collectPaymentMethod = await this.terminal.collectPaymentMethod(clientSecret);
if (collectPaymentMethod.error) {
this._showError(collectPaymentMethod.error.message, collectPaymentMethod.error.code);
line.set_payment_status('retry');
return false;
} else {
line.set_payment_status('waitingCapture');
let processPayment = await this.terminal.processPayment(collectPaymentMethod.paymentIntent);
line.transaction_id = collectPaymentMethod.paymentIntent.id;
if (processPayment.error) {
this._showError(processPayment.error.message, processPayment.error.code);
line.set_payment_status('retry');
return false;
} else if (processPayment.paymentIntent) {
line.set_payment_status('waitingCapture');
const [captured_card_type, captured_transaction_id] = this._getCapturedCardAndTransactionId(processPayment);
if (captured_card_type && captured_transaction_id) {
line.card_type = captured_card_type;
line.transaction_id = captured_transaction_id;
} else {
await this.captureAfterPayment(processPayment, line);
}
line.set_payment_status('done');
return true;
}
}
},
createStripeTerminal: function () {
try {
this.terminal = StripeTerminal.create({
onFetchConnectionToken: this.stripeFetchConnectionToken.bind(this),
onUnexpectedReaderDisconnect: this.stripeUnexpectedDisconnect.bind(this),
});
this.discoverReaders();
return true;
} catch (error) {
this._showError(_t('Failed to load resource: net::ERR_INTERNET_DISCONNECTED.'), error);
this.terminal = false;
return false;
}
},
getCardBrandFromPaymentMethodDetails(paymentMethodDetails) {
// Both `card_present` and `interac_present` are "nullable" so we need to check for their existence, see:
// https://docs.stripe.com/api/charges/object#charge_object-payment_method_details-card_present
// https://docs.stripe.com/api/charges/object#charge_object-payment_method_details-interac_present
// In Canada `card_present` might not be present, but `interac_present` will be
if (paymentMethodDetails.card_present) {
return paymentMethodDetails.card_present.brand;
}
if (paymentMethodDetails.interac_present) {
return paymentMethodDetails.interac_present.brand;
}
return "";
},
captureAfterPayment: async function (processPayment, line) {
let capturePayment = await this.capturePayment(processPayment.paymentIntent.id);
if (capturePayment.charges)
line.card_type = this.getCardBrandFromPaymentMethodDetails(capturePayment.charges.data[0].payment_method_details);
line.transaction_id = capturePayment.id;
},
capturePayment: async function (paymentIntentId) {
try {
let data = await rpc.query({
model: 'pos.payment.method',
method: 'stripe_capture_payment',
args: [paymentIntentId],
kwargs: { context: this.pos.env.session.user_context },
}, {
silent: true,
});
if (data.error) {
throw data.error;
}
return data;
} catch (error) {
const message = error.message.code === 200 ? error.message.data.message : error.message.message;
this._showError(message, 'Capture Payment');
return false;
};
},
fetchPaymentIntentClientSecret: async function (payment_method, amount) {
try {
let data = await rpc.query({
model: 'pos.payment.method',
method: 'stripe_payment_intent',
args: [[payment_method.id], amount],
kwargs: { context: this.pos.env.session.user_context },
}, {
silent: true,
});
if (data.error) {
throw data.error;
}
return data.client_secret;
} catch (error) {
const message = error.message.code === 200 ? error.message.data.message : error.message.message || error.message;
this._showError(message, 'Fetch Secret');
return false;
};
},
send_payment_request: async function (cid) {
/**
* Override
*/
await this._super.apply(this, arguments);
let line = this.pos.get_order().selected_paymentline;
line.set_payment_status('waiting');
try {
if (await this.checkReader()) {
return await this.collectPayment(line.amount);
}
} catch (error) {
this._showError(error);
return false;
}
},
send_payment_cancel: async function (order, cid) {
/**
* Override
*/
this._super.apply(this, arguments);
let line = this.pos.get_order().selected_paymentline;
let stripeCancel = await this.stripeCancel();
if (stripeCancel) {
line.set_payment_status('retry');
return true;
}
},
stripeCancel: async function () {
if (!this.terminal) {
return true;
} else if (this.terminal.getConnectionStatus() != 'connected') {
this._showError(_t('Payment canceled because not reader connected'));
return true;
} else {
let cancelCollectPaymentMethod = await this.terminal.cancelCollectPaymentMethod();
if (cancelCollectPaymentMethod.error) {
this._showError(cancelCollectPaymentMethod.error.message, cancelCollectPaymentMethod.error.code);
}
return true;
}
},
// private methods
_showError: function (msg, title) {
if (!title) {
title = _t('Stripe Error');
}
Gui.showPopup('ErrorPopup',{
'title': title,
'body': msg,
});
},
});
return PaymentStripe;
});