POS: totales y pago en USD con tipo de cambio

- Equivalente USD en totales del ticket (TC configurable)
- Selector MXN/USD al cobrar: recibido en dólares convertido a MXN,
  cambio en ambas monedas, referencia 'USD X @ TC Y' en el pago
- Validación de TC antes de crear la venta (sin ventas huérfanas)
This commit is contained in:
2026-08-14 06:42:18 +00:00
parent 84f34c542f
commit 953cdf1108
3 changed files with 138 additions and 17 deletions

View File

@@ -20,6 +20,9 @@ import { odooApi, type Service, type Patient, type InventoryItem, type PosChecko
const fmtMoney = (n: number) => const fmtMoney = (n: number) =>
(n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 }); (n || 0).toLocaleString('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 2 });
const fmtUsd = (n: number) =>
`$${(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} USD`;
const METODO_OPTIONS = [ const METODO_OPTIONS = [
{ value: 'cash', label: 'Efectivo' }, { value: 'cash', label: 'Efectivo' },
{ value: 'card', label: 'Tarjeta' }, { value: 'card', label: 'Tarjeta' },
@@ -62,9 +65,11 @@ const Pos: FC = () => {
// Cobro // Cobro
const [cobroOpen, setCobroOpen] = useState(false); const [cobroOpen, setCobroOpen] = useState(false);
const [metodo, setMetodo] = useState('cash'); const [metodo, setMetodo] = useState('cash');
const [moneda, setMoneda] = useState<'MXN' | 'USD'>('MXN');
const [recibido, setRecibido] = useState(''); const [recibido, setRecibido] = useState('');
const [conPuntos, setConPuntos] = useState(false); const [conPuntos, setConPuntos] = useState(false);
const [cobrando, setCobrando] = useState(false); const [cobrando, setCobrando] = useState(false);
const [tcRate, setTcRate] = useState<number | null>(null);
// Resultado // Resultado
const [resultado, setResultado] = useState<PosCheckoutResult | null>(null); const [resultado, setResultado] = useState<PosCheckoutResult | null>(null);
@@ -118,6 +123,18 @@ const Pos: FC = () => {
.catch(() => {}); .catch(() => {});
}, []); }, []);
// Tipo de cambio USD/MXN: al montar y refresco cada 5 min
useEffect(() => {
const fetchTc = () => {
odooApi.getExchangeRate()
.then((res) => { if (res.current) setTcRate(res.current.rate); })
.catch(() => {});
};
fetchTc();
const interval = setInterval(fetchTc, 5 * 60 * 1000);
return () => clearInterval(interval);
}, []);
// Búsqueda server-side de paciente // Búsqueda server-side de paciente
useEffect(() => { useEffect(() => {
if (paciente || altaRapida) return; if (paciente || altaRapida) return;
@@ -183,7 +200,15 @@ const Pos: FC = () => {
const numItems = lines.reduce((a, l) => a + l.qty, 0); const numItems = lines.reduce((a, l) => a + l.qty, 0);
const puntosPaciente = paciente?.wallet_points ?? 0; const puntosPaciente = paciente?.wallet_points ?? 0;
const puedePuntos = puntosPaciente >= total && total > 0; const puedePuntos = puntosPaciente >= total && total > 0;
const cambio = metodo === 'cash' && recibido ? Math.max(0, (parseFloat(recibido) || 0) - total) : 0; // Equivalentes USD (si hay tipo de cambio configurado)
const toUsd = (mxn: number): number | null => (tcRate ? mxn / tcRate : null);
const recibidoMxn = recibido
? moneda === 'USD' && tcRate
? (parseFloat(recibido) || 0) * tcRate
: parseFloat(recibido) || 0
: 0;
const cambio = metodo === 'cash' && recibido ? Math.max(0, recibidoMxn - total) : 0;
const cambioUsd = moneda === 'USD' && tcRate ? cambio / tcRate : 0;
const crearPacienteRapido = async () => { const crearPacienteRapido = async () => {
if (!altaForm.name.trim() || !altaForm.phone.trim()) { if (!altaForm.name.trim() || !altaForm.phone.trim()) {
@@ -214,6 +239,7 @@ const Pos: FC = () => {
return; return;
} }
setMetodo('cash'); setMetodo('cash');
setMoneda('MXN');
setRecibido(''); setRecibido('');
setConPuntos(false); setConPuntos(false);
setCobroOpen(true); setCobroOpen(true);
@@ -235,6 +261,7 @@ const Pos: FC = () => {
payment_method: metodo, payment_method: metodo,
amount_received: metodo === 'cash' && recibido ? parseFloat(recibido) : undefined, amount_received: metodo === 'cash' && recibido ? parseFloat(recibido) : undefined,
pay_with_points: conPuntos, pay_with_points: conPuntos,
currency: moneda,
}); });
if (res.status === 'success') { if (res.status === 'success') {
setResultado(res); setResultado(res);
@@ -372,23 +399,41 @@ const Pos: FC = () => {
<> <>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-theme-muted">Subtotal</span> <span className="text-theme-muted">Subtotal</span>
<span className="text-theme-heading">{fmtMoney(subtotal)}</span> <span className="text-theme-heading">
{fmtMoney(subtotal)}
<span className="text-xs text-theme-muted ml-1.5" title={toUsd(subtotal) === null ? 'Configura el tipo de cambio en Configuración' : undefined}>
{toUsd(subtotal) !== null ? `${fmtUsd(toUsd(subtotal)!)}` : '—'}
</span>
</span>
</div> </div>
<div className="flex items-center justify-between text-sm gap-2"> <div className="flex items-center justify-between text-sm gap-2">
<span className="text-theme-muted">Descuento</span> <span className="text-theme-muted">Descuento</span>
<input <span className="flex items-center gap-1.5">
type="number" {discountNum > 0 && toUsd(discountNum) !== null && (
min={0} <span className="text-xs text-theme-muted"> {fmtUsd(toUsd(discountNum)!)}</span>
step={50} )}
value={discount} <input
onChange={(e) => setDiscount(e.target.value)} type="number"
placeholder="0" min={0}
className="w-28 border border-theme-border-strong rounded-lg px-2 py-1 text-sm text-right" step={50}
/> value={discount}
onChange={(e) => setDiscount(e.target.value)}
placeholder="0"
className="w-28 border border-theme-border-strong rounded-lg px-2 py-1 text-sm text-right"
/>
</span>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-base font-semibold text-theme-heading">Total</span> <span className="text-base font-semibold text-theme-heading">Total</span>
<span className="text-xl font-heading font-bold text-theme-heading">{fmtMoney(total)}</span> <span className="text-right">
<span className="text-xl font-heading font-bold text-theme-heading">{fmtMoney(total)}</span>
<span
className="block text-xs text-theme-muted"
title={toUsd(total) === null ? 'Configura el tipo de cambio en Configuración' : undefined}
>
{toUsd(total) !== null ? `${fmtUsd(toUsd(total)!)}` : '—'}
</span>
</span>
</div> </div>
<Button <Button
className="w-full mt-1 !py-3 text-base" className="w-full mt-1 !py-3 text-base"
@@ -591,24 +636,60 @@ const Pos: FC = () => {
<Select label="Método de pago" options={METODO_OPTIONS} value={metodo} onChange={(e) => setMetodo(e.target.value)} /> <Select label="Método de pago" options={METODO_OPTIONS} value={metodo} onChange={(e) => setMetodo(e.target.value)} />
{metodo === 'cash' && !conPuntos && ( {metodo === 'cash' && !conPuntos && (
<> <>
<div>
<p className="block text-xs font-medium mb-1.5 text-theme-muted">Moneda</p>
<div className="inline-flex rounded-full border border-theme-border-strong overflow-hidden">
<button
type="button"
onClick={() => setMoneda('MXN')}
className={`px-4 py-1.5 text-sm font-medium transition ${
moneda === 'MXN' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
}`}
>
MXN
</button>
<button
type="button"
onClick={() => tcRate && setMoneda('USD')}
disabled={!tcRate}
title={!tcRate ? 'Configura el tipo de cambio en Configuración' : undefined}
className={`px-4 py-1.5 text-sm font-medium transition ${
moneda === 'USD' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-surface text-theme-muted hover:bg-theme-bg'
} ${!tcRate ? 'opacity-40 cursor-not-allowed' : ''}`}
>
USD
</button>
</div>
{moneda === 'USD' && tcRate && (
<p className="text-xs text-theme-muted mt-1.5">Tipo de cambio: {tcRate.toFixed(2)} MXN por 1 USD</p>
)}
</div>
<Input <Input
label="Recibido" label={moneda === 'USD' ? 'Recibido (USD)' : 'Recibido'}
type="number" type="number"
min={0} min={0}
step={10} step={10}
value={recibido} value={recibido}
onChange={(e) => setRecibido(e.target.value)} onChange={(e) => setRecibido(e.target.value)}
placeholder={String(total)} placeholder={moneda === 'USD' && tcRate ? (total / tcRate).toFixed(2) : String(total)}
/> />
{moneda === 'USD' && recibido && tcRate && (
<p className="text-sm text-theme-muted"> {fmtMoney(recibidoMxn)} MXN</p>
)}
{recibido && ( {recibido && (
<p className="text-sm"> <p className="text-sm">
<span className="text-theme-muted">Cambio: </span> <span className="text-theme-muted">Cambio: </span>
<span className={`font-semibold ${cambio > 0 ? 'text-theme-heading' : 'text-theme-muted'}`}>{fmtMoney(cambio)}</span> <span className={`font-semibold ${cambio > 0 ? 'text-theme-heading' : 'text-theme-muted'}`}>
{fmtMoney(cambio)}
{moneda === 'USD' && cambio > 0 && (
<span className="text-theme-muted font-normal"> ( {fmtUsd(cambioUsd)})</span>
)}
</span>
</p> </p>
)} )}
</> </>
)} )}
{puedePuntos && ( {puedePuntos && moneda === 'MXN' && (
<label className="flex items-center gap-3 p-3 border border-theme-border-strong rounded-xl cursor-pointer hover:bg-theme-bg"> <label className="flex items-center gap-3 p-3 border border-theme-border-strong rounded-xl cursor-pointer hover:bg-theme-bg">
<input <input
type="checkbox" type="checkbox"
@@ -676,8 +757,19 @@ const Pos: FC = () => {
)} )}
<div className="flex justify-between font-bold text-theme-heading"><span>Total</span><span>{fmtMoney(resultado.sale.total)}</span></div> <div className="flex justify-between font-bold text-theme-heading"><span>Total</span><span>{fmtMoney(resultado.sale.total)}</span></div>
<div className="flex justify-between"><span className="text-theme-muted">Método</span><span>{resultado.payment ? metodoLabel(resultado.payment.payment_method) : 'Puntos'}</span></div> <div className="flex justify-between"><span className="text-theme-muted">Método</span><span>{resultado.payment ? metodoLabel(resultado.payment.payment_method) : 'Puntos'}</span></div>
{resultado.recibido_usd && resultado.recibido_usd > 0 && resultado.rate_used && (
<div className="flex justify-between"><span className="text-theme-muted">Recibido USD</span><span>{fmtUsd(resultado.recibido_usd)} (TC {resultado.rate_used.toFixed(2)})</span></div>
)}
{resultado.cambio > 0 && ( {resultado.cambio > 0 && (
<div className="flex justify-between"><span className="text-theme-muted">Cambio</span><span>{fmtMoney(resultado.cambio)}</span></div> <div className="flex justify-between">
<span className="text-theme-muted">Cambio</span>
<span>
{fmtMoney(resultado.cambio)}
{resultado.cambio_usd && resultado.cambio_usd > 0 && (
<span className="text-theme-muted"> · {fmtUsd(resultado.cambio_usd)}</span>
)}
</span>
</div>
)} )}
{resultado.puntos_ganados > 0 && ( {resultado.puntos_ganados > 0 && (
<div className="flex justify-between"><span className="text-theme-muted">Puntos ganados</span><span>+{resultado.puntos_ganados} pts</span></div> <div className="flex justify-between"><span className="text-theme-muted">Puntos ganados</span><span>+{resultado.puntos_ganados} pts</span></div>

View File

@@ -690,6 +690,7 @@ export interface PosCheckoutPayload {
payment_method?: string; payment_method?: string;
amount_received?: number; amount_received?: number;
pay_with_points?: boolean; pay_with_points?: boolean;
currency?: 'MXN' | 'USD';
} }
export interface PosCheckoutResult { export interface PosCheckoutResult {
@@ -697,6 +698,9 @@ export interface PosCheckoutResult {
sale: Sale; sale: Sale;
payment: Payment | null; payment: Payment | null;
cambio: number; cambio: number;
cambio_usd?: number;
rate_used?: number | null;
recibido_usd?: number;
puntos_usados: number; puntos_usados: number;
puntos_ganados: number; puntos_ganados: number;
wallet_points: number; wallet_points: number;

View File

@@ -2016,6 +2016,17 @@ class SkeenFrontendController(http.Controller):
})) }))
total_est = max(0.0, subtotal - discount) total_est = max(0.0, subtotal - discount)
# Moneda y tipo de cambio: validar ANTES de crear nada
currency = data.get('currency', 'MXN')
rate_used = None
if currency == 'USD':
tc = request.env['skeen.tipo.cambio'].sudo().search([], order='date desc, id desc', limit=1)
if not tc:
return json_response({'status': 'error', 'message': 'Configura el tipo de cambio USD en Configuración'}, 400)
rate_used = tc.rate
elif currency != 'MXN':
return json_response({'status': 'error', 'message': 'Moneda no soportada (MXN/USD)'}, 400)
# Validar saldo de puntos ANTES de crear nada # Validar saldo de puntos ANTES de crear nada
pay_with_points = bool(data.get('pay_with_points')) pay_with_points = bool(data.get('pay_with_points'))
Monedero = request.env['skeen.monedero'].sudo() Monedero = request.env['skeen.monedero'].sudo()
@@ -2061,16 +2072,27 @@ class SkeenFrontendController(http.Controller):
restante = round(venta.amount_due, 2) restante = round(venta.amount_due, 2)
pago = None pago = None
cambio = 0.0 cambio = 0.0
cambio_usd = 0.0
recibido_usd = 0.0
if restante > 0: if restante > 0:
received = float(data.get('amount_received', restante) or restante) received = float(data.get('amount_received', restante) or restante)
reference = ''
if currency == 'USD':
# Recibido en dólares: convertir a MXN con el TC vigente (ya validado arriba)
recibido_usd = received
received = round(recibido_usd * rate_used, 2)
reference = f'USD {recibido_usd:.2f} @ TC {rate_used:.2f}'
pay_amount = restante if received >= restante - 0.001 else received pay_amount = restante if received >= restante - 0.001 else received
cambio = max(0.0, round(received - restante, 2)) if received > restante else 0.0 cambio = max(0.0, round(received - restante, 2)) if received > restante else 0.0
if rate_used:
cambio_usd = round(cambio / rate_used, 2)
pago = request.env['skeen.pago'].sudo().create({ pago = request.env['skeen.pago'].sudo().create({
'partner_id': partner.id, 'partner_id': partner.id,
'amount': pay_amount, 'amount': pay_amount,
'payment_method': method, 'payment_method': method,
'state': 'completed', 'state': 'completed',
'payment_date': fields.Datetime.now(), 'payment_date': fields.Datetime.now(),
'provider_reference': reference,
}) })
venta.write({'amount_paid': venta.amount_paid + pay_amount}) venta.write({'amount_paid': venta.amount_paid + pay_amount})
if venta.amount_paid >= venta.total: if venta.amount_paid >= venta.total:
@@ -2086,6 +2108,9 @@ class SkeenFrontendController(http.Controller):
'sale': self._sale_to_dict(venta), 'sale': self._sale_to_dict(venta),
'payment': self._payment_to_dict(pago) if pago else None, 'payment': self._payment_to_dict(pago) if pago else None,
'cambio': cambio, 'cambio': cambio,
'cambio_usd': cambio_usd,
'rate_used': rate_used,
'recibido_usd': recibido_usd,
'puntos_usados': puntos_usados, 'puntos_usados': puntos_usados,
'puntos_ganados': int(venta.total / 10) if venta.state == 'paid' else 0, 'puntos_ganados': int(venta.total / 10) if venta.state == 'paid' else 0,
'wallet_points': partner.wallet_points, 'wallet_points': partner.wallet_points,