From 6a6c2a928b9597804221b1ca2556871e09c9fe6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Consultor=C3=ADa=20Alcaraz=20Salazar?= Date: Sun, 11 Oct 2026 23:54:47 +0000 Subject: [PATCH] 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 --- docs/producto/configuracion-cliente.md | 85 ++- frontend/src/App.tsx | 3 + frontend/src/components/MembresiasPanel.tsx | 65 +- .../src/components/ReservasOnlinePanel.tsx | 57 +- .../src/components/reportes/CobrosReport.tsx | 183 +++++ frontend/src/lib/clinic-settings.ts | 19 +- frontend/src/lib/i18n.tsx | 125 ++++ frontend/src/lib/permissions.ts | 4 + frontend/src/pages/Agenda.tsx | 10 +- frontend/src/pages/Configuracion.tsx | 13 +- frontend/src/pages/Pagar.tsx | 191 ++++++ frontend/src/pages/Reportes.tsx | 4 + frontend/src/pages/Reservar.tsx | 32 +- frontend/src/services/odoo.ts | 195 +++++- odoo-addons/skeen_citas/clinic_settings.py | 225 ++++++- odoo-addons/skeen_citas/models/cita.py | 12 + odoo-addons/skeen_cobros/__init__.py | 4 + odoo-addons/skeen_cobros/__manifest__.py | 38 ++ .../skeen_cobros/controllers/__init__.py | 3 + .../skeen_cobros/controllers/public_cobro.py | 356 ++++++++++ .../skeen_cobros/data/cobro_sequence.xml | 12 + odoo-addons/skeen_cobros/data/cobros_cron.xml | 20 + odoo-addons/skeen_cobros/models/__init__.py | 3 + .../skeen_cobros/models/cobro_enlace.py | 624 ++++++++++++++++++ .../skeen_cobros/security/ir.model.access.csv | 3 + .../skeen_cobros/views/cobro_views.xml | 77 +++ odoo-addons/skeen_membresias/__manifest__.py | 3 +- .../skeen_membresias/models/__init__.py | 2 + odoo-addons/skeen_membresias/models/cobro.py | 250 +++++++ .../skeen_membresias/models/membresia.py | 4 +- odoo-addons/skeen_whatsapp/__manifest__.py | 3 +- .../skeen_whatsapp/controllers/frontend.py | 145 ++++ .../controllers/public_booking.py | 86 ++- .../skeen_whatsapp/models/notificacion.py | 3 + odoo-addons/skeen_whatsapp/permissions.py | 14 + 35 files changed, 2841 insertions(+), 32 deletions(-) create mode 100644 frontend/src/components/reportes/CobrosReport.tsx create mode 100644 frontend/src/pages/Pagar.tsx create mode 100644 odoo-addons/skeen_cobros/__init__.py create mode 100644 odoo-addons/skeen_cobros/__manifest__.py create mode 100644 odoo-addons/skeen_cobros/controllers/__init__.py create mode 100644 odoo-addons/skeen_cobros/controllers/public_cobro.py create mode 100644 odoo-addons/skeen_cobros/data/cobro_sequence.xml create mode 100644 odoo-addons/skeen_cobros/data/cobros_cron.xml create mode 100644 odoo-addons/skeen_cobros/models/__init__.py create mode 100644 odoo-addons/skeen_cobros/models/cobro_enlace.py create mode 100644 odoo-addons/skeen_cobros/security/ir.model.access.csv create mode 100644 odoo-addons/skeen_cobros/views/cobro_views.xml create mode 100644 odoo-addons/skeen_membresias/models/cobro.py diff --git a/docs/producto/configuracion-cliente.md b/docs/producto/configuracion-cliente.md index 67d80d0..8fbc67d 100644 --- a/docs/producto/configuracion-cliente.md +++ b/docs/producto/configuracion-cliente.md @@ -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 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) **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 roles base (admin, médico, recepción, enfermería, cosmetología, psicóloga, 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, WhatsApp, Notificaciones, Formularios, Membresías, Compras, Comisiones, - Configuración, Plataforma), con botones todo/ninguno. El backend los hace - cumplir aunque el usuario manipule la interfaz. + Cobros y enlaces de pago, Configuración, Plataforma), con botones todo/ninguno. + El backend los hace cumplir aunque el usuario manipule la interfaz. 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. ## 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. - **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/` (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:///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:///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 **Configuración → Compras** (apagado por defecto) y **Compras** (menú). diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 103017d..efa9476 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -42,6 +42,7 @@ import MisVentas from './pages/MisVentas'; import Login from './pages/Login'; import Reservar from './pages/Reservar'; import ReservarCita from './pages/ReservarCita'; +import Pagar from './pages/Pagar'; import FormularioPublico from './pages/FormularioPublico'; import './App.css'; @@ -180,6 +181,8 @@ const App: FC = () => { {/* Portal público de reservas: FUERA del gate de autenticación. */} } /> } /> + {/* Página pública de pago de un enlace de cobro (token HMAC). */} + } /> {/* Portal público de formularios: token HMAC como credencial. */} } /> } /> diff --git a/frontend/src/components/MembresiasPanel.tsx b/frontend/src/components/MembresiasPanel.tsx index 338f165..1cc5d0b 100644 --- a/frontend/src/components/MembresiasPanel.tsx +++ b/frontend/src/components/MembresiasPanel.tsx @@ -3,10 +3,12 @@ import { useCallback, useEffect, useState } from 'react'; import { Repeat, Plus, Pencil, Trash2, Save } from 'lucide-react'; import { Button, Input, TextArea, Select, Modal, toast } from './ui'; import { - odooApi, MEMBRESIAS_DEFAULT, - type ClinicSettings, type MembresiasSettings, type MembresiaPlan, + odooApi, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT, + type ClinicSettings, type MembresiasSettings, type CobroAutomaticoSettings, + type MembresiaPlan, type Service, type ServiceCategory, type PeriodicidadMembresia, } from '../services/odoo'; +import { useI18n } from '../lib/i18n'; // 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 @@ -71,6 +73,7 @@ const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => vo ); const MembresiasPanel: FC = ({ settings, onSaved }) => { + const { t } = useI18n(); const [form, setForm] = useState(settings.membresias || MEMBRESIAS_DEFAULT); const [saving, setSaving] = useState(false); const [avisos, setAvisos] = useState((settings.membresias?.dias_aviso_renovacion || [7, 1]).join(', ')); @@ -115,13 +118,27 @@ const MembresiasPanel: FC = ({ settings, onSaved }) => { const set = (k: K, v: MembresiasSettings[K]) => setForm((prev) => ({ ...prev, [k]: v })); + const cobro: CobroAutomaticoSettings = form.cobro_automatico || COBRO_AUTOMATICO_DEFAULT; + const setCobro = (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 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; } + const reintentos = reintentosTxt.split(',').map((d) => parseInt(d.trim(), 10)).filter((d) => !Number.isNaN(d)); setSaving(true); try { 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', }); if (res.status === 'success') { @@ -270,6 +287,48 @@ const MembresiasPanel: FC = ({ settings, onSaved }) => { set('permitir_pausa_pago', v)} /> + + {/* Cobro automático del periodo con enlace de pago. Apagado por defecto. */} +
+

{t('config.cobroAuto.title')}

+

{t('config.cobroAuto.help')}

+
+ setCobro('activo', v)} + ayuda={t('config.cobroAuto.enableHelp')} /> +
+ {cobro.activo && ( +
+
+ setCobro('dias_antes', parseInt(e.target.value, 10) || 0)} /> + setCobro('dias_gracia', parseInt(e.target.value, 10) || 0)} /> +
+
+ + setReintentosTxt(e.target.value)} placeholder="1, 3, 7" /> +

{t('config.cobroAuto.retriesHelp')}

+
+
+ setCobro('cancelar_si_no_paga', v)} + ayuda={t('config.cobroAuto.cancelHelp')} /> + setCobro('permitir_simulado', v)} + ayuda={t('config.cobroAuto.simulatedHelp')} /> +
+
+ )} +