diff --git a/pos_payment_method_cashdro_fix/README.rst b/pos_payment_method_cashdro_fix/README.rst index 74ef554..d636113 100644 --- a/pos_payment_method_cashdro_fix/README.rst +++ b/pos_payment_method_cashdro_fix/README.rst @@ -51,10 +51,12 @@ To check the three fixes on a PoS with a CashDro terminal: due amount (fix 2). #. The drawer must start the operation and ask for the amount, instead of the *"An error occurred while connecting to the cashdro"* dialog (fixes 1 and 3). -#. With no CashDro at hand, the browser console tells fix 1 from fix 3 apart: - without this module the failure is a ``TypeError: Cannot read properties of - undefined (reading 'payment_method')``; with it, a network error against the - configured host. + +These three fixes are not enough on their own: the payment flow of the base +module is broken on 18.0 for reasons of its own, and it hides every failure — +these ones included — behind that same dialog, without logging anything. +Install ``pos_payment_method_cashdro_flow_fix`` as well; its ``USAGE`` explains +how to read what actually went wrong. The Python side is covered by the tests. The base module lives outside this repo, so the OCA sources have to be on the addons path (mounted by @@ -76,7 +78,7 @@ the credential fields that are missing from the loader, and the JavaScript patches are idempotent with respect to the fixed upstream code. The JavaScript side can't be covered by automated tests, as it needs a CashDro -terminal answering on the network. +terminal answering on the network. See ``USAGE`` for the manual check. Contributors ============ diff --git a/pos_payment_method_cashdro_fix/readme/USAGE.rst b/pos_payment_method_cashdro_fix/readme/USAGE.rst index 2b6d928..ee98fe0 100644 --- a/pos_payment_method_cashdro_fix/readme/USAGE.rst +++ b/pos_payment_method_cashdro_fix/readme/USAGE.rst @@ -7,10 +7,12 @@ To check the three fixes on a PoS with a CashDro terminal: due amount (fix 2). #. The drawer must start the operation and ask for the amount, instead of the *"An error occurred while connecting to the cashdro"* dialog (fixes 1 and 3). -#. With no CashDro at hand, the browser console tells fix 1 from fix 3 apart: - without this module the failure is a ``TypeError: Cannot read properties of - undefined (reading 'payment_method')``; with it, a network error against the - configured host. + +These three fixes are not enough on their own: the payment flow of the base +module is broken on 18.0 for reasons of its own, and it hides every failure — +these ones included — behind that same dialog, without logging anything. +Install ``pos_payment_method_cashdro_flow_fix`` as well; its ``USAGE`` explains +how to read what actually went wrong. The Python side is covered by the tests. The base module lives outside this repo, so the OCA sources have to be on the addons path (mounted by 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(); + }, +}); diff --git a/pos_payment_method_cashdro_rounding/README_DEV.md b/pos_payment_method_cashdro_rounding/README_DEV.md new file mode 100644 index 0000000..0c2e5f5 --- /dev/null +++ b/pos_payment_method_cashdro_rounding/README_DEV.md @@ -0,0 +1,52 @@ +# pos_payment_method_cashdro_rounding + +Sends the CashDro the amount the customer is really expected to pay — the due +amount with the PoS **cash rounding** applied — instead of the plain due amount +the OCA module asks for. + +## Why + +A CashDro can only give change with the coins it holds. When the PoS is +configured with a cash rounding (`pos.config.cash_rounding`, typically to 0.05 +so that 1 and 2 cent coins are not needed), asking the drawer for the unrounded +due leaves it trying to return change it does not have. + +Core already computes that amount: `PosOrder.getDefaultAmountDueToPayIn()` is +what a payment line defaults to when it is created. We simply reuse it, so the +drawer is asked for exactly what the payment screen shows. + +`shouldRound()` decides whether the rounding applies at all — it takes +`only_round_cash_method` into account, and the CashDro method is a cash one, so +in practice it is always true when a cash rounding is configured. + +## How + +One single patch on `PaymentCashdro.prototype._cashdro_amount()`, the hook +`pos_payment_method_cashdro_flow_fix` extracted for this. Everything else of the +OCA payment flow (request, acknowledge, polling, error handling) is untouched. + +Two things this deliberately does **not** do: + +- It does not touch the **reversals**. There the amount to send is the one of + the line being given back, not the remaining of the order, and it is already + the rounded figure that was collected. Rounding it a second time against the + order total would ask the drawer to collect the total all over again. +- It does not touch `_cashdro_payment_url()`, which is what it patched until + `18.0.1.1.0`. That method is shared by the payment and the reversal paths, + which is exactly how the reversal bug above got in, and it collides with the + fix modules that also patch it — the outcome then depending on the asset load + order. + +## Dependencies + +Needs `pos_payment_method_cashdro_flow_fix` for the `_cashdro_amount()` hook, +and through it `pos_payment_method_cashdro_fix`: without them the OCA module +does not work on 18.0 at all, so there is nothing to round. + +## Testing + +There are no automated tests — the flow needs a CashDro answering on the +network. To check it by hand, configure a cash rounding of 0.05 on the PoS, take +an order to a total that is not a multiple of 0.05 (e.g. 17.43) and confirm the +drawer asks for 17.45. See the `USAGE` of +`pos_payment_method_cashdro_flow_fix` for how to read a failure. diff --git a/pos_payment_method_cashdro_rounding/__manifest__.py b/pos_payment_method_cashdro_rounding/__manifest__.py index a16b1e3..1ac11c0 100644 --- a/pos_payment_method_cashdro_rounding/__manifest__.py +++ b/pos_payment_method_cashdro_rounding/__manifest__.py @@ -1,18 +1,19 @@ +# Copyright Criptomart +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). { "name": "POS CashDro Rounding", - "version": "18.0.1.0.0", + "version": "18.0.1.1.0", "summary": "Apply cash/currency rounding to CashDro payment request amount.", "category": "Point of Sale", "license": "AGPL-3", "author": "Criptomart", "depends": [ - "point_of_sale", "pos_payment_method_cashdro", + "pos_payment_method_cashdro_flow_fix", ], "assets": { "point_of_sale._assets_pos": [ "pos_payment_method_cashdro_rounding/static/src/js/cashdro_rounding.esm.js", ], }, - "installable": True, } diff --git a/pos_payment_method_cashdro_rounding/static/src/js/cashdro_rounding.esm.js b/pos_payment_method_cashdro_rounding/static/src/js/cashdro_rounding.esm.js index eb01842..d369ebc 100644 --- a/pos_payment_method_cashdro_rounding/static/src/js/cashdro_rounding.esm.js +++ b/pos_payment_method_cashdro_rounding/static/src/js/cashdro_rounding.esm.js @@ -17,21 +17,29 @@ patch(PaymentCashdro.prototype, { * really expected to pay: the due amount with the cash rounding applied, * which is the very same amount core defaults a payment line to. * - * Only the url builder is patched so the rest of the payment flow of the - * base module (request, acknowledge, polling and error handling) is kept. + * The hook belongs to `pos_payment_method_cashdro_flow_fix`, where the + * amount is computed. This module used to patch `_cashdro_payment_url` + * instead, which is shared with the reversals: there the amount to send is + * the one of the line being given back, not the remaining of the order, so + * reversing a paid order asked the drawer to collect the rounded total all + * over again. */ - _cashdro_payment_url(parameters) { - const order = this.pos.get_order(); - const line = order.get_selected_paymentline(); - if (!line || !order.shouldRound(line.payment_method_id)) { - return super._cashdro_payment_url(...arguments); + _cashdro_amount(line, reversal) { + const order = line.pos_order_id; + // `shouldRound` and `getDefaultAmountDueToPayIn` came with the 18.0 tax + // totals rework. On a build that predates them, fall back instead of + // breaking the payment with a TypeError the operator would read as one + // more connection error. + const rounds = + !reversal && + typeof order.getDefaultAmountDueToPayIn === "function" && + order.shouldRound?.(line.payment_method_id); + if (!rounds) { + return super._cashdro_amount(...arguments); } // CashDro treats decimals as positions in an integer, so we round the // product to avoid the trailing decimals of the floating point // computation or the drawer would reject our request. - const amount = Math.round( - order.getDefaultAmountDueToPayIn(line.payment_method_id) * 100 - ); - return super._cashdro_payment_url({ ...parameters, amount }); + return Math.round(order.getDefaultAmountDueToPayIn(line.payment_method_id) * 100); }, });