add pos_cashdro_refund,pos_cashdro_allow_manual

This commit is contained in:
Luis 2026-04-24 13:34:06 +02:00
parent 9b25650118
commit b7e8f8967c
14 changed files with 409 additions and 0 deletions

View file

@ -0,0 +1,3 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from . import models

View file

@ -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,
}

View file

@ -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

View file

@ -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,
)

View file

@ -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

View file

@ -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);

View file

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!-- Copyright 2026 Criptomart
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
Extends the PaymentScreenPaymentLines template to add a "Manual" button
next to the Send/Retry action buttons on Cashdro payment lines.
The button is shown when:
- the payment method is of type "cashdro"
- cashdro_allow_manual_amount is True on that payment method
- the line is in "pending" or "retry" state (the two states where
the numpad is already active and the cashier can edit the amount)
Clicking it triggers the "cashdro-send-manual" event which is handled
by CashdroAllowManualPaymentScreen in PaymentScreen.esm.js.
-->
<templates id="template" xml:space="preserve">
<t
t-name="pos_cashdro_allow_manual.PaymentScreenPaymentLines"
t-inherit="point_of_sale.PaymentScreenPaymentLines"
t-inherit-mode="extension"
owl="1"
>
<!--
//div[hasclass('send_payment_request')] matches two nodes:
1. the "Send" button shown in the "pending" state
2. the "Retry" button shown in the "retry" state
position="after" inserts our node after each match,
so the Manual button appears in both states.
-->
<xpath expr="//div[hasclass('send_payment_request')]" position="before">
<t
t-if="line.payment_method.use_payment_terminal === 'cashdro'
and line.payment_method.cashdro_allow_manual_amount"
>
<div
class="button cashdro-manual-button"
style="flex: 0 0 auto; padding: 0 12px; font-size: 0.8em; opacity: 0.75;"
title="Confirmar importe manualmente sin usar la máquina CashDro"
t-on-click="() => this.trigger('cashdro-send-manual', line)"
>
Manual
</div>
</t>
</xpath>
<!--
Extend the t-if on the wrapper <t> that controls the delete button
visibility so that manually-confirmed Cashdro lines keep showing the
delete button even in 'done' state. This lets the cashier undo
an accidental Manual press without blocking the POS session.
The targeted element is the <t t-if="..."> that wraps the
delete-button <div> inside the selected payment line block.
-->
<xpath
expr="//t[@t-if='line.selected']/div/t[div[hasclass('delete-button')]]"
position="attributes"
>
<attribute name="t-if">!line.payment_status or !['done', 'reversed', 'waitingCard', 'waitingCapture'].includes(line.payment_status) or line.cashdro_manual_done</attribute>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!-- Copyright 2026 Criptomart
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<odoo>
<record id="pos_payment_method_cashdro_allow_manual_form" model="ir.ui.view">
<field name="name">pos.payment.method.cashdro.allow.manual.form</field>
<field name="model">pos.payment.method</field>
<field name="inherit_id" ref="pos_payment_method_cashdro.pos_payment_method_view_form"/>
<field name="arch" type="xml">
<field name="cashdro_password" position="after">
<field
name="cashdro_allow_manual_amount"
attrs="{'invisible': [('use_payment_terminal', '!=', 'cashdro')]}"
/>
</field>
</field>
</record>
</odoo>

View file

@ -0,0 +1,2 @@
# Copyright 2026 Criptomart
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

View file

@ -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,
}

View file

@ -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;
});

View file

@ -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 += `&parameters=${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);
};

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Copyright 2026 Criptomart - extracted from pos_payment_method_cashdro
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -->
<templates id="template" xml:space="preserve">
<t t-name="PasswordInputPopup" owl="1">
<div class="popup popup-textinput">
<header class="title">
<t t-esc="props.title" />
</header>
<div class="body">
<p>
<t t-esc="props.body" />
</p>
<input
type="password"
t-model="state.inputValue"
t-ref="input"
t-att-placeholder="props.placeholder"
/>
</div>
<div class="footer">
<div class="button confirm highlight" t-on-click="confirm">
<t t-esc="props.confirmText" />
</div>
<div class="button cancel" t-on-click="cancel">
<t t-esc="props.cancelText" />
</div>
</div>
</div>
</t>
</templates>

View file

@ -11,6 +11,17 @@ import { PaymentCashdro } from "@pos_payment_method_cashdro/js/payment_cashdro.e
PaymentCashdro.prototype.cashdro_send_payment_request = async function (order) { PaymentCashdro.prototype.cashdro_send_payment_request = async function (order) {
const payment_line = order.selected_paymentline; const payment_line = order.selected_paymentline;
try { 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 // Cashdro treats decimals as positions in an integer we also have
// to deal with floating point computing to avoid decimals at the // to deal with floating point computing to avoid decimals at the
// end or the drawer will reject our request. // end or the drawer will reject our request.