[ADD] pos_payment_method_cashdro_flow_fix: fix the CashDro payment flow on 18.0

`_cashdro_payment_url()` builds the startOperation url with
`this.pos.pos_session.name`. 18.0 replaced that accessor by the `session`
getter of PosStore, so reading `.name` on the resulting `undefined` raises a
TypeError on every single payment, before any request leaves the browser.
`cashdro_send_payment_request` catches it with a bare `catch` that neither
logs nor rethrows and shows the generic "error connecting to the cashdro",
which is why the failure looked like a problem with the machine.

`pos_payment_method_cashdro_fix` only covers the data loading, so this goes
in a module of its own: it has a longer life than that temporary patch, and
it supersedes its `_cashdro_url()` override.

Along with the blocking one, the flow gets:

* the real error logged and shown in the dialog, so a wrong host, a busy
  drawer and a dead network stop being the same sentence,
* a missing host raised instead of the boolean `false` travelling into
  `fetch`, where it resolves against the Odoo server,
* `finishOperation` always sent on failure, or the drawer keeps the operation
  open and refuses every later payment of the session,
* polling on a 700ms interval with a 5 minute deadline, stopped by a cancel
  or by leaving the payment screen, instead of a tight unbounded loop,
* `send_payment_cancel()` returning whether the drawer took it, so the line
  no longer sticks in `waitingCard`,
* reversals sending the amount of the line negated: `get_due()` only
  discounts the payments already done, so the base module asked the drawer to
  collect the money a second time instead of dispensing it,
* the `uuid` of the line honoured instead of the selected one,
* the CashDro answers validated (HTTP status, JSON body, operation id).

`_cashdro_amount()` is split out as the single hook for the amount, which
`pos_payment_method_cashdro_rounding` uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
GitHub Copilot 2026-08-31 17:43:25 +02:00
parent 40c92613d9
commit 7ae10abda8
Signed by: snt
GPG key ID: A9FD34930EADBE71
8 changed files with 675 additions and 0 deletions

View file

@ -0,0 +1,353 @@
/* Copyright 2026 - Today Criptomart
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
Payment flow of `pos_payment_method_cashdro` reworked for 18.0. See the
README for the reasoning behind each patch; the short version is that the
base module builds the *startOperation* url with the 17.0 session accessor,
which throws before any request leaves the browser, and then hides every
failure — that one included — behind the same generic dialog.
*/
import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
import { PaymentCashdro } from "@pos_payment_method_cashdro/js/payment_cashdro.esm";
import { _t } from "@web/core/l10n/translation";
import { patch } from "@web/core/utils/patch";
// The CashDro answers `askOperation` with the state "F" once the operation is
// finished. Anything else means "keep asking", which we do on a timer instead
// of in a tight loop, and give up on after a while.
const POLL_INTERVAL_MS = 700;
const POLL_TIMEOUT_MS = 5 * 60 * 1000;
/**
* Raised when the polling is stopped on purpose (a cancel, or leaving the
* payment screen). It is not an error to report to the operator.
*/
class CashdroAborted extends Error {}
patch(PaymentCashdro.prototype, {
/**
* @override
*/
setup() {
super.setup(...arguments);
// Set while `_cashdro_request_payment` is asking the drawer, so that a
// cancel can actually stop it.
this.cashdro_polling = false;
},
// --------------------------------------------------------------------------
// Entry points of the PaymentInterface
//
// The three of them get the uuid of the line to act on, and the base module
// ignores it in favour of the selected one: the per-line Send/Reverse
// buttons don't select the line first, so with two payment lines on the
// order the CashDro ends up working on the wrong one. None of them can
// delegate to `super`, as the base body is what we are replacing.
// --------------------------------------------------------------------------
/**
* @override
*/
send_payment_request(uuid) {
const order = this.pos.get_order();
const line = this._cashdro_line(order, uuid);
line?.set_payment_status("waiting");
return this.cashdro_send_payment_request(order, line, false);
},
/**
* @override
*/
send_payment_reversal(uuid) {
const order = this.pos.get_order();
const line = this._cashdro_line(order, uuid);
line?.set_payment_status("reversing");
return this.cashdro_send_payment_request(order, line, true);
},
/**
* @override
* `payment_screen` reads the returned value: anything falsy means "the
* cancel failed" and leaves the line in `waitingCard` for good, with
* `deletePaymentLine` re-entering here on every click. The base method
* returns `undefined` even when the operation was finished just fine.
*/
async send_payment_cancel(order) {
// Stop the polling first: `cashdro_finish_operation` closes the
// operation on the drawer, and asking about it afterwards is pointless.
this.cashdro_polling = false;
const operation = (order || this.pos.get_order())?.cashdro_operation;
if (!operation) {
return true;
}
try {
await this.cashdro_finish_operation(operation);
} catch (error) {
console.error("[CASHDRO] finishOperation failed", error);
return false;
}
return true;
},
/**
* @override
* Leaving the payment screen has to stop the polling too, or it keeps
* hitting the drawer for the rest of the session.
*/
close() {
this.cashdro_polling = false;
return super.close(...arguments);
},
// --------------------------------------------------------------------------
// Payment flow
// --------------------------------------------------------------------------
/**
* @override
* Same three steps as the base module (startOperation, acknowledge, poll),
* with the failures made visible and the started operation always closed.
*
* @param {Object} order
* @param {Object} [line] the line to pay, defaults to the selected one
* @param {Boolean} [reversal]
* @returns {Promise<Boolean>}
*/
async cashdro_send_payment_request(order, line = null, reversal = false) {
const payment_line = line || order.get_selected_paymentline();
let operation_id = "";
try {
const amount = this._cashdro_amount(payment_line, reversal);
const res = await this._cashdro_request(this._cashdro_payment_url({ amount }));
operation_id = res.data || "";
if (!operation_id) {
// The drawer answers a refused operation (busy, bad
// credentials, unknown type) with an envelope carrying no
// data. Acknowledging and polling an empty id gets us nowhere.
throw new Error(
_t("The CashDro did not return an operation id: %s", JSON.stringify(res))
);
}
order.cashdro_operation = operation_id;
await this._cashdro_request(this._cashdro_ack_url(operation_id));
const operation_data = await this._cashdro_request_payment(
this._cashdro_ask_url(operation_id)
);
const data = JSON.parse(operation_data.data);
payment_line.cashdro_operation_data = data;
// `totalin` is what the customer put in the drawer, so it only says
// anything when we were collecting. On a reversal core zeroes the
// line itself, and on a refund the line already holds the amount
// the drawer was asked to give back.
if (!reversal && amount > 0) {
payment_line.set_amount(data.operation.totalin / 100);
}
} catch (error) {
if (error instanceof CashdroAborted) {
// `send_payment_cancel` already closed the operation and core
// takes care of the payment line.
return false;
}
console.error("[CASHDRO] payment request failed", error);
// An operation that was started and never finished keeps the drawer
// busy: it refuses the next `startOperation`, so every later
// payment of the session fails until it is power cycled.
if (operation_id) {
await this.cashdro_finish_operation(operation_id).catch((finish_error) =>
console.error("[CASHDRO] could not close the operation", finish_error)
);
}
payment_line?.set_payment_status("retry");
this.env.services.dialog.add(AlertDialog, {
title: _t("Error"),
body: _t(
"An error occurred while connecting to the cashdro.\n\n%s",
error.message || error
),
});
return false;
}
return true;
},
/**
* @override
* The order is not always around any more when the operation has to be
* closed (a cancel right after validating, for instance), and the caller
* wants to know whether the drawer took it.
*/
async cashdro_finish_operation(operation) {
if (!operation) {
return false;
}
const res = await this._cashdro_request(this._cashdro_finish_url(operation));
const order = this.pos.get_order();
if (order) {
order.cashdro_operation = false;
}
return res;
},
// --------------------------------------------------------------------------
// Amounts
// --------------------------------------------------------------------------
/**
* The amount, in cents, to ask the drawer for: positive collects money,
* negative gives it back. Split out of `cashdro_send_payment_request` so
* that `pos_payment_method_cashdro_rounding` has one single place to hook
* into, whatever the flow that reaches the drawer.
*
* @param {Object} line the payment line being paid or reversed
* @param {Boolean} reversal
* @returns {Number}
*/
_cashdro_amount(line, reversal) {
// `get_due()` only discounts the payments that are already `is_done()`,
// and `sendPaymentReverse` sets the line to "reversing" before getting
// here: the line being given back still counts as pending, so the due
// comes out positive and the base module asks the drawer to *collect*
// the amount a second time instead of dispensing it.
const amount = reversal ? -line.get_amount() : line.pos_order_id.get_due();
// The CashDro treats decimals as positions in an integer, and the
// floating point noise has to be absorbed or the drawer rejects the
// request.
return Math.round(amount * 100);
},
// --------------------------------------------------------------------------
// API communication
// --------------------------------------------------------------------------
/**
* @override
* Two changes over the base method (and over the one in
* `pos_payment_method_cashdro_fix`, which this one supersedes): the payment
* method comes from the terminal itself instead of the selected line —
* `send_payment_cancel` runs when the line is already deselected or
* removed — and a missing host is an error instead of the string "false"
* silently travelling into `fetch`, where it resolves against the Odoo
* server and looks exactly like a dead drawer.
*/
_cashdro_url() {
const method = this.payment_method_id;
if (!method?.cashdro_host) {
throw new Error(
_t(
"The CashDro payment method '%s' has no terminal host configured.",
method?.name || ""
)
);
}
// Cashdro machines don't support safe POST calls, so we're sending
// all the data quite unsafely constantly...
let url = `${method.cashdro_host}/Cashdro3WS/index.php`;
url += `?name=${method.cashdro_user}`;
url += `&password=${method.cashdro_password}`;
return url;
},
/**
* @override
* `this.pos.pos_session` is the 17.0 accessor, renamed in 18.0 to the
* `session` getter of `PosStore`. Reading `.name` on the resulting
* `undefined` throws a TypeError on every single payment, before any
* request reaches the drawer, and the base module turns it into the
* generic "error connecting to the cashdro" dialog.
*/
_cashdro_payment_url(parameters) {
const user = this.pos.get_cashier().id || this.pos.user.id;
// Type 4 for payment, 3 for refund
const operationType = parameters.amount > 0 ? 4 : 3;
parameters = { ...parameters, amount: Math.abs(parameters.amount) };
let url = `${this._cashdro_url()}&operation=startOperation&type=${operationType}`;
url += `&posid=pos-${this.pos.session.name}`;
url += `&posuser=${user}`;
url += `&parameters=${encodeURIComponent(JSON.stringify(parameters))}`;
return url;
},
/**
* @override
* The base method assumes any answer is a valid JSON envelope, so an HTTP
* error page comes back as a bare `SyntaxError` with no hint of what the
* drawer actually said.
*/
async _cashdro_request(url) {
const response = await fetch(url, { method: "GET" });
if (!response.ok) {
throw new Error(
_t("The CashDro answered %(status)s %(statusText)s", {
status: response.status,
statusText: response.statusText,
})
);
}
const body = await response.text();
try {
return JSON.parse(body);
} catch {
throw new Error(_t("Unexpected answer from the CashDro: %s", body.slice(0, 200)));
}
},
/**
* @override
* The base implementation re-asks the drawer with no delay, no bound and no
* way to stop: it floods the CashDro3WS endpoint for as long as the
* customer is inserting coins — which the embedded web service answers by
* dropping connections — and keeps polling a finished operation after a
* cancel.
*
* @param {String} request_url
* @returns {Promise}
*/
_cashdro_request_payment(request_url) {
this.cashdro_polling = true;
const deadline = Date.now() + POLL_TIMEOUT_MS;
return new Promise((resolve, reject) => {
const ask = async () => {
if (!this.cashdro_polling) {
return reject(new CashdroAborted("Polling stopped"));
}
if (Date.now() > deadline) {
this.cashdro_polling = false;
return reject(
new Error(_t("The CashDro did not finish the operation in time."))
);
}
try {
const responseData = await this._cashdro_request(request_url);
const data = JSON.parse(responseData.data);
if (data.operation.state === "F") {
this.cashdro_polling = false;
return resolve(responseData);
}
setTimeout(ask, POLL_INTERVAL_MS);
} catch (error) {
this.cashdro_polling = false;
reject(error);
}
};
ask();
});
},
// --------------------------------------------------------------------------
// Private
// --------------------------------------------------------------------------
/**
* The line the terminal has been asked to act on. 18.0 identifies it by
* uuid; the selected line is only a fallback for the base module's own
* calls.
*
* @param {Object} order
* @param {String} [uuid]
* @returns {Object}
*/
_cashdro_line(order, uuid) {
return (uuid && order.get_paymentline_by_uuid(uuid)) || order.get_selected_paymentline();
},
});