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:
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user