From 7ae10abda869638ffbd490526c8108e2f9c5d771 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Mon, 31 Aug 2026 17:43:25 +0200 Subject: [PATCH] [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 --- .../README.rst | 164 ++++++++ .../__init__.py | 2 + .../__manifest__.py | 22 ++ .../readme/CONTRIBUTORS.rst | 3 + .../readme/DESCRIPTION.rst | 54 +++ .../readme/ROADMAP.rst | 24 ++ .../readme/USAGE.rst | 53 +++ .../static/src/js/payment_cashdro_flow.esm.js | 353 ++++++++++++++++++ 8 files changed, 675 insertions(+) create mode 100644 pos_payment_method_cashdro_flow_fix/README.rst create mode 100644 pos_payment_method_cashdro_flow_fix/__init__.py create mode 100644 pos_payment_method_cashdro_flow_fix/__manifest__.py create mode 100644 pos_payment_method_cashdro_flow_fix/readme/CONTRIBUTORS.rst create mode 100644 pos_payment_method_cashdro_flow_fix/readme/DESCRIPTION.rst create mode 100644 pos_payment_method_cashdro_flow_fix/readme/ROADMAP.rst create mode 100644 pos_payment_method_cashdro_flow_fix/readme/USAGE.rst create mode 100644 pos_payment_method_cashdro_flow_fix/static/src/js/payment_cashdro_flow.esm.js diff --git a/pos_payment_method_cashdro_flow_fix/README.rst b/pos_payment_method_cashdro_flow_fix/README.rst new file mode 100644 index 0000000..4961dd2 --- /dev/null +++ b/pos_payment_method_cashdro_flow_fix/README.rst @@ -0,0 +1,164 @@ +============================== +POS CashDro Payment Flow Fixes +============================== + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +|badge1| |badge2| + +``pos_payment_method_cashdro_fix`` patches the three 17.0 leftovers that the +18.0 migration of ``pos_payment_method_cashdro`` left in the *data loading*. +This module takes care of the *payment flow* itself, which stays broken after +them. + +**The blocking one.** ``_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 ``this.pos.pos_session`` is ``undefined`` +and reading ``.name`` on it 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 *"An error occurred while connecting to the +cashdro"* — the same message a network failure or an unplugged drawer produce. +That is why the failure looks like a communication problem with the machine, +and why it happens with any other module of ours enabled or disabled. + +**The rest.** Once the payment goes through, the flow still has these problems, +all of them fixed here: + +#. **Failures are invisible.** The ``catch`` has no error binding, so nothing + reaches the browser console. Every cause — a wrong host, a busy drawer, a + bad password, an API mistake — reads as the same sentence. The error is now + logged and its message shown in the dialog. +#. **A missing host is not reported.** ``_cashdro_url()`` returns the boolean + ``false``, which the callers concatenate into ``"false&operation=..."``; + ``fetch`` resolves that against the Odoo server, gets a 404 page back and the + operator sees a connection error. It now raises with the method name. +#. **A started operation is never closed on failure.** If the acknowledge or the + polling fails after ``startOperation`` succeeded, the CashDro keeps the + operation open and refuses the next one: every later payment of the session + fails until the drawer is power cycled. ``finishOperation`` is now always + sent. +#. **The polling has no interval, no bound and no way to stop.** The base + implementation re-asks the drawer as fast as the network allows for as long + as the customer is inserting coins, and keeps going after a cancel. It is now + a 700 ms interval with a 5 minute deadline, stopped by a cancel or by leaving + the payment screen. +#. **A cancel is reported as failed.** ``send_payment_cancel()`` resolves to + ``undefined``, which ``payment_screen`` reads as "the cancel failed" and + leaves the line stuck in ``waitingCard``, re-entering the cancel on every + click. It now returns whether the drawer took it. +#. **A reversal asks the drawer to collect the money again.** + ``order.get_due()`` only discounts the payments that are already done, and + the line being reversed is not one of them, so the amount comes out positive + and the operation type is 4 (payment) instead of 3 (refund). The reversal + now uses the amount of the line, negated. +#. **The uuid of the line is ignored.** The three ``PaymentInterface`` entry + points receive the uuid of the line to act on and the base module works on + the selected one instead. The per-line *Send* and *Reverse* buttons do not + select the line first, so with two payment lines on the order the CashDro + works on the wrong one. +#. **The answers are not validated.** An HTTP error page or an envelope without + ``data`` are taken as valid: the module acknowledges an empty operation id + and polls it forever. Both are now checked. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +There is nothing to configure: installing the module is enough. + +Before anything else, check the payment method +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +18.0 added the ``payment_method_type`` field (*Integration*) to +``pos.payment.method``, and core clears ``use_payment_terminal`` whenever it is +saved as *None required*. On **Point of Sale ‣ Configuration ‣ Payment +Methods**, the CashDro method must have: + +* **Integration** = *Terminal* +* **Integrate with** = *Cashdro* +* the terminal host, user and password filled in + +With *Integration* left at *None required* the terminal is not wired at all: +clicking the payment method just adds a plain cash line, and nothing is ever +sent to the drawer — no dialog, no error, no traffic. + +On site +~~~~~~~ + +#. Open a PoS session, add a product and go to the payment screen. +#. Select the CashDro payment method. The payment line must show **0**, not the + due amount (that one comes from ``pos_payment_method_cashdro_fix``). +#. The drawer must start the operation and ask for the amount. + +If it still fails, the dialog now carries the reason instead of the generic +sentence, and the browser console has the full error under ``[CASHDRO]``. What +to expect: + +``The CashDro payment method '...' has no terminal host configured.`` + The credentials did not reach the front end. Either the fields are empty on + the payment method, or ``pos_payment_method_cashdro_fix`` is not installed. + +``TypeError: Failed to fetch`` / ``NetworkError`` + The browser cannot reach ``cashdro_host``. Check the host and that the till + is on the same network as the drawer. A PoS served over HTTPS also blocks a + plain ``http://`` drawer as mixed content, which shows up here. + +``The CashDro answered 401/403/500 ...`` + The drawer is reachable and answering: wrong user or password, or the + CashDro3WS service is failing. + +``The CashDro did not return an operation id: ...`` + The drawer refused ``startOperation``. Its own envelope is in the message — + usually a busy drawer with an operation still open from a previous failure. + +``The CashDro did not finish the operation in time.`` + ``startOperation`` was accepted but the drawer never reached the finished + state within five minutes. + +There are no automated tests: the flow needs a CashDro terminal answering on the +network, which is also why none of this is caught by the OCA CI. + +Known issues / Roadmap +====================== + +Split from ``pos_payment_method_cashdro_fix`` on purpose: that module is a +temporary patch for three migration leftovers, meant to disappear as soon as +its ``upstream/*.patch`` is merged in ``OCA/pos``. This one has a longer life — +only the ``pos_session`` fix is a migration leftover, the rest are problems the +module has carried since 17.0 — so it should not go away with it. + +It supersedes the ``_cashdro_url()`` patch of ``pos_payment_method_cashdro_fix`` +(same method, resolved from the terminal instead of the selected line, raising +instead of returning ``false``). Once the upstream patch is merged, the +dependency has to be moved to ``pos_payment_method_cashdro`` and the +``_load_pos_data_fields`` override checked, as it is the only part of the other +module this one really needs. + +Still open: + +* The success path leaves the operation open on the drawer, as upstream does. + Whether the CashDro3WS expects a ``finishOperation`` after a finished + operation could not be checked without a machine, so the behaviour was left + untouched. +* ``_cashdro_url()`` does not escape the user and the password, again as + upstream does: credentials containing ``&``, ``=`` or ``#`` break the query + string. +* The whole thing wants a tour with a mocked CashDro3WS endpoint, which would + also let the OCA CI catch this class of breakage. + +Contributors +============ + +* `Criptomart `_: + + * Criptomart diff --git a/pos_payment_method_cashdro_flow_fix/__init__.py b/pos_payment_method_cashdro_flow_fix/__init__.py new file mode 100644 index 0000000..1cfa428 --- /dev/null +++ b/pos_payment_method_cashdro_flow_fix/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 - Today Criptomart +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). diff --git a/pos_payment_method_cashdro_flow_fix/__manifest__.py b/pos_payment_method_cashdro_flow_fix/__manifest__.py new file mode 100644 index 0000000..d2cb622 --- /dev/null +++ b/pos_payment_method_cashdro_flow_fix/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2026 - Today Criptomart +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "POS CashDro Payment Flow Fixes", + "version": "18.0.1.0.0", + "summary": "Fix and harden the CashDro payment flow on 18.0: real errors, " + "bounded polling and correct reversals", + "category": "Point of Sale", + "license": "AGPL-3", + "author": "Criptomart", + "maintainers": ["Criptomart"], + "website": "https://git.criptomart.net/criptomart/addons-cm", + "depends": [ + "pos_payment_method_cashdro", + "pos_payment_method_cashdro_fix", + ], + "assets": { + "point_of_sale._assets_pos": [ + "pos_payment_method_cashdro_flow_fix/static/src/js/payment_cashdro_flow.esm.js", + ], + }, +} diff --git a/pos_payment_method_cashdro_flow_fix/readme/CONTRIBUTORS.rst b/pos_payment_method_cashdro_flow_fix/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..16df929 --- /dev/null +++ b/pos_payment_method_cashdro_flow_fix/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Criptomart `_: + + * Criptomart diff --git a/pos_payment_method_cashdro_flow_fix/readme/DESCRIPTION.rst b/pos_payment_method_cashdro_flow_fix/readme/DESCRIPTION.rst new file mode 100644 index 0000000..c54cdb9 --- /dev/null +++ b/pos_payment_method_cashdro_flow_fix/readme/DESCRIPTION.rst @@ -0,0 +1,54 @@ +``pos_payment_method_cashdro_fix`` patches the three 17.0 leftovers that the +18.0 migration of ``pos_payment_method_cashdro`` left in the *data loading*. +This module takes care of the *payment flow* itself, which stays broken after +them. + +**The blocking one.** ``_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 ``this.pos.pos_session`` is ``undefined`` +and reading ``.name`` on it 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 *"An error occurred while connecting to the +cashdro"* — the same message a network failure or an unplugged drawer produce. +That is why the failure looks like a communication problem with the machine, +and why it happens with any other module of ours enabled or disabled. + +**The rest.** Once the payment goes through, the flow still has these problems, +all of them fixed here: + +#. **Failures are invisible.** The ``catch`` has no error binding, so nothing + reaches the browser console. Every cause — a wrong host, a busy drawer, a + bad password, an API mistake — reads as the same sentence. The error is now + logged and its message shown in the dialog. +#. **A missing host is not reported.** ``_cashdro_url()`` returns the boolean + ``false``, which the callers concatenate into ``"false&operation=..."``; + ``fetch`` resolves that against the Odoo server, gets a 404 page back and the + operator sees a connection error. It now raises with the method name. +#. **A started operation is never closed on failure.** If the acknowledge or the + polling fails after ``startOperation`` succeeded, the CashDro keeps the + operation open and refuses the next one: every later payment of the session + fails until the drawer is power cycled. ``finishOperation`` is now always + sent. +#. **The polling has no interval, no bound and no way to stop.** The base + implementation re-asks the drawer as fast as the network allows for as long + as the customer is inserting coins, and keeps going after a cancel. It is now + a 700 ms interval with a 5 minute deadline, stopped by a cancel or by leaving + the payment screen. +#. **A cancel is reported as failed.** ``send_payment_cancel()`` resolves to + ``undefined``, which ``payment_screen`` reads as "the cancel failed" and + leaves the line stuck in ``waitingCard``, re-entering the cancel on every + click. It now returns whether the drawer took it. +#. **A reversal asks the drawer to collect the money again.** + ``order.get_due()`` only discounts the payments that are already done, and + the line being reversed is not one of them, so the amount comes out positive + and the operation type is 4 (payment) instead of 3 (refund). The reversal + now uses the amount of the line, negated. +#. **The uuid of the line is ignored.** The three ``PaymentInterface`` entry + points receive the uuid of the line to act on and the base module works on + the selected one instead. The per-line *Send* and *Reverse* buttons do not + select the line first, so with two payment lines on the order the CashDro + works on the wrong one. +#. **The answers are not validated.** An HTTP error page or an envelope without + ``data`` are taken as valid: the module acknowledges an empty operation id + and polls it forever. Both are now checked. diff --git a/pos_payment_method_cashdro_flow_fix/readme/ROADMAP.rst b/pos_payment_method_cashdro_flow_fix/readme/ROADMAP.rst new file mode 100644 index 0000000..e06dcf0 --- /dev/null +++ b/pos_payment_method_cashdro_flow_fix/readme/ROADMAP.rst @@ -0,0 +1,24 @@ +Split from ``pos_payment_method_cashdro_fix`` on purpose: that module is a +temporary patch for three migration leftovers, meant to disappear as soon as +its ``upstream/*.patch`` is merged in ``OCA/pos``. This one has a longer life — +only the ``pos_session`` fix is a migration leftover, the rest are problems the +module has carried since 17.0 — so it should not go away with it. + +It supersedes the ``_cashdro_url()`` patch of ``pos_payment_method_cashdro_fix`` +(same method, resolved from the terminal instead of the selected line, raising +instead of returning ``false``). Once the upstream patch is merged, the +dependency has to be moved to ``pos_payment_method_cashdro`` and the +``_load_pos_data_fields`` override checked, as it is the only part of the other +module this one really needs. + +Still open: + +* The success path leaves the operation open on the drawer, as upstream does. + Whether the CashDro3WS expects a ``finishOperation`` after a finished + operation could not be checked without a machine, so the behaviour was left + untouched. +* ``_cashdro_url()`` does not escape the user and the password, again as + upstream does: credentials containing ``&``, ``=`` or ``#`` break the query + string. +* The whole thing wants a tour with a mocked CashDro3WS endpoint, which would + also let the OCA CI catch this class of breakage. diff --git a/pos_payment_method_cashdro_flow_fix/readme/USAGE.rst b/pos_payment_method_cashdro_flow_fix/readme/USAGE.rst new file mode 100644 index 0000000..ca9cfa4 --- /dev/null +++ b/pos_payment_method_cashdro_flow_fix/readme/USAGE.rst @@ -0,0 +1,53 @@ +There is nothing to configure: installing the module is enough. + +Before anything else, check the payment method +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +18.0 added the ``payment_method_type`` field (*Integration*) to +``pos.payment.method``, and core clears ``use_payment_terminal`` whenever it is +saved as *None required*. On **Point of Sale ‣ Configuration ‣ Payment +Methods**, the CashDro method must have: + +* **Integration** = *Terminal* +* **Integrate with** = *Cashdro* +* the terminal host, user and password filled in + +With *Integration* left at *None required* the terminal is not wired at all: +clicking the payment method just adds a plain cash line, and nothing is ever +sent to the drawer — no dialog, no error, no traffic. + +On site +~~~~~~~ + +#. Open a PoS session, add a product and go to the payment screen. +#. Select the CashDro payment method. The payment line must show **0**, not the + due amount (that one comes from ``pos_payment_method_cashdro_fix``). +#. The drawer must start the operation and ask for the amount. + +If it still fails, the dialog now carries the reason instead of the generic +sentence, and the browser console has the full error under ``[CASHDRO]``. What +to expect: + +``The CashDro payment method '...' has no terminal host configured.`` + The credentials did not reach the front end. Either the fields are empty on + the payment method, or ``pos_payment_method_cashdro_fix`` is not installed. + +``TypeError: Failed to fetch`` / ``NetworkError`` + The browser cannot reach ``cashdro_host``. Check the host and that the till + is on the same network as the drawer. A PoS served over HTTPS also blocks a + plain ``http://`` drawer as mixed content, which shows up here. + +``The CashDro answered 401/403/500 ...`` + The drawer is reachable and answering: wrong user or password, or the + CashDro3WS service is failing. + +``The CashDro did not return an operation id: ...`` + The drawer refused ``startOperation``. Its own envelope is in the message — + usually a busy drawer with an operation still open from a previous failure. + +``The CashDro did not finish the operation in time.`` + ``startOperation`` was accepted but the drawer never reached the finished + state within five minutes. + +There are no automated tests: the flow needs a CashDro terminal answering on the +network, which is also why none of this is caught by the OCA CI. diff --git a/pos_payment_method_cashdro_flow_fix/static/src/js/payment_cashdro_flow.esm.js b/pos_payment_method_cashdro_flow_fix/static/src/js/payment_cashdro_flow.esm.js new file mode 100644 index 0000000..c93ca9a --- /dev/null +++ b/pos_payment_method_cashdro_flow_fix/static/src/js/payment_cashdro_flow.esm.js @@ -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} + */ + 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 += `¶meters=${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(); + }, +});