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:
2026-10-11 23:54:47 +00:00
parent 5fc0d2a780
commit 6a6c2a928b
35 changed files with 2841 additions and 32 deletions

View File

@@ -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ú).

View File

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

View File

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

View File

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

View 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;

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

@@ -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(() => {

View 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;

View File

@@ -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' ? (

View File

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

View File

@@ -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> = {};

View File

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

View File

@@ -201,6 +201,18 @@ class SkeenCita(models.Model):
('paid', 'Pagado'), ('paid', 'Pagado'),
], 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')

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import models
from . import controllers

View 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',
}

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import public_cobro

View 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)

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

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

View File

@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import cobro_enlace

View 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

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_skeen_cobro_enlace_user Acceso a enlaces de cobro model_skeen_cobro_enlace base.group_user 1 1 1 1
3 access_skeen_cobro_enlace_log_user Acceso a bitácora de cobros model_skeen_cobro_enlace_log base.group_user 1 1 1 1

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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