diff --git a/pos_cashdro_allow_manual/__init__.py b/pos_cashdro_allow_manual/__init__.py
new file mode 100644
index 0000000..9897421
--- /dev/null
+++ b/pos_cashdro_allow_manual/__init__.py
@@ -0,0 +1,3 @@
+# Copyright 2026 Criptomart
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from . import models
diff --git a/pos_cashdro_allow_manual/__manifest__.py b/pos_cashdro_allow_manual/__manifest__.py
new file mode 100644
index 0000000..c866887
--- /dev/null
+++ b/pos_cashdro_allow_manual/__manifest__.py
@@ -0,0 +1,23 @@
+# Copyright 2026 Criptomart
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+{
+ "name": "POS CashDro Allow Manual Amount",
+ "summary": "Permite introducir manualmente el importe en pagos CashDro",
+ "version": "16.0.1.0.0",
+ "category": "Point of Sale",
+ "license": "AGPL-3",
+ "author": "Criptomart",
+ "depends": [
+ "pos_payment_method_cashdro",
+ ],
+ "data": [
+ "views/pos_payment_method_views.xml",
+ ],
+ "assets": {
+ "point_of_sale.assets": [
+ "pos_cashdro_allow_manual/static/src/js/PaymentScreen.esm.js",
+ "pos_cashdro_allow_manual/static/src/xml/PaymentScreenPaymentLines.xml",
+ ],
+ },
+ "installable": True,
+}
diff --git a/pos_cashdro_allow_manual/models/__init__.py b/pos_cashdro_allow_manual/models/__init__.py
new file mode 100644
index 0000000..5d0027b
--- /dev/null
+++ b/pos_cashdro_allow_manual/models/__init__.py
@@ -0,0 +1,4 @@
+# Copyright 2026 Criptomart
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from . import pos_payment_method
+from . import pos_session
diff --git a/pos_cashdro_allow_manual/models/pos_payment_method.py b/pos_cashdro_allow_manual/models/pos_payment_method.py
new file mode 100644
index 0000000..ead4aa4
--- /dev/null
+++ b/pos_cashdro_allow_manual/models/pos_payment_method.py
@@ -0,0 +1,18 @@
+# Copyright 2026 Criptomart
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from odoo import fields, models
+
+
+class PosPaymentMethod(models.Model):
+ _inherit = "pos.payment.method"
+
+ cashdro_allow_manual_amount = fields.Boolean(
+ string="Permitir importe manual",
+ help=(
+ "Cuando está activado, aparece un botón 'Manual' en la pantalla de "
+ "pago del TPV que permite confirmar el importe de la línea sin pasar "
+ "por la máquina CashDro. Útil ante fallos de comunicación, "
+ "devoluciones u otras situaciones de emergencia."
+ ),
+ default=True,
+ )
diff --git a/pos_cashdro_allow_manual/models/pos_session.py b/pos_cashdro_allow_manual/models/pos_session.py
new file mode 100644
index 0000000..1a0bf31
--- /dev/null
+++ b/pos_cashdro_allow_manual/models/pos_session.py
@@ -0,0 +1,12 @@
+# Copyright 2026 Criptomart
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+from odoo import models
+
+
+class PosSession(models.Model):
+ _inherit = "pos.session"
+
+ def _loader_params_pos_payment_method(self):
+ result = super()._loader_params_pos_payment_method()
+ result["search_params"]["fields"].append("cashdro_allow_manual_amount")
+ return result
diff --git a/pos_cashdro_allow_manual/static/src/js/PaymentScreen.esm.js b/pos_cashdro_allow_manual/static/src/js/PaymentScreen.esm.js
new file mode 100644
index 0000000..6a9df37
--- /dev/null
+++ b/pos_cashdro_allow_manual/static/src/js/PaymentScreen.esm.js
@@ -0,0 +1,45 @@
+/** @odoo-module **/
+// Copyright 2026 Criptomart
+// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+import {Component} from "point_of_sale.Registries";
+import PaymentScreen from "point_of_sale.PaymentScreen";
+import {useListener} from "@web/core/utils/hooks";
+
+const CashdroAllowManualPaymentScreen = (OriginalPaymentScreen) =>
+ class extends OriginalPaymentScreen {
+ setup() {
+ super.setup();
+ useListener("cashdro-send-manual", this._cashdroSendManual);
+ }
+
+ /**
+ * Mark the Cashdro payment line as done using the amount that is already
+ * set on the line (entered manually via the numpad), without contacting
+ * the CashDro machine.
+ *
+ * Sets `cashdro_manual_done = true` on the line so the delete button
+ * remains visible even in `done` state, letting the cashier undo the
+ * operation in case of a mistake.
+ *
+ * Refuses to proceed when the amount is 0 to avoid locking the POS with
+ * an undeletable zero-amount payment line.
+ */
+ async _cashdroSendManual({detail: line}) {
+ if (!line.get_amount()) {
+ this.showPopup("ErrorPopup", {
+ title: this.env._t("Importe no válido"),
+ body: this.env._t(
+ "Introduzca el importe en el teclado numérico antes de confirmar manualmente el pago."
+ ),
+ });
+ return;
+ }
+ // Flag this line as manually confirmed so the template keeps
+ // showing its delete button even after the status is 'done'.
+ line.cashdro_manual_done = true;
+ line.set_payment_status("done");
+ }
+ };
+
+Component.extend(PaymentScreen, CashdroAllowManualPaymentScreen);
diff --git a/pos_cashdro_allow_manual/static/src/xml/PaymentScreenPaymentLines.xml b/pos_cashdro_allow_manual/static/src/xml/PaymentScreenPaymentLines.xml
new file mode 100644
index 0000000..105b80e
--- /dev/null
+++ b/pos_cashdro_allow_manual/static/src/xml/PaymentScreenPaymentLines.xml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+ Manual
+
+
+
+
+
+ !line.payment_status or !['done', 'reversed', 'waitingCard', 'waitingCapture'].includes(line.payment_status) or line.cashdro_manual_done
+
+
+
+
diff --git a/pos_cashdro_allow_manual/views/pos_payment_method_views.xml b/pos_cashdro_allow_manual/views/pos_payment_method_views.xml
new file mode 100644
index 0000000..b1cc651
--- /dev/null
+++ b/pos_cashdro_allow_manual/views/pos_payment_method_views.xml
@@ -0,0 +1,18 @@
+
+
+
+
+ pos.payment.method.cashdro.allow.manual.form
+ pos.payment.method
+
+
+
+
+
+
+
+
diff --git a/pos_cashdro_refund/__init__.py b/pos_cashdro_refund/__init__.py
new file mode 100644
index 0000000..efb0bb1
--- /dev/null
+++ b/pos_cashdro_refund/__init__.py
@@ -0,0 +1,2 @@
+# Copyright 2026 Criptomart
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
diff --git a/pos_cashdro_refund/__manifest__.py b/pos_cashdro_refund/__manifest__.py
new file mode 100644
index 0000000..5914bbe
--- /dev/null
+++ b/pos_cashdro_refund/__manifest__.py
@@ -0,0 +1,21 @@
+# Copyright 2026 Criptomart
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+{
+ "name": "POS CashDro Refund",
+ "summary": "Permite devoluciones (payouts) a través de la máquina CashDro desde el TPV",
+ "version": "16.0.1.0.0",
+ "category": "Point of Sale",
+ "license": "AGPL-3",
+ "author": "Criptomart",
+ "depends": [
+ "pos_payment_method_cashdro",
+ ],
+ "assets": {
+ "point_of_sale.assets": [
+ "pos_cashdro_refund/static/src/js/PasswordInputPopup.js",
+ "pos_cashdro_refund/static/src/js/payment_cashdro_refund.esm.js",
+ "pos_cashdro_refund/static/src/xml/PasswordInputPopup.xml",
+ ],
+ },
+ "installable": True,
+}
diff --git a/pos_cashdro_refund/static/src/js/PasswordInputPopup.js b/pos_cashdro_refund/static/src/js/PasswordInputPopup.js
new file mode 100644
index 0000000..0dfb636
--- /dev/null
+++ b/pos_cashdro_refund/static/src/js/PasswordInputPopup.js
@@ -0,0 +1,38 @@
+odoo.define("pos_cashdro_refund.PasswordInputPopup", function (require) {
+ "use strict";
+ /* Copyright 2026 Criptomart - extracted from pos_payment_method_cashdro
+ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). */
+
+ const AbstractAwaitablePopup = require("point_of_sale.AbstractAwaitablePopup");
+ const Registries = require("point_of_sale.Registries");
+ const {_lt} = require("@web/core/l10n/translation");
+ const {onMounted, useRef, useState} = owl;
+
+ class PasswordInputPopup extends AbstractAwaitablePopup {
+ setup() {
+ super.setup();
+ this.state = useState({inputValue: this.props.startingValue});
+ this.inputRef = useRef("input");
+ onMounted(this.onMounted);
+ }
+ onMounted() {
+ this.inputRef.el.focus();
+ }
+ getPayload() {
+ return this.state.inputValue;
+ }
+ }
+ PasswordInputPopup.template = "PasswordInputPopup";
+ PasswordInputPopup.defaultProps = {
+ confirmText: _lt("Ok"),
+ cancelText: _lt("Cancel"),
+ title: "",
+ body: "",
+ startingValue: "",
+ placeholder: "",
+ };
+
+ Registries.Component.add(PasswordInputPopup);
+
+ return PasswordInputPopup;
+});
diff --git a/pos_cashdro_refund/static/src/js/payment_cashdro_refund.esm.js b/pos_cashdro_refund/static/src/js/payment_cashdro_refund.esm.js
new file mode 100644
index 0000000..de8bc67
--- /dev/null
+++ b/pos_cashdro_refund/static/src/js/payment_cashdro_refund.esm.js
@@ -0,0 +1,117 @@
+/** @odoo-module */
+/* Copyright 2026 Criptomart - extracted from pos_payment_method_cashdro
+ License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
+
+ Adds refund (payout) support to the CashDro payment terminal integration.
+ When the order due amount is negative (i.e. a return/refund), this module:
+ 1. Asks the cashier for the CashDro password to authorise the payout.
+ 2. Sends a type=3 (payout) request to the CashDro instead of the normal
+ type=4 (collection) request.
+ 3. Sets the payment line amount to the negative amount dispensed by the
+ machine (totalout).
+ Normal (positive) payments are delegated unchanged to the original
+ cashdro_send_payment_request method.
+*/
+
+import {PaymentCashdro} from "@pos_payment_method_cashdro/js/payment_cashdro.esm";
+import {Gui} from "point_of_sale.Gui";
+import {_t} from "web.core";
+
+/**
+ * Dedicated refund (payout) handler added to the CashDro prototype.
+ *
+ * Keeping this as a SEPARATE named method (rather than inlining it inside an
+ * override of cashdro_send_payment_request) means that pos_cashdro_rounding
+ * can always delegate refunds here regardless of which module loaded last.
+ * Both modules patch cashdro_send_payment_request directly on the prototype,
+ * so the last writer wins for that method; but _cashdro_send_refund_request is
+ * only ever written by this module and is never overwritten.
+ */
+PaymentCashdro.prototype._cashdro_send_refund_request = async function (order) {
+ const payment_line = order.selected_paymentline;
+ const due = order.get_due(payment_line);
+ // Include cash rounding if applied (same logic as pos_cashdro_rounding for
+ // normal payments). get_rounding_applied() returns 0 when no rounding is
+ // configured, so this is safe without pos_cashdro_rounding installed.
+ const rounding = order.get_rounding_applied ? order.get_rounding_applied() : 0;
+ const amount = Math.round(Math.abs(due + rounding) * 100);
+
+ // Require password authorisation before opening the drawer for a payout.
+ const {confirmed, payload: password} = await Gui.showPopup(
+ "PasswordInputPopup",
+ {
+ title: _t("CashDro Refund Authorization"),
+ body: _t("Enter the CashDro password to authorize this refund"),
+ placeholder: _t("Password"),
+ }
+ );
+ if (!confirmed) {
+ payment_line.set_payment_status("retry");
+ return false;
+ }
+ const method = payment_line.payment_method;
+ if (password !== method.cashdro_password) {
+ await Gui.showPopup("ErrorPopup", {
+ title: _t("Authentication Failed"),
+ body: _t("The CashDro password is incorrect."),
+ });
+ payment_line.set_payment_status("retry");
+ return false;
+ }
+
+ const start_url = this._cashdro_payout_url({amount: amount});
+ console.log(start_url);
+ const res = await this._cashdro_request(start_url);
+ console.log(res);
+ const operation_id = res.data || "";
+ this.pos.get_order().cashdro_operation = operation_id;
+
+ const ack_url = this._cashdro_ack_url(operation_id);
+ const res_ack = await this._cashdro_request(ack_url);
+ console.log(res_ack);
+
+ const ask_url = this._cashdro_ask_url(operation_id);
+ const operation_data = await this._cashdro_request_payment(ask_url);
+ console.log(operation_data);
+
+ const data = JSON.parse(operation_data.data);
+ payment_line.cashdro_operation_data = data;
+ // CashDro reports the dispensed amount in totalout for payouts.
+ const dispensed = data.operation.totalout / 100;
+ payment_line.set_amount(-dispensed);
+ return true;
+};
+
+/**
+ * Build the URL for a type=3 (payout/refund) operation on the CashDro.
+ */
+PaymentCashdro.prototype._cashdro_payout_url = function (parameters) {
+ const user = this.pos.get_cashier().id || this.pos.user.id;
+ let url = `${this._cashdro_url()}&operation=startOperation&type=3`;
+ url += `&posid=pos-${this.pos.pos_session.name}`;
+ url += `&posuser=${user}`;
+ url += `¶meters=${encodeURIComponent(JSON.stringify(parameters))}`;
+ return url;
+};
+
+/**
+ * Override cashdro_send_payment_request to route refunds to
+ * _cashdro_send_refund_request.
+ *
+ * NOTE: if pos_cashdro_rounding is also installed it will overwrite this
+ * override (because "rounding" > "refund" alphabetically), but that is fine
+ * because pos_cashdro_rounding also delegates negative amounts to
+ * _cashdro_send_refund_request. This override only matters when the rounding
+ * module is NOT installed.
+ */
+const _originalSendPaymentRequest =
+ PaymentCashdro.prototype.cashdro_send_payment_request;
+
+PaymentCashdro.prototype.cashdro_send_payment_request = async function (order) {
+ const payment_line = order.selected_paymentline;
+ const due = order.get_due(payment_line);
+ if (due < 0) {
+ return this._cashdro_send_refund_request(order);
+ }
+ return _originalSendPaymentRequest.call(this, order);
+};
diff --git a/pos_cashdro_refund/static/src/xml/PasswordInputPopup.xml b/pos_cashdro_refund/static/src/xml/PasswordInputPopup.xml
new file mode 100644
index 0000000..3e6358c
--- /dev/null
+++ b/pos_cashdro_refund/static/src/xml/PasswordInputPopup.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
diff --git a/pos_cashdro_rounding/static/src/js/cashdro_rounding.esm.js b/pos_cashdro_rounding/static/src/js/cashdro_rounding.esm.js
index e117a1f..222c9f2 100644
--- a/pos_cashdro_rounding/static/src/js/cashdro_rounding.esm.js
+++ b/pos_cashdro_rounding/static/src/js/cashdro_rounding.esm.js
@@ -11,6 +11,17 @@ import { PaymentCashdro } from "@pos_payment_method_cashdro/js/payment_cashdro.e
PaymentCashdro.prototype.cashdro_send_payment_request = async function (order) {
const payment_line = order.selected_paymentline;
try {
+ // If pos_cashdro_refund is installed this method handles payouts.
+ // Rounding is not applicable to refunds so we delegate and return.
+ const due = order.get_due(payment_line);
+ if (due < 0) {
+ if (this._cashdro_send_refund_request) {
+ return this._cashdro_send_refund_request(order);
+ }
+ // Refund module not installed – nothing we can do.
+ payment_line.set_payment_status("retry");
+ return false;
+ }
// Cashdro treats decimals as positions in an integer we also have
// to deal with floating point computing to avoid decimals at the
// end or the drawer will reject our request.