bloque 1: anticipo al reservar en linea y cobro automatico de membresias con enlace de pago (modulo skeen_cobros: enlaces con token firmado, webhooks de Stripe/MercadoPago, modo simulado para pruebas, pagina publica de pago, expiracion automatica y reembolso, reintentos de cobro, permisos y reporte); apagado por defecto
This commit is contained in:
@@ -123,6 +123,27 @@ Procedimiento, Paquete) se pueden renombrar y se pueden crear más (p. ej.
|
|||||||
- El cliente recibe un enlace propio para consultar o cancelar su cita. Las
|
- El cliente recibe un enlace propio para consultar o cancelar su cita. Las
|
||||||
reservas que llegan por el portal se distinguen en la agenda.
|
reservas que llegan por el portal se distinguen en la agenda.
|
||||||
|
|
||||||
|
### 7.1 Anticipo (depósito) al reservar — apagado por defecto
|
||||||
|
|
||||||
|
Dentro del mismo bloque, la llave `reservas_online.anticipo`. Con `activo = true`:
|
||||||
|
|
||||||
|
- **Opciones** (defaults entre paréntesis): `tipo` (`porcentaje` | `monto_fijo`),
|
||||||
|
`valor` (0; el % sobre el precio del servicio o el monto fijo), `moneda`
|
||||||
|
(`MXN`), `horas_para_pagar` (24), `reembolsable` (sí), `texto_aviso` (''), y
|
||||||
|
`permitir_simulado` (no, ver más abajo).
|
||||||
|
- Al reservar, la cita queda **retenida sin confirmar** (badge **"Esperando
|
||||||
|
anticipo"** en la agenda y en el detalle) y el portal muestra el monto y el
|
||||||
|
botón para pagar. Al pagarse, la cita pasa a **confirmada** y se avisa por el
|
||||||
|
motor de notificaciones.
|
||||||
|
- Si no se paga dentro de `horas_para_pagar`, un proceso automático cancela la
|
||||||
|
cita y **libera el horario**.
|
||||||
|
- El anticipo se registra como pago real (`skeen.pago`) ligado a la cita y a la
|
||||||
|
venta, por lo que aparece en cortes de caja y reportes; no se duplica con el
|
||||||
|
resto que el cliente pague después en caja.
|
||||||
|
- **Reembolso**: con `reembolsable = true`, al cancelar una cita con anticipo
|
||||||
|
pagado, el pago se marca como reembolsado y la cita como "Anticipo
|
||||||
|
reembolsado". Con `false`, el anticipo se retiene.
|
||||||
|
|
||||||
## 8. Recursos (cabinas, salas y equipos)
|
## 8. Recursos (cabinas, salas y equipos)
|
||||||
|
|
||||||
**Configuración → Recursos** (apagado por defecto)
|
**Configuración → Recursos** (apagado por defecto)
|
||||||
@@ -161,13 +182,17 @@ generar visitas, ver todas, bloquear).
|
|||||||
fin de semana), renombrar, ajustar jerarquía y activar/desactivar. Vienen 10
|
fin de semana), renombrar, ajustar jerarquía y activar/desactivar. Vienen 10
|
||||||
roles base (admin, médico, recepción, enfermería, cosmetología, psicóloga,
|
roles base (admin, médico, recepción, enfermería, cosmetología, psicóloga,
|
||||||
farmacia, gerencia, marketing, lectura).
|
farmacia, gerencia, marketing, lectura).
|
||||||
2. **Permisos**: **71 permisos por acción agrupados en 16 grupos** (Agenda,
|
2. **Permisos**: **75 permisos por acción agrupados en 17 grupos** (Agenda,
|
||||||
Recursos, Pacientes, Visitas, Ventas, Inventario, Servicios, Reportes,
|
Recursos, Pacientes, Visitas, Ventas, Inventario, Servicios, Reportes,
|
||||||
WhatsApp, Notificaciones, Formularios, Membresías, Compras, Comisiones,
|
WhatsApp, Notificaciones, Formularios, Membresías, Compras, Comisiones,
|
||||||
Configuración, Plataforma), con botones todo/ninguno. El backend los hace
|
Cobros y enlaces de pago, Configuración, Plataforma), con botones todo/ninguno.
|
||||||
cumplir aunque el usuario manipule la interfaz.
|
El backend los hace cumplir aunque el usuario manipule la interfaz.
|
||||||
3. **Menús**: qué sección ve cada rol.
|
3. **Menús**: qué sección ve cada rol.
|
||||||
|
|
||||||
|
Los permisos de **Cobros** (defaults): `cobros.ver` (todos), `cobros.administrar`
|
||||||
|
(admin/gerencia), `cobros.forzar` (admin/recepción/gerencia) y `cobros.cancelar`
|
||||||
|
(admin/gerencia).
|
||||||
|
|
||||||
Recomendación: partir del rol más parecido, ajustar y guardar.
|
Recomendación: partir del rol más parecido, ajustar y guardar.
|
||||||
|
|
||||||
## 10. Notificaciones
|
## 10. Notificaciones
|
||||||
@@ -218,6 +243,60 @@ sesiones incluidas, cobro periódico, congelamiento y renovación.
|
|||||||
envía avisos de renovación en cada ventana y descongela al terminar la ventana.
|
envía avisos de renovación en cada ventana y descongela al terminar la ventana.
|
||||||
- **Reporte**: **Membresías → reporte** con ingreso recurrente por periodo.
|
- **Reporte**: **Membresías → reporte** con ingreso recurrente por periodo.
|
||||||
|
|
||||||
|
### 11.1 Cobro automático de membresías — apagado por defecto
|
||||||
|
|
||||||
|
Dentro del bloque, la llave `membresias.cobro_automatico`. Con `activo = true`:
|
||||||
|
|
||||||
|
- **Opciones** (defaults entre paréntesis): `dias_antes` (3), `metodo`
|
||||||
|
(`enlace` | `tarjeta_guardada`), `reintentos_dias` (`[1, 3, 7]`),
|
||||||
|
`cancelar_si_no_paga` (no), `dias_gracia` (5) y `permitir_simulado` (no).
|
||||||
|
- **Método `enlace`**: al acercarse el vencimiento el cron genera el **enlace de
|
||||||
|
pago del periodo** y lo envía al paciente por el motor de notificaciones
|
||||||
|
(plantilla `plantilla_cobro_membresia`, variable `{enlace}`). El periodo queda
|
||||||
|
**pendiente** hasta que se pague; al pagarse pasa a **pagado** y la membresía
|
||||||
|
**se renueva sola**. Reintentos: se vuelve a avisar en los días indicados
|
||||||
|
respecto al vencimiento. Al agotarse los reintentos y los días de gracia, con
|
||||||
|
`cancelar_si_no_paga` la membresía pasa a **vencida**; apagado, solo se marca
|
||||||
|
el periodo y es el proceso normal de vencimiento el que la vence.
|
||||||
|
- **Método `tarjeta_guardada`**: cargo sin presencia del cliente. **Requiere
|
||||||
|
credenciales reales de Stripe y tarjetas guardadas** (que hoy no se persisten);
|
||||||
|
sin ellas queda **inerte** (no cobra ni marca nada como pagado).
|
||||||
|
|
||||||
|
## 11 bis. Cobros en línea y pasarelas de pago
|
||||||
|
|
||||||
|
Los anticipos de reservas y el cobro automático de membresías usan un mismo
|
||||||
|
objeto, el **enlace de cobro** (`skeen.cobro.enlace`): origen (reserva,
|
||||||
|
membresía o venta), monto y moneda, token firmado con caducidad, estado
|
||||||
|
(pendiente/pagado/expirado/cancelado), proveedor (Stripe, MercadoPago o
|
||||||
|
**simulado**) y bitácora. Al pagarse se registra un `skeen.pago` real ligado a
|
||||||
|
la cita y a la venta.
|
||||||
|
|
||||||
|
- **Página pública de pago**: `/pagar/<token>` (sin sesión). Muestra concepto,
|
||||||
|
monto, estado y el botón de pago; tras pagar muestra el comprobante. Respeta
|
||||||
|
marca, glosario e idioma y funciona en celular.
|
||||||
|
- **Proveedor `simulado`**: cuando no hay credenciales de pasarela, el enlace
|
||||||
|
nace `simulado`. Para poder probar el flujo completo sin dinero real hay que
|
||||||
|
encender `reservas_online.anticipo.permitir_simulado` y/o
|
||||||
|
`membresias.cobro_automatico.permitir_simulado`. En producción déjalos
|
||||||
|
**apagados**: nunca se simula un pago sin permiso explícito.
|
||||||
|
- **Configurar Stripe** (Odoo → Ajustes técnicos → Parámetros del sistema):
|
||||||
|
- `skeen.stripe_secret_key`: llave secreta (`sk_live_…`/`sk_test_…`).
|
||||||
|
- `skeen.stripe_webhook_secret`: secreto del endpoint de webhook (`whsec_…`).
|
||||||
|
- Webhook a `https://<url-pública>/skeen/frontend/v1/public/cobro/webhook/stripe`
|
||||||
|
con los eventos `checkout.session.completed` y `payment_intent.succeeded`.
|
||||||
|
La firma (`Stripe-Signature`) se verifica; sin secreto el webhook responde 400.
|
||||||
|
- **Configurar MercadoPago** (mismos parámetros):
|
||||||
|
- `skeen.mercadopago_access_token`: access token de producción/prueba.
|
||||||
|
- `skeen.mercadopago_webhook_secret` (opcional): si se define, se verifica la
|
||||||
|
firma `x-signature`; si no, se confirma consultando el pago con el token.
|
||||||
|
- Webhook a `https://<url-pública>/skeen/frontend/v1/public/cobro/webhook/mercadopago`.
|
||||||
|
- **Sin credenciales**: ambos webhooks existen pero rechazan la notificación
|
||||||
|
(400) y el checkout real no se crea; el flujo queda inerte salvo el modo
|
||||||
|
simulado explicado arriba.
|
||||||
|
- **Reporte**: **Reportes → Cobros** lista enlaces pendientes y expirados
|
||||||
|
(anticipos y membresías), con totales, exportable a Excel, y permite **forzar**
|
||||||
|
el cobro o **cancelar** la retención según los permisos de Cobros.
|
||||||
|
|
||||||
## 12. Compras y proveedores
|
## 12. Compras y proveedores
|
||||||
|
|
||||||
**Configuración → Compras** (apagado por defecto) y **Compras** (menú).
|
**Configuración → Compras** (apagado por defecto) y **Compras** (menú).
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import MisVentas from './pages/MisVentas';
|
|||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Reservar from './pages/Reservar';
|
import Reservar from './pages/Reservar';
|
||||||
import ReservarCita from './pages/ReservarCita';
|
import ReservarCita from './pages/ReservarCita';
|
||||||
|
import Pagar from './pages/Pagar';
|
||||||
import FormularioPublico from './pages/FormularioPublico';
|
import FormularioPublico from './pages/FormularioPublico';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
@@ -180,6 +181,8 @@ const App: FC = () => {
|
|||||||
{/* Portal público de reservas: FUERA del gate de autenticación. */}
|
{/* Portal público de reservas: FUERA del gate de autenticación. */}
|
||||||
<Route path="/reservar" element={<Reservar />} />
|
<Route path="/reservar" element={<Reservar />} />
|
||||||
<Route path="/reservar/cita/:token" element={<ReservarCita />} />
|
<Route path="/reservar/cita/:token" element={<ReservarCita />} />
|
||||||
|
{/* Página pública de pago de un enlace de cobro (token HMAC). */}
|
||||||
|
<Route path="/pagar/:token" element={<Pagar />} />
|
||||||
{/* Portal público de formularios: token HMAC como credencial. */}
|
{/* Portal público de formularios: token HMAC como credencial. */}
|
||||||
<Route path="/formulario/:token" element={<FormularioPublico />} />
|
<Route path="/formulario/:token" element={<FormularioPublico />} />
|
||||||
<Route path="/" element={<RequireAuth href="/"><HomeRoute /></RequireAuth>} />
|
<Route path="/" element={<RequireAuth href="/"><HomeRoute /></RequireAuth>} />
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import { Repeat, Plus, Pencil, Trash2, Save } from 'lucide-react';
|
import { Repeat, Plus, Pencil, Trash2, Save } from 'lucide-react';
|
||||||
import { Button, Input, TextArea, Select, Modal, toast } from './ui';
|
import { Button, Input, TextArea, Select, Modal, toast } from './ui';
|
||||||
import {
|
import {
|
||||||
odooApi, MEMBRESIAS_DEFAULT,
|
odooApi, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT,
|
||||||
type ClinicSettings, type MembresiasSettings, type MembresiaPlan,
|
type ClinicSettings, type MembresiasSettings, type CobroAutomaticoSettings,
|
||||||
|
type MembresiaPlan,
|
||||||
type Service, type ServiceCategory, type PeriodicidadMembresia,
|
type Service, type ServiceCategory, type PeriodicidadMembresia,
|
||||||
} from '../services/odoo';
|
} from '../services/odoo';
|
||||||
|
import { useI18n } from '../lib/i18n';
|
||||||
|
|
||||||
// Configuración → Membresías: ajustes del bloque + CRUD de planes.
|
// Configuración → Membresías: ajustes del bloque + CRUD de planes.
|
||||||
// Mientras `activo` esté apagado, la sección no aparece en el menú y el POS no
|
// Mientras `activo` esté apagado, la sección no aparece en el menú y el POS no
|
||||||
@@ -71,6 +73,7 @@ const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => vo
|
|||||||
);
|
);
|
||||||
|
|
||||||
const MembresiasPanel: FC<Props> = ({ settings, onSaved }) => {
|
const MembresiasPanel: FC<Props> = ({ settings, onSaved }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const [form, setForm] = useState<MembresiasSettings>(settings.membresias || MEMBRESIAS_DEFAULT);
|
const [form, setForm] = useState<MembresiasSettings>(settings.membresias || MEMBRESIAS_DEFAULT);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [avisos, setAvisos] = useState((settings.membresias?.dias_aviso_renovacion || [7, 1]).join(', '));
|
const [avisos, setAvisos] = useState((settings.membresias?.dias_aviso_renovacion || [7, 1]).join(', '));
|
||||||
@@ -115,13 +118,27 @@ const MembresiasPanel: FC<Props> = ({ settings, onSaved }) => {
|
|||||||
const set = <K extends keyof MembresiasSettings>(k: K, v: MembresiasSettings[K]) =>
|
const set = <K extends keyof MembresiasSettings>(k: K, v: MembresiasSettings[K]) =>
|
||||||
setForm((prev) => ({ ...prev, [k]: v }));
|
setForm((prev) => ({ ...prev, [k]: v }));
|
||||||
|
|
||||||
|
const cobro: CobroAutomaticoSettings = form.cobro_automatico || COBRO_AUTOMATICO_DEFAULT;
|
||||||
|
const setCobro = <K extends keyof CobroAutomaticoSettings>(k: K, v: CobroAutomaticoSettings[K]) =>
|
||||||
|
setForm((prev) => ({ ...prev, cobro_automatico: { ...(prev.cobro_automatico || COBRO_AUTOMATICO_DEFAULT), [k]: v } }));
|
||||||
|
const [reintentosTxt, setReintentosTxt] = useState(
|
||||||
|
(settings.membresias?.cobro_automatico?.reintentos_dias || [1, 3, 7]).join(', '));
|
||||||
|
useEffect(() => {
|
||||||
|
setReintentosTxt((settings.membresias?.cobro_automatico?.reintentos_dias || [1, 3, 7]).join(', '));
|
||||||
|
}, [settings.membresias]);
|
||||||
|
|
||||||
const guardarConfig = async () => {
|
const guardarConfig = async () => {
|
||||||
const dias = avisos.split(',').map((d) => parseInt(d.trim(), 10)).filter((d) => !Number.isNaN(d));
|
const dias = avisos.split(',').map((d) => parseInt(d.trim(), 10)).filter((d) => !Number.isNaN(d));
|
||||||
if (dias.length === 0) { toast.error('Indica al menos un día de aviso (ej. 7, 1)'); return; }
|
if (dias.length === 0) { toast.error('Indica al menos un día de aviso (ej. 7, 1)'); return; }
|
||||||
|
const reintentos = reintentosTxt.split(',').map((d) => parseInt(d.trim(), 10)).filter((d) => !Number.isNaN(d));
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await odooApi.putClinicSettings({
|
const res = await odooApi.putClinicSettings({
|
||||||
membresias: { ...form, dias_aviso_renovacion: dias },
|
membresias: {
|
||||||
|
...form,
|
||||||
|
dias_aviso_renovacion: dias,
|
||||||
|
cobro_automatico: { ...cobro, reintentos_dias: reintentos },
|
||||||
|
},
|
||||||
motivo: 'Configuración de membresías',
|
motivo: 'Configuración de membresías',
|
||||||
});
|
});
|
||||||
if (res.status === 'success') {
|
if (res.status === 'success') {
|
||||||
@@ -270,6 +287,48 @@ const MembresiasPanel: FC<Props> = ({ settings, onSaved }) => {
|
|||||||
<Toggle label="Permitir pausa por falta de pago" checked={form.permitir_pausa_pago}
|
<Toggle label="Permitir pausa por falta de pago" checked={form.permitir_pausa_pago}
|
||||||
onChange={(v) => set('permitir_pausa_pago', v)} />
|
onChange={(v) => set('permitir_pausa_pago', v)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Cobro automático del periodo con enlace de pago. Apagado por defecto. */}
|
||||||
|
<div className="rounded-2xl border border-theme-border p-4">
|
||||||
|
<h4 className="font-heading text-base text-theme-heading">{t('config.cobroAuto.title')}</h4>
|
||||||
|
<p className="mt-1 text-sm text-theme-muted">{t('config.cobroAuto.help')}</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Toggle label={t('config.cobroAuto.enable')} checked={cobro.activo}
|
||||||
|
onChange={(v) => setCobro('activo', v)}
|
||||||
|
ayuda={t('config.cobroAuto.enableHelp')} />
|
||||||
|
</div>
|
||||||
|
{cobro.activo && (
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<Select label={t('config.cobroAuto.method')} value={cobro.metodo}
|
||||||
|
onChange={(e) => setCobro('metodo', e.target.value as CobroAutomaticoSettings['metodo'])}
|
||||||
|
options={[
|
||||||
|
{ value: 'enlace', label: t('config.cobroAuto.methodLink') },
|
||||||
|
{ value: 'tarjeta_guardada', label: t('config.cobroAuto.methodCard') },
|
||||||
|
]} />
|
||||||
|
<Input label={t('config.cobroAuto.daysBefore')} type="number" min={0} max={365}
|
||||||
|
value={String(cobro.dias_antes)}
|
||||||
|
onChange={(e) => setCobro('dias_antes', parseInt(e.target.value, 10) || 0)} />
|
||||||
|
<Input label={t('config.cobroAuto.grace')} type="number" min={0} max={365}
|
||||||
|
value={String(cobro.dias_gracia)}
|
||||||
|
onChange={(e) => setCobro('dias_gracia', parseInt(e.target.value, 10) || 0)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-theme-muted mb-1.5">{t('config.cobroAuto.retries')}</label>
|
||||||
|
<Input value={reintentosTxt} onChange={(e) => setReintentosTxt(e.target.value)} placeholder="1, 3, 7" />
|
||||||
|
<p className="text-[11px] text-theme-muted mt-1">{t('config.cobroAuto.retriesHelp')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<Toggle label={t('config.cobroAuto.cancel')} checked={cobro.cancelar_si_no_paga}
|
||||||
|
onChange={(v) => setCobro('cancelar_si_no_paga', v)}
|
||||||
|
ayuda={t('config.cobroAuto.cancelHelp')} />
|
||||||
|
<Toggle label={t('config.cobroAuto.simulated')} checked={cobro.permitir_simulado}
|
||||||
|
onChange={(v) => setCobro('permitir_simulado', v)}
|
||||||
|
ayuda={t('config.cobroAuto.simulatedHelp')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button onClick={guardarConfig} disabled={saving}>
|
<Button onClick={guardarConfig} disabled={saving}>
|
||||||
<Save size={16} className="mr-2" /> {saving ? 'Guardando…' : 'Guardar configuración'}
|
<Save size={16} className="mr-2" /> {saving ? 'Guardando…' : 'Guardar configuración'}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { FC, ReactNode } from 'react';
|
import type { FC, ReactNode } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { CalendarClock, Copy, ExternalLink, Save } from 'lucide-react';
|
import { CalendarClock, Copy, ExternalLink, Save } from 'lucide-react';
|
||||||
import { Button, Input, TextArea, toast } from './ui';
|
import { Button, Input, Select, TextArea, toast } from './ui';
|
||||||
import {
|
import {
|
||||||
odooApi, RESERVAS_ONLINE_DEFAULT,
|
odooApi, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT,
|
||||||
type ClinicSettings, type ReservasOnlineSettings,
|
type ClinicSettings, type ReservasOnlineSettings, type AnticipoSettings,
|
||||||
} from '../services/odoo';
|
} from '../services/odoo';
|
||||||
import { enlaceReserva } from '../lib/publicBooking';
|
import { enlaceReserva } from '../lib/publicBooking';
|
||||||
|
import { useI18n } from '../lib/i18n';
|
||||||
|
|
||||||
// Editor del bloque `reservas_online` (portal público de auto-agendado) y
|
// Editor del bloque `reservas_online` (portal público de auto-agendado) y
|
||||||
// botón para copiar/abrir el enlace público de la clínica.
|
// botón para copiar/abrir el enlace público de la clínica.
|
||||||
@@ -17,6 +18,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ReservasOnlinePanel: FC<Props> = ({ settings, onSaved }) => {
|
const ReservasOnlinePanel: FC<Props> = ({ settings, onSaved }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
const [form, setForm] = useState<ReservasOnlineSettings>(
|
const [form, setForm] = useState<ReservasOnlineSettings>(
|
||||||
settings.reservas_online || RESERVAS_ONLINE_DEFAULT);
|
settings.reservas_online || RESERVAS_ONLINE_DEFAULT);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -29,6 +31,10 @@ const ReservasOnlinePanel: FC<Props> = ({ settings, onSaved }) => {
|
|||||||
const set = <K extends keyof ReservasOnlineSettings>(k: K, v: ReservasOnlineSettings[K]) =>
|
const set = <K extends keyof ReservasOnlineSettings>(k: K, v: ReservasOnlineSettings[K]) =>
|
||||||
setForm((prev) => ({ ...prev, [k]: v }));
|
setForm((prev) => ({ ...prev, [k]: v }));
|
||||||
|
|
||||||
|
const anticipo: AnticipoSettings = form.anticipo || ANTICIPO_DEFAULT;
|
||||||
|
const setAnticipo = <K extends keyof AnticipoSettings>(k: K, v: AnticipoSettings[K]) =>
|
||||||
|
setForm((prev) => ({ ...prev, anticipo: { ...(prev.anticipo || ANTICIPO_DEFAULT), [k]: v } }));
|
||||||
|
|
||||||
const enlace = enlaceReserva(settings.url_publica);
|
const enlace = enlaceReserva(settings.url_publica);
|
||||||
|
|
||||||
const guardar = async () => {
|
const guardar = async () => {
|
||||||
@@ -119,6 +125,51 @@ const ReservasOnlinePanel: FC<Props> = ({ settings, onSaved }) => {
|
|||||||
onChange={(e) => set('mensaje_confirmacion', e.target.value)} rows={2}
|
onChange={(e) => set('mensaje_confirmacion', e.target.value)} rows={2}
|
||||||
placeholder="Se muestra al cliente al terminar su reserva" />
|
placeholder="Se muestra al cliente al terminar su reserva" />
|
||||||
|
|
||||||
|
{/* Anticipo (depósito) al reservar en línea. Apagado por defecto. */}
|
||||||
|
<div className="rounded-2xl border border-theme-border p-4">
|
||||||
|
<h4 className="font-heading text-base text-theme-heading">{t('config.anticipo.title')}</h4>
|
||||||
|
<p className="mt-1 text-sm text-theme-muted">{t('config.anticipo.help')}</p>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Toggle label={t('config.anticipo.enable')} checked={anticipo.activo}
|
||||||
|
onChange={(v) => setAnticipo('activo', v)}
|
||||||
|
ayuda={t('config.anticipo.enableHelp')} />
|
||||||
|
</div>
|
||||||
|
{anticipo.activo && (
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<Select label={t('config.anticipo.type')} value={anticipo.tipo}
|
||||||
|
onChange={(e) => setAnticipo('tipo', e.target.value as AnticipoSettings['tipo'])}
|
||||||
|
options={[
|
||||||
|
{ value: 'porcentaje', label: t('config.anticipo.typePercent') },
|
||||||
|
{ value: 'monto_fijo', label: t('config.anticipo.typeFixed') },
|
||||||
|
]} />
|
||||||
|
<Input label={t('config.anticipo.value')} type="number" min={0}
|
||||||
|
value={String(anticipo.valor)}
|
||||||
|
onChange={(e) => setAnticipo('valor', Number(e.target.value) || 0)} />
|
||||||
|
<Select label={t('config.anticipo.currency')} value={anticipo.moneda}
|
||||||
|
onChange={(e) => setAnticipo('moneda', e.target.value)}
|
||||||
|
options={[{ value: 'MXN', label: 'MXN' }, { value: 'USD', label: 'USD' }]} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Input label={t('config.anticipo.hours')} type="number" min={1} max={720}
|
||||||
|
value={String(anticipo.horas_para_pagar)}
|
||||||
|
onChange={(e) => setAnticipo('horas_para_pagar', Number(e.target.value) || 1)} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Toggle label={t('config.anticipo.refundable')} checked={anticipo.reembolsable}
|
||||||
|
onChange={(v) => setAnticipo('reembolsable', v)}
|
||||||
|
ayuda={t('config.anticipo.refundableHelp')} />
|
||||||
|
<Toggle label={t('config.anticipo.simulated')} checked={anticipo.permitir_simulado}
|
||||||
|
onChange={(v) => setAnticipo('permitir_simulado', v)}
|
||||||
|
ayuda={t('config.anticipo.simulatedHelp')} />
|
||||||
|
</div>
|
||||||
|
<TextArea label={t('config.anticipo.notice')} value={anticipo.texto_aviso}
|
||||||
|
onChange={(e) => setAnticipo('texto_aviso', e.target.value)} rows={2}
|
||||||
|
placeholder={t('config.anticipo.noticePlaceholder')} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-theme-border bg-theme-bg p-4">
|
<div className="rounded-2xl border border-theme-border bg-theme-bg p-4">
|
||||||
<p className="text-xs font-medium text-theme-muted">Enlace público de la clínica</p>
|
<p className="text-xs font-medium text-theme-muted">Enlace público de la clínica</p>
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
|||||||
183
frontend/src/components/reportes/CobrosReport.tsx
Normal file
183
frontend/src/components/reportes/CobrosReport.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { CreditCard, RefreshCw } from 'lucide-react';
|
||||||
|
import { Card, Button, EmptyState, Skeleton, Badge, toast } from '../ui';
|
||||||
|
import { ExportButton } from '../ui/ExportButton';
|
||||||
|
import { exportToExcel } from '../../lib/exporter';
|
||||||
|
import { odooApi, type Cobro } from '../../services/odoo';
|
||||||
|
import { useReportRange } from '../../lib/reportRange';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
|
||||||
|
const fmtMoney = (value: number, currency = 'MXN') =>
|
||||||
|
new Intl.NumberFormat('es-MX', { style: 'currency', currency, maximumFractionDigits: 2 }).format(value || 0);
|
||||||
|
|
||||||
|
// Pestaña "Cobros" del módulo de Reportes: enlaces de cobro pendientes y
|
||||||
|
// expirados (anticipos de reservas en línea y periodos de membresía). Permite
|
||||||
|
// forzar el cobro y cancelar la retención según los permisos del rol.
|
||||||
|
const CobrosReport: FC = () => {
|
||||||
|
const { start, end } = useReportRange('cobros');
|
||||||
|
const { can } = useAuth();
|
||||||
|
const [cobros, setCobros] = useState<Cobro[]>([]);
|
||||||
|
const [resumen, setResumen] = useState({ pendiente: 0, expirado: 0, cancelado: 0 });
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [accionando, setAccionando] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await odooApi.getCobrosReport({ start, end });
|
||||||
|
if (res.status === 'success') {
|
||||||
|
setCobros(res.cobros);
|
||||||
|
setResumen(res.resumen);
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || 'No se pudo cargar el reporte');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'Error al cargar el reporte de cobros');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [start, end]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const forzar = async (c: Cobro) => {
|
||||||
|
setAccionando(c.id);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.forzarCobro(c.id, { referencia: 'Panel cobros' });
|
||||||
|
if (res.status === 'success') { toast.success('Cobro forzado'); await load(); }
|
||||||
|
else toast.error(res.message || 'No se pudo forzar el cobro');
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'No se pudo forzar el cobro');
|
||||||
|
} finally { setAccionando(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelar = async (c: Cobro) => {
|
||||||
|
setAccionando(c.id);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.cancelarCobro(c.id, 'Cancelado desde Reportes');
|
||||||
|
if (res.status === 'success') { toast.success('Enlace cancelado'); await load(); }
|
||||||
|
else toast.error(res.message || 'No se pudo cancelar');
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
toast.error(msg || 'No se pudo cancelar');
|
||||||
|
} finally { setAccionando(null); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportar = async () => {
|
||||||
|
await exportToExcel({
|
||||||
|
filename: `cobros-${start}-${end}`,
|
||||||
|
sheetName: 'Cobros',
|
||||||
|
title: 'Cobros pendientes y expirados',
|
||||||
|
subtitle: `Periodo ${start} a ${end}`,
|
||||||
|
columns: [
|
||||||
|
{ header: 'Folio', key: 'nombre' },
|
||||||
|
{ header: 'Origen', key: 'origen_label' },
|
||||||
|
{ header: 'Paciente', key: 'patient' },
|
||||||
|
{ header: 'Concepto', key: 'concepto' },
|
||||||
|
{ header: 'Monto', key: 'monto', format: 'currency' },
|
||||||
|
{ header: 'Estado', key: 'estado_label' },
|
||||||
|
{ header: 'Vencido', key: 'vencido' },
|
||||||
|
{ header: 'Creado', key: 'creado' },
|
||||||
|
{ header: 'Vence', key: 'expira' },
|
||||||
|
],
|
||||||
|
rows: cobros as unknown as Record<string, unknown>[],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Card><Card.Body><Skeleton count={6} /></Card.Body></Card>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="text-xs text-theme-muted">Periodo {start} a {end}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={load}>
|
||||||
|
<RefreshCw size={14} className="mr-1.5" /> Actualizar
|
||||||
|
</Button>
|
||||||
|
<ExportButton onClick={exportar} loading={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<Card><Card.Body>
|
||||||
|
<p className="text-xs text-theme-muted">Pendientes</p>
|
||||||
|
<p className="font-heading text-2xl text-theme-heading">{fmtMoney(resumen.pendiente)}</p>
|
||||||
|
</Card.Body></Card>
|
||||||
|
<Card><Card.Body>
|
||||||
|
<p className="text-xs text-theme-muted">Expirados</p>
|
||||||
|
<p className="font-heading text-2xl text-theme-heading">{fmtMoney(resumen.expirado)}</p>
|
||||||
|
</Card.Body></Card>
|
||||||
|
<Card><Card.Body>
|
||||||
|
<p className="text-xs text-theme-muted">Cancelados</p>
|
||||||
|
<p className="font-heading text-2xl text-theme-heading">{fmtMoney(resumen.cancelado)}</p>
|
||||||
|
</Card.Body></Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cobros.length === 0 ? (
|
||||||
|
<Card><Card.Body>
|
||||||
|
<EmptyState title="Sin cobros pendientes" subtitle="No hay enlaces pendientes ni expirados en el periodo."
|
||||||
|
icon={<CreditCard size={28} />} />
|
||||||
|
</Card.Body></Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-theme-border text-left text-xs uppercase tracking-wide text-theme-muted">
|
||||||
|
<th className="px-3 py-2">Folio</th>
|
||||||
|
<th className="px-3 py-2">Origen</th>
|
||||||
|
<th className="px-3 py-2">Paciente</th>
|
||||||
|
<th className="px-3 py-2">Concepto</th>
|
||||||
|
<th className="px-3 py-2 text-right">Monto</th>
|
||||||
|
<th className="px-3 py-2">Estado</th>
|
||||||
|
<th className="px-3 py-2">Vence</th>
|
||||||
|
<th className="px-3 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{cobros.map((c) => (
|
||||||
|
<tr key={c.id} className="border-b border-theme-border/60">
|
||||||
|
<td className="px-3 py-2 text-theme-heading">{c.nombre}</td>
|
||||||
|
<td className="px-3 py-2 text-theme-muted">{c.origen_label}</td>
|
||||||
|
<td className="px-3 py-2 text-theme-muted">{c.patient || '—'}</td>
|
||||||
|
<td className="px-3 py-2 text-theme-heading">{c.concepto}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-theme-heading">{fmtMoney(c.monto, c.moneda)}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Badge variant={c.estado === 'expirado' ? 'danger' : c.estado === 'cancelado' ? 'cancelled' : 'warning'}>
|
||||||
|
{c.estado_label}{c.vencido ? ' · vencido' : ''}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-theme-muted">{c.expira || '—'}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{c.estado === 'pendiente' && (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{can('cobros.forzar') && (
|
||||||
|
<Button size="sm" variant="outline" loading={accionando === c.id} onClick={() => forzar(c)}>
|
||||||
|
Forzar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{can('cobros.cancelar') && (
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => cancelar(c)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CobrosReport;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT } from '../services/odoo';
|
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, RECURSOS_DEFAULT, COMPRAS_DEFAULT, COMISIONES_DEFAULT, LICENCIA_DEFAULT, LICENCIA_LIMITES_DEFAULT } from '../services/odoo';
|
||||||
|
|
||||||
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
|
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
|
||||||
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
|
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
|
||||||
@@ -9,8 +9,17 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
|
|||||||
...CLINIC_SETTINGS_DEFAULT,
|
...CLINIC_SETTINGS_DEFAULT,
|
||||||
...(settings || {}),
|
...(settings || {}),
|
||||||
impuestos: { ...IMPUESTOS_DEFAULT, ...(settings?.impuestos || {}) },
|
impuestos: { ...IMPUESTOS_DEFAULT, ...(settings?.impuestos || {}) },
|
||||||
|
reservas_online: {
|
||||||
|
...RESERVAS_ONLINE_DEFAULT,
|
||||||
|
...(settings?.reservas_online || {}),
|
||||||
|
anticipo: { ...ANTICIPO_DEFAULT, ...((settings?.reservas_online || {}).anticipo || {}) },
|
||||||
|
},
|
||||||
notificaciones: { ...NOTIFICACIONES_DEFAULT, ...(settings?.notificaciones || {}) },
|
notificaciones: { ...NOTIFICACIONES_DEFAULT, ...(settings?.notificaciones || {}) },
|
||||||
membresias: { ...MEMBRESIAS_DEFAULT, ...(settings?.membresias || {}) },
|
membresias: {
|
||||||
|
...MEMBRESIAS_DEFAULT,
|
||||||
|
...(settings?.membresias || {}),
|
||||||
|
cobro_automatico: { ...COBRO_AUTOMATICO_DEFAULT, ...((settings?.membresias || {}).cobro_automatico || {}) },
|
||||||
|
},
|
||||||
recursos: { ...RECURSOS_DEFAULT, ...(settings?.recursos || {}) },
|
recursos: { ...RECURSOS_DEFAULT, ...(settings?.recursos || {}) },
|
||||||
compras: { ...COMPRAS_DEFAULT, ...(settings?.compras || {}) },
|
compras: { ...COMPRAS_DEFAULT, ...(settings?.compras || {}) },
|
||||||
comisiones: { ...COMISIONES_DEFAULT, ...(settings?.comisiones || {}) },
|
comisiones: { ...COMISIONES_DEFAULT, ...(settings?.comisiones || {}) },
|
||||||
@@ -31,6 +40,12 @@ const publicar = (s: ClinicSettings) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const cargarClinicSettings = (): Promise<ClinicSettings> => {
|
export const cargarClinicSettings = (): Promise<ClinicSettings> => {
|
||||||
|
// Sin sesión no hay nada que pedir: los ajustes requieren token y el portal
|
||||||
|
// público (reservas, pago, formularios) no lo tiene. Se devuelven los
|
||||||
|
// defaults para no ensuciar la consola con un 401.
|
||||||
|
if (typeof window !== 'undefined' && !window.localStorage.getItem('skeen_token')) {
|
||||||
|
return Promise.resolve(actual);
|
||||||
|
}
|
||||||
if (peticion) return peticion;
|
if (peticion) return peticion;
|
||||||
peticion = odooApi.getClinicSettings()
|
peticion = odooApi.getClinicSettings()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
|
|||||||
@@ -388,6 +388,8 @@ export const es: Dict = {
|
|||||||
'reports.tab.cortes.desc': 'Arqueos y cierres por día',
|
'reports.tab.cortes.desc': 'Arqueos y cierres por día',
|
||||||
'reports.tab.adeudos': 'Adeudos',
|
'reports.tab.adeudos': 'Adeudos',
|
||||||
'reports.tab.adeudos.desc': 'Cartera por cobrar y planes de pago',
|
'reports.tab.adeudos.desc': 'Cartera por cobrar y planes de pago',
|
||||||
|
'reports.tab.cobros': 'Cobros',
|
||||||
|
'reports.tab.cobros.desc': 'Anticipos y membresías por cobrar (pendientes y expirados)',
|
||||||
'reports.tab.devoluciones': 'Devoluciones',
|
'reports.tab.devoluciones': 'Devoluciones',
|
||||||
'reports.tab.devoluciones.desc': 'Ventas devueltas y montos',
|
'reports.tab.devoluciones.desc': 'Ventas devueltas y montos',
|
||||||
'reports.tab.top-clientes': 'Top clientes',
|
'reports.tab.top-clientes': 'Top clientes',
|
||||||
@@ -487,6 +489,35 @@ export const es: Dict = {
|
|||||||
'public.manage.leadTime': ' (con menos de {h} h de anticipación)',
|
'public.manage.leadTime': ' (con menos de {h} h de anticipación)',
|
||||||
'public.manage.contactPhone': ' Comunícate al {phone}.',
|
'public.manage.contactPhone': ' Comunícate al {phone}.',
|
||||||
'public.manage.contactClinic': ' Comunícate con la clínica.',
|
'public.manage.contactClinic': ' Comunícate con la clínica.',
|
||||||
|
// Anticipo al reservar en línea
|
||||||
|
'public.awaitingDeposit': 'Reserva retenida: falta tu anticipo',
|
||||||
|
'public.depositAmount': 'Anticipo a pagar: {amount}',
|
||||||
|
'public.depositHelp': 'Tienes {h} horas para pagar el anticipo; si no se paga, la reserva se libera.',
|
||||||
|
'public.payDeposit': 'Pagar anticipo',
|
||||||
|
'public.depositExpires': 'El enlace vence en {h} horas',
|
||||||
|
|
||||||
|
// --- Página pública de pago de un enlace de cobro ---
|
||||||
|
'pay.title': 'Pago en línea',
|
||||||
|
'pay.folioWord': 'Folio',
|
||||||
|
'pay.concept': 'Concepto',
|
||||||
|
'pay.amount': 'Monto',
|
||||||
|
'pay.forPatient': 'A nombre de {name}',
|
||||||
|
'pay.payNow': 'Pagar ahora',
|
||||||
|
'pay.paySimulated': 'Confirmar pago (modo prueba)',
|
||||||
|
'pay.paidOk': '¡Pago registrado!',
|
||||||
|
'pay.receipt': 'Comprobante',
|
||||||
|
'pay.date': 'Fecha',
|
||||||
|
'pay.notAvailable': 'El pago en línea no está disponible en este momento. Comunícate con la clínica.',
|
||||||
|
'pay.expiresAt': 'Este enlace vence el {date}',
|
||||||
|
'pay.expiredBody': 'Este enlace ya expiró. Pide uno nuevo a la clínica.',
|
||||||
|
'pay.cancelledBody': 'Este enlace fue cancelado.',
|
||||||
|
'pay.unavailableTitle': 'Enlace no disponible',
|
||||||
|
'pay.invalidLink': 'Enlace no válido',
|
||||||
|
'pay.notFound': 'No encontramos este enlace de pago.',
|
||||||
|
'pay.payError': 'No se pudo completar el pago. Intenta de nuevo.',
|
||||||
|
'pay.simulatedNote': 'Modo de prueba: no se cobra dinero real.',
|
||||||
|
'pay.secureNote': 'Pago protegido por la pasarela de la clínica.',
|
||||||
|
'pay.state.pagado': 'Pagado',
|
||||||
|
|
||||||
// --- POS ---
|
// --- POS ---
|
||||||
'pos.title': 'Punto de Venta',
|
'pos.title': 'Punto de Venta',
|
||||||
@@ -603,8 +634,42 @@ export const es: Dict = {
|
|||||||
'agenda.noDoctorsSubWeek': 'No hay médicos dados de alta para mostrar en la vista semanal.',
|
'agenda.noDoctorsSubWeek': 'No hay médicos dados de alta para mostrar en la vista semanal.',
|
||||||
'agenda.noResourcesTitle': 'Sin recursos',
|
'agenda.noResourcesTitle': 'Sin recursos',
|
||||||
'agenda.noResourcesSub': 'Agrega recursos en Configuración › Recursos para ver esta vista.',
|
'agenda.noResourcesSub': 'Agrega recursos en Configuración › Recursos para ver esta vista.',
|
||||||
|
'agenda.depositPending': 'Esperando anticipo',
|
||||||
'config.idiomaTitle': 'Idioma de la interfaz',
|
'config.idiomaTitle': 'Idioma de la interfaz',
|
||||||
'config.idiomaBody': 'Elige el idioma con el que se muestra el sistema en este navegador.',
|
'config.idiomaBody': 'Elige el idioma con el que se muestra el sistema en este navegador.',
|
||||||
|
// Anticipo de reservas en línea
|
||||||
|
'config.anticipo.title': 'Anticipo al reservar',
|
||||||
|
'config.anticipo.help': 'Pide un depósito al reservar en línea. La reserva queda retenida sin confirmar hasta que se pague; si no se paga a tiempo, se libera el horario.',
|
||||||
|
'config.anticipo.enable': 'Requerir anticipo',
|
||||||
|
'config.anticipo.enableHelp': 'Apagado, el portal reserva sin pedir dinero (como hasta hoy).',
|
||||||
|
'config.anticipo.type': 'Tipo de anticipo',
|
||||||
|
'config.anticipo.typePercent': 'Porcentaje del servicio',
|
||||||
|
'config.anticipo.typeFixed': 'Monto fijo',
|
||||||
|
'config.anticipo.value': 'Valor (porcentaje o monto)',
|
||||||
|
'config.anticipo.currency': 'Moneda',
|
||||||
|
'config.anticipo.hours': 'Horas para pagar',
|
||||||
|
'config.anticipo.refundable': 'Reembolsable',
|
||||||
|
'config.anticipo.refundableHelp': 'Si se cancela la cita, el anticipo se devuelve.',
|
||||||
|
'config.anticipo.simulated': 'Permitir pago simulado',
|
||||||
|
'config.anticipo.simulatedHelp': 'Permite probar el flujo sin pasarela ni dinero real. Déjalo apagado en producción.',
|
||||||
|
'config.anticipo.notice': 'Aviso para el cliente',
|
||||||
|
'config.anticipo.noticePlaceholder': 'Se requiere un anticipo para confirmar tu cita.',
|
||||||
|
// Cobro automático de membresías
|
||||||
|
'config.cobroAuto.title': 'Cobro automático de membresías',
|
||||||
|
'config.cobroAuto.help': 'Genera el enlace de pago del periodo y lo envía al paciente por notificaciones, con reintentos. El periodo queda pendiente hasta que se pague.',
|
||||||
|
'config.cobroAuto.enable': 'Activar cobro automático',
|
||||||
|
'config.cobroAuto.enableHelp': 'Apagado, el proceso solo genera el adeudo (no cobra ni avisa).',
|
||||||
|
'config.cobroAuto.method': 'Método',
|
||||||
|
'config.cobroAuto.methodLink': 'Enlace de pago',
|
||||||
|
'config.cobroAuto.methodCard': 'Tarjeta guardada (Stripe)',
|
||||||
|
'config.cobroAuto.daysBefore': 'Días antes del vencimiento',
|
||||||
|
'config.cobroAuto.grace': 'Días de gracia',
|
||||||
|
'config.cobroAuto.retries': 'Días de reintento del aviso',
|
||||||
|
'config.cobroAuto.retriesHelp': 'Días (respecto al vencimiento) en que se reintenta avisar, separados por coma.',
|
||||||
|
'config.cobroAuto.cancel': 'Vencer si no paga',
|
||||||
|
'config.cobroAuto.cancelHelp': 'Al agotar los reintentos, marca la membresía vencida. Apagado, sigue activa hasta el proceso normal de vencimiento.',
|
||||||
|
'config.cobroAuto.simulated': 'Permitir pago simulado',
|
||||||
|
'config.cobroAuto.simulatedHelp': 'Permite probar el flujo sin pasarela ni dinero real. Déjalo apagado en producción.',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
@@ -953,6 +1018,8 @@ export const en: Dict = {
|
|||||||
'reports.tab.cortes.desc': 'Daily counts and closings',
|
'reports.tab.cortes.desc': 'Daily counts and closings',
|
||||||
'reports.tab.adeudos': 'Debts',
|
'reports.tab.adeudos': 'Debts',
|
||||||
'reports.tab.adeudos.desc': 'Accounts receivable and payment plans',
|
'reports.tab.adeudos.desc': 'Accounts receivable and payment plans',
|
||||||
|
'reports.tab.cobros': 'Charges',
|
||||||
|
'reports.tab.cobros.desc': 'Deposits and memberships pending collection (pending and expired)',
|
||||||
'reports.tab.devoluciones': 'Refunds',
|
'reports.tab.devoluciones': 'Refunds',
|
||||||
'reports.tab.devoluciones.desc': 'Returned sales and amounts',
|
'reports.tab.devoluciones.desc': 'Returned sales and amounts',
|
||||||
'reports.tab.top-clientes': 'Top clients',
|
'reports.tab.top-clientes': 'Top clients',
|
||||||
@@ -1052,6 +1119,32 @@ export const en: Dict = {
|
|||||||
'public.manage.leadTime': ' (less than {h} h in advance)',
|
'public.manage.leadTime': ' (less than {h} h in advance)',
|
||||||
'public.manage.contactPhone': ' Contact us at {phone}.',
|
'public.manage.contactPhone': ' Contact us at {phone}.',
|
||||||
'public.manage.contactClinic': ' Contact the clinic.',
|
'public.manage.contactClinic': ' Contact the clinic.',
|
||||||
|
'public.awaitingDeposit': 'Booking held: deposit pending',
|
||||||
|
'public.depositAmount': 'Deposit due: {amount}',
|
||||||
|
'public.depositHelp': 'You have {h} hours to pay the deposit; otherwise the booking is released.',
|
||||||
|
'public.payDeposit': 'Pay deposit',
|
||||||
|
'public.depositExpires': 'The link expires in {h} hours',
|
||||||
|
'pay.title': 'Online payment',
|
||||||
|
'pay.folioWord': 'Reference',
|
||||||
|
'pay.concept': 'Concept',
|
||||||
|
'pay.amount': 'Amount',
|
||||||
|
'pay.forPatient': 'For {name}',
|
||||||
|
'pay.payNow': 'Pay now',
|
||||||
|
'pay.paySimulated': 'Confirm payment (test mode)',
|
||||||
|
'pay.paidOk': 'Payment recorded!',
|
||||||
|
'pay.receipt': 'Receipt',
|
||||||
|
'pay.date': 'Date',
|
||||||
|
'pay.notAvailable': 'Online payment is not available right now. Please contact the clinic.',
|
||||||
|
'pay.expiresAt': 'This link expires on {date}',
|
||||||
|
'pay.expiredBody': 'This link has expired. Please ask the clinic for a new one.',
|
||||||
|
'pay.cancelledBody': 'This link was cancelled.',
|
||||||
|
'pay.unavailableTitle': 'Link unavailable',
|
||||||
|
'pay.invalidLink': 'Invalid link',
|
||||||
|
'pay.notFound': 'We could not find this payment link.',
|
||||||
|
'pay.payError': 'The payment could not be completed. Please try again.',
|
||||||
|
'pay.simulatedNote': 'Test mode: no real money is charged.',
|
||||||
|
'pay.secureNote': 'Payment protected by the clinic payment gateway.',
|
||||||
|
'pay.state.pagado': 'Paid',
|
||||||
|
|
||||||
// --- POS ---
|
// --- POS ---
|
||||||
'pos.title': 'Point of Sale',
|
'pos.title': 'Point of Sale',
|
||||||
@@ -1168,8 +1261,40 @@ export const en: Dict = {
|
|||||||
'agenda.noDoctorsSubWeek': 'No doctors are registered to show in the weekly view.',
|
'agenda.noDoctorsSubWeek': 'No doctors are registered to show in the weekly view.',
|
||||||
'agenda.noResourcesTitle': 'No resources',
|
'agenda.noResourcesTitle': 'No resources',
|
||||||
'agenda.noResourcesSub': 'Add resources in Settings › Resources to see this view.',
|
'agenda.noResourcesSub': 'Add resources in Settings › Resources to see this view.',
|
||||||
|
'agenda.depositPending': 'Deposit pending',
|
||||||
'config.idiomaTitle': 'Interface language',
|
'config.idiomaTitle': 'Interface language',
|
||||||
'config.idiomaBody': 'Choose the language the system displays in this browser.',
|
'config.idiomaBody': 'Choose the language the system displays in this browser.',
|
||||||
|
'config.anticipo.title': 'Deposit at booking',
|
||||||
|
'config.anticipo.help': 'Ask for a deposit when booking online. The booking is held without confirming until paid; if unpaid in time, the slot is released.',
|
||||||
|
'config.anticipo.enable': 'Require deposit',
|
||||||
|
'config.anticipo.enableHelp': 'Off, the portal books without asking for money (as today).',
|
||||||
|
'config.anticipo.type': 'Deposit type',
|
||||||
|
'config.anticipo.typePercent': 'Percentage of the service',
|
||||||
|
'config.anticipo.typeFixed': 'Fixed amount',
|
||||||
|
'config.anticipo.value': 'Value (percentage or amount)',
|
||||||
|
'config.anticipo.currency': 'Currency',
|
||||||
|
'config.anticipo.hours': 'Hours to pay',
|
||||||
|
'config.anticipo.refundable': 'Refundable',
|
||||||
|
'config.anticipo.refundableHelp': 'If the appointment is cancelled, the deposit is refunded.',
|
||||||
|
'config.anticipo.simulated': 'Allow simulated payment',
|
||||||
|
'config.anticipo.simulatedHelp': 'Lets you test the flow with no gateway and no real money. Keep it off in production.',
|
||||||
|
'config.anticipo.notice': 'Customer notice',
|
||||||
|
'config.anticipo.noticePlaceholder': 'A deposit is required to confirm your appointment.',
|
||||||
|
'config.cobroAuto.title': 'Automatic membership charging',
|
||||||
|
'config.cobroAuto.help': 'Generates the payment link for the period and sends it to the patient via notifications, with retries. The period stays pending until paid.',
|
||||||
|
'config.cobroAuto.enable': 'Enable automatic charging',
|
||||||
|
'config.cobroAuto.enableHelp': 'Off, the process only creates the charge (no charge or notice).',
|
||||||
|
'config.cobroAuto.method': 'Method',
|
||||||
|
'config.cobroAuto.methodLink': 'Payment link',
|
||||||
|
'config.cobroAuto.methodCard': 'Saved card (Stripe)',
|
||||||
|
'config.cobroAuto.daysBefore': 'Days before due date',
|
||||||
|
'config.cobroAuto.grace': 'Grace days',
|
||||||
|
'config.cobroAuto.retries': 'Notice retry days',
|
||||||
|
'config.cobroAuto.retriesHelp': 'Days (relative to due date) on which the notice is retried, comma separated.',
|
||||||
|
'config.cobroAuto.cancel': 'Expire if unpaid',
|
||||||
|
'config.cobroAuto.cancelHelp': 'When retries are exhausted, marks the membership as expired. Off, it stays active until the normal expiration process.',
|
||||||
|
'config.cobroAuto.simulated': 'Allow simulated payment',
|
||||||
|
'config.cobroAuto.simulatedHelp': 'Lets you test the flow with no gateway and no real money. Keep it off in production.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DICT: Record<Locale, Dict> = { es, en };
|
export const DICT: Record<Locale, Dict> = { es, en };
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const PERM_KEYS: string[] = [
|
|||||||
'compras.ver', 'compras.crear', 'compras.recibir', 'compras.pagar',
|
'compras.ver', 'compras.crear', 'compras.recibir', 'compras.pagar',
|
||||||
'compras.editar', 'compras.cancelar', 'proveedores.ver', 'proveedores.editar',
|
'compras.editar', 'compras.cancelar', 'proveedores.ver', 'proveedores.editar',
|
||||||
'comisiones.ver', 'comisiones.editar', 'comisiones.liquidar',
|
'comisiones.ver', 'comisiones.editar', 'comisiones.liquidar',
|
||||||
|
'cobros.ver', 'cobros.administrar', 'cobros.forzar', 'cobros.cancelar',
|
||||||
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
|
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
|
||||||
'api.claves', 'webhooks.editar',
|
'api.claves', 'webhooks.editar',
|
||||||
];
|
];
|
||||||
@@ -53,6 +54,7 @@ const READ_PERM_KEYS = new Set([
|
|||||||
'compras.ver',
|
'compras.ver',
|
||||||
'proveedores.ver',
|
'proveedores.ver',
|
||||||
'comisiones.ver',
|
'comisiones.ver',
|
||||||
|
'cobros.ver',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
||||||
@@ -84,6 +86,8 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
|||||||
'compras.editar': AG, 'compras.cancelar': AG,
|
'compras.editar': AG, 'compras.cancelar': AG,
|
||||||
'proveedores.ver': ALL, 'proveedores.editar': AG,
|
'proveedores.ver': ALL, 'proveedores.editar': AG,
|
||||||
'comisiones.ver': ALL, 'comisiones.editar': AG, 'comisiones.liquidar': AG,
|
'comisiones.ver': ALL, 'comisiones.editar': AG, 'comisiones.liquidar': AG,
|
||||||
|
'cobros.ver': ALL, 'cobros.administrar': AG,
|
||||||
|
'cobros.forzar': ['admin', 'recepcion', 'gerencia'], 'cobros.cancelar': AG,
|
||||||
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
|
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
|
||||||
'api.claves': AD, 'webhooks.editar': AG,
|
'api.claves': AD, 'webhooks.editar': AG,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2882,7 +2882,12 @@ const Agenda: FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{apt.doctor || '-'}</td>
|
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{apt.doctor || '-'}</td>
|
||||||
<td className="p-3">
|
<td className="p-3">
|
||||||
<Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term, t)}</Badge>
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
|
<Badge variant={badgeForAppointmentState(apt.state)}>{aptStateLabel(apt.state, term, t)}</Badge>
|
||||||
|
{apt.anticipo_estado === 'pendiente' && (
|
||||||
|
<Badge variant="warning">{t('agenda.depositPending')}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-3 text-center">
|
<td className="p-3 text-center">
|
||||||
<button
|
<button
|
||||||
@@ -3369,6 +3374,9 @@ const Agenda: FC = () => {
|
|||||||
{detailApt.is_first_visit && (
|
{detailApt.is_first_visit && (
|
||||||
<Badge variant="primary">Primera vez</Badge>
|
<Badge variant="primary">Primera vez</Badge>
|
||||||
)}
|
)}
|
||||||
|
{detailApt.anticipo_estado === 'pendiente' && (
|
||||||
|
<Badge variant="warning">{t('agenda.depositPending')}</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="p-3 bg-theme-bg rounded-xl">
|
<div className="p-3 bg-theme-bg rounded-xl">
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import LicenciaPanel from '../components/LicenciaPanel';
|
|||||||
import ApiKeysPanel from '../components/ApiKeysPanel';
|
import ApiKeysPanel from '../components/ApiKeysPanel';
|
||||||
import WebhooksPanel from '../components/WebhooksPanel';
|
import WebhooksPanel from '../components/WebhooksPanel';
|
||||||
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
import { Card, Button, Input, PageHeader, toast } from '../components/ui';
|
||||||
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo';
|
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo';
|
||||||
import { useAuth } from '../lib/auth';
|
import { useAuth } from '../lib/auth';
|
||||||
import { publicarClinicSettings } from '../lib/clinic-settings';
|
import { publicarClinicSettings } from '../lib/clinic-settings';
|
||||||
import { useI18n } from '../lib/i18n';
|
import { useI18n } from '../lib/i18n';
|
||||||
@@ -143,8 +143,17 @@ const Configuracion: FC = () => {
|
|||||||
...CLINIC_SETTINGS_DEFAULT,
|
...CLINIC_SETTINGS_DEFAULT,
|
||||||
...next,
|
...next,
|
||||||
impuestos: { ...IMPUESTOS_DEFAULT, ...(next.impuestos || {}) },
|
impuestos: { ...IMPUESTOS_DEFAULT, ...(next.impuestos || {}) },
|
||||||
reservas_online: { ...RESERVAS_ONLINE_DEFAULT, ...(next.reservas_online || {}) },
|
reservas_online: {
|
||||||
|
...RESERVAS_ONLINE_DEFAULT,
|
||||||
|
...(next.reservas_online || {}),
|
||||||
|
anticipo: { ...ANTICIPO_DEFAULT, ...((next.reservas_online || {}).anticipo || {}) },
|
||||||
|
},
|
||||||
notificaciones: { ...NOTIFICACIONES_DEFAULT, ...(next.notificaciones || {}) },
|
notificaciones: { ...NOTIFICACIONES_DEFAULT, ...(next.notificaciones || {}) },
|
||||||
|
membresias: {
|
||||||
|
...MEMBRESIAS_DEFAULT,
|
||||||
|
...(next.membresias || {}),
|
||||||
|
cobro_automatico: { ...COBRO_AUTOMATICO_DEFAULT, ...((next.membresias || {}).cobro_automatico || {}) },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
|||||||
191
frontend/src/pages/Pagar.tsx
Normal file
191
frontend/src/pages/Pagar.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
AlertTriangle, Check, CreditCard, Loader2, Lock, ShieldCheck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '../components/ui';
|
||||||
|
import { odooApi, type CobroComprobante, type CobroPublico } from '../services/odoo';
|
||||||
|
import { useI18n } from '../lib/i18n';
|
||||||
|
|
||||||
|
// Página pública de pago de un enlace de cobro (anticipo de reserva o periodo
|
||||||
|
// de membresía). Anónima: el token HMAC del enlace es la única credencial.
|
||||||
|
// Funciona igual con pasarela real (redirige al checkout) o en modo simulado.
|
||||||
|
|
||||||
|
const Pagar: FC = () => {
|
||||||
|
const { token = '' } = useParams();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [cobro, setCobro] = useState<CobroPublico | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [pagando, setPagando] = useState(false);
|
||||||
|
const [comprobante, setComprobante] = useState<CobroComprobante | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
if (!token) { setLoading(false); setError(t('pay.invalidLink')); return; }
|
||||||
|
odooApi.getCobroPublico(token)
|
||||||
|
.then((res) => { if (alive) setCobro(res); })
|
||||||
|
.catch((err) => {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } });
|
||||||
|
if (alive) setError(msg?.response?.data?.message || t('pay.notFound'));
|
||||||
|
})
|
||||||
|
.finally(() => { if (alive) setLoading(false); });
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const pagar = async () => {
|
||||||
|
setPagando(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await odooApi.pagarCobroPublico(token);
|
||||||
|
if (res.requiere_pasarela && res.url) {
|
||||||
|
window.location.href = res.url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.comprobante) {
|
||||||
|
setComprobante(res.comprobante);
|
||||||
|
setCobro((prev) => (prev ? { ...prev, estado: 'pagado', estado_label: t('pay.state.pagado') } : prev));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||||
|
setError(msg || t('pay.payError'));
|
||||||
|
} finally {
|
||||||
|
setPagando(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Centro><Loader2 className="h-6 w-6 animate-spin text-theme-muted" /></Centro>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cobro) {
|
||||||
|
return (
|
||||||
|
<Centro>
|
||||||
|
<Card>
|
||||||
|
<AlertTriangle className="mx-auto mb-3 h-8 w-8 text-amber-500" />
|
||||||
|
<h1 className="font-heading text-lg text-theme-heading">{t('pay.unavailableTitle')}</h1>
|
||||||
|
<p className="mt-2 text-sm text-theme-muted">{error || t('pay.expiredBody')}</p>
|
||||||
|
</Card>
|
||||||
|
</Centro>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pagado = comprobante || cobro.estado === 'pagado';
|
||||||
|
const montoTxt = `${cobro.moneda} ${cobro.monto.toFixed(2)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Centro>
|
||||||
|
<div className="w-full max-w-md space-y-4">
|
||||||
|
<header className="text-center">
|
||||||
|
{cobro.brand?.logo_oscuro && (
|
||||||
|
<img src={cobro.brand.logo_oscuro} alt={cobro.brand.nombre}
|
||||||
|
className="mx-auto mb-3 h-9 w-auto object-contain" />
|
||||||
|
)}
|
||||||
|
<h1 className="font-heading text-lg text-theme-heading">{cobro.brand?.nombre || ''}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="rounded-3xl border border-theme-border bg-theme-surface p-6 shadow-soft">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="font-heading text-base text-theme-heading">{t('pay.title')}</h2>
|
||||||
|
<span className={`rounded-full px-3 py-1 text-xs font-medium ${badgeClase(pagado ? 'pagado' : cobro.estado)}`}>
|
||||||
|
{pagado ? t('pay.state.pagado') : cobro.estado_label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-theme-muted">
|
||||||
|
{t('pay.folioWord')} <span className="font-semibold text-theme-heading">{cobro.folio}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-5 rounded-2xl bg-theme-bg p-4">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-theme-muted">{t('pay.concept')}</p>
|
||||||
|
<p className="mt-0.5 text-sm text-theme-heading">{cobro.concepto}</p>
|
||||||
|
<div className="mt-3 flex items-end justify-between">
|
||||||
|
<span className="text-xs uppercase tracking-wide text-theme-muted">{t('pay.amount')}</span>
|
||||||
|
<span className="font-heading text-2xl text-theme-heading">{montoTxt}</span>
|
||||||
|
</div>
|
||||||
|
{cobro.paciente && (
|
||||||
|
<p className="mt-2 text-xs text-theme-muted">{t('pay.forPatient', { name: cobro.paciente })}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Estado pagado: comprobante */}
|
||||||
|
{pagado && (
|
||||||
|
<div className="mt-4 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-center">
|
||||||
|
<Check className="mx-auto mb-2 h-7 w-7 text-emerald-600" />
|
||||||
|
<p className="text-sm font-medium text-emerald-800">{t('pay.paidOk')}</p>
|
||||||
|
<div className="mt-3 space-y-1 text-left text-xs text-emerald-800">
|
||||||
|
<p>{t('pay.receipt')}: <span className="font-semibold">{comprobante?.pago || cobro.folio}</span></p>
|
||||||
|
<p>{t('pay.concept')}: {comprobante?.concepto || cobro.concepto}</p>
|
||||||
|
<p>{t('pay.amount')}: {montoTxt}</p>
|
||||||
|
{comprobante?.fecha && <p>{t('pay.date')}: {String(comprobante.fecha).slice(0, 16)}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pendiente: botón de pago */}
|
||||||
|
{!pagado && cobro.estado === 'pendiente' && (
|
||||||
|
<>
|
||||||
|
{cobro.puede_pagar ? (
|
||||||
|
<Button className="mt-5 w-full" loading={pagando} onClick={pagar}>
|
||||||
|
<CreditCard size={16} className="mr-2" />
|
||||||
|
{cobro.simulado ? t('pay.paySimulated') : t('pay.payNow')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<p className="mt-5 rounded-xl bg-amber-50 p-3 text-sm text-amber-800">
|
||||||
|
{t('pay.notAvailable')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{cobro.expira && (
|
||||||
|
<p className="mt-3 text-center text-xs text-theme-muted">
|
||||||
|
{t('pay.expiresAt', { date: String(cobro.expira).slice(0, 16) })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(cobro.estado === 'expirado' || cobro.estado === 'cancelado') && !pagado && (
|
||||||
|
<p className="mt-5 rounded-xl bg-rose-50 p-3 text-sm text-rose-700">
|
||||||
|
{cobro.estado === 'expirado' ? t('pay.expiredBody') : t('pay.cancelledBody')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="mt-3 text-sm text-rose-600">{error}</p>}
|
||||||
|
|
||||||
|
{cobro.simulado && !pagado && (
|
||||||
|
<p className="mt-4 flex items-center justify-center gap-1 text-xs text-theme-muted">
|
||||||
|
<ShieldCheck size={13} /> {t('pay.simulatedNote')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!cobro.simulado && (
|
||||||
|
<p className="mt-4 flex items-center justify-center gap-1 text-xs text-theme-muted">
|
||||||
|
<Lock size={13} /> {t('pay.secureNote')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Centro>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const badgeClase = (estado: string): string => {
|
||||||
|
switch (estado) {
|
||||||
|
case 'pagado': return 'bg-emerald-100 text-emerald-700';
|
||||||
|
case 'pendiente': return 'bg-amber-100 text-amber-700';
|
||||||
|
case 'expirado': return 'bg-rose-100 text-rose-700';
|
||||||
|
default: return 'bg-theme-bg text-theme-muted';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Centro: FC<{ children: ReactNode }> = ({ children }) => (
|
||||||
|
<div className="flex min-h-screen items-start justify-center bg-theme-bg px-4 py-8 sm:items-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Card: FC<{ children: ReactNode }> = ({ children }) => (
|
||||||
|
<div className="w-full max-w-sm rounded-3xl border border-theme-border bg-theme-surface p-8 text-center shadow-soft">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Pagar;
|
||||||
@@ -37,6 +37,7 @@ import KpisReport from '../components/reportes/KpisReport';
|
|||||||
import RecursosReport from '../components/reportes/RecursosReport';
|
import RecursosReport from '../components/reportes/RecursosReport';
|
||||||
import ComprasReport from '../components/reportes/ComprasReport';
|
import ComprasReport from '../components/reportes/ComprasReport';
|
||||||
import MargenReport from '../components/reportes/MargenReport';
|
import MargenReport from '../components/reportes/MargenReport';
|
||||||
|
import CobrosReport from '../components/reportes/CobrosReport';
|
||||||
import { Card, Button, Input, PageHeader, Skeleton, EmptyState, toast } from '../components/ui';
|
import { Card, Button, Input, PageHeader, Skeleton, EmptyState, toast } from '../components/ui';
|
||||||
import { odooApi } from '../services/odoo';
|
import { odooApi } from '../services/odoo';
|
||||||
import type { CommissionRow } from '../services/odoo';
|
import type { CommissionRow } from '../services/odoo';
|
||||||
@@ -82,6 +83,7 @@ const FAMILIAS: ReportFamily[] = [
|
|||||||
{ key: 'impuestos', label: 'Impuestos', desc: 'Base e impuesto del periodo por mes, servicio y sucursal' },
|
{ key: 'impuestos', label: 'Impuestos', desc: 'Base e impuesto del periodo por mes, servicio y sucursal' },
|
||||||
{ key: 'cortes', label: 'Cortes de caja', desc: 'Arqueos y cierres por día' },
|
{ key: 'cortes', label: 'Cortes de caja', desc: 'Arqueos y cierres por día' },
|
||||||
{ key: 'adeudos', label: 'Adeudos', desc: 'Cartera por cobrar y planes de pago' },
|
{ key: 'adeudos', label: 'Adeudos', desc: 'Cartera por cobrar y planes de pago' },
|
||||||
|
{ key: 'cobros', label: 'Cobros', desc: 'Anticipos y membresías por cobrar (pendientes y expirados)' },
|
||||||
{ key: 'devoluciones', label: 'Devoluciones', desc: 'Ventas devueltas y montos' },
|
{ key: 'devoluciones', label: 'Devoluciones', desc: 'Ventas devueltas y montos' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -478,6 +480,8 @@ const ReportesContent: FC = () => {
|
|||||||
<MargenReport />
|
<MargenReport />
|
||||||
) : tab === 'adeudos' ? (
|
) : tab === 'adeudos' ? (
|
||||||
<AdeudosReport />
|
<AdeudosReport />
|
||||||
|
) : tab === 'cobros' ? (
|
||||||
|
<CobrosReport />
|
||||||
) : tab === 'comisiones' ? (
|
) : tab === 'comisiones' ? (
|
||||||
<ComisionesReport />
|
<ComisionesReport />
|
||||||
) : tab === 'pagos-servicios' ? (
|
) : tab === 'pagos-servicios' ? (
|
||||||
|
|||||||
@@ -158,11 +158,15 @@ const Reservar: FC = () => {
|
|||||||
<Centro>
|
<Centro>
|
||||||
<div className="w-full max-w-lg space-y-4">
|
<div className="w-full max-w-lg space-y-4">
|
||||||
<div className="rounded-3xl border border-theme-border bg-theme-surface p-6 text-center shadow-soft">
|
<div className="rounded-3xl border border-theme-border bg-theme-surface p-6 text-center shadow-soft">
|
||||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100">
|
<div className={`mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full ${resultado.esperando_anticipo ? 'bg-amber-100' : 'bg-emerald-100'}`}>
|
||||||
<Check className="h-6 w-6 text-emerald-600" />
|
{resultado.esperando_anticipo
|
||||||
|
? <Clock className="h-6 w-6 text-amber-600" />
|
||||||
|
: <Check className="h-6 w-6 text-emerald-600" />}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="font-heading text-xl text-theme-heading">
|
<h1 className="font-heading text-xl text-theme-heading">
|
||||||
{resultado.requiere_confirmacion ? t('public.requestReceived') : t('public.confirmed')}
|
{resultado.esperando_anticipo
|
||||||
|
? t('public.awaitingDeposit')
|
||||||
|
: (resultado.requiere_confirmacion ? t('public.requestReceived') : t('public.confirmed'))}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1 text-sm text-theme-muted">{t('public.folioWord')} <span className="font-semibold text-theme-heading">{resultado.folio}</span></p>
|
<p className="mt-1 text-sm text-theme-muted">{t('public.folioWord')} <span className="font-semibold text-theme-heading">{resultado.folio}</span></p>
|
||||||
<div className="mt-5 space-y-2 rounded-2xl bg-theme-bg p-4 text-left text-sm">
|
<div className="mt-5 space-y-2 rounded-2xl bg-theme-bg p-4 text-left text-sm">
|
||||||
@@ -171,10 +175,28 @@ const Reservar: FC = () => {
|
|||||||
{resultado.medico && <Linea icon={<User className="h-4 w-4" />} texto={resultado.medico} />}
|
{resultado.medico && <Linea icon={<User className="h-4 w-4" />} texto={resultado.medico} />}
|
||||||
{resultado.sucursal && <Linea icon={<MapPin className="h-4 w-4" />} texto={resultado.sucursal} />}
|
{resultado.sucursal && <Linea icon={<MapPin className="h-4 w-4" />} texto={resultado.sucursal} />}
|
||||||
</div>
|
</div>
|
||||||
{resultado.mensaje_confirmacion && (
|
{resultado.esperando_anticipo && resultado.anticipo && (
|
||||||
|
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-4 text-left">
|
||||||
|
<p className="text-sm font-medium text-amber-800">
|
||||||
|
{t('public.depositAmount', {
|
||||||
|
amount: `${resultado.anticipo.moneda} ${resultado.anticipo.monto.toFixed(2)}`,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-amber-700">
|
||||||
|
{resultado.mensaje_anticipo || t('public.depositHelp', { h: resultado.anticipo.horas_para_pagar })}
|
||||||
|
</p>
|
||||||
|
<Button className="mt-3 w-full" onClick={() => navigate(`/pagar/${resultado.anticipo!.token}`)}>
|
||||||
|
{t('public.payDeposit')}
|
||||||
|
</Button>
|
||||||
|
<p className="mt-2 text-center text-xs text-amber-700">
|
||||||
|
{t('public.depositExpires', { h: resultado.anticipo.horas_para_pagar })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!resultado.esperando_anticipo && resultado.mensaje_confirmacion && (
|
||||||
<p className="mt-4 text-sm text-theme-muted">{resultado.mensaje_confirmacion}</p>
|
<p className="mt-4 text-sm text-theme-muted">{resultado.mensaje_confirmacion}</p>
|
||||||
)}
|
)}
|
||||||
{resultado.requiere_confirmacion && !resultado.mensaje_confirmacion && (
|
{!resultado.esperando_anticipo && resultado.requiere_confirmacion && !resultado.mensaje_confirmacion && (
|
||||||
<p className="mt-4 text-sm text-theme-muted">
|
<p className="mt-4 text-sm text-theme-muted">
|
||||||
{t('public.willConfirm')}
|
{t('public.willConfirm')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -18,9 +18,14 @@ axios.interceptors.response.use(
|
|||||||
(res) => res,
|
(res) => res,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error?.response?.status === 401) {
|
if (error?.response?.status === 401) {
|
||||||
const onLogin = window.location.pathname === '/login';
|
const path = window.location.pathname;
|
||||||
|
// Rutas públicas (sin sesión): un 401 de un endpoint autenticado no debe
|
||||||
|
// expulsar al visitante a /login. Cubre el portal de reservas, la página
|
||||||
|
// de pago y el portal de formularios.
|
||||||
|
const PUBLICAS = ['/login', '/reservar', '/pagar', '/formulario'];
|
||||||
|
const onPublica = PUBLICAS.some((p) => path === p || path.startsWith(p + '/'));
|
||||||
const isLoginCall = String(error.config?.url || '').includes('/auth/login');
|
const isLoginCall = String(error.config?.url || '').includes('/auth/login');
|
||||||
if (!onLogin && !isLoginCall) {
|
if (!onPublica && !isLoginCall) {
|
||||||
localStorage.removeItem('skeen_token');
|
localStorage.removeItem('skeen_token');
|
||||||
localStorage.removeItem('skeen_user');
|
localStorage.removeItem('skeen_user');
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
@@ -92,6 +97,10 @@ export interface Appointment {
|
|||||||
time_float?: number;
|
time_float?: number;
|
||||||
/** Etiqueta del estado con el vocabulario de la clínica */
|
/** Etiqueta del estado con el vocabulario de la clínica */
|
||||||
state_label?: string;
|
state_label?: string;
|
||||||
|
/** Anticipo de reserva en línea: 'ninguno' | 'pendiente' | 'pagado' | 'reembolsado' */
|
||||||
|
anticipo_estado?: string;
|
||||||
|
anticipo_estado_label?: string;
|
||||||
|
anticipo_monto?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Motivos predefinidos de un bloqueo de agenda */
|
/** Motivos predefinidos de un bloqueo de agenda */
|
||||||
@@ -1213,6 +1222,33 @@ export interface WebhookEventoCatalogo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Reservas en línea (portal público de auto-agendado) ----
|
// ---- Reservas en línea (portal público de auto-agendado) ----
|
||||||
|
export interface AnticipoSettings {
|
||||||
|
/** Enciende el anticipo al reservar en línea. Apagado = nada cambia. */
|
||||||
|
activo: boolean;
|
||||||
|
tipo: 'porcentaje' | 'monto_fijo';
|
||||||
|
/** Porcentaje (%) o monto fijo según `tipo`. */
|
||||||
|
valor: number;
|
||||||
|
moneda: string;
|
||||||
|
/** Horas que tiene el cliente para pagar antes de liberar el horario. */
|
||||||
|
horas_para_pagar: number;
|
||||||
|
reembolsable: boolean;
|
||||||
|
/** Texto que el portal muestra al pedir el anticipo. */
|
||||||
|
texto_aviso: string;
|
||||||
|
/** Permite confirmar el pago en modo simulado (sin pasarela). */
|
||||||
|
permitir_simulado: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ANTICIPO_DEFAULT: AnticipoSettings = {
|
||||||
|
activo: false,
|
||||||
|
tipo: 'porcentaje',
|
||||||
|
valor: 0,
|
||||||
|
moneda: 'MXN',
|
||||||
|
horas_para_pagar: 24,
|
||||||
|
reembolsable: true,
|
||||||
|
texto_aviso: '',
|
||||||
|
permitir_simulado: false,
|
||||||
|
};
|
||||||
|
|
||||||
export interface ReservasOnlineSettings {
|
export interface ReservasOnlineSettings {
|
||||||
activo: boolean;
|
activo: boolean;
|
||||||
titulo: string;
|
titulo: string;
|
||||||
@@ -1226,6 +1262,7 @@ export interface ReservasOnlineSettings {
|
|||||||
cancelar_hasta_horas: number;
|
cancelar_hasta_horas: number;
|
||||||
mensaje_confirmacion: string;
|
mensaje_confirmacion: string;
|
||||||
telefono_contacto: string;
|
telefono_contacto: string;
|
||||||
|
anticipo: AnticipoSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RESERVAS_ONLINE_DEFAULT: ReservasOnlineSettings = {
|
export const RESERVAS_ONLINE_DEFAULT: ReservasOnlineSettings = {
|
||||||
@@ -1241,6 +1278,7 @@ export const RESERVAS_ONLINE_DEFAULT: ReservasOnlineSettings = {
|
|||||||
cancelar_hasta_horas: 24,
|
cancelar_hasta_horas: 24,
|
||||||
mensaje_confirmacion: '',
|
mensaje_confirmacion: '',
|
||||||
telefono_contacto: '',
|
telefono_contacto: '',
|
||||||
|
anticipo: { ...ANTICIPO_DEFAULT },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- Notificaciones y recordatorios de citas ----
|
// ---- Notificaciones y recordatorios de citas ----
|
||||||
@@ -1309,6 +1347,8 @@ export interface NotificacionesSettings {
|
|||||||
plantilla_post_visita: string;
|
plantilla_post_visita: string;
|
||||||
/** Aviso de un formulario digital: usa {formulario} y {enlace}. */
|
/** Aviso de un formulario digital: usa {formulario} y {enlace}. */
|
||||||
plantilla_formulario: string;
|
plantilla_formulario: string;
|
||||||
|
/** Aviso con enlace de pago del periodo (cobro automático de membresías). */
|
||||||
|
plantilla_cobro_membresia: string;
|
||||||
whatsapp_numero: string;
|
whatsapp_numero: string;
|
||||||
email_remitente: string;
|
email_remitente: string;
|
||||||
}
|
}
|
||||||
@@ -1328,12 +1368,13 @@ export const NOTIFICACIONES_DEFAULT: NotificacionesSettings = {
|
|||||||
plantilla_cumpleanos: '¡Feliz cumpleaños, {nombre}! Todo el equipo de {sucursal} te desea un gran día. Te esperamos pronto.',
|
plantilla_cumpleanos: '¡Feliz cumpleaños, {nombre}! Todo el equipo de {sucursal} te desea un gran día. Te esperamos pronto.',
|
||||||
plantilla_post_visita: 'Hola {nombre}, gracias por tu visita de {servicio} el {fecha}. Si tienes dudas o quieres agendar de nuevo, llámanos al {telefono}.',
|
plantilla_post_visita: 'Hola {nombre}, gracias por tu visita de {servicio} el {fecha}. Si tienes dudas o quieres agendar de nuevo, llámanos al {telefono}.',
|
||||||
plantilla_formulario: 'Hola {nombre}, te compartimos el formulario "{formulario}" para completar. Ábrelo desde tu celular aquí: {enlace}',
|
plantilla_formulario: 'Hola {nombre}, te compartimos el formulario "{formulario}" para completar. Ábrelo desde tu celular aquí: {enlace}',
|
||||||
|
plantilla_cobro_membresia: 'Hola {nombre}, tu plan {plan} ({periodo}) está por renovarse. Paga {monto} desde este enlace: {enlace}',
|
||||||
whatsapp_numero: '',
|
whatsapp_numero: '',
|
||||||
email_remitente: '',
|
email_remitente: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Variables permitidas en las plantillas (espejo del backend). */
|
/** Variables permitidas en las plantillas (espejo del backend). */
|
||||||
export const NOTIF_VARIABLES = ['nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor', 'sucursal', 'telefono', 'enlace', 'formulario'] as const;
|
export const NOTIF_VARIABLES = ['nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor', 'sucursal', 'telefono', 'enlace', 'formulario', 'plan', 'membresia', 'monto', 'sesiones', 'periodo'] as const;
|
||||||
|
|
||||||
/** Ventanas sugeridas para los chips de recordatorio. */
|
/** Ventanas sugeridas para los chips de recordatorio. */
|
||||||
export const NOTIF_VENTANAS: { horas: number; label: string }[] = [
|
export const NOTIF_VENTANAS: { horas: number; label: string }[] = [
|
||||||
@@ -1345,6 +1386,31 @@ export const NOTIF_VENTANAS: { horas: number; label: string }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// ---- Membresías y suscripciones ----
|
// ---- Membresías y suscripciones ----
|
||||||
|
export interface CobroAutomaticoSettings {
|
||||||
|
/** Enciende el cobro automático del periodo de membresía. */
|
||||||
|
activo: boolean;
|
||||||
|
/** Días antes del vencimiento en que se envía el primer aviso/enlace. */
|
||||||
|
dias_antes: number;
|
||||||
|
metodo: 'enlace' | 'tarjeta_guardada';
|
||||||
|
/** Días (respecto al vencimiento) en que se reintenta el aviso. */
|
||||||
|
reintentos_dias: number[];
|
||||||
|
/** Al agotar reintentos, marca la membresía vencida (si no, sigue activa). */
|
||||||
|
cancelar_si_no_paga: boolean;
|
||||||
|
dias_gracia: number;
|
||||||
|
/** Permite confirmar el pago en modo simulado (sin pasarela). */
|
||||||
|
permitir_simulado: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COBRO_AUTOMATICO_DEFAULT: CobroAutomaticoSettings = {
|
||||||
|
activo: false,
|
||||||
|
dias_antes: 3,
|
||||||
|
metodo: 'enlace',
|
||||||
|
reintentos_dias: [1, 3, 7],
|
||||||
|
cancelar_si_no_paga: false,
|
||||||
|
dias_gracia: 5,
|
||||||
|
permitir_simulado: false,
|
||||||
|
};
|
||||||
|
|
||||||
export interface MembresiasSettings {
|
export interface MembresiasSettings {
|
||||||
/** Enciende TODO el módulo: menú, POS, cron y cargos. Apagado = no cambia nada. */
|
/** Enciende TODO el módulo: menú, POS, cron y cargos. Apagado = no cambia nada. */
|
||||||
activo: boolean;
|
activo: boolean;
|
||||||
@@ -1361,6 +1427,8 @@ export interface MembresiasSettings {
|
|||||||
generar_cargo_al_renovar: boolean;
|
generar_cargo_al_renovar: boolean;
|
||||||
/** Días tras el vencimiento antes de marcar la membresía vencida. */
|
/** Días tras el vencimiento antes de marcar la membresía vencida. */
|
||||||
dias_gracia: number;
|
dias_gracia: number;
|
||||||
|
/** Cobro automático del periodo con enlace de pago. */
|
||||||
|
cobro_automatico: CobroAutomaticoSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MEMBRESIAS_DEFAULT: MembresiasSettings = {
|
export const MEMBRESIAS_DEFAULT: MembresiasSettings = {
|
||||||
@@ -1373,6 +1441,7 @@ export const MEMBRESIAS_DEFAULT: MembresiasSettings = {
|
|||||||
permitir_pausa_pago: false,
|
permitir_pausa_pago: false,
|
||||||
generar_cargo_al_renovar: true,
|
generar_cargo_al_renovar: true,
|
||||||
dias_gracia: 5,
|
dias_gracia: 5,
|
||||||
|
cobro_automatico: { ...COBRO_AUTOMATICO_DEFAULT },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- Compras y proveedores ----
|
// ---- Compras y proveedores ----
|
||||||
@@ -2042,6 +2111,16 @@ export interface BookingConfig {
|
|||||||
descripcion: string;
|
descripcion: string;
|
||||||
mensaje_confirmacion: string;
|
mensaje_confirmacion: string;
|
||||||
zona_horaria?: string;
|
zona_horaria?: string;
|
||||||
|
/** Anticipo del portal (apagado por defecto). */
|
||||||
|
anticipo?: {
|
||||||
|
activo: boolean;
|
||||||
|
tipo: 'porcentaje' | 'monto_fijo';
|
||||||
|
valor: number;
|
||||||
|
moneda: string;
|
||||||
|
horas_para_pagar: number;
|
||||||
|
reembolsable: boolean;
|
||||||
|
texto_aviso: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2070,6 +2149,19 @@ export interface BookingCreateResult {
|
|||||||
requiere_confirmacion: boolean;
|
requiere_confirmacion: boolean;
|
||||||
mensaje_confirmacion: string;
|
mensaje_confirmacion: string;
|
||||||
token_de_gestion: string;
|
token_de_gestion: string;
|
||||||
|
/** Con anticipo activo: la cita queda retenida esperando el pago. */
|
||||||
|
esperando_anticipo?: boolean;
|
||||||
|
mensaje_anticipo?: string;
|
||||||
|
anticipo?: {
|
||||||
|
monto: number;
|
||||||
|
moneda: string;
|
||||||
|
horas_para_pagar: number;
|
||||||
|
reembolsable: boolean;
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
expira: string;
|
||||||
|
proveedor: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BookingCita {
|
export interface BookingCita {
|
||||||
@@ -2088,6 +2180,63 @@ export interface BookingCita {
|
|||||||
telefono_contacto: string;
|
telefono_contacto: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Enlaces de cobro (anticipos y membresías) ----
|
||||||
|
export interface CobroPublico {
|
||||||
|
status: string;
|
||||||
|
folio: string;
|
||||||
|
concepto: string;
|
||||||
|
monto: number;
|
||||||
|
moneda: string;
|
||||||
|
estado: 'pendiente' | 'pagado' | 'expirado' | 'cancelado';
|
||||||
|
estado_label: string;
|
||||||
|
origen: 'reserva' | 'membresia' | 'venta';
|
||||||
|
proveedor: 'simulado' | 'stripe' | 'mercadopago';
|
||||||
|
expira: string | null;
|
||||||
|
fecha_pago: string | null;
|
||||||
|
paciente: string;
|
||||||
|
puede_pagar: boolean;
|
||||||
|
simulado: boolean;
|
||||||
|
url_externa: string;
|
||||||
|
brand: ClinicBrand;
|
||||||
|
terms: GlossaryTerms;
|
||||||
|
zona_horaria?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CobroComprobante {
|
||||||
|
folio: string;
|
||||||
|
concepto: string;
|
||||||
|
monto: number;
|
||||||
|
moneda: string;
|
||||||
|
fecha: string;
|
||||||
|
proveedor: string;
|
||||||
|
pago: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Cobro {
|
||||||
|
id: number;
|
||||||
|
nombre: string;
|
||||||
|
origen: string;
|
||||||
|
origen_label: string;
|
||||||
|
estado: string;
|
||||||
|
estado_label: string;
|
||||||
|
proveedor: string;
|
||||||
|
partner_id: number | null;
|
||||||
|
patient: string;
|
||||||
|
cita_id: number | null;
|
||||||
|
folio_cita: string;
|
||||||
|
venta_id: number | null;
|
||||||
|
concepto: string;
|
||||||
|
monto: number;
|
||||||
|
moneda: string;
|
||||||
|
creado: string | null;
|
||||||
|
expira: string | null;
|
||||||
|
fecha_pago: string | null;
|
||||||
|
recordatorios: number;
|
||||||
|
url: string;
|
||||||
|
vencido?: boolean;
|
||||||
|
dias_para_vencer?: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Formularios digitales y consentimientos ----
|
// ---- Formularios digitales y consentimientos ----
|
||||||
export type FormularioCampoTipo =
|
export type FormularioCampoTipo =
|
||||||
| 'texto' | 'texto_largo' | 'numero' | 'fecha' | 'seleccion' | 'multi_seleccion'
|
| 'texto' | 'texto_largo' | 'numero' | 'fecha' | 'seleccion' | 'multi_seleccion'
|
||||||
@@ -3298,6 +3447,46 @@ export const odooApi = {
|
|||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---- Enlaces de cobro ----
|
||||||
|
// Público (sin sesión): consultar y pagar un enlace por token.
|
||||||
|
async getCobroPublico(token: string): Promise<CobroPublico> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/public/cobro`, { params: { token } });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async pagarCobroPublico(token: string, hp?: string): Promise<{
|
||||||
|
status: string; ok?: boolean; estado?: string; estado_label?: string;
|
||||||
|
requiere_pasarela?: boolean; url?: string; proveedor?: string;
|
||||||
|
comprobante?: CobroComprobante; message?: string;
|
||||||
|
}> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/public/cobro/pagar`, { token, hp });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Personal (con sesión).
|
||||||
|
async listCobros(params?: { estado?: string; origen?: string }): Promise<{ status: string; cobros: Cobro[]; message?: string }> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/cobros`, { params });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async forzarCobro(id: number, payload?: { proveedor?: string; referencia?: string }): Promise<{ status: string; cobro: Cobro; pago: string; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/cobros/${id}/forzar`, payload || {});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancelarCobro(id: number, motivo?: string): Promise<{ status: string; cobro: Cobro; message?: string }> {
|
||||||
|
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/cobros/${id}/cancelar`, { motivo });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCobrosReport(params: { start?: string; end?: string }): Promise<{
|
||||||
|
status: string; start: string | null; end: string | null; cobros: Cobro[];
|
||||||
|
total: number; resumen: { pendiente: number; expirado: number; cancelado: number }; message?: string;
|
||||||
|
}> {
|
||||||
|
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/reports/cobros`, { params });
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
// ---- Formularios digitales: plantillas ----
|
// ---- Formularios digitales: plantillas ----
|
||||||
async getFormularioPlantillas(opts?: { includeInactive?: boolean; tipo?: string }): Promise<{ status: string; plantillas: FormularioPlantilla[]; total: number; message?: string }> {
|
async getFormularioPlantillas(opts?: { includeInactive?: boolean; tipo?: string }): Promise<{ status: string; plantillas: FormularioPlantilla[]; total: number; message?: string }> {
|
||||||
const params: Record<string, string | number> = {};
|
const params: Record<string, string | number> = {};
|
||||||
|
|||||||
@@ -184,12 +184,29 @@ RESERVAS_ONLINE_DEFAULT = {
|
|||||||
'cancelar_hasta_horas': 24,
|
'cancelar_hasta_horas': 24,
|
||||||
'mensaje_confirmacion': '',
|
'mensaje_confirmacion': '',
|
||||||
'telefono_contacto': '',
|
'telefono_contacto': '',
|
||||||
|
'anticipo': {
|
||||||
|
'activo': False,
|
||||||
|
'tipo': 'porcentaje',
|
||||||
|
'valor': 0.0,
|
||||||
|
'moneda': 'MXN',
|
||||||
|
'horas_para_pagar': 24,
|
||||||
|
'reembolsable': True,
|
||||||
|
'texto_aviso': '',
|
||||||
|
# Extensión propia: sin credenciales de pasarela el enlace nace
|
||||||
|
# `simulado`; confirmarlo solo se permite si esta llave está encendida
|
||||||
|
# (nunca se simula un pago en producción con el default apagado).
|
||||||
|
'permitir_simulado': False,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Rangos razonables de validación del PUT (días / horas).
|
# Rangos razonables de validación del PUT (días / horas).
|
||||||
RESERVAS_MAX_DIAS_MIN, RESERVAS_MAX_DIAS_MAX = 1, 365
|
RESERVAS_MAX_DIAS_MIN, RESERVAS_MAX_DIAS_MAX = 1, 365
|
||||||
RESERVAS_MIN_ANTICIPACION_MAX = 168 # 1 semana
|
RESERVAS_MIN_ANTICIPACION_MAX = 168 # 1 semana
|
||||||
RESERVAS_CANCELAR_HASTA_MAX = 168 # 1 semana
|
RESERVAS_CANCELAR_HASTA_MAX = 168 # 1 semana
|
||||||
|
ANTICIPO_HORAS_MAX = 720 # 30 días para pagar el anticipo
|
||||||
|
ANTICIPO_TIPOS = ('porcentaje', 'monto_fijo')
|
||||||
|
ANTICIPO_MONEDAS = ('MXN', 'USD')
|
||||||
|
ANTICIPO_TEXTO_MAX = 500
|
||||||
|
|
||||||
|
|
||||||
def _int(valor, por_defecto=0):
|
def _int(valor, por_defecto=0):
|
||||||
@@ -199,9 +216,77 @@ def _int(valor, por_defecto=0):
|
|||||||
return por_defecto
|
return por_defecto
|
||||||
|
|
||||||
|
|
||||||
|
def normalizar_anticipo(raw):
|
||||||
|
"""Sub-bloque `anticipo` de reservas en línea, con defaults. Nunca lanza."""
|
||||||
|
out = dict(RESERVAS_ONLINE_DEFAULT['anticipo'])
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return out
|
||||||
|
out['activo'] = _bool(raw.get('activo'), out['activo'])
|
||||||
|
out['reembolsable'] = _bool(raw.get('reembolsable'), out['reembolsable'])
|
||||||
|
out['permitir_simulado'] = _bool(raw.get('permitir_simulado'), out['permitir_simulado'])
|
||||||
|
tipo = str(raw.get('tipo') or '').strip()
|
||||||
|
out['tipo'] = tipo if tipo in ANTICIPO_TIPOS else out['tipo']
|
||||||
|
moneda = str(raw.get('moneda') or '').strip().upper()
|
||||||
|
out['moneda'] = moneda if moneda in ANTICIPO_MONEDAS else out['moneda']
|
||||||
|
out['valor'] = max(0.0, _num(raw.get('valor'), out['valor']))
|
||||||
|
out['horas_para_pagar'] = min(max(_int(raw.get('horas_para_pagar'),
|
||||||
|
out['horas_para_pagar']), 1), ANTICIPO_HORAS_MAX)
|
||||||
|
out['texto_aviso'] = str(raw.get('texto_aviso') or '')[:ANTICIPO_TEXTO_MAX]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def validar_anticipo(raw):
|
||||||
|
"""Valida el sub-bloque `anticipo` de un PUT. Devuelve solo las claves
|
||||||
|
presentes y normalizadas (merge anidado)."""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError('El bloque de anticipo debe ser un objeto')
|
||||||
|
out = {}
|
||||||
|
for k in ('activo', 'reembolsable', 'permitir_simulado'):
|
||||||
|
if k in raw:
|
||||||
|
if not isinstance(raw[k], bool):
|
||||||
|
raise ValueError('El campo %s del anticipo debe ser verdadero o falso' % k)
|
||||||
|
out[k] = raw[k]
|
||||||
|
if 'tipo' in raw:
|
||||||
|
tipo = str(raw['tipo'] or '').strip()
|
||||||
|
if tipo not in ANTICIPO_TIPOS:
|
||||||
|
raise ValueError("El tipo de anticipo debe ser 'porcentaje' o 'monto_fijo'")
|
||||||
|
out['tipo'] = tipo
|
||||||
|
if 'moneda' in raw:
|
||||||
|
moneda = str(raw['moneda'] or '').strip().upper()
|
||||||
|
if moneda not in ANTICIPO_MONEDAS:
|
||||||
|
raise ValueError('La moneda del anticipo debe ser MXN o USD')
|
||||||
|
out['moneda'] = moneda
|
||||||
|
if 'valor' in raw:
|
||||||
|
n = _num(raw['valor'], None)
|
||||||
|
if n is None:
|
||||||
|
raise ValueError('El valor del anticipo debe ser un número')
|
||||||
|
if n < 0:
|
||||||
|
raise ValueError('El valor del anticipo no puede ser negativo')
|
||||||
|
if (out.get('tipo') or raw.get('tipo') or 'porcentaje') == 'porcentaje' and n > 100:
|
||||||
|
raise ValueError('El porcentaje del anticipo debe estar entre 0 y 100')
|
||||||
|
out['valor'] = n
|
||||||
|
if 'horas_para_pagar' in raw:
|
||||||
|
if isinstance(raw['horas_para_pagar'], bool) or not isinstance(raw['horas_para_pagar'], int):
|
||||||
|
raise ValueError('Las horas para pagar deben ser un número entero')
|
||||||
|
if not (1 <= raw['horas_para_pagar'] <= ANTICIPO_HORAS_MAX):
|
||||||
|
raise ValueError('Las horas para pagar deben estar entre 1 y %d' % ANTICIPO_HORAS_MAX)
|
||||||
|
out['horas_para_pagar'] = raw['horas_para_pagar']
|
||||||
|
if 'texto_aviso' in raw:
|
||||||
|
if not isinstance(raw['texto_aviso'], str):
|
||||||
|
raise ValueError('El texto de aviso debe ser texto')
|
||||||
|
if len(raw['texto_aviso']) > ANTICIPO_TEXTO_MAX:
|
||||||
|
raise ValueError('El texto de aviso es demasiado largo (máx. %d)' % ANTICIPO_TEXTO_MAX)
|
||||||
|
out['texto_aviso'] = raw['texto_aviso'].strip()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def normalizar_reservas_online(raw):
|
def normalizar_reservas_online(raw):
|
||||||
"""Bloque `reservas_online` completo, con defaults. Tolerante: nunca lanza."""
|
"""Bloque `reservas_online` completo, con defaults. Tolerante: nunca lanza."""
|
||||||
out = dict(RESERVAS_ONLINE_DEFAULT)
|
out = dict(RESERVAS_ONLINE_DEFAULT)
|
||||||
|
out['anticipo'] = normalizar_anticipo(
|
||||||
|
raw.get('anticipo') if isinstance(raw, dict) else None)
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
return out
|
return out
|
||||||
for k in ('activo', 'requiere_confirmacion', 'permitir_eleccion_doctor',
|
for k in ('activo', 'requiere_confirmacion', 'permitir_eleccion_doctor',
|
||||||
@@ -261,6 +346,10 @@ def validar_reservas_online(raw):
|
|||||||
if not (lo <= raw[k] <= hi):
|
if not (lo <= raw[k] <= hi):
|
||||||
raise ValueError('El campo %s debe estar entre %d y %d' % (k, lo, hi))
|
raise ValueError('El campo %s debe estar entre %d y %d' % (k, lo, hi))
|
||||||
out[k] = raw[k]
|
out[k] = raw[k]
|
||||||
|
if 'anticipo' in raw:
|
||||||
|
sub = validar_anticipo(raw['anticipo'])
|
||||||
|
if sub is not None:
|
||||||
|
out['anticipo'] = sub
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -268,6 +357,11 @@ def get_reservas_online(env):
|
|||||||
"""Bloque de reservas en línea vigente (copia). Fuente única para la API."""
|
"""Bloque de reservas en línea vigente (copia). Fuente única para la API."""
|
||||||
return normalizar_reservas_online(get_settings(env).get('reservas_online'))
|
return normalizar_reservas_online(get_settings(env).get('reservas_online'))
|
||||||
|
|
||||||
|
|
||||||
|
def get_anticipo(env):
|
||||||
|
"""Sub-bloque `anticipo` vigente (copia). Apagado por defecto."""
|
||||||
|
return get_reservas_online(env)['anticipo']
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Recordatorios y notificaciones automáticas de citas.
|
# Recordatorios y notificaciones automáticas de citas.
|
||||||
#
|
#
|
||||||
@@ -328,6 +422,11 @@ NOTIFICACIONES_DEFAULT = {
|
|||||||
'Hola {nombre}, tu plan {plan} se renueva el {fecha} ({periodo}) por '
|
'Hola {nombre}, tu plan {plan} se renueva el {fecha} ({periodo}) por '
|
||||||
'{monto}. Tienes {sesiones} sesiones este periodo. {sucursal}.'
|
'{monto}. Tienes {sesiones} sesiones este periodo. {sucursal}.'
|
||||||
),
|
),
|
||||||
|
# Aviso con el ENLACE DE PAGO del periodo (cobro automático de membresías).
|
||||||
|
'plantilla_cobro_membresia': (
|
||||||
|
'Hola {nombre}, tu plan {plan} ({periodo}) está por renovarse. '
|
||||||
|
'Paga {monto} desde este enlace: {enlace}'
|
||||||
|
),
|
||||||
'whatsapp_numero': '',
|
'whatsapp_numero': '',
|
||||||
'email_remitente': '',
|
'email_remitente': '',
|
||||||
}
|
}
|
||||||
@@ -339,7 +438,8 @@ NOTIF_VARIABLES = ('nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor',
|
|||||||
'plan', 'membresia', 'monto', 'sesiones', 'periodo')
|
'plan', 'membresia', 'monto', 'sesiones', 'periodo')
|
||||||
NOTIF_PLANTILLAS = ('plantilla_recordatorio', 'plantilla_confirmacion',
|
NOTIF_PLANTILLAS = ('plantilla_recordatorio', 'plantilla_confirmacion',
|
||||||
'plantilla_cumpleanos', 'plantilla_post_visita',
|
'plantilla_cumpleanos', 'plantilla_post_visita',
|
||||||
'plantilla_formulario', 'plantilla_renovacion_membresia')
|
'plantilla_formulario', 'plantilla_renovacion_membresia',
|
||||||
|
'plantilla_cobro_membresia')
|
||||||
NOTIF_HORAS_MAX = 168 # 1 semana
|
NOTIF_HORAS_MAX = 168 # 1 semana
|
||||||
NOTIF_PLANTILLA_MAX = 1000 # caracteres por plantilla
|
NOTIF_PLANTILLA_MAX = 1000 # caracteres por plantilla
|
||||||
NOTIF_TEXTO_MAX = 120 # whatsapp_numero / email_remitente
|
NOTIF_TEXTO_MAX = 120 # whatsapp_numero / email_remitente
|
||||||
@@ -516,6 +616,8 @@ def get_notificaciones(env):
|
|||||||
# permitir_pausa_pago: permite pausar la membresía por falta de pago
|
# permitir_pausa_pago: permite pausar la membresía por falta de pago
|
||||||
# generar_cargo_al_renovar: el cron genera el adeudo del periodo
|
# generar_cargo_al_renovar: el cron genera el adeudo del periodo
|
||||||
# dias_gracia: días tras el vencimiento antes de marcarla vencida
|
# dias_gracia: días tras el vencimiento antes de marcarla vencida
|
||||||
|
# cobro_automatico: {activo, dias_antes, metodo, reintentos_dias,
|
||||||
|
# cancelar_si_no_paga, dias_gracia, permitir_simulado}
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
MEMBRESIAS_DEFAULT = {
|
MEMBRESIAS_DEFAULT = {
|
||||||
'activo': False,
|
'activo': False,
|
||||||
@@ -527,16 +629,113 @@ MEMBRESIAS_DEFAULT = {
|
|||||||
'permitir_pausa_pago': False,
|
'permitir_pausa_pago': False,
|
||||||
'generar_cargo_al_renovar': True,
|
'generar_cargo_al_renovar': True,
|
||||||
'dias_gracia': 5,
|
'dias_gracia': 5,
|
||||||
|
'cobro_automatico': {
|
||||||
|
'activo': False,
|
||||||
|
'dias_antes': 3,
|
||||||
|
'metodo': 'enlace',
|
||||||
|
'reintentos_dias': [1, 3, 7],
|
||||||
|
'cancelar_si_no_paga': False,
|
||||||
|
'dias_gracia': 5,
|
||||||
|
# Extensión propia: permite confirmar enlaces en modo simulado (sin
|
||||||
|
# pasarela). Apagado por defecto: nunca se cobra de mentiras.
|
||||||
|
'permitir_simulado': False,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
MEMBRESIAS_DIAS_MAX = 365 # tope del límite de congelamiento por año
|
MEMBRESIAS_DIAS_MAX = 365 # tope del límite de congelamiento por año
|
||||||
MEMBRESIAS_AVISO_MAX = 365 # tope de días de aviso de renovación
|
MEMBRESIAS_AVISO_MAX = 365 # tope de días de aviso de renovación
|
||||||
|
COBRO_METODOS = ('enlace', 'tarjeta_guardada')
|
||||||
|
COBRO_DIAS_ANTES_MAX = 365
|
||||||
|
COBRO_REINTENTO_MAX = 365
|
||||||
|
|
||||||
|
|
||||||
|
def _dias_lista(valor, por_defecto=None):
|
||||||
|
"""Lista de días (enteros 0-365, sin repetir, ordenada). Tolerante."""
|
||||||
|
default = list(por_defecto or MEMBRESIAS_DEFAULT['cobro_automatico']['reintentos_dias'])
|
||||||
|
if not isinstance(valor, (list, tuple)):
|
||||||
|
return default
|
||||||
|
limpios = []
|
||||||
|
for v in valor:
|
||||||
|
if isinstance(v, bool):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
n = int(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if 0 <= n <= COBRO_REINTENTO_MAX and n not in limpios:
|
||||||
|
limpios.append(n)
|
||||||
|
return sorted(limpios) if limpios else default
|
||||||
|
|
||||||
|
|
||||||
|
def normalizar_cobro_automatico(raw):
|
||||||
|
"""Sub-bloque `cobro_automatico` de membresías, con defaults. Nunca lanza."""
|
||||||
|
out = dict(MEMBRESIAS_DEFAULT['cobro_automatico'])
|
||||||
|
out['reintentos_dias'] = list(MEMBRESIAS_DEFAULT['cobro_automatico']['reintentos_dias'])
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return out
|
||||||
|
for k in ('activo', 'cancelar_si_no_paga', 'permitir_simulado'):
|
||||||
|
out[k] = _bool(raw.get(k), out[k])
|
||||||
|
metodo = str(raw.get('metodo') or '').strip()
|
||||||
|
out['metodo'] = metodo if metodo in COBRO_METODOS else out['metodo']
|
||||||
|
out['dias_antes'] = min(max(_int(raw.get('dias_antes'), out['dias_antes']),
|
||||||
|
0), COBRO_DIAS_ANTES_MAX)
|
||||||
|
out['dias_gracia'] = min(max(_int(raw.get('dias_gracia'), out['dias_gracia']), 0), 365)
|
||||||
|
out['reintentos_dias'] = _dias_lista(raw.get('reintentos_dias'),
|
||||||
|
out['reintentos_dias'])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def validar_cobro_automatico(raw):
|
||||||
|
"""Valida el sub-bloque `cobro_automatico` de un PUT (merge anidado)."""
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError('El bloque de cobro automático debe ser un objeto')
|
||||||
|
out = {}
|
||||||
|
for k in ('activo', 'cancelar_si_no_paga', 'permitir_simulado'):
|
||||||
|
if k in raw:
|
||||||
|
if not isinstance(raw[k], bool):
|
||||||
|
raise ValueError('El campo %s del cobro automático debe ser verdadero o falso' % k)
|
||||||
|
out[k] = raw[k]
|
||||||
|
if 'metodo' in raw:
|
||||||
|
metodo = str(raw['metodo'] or '').strip()
|
||||||
|
if metodo not in COBRO_METODOS:
|
||||||
|
raise ValueError("El método debe ser 'enlace' o 'tarjeta_guardada'")
|
||||||
|
out['metodo'] = metodo
|
||||||
|
if 'dias_antes' in raw:
|
||||||
|
if isinstance(raw['dias_antes'], bool) or not isinstance(raw['dias_antes'], int):
|
||||||
|
raise ValueError('Los días antes del cobro deben ser un número entero')
|
||||||
|
if not (0 <= raw['dias_antes'] <= COBRO_DIAS_ANTES_MAX):
|
||||||
|
raise ValueError('Los días antes deben estar entre 0 y %d' % COBRO_DIAS_ANTES_MAX)
|
||||||
|
out['dias_antes'] = raw['dias_antes']
|
||||||
|
if 'dias_gracia' in raw:
|
||||||
|
if isinstance(raw['dias_gracia'], bool) or not isinstance(raw['dias_gracia'], int):
|
||||||
|
raise ValueError('Los días de gracia del cobro deben ser un número entero')
|
||||||
|
if not (0 <= raw['dias_gracia'] <= 365):
|
||||||
|
raise ValueError('Los días de gracia deben estar entre 0 y 365')
|
||||||
|
out['dias_gracia'] = raw['dias_gracia']
|
||||||
|
if 'reintentos_dias' in raw:
|
||||||
|
valor = raw['reintentos_dias']
|
||||||
|
if not isinstance(valor, list):
|
||||||
|
raise ValueError('Los días de reintento deben ser una lista')
|
||||||
|
dias = []
|
||||||
|
for v in valor:
|
||||||
|
if isinstance(v, bool) or not isinstance(v, int):
|
||||||
|
raise ValueError('Cada día de reintento debe ser un número entero')
|
||||||
|
if not (0 <= v <= COBRO_REINTENTO_MAX):
|
||||||
|
raise ValueError('Los días de reintento deben estar entre 0 y %d' % COBRO_REINTENTO_MAX)
|
||||||
|
if v not in dias:
|
||||||
|
dias.append(v)
|
||||||
|
out['reintentos_dias'] = sorted(dias)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def normalizar_membresias(raw):
|
def normalizar_membresias(raw):
|
||||||
"""Bloque `membresias` completo, con defaults. Tolerante: nunca lanza."""
|
"""Bloque `membresias` completo, con defaults. Tolerante: nunca lanza."""
|
||||||
out = dict(MEMBRESIAS_DEFAULT)
|
out = dict(MEMBRESIAS_DEFAULT)
|
||||||
out['dias_aviso_renovacion'] = list(MEMBRESIAS_DEFAULT['dias_aviso_renovacion'])
|
out['dias_aviso_renovacion'] = list(MEMBRESIAS_DEFAULT['dias_aviso_renovacion'])
|
||||||
|
out['cobro_automatico'] = normalizar_cobro_automatico(
|
||||||
|
raw.get('cobro_automatico') if isinstance(raw, dict) else None)
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
return out
|
return out
|
||||||
for k in ('activo', 'permitir_congelamiento', 'renovacion_automatica_default',
|
for k in ('activo', 'permitir_congelamiento', 'renovacion_automatica_default',
|
||||||
@@ -604,6 +803,10 @@ def validar_membresias(raw):
|
|||||||
if not (0 <= raw['dias_gracia'] <= 365):
|
if not (0 <= raw['dias_gracia'] <= 365):
|
||||||
raise ValueError('Los días de gracia deben estar entre 0 y 365')
|
raise ValueError('Los días de gracia deben estar entre 0 y 365')
|
||||||
out['dias_gracia'] = raw['dias_gracia']
|
out['dias_gracia'] = raw['dias_gracia']
|
||||||
|
if 'cobro_automatico' in raw:
|
||||||
|
sub = validar_cobro_automatico(raw['cobro_automatico'])
|
||||||
|
if sub is not None:
|
||||||
|
out['cobro_automatico'] = sub
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -611,6 +814,11 @@ def get_membresias(env):
|
|||||||
"""Bloque de membresías vigente (copia). Fuente única para modelos y API."""
|
"""Bloque de membresías vigente (copia). Fuente única para modelos y API."""
|
||||||
return normalizar_membresias(get_settings(env).get('membresias'))
|
return normalizar_membresias(get_settings(env).get('membresias'))
|
||||||
|
|
||||||
|
|
||||||
|
def get_cobro_automatico(env):
|
||||||
|
"""Sub-bloque `cobro_automatico` de membresías (copia). Apagado por defecto."""
|
||||||
|
return get_membresias(env)['cobro_automatico']
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Recursos físicos de la agenda (cabinas, salas, consultorios, equipos).
|
# Recursos físicos de la agenda (cabinas, salas, consultorios, equipos).
|
||||||
#
|
#
|
||||||
@@ -1283,7 +1491,7 @@ def get_settings(env, aplicar_licencia=True):
|
|||||||
'moneda_base': MONEDA_DEFAULT,
|
'moneda_base': MONEDA_DEFAULT,
|
||||||
'nota_ticket': '',
|
'nota_ticket': '',
|
||||||
'impuestos': dict(IMPUESTOS_DEFAULT),
|
'impuestos': dict(IMPUESTOS_DEFAULT),
|
||||||
'reservas_online': dict(RESERVAS_ONLINE_DEFAULT),
|
'reservas_online': normalizar_reservas_online(None),
|
||||||
'notificaciones': normalizar_notificaciones(None),
|
'notificaciones': normalizar_notificaciones(None),
|
||||||
'membresias': normalizar_membresias(None),
|
'membresias': normalizar_membresias(None),
|
||||||
'recursos': dict(RECURSOS_DEFAULT),
|
'recursos': dict(RECURSOS_DEFAULT),
|
||||||
@@ -1330,7 +1538,11 @@ def set_settings(env, vals):
|
|||||||
if 'reservas_online' in vals:
|
if 'reservas_online' in vals:
|
||||||
bloque = validar_reservas_online(vals['reservas_online'])
|
bloque = validar_reservas_online(vals['reservas_online'])
|
||||||
if bloque is not None:
|
if bloque is not None:
|
||||||
actual['reservas_online'] = {**actual['reservas_online'], **bloque}
|
anterior = actual['reservas_online']
|
||||||
|
nuevo = {**anterior, **bloque}
|
||||||
|
if isinstance(bloque.get('anticipo'), dict):
|
||||||
|
nuevo['anticipo'] = {**anterior.get('anticipo', {}), **bloque['anticipo']}
|
||||||
|
actual['reservas_online'] = nuevo
|
||||||
if 'notificaciones' in vals:
|
if 'notificaciones' in vals:
|
||||||
bloque = validar_notificaciones(vals['notificaciones'])
|
bloque = validar_notificaciones(vals['notificaciones'])
|
||||||
if bloque is not None:
|
if bloque is not None:
|
||||||
@@ -1338,7 +1550,12 @@ def set_settings(env, vals):
|
|||||||
if 'membresias' in vals:
|
if 'membresias' in vals:
|
||||||
bloque = validar_membresias(vals['membresias'])
|
bloque = validar_membresias(vals['membresias'])
|
||||||
if bloque is not None:
|
if bloque is not None:
|
||||||
actual['membresias'] = {**actual['membresias'], **bloque}
|
anterior = actual['membresias']
|
||||||
|
nuevo = {**anterior, **bloque}
|
||||||
|
if isinstance(bloque.get('cobro_automatico'), dict):
|
||||||
|
nuevo['cobro_automatico'] = {
|
||||||
|
**anterior.get('cobro_automatico', {}), **bloque['cobro_automatico']}
|
||||||
|
actual['membresias'] = nuevo
|
||||||
if 'recursos' in vals:
|
if 'recursos' in vals:
|
||||||
bloque = validar_recursos(vals['recursos'])
|
bloque = validar_recursos(vals['recursos'])
|
||||||
if bloque is not None:
|
if bloque is not None:
|
||||||
|
|||||||
@@ -202,6 +202,18 @@ class SkeenCita(models.Model):
|
|||||||
], string='Estado de Pago', default='not_paid')
|
], string='Estado de Pago', default='not_paid')
|
||||||
amount_paid = fields.Float(string='Monto Pagado', default=0.0)
|
amount_paid = fields.Float(string='Monto Pagado', default=0.0)
|
||||||
|
|
||||||
|
# Anticipo de una reserva en línea. 'ninguno' es el comportamiento de
|
||||||
|
# siempre (una cita normal no tiene anticipo). Con 'pendiente' la cita está
|
||||||
|
# RETENIDA esperando el pago; el enlace de cobro la confirma y la pasa a
|
||||||
|
# 'pagado'. El campo es puramente informativo/de UI: no cambia la agenda.
|
||||||
|
anticipo_estado = fields.Selection([
|
||||||
|
('ninguno', 'Sin anticipo'),
|
||||||
|
('pendiente', 'Esperando anticipo'),
|
||||||
|
('pagado', 'Anticipo pagado'),
|
||||||
|
('reembolsado', 'Anticipo reembolsado'),
|
||||||
|
], string='Anticipo', default='ninguno', tracking=True, index=True)
|
||||||
|
anticipo_monto = fields.Float(string='Monto del anticipo', default=0.0)
|
||||||
|
|
||||||
# WACRM
|
# WACRM
|
||||||
wacrm_conversation_id = fields.Char(string='WACRM Conversation ID')
|
wacrm_conversation_id = fields.Char(string='WACRM Conversation ID')
|
||||||
|
|
||||||
|
|||||||
4
odoo-addons/skeen_cobros/__init__.py
Normal file
4
odoo-addons/skeen_cobros/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
38
odoo-addons/skeen_cobros/__manifest__.py
Normal file
38
odoo-addons/skeen_cobros/__manifest__.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
{
|
||||||
|
'name': 'SKEEN Cobros (enlaces de pago)',
|
||||||
|
'version': '1.0.0',
|
||||||
|
'category': 'Healthcare',
|
||||||
|
'summary': 'Enlaces de cobro reutilizables: anticipos de reservas en línea y cobro automático de membresías',
|
||||||
|
'description': """
|
||||||
|
Modelo único de ENLACE DE COBRO (`skeen.cobro.enlace`) reutilizable por
|
||||||
|
anticipos de reservas en línea, cobro automático de membresías y ventas.
|
||||||
|
|
||||||
|
- Token HMAC firmado con caducidad y estado (pendiente/pagado/expirado/
|
||||||
|
cancelado).
|
||||||
|
- Proveedor: Stripe, MercadoPago o `simulado` cuando no hay credenciales
|
||||||
|
de pasarela (permite probar el flujo completo sin dinero real ni llaves).
|
||||||
|
- Endpoints públicos seguros para consultar y pagar el enlace, con límite
|
||||||
|
de peticiones y trampa antiautómatas.
|
||||||
|
- Webhooks de Stripe y MercadoPago que verifican la firma/secreto.
|
||||||
|
- Al confirmarse el pago se crea un `skeen.pago` real ligado a la cita y
|
||||||
|
a la venta, para que aparezca en cortes de caja y reportes.
|
||||||
|
|
||||||
|
Todo el módulo está APAGADO por defecto: los sub-bloques `anticipo` y
|
||||||
|
`cobro_automatico` nacen con `activo=false`, así que una instancia sin
|
||||||
|
configurar se comporta EXACTAMENTE como antes.
|
||||||
|
""",
|
||||||
|
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
|
||||||
|
'website': 'https://skeen.mx',
|
||||||
|
'depends': ['base', 'skeen_citas', 'skeen_pagos', 'skeen_ventas'],
|
||||||
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'data/cobro_sequence.xml',
|
||||||
|
'data/cobros_cron.xml',
|
||||||
|
'views/cobro_views.xml',
|
||||||
|
],
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
'auto_install': False,
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
}
|
||||||
3
odoo-addons/skeen_cobros/controllers/__init__.py
Normal file
3
odoo-addons/skeen_cobros/controllers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from . import public_cobro
|
||||||
356
odoo-addons/skeen_cobros/controllers/public_cobro.py
Normal file
356
odoo-addons/skeen_cobros/controllers/public_cobro.py
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Página pública de pago de un enlace de cobro (sin sesión).
|
||||||
|
|
||||||
|
Endpoints anónimos bajo `/skeen/frontend/v1/public/cobro`:
|
||||||
|
|
||||||
|
GET /cobro?token=... datos mínimos y no sensibles del enlace
|
||||||
|
POST /cobro/pagar confirma el pago (simulado o pasarela real)
|
||||||
|
POST /cobro/webhook/stripe notificación de Stripe (firma verificada)
|
||||||
|
POST /cobro/webhook/mercadopago notificación de MercadoPago (secreto/token)
|
||||||
|
|
||||||
|
Reglas de oro:
|
||||||
|
- El token es HMAC firmado con caducidad; sin token válido no se expone nada.
|
||||||
|
- El pago en modo `simulado` SOLO se permite si el ajuste correspondiente
|
||||||
|
(`reservas_online.anticipo.permitir_simulado` o
|
||||||
|
`membresias.cobro_automatico.permitir_simulado`) está encendido. Nunca se
|
||||||
|
simula un pago en producción con la configuración por defecto.
|
||||||
|
- Límite de peticiones por IP y trampa antiautómatas (honeypot) como en el
|
||||||
|
portal de reservas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request, Response
|
||||||
|
|
||||||
|
from odoo.addons.skeen_citas.clinic_settings import (
|
||||||
|
get_settings as get_clinic_settings,
|
||||||
|
get_anticipo,
|
||||||
|
get_cobro_automatico,
|
||||||
|
brand_dict,
|
||||||
|
)
|
||||||
|
from odoo.addons.skeen_citas.glosario import resolve as resolver_glosario
|
||||||
|
|
||||||
|
PREFIX = '/skeen/frontend/v1/public/cobro'
|
||||||
|
|
||||||
|
RATE_LIMITS = {'get': (60, 60), 'pagar': (10, 60), 'webhook': (120, 60)}
|
||||||
|
_RATE_LOCK = threading.Lock()
|
||||||
|
_RATE_HITS = {}
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
_ERROR_INTERNO = 'No se pudo procesar la solicitud. Intenta de nuevo.'
|
||||||
|
|
||||||
|
|
||||||
|
def _json(data, status=200):
|
||||||
|
return Response(
|
||||||
|
json.dumps(data, default=str),
|
||||||
|
status=status,
|
||||||
|
mimetype='application/json',
|
||||||
|
headers={
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Stripe-Signature',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip():
|
||||||
|
xff = request.httprequest.headers.get('X-Forwarded-For') or ''
|
||||||
|
if xff:
|
||||||
|
return xff.split(',')[0].strip()
|
||||||
|
return request.httprequest.remote_addr or 'desconocida'
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_limited(bucket):
|
||||||
|
limit, window = RATE_LIMITS.get(bucket, (30, 60))
|
||||||
|
key = (_client_ip(), bucket)
|
||||||
|
ahora = time.time()
|
||||||
|
with _RATE_LOCK:
|
||||||
|
hits = [t for t in _RATE_HITS.get(key, []) if ahora - t < window]
|
||||||
|
if len(hits) >= limit:
|
||||||
|
_RATE_HITS[key] = hits
|
||||||
|
return True
|
||||||
|
hits.append(ahora)
|
||||||
|
_RATE_HITS[key] = hits
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenPublicCobroController(http.Controller):
|
||||||
|
"""Página pública de pago de un enlace."""
|
||||||
|
|
||||||
|
def _service(self):
|
||||||
|
return request.env['skeen.cobro.service'].sudo()
|
||||||
|
|
||||||
|
def _settings(self):
|
||||||
|
return get_clinic_settings(request.env)
|
||||||
|
|
||||||
|
def _brand(self):
|
||||||
|
guardado = brand_dict(request.env)
|
||||||
|
out = {
|
||||||
|
'nombre': self._settings().get('nombre') or 'Clínica',
|
||||||
|
'logo_claro': '/brand/logo-blanco.svg',
|
||||||
|
'logo_oscuro': '/brand/logo-negro.svg',
|
||||||
|
'colores': {'primario': '', 'acento': ''},
|
||||||
|
}
|
||||||
|
if isinstance(guardado, dict):
|
||||||
|
if guardado.get('nombre'):
|
||||||
|
out['nombre'] = str(guardado['nombre'])
|
||||||
|
for k in ('logo_claro', 'logo_oscuro'):
|
||||||
|
if guardado.get(k):
|
||||||
|
out[k] = str(guardado[k])
|
||||||
|
colores = guardado.get('colores')
|
||||||
|
if isinstance(colores, dict):
|
||||||
|
for c in ('primario', 'acento'):
|
||||||
|
if colores.get(c):
|
||||||
|
out['colores'][c] = str(colores[c])
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _simulado_permitido(self, enlace):
|
||||||
|
try:
|
||||||
|
if enlace.origen == 'reserva':
|
||||||
|
return bool(get_anticipo(request.env).get('permitir_simulado'))
|
||||||
|
if enlace.origen == 'membresia':
|
||||||
|
return bool(get_cobro_automatico(request.env).get('permitir_simulado'))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _estado_label(enlace):
|
||||||
|
return dict(enlace._fields['estado'].selection).get(enlace.estado, enlace.estado)
|
||||||
|
|
||||||
|
def _enlace_de_token(self, token):
|
||||||
|
return self._service().verificar_token(token)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 1. Datos del enlace (mínimos, no sensibles)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(PREFIX, type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
def cobro_get(self, **kw):
|
||||||
|
if request.httprequest.method == 'OPTIONS':
|
||||||
|
return _json({}, 200)
|
||||||
|
if _rate_limited('get'):
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
|
||||||
|
try:
|
||||||
|
enlace = self._enlace_de_token(kw.get('token'))
|
||||||
|
if not enlace:
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Enlace no válido o expirado'}, 404)
|
||||||
|
settings = self._settings()
|
||||||
|
_pack, terms = resolver_glosario(request.env)
|
||||||
|
proveedor = self._service().proveedor_disponible()
|
||||||
|
puede_pagar = (enlace.estado == 'pendiente' and
|
||||||
|
(proveedor != 'simulado' or self._simulado_permitido(enlace)))
|
||||||
|
return _json({
|
||||||
|
'status': 'success',
|
||||||
|
'folio': enlace.name,
|
||||||
|
'concepto': enlace.concepto,
|
||||||
|
'monto': enlace.monto,
|
||||||
|
'moneda': enlace.currency_id.name or 'MXN',
|
||||||
|
'estado': enlace.estado,
|
||||||
|
'estado_label': self._estado_label(enlace),
|
||||||
|
'origen': enlace.origen,
|
||||||
|
'proveedor': enlace.proveedor,
|
||||||
|
'expira': enlace.token_expira,
|
||||||
|
'fecha_pago': enlace.fecha_pago,
|
||||||
|
'paciente': (enlace.partner_id.name or '').split(' ')[0] if enlace.partner_id else '',
|
||||||
|
'puede_pagar': bool(puede_pagar),
|
||||||
|
'simulado': enlace.proveedor == 'simulado',
|
||||||
|
'url_externa': enlace.url_externa or '',
|
||||||
|
'brand': self._brand(),
|
||||||
|
'terms': terms,
|
||||||
|
'zona_horaria': settings.get('zona_horaria') or 'America/Tijuana',
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.exception('Error consultando enlace de cobro: %s', exc)
|
||||||
|
return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 2. Pagar
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(PREFIX + '/pagar', type='http', auth='none',
|
||||||
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
def cobro_pagar(self, **kw):
|
||||||
|
if request.httprequest.method == 'OPTIONS':
|
||||||
|
return _json({}, 200)
|
||||||
|
if _rate_limited('pagar'):
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
|
||||||
|
try:
|
||||||
|
data = json.loads(request.httprequest.get_data(as_text=True) or '{}')
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
if str(data.get('hp') or '').strip():
|
||||||
|
return _json({'status': 'error', 'message': 'Solicitud rechazada'}, 400)
|
||||||
|
try:
|
||||||
|
enlace = self._enlace_de_token(data.get('token') or kw.get('token'))
|
||||||
|
if not enlace:
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Enlace no válido o expirado'}, 404)
|
||||||
|
if enlace.estado == 'pagado':
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Este enlace ya fue pagado.'}, 409)
|
||||||
|
if enlace.estado == 'expirado':
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Este enlace expiró.'}, 410)
|
||||||
|
if enlace.estado == 'cancelado':
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Este enlace fue cancelado.'}, 409)
|
||||||
|
|
||||||
|
proveedor = self._service().proveedor_disponible()
|
||||||
|
if proveedor != 'simulado':
|
||||||
|
# Camino real: se crea/recupera el checkout de la pasarela.
|
||||||
|
if not enlace.url_externa:
|
||||||
|
self._service().intentar_checkout(enlace)
|
||||||
|
if enlace.url_externa:
|
||||||
|
return _json({'status': 'success', 'requiere_pasarela': True,
|
||||||
|
'url': enlace.url_externa, 'proveedor': proveedor})
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'La pasarela no está disponible. Contacta a la clínica.'}, 502)
|
||||||
|
|
||||||
|
# Camino simulado (sin pasarela).
|
||||||
|
if not self._simulado_permitido(enlace):
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'El pago en línea no está disponible. '
|
||||||
|
'Contacta a la clínica.'}, 403)
|
||||||
|
pago = self._service().confirmar(
|
||||||
|
enlace, proveedor='simulado', referencia='SIMULADO-%s' % enlace.id,
|
||||||
|
response='Pago simulado desde la página pública', user='Portal en línea')
|
||||||
|
if not pago:
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'No se pudo confirmar el pago.'}, 409)
|
||||||
|
return _json({
|
||||||
|
'status': 'success',
|
||||||
|
'ok': True,
|
||||||
|
'estado': 'pagado',
|
||||||
|
'estado_label': self._estado_label(enlace),
|
||||||
|
'comprobante': {
|
||||||
|
'folio': enlace.name,
|
||||||
|
'concepto': enlace.concepto,
|
||||||
|
'monto': enlace.monto,
|
||||||
|
'moneda': enlace.currency_id.name or 'MXN',
|
||||||
|
'fecha': enlace.fecha_pago,
|
||||||
|
'proveedor': 'simulado',
|
||||||
|
'pago': pago.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
except Exception as exc:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
_logger.exception('Error pagando enlace de cobro: %s', exc)
|
||||||
|
return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 3. Webhooks (notificación de la pasarela)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route(PREFIX + '/webhook/stripe', type='http', auth='none',
|
||||||
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
def webhook_stripe(self, **kw):
|
||||||
|
if request.httprequest.method == 'OPTIONS':
|
||||||
|
return _json({}, 200)
|
||||||
|
secret = request.env['ir.config_parameter'].sudo().get_param(
|
||||||
|
'skeen.stripe_webhook_secret')
|
||||||
|
raw = request.httprequest.get_data() or b''
|
||||||
|
if not secret:
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Webhook de Stripe no configurado'}, 400)
|
||||||
|
firma = request.httprequest.headers.get('Stripe-Signature') or ''
|
||||||
|
if not self._stripe_firma_valida(raw, firma, secret):
|
||||||
|
return _json({'status': 'error', 'message': 'Firma inválida'}, 400)
|
||||||
|
try:
|
||||||
|
evento = json.loads(raw.decode('utf-8') or '{}')
|
||||||
|
except Exception:
|
||||||
|
return _json({'status': 'error', 'message': 'Cuerpo inválido'}, 400)
|
||||||
|
tipo = evento.get('type') or ''
|
||||||
|
obj = ((evento.get('data') or {}).get('object') or {})
|
||||||
|
if tipo in ('checkout.session.completed', 'payment_intent.succeeded'):
|
||||||
|
meta = obj.get('metadata') or {}
|
||||||
|
enlace_id = int(meta.get('enlace_id') or 0)
|
||||||
|
enlace = request.env['skeen.cobro.enlace'].sudo().browse(enlace_id)
|
||||||
|
if enlace.exists():
|
||||||
|
self._service().confirmar(
|
||||||
|
enlace, proveedor='stripe',
|
||||||
|
referencia=obj.get('id') or obj.get('payment_intent') or '',
|
||||||
|
response=json.dumps(obj)[:2000], user='Webhook Stripe')
|
||||||
|
return _json({'status': 'success'})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _stripe_firma_valida(raw, header, secret):
|
||||||
|
partes = {}
|
||||||
|
for item in (header or '').split(','):
|
||||||
|
if '=' in item:
|
||||||
|
k, v = item.split('=', 1)
|
||||||
|
partes.setdefault(k.strip(), v.strip())
|
||||||
|
t = partes.get('t')
|
||||||
|
v1 = partes.get('v1')
|
||||||
|
if not t or not v1:
|
||||||
|
return False
|
||||||
|
firmado = ('%s.%s' % (t, raw.decode('utf-8'))).encode('utf-8')
|
||||||
|
esperada = hmac.new(secret.encode('utf-8'), firmado, hashlib.sha256).hexdigest()
|
||||||
|
return hmac.compare_digest(esperada, v1)
|
||||||
|
|
||||||
|
@http.route(PREFIX + '/webhook/mercadopago', type='http', auth='none',
|
||||||
|
methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
def webhook_mercadopago(self, **kw):
|
||||||
|
if request.httprequest.method == 'OPTIONS':
|
||||||
|
return _json({}, 200)
|
||||||
|
ICP = request.env['ir.config_parameter'].sudo()
|
||||||
|
token = ICP.get_param('skeen.mercadopago_access_token')
|
||||||
|
secret = ICP.get_param('skeen.mercadopago_webhook_secret')
|
||||||
|
raw = request.httprequest.get_data() or b''
|
||||||
|
try:
|
||||||
|
cuerpo = json.loads(raw.decode('utf-8') or '{}')
|
||||||
|
except Exception:
|
||||||
|
cuerpo = {}
|
||||||
|
data_id = (cuerpo.get('data') or {}).get('id') or request.httprequest.args.get('data.id') or ''
|
||||||
|
tipo = cuerpo.get('type') or request.httprequest.args.get('type') or ''
|
||||||
|
if not token and not secret:
|
||||||
|
return _json({'status': 'error',
|
||||||
|
'message': 'Webhook de MercadoPago no configurado'}, 400)
|
||||||
|
# Verificación por secreto (x-signature) cuando está configurado.
|
||||||
|
if secret:
|
||||||
|
firma = request.httprequest.headers.get('x-signature') or ''
|
||||||
|
if not self._mp_firma_valida(request.httprequest.args.get('data.id') or '', firma, secret):
|
||||||
|
return _json({'status': 'error', 'message': 'Firma inválida'}, 400)
|
||||||
|
elif tipo != 'payment' or not data_id:
|
||||||
|
return _json({'status': 'error', 'message': 'Notificación inválida'}, 400)
|
||||||
|
# Con token, se confirma el pago real consultando la API de MercadoPago.
|
||||||
|
if tipo == 'payment' and data_id and token:
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
resp = requests.get('https://api.mercadopago.com/v1/payments/%s' % data_id,
|
||||||
|
headers={'Authorization': 'Bearer %s' % token}, timeout=30)
|
||||||
|
pago = resp.json() if resp.status_code == 200 else {}
|
||||||
|
estado = pago.get('status')
|
||||||
|
ext = (pago.get('metadata') or {}).get('enlace_id')
|
||||||
|
if estado == 'approved' and ext:
|
||||||
|
enlace = request.env['skeen.cobro.enlace'].sudo().browse(int(ext))
|
||||||
|
if enlace.exists():
|
||||||
|
self._service().confirmar(
|
||||||
|
enlace, proveedor='mercadopago', referencia=str(data_id),
|
||||||
|
response=json.dumps(pago)[:2000], user='Webhook MercadoPago')
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.warning('Webhook MercadoPago: %s', exc)
|
||||||
|
return _json({'status': 'error', 'message': 'No se pudo verificar'}, 502)
|
||||||
|
return _json({'status': 'success'})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mp_firma_valida(data_id, header, secret):
|
||||||
|
partes = {}
|
||||||
|
for item in (header or '').split(','):
|
||||||
|
if '=' in item:
|
||||||
|
k, v = item.split('=', 1)
|
||||||
|
partes[k.strip()] = v.strip()
|
||||||
|
ts = partes.get('ts')
|
||||||
|
v1 = partes.get('v1')
|
||||||
|
if not ts or not v1:
|
||||||
|
return False
|
||||||
|
manifiesto = 'id:%s;ts:%s;' % (data_id, ts)
|
||||||
|
esperada = hmac.new(secret.encode('utf-8'), manifiesto.encode('utf-8'),
|
||||||
|
hashlib.sha256).hexdigest()
|
||||||
|
return hmac.compare_digest(esperada, v1)
|
||||||
12
odoo-addons/skeen_cobros/data/cobro_sequence.xml
Normal file
12
odoo-addons/skeen_cobros/data/cobro_sequence.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<record id="seq_skeen_cobro_enlace" model="ir.sequence">
|
||||||
|
<field name="name">Enlaces de cobro SKEEN</field>
|
||||||
|
<field name="code">skeen.cobro.enlace</field>
|
||||||
|
<field name="prefix">COBRO-</field>
|
||||||
|
<field name="padding">5</field>
|
||||||
|
<field name="company_id" eval="False"/>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
20
odoo-addons/skeen_cobros/data/cobros_cron.xml
Normal file
20
odoo-addons/skeen_cobros/data/cobros_cron.xml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data noupdate="1">
|
||||||
|
<!-- Expiración de enlaces de cobro. Marca expirados los enlaces vencidos
|
||||||
|
y aplica su consecuencia: cancela la cita retenida (libera el
|
||||||
|
horario) o avisa al periodo de la membresía. Con los ajustes por
|
||||||
|
defecto (anticipo apagado / cobro_automatico apagado) no hay
|
||||||
|
enlaces pendientes y no hace nada. -->
|
||||||
|
<record id="cron_cobros_expirar" model="ir.cron">
|
||||||
|
<field name="name">SKEEN Cobros: expirar enlaces y liberar retenciones</field>
|
||||||
|
<field name="model_id" ref="model_skeen_cobro_service"/>
|
||||||
|
<field name="state">code</field>
|
||||||
|
<field name="code">model._cron_tick()</field>
|
||||||
|
<field name="interval_number">15</field>
|
||||||
|
<field name="interval_type">minutes</field>
|
||||||
|
<field name="numbercall">-1</field>
|
||||||
|
<field name="active">True</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
3
odoo-addons/skeen_cobros/models/__init__.py
Normal file
3
odoo-addons/skeen_cobros/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from . import cobro_enlace
|
||||||
624
odoo-addons/skeen_cobros/models/cobro_enlace.py
Normal file
624
odoo-addons/skeen_cobros/models/cobro_enlace.py
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Enlaces de cobro reutilizables (anticipos y membresías).
|
||||||
|
|
||||||
|
Un ENLACE DE COBRO es una promesa de pago con un token firmado y caducidad.
|
||||||
|
Lo usan dos flujos:
|
||||||
|
|
||||||
|
- `reserva`: anticipo de una reserva creada en el portal público. La cita
|
||||||
|
queda retenida (`anticipo_estado='pendiente'`) hasta que se paga; si el
|
||||||
|
enlace expira, el cron cancela la cita y libera el horario.
|
||||||
|
- `membresia`: cobro automático del periodo de una membresía. Al pagarse, el
|
||||||
|
periodo pasa a `pagado` y la membresía se renueva.
|
||||||
|
|
||||||
|
Regla de oro: NADA de esto se activa solo. El anticipo vive dentro de
|
||||||
|
`reservas_online.anticipo` y el cobro de membresías dentro de
|
||||||
|
`membresias.cobro_automatico`; ambos con `activo=false` por defecto.
|
||||||
|
|
||||||
|
Al confirmarse un pago se crea un `skeen.pago` REAL (state='completed', con
|
||||||
|
`payment_date`) ligado a la cita y a la venta, de modo que el corte de caja y
|
||||||
|
los reportes lo ven como cualquier otro cobro.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
from odoo.exceptions import ValidationError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ORIGENES = [
|
||||||
|
('reserva', 'Reserva en línea'),
|
||||||
|
('membresia', 'Membresía'),
|
||||||
|
('venta', 'Venta'),
|
||||||
|
]
|
||||||
|
ESTADOS = [
|
||||||
|
('pendiente', 'Pendiente'),
|
||||||
|
('pagado', 'Pagado'),
|
||||||
|
('expirado', 'Expirado'),
|
||||||
|
('cancelado', 'Cancelado'),
|
||||||
|
]
|
||||||
|
PROVEEDORES = [
|
||||||
|
('simulado', 'Simulado (sin pasarela)'),
|
||||||
|
('stripe', 'Stripe'),
|
||||||
|
('mercadopago', 'MercadoPago'),
|
||||||
|
]
|
||||||
|
|
||||||
|
SECRET_KEY = 'skeen.frontend.secret'
|
||||||
|
TOKEN_KIND = 'cobro'
|
||||||
|
TTL_DEFAULT_HORAS = 24
|
||||||
|
TTL_MAX_HORAS = 720 # 30 días
|
||||||
|
|
||||||
|
# Método de `skeen.pago` con el que se registra cada proveedor. El simulado se
|
||||||
|
# registra como tarjeta (es un cobro en línea ficticio) para el corte de caja.
|
||||||
|
METODO_PAGO = {
|
||||||
|
'stripe': 'stripe',
|
||||||
|
'mercadopago': 'mercadopago',
|
||||||
|
'simulado': 'stripe',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenCobroEnlace(models.Model):
|
||||||
|
_name = 'skeen.cobro.enlace'
|
||||||
|
_description = 'Enlace de cobro'
|
||||||
|
_order = 'create_date desc, id desc'
|
||||||
|
|
||||||
|
name = fields.Char(string='Folio', readonly=True, copy=False, index=True)
|
||||||
|
origen = fields.Selection(ORIGENES, string='Origen', required=True, index=True)
|
||||||
|
estado = fields.Selection(ESTADOS, string='Estado', default='pendiente',
|
||||||
|
required=True, index=True)
|
||||||
|
proveedor = fields.Selection(PROVEEDORES, string='Proveedor', default='simulado',
|
||||||
|
required=True)
|
||||||
|
|
||||||
|
# Documentos ligados
|
||||||
|
partner_id = fields.Many2one('res.partner', string='Paciente', index=True,
|
||||||
|
ondelete='set null')
|
||||||
|
cita_id = fields.Many2one('skeen.cita', string='Cita', index=True,
|
||||||
|
ondelete='set null')
|
||||||
|
venta_id = fields.Many2one('skeen.venta', string='Venta', index=True,
|
||||||
|
ondelete='set null')
|
||||||
|
pago_id = fields.Many2one('skeen.pago', string='Pago', ondelete='set null')
|
||||||
|
|
||||||
|
# Monto y concepto
|
||||||
|
concepto = fields.Char(string='Concepto', required=True)
|
||||||
|
monto = fields.Float(string='Monto', required=True, default=0.0)
|
||||||
|
currency_id = fields.Many2one(
|
||||||
|
'res.currency', string='Moneda',
|
||||||
|
default=lambda self: self.env.ref('base.MXN', raise_if_not_found=False))
|
||||||
|
|
||||||
|
# Token y vigencia
|
||||||
|
token = fields.Char(string='Token', index=True, copy=False)
|
||||||
|
token_expira = fields.Datetime(string='Vence el', index=True)
|
||||||
|
url = fields.Char(string='URL de pago')
|
||||||
|
url_externa = fields.Char(string='URL en la pasarela')
|
||||||
|
|
||||||
|
# Respuesta del proveedor
|
||||||
|
provider_reference = fields.Char(string='Referencia de la pasarela')
|
||||||
|
provider_response = fields.Text(string='Respuesta de la pasarela')
|
||||||
|
|
||||||
|
# Fechas
|
||||||
|
fecha_pago = fields.Datetime(string='Pagado el', readonly=True, copy=False)
|
||||||
|
enviado_at = fields.Datetime(string='Enviado el', copy=False)
|
||||||
|
ultimo_envio = fields.Datetime(string='Último aviso', copy=False)
|
||||||
|
recordatorios = fields.Integer(string='Avisos enviados', default=0)
|
||||||
|
notas = fields.Text(string='Notas')
|
||||||
|
|
||||||
|
log_ids = fields.One2many('skeen.cobro.enlace.log', 'enlace_id',
|
||||||
|
string='Bitácora')
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
('token_uniq', 'unique(token)', 'El token del enlace de cobro debe ser único.'),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _log(self, evento, detalle='', user=''):
|
||||||
|
"""Registra un evento en la bitácora. Nunca tumba la operación."""
|
||||||
|
try:
|
||||||
|
self.env['skeen.cobro.enlace.log'].sudo().create({
|
||||||
|
'enlace_id': self.id,
|
||||||
|
'evento': evento,
|
||||||
|
'detalle': detalle or '',
|
||||||
|
'user_name': user or '',
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
_logger.exception('No se pudo registrar la bitácora del enlace %s', self.id)
|
||||||
|
|
||||||
|
@api.model_create_multi
|
||||||
|
def create(self, vals_list):
|
||||||
|
for vals in vals_list:
|
||||||
|
if not vals.get('name'):
|
||||||
|
vals['name'] = self.env['ir.sequence'].next_by_code(
|
||||||
|
'skeen.cobro.enlace') or 'COBRO-001'
|
||||||
|
return super().create(vals_list)
|
||||||
|
|
||||||
|
def action_cancelar(self, motivo='', user=''):
|
||||||
|
"""Cancela el enlace pendiente y libera la retención asociada."""
|
||||||
|
svc = self.env['skeen.cobro.service']
|
||||||
|
for enlace in self:
|
||||||
|
if enlace.estado != 'pendiente':
|
||||||
|
continue
|
||||||
|
enlace.write({'estado': 'cancelado'})
|
||||||
|
enlace._log('cancelado', motivo or 'Cancelado manualmente', user=user)
|
||||||
|
try:
|
||||||
|
svc._on_expirado(enlace)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception('No se pudo liberar la retención del enlace %s', enlace.id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenCobroEnlaceLog(models.Model):
|
||||||
|
_name = 'skeen.cobro.enlace.log'
|
||||||
|
_description = 'Bitácora de enlace de cobro'
|
||||||
|
_order = 'create_date desc, id desc'
|
||||||
|
|
||||||
|
enlace_id = fields.Many2one('skeen.cobro.enlace', string='Enlace',
|
||||||
|
required=True, ondelete='cascade', index=True)
|
||||||
|
evento = fields.Char(string='Evento')
|
||||||
|
detalle = fields.Text(string='Detalle')
|
||||||
|
user_name = fields.Char(string='Usuario')
|
||||||
|
create_date = fields.Datetime(string='Fecha', readonly=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenCobroService(models.AbstractModel):
|
||||||
|
"""Servicio único de enlaces de cobro (crear, firmar, confirmar, expirar)."""
|
||||||
|
_name = 'skeen.cobro.service'
|
||||||
|
_description = 'Servicio de enlaces de cobro'
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Token firmado
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def _secreto(self):
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
secret = ICP.get_param(SECRET_KEY)
|
||||||
|
if not secret:
|
||||||
|
secret = secrets.token_urlsafe(48)
|
||||||
|
ICP.set_param(SECRET_KEY, secret)
|
||||||
|
return secret
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _b64(self, raw):
|
||||||
|
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _b64d(self, texto):
|
||||||
|
return base64.urlsafe_b64decode(texto + '=' * (-len(texto) % 4))
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def firmar_token(self, enlace):
|
||||||
|
payload = {'eid': enlace.id, 'kind': TOKEN_KIND,
|
||||||
|
'exp': int(time.time()) + int(
|
||||||
|
(enlace.token_expira - fields.Datetime.now()).total_seconds())}
|
||||||
|
body = self._b64(json.dumps(payload, separators=(',', ':')).encode('utf-8'))
|
||||||
|
sig = hmac.new(self._secreto().encode('utf-8'), body.encode('ascii'),
|
||||||
|
hashlib.sha256).hexdigest()
|
||||||
|
return '%s.%s' % (body, sig)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def verificar_token(self, token):
|
||||||
|
"""Devuelve el enlace del token o None si es inválido/expirado/manipulado."""
|
||||||
|
if not token or '.' not in str(token):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
body, sig = str(token).rsplit('.', 1)
|
||||||
|
esperada = hmac.new(self._secreto().encode('utf-8'), body.encode('ascii'),
|
||||||
|
hashlib.sha256).hexdigest()
|
||||||
|
if not hmac.compare_digest(sig, esperada):
|
||||||
|
return None
|
||||||
|
payload = json.loads(self._b64d(body).decode('utf-8'))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if payload.get('kind') != TOKEN_KIND:
|
||||||
|
return None
|
||||||
|
if int(payload.get('exp', 0)) < int(time.time()):
|
||||||
|
return None
|
||||||
|
enlace = self.env['skeen.cobro.enlace'].sudo().browse(int(payload.get('eid') or 0))
|
||||||
|
if not enlace.exists():
|
||||||
|
return None
|
||||||
|
# El token guardado debe coincidir (defensa en profundidad).
|
||||||
|
if enlace.token and not hmac.compare_digest(str(enlace.token), str(token)):
|
||||||
|
return None
|
||||||
|
return enlace
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Proveedor / URL
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def proveedor_disponible(self):
|
||||||
|
"""'stripe' | 'mercadopago' | 'simulado' según las credenciales ICP.
|
||||||
|
|
||||||
|
Sin llaves configuradas devuelve 'simulado': el enlace se puede probar
|
||||||
|
por el camino explícito de simulación (nunca cobra dinero real).
|
||||||
|
"""
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
if ICP.get_param('skeen.stripe_secret_key'):
|
||||||
|
return 'stripe'
|
||||||
|
if ICP.get_param('skeen.mercadopago_access_token'):
|
||||||
|
return 'mercadopago'
|
||||||
|
return 'simulado'
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def url_publica(self):
|
||||||
|
from odoo.addons.skeen_citas.clinic_settings import url_publica
|
||||||
|
base = url_publica(self.env, fallback='')
|
||||||
|
if not base:
|
||||||
|
base = self.env['ir.config_parameter'].sudo().get_param('web.base.url') or ''
|
||||||
|
return (base or '').rstrip('/')
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def url_pago_token(self, token):
|
||||||
|
return '%s/pagar/%s' % (self.url_publica(), token)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _currency(self, moneda):
|
||||||
|
codigo = (moneda or 'MXN').upper()
|
||||||
|
cur = self.env['res.currency'].sudo().search([('name', '=', codigo)], limit=1)
|
||||||
|
if cur:
|
||||||
|
return cur
|
||||||
|
return self.env.ref('base.MXN', raise_if_not_found=False) or self.env['res.currency'].sudo().search([], limit=1)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Crear
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def crear(self, origen, monto, concepto, ttl_horas=None, partner=None,
|
||||||
|
cita=None, moneda='MXN', notas=''):
|
||||||
|
"""Crea un enlace de cobro pendiente con su token y URL.
|
||||||
|
|
||||||
|
No intenta crear el checkout real (eso lo hace `intentar_checkout`),
|
||||||
|
para que la reserva no dependa de la pasarela.
|
||||||
|
"""
|
||||||
|
monto = round(float(monto or 0.0), 2)
|
||||||
|
horas = int(ttl_horas or TTL_DEFAULT_HORAS)
|
||||||
|
horas = min(max(horas, 1), TTL_MAX_HORAS)
|
||||||
|
partner = partner or (cita.partner_id if cita else None)
|
||||||
|
enlace = self.env['skeen.cobro.enlace'].sudo().create({
|
||||||
|
'origen': origen,
|
||||||
|
'estado': 'pendiente',
|
||||||
|
'proveedor': self.proveedor_disponible(),
|
||||||
|
'partner_id': partner.id if partner else False,
|
||||||
|
'cita_id': cita.id if cita else False,
|
||||||
|
'concepto': (concepto or '')[:200],
|
||||||
|
'monto': monto,
|
||||||
|
'currency_id': self._currency(moneda).id,
|
||||||
|
'notas': notas or '',
|
||||||
|
'token_expira': fields.Datetime.now() + timedelta(hours=horas),
|
||||||
|
})
|
||||||
|
token = self.firmar_token(enlace)
|
||||||
|
enlace.write({'token': token, 'url': self.url_pago_token(token)})
|
||||||
|
enlace._log('creado', 'Enlace creado por %s (%s %s)' % (
|
||||||
|
origen, enlace.currency_id.name or '', monto))
|
||||||
|
return enlace
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def intentar_checkout(self, enlace):
|
||||||
|
"""Crea la sesión en la pasarela real. Sin credenciales no hace nada.
|
||||||
|
|
||||||
|
Devuelve True si quedó una URL externa usable. Nunca lanza hacia el
|
||||||
|
llamador (la reserva/cobro no debe caerse por la pasarela).
|
||||||
|
"""
|
||||||
|
enlace = enlace.exists()
|
||||||
|
if not enlace or enlace.proveedor == 'simulado':
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
if enlace.proveedor == 'stripe':
|
||||||
|
return self._checkout_stripe(enlace)
|
||||||
|
if enlace.proveedor == 'mercadopago':
|
||||||
|
return self._checkout_mercadopago(enlace)
|
||||||
|
except Exception as exc:
|
||||||
|
_logger.warning('No se pudo crear el checkout %s: %s', enlace.proveedor, exc)
|
||||||
|
enlace.write({'provider_response': str(exc)[:2000]})
|
||||||
|
return False
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _checkout_stripe(self, enlace):
|
||||||
|
import requests
|
||||||
|
key = self.env['ir.config_parameter'].sudo().get_param('skeen.stripe_secret_key')
|
||||||
|
if not key:
|
||||||
|
return False
|
||||||
|
base = self.url_publica()
|
||||||
|
data = {
|
||||||
|
'mode': 'payment',
|
||||||
|
'payment_method_types[]': 'card',
|
||||||
|
'line_items[0][price_data][currency]': (enlace.currency_id.name or 'MXN').lower(),
|
||||||
|
'line_items[0][price_data][product_data][name]': enlace.concepto or 'Cobro',
|
||||||
|
'line_items[0][price_data][unit_amount]': int(round(enlace.monto * 100)),
|
||||||
|
'line_items[0][quantity]': 1,
|
||||||
|
'success_url': '%s/pagar/%s?estado=pagado' % (base, enlace.token),
|
||||||
|
'cancel_url': '%s/pagar/%s?estado=cancelado' % (base, enlace.token),
|
||||||
|
'metadata[enlace_id]': str(enlace.id),
|
||||||
|
}
|
||||||
|
resp = requests.post('https://api.stripe.com/v1/checkout/sessions',
|
||||||
|
headers={'Authorization': 'Bearer %s' % key}, data=data, timeout=30)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
enlace.write({'provider_response': resp.text[:2000]})
|
||||||
|
return False
|
||||||
|
sesion = resp.json()
|
||||||
|
enlace.write({'url_externa': sesion.get('url'), 'provider_reference': sesion.get('id'),
|
||||||
|
'provider_response': json.dumps(sesion)[:2000]})
|
||||||
|
return bool(sesion.get('url'))
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _checkout_mercadopago(self, enlace):
|
||||||
|
import requests
|
||||||
|
token = self.env['ir.config_parameter'].sudo().get_param('skeen.mercadopago_access_token')
|
||||||
|
if not token:
|
||||||
|
return False
|
||||||
|
base = self.url_publica()
|
||||||
|
data = {
|
||||||
|
'items': [{'title': enlace.concepto or 'Cobro', 'quantity': 1,
|
||||||
|
'unit_price': float(enlace.monto)}],
|
||||||
|
'back_urls': {'success': '%s/pagar/%s?estado=pagado' % (base, enlace.token),
|
||||||
|
'pending': '%s/pagar/%s' % (base, enlace.token),
|
||||||
|
'failure': '%s/pagar/%s?estado=cancelado' % (base, enlace.token)},
|
||||||
|
'notification_url': '%s/skeen/frontend/v1/public/cobro/webhook/mercadopago' % base,
|
||||||
|
'external_reference': enlace.token,
|
||||||
|
'metadata': {'enlace_id': enlace.id},
|
||||||
|
}
|
||||||
|
resp = requests.post('https://api.mercadopago.com/checkout/preferences',
|
||||||
|
headers={'Authorization': 'Bearer %s' % token}, json=data, timeout=30)
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
enlace.write({'provider_response': resp.text[:2000]})
|
||||||
|
return False
|
||||||
|
pref = resp.json()
|
||||||
|
enlace.write({'url_externa': pref.get('init_point'), 'provider_reference': pref.get('id'),
|
||||||
|
'provider_response': json.dumps(pref)[:2000]})
|
||||||
|
return bool(pref.get('init_point'))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Confirmar pago
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def confirmar(self, enlace, proveedor=None, referencia='', monto=None,
|
||||||
|
response='', user='', force=False):
|
||||||
|
"""Marca el enlace como pagado y registra el pago real.
|
||||||
|
|
||||||
|
Idempotente: un enlace ya pagado NO se vuelve a cobrar (devuelve False).
|
||||||
|
Un enlace expirado/cancelado solo se puede forzar con `force=True`
|
||||||
|
(camino de la pantalla de personal, nunca del portal público).
|
||||||
|
"""
|
||||||
|
enlace = enlace.exists()
|
||||||
|
if not enlace:
|
||||||
|
return False
|
||||||
|
if enlace.estado == 'pagado':
|
||||||
|
return False
|
||||||
|
if enlace.estado in ('expirado', 'cancelado') and not force:
|
||||||
|
return False
|
||||||
|
monto = round(float(monto), 2) if monto not in (None, '') else enlace.monto
|
||||||
|
proveedor = proveedor or enlace.proveedor
|
||||||
|
pago = self._registrar_pago(enlace, proveedor, monto, referencia, response)
|
||||||
|
enlace.write({
|
||||||
|
'estado': 'pagado',
|
||||||
|
'fecha_pago': fields.Datetime.now(),
|
||||||
|
'pago_id': pago.id,
|
||||||
|
'proveedor': proveedor,
|
||||||
|
'provider_reference': referencia or enlace.provider_reference,
|
||||||
|
'provider_response': (response or enlace.provider_response or '')[:2000],
|
||||||
|
})
|
||||||
|
enlace._log('pagado', 'Pago %s por %s (%s)%s' % (
|
||||||
|
pago.name, monto, proveedor, ' · forzado' if force else ''), user=user)
|
||||||
|
try:
|
||||||
|
self._post_pago(enlace, pago)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception('No se pudo aplicar el post-pago del enlace %s', enlace.id)
|
||||||
|
return pago
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _registrar_pago(self, enlace, proveedor, monto, referencia, response):
|
||||||
|
"""Crea la venta (si falta) y el `skeen.pago` completado."""
|
||||||
|
venta = enlace.venta_id
|
||||||
|
if not venta:
|
||||||
|
venta = self._crear_venta(enlace)
|
||||||
|
metodo = METODO_PAGO.get(proveedor, 'stripe')
|
||||||
|
pago = self.env['skeen.pago'].sudo().create({
|
||||||
|
'partner_id': enlace.partner_id.id,
|
||||||
|
'cita_id': enlace.cita_id.id if enlace.cita_id else False,
|
||||||
|
'venta_id': venta.id if venta else False,
|
||||||
|
'amount': monto,
|
||||||
|
'currency_id': enlace.currency_id.id,
|
||||||
|
'payment_method': metodo,
|
||||||
|
'state': 'completed',
|
||||||
|
'payment_date': fields.Datetime.now(),
|
||||||
|
'provider_reference': referencia or False,
|
||||||
|
'provider_response': (response or '')[:2000] or False,
|
||||||
|
})
|
||||||
|
if venta:
|
||||||
|
pagado = (venta.amount_paid or 0.0) + monto
|
||||||
|
venta.write({
|
||||||
|
'amount_paid': pagado,
|
||||||
|
'state': 'paid' if pagado + 1e-6 >= venta.total else 'partial',
|
||||||
|
})
|
||||||
|
enlace.write({'venta_id': venta.id if venta else False})
|
||||||
|
return pago
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _crear_venta(self, enlace):
|
||||||
|
"""Venta del anticipo/concepto. Se liga a la cita cuando existe.
|
||||||
|
|
||||||
|
La línea de `skeen.venta.line` EXIGE un servicio o un artículo. Si el
|
||||||
|
enlace no tiene servicio asociado (caso raro de origen 'venta') no se
|
||||||
|
crea venta: el pago igual queda registrado en el corte de caja.
|
||||||
|
"""
|
||||||
|
cita = enlace.cita_id
|
||||||
|
servicio = cita.servicio_id if cita else False
|
||||||
|
if not servicio:
|
||||||
|
return self.env['skeen.venta']
|
||||||
|
vals = {
|
||||||
|
'partner_id': enlace.partner_id.id,
|
||||||
|
'date': fields.Date.today(),
|
||||||
|
'notes': _('Cobro en línea %s') % (enlace.name or ''),
|
||||||
|
'line_ids': [(0, 0, {
|
||||||
|
'service_id': servicio.id,
|
||||||
|
'description': enlace.concepto or 'Cobro en línea',
|
||||||
|
'quantity': 1.0,
|
||||||
|
'price_unit': enlace.monto,
|
||||||
|
})],
|
||||||
|
}
|
||||||
|
if cita:
|
||||||
|
vals['cita_id'] = cita.id
|
||||||
|
venta = self.env['skeen.venta'].sudo().create(vals)
|
||||||
|
venta.action_confirm()
|
||||||
|
return venta
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Post-pago por origen
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def _post_pago(self, enlace, pago):
|
||||||
|
if enlace.origen == 'reserva' and enlace.cita_id:
|
||||||
|
self._confirmar_reserva(enlace, pago)
|
||||||
|
elif enlace.origen == 'membresia':
|
||||||
|
self._confirmar_membresia(enlace, pago)
|
||||||
|
elif enlace.origen == 'venta' and enlace.venta_id:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _confirmar_reserva(self, enlace, pago):
|
||||||
|
"""Confirma la cita retenida y refleja el anticipo pagado."""
|
||||||
|
cita = enlace.cita_id
|
||||||
|
if not cita:
|
||||||
|
return
|
||||||
|
vals = {'anticipo_estado': 'pagado'}
|
||||||
|
if enlace.monto:
|
||||||
|
vals['amount_paid'] = (cita.amount_paid or 0.0) + enlace.monto
|
||||||
|
if cita.state == 'pending':
|
||||||
|
cita.write(vals)
|
||||||
|
cita.set_state('confirmed', user='Portal en línea',
|
||||||
|
note='Anticipo pagado (%s)' % (enlace.name or ''))
|
||||||
|
else:
|
||||||
|
cita.write(vals)
|
||||||
|
precio = cita.servicio_price or 0.0
|
||||||
|
if precio and cita.amount_paid + 1e-6 >= precio:
|
||||||
|
cita.write({'payment_state': 'paid'})
|
||||||
|
elif cita.payment_state != 'paid':
|
||||||
|
cita.write({'payment_state': 'partial'})
|
||||||
|
try:
|
||||||
|
cita.message_post(body='Anticipo pagado en línea: %s %s (enlace %s).' % (
|
||||||
|
enlace.currency_id.name or '', enlace.monto, enlace.name))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.notificar_confirmacion_reserva(cita)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def notificar_confirmacion_reserva(self, cita):
|
||||||
|
"""Encola la confirmación al pagarse el anticipo (soft-ref a WhatsApp)."""
|
||||||
|
from odoo.addons.skeen_citas.clinic_settings import get_notificaciones
|
||||||
|
cfg = get_notificaciones(self.env)
|
||||||
|
if not (cfg.get('activo') and cfg.get('confirmacion_al_crear')):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
service = self.env['skeen.notificacion.service'].sudo()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
creadas = service.encolar(cita, 'confirmacion')
|
||||||
|
for notif in creadas:
|
||||||
|
service._intentar(notif, cfg)
|
||||||
|
return bool(creadas)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception('No se pudo avisar la confirmación del anticipo')
|
||||||
|
return False
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def reembolsar_anticipo(self, cita):
|
||||||
|
"""Reembolsa el anticipo de una cita cancelada si la config lo permite.
|
||||||
|
|
||||||
|
Comportamiento elegido: si `reembolsable=True` se marca el `skeen.pago`
|
||||||
|
como 'refunded' y la cita como `anticipo_estado='reembolsado'` (NO se
|
||||||
|
revierten puntos de monedero, porque el anticipo no los generó). Si
|
||||||
|
`reembolsable=False` el anticipo se retiene y se deja bitácora.
|
||||||
|
"""
|
||||||
|
from odoo.addons.skeen_citas.clinic_settings import get_anticipo
|
||||||
|
cfg = get_anticipo(self.env)
|
||||||
|
if not cfg.get('activo'):
|
||||||
|
return False
|
||||||
|
enlaces = self.env['skeen.cobro.enlace'].sudo().search([
|
||||||
|
('cita_id', '=', cita.id), ('origen', '=', 'reserva'),
|
||||||
|
('estado', '=', 'pagado')])
|
||||||
|
hecho = False
|
||||||
|
for enlace in enlaces:
|
||||||
|
if not cfg.get('reembolsable', True):
|
||||||
|
enlace._log('reembolso_omitido', 'El anticipo no es reembolsable')
|
||||||
|
continue
|
||||||
|
if enlace.pago_id and enlace.pago_id.state != 'refunded':
|
||||||
|
enlace.pago_id.write({'state': 'refunded'})
|
||||||
|
enlace._log('reembolsado', 'Anticipo reembolsado por cancelación')
|
||||||
|
cita.write({'anticipo_estado': 'reembolsado'})
|
||||||
|
hecho = True
|
||||||
|
return hecho
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def cancelar_enlaces_de_cita(self, cita, motivo=''):
|
||||||
|
"""Cancela los enlaces pendientes de una cita (al cancelarla)."""
|
||||||
|
enlaces = self.env['skeen.cobro.enlace'].sudo().search([
|
||||||
|
('cita_id', '=', cita.id), ('estado', '=', 'pendiente')])
|
||||||
|
for enlace in enlaces:
|
||||||
|
enlace.write({'estado': 'cancelado'})
|
||||||
|
enlace._log('cancelado', motivo or 'La cita se canceló')
|
||||||
|
return len(enlaces)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _confirmar_membresia(self, enlace, pago):
|
||||||
|
"""Delega en el periodo de la membresía (definido en skeen_membresias)."""
|
||||||
|
periodo_id = getattr(enlace, 'periodo_id', None)
|
||||||
|
if periodo_id and hasattr(periodo_id, '_cobro_confirmado'):
|
||||||
|
periodo_id._cobro_confirmado(enlace, pago)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Expiración (cron)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def _cron_tick(self):
|
||||||
|
return self.expirar_vencidos()
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def expirar_vencidos(self):
|
||||||
|
"""Marca expirados los enlaces vencidos y aplica su consecuencia."""
|
||||||
|
ahora = fields.Datetime.now()
|
||||||
|
enlaces = self.env['skeen.cobro.enlace'].sudo().search([
|
||||||
|
('estado', '=', 'pendiente'),
|
||||||
|
('token_expira', '!=', False),
|
||||||
|
('token_expira', '<', ahora),
|
||||||
|
])
|
||||||
|
total = 0
|
||||||
|
for enlace in enlaces:
|
||||||
|
enlace.write({'estado': 'expirado'})
|
||||||
|
enlace._log('expirado', 'Venció el %s' % enlace.token_expira)
|
||||||
|
try:
|
||||||
|
self._on_expirado(enlace)
|
||||||
|
except Exception:
|
||||||
|
_logger.exception('No se pudo aplicar la expiración del enlace %s', enlace.id)
|
||||||
|
total += 1
|
||||||
|
return total
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _on_expirado(self, enlace):
|
||||||
|
if enlace.origen == 'reserva' and enlace.cita_id:
|
||||||
|
self._cancelar_reserva(enlace)
|
||||||
|
elif enlace.origen == 'membresia':
|
||||||
|
periodo_id = getattr(enlace, 'periodo_id', None)
|
||||||
|
if periodo_id and hasattr(periodo_id, '_cobro_expirado'):
|
||||||
|
periodo_id._cobro_expirado(enlace)
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cancelar_reserva(self, enlace):
|
||||||
|
"""Cancela la cita retenida y libera el horario."""
|
||||||
|
cita = enlace.cita_id
|
||||||
|
if not cita or cita.anticipo_estado != 'pendiente':
|
||||||
|
return False
|
||||||
|
if cita.state not in ('pending', 'confirmed'):
|
||||||
|
return False
|
||||||
|
cita.write({'cancellation_reason': 'Anticipo no pagado'})
|
||||||
|
cita.set_state('cancelled', user='Sistema',
|
||||||
|
note='Anticipo no pagado en %s' % (enlace.name or ''))
|
||||||
|
cita.write({'anticipo_estado': 'ninguno'})
|
||||||
|
try:
|
||||||
|
cita.message_post(body='Reserva cancelada automáticamente: no se pagó el anticipo (%s).' % (enlace.name or ''))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
3
odoo-addons/skeen_cobros/security/ir.model.access.csv
Normal file
3
odoo-addons/skeen_cobros/security/ir.model.access.csv
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_skeen_cobro_enlace_user,Acceso a enlaces de cobro,model_skeen_cobro_enlace,base.group_user,1,1,1,1
|
||||||
|
access_skeen_cobro_enlace_log_user,Acceso a bitácora de cobros,model_skeen_cobro_enlace_log,base.group_user,1,1,1,1
|
||||||
|
77
odoo-addons/skeen_cobros/views/cobro_views.xml
Normal file
77
odoo-addons/skeen_cobros/views/cobro_views.xml
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_skeen_cobro_enlace_tree" model="ir.ui.view">
|
||||||
|
<field name="name">skeen.cobro.enlace.tree</field>
|
||||||
|
<field name="model">skeen.cobro.enlace</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree string="Enlaces de cobro" create="false">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="origen"/>
|
||||||
|
<field name="partner_id"/>
|
||||||
|
<field name="concepto"/>
|
||||||
|
<field name="monto"/>
|
||||||
|
<field name="currency_id"/>
|
||||||
|
<field name="proveedor"/>
|
||||||
|
<field name="estado"/>
|
||||||
|
<field name="token_expira"/>
|
||||||
|
<field name="fecha_pago"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_skeen_cobro_enlace_form" model="ir.ui.view">
|
||||||
|
<field name="name">skeen.cobro.enlace.form</field>
|
||||||
|
<field name="model">skeen.cobro.enlace</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Enlace de cobro" create="false">
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="origen"/>
|
||||||
|
<field name="estado"/>
|
||||||
|
<field name="proveedor"/>
|
||||||
|
<field name="partner_id"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="concepto"/>
|
||||||
|
<field name="monto"/>
|
||||||
|
<field name="currency_id"/>
|
||||||
|
<field name="token_expira"/>
|
||||||
|
<field name="fecha_pago"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="cita_id"/>
|
||||||
|
<field name="venta_id"/>
|
||||||
|
<field name="pago_id"/>
|
||||||
|
<field name="url"/>
|
||||||
|
<field name="provider_reference"/>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Bitácora">
|
||||||
|
<field name="log_ids">
|
||||||
|
<tree create="false" edit="false">
|
||||||
|
<field name="create_date"/>
|
||||||
|
<field name="evento"/>
|
||||||
|
<field name="detalle"/>
|
||||||
|
<field name="user_name"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
<page string="Respuesta de la pasarela">
|
||||||
|
<field name="provider_response"/>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
<field name="notas"/>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_skeen_cobro_enlace" model="ir.actions.act_window">
|
||||||
|
<field name="name">Enlaces de cobro</field>
|
||||||
|
<field name="res_model">skeen.cobro.enlace</field>
|
||||||
|
<field name="view_mode">tree,form</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
@@ -21,7 +21,8 @@
|
|||||||
""",
|
""",
|
||||||
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
|
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
|
||||||
'website': 'https://skeen.mx',
|
'website': 'https://skeen.mx',
|
||||||
'depends': ['base', 'mail', 'skeen_citas', 'skeen_pagos', 'skeen_ventas', 'skeen_whatsapp'],
|
'depends': ['base', 'mail', 'skeen_citas', 'skeen_pagos', 'skeen_ventas',
|
||||||
|
'skeen_whatsapp', 'skeen_cobros'],
|
||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'data/sequences.xml',
|
'data/sequences.xml',
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
from . import membresia
|
from . import membresia
|
||||||
from . import venta
|
from . import venta
|
||||||
|
from . import cobro
|
||||||
|
|||||||
250
odoo-addons/skeen_membresias/models/cobro.py
Normal file
250
odoo-addons/skeen_membresias/models/cobro.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Cobro automático de membresías con enlace de pago (bloque `cobro_automatico`).
|
||||||
|
|
||||||
|
Añade al enlace de cobro su liga con el periodo de membresía y el motor que,
|
||||||
|
desde el cron diario, genera el enlace del periodo, avisa por el motor de
|
||||||
|
notificaciones (con reintentos) y renueva la membresía al pagarse.
|
||||||
|
|
||||||
|
Con `membresias.cobro_automatico.activo=False` (default) NADA cambia: el cron
|
||||||
|
sigue generando solo el adeudo, sin enlaces ni avisos.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
from odoo import api, fields, models, _
|
||||||
|
|
||||||
|
from odoo.addons.skeen_citas.clinic_settings import (
|
||||||
|
get_cobro_automatico,
|
||||||
|
get_membresias,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .membresia import _delta_periodo
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenCobroEnlace(models.Model):
|
||||||
|
_inherit = 'skeen.cobro.enlace'
|
||||||
|
|
||||||
|
periodo_id = fields.Many2one('skeen.membresia.periodo', string='Periodo de membresía',
|
||||||
|
index=True, ondelete='set null')
|
||||||
|
membresia_id = fields.Many2one(related='periodo_id.membresia_id',
|
||||||
|
string='Membresía', store=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenMembresiaPeriodo(models.Model):
|
||||||
|
_inherit = 'skeen.membresia.periodo'
|
||||||
|
|
||||||
|
def _cobro_confirmado(self, enlace, pago):
|
||||||
|
"""Marca el periodo pagado y renueva la membresía (avanza el ciclo)."""
|
||||||
|
self.ensure_one()
|
||||||
|
vals = {'estado': 'pagado'}
|
||||||
|
if pago and pago.venta_id:
|
||||||
|
vals['venta_id'] = pago.venta_id.id
|
||||||
|
self.write(vals)
|
||||||
|
m = self.membresia_id
|
||||||
|
if self.tipo == 'periodo' and m.proximo_cobro == self.periodo_inicio:
|
||||||
|
m.write({
|
||||||
|
'estado': 'activa',
|
||||||
|
'periodo_actual_inicio': self.periodo_inicio,
|
||||||
|
'periodo_actual_fin': self.periodo_fin,
|
||||||
|
'proximo_cobro': self.periodo_fin + relativedelta(days=1),
|
||||||
|
'sesiones_usadas_periodo': 0,
|
||||||
|
})
|
||||||
|
m._post('Periodo pagado en línea (%s): %s a %s.' % (
|
||||||
|
enlace.name or '', self.periodo_inicio, self.periodo_fin))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _cobro_expirado(self, enlace):
|
||||||
|
"""El enlace venció sin pago: el periodo queda vencido."""
|
||||||
|
self.ensure_one()
|
||||||
|
if self.estado == 'pendiente':
|
||||||
|
self.write({'estado': 'vencido'})
|
||||||
|
self.membresia_id._post('Enlace de cobro vencido (%s) para el periodo %s a %s.' % (
|
||||||
|
enlace.name or '', self.periodo_inicio, self.periodo_fin))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class SkeenMembresiaService(models.AbstractModel):
|
||||||
|
_inherit = 'skeen.membresia.service'
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _cobro_automatico(self, cfg):
|
||||||
|
"""Motor del cobro automático. Devuelve cuántos enlaces creó/avisó."""
|
||||||
|
ca = cfg.get('cobro_automatico') or get_cobro_automatico(self.env)
|
||||||
|
if not ca.get('activo'):
|
||||||
|
return 0
|
||||||
|
if (ca.get('metodo') or 'enlace') == 'tarjeta_guardada':
|
||||||
|
return self._cobro_tarjeta_guardada(ca)
|
||||||
|
return self._cobro_por_enlace(ca)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Método 'enlace'
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def _cobro_por_enlace(self, ca):
|
||||||
|
hoy = fields.Date.today()
|
||||||
|
dias_antes = int(ca.get('dias_antes') or 3)
|
||||||
|
reintentos = [int(d) for d in (ca.get('reintentos_dias') or [])]
|
||||||
|
gracia = int(ca.get('dias_gracia') or 0)
|
||||||
|
Membresia = self.env['skeen.membresia'].sudo()
|
||||||
|
creados = 0
|
||||||
|
for m in Membresia.search([('estado', '=', 'activa')]):
|
||||||
|
if not m.proximo_cobro:
|
||||||
|
continue
|
||||||
|
faltan = (m.proximo_cobro - hoy).days
|
||||||
|
if faltan > dias_antes:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
creados += self._asegurar_enlace_periodo(m, ca)
|
||||||
|
except Exception:
|
||||||
|
# Una membresía sin servicio de cobro (u otro dato faltante) no
|
||||||
|
# debe detener el resto del proceso.
|
||||||
|
_logger.exception('Cobro automático: falló la membresía %s', m.id)
|
||||||
|
return creados
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _asegurar_enlace_periodo(self, m, ca):
|
||||||
|
"""Crea (si falta) el enlace del próximo periodo y aplica los avisos."""
|
||||||
|
hoy = fields.Date.today()
|
||||||
|
proximo = m.proximo_cobro
|
||||||
|
delta = _delta_periodo(m.plan_id.periodicidad)
|
||||||
|
fin = proximo + delta - relativedelta(days=1)
|
||||||
|
periodo = m._crear_periodo_pendiente(proximo, fin)
|
||||||
|
if not periodo or periodo.estado == 'pagado':
|
||||||
|
return 0
|
||||||
|
svc = self.env['skeen.cobro.service'].sudo()
|
||||||
|
enlace = self.env['skeen.cobro.enlace'].sudo().search([
|
||||||
|
('origen', '=', 'membresia'), ('periodo_id', '=', periodo.id),
|
||||||
|
('estado', '=', 'pendiente')], limit=1)
|
||||||
|
creado = 0
|
||||||
|
if not enlace:
|
||||||
|
anterior = self.env['skeen.cobro.enlace'].sudo().search([
|
||||||
|
('origen', '=', 'membresia'), ('periodo_id', '=', periodo.id),
|
||||||
|
('estado', '=', 'pagado')], limit=1)
|
||||||
|
if anterior:
|
||||||
|
return 0
|
||||||
|
reintentos = [int(d) for d in (ca.get('reintentos_dias') or [])]
|
||||||
|
ultimo = max(reintentos) if reintentos else 0
|
||||||
|
ttl_horas = max(24, ((proximo + timedelta(days=ultimo + int(ca.get('dias_gracia') or 0) + 2)
|
||||||
|
- hoy).days or 1) * 24)
|
||||||
|
enlace = svc.crear(
|
||||||
|
'membresia', periodo.monto,
|
||||||
|
'Membresía %s (%s a %s)' % (m.plan_id.name, proximo, fin),
|
||||||
|
ttl_horas=ttl_horas, partner=m.partner_id, moneda=periodo.moneda)
|
||||||
|
enlace.write({
|
||||||
|
'periodo_id': periodo.id,
|
||||||
|
'venta_id': periodo.venta_id.id if periodo.venta_id else False,
|
||||||
|
})
|
||||||
|
svc.intentar_checkout(enlace)
|
||||||
|
creado = 1
|
||||||
|
self._avisar_enlace(m, enlace, ca, periodo)
|
||||||
|
return creado
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _hitos_aviso(self, proximo_cobro, ca):
|
||||||
|
"""Fechas en que toca avisar: inicial (dias_antes) + reintentos."""
|
||||||
|
dias_antes = int(ca.get('dias_antes') or 3)
|
||||||
|
hitos = [proximo_cobro - timedelta(days=dias_antes)]
|
||||||
|
for d in (ca.get('reintentos_dias') or []):
|
||||||
|
hitos.append(proximo_cobro + timedelta(days=int(d)))
|
||||||
|
return hitos
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _avisar_enlace(self, m, enlace, ca, periodo):
|
||||||
|
"""Encola el aviso que toque (inicial o reintento) sin duplicar."""
|
||||||
|
hoy = fields.Date.today()
|
||||||
|
hitos = self._hitos_aviso(m.proximo_cobro, ca)
|
||||||
|
objetivo = None
|
||||||
|
for i, fecha in enumerate(hitos):
|
||||||
|
if hoy >= fecha and (enlace.recordatorios or 0) <= i:
|
||||||
|
objetivo = i
|
||||||
|
break
|
||||||
|
if objetivo is None:
|
||||||
|
return 0
|
||||||
|
self._encolar_aviso_cobro(m, enlace, periodo, objetivo)
|
||||||
|
enlace.write({
|
||||||
|
'recordatorios': objetivo + 1,
|
||||||
|
'ultimo_envio': fields.Datetime.now(),
|
||||||
|
'enviado_at': enlace.enviado_at or fields.Datetime.now(),
|
||||||
|
})
|
||||||
|
# Escalada cuando ya se agotaron los avisos.
|
||||||
|
if (enlace.recordatorios or 0) >= len(hitos):
|
||||||
|
self._escalar_sin_pago(m, enlace, periodo, ca)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _encolar_aviso_cobro(self, m, enlace, periodo, hito):
|
||||||
|
try:
|
||||||
|
service = self.env['skeen.notificacion.service'].sudo()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
clave = 'cobro_mem:%s:%s' % (periodo.id, hito)
|
||||||
|
try:
|
||||||
|
service.encolar(
|
||||||
|
partner=m.partner_id, tipo='cobro_membresia',
|
||||||
|
cuando=fields.Datetime.now(), ventana=str(hito),
|
||||||
|
clave_unica=clave,
|
||||||
|
contexto_extra={
|
||||||
|
'enlace': enlace.url or '',
|
||||||
|
'monto': '%s %s' % (enlace.currency_id.name or '',
|
||||||
|
format(enlace.monto, ',.2f')),
|
||||||
|
'plan': m.plan_id.name,
|
||||||
|
'periodo': '%s a %s' % (periodo.periodo_inicio, periodo.periodo_fin),
|
||||||
|
'membresia': m.folio or '',
|
||||||
|
'fecha': (m.proximo_cobro.strftime('%d/%m/%Y')
|
||||||
|
if m.proximo_cobro else ''),
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
_logger.exception('No se pudo encolar el aviso de cobro de membresía')
|
||||||
|
return False
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def _escalar_sin_pago(self, m, enlace, periodo, ca):
|
||||||
|
"""Comportamiento al agotar los reintentos.
|
||||||
|
|
||||||
|
Si `cancelar_si_no_paga` está encendido, al pasar los reintentos + días
|
||||||
|
de gracia del cobro se marca el periodo vencido y la membresía vencida de
|
||||||
|
inmediato. Si está apagado, el periodo queda pendiente y es el proceso
|
||||||
|
normal de vencimiento (`_marcar_vencidas`, con `dias_gracia` de
|
||||||
|
membresías) el que la vence unos días después.
|
||||||
|
"""
|
||||||
|
if not ca.get('cancelar_si_no_paga'):
|
||||||
|
return False
|
||||||
|
hoy = fields.Date.today()
|
||||||
|
reintentos = [int(d) for d in (ca.get('reintentos_dias') or [])]
|
||||||
|
ultimo = max(reintentos) if reintentos else 0
|
||||||
|
limite = m.proximo_cobro + timedelta(days=ultimo + int(ca.get('dias_gracia') or 0))
|
||||||
|
if hoy <= limite:
|
||||||
|
return False
|
||||||
|
if periodo.estado == 'pendiente':
|
||||||
|
periodo.write({'estado': 'vencido'})
|
||||||
|
if m.estado == 'activa':
|
||||||
|
m.write({'estado': 'vencida'})
|
||||||
|
m._post('Membresía vencida por falta de pago del periodo (cobro automático).')
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Método 'tarjeta_guardada' (requiere Stripe real)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@api.model
|
||||||
|
def _cobro_tarjeta_guardada(self, ca):
|
||||||
|
"""Cargo sin presencia del cliente con tarjeta guardada (Stripe).
|
||||||
|
|
||||||
|
Camino técnico dejado INERTE y documentado: sin `skeen.stripe_secret_key`
|
||||||
|
y sin un método de pago guardado del cliente no hay nada que cobrar. No
|
||||||
|
se simula: si no hay llaves, no se hace nada (ni cargos ni avisos).
|
||||||
|
"""
|
||||||
|
ICP = self.env['ir.config_parameter'].sudo()
|
||||||
|
if not ICP.get_param('skeen.stripe_secret_key'):
|
||||||
|
_logger.info('Cobro automático con tarjeta_guardada: sin Stripe configurado, inerte.')
|
||||||
|
return 0
|
||||||
|
# A partir de aquí requeriría tarjetas guardadas por cliente (Stripe
|
||||||
|
# Customer + PaymentMethod) que hoy no se persisten. Se deja el punto de
|
||||||
|
# extensión explícito y no se marca nada como cobrado.
|
||||||
|
_logger.info('Cobro automático con tarjeta_guardada: no hay tarjetas guardadas; inerte.')
|
||||||
|
return 0
|
||||||
@@ -688,7 +688,8 @@ class SkeenMembresiaService(models.AbstractModel):
|
|||||||
@api.model
|
@api.model
|
||||||
def _cron_tick(self):
|
def _cron_tick(self):
|
||||||
"""Corre la lógica diaria. Con `membresias.activo=False` no hace nada."""
|
"""Corre la lógica diaria. Con `membresias.activo=False` no hace nada."""
|
||||||
resultado = {'vencidas': 0, 'avisos': 0, 'cargos': 0, 'descongeladas': 0}
|
resultado = {'vencidas': 0, 'avisos': 0, 'cargos': 0, 'descongeladas': 0,
|
||||||
|
'cobros': 0}
|
||||||
cfg = self._cfg()
|
cfg = self._cfg()
|
||||||
if not cfg.get('activo'):
|
if not cfg.get('activo'):
|
||||||
return resultado
|
return resultado
|
||||||
@@ -696,6 +697,7 @@ class SkeenMembresiaService(models.AbstractModel):
|
|||||||
resultado['vencidas'] = self._marcar_vencidas(cfg)
|
resultado['vencidas'] = self._marcar_vencidas(cfg)
|
||||||
resultado['avisos'] = self._encolar_avisos(cfg)
|
resultado['avisos'] = self._encolar_avisos(cfg)
|
||||||
resultado['cargos'] = self._generar_cargos(cfg)
|
resultado['cargos'] = self._generar_cargos(cfg)
|
||||||
|
resultado['cobros'] = self._cobro_automatico(cfg)
|
||||||
if any(resultado.values()):
|
if any(resultado.values()):
|
||||||
_logger.info('SKEEN membresías: %s', resultado)
|
_logger.info('SKEEN membresías: %s', resultado)
|
||||||
return resultado
|
return resultado
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
""",
|
""",
|
||||||
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
|
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
|
||||||
'website': 'https://skeen.mx',
|
'website': 'https://skeen.mx',
|
||||||
'depends': ['base', 'skeen_citas', 'skeen_monedero', 'skeen_pagos', 'skeen_ventas'],
|
'depends': ['base', 'skeen_citas', 'skeen_monedero', 'skeen_pagos', 'skeen_ventas',
|
||||||
|
'skeen_cobros'],
|
||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'data/roles.xml',
|
'data/roles.xml',
|
||||||
|
|||||||
@@ -1921,6 +1921,11 @@ class SkeenFrontendController(http.Controller):
|
|||||||
'payment_state': cita.payment_state,
|
'payment_state': cita.payment_state,
|
||||||
'price': cita.servicio_price,
|
'price': cita.servicio_price,
|
||||||
'amount_paid': cita.amount_paid,
|
'amount_paid': cita.amount_paid,
|
||||||
|
# Anticipo de reserva en línea (distinguir "esperando anticipo").
|
||||||
|
'anticipo_estado': cita.anticipo_estado or 'ninguno',
|
||||||
|
'anticipo_estado_label': dict(cita._fields['anticipo_estado'].selection).get(
|
||||||
|
cita.anticipo_estado, cita.anticipo_estado),
|
||||||
|
'anticipo_monto': cita.anticipo_monto or 0.0,
|
||||||
# Duración efectiva (propia de la cita o la del servicio)
|
# Duración efectiva (propia de la cita o la del servicio)
|
||||||
'duration': cita._duracion(),
|
'duration': cita._duracion(),
|
||||||
'duration_override': cita.duration or 0,
|
'duration_override': cita.duration or 0,
|
||||||
@@ -5423,6 +5428,146 @@ class SkeenFrontendController(http.Controller):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return json_response({'status': 'error', 'message': str(e)}, 500)
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Cobros y enlaces de pago
|
||||||
|
# ============================================================
|
||||||
|
def _cobro_to_dict(self, e):
|
||||||
|
return {
|
||||||
|
'id': e.id,
|
||||||
|
'nombre': e.name,
|
||||||
|
'origen': e.origen,
|
||||||
|
'origen_label': dict(e._fields['origen'].selection).get(e.origen, e.origen),
|
||||||
|
'estado': e.estado,
|
||||||
|
'estado_label': dict(e._fields['estado'].selection).get(e.estado, e.estado),
|
||||||
|
'proveedor': e.proveedor,
|
||||||
|
'partner_id': e.partner_id.id if e.partner_id else None,
|
||||||
|
'patient': e.partner_id.name if e.partner_id else '',
|
||||||
|
'cita_id': e.cita_id.id if e.cita_id else None,
|
||||||
|
'folio_cita': e.cita_id.name if e.cita_id else '',
|
||||||
|
'venta_id': e.venta_id.id if e.venta_id else None,
|
||||||
|
'concepto': e.concepto or '',
|
||||||
|
'monto': e.monto,
|
||||||
|
'moneda': e.currency_id.name or 'MXN',
|
||||||
|
'creado': e.create_date.strftime('%Y-%m-%d %H:%M') if e.create_date else None,
|
||||||
|
'expira': e.token_expira.strftime('%Y-%m-%d %H:%M') if e.token_expira else None,
|
||||||
|
'fecha_pago': e.fecha_pago.strftime('%Y-%m-%d %H:%M') if e.fecha_pago else None,
|
||||||
|
'recordatorios': e.recordatorios,
|
||||||
|
'url': e.url or '',
|
||||||
|
}
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/cobros', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('cobros.ver')
|
||||||
|
def list_cobros(self, **kw):
|
||||||
|
"""Listado de enlaces de cobro (pendientes, pagados, expirados)."""
|
||||||
|
try:
|
||||||
|
domain = []
|
||||||
|
if kw.get('estado'):
|
||||||
|
domain.append(('estado', '=', kw.get('estado')))
|
||||||
|
if kw.get('origen'):
|
||||||
|
domain.append(('origen', '=', kw.get('origen')))
|
||||||
|
enlaces = request.env['skeen.cobro.enlace'].sudo().search(
|
||||||
|
domain, order='create_date desc', limit=200)
|
||||||
|
return json_response({
|
||||||
|
'status': 'success',
|
||||||
|
'cobros': [self._cobro_to_dict(e) for e in enlaces],
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/cobros/<int:enlace_id>/forzar', type='http',
|
||||||
|
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('recepcion')
|
||||||
|
@require_perm('cobros.forzar')
|
||||||
|
@forbid_roles('marketing')
|
||||||
|
def forzar_cobro(self, enlace_id, **kw):
|
||||||
|
"""Registra el pago de un enlace sin pasar por la pasarela."""
|
||||||
|
try:
|
||||||
|
enlace = request.env['skeen.cobro.enlace'].sudo().browse(enlace_id)
|
||||||
|
if not enlace.exists():
|
||||||
|
return json_response({'status': 'error', 'message': 'Enlace no encontrado'}, 404)
|
||||||
|
if enlace.estado == 'pagado':
|
||||||
|
return json_response({'status': 'error', 'message': 'El enlace ya está pagado'}, 409)
|
||||||
|
user = current_user(request.env)
|
||||||
|
data = _parse_json_body()
|
||||||
|
pago = request.env['skeen.cobro.service'].sudo().confirmar(
|
||||||
|
enlace, proveedor=(data.get('proveedor') or enlace.proveedor or 'simulado'),
|
||||||
|
referencia=(data.get('referencia') or 'FORZADO'),
|
||||||
|
response='Forzado desde el panel', user=(user.login if user else 'staff'),
|
||||||
|
force=True)
|
||||||
|
if not pago:
|
||||||
|
return json_response({'status': 'error', 'message': 'No se pudo registrar el pago'}, 409)
|
||||||
|
self._auditar('cobros', 'cobro', str(enlace.id),
|
||||||
|
new_value='forzado', entity_label=enlace.concepto or enlace.name,
|
||||||
|
notes='Pago forzado')
|
||||||
|
return json_response({'status': 'success', 'cobro': self._cobro_to_dict(enlace),
|
||||||
|
'pago': pago.name})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/cobros/<int:enlace_id>/cancelar', type='http',
|
||||||
|
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('recepcion')
|
||||||
|
@require_perm('cobros.cancelar')
|
||||||
|
def cancelar_cobro(self, enlace_id, **kw):
|
||||||
|
"""Cancela un enlace pendiente y libera la retención (cita/periodo)."""
|
||||||
|
try:
|
||||||
|
enlace = request.env['skeen.cobro.enlace'].sudo().browse(enlace_id)
|
||||||
|
if not enlace.exists():
|
||||||
|
return json_response({'status': 'error', 'message': 'Enlace no encontrado'}, 404)
|
||||||
|
if enlace.estado != 'pendiente':
|
||||||
|
return json_response({'status': 'error',
|
||||||
|
'message': 'Solo se pueden cancelar enlaces pendientes'}, 409)
|
||||||
|
data = _parse_json_body()
|
||||||
|
user = current_user(request.env)
|
||||||
|
enlace.action_cancelar(motivo=(data.get('motivo') or 'Cancelado desde el panel'),
|
||||||
|
user=(user.login if user else 'staff'))
|
||||||
|
return json_response({'status': 'success', 'cobro': self._cobro_to_dict(enlace)})
|
||||||
|
except Exception as e:
|
||||||
|
request.env.cr.rollback()
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
|
@http.route('/skeen/frontend/v1/reports/cobros', type='http', auth='none',
|
||||||
|
methods=['GET', 'OPTIONS'], csrf=False)
|
||||||
|
@require_role('lectura')
|
||||||
|
@require_perm('reportes.ver')
|
||||||
|
def report_cobros(self, **kw):
|
||||||
|
"""Reporte de cobros pendientes y expirados (anticipos y membresías)."""
|
||||||
|
try:
|
||||||
|
start = kw.get('start')
|
||||||
|
end = kw.get('end')
|
||||||
|
domain = [('estado', 'in', ('pendiente', 'expirado', 'cancelado'))]
|
||||||
|
if start:
|
||||||
|
domain.append(('create_date', '>=', f'{start} 00:00:00'))
|
||||||
|
if end:
|
||||||
|
domain.append(('create_date', '<=', f'{end} 23:59:59'))
|
||||||
|
enlaces = request.env['skeen.cobro.enlace'].sudo().search(domain,
|
||||||
|
order='estado, token_expira')
|
||||||
|
ahora = fields.Datetime.now()
|
||||||
|
filas = []
|
||||||
|
resumen = {'pendiente': 0.0, 'expirado': 0.0, 'cancelado': 0.0}
|
||||||
|
for e in enlaces:
|
||||||
|
vencido = bool(e.estado == 'pendiente' and e.token_expira and e.token_expira < ahora)
|
||||||
|
filas.append({
|
||||||
|
**self._cobro_to_dict(e),
|
||||||
|
'vencido': vencido,
|
||||||
|
'dias_para_vencer': ((e.token_expira - ahora).days
|
||||||
|
if e.estado == 'pendiente' and e.token_expira else 0),
|
||||||
|
})
|
||||||
|
if e.estado in resumen:
|
||||||
|
resumen[e.estado] += e.monto
|
||||||
|
return json_response({
|
||||||
|
'status': 'success',
|
||||||
|
'start': start, 'end': end,
|
||||||
|
'cobros': filas,
|
||||||
|
'total': len(filas),
|
||||||
|
'resumen': resumen,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return json_response({'status': 'error', 'message': str(e)}, 500)
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Cortes de Caja
|
# Cortes de Caja
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -333,9 +333,24 @@ class SkeenPublicBookingController(http.Controller):
|
|||||||
'descripcion': cfg['descripcion'],
|
'descripcion': cfg['descripcion'],
|
||||||
'mensaje_confirmacion': cfg['mensaje_confirmacion'],
|
'mensaje_confirmacion': cfg['mensaje_confirmacion'],
|
||||||
'zona_horaria': settings.get('zona_horaria') or 'America/Tijuana',
|
'zona_horaria': settings.get('zona_horaria') or 'America/Tijuana',
|
||||||
|
'anticipo': self._anticipo_publico(cfg),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
def _anticipo_publico(self, cfg):
|
||||||
|
"""Sub-bloque de anticipo expuesto al portal (sin datos sensibles)."""
|
||||||
|
a = cfg.get('anticipo') or {}
|
||||||
|
tipo = a.get('tipo') or 'porcentaje'
|
||||||
|
return {
|
||||||
|
'activo': bool(a.get('activo')) and float(a.get('valor') or 0) > 0,
|
||||||
|
'tipo': tipo,
|
||||||
|
'valor': float(a.get('valor') or 0),
|
||||||
|
'moneda': a.get('moneda') or 'MXN',
|
||||||
|
'horas_para_pagar': int(a.get('horas_para_pagar') or 24),
|
||||||
|
'reembolsable': bool(a.get('reembolsable', True)),
|
||||||
|
'texto_aviso': a.get('texto_aviso') or '',
|
||||||
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 2. Disponibilidad de horarios
|
# 2. Disponibilidad de horarios
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -400,6 +415,21 @@ class SkeenPublicBookingController(http.Controller):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _error_interno(e)
|
return _error_interno(e)
|
||||||
|
|
||||||
|
def _monto_anticipo(self, anticipo, servicio):
|
||||||
|
"""Monto del anticipo en moneda de la clínica (0 si no aplica)."""
|
||||||
|
if not isinstance(anticipo, dict) or not anticipo.get('activo'):
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
valor = float(anticipo.get('valor') or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
if valor <= 0:
|
||||||
|
return 0.0
|
||||||
|
if (anticipo.get('tipo') or 'porcentaje') == 'porcentaje':
|
||||||
|
precio = float(getattr(servicio, 'price', 0) or 0)
|
||||||
|
return round(precio * valor / 100.0, 2)
|
||||||
|
return round(valor, 2)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 3. Crear la reserva
|
# 3. Crear la reserva
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -523,6 +553,13 @@ class SkeenPublicBookingController(http.Controller):
|
|||||||
creado_paciente = True
|
creado_paciente = True
|
||||||
|
|
||||||
state = 'pending' if cfg['requiere_confirmacion'] else 'confirmed'
|
state = 'pending' if cfg['requiere_confirmacion'] else 'confirmed'
|
||||||
|
# Anticipo: si está encendido y hay monto, la cita queda RETENIDA
|
||||||
|
# (state pending) esperando el pago, aunque no se exija confirmación.
|
||||||
|
anticipo = cfg.get('anticipo') or {}
|
||||||
|
monto_anticipo = self._monto_anticipo(anticipo, servicio)
|
||||||
|
con_anticipo = bool(anticipo.get('activo')) and monto_anticipo > 0
|
||||||
|
if con_anticipo:
|
||||||
|
state = 'pending'
|
||||||
nota = ORIGEN_MARCA
|
nota = ORIGEN_MARCA
|
||||||
if notas:
|
if notas:
|
||||||
nota = '%s\n%s' % (ORIGEN_MARCA, notas)
|
nota = '%s\n%s' % (ORIGEN_MARCA, notas)
|
||||||
@@ -537,6 +574,9 @@ class SkeenPublicBookingController(http.Controller):
|
|||||||
'notes': nota,
|
'notes': nota,
|
||||||
'booking_source': 'public_portal',
|
'booking_source': 'public_portal',
|
||||||
}
|
}
|
||||||
|
if con_anticipo:
|
||||||
|
cita_vals['anticipo_estado'] = 'pendiente'
|
||||||
|
cita_vals['anticipo_monto'] = monto_anticipo
|
||||||
if doctor_id:
|
if doctor_id:
|
||||||
cita_vals['doctor_id'] = doctor_id
|
cita_vals['doctor_id'] = doctor_id
|
||||||
|
|
||||||
@@ -559,10 +599,30 @@ class SkeenPublicBookingController(http.Controller):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Hook Fase 2.4: aviso de confirmación por WhatsApp/WACRM.
|
# Hook Fase 2.4: aviso de confirmación por WhatsApp/WACRM.
|
||||||
if cfg['requiere_confirmacion']:
|
# Con anticipo NO se confirma todavía: se avisa al pagarse.
|
||||||
|
enlace = None
|
||||||
|
if con_anticipo:
|
||||||
|
try:
|
||||||
|
svc = request.env['skeen.cobro.service'].sudo()
|
||||||
|
enlace = svc.crear(
|
||||||
|
'reserva', monto_anticipo,
|
||||||
|
'Anticipo: %s' % (servicio.name or ''),
|
||||||
|
ttl_horas=anticipo.get('horas_para_pagar') or 24,
|
||||||
|
partner=partner, cita=cita,
|
||||||
|
moneda=anticipo.get('moneda') or 'MXN')
|
||||||
|
svc.intentar_checkout(enlace)
|
||||||
|
cita.sudo().message_post(
|
||||||
|
subject='Anticipo solicitado',
|
||||||
|
body='Enlace %s por %s %s (vence %s).' % (
|
||||||
|
enlace.name, enlace.currency_id.name or '',
|
||||||
|
monto_anticipo, enlace.token_expira),
|
||||||
|
message_type='comment')
|
||||||
|
except Exception:
|
||||||
|
_logger.exception('No se pudo crear el enlace de anticipo')
|
||||||
|
elif cfg['requiere_confirmacion']:
|
||||||
self._encolar_confirmacion(cita)
|
self._encolar_confirmacion(cita)
|
||||||
|
|
||||||
return _json({
|
respuesta = {
|
||||||
'ok': True,
|
'ok': True,
|
||||||
'folio': cita.name,
|
'folio': cita.name,
|
||||||
'fecha': cita.date.strftime('%Y-%m-%d'),
|
'fecha': cita.date.strftime('%Y-%m-%d'),
|
||||||
@@ -574,7 +634,21 @@ class SkeenPublicBookingController(http.Controller):
|
|||||||
'requiere_confirmacion': cfg['requiere_confirmacion'],
|
'requiere_confirmacion': cfg['requiere_confirmacion'],
|
||||||
'mensaje_confirmacion': cfg['mensaje_confirmacion'],
|
'mensaje_confirmacion': cfg['mensaje_confirmacion'],
|
||||||
'token_de_gestion': self._sign_token(cita),
|
'token_de_gestion': self._sign_token(cita),
|
||||||
}, 201)
|
}
|
||||||
|
if con_anticipo and enlace:
|
||||||
|
respuesta['esperando_anticipo'] = True
|
||||||
|
respuesta['mensaje_anticipo'] = anticipo.get('texto_aviso') or ''
|
||||||
|
respuesta['anticipo'] = {
|
||||||
|
'monto': monto_anticipo,
|
||||||
|
'moneda': anticipo.get('moneda') or 'MXN',
|
||||||
|
'horas_para_pagar': int(anticipo.get('horas_para_pagar') or 24),
|
||||||
|
'reembolsable': bool(anticipo.get('reembolsable', True)),
|
||||||
|
'url': enlace.url,
|
||||||
|
'token': enlace.token,
|
||||||
|
'expira': enlace.token_expira,
|
||||||
|
'proveedor': enlace.proveedor,
|
||||||
|
}
|
||||||
|
return _json(respuesta, 201)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
request.env.cr.rollback()
|
request.env.cr.rollback()
|
||||||
return _error_interno(e)
|
return _error_interno(e)
|
||||||
@@ -669,6 +743,12 @@ class SkeenPublicBookingController(http.Controller):
|
|||||||
cita.write({'cancellation_reason': motivo or 'Cancelada por el cliente en el portal'})
|
cita.write({'cancellation_reason': motivo or 'Cancelada por el cliente en el portal'})
|
||||||
cita.set_state('cancelled', user='Portal en línea',
|
cita.set_state('cancelled', user='Portal en línea',
|
||||||
note=motivo or 'Cancelada en el portal público')
|
note=motivo or 'Cancelada en el portal público')
|
||||||
|
try:
|
||||||
|
svc = request.env['skeen.cobro.service'].sudo()
|
||||||
|
svc.reembolsar_anticipo(cita)
|
||||||
|
svc.cancelar_enlaces_de_cita(cita, 'Cita cancelada en el portal')
|
||||||
|
except Exception:
|
||||||
|
_logger.exception('No se pudo resolver el anticipo al cancelar la cita')
|
||||||
try:
|
try:
|
||||||
cita.sudo().message_post(
|
cita.sudo().message_post(
|
||||||
subject='Cancelación en línea',
|
subject='Cancelación en línea',
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ TIPOS = [
|
|||||||
('recuperacion', 'Recuperación'),
|
('recuperacion', 'Recuperación'),
|
||||||
('formulario', 'Formulario'),
|
('formulario', 'Formulario'),
|
||||||
('renovacion_membresia', 'Renovación de membresía'),
|
('renovacion_membresia', 'Renovación de membresía'),
|
||||||
|
('cobro_membresia', 'Cobro de membresía'),
|
||||||
('manual', 'Manual'),
|
('manual', 'Manual'),
|
||||||
]
|
]
|
||||||
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')]
|
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')]
|
||||||
@@ -70,6 +71,8 @@ PLANTILLA_POR_TIPO = {
|
|||||||
'formulario': 'plantilla_formulario',
|
'formulario': 'plantilla_formulario',
|
||||||
# Aviso de renovación/cobro de una membresía (bloque `membresias`).
|
# Aviso de renovación/cobro de una membresía (bloque `membresias`).
|
||||||
'renovacion_membresia': 'plantilla_renovacion_membresia',
|
'renovacion_membresia': 'plantilla_renovacion_membresia',
|
||||||
|
# Aviso con enlace de pago del periodo (cobro automático de membresías).
|
||||||
|
'cobro_membresia': 'plantilla_cobro_membresia',
|
||||||
'manual': 'plantilla_recordatorio',
|
'manual': 'plantilla_recordatorio',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,12 @@ PERMISSION_GROUPS = [
|
|||||||
('comisiones.editar', 'Administrar reglas y metas de comisiones'),
|
('comisiones.editar', 'Administrar reglas y metas de comisiones'),
|
||||||
('comisiones.liquidar', 'Generar, ajustar y pagar liquidaciones'),
|
('comisiones.liquidar', 'Generar, ajustar y pagar liquidaciones'),
|
||||||
]),
|
]),
|
||||||
|
('cobros', 'Cobros y enlaces de pago', [
|
||||||
|
('cobros.ver', 'Ver enlaces de cobro pendientes y pagados'),
|
||||||
|
('cobros.administrar', 'Administrar enlaces de cobro'),
|
||||||
|
('cobros.forzar', 'Forzar el cobro de un enlace'),
|
||||||
|
('cobros.cancelar', 'Cancelar enlaces y liberar retenciones'),
|
||||||
|
]),
|
||||||
('config', 'Usuarios y configuración', [
|
('config', 'Usuarios y configuración', [
|
||||||
('config.usuarios', 'Administrar usuarios'),
|
('config.usuarios', 'Administrar usuarios'),
|
||||||
('config.roles', 'Administrar roles y menús'),
|
('config.roles', 'Administrar roles y menús'),
|
||||||
@@ -158,6 +164,7 @@ READ_PERM_KEYS = (
|
|||||||
'compras.ver',
|
'compras.ver',
|
||||||
'proveedores.ver',
|
'proveedores.ver',
|
||||||
'comisiones.ver',
|
'comisiones.ver',
|
||||||
|
'cobros.ver',
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -273,6 +280,13 @@ DEFAULT_PERM_ROLES = {
|
|||||||
'comisiones.ver': ALL,
|
'comisiones.ver': ALL,
|
||||||
'comisiones.editar': AG,
|
'comisiones.editar': AG,
|
||||||
'comisiones.liquidar': AG,
|
'comisiones.liquidar': AG,
|
||||||
|
# Cobros y enlaces de pago. Ver es de todos; administrar (crear/cancelar) es
|
||||||
|
# de admin/gerencia; forzar el cobro (registrar un pago sin pasarela) lo
|
||||||
|
# hace recepción/gerencia/admin; cancelar retenciones, admin/gerencia.
|
||||||
|
'cobros.ver': ALL,
|
||||||
|
'cobros.administrar': AG,
|
||||||
|
'cobros.forzar': ('admin', 'recepcion', 'gerencia'),
|
||||||
|
'cobros.cancelar': AG,
|
||||||
# Usuarios y configuración
|
# Usuarios y configuración
|
||||||
'config.usuarios': AG,
|
'config.usuarios': AG,
|
||||||
'config.roles': AG,
|
'config.roles': AG,
|
||||||
|
|||||||
Reference in New Issue
Block a user