bloque 1: anticipo al reservar en linea y cobro automatico de membresias con enlace de pago (modulo skeen_cobros: enlaces con token firmado, webhooks de Stripe/MercadoPago, modo simulado para pruebas, pagina publica de pago, expiracion automatica y reembolso, reintentos de cobro, permisos y reporte); apagado por defecto
This commit is contained in:
@@ -123,6 +123,27 @@ Procedimiento, Paquete) se pueden renombrar y se pueden crear más (p. ej.
|
||||
- El cliente recibe un enlace propio para consultar o cancelar su cita. Las
|
||||
reservas que llegan por el portal se distinguen en la agenda.
|
||||
|
||||
### 7.1 Anticipo (depósito) al reservar — apagado por defecto
|
||||
|
||||
Dentro del mismo bloque, la llave `reservas_online.anticipo`. Con `activo = true`:
|
||||
|
||||
- **Opciones** (defaults entre paréntesis): `tipo` (`porcentaje` | `monto_fijo`),
|
||||
`valor` (0; el % sobre el precio del servicio o el monto fijo), `moneda`
|
||||
(`MXN`), `horas_para_pagar` (24), `reembolsable` (sí), `texto_aviso` (''), y
|
||||
`permitir_simulado` (no, ver más abajo).
|
||||
- Al reservar, la cita queda **retenida sin confirmar** (badge **"Esperando
|
||||
anticipo"** en la agenda y en el detalle) y el portal muestra el monto y el
|
||||
botón para pagar. Al pagarse, la cita pasa a **confirmada** y se avisa por el
|
||||
motor de notificaciones.
|
||||
- Si no se paga dentro de `horas_para_pagar`, un proceso automático cancela la
|
||||
cita y **libera el horario**.
|
||||
- El anticipo se registra como pago real (`skeen.pago`) ligado a la cita y a la
|
||||
venta, por lo que aparece en cortes de caja y reportes; no se duplica con el
|
||||
resto que el cliente pague después en caja.
|
||||
- **Reembolso**: con `reembolsable = true`, al cancelar una cita con anticipo
|
||||
pagado, el pago se marca como reembolsado y la cita como "Anticipo
|
||||
reembolsado". Con `false`, el anticipo se retiene.
|
||||
|
||||
## 8. Recursos (cabinas, salas y equipos)
|
||||
|
||||
**Configuración → Recursos** (apagado por defecto)
|
||||
@@ -161,13 +182,17 @@ generar visitas, ver todas, bloquear).
|
||||
fin de semana), renombrar, ajustar jerarquía y activar/desactivar. Vienen 10
|
||||
roles base (admin, médico, recepción, enfermería, cosmetología, psicóloga,
|
||||
farmacia, gerencia, marketing, lectura).
|
||||
2. **Permisos**: **71 permisos por acción agrupados en 16 grupos** (Agenda,
|
||||
2. **Permisos**: **75 permisos por acción agrupados en 17 grupos** (Agenda,
|
||||
Recursos, Pacientes, Visitas, Ventas, Inventario, Servicios, Reportes,
|
||||
WhatsApp, Notificaciones, Formularios, Membresías, Compras, Comisiones,
|
||||
Configuración, Plataforma), con botones todo/ninguno. El backend los hace
|
||||
cumplir aunque el usuario manipule la interfaz.
|
||||
Cobros y enlaces de pago, Configuración, Plataforma), con botones todo/ninguno.
|
||||
El backend los hace cumplir aunque el usuario manipule la interfaz.
|
||||
3. **Menús**: qué sección ve cada rol.
|
||||
|
||||
Los permisos de **Cobros** (defaults): `cobros.ver` (todos), `cobros.administrar`
|
||||
(admin/gerencia), `cobros.forzar` (admin/recepción/gerencia) y `cobros.cancelar`
|
||||
(admin/gerencia).
|
||||
|
||||
Recomendación: partir del rol más parecido, ajustar y guardar.
|
||||
|
||||
## 10. Notificaciones
|
||||
@@ -218,6 +243,60 @@ sesiones incluidas, cobro periódico, congelamiento y renovación.
|
||||
envía avisos de renovación en cada ventana y descongela al terminar la ventana.
|
||||
- **Reporte**: **Membresías → reporte** con ingreso recurrente por periodo.
|
||||
|
||||
### 11.1 Cobro automático de membresías — apagado por defecto
|
||||
|
||||
Dentro del bloque, la llave `membresias.cobro_automatico`. Con `activo = true`:
|
||||
|
||||
- **Opciones** (defaults entre paréntesis): `dias_antes` (3), `metodo`
|
||||
(`enlace` | `tarjeta_guardada`), `reintentos_dias` (`[1, 3, 7]`),
|
||||
`cancelar_si_no_paga` (no), `dias_gracia` (5) y `permitir_simulado` (no).
|
||||
- **Método `enlace`**: al acercarse el vencimiento el cron genera el **enlace de
|
||||
pago del periodo** y lo envía al paciente por el motor de notificaciones
|
||||
(plantilla `plantilla_cobro_membresia`, variable `{enlace}`). El periodo queda
|
||||
**pendiente** hasta que se pague; al pagarse pasa a **pagado** y la membresía
|
||||
**se renueva sola**. Reintentos: se vuelve a avisar en los días indicados
|
||||
respecto al vencimiento. Al agotarse los reintentos y los días de gracia, con
|
||||
`cancelar_si_no_paga` la membresía pasa a **vencida**; apagado, solo se marca
|
||||
el periodo y es el proceso normal de vencimiento el que la vence.
|
||||
- **Método `tarjeta_guardada`**: cargo sin presencia del cliente. **Requiere
|
||||
credenciales reales de Stripe y tarjetas guardadas** (que hoy no se persisten);
|
||||
sin ellas queda **inerte** (no cobra ni marca nada como pagado).
|
||||
|
||||
## 11 bis. Cobros en línea y pasarelas de pago
|
||||
|
||||
Los anticipos de reservas y el cobro automático de membresías usan un mismo
|
||||
objeto, el **enlace de cobro** (`skeen.cobro.enlace`): origen (reserva,
|
||||
membresía o venta), monto y moneda, token firmado con caducidad, estado
|
||||
(pendiente/pagado/expirado/cancelado), proveedor (Stripe, MercadoPago o
|
||||
**simulado**) y bitácora. Al pagarse se registra un `skeen.pago` real ligado a
|
||||
la cita y a la venta.
|
||||
|
||||
- **Página pública de pago**: `/pagar/<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
|
||||
|
||||
**Configuración → Compras** (apagado por defecto) y **Compras** (menú).
|
||||
|
||||
@@ -42,6 +42,7 @@ import MisVentas from './pages/MisVentas';
|
||||
import Login from './pages/Login';
|
||||
import Reservar from './pages/Reservar';
|
||||
import ReservarCita from './pages/ReservarCita';
|
||||
import Pagar from './pages/Pagar';
|
||||
import FormularioPublico from './pages/FormularioPublico';
|
||||
import './App.css';
|
||||
|
||||
@@ -180,6 +181,8 @@ const App: FC = () => {
|
||||
{/* Portal público de reservas: FUERA del gate de autenticación. */}
|
||||
<Route path="/reservar" element={<Reservar />} />
|
||||
<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. */}
|
||||
<Route path="/formulario/:token" element={<FormularioPublico />} />
|
||||
<Route path="/" element={<RequireAuth href="/"><HomeRoute /></RequireAuth>} />
|
||||
|
||||
@@ -3,10 +3,12 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { Repeat, Plus, Pencil, Trash2, Save } from 'lucide-react';
|
||||
import { Button, Input, TextArea, Select, Modal, toast } from './ui';
|
||||
import {
|
||||
odooApi, MEMBRESIAS_DEFAULT,
|
||||
type ClinicSettings, type MembresiasSettings, type MembresiaPlan,
|
||||
odooApi, MEMBRESIAS_DEFAULT, COBRO_AUTOMATICO_DEFAULT,
|
||||
type ClinicSettings, type MembresiasSettings, type CobroAutomaticoSettings,
|
||||
type MembresiaPlan,
|
||||
type Service, type ServiceCategory, type PeriodicidadMembresia,
|
||||
} from '../services/odoo';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
|
||||
// Configuración → Membresías: ajustes del bloque + CRUD de planes.
|
||||
// Mientras `activo` esté apagado, la sección no aparece en el menú y el POS no
|
||||
@@ -71,6 +73,7 @@ const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => vo
|
||||
);
|
||||
|
||||
const MembresiasPanel: FC<Props> = ({ settings, onSaved }) => {
|
||||
const { t } = useI18n();
|
||||
const [form, setForm] = useState<MembresiasSettings>(settings.membresias || MEMBRESIAS_DEFAULT);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [avisos, setAvisos] = useState((settings.membresias?.dias_aviso_renovacion || [7, 1]).join(', '));
|
||||
@@ -115,13 +118,27 @@ const MembresiasPanel: FC<Props> = ({ settings, onSaved }) => {
|
||||
const set = <K extends keyof MembresiasSettings>(k: K, v: MembresiasSettings[K]) =>
|
||||
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 dias = avisos.split(',').map((d) => parseInt(d.trim(), 10)).filter((d) => !Number.isNaN(d));
|
||||
if (dias.length === 0) { toast.error('Indica al menos un día de aviso (ej. 7, 1)'); return; }
|
||||
const reintentos = reintentosTxt.split(',').map((d) => parseInt(d.trim(), 10)).filter((d) => !Number.isNaN(d));
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await odooApi.putClinicSettings({
|
||||
membresias: { ...form, dias_aviso_renovacion: dias },
|
||||
membresias: {
|
||||
...form,
|
||||
dias_aviso_renovacion: dias,
|
||||
cobro_automatico: { ...cobro, reintentos_dias: reintentos },
|
||||
},
|
||||
motivo: 'Configuración de membresías',
|
||||
});
|
||||
if (res.status === 'success') {
|
||||
@@ -270,6 +287,48 @@ const MembresiasPanel: FC<Props> = ({ settings, onSaved }) => {
|
||||
<Toggle label="Permitir pausa por falta de pago" checked={form.permitir_pausa_pago}
|
||||
onChange={(v) => set('permitir_pausa_pago', v)} />
|
||||
</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">
|
||||
<Button onClick={guardarConfig} disabled={saving}>
|
||||
<Save size={16} className="mr-2" /> {saving ? 'Guardando…' : 'Guardar configuración'}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { useEffect, useState } from '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 {
|
||||
odooApi, RESERVAS_ONLINE_DEFAULT,
|
||||
type ClinicSettings, type ReservasOnlineSettings,
|
||||
odooApi, RESERVAS_ONLINE_DEFAULT, ANTICIPO_DEFAULT,
|
||||
type ClinicSettings, type ReservasOnlineSettings, type AnticipoSettings,
|
||||
} from '../services/odoo';
|
||||
import { enlaceReserva } from '../lib/publicBooking';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
|
||||
// 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.
|
||||
@@ -17,6 +18,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const ReservasOnlinePanel: FC<Props> = ({ settings, onSaved }) => {
|
||||
const { t } = useI18n();
|
||||
const [form, setForm] = useState<ReservasOnlineSettings>(
|
||||
settings.reservas_online || RESERVAS_ONLINE_DEFAULT);
|
||||
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]) =>
|
||||
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 guardar = async () => {
|
||||
@@ -119,6 +125,51 @@ const ReservasOnlinePanel: FC<Props> = ({ settings, onSaved }) => {
|
||||
onChange={(e) => set('mensaje_confirmacion', e.target.value)} rows={2}
|
||||
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">
|
||||
<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">
|
||||
|
||||
183
frontend/src/components/reportes/CobrosReport.tsx
Normal file
183
frontend/src/components/reportes/CobrosReport.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { CreditCard, RefreshCw } from 'lucide-react';
|
||||
import { Card, Button, EmptyState, Skeleton, Badge, toast } from '../ui';
|
||||
import { ExportButton } from '../ui/ExportButton';
|
||||
import { exportToExcel } from '../../lib/exporter';
|
||||
import { odooApi, type Cobro } from '../../services/odoo';
|
||||
import { useReportRange } from '../../lib/reportRange';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
|
||||
const fmtMoney = (value: number, currency = 'MXN') =>
|
||||
new Intl.NumberFormat('es-MX', { style: 'currency', currency, maximumFractionDigits: 2 }).format(value || 0);
|
||||
|
||||
// Pestaña "Cobros" del módulo de Reportes: enlaces de cobro pendientes y
|
||||
// expirados (anticipos de reservas en línea y periodos de membresía). Permite
|
||||
// forzar el cobro y cancelar la retención según los permisos del rol.
|
||||
const CobrosReport: FC = () => {
|
||||
const { start, end } = useReportRange('cobros');
|
||||
const { can } = useAuth();
|
||||
const [cobros, setCobros] = useState<Cobro[]>([]);
|
||||
const [resumen, setResumen] = useState({ pendiente: 0, expirado: 0, cancelado: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [accionando, setAccionando] = useState<number | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await odooApi.getCobrosReport({ start, end });
|
||||
if (res.status === 'success') {
|
||||
setCobros(res.cobros);
|
||||
setResumen(res.resumen);
|
||||
} else {
|
||||
toast.error(res.message || 'No se pudo cargar el reporte');
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'Error al cargar el reporte de cobros');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [start, end]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const forzar = async (c: Cobro) => {
|
||||
setAccionando(c.id);
|
||||
try {
|
||||
const res = await odooApi.forzarCobro(c.id, { referencia: 'Panel cobros' });
|
||||
if (res.status === 'success') { toast.success('Cobro forzado'); await load(); }
|
||||
else toast.error(res.message || 'No se pudo forzar el cobro');
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo forzar el cobro');
|
||||
} finally { setAccionando(null); }
|
||||
};
|
||||
|
||||
const cancelar = async (c: Cobro) => {
|
||||
setAccionando(c.id);
|
||||
try {
|
||||
const res = await odooApi.cancelarCobro(c.id, 'Cancelado desde Reportes');
|
||||
if (res.status === 'success') { toast.success('Enlace cancelado'); await load(); }
|
||||
else toast.error(res.message || 'No se pudo cancelar');
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
toast.error(msg || 'No se pudo cancelar');
|
||||
} finally { setAccionando(null); }
|
||||
};
|
||||
|
||||
const exportar = async () => {
|
||||
await exportToExcel({
|
||||
filename: `cobros-${start}-${end}`,
|
||||
sheetName: 'Cobros',
|
||||
title: 'Cobros pendientes y expirados',
|
||||
subtitle: `Periodo ${start} a ${end}`,
|
||||
columns: [
|
||||
{ header: 'Folio', key: 'nombre' },
|
||||
{ header: 'Origen', key: 'origen_label' },
|
||||
{ header: 'Paciente', key: 'patient' },
|
||||
{ header: 'Concepto', key: 'concepto' },
|
||||
{ header: 'Monto', key: 'monto', format: 'currency' },
|
||||
{ header: 'Estado', key: 'estado_label' },
|
||||
{ header: 'Vencido', key: 'vencido' },
|
||||
{ header: 'Creado', key: 'creado' },
|
||||
{ header: 'Vence', key: 'expira' },
|
||||
],
|
||||
rows: cobros as unknown as Record<string, unknown>[],
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Card><Card.Body><Skeleton count={6} /></Card.Body></Card>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-xs text-theme-muted">Periodo {start} a {end}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={load}>
|
||||
<RefreshCw size={14} className="mr-1.5" /> Actualizar
|
||||
</Button>
|
||||
<ExportButton onClick={exportar} loading={false} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<Card><Card.Body>
|
||||
<p className="text-xs text-theme-muted">Pendientes</p>
|
||||
<p className="font-heading text-2xl text-theme-heading">{fmtMoney(resumen.pendiente)}</p>
|
||||
</Card.Body></Card>
|
||||
<Card><Card.Body>
|
||||
<p className="text-xs text-theme-muted">Expirados</p>
|
||||
<p className="font-heading text-2xl text-theme-heading">{fmtMoney(resumen.expirado)}</p>
|
||||
</Card.Body></Card>
|
||||
<Card><Card.Body>
|
||||
<p className="text-xs text-theme-muted">Cancelados</p>
|
||||
<p className="font-heading text-2xl text-theme-heading">{fmtMoney(resumen.cancelado)}</p>
|
||||
</Card.Body></Card>
|
||||
</div>
|
||||
|
||||
{cobros.length === 0 ? (
|
||||
<Card><Card.Body>
|
||||
<EmptyState title="Sin cobros pendientes" subtitle="No hay enlaces pendientes ni expirados en el periodo."
|
||||
icon={<CreditCard size={28} />} />
|
||||
</Card.Body></Card>
|
||||
) : (
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-theme-border text-left text-xs uppercase tracking-wide text-theme-muted">
|
||||
<th className="px-3 py-2">Folio</th>
|
||||
<th className="px-3 py-2">Origen</th>
|
||||
<th className="px-3 py-2">Paciente</th>
|
||||
<th className="px-3 py-2">Concepto</th>
|
||||
<th className="px-3 py-2 text-right">Monto</th>
|
||||
<th className="px-3 py-2">Estado</th>
|
||||
<th className="px-3 py-2">Vence</th>
|
||||
<th className="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cobros.map((c) => (
|
||||
<tr key={c.id} className="border-b border-theme-border/60">
|
||||
<td className="px-3 py-2 text-theme-heading">{c.nombre}</td>
|
||||
<td className="px-3 py-2 text-theme-muted">{c.origen_label}</td>
|
||||
<td className="px-3 py-2 text-theme-muted">{c.patient || '—'}</td>
|
||||
<td className="px-3 py-2 text-theme-heading">{c.concepto}</td>
|
||||
<td className="px-3 py-2 text-right text-theme-heading">{fmtMoney(c.monto, c.moneda)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant={c.estado === 'expirado' ? 'danger' : c.estado === 'cancelado' ? 'cancelled' : 'warning'}>
|
||||
{c.estado_label}{c.vencido ? ' · vencido' : ''}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-theme-muted">{c.expira || '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
{c.estado === 'pendiente' && (
|
||||
<div className="flex justify-end gap-1">
|
||||
{can('cobros.forzar') && (
|
||||
<Button size="sm" variant="outline" loading={accionando === c.id} onClick={() => forzar(c)}>
|
||||
Forzar
|
||||
</Button>
|
||||
)}
|
||||
{can('cobros.cancelar') && (
|
||||
<Button size="sm" variant="ghost" onClick={() => cancelar(c)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CobrosReport;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { 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).
|
||||
// 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,
|
||||
...(settings || {}),
|
||||
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 || {}) },
|
||||
membresias: { ...MEMBRESIAS_DEFAULT, ...(settings?.membresias || {}) },
|
||||
membresias: {
|
||||
...MEMBRESIAS_DEFAULT,
|
||||
...(settings?.membresias || {}),
|
||||
cobro_automatico: { ...COBRO_AUTOMATICO_DEFAULT, ...((settings?.membresias || {}).cobro_automatico || {}) },
|
||||
},
|
||||
recursos: { ...RECURSOS_DEFAULT, ...(settings?.recursos || {}) },
|
||||
compras: { ...COMPRAS_DEFAULT, ...(settings?.compras || {}) },
|
||||
comisiones: { ...COMISIONES_DEFAULT, ...(settings?.comisiones || {}) },
|
||||
@@ -31,6 +40,12 @@ const publicar = (s: 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;
|
||||
peticion = odooApi.getClinicSettings()
|
||||
.then((res) => {
|
||||
|
||||
@@ -388,6 +388,8 @@ export const es: Dict = {
|
||||
'reports.tab.cortes.desc': 'Arqueos y cierres por día',
|
||||
'reports.tab.adeudos': 'Adeudos',
|
||||
'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.desc': 'Ventas devueltas y montos',
|
||||
'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.contactPhone': ' Comunícate al {phone}.',
|
||||
'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.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.noResourcesTitle': 'Sin recursos',
|
||||
'agenda.noResourcesSub': 'Agrega recursos en Configuración › Recursos para ver esta vista.',
|
||||
'agenda.depositPending': 'Esperando anticipo',
|
||||
'config.idiomaTitle': 'Idioma de la interfaz',
|
||||
'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.adeudos': 'Debts',
|
||||
'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.desc': 'Returned sales and amounts',
|
||||
'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.contactPhone': ' Contact us at {phone}.',
|
||||
'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.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.noResourcesTitle': 'No resources',
|
||||
'agenda.noResourcesSub': 'Add resources in Settings › Resources to see this view.',
|
||||
'agenda.depositPending': 'Deposit pending',
|
||||
'config.idiomaTitle': 'Interface language',
|
||||
'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 };
|
||||
|
||||
@@ -23,6 +23,7 @@ export const PERM_KEYS: string[] = [
|
||||
'compras.ver', 'compras.crear', 'compras.recibir', 'compras.pagar',
|
||||
'compras.editar', 'compras.cancelar', 'proveedores.ver', 'proveedores.editar',
|
||||
'comisiones.ver', 'comisiones.editar', 'comisiones.liquidar',
|
||||
'cobros.ver', 'cobros.administrar', 'cobros.forzar', 'cobros.cancelar',
|
||||
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
|
||||
'api.claves', 'webhooks.editar',
|
||||
];
|
||||
@@ -53,6 +54,7 @@ const READ_PERM_KEYS = new Set([
|
||||
'compras.ver',
|
||||
'proveedores.ver',
|
||||
'comisiones.ver',
|
||||
'cobros.ver',
|
||||
]);
|
||||
|
||||
const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
||||
@@ -84,6 +86,8 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
|
||||
'compras.editar': AG, 'compras.cancelar': AG,
|
||||
'proveedores.ver': ALL, 'proveedores.editar': 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,
|
||||
'api.claves': AD, 'webhooks.editar': AG,
|
||||
};
|
||||
|
||||
@@ -2882,7 +2882,12 @@ const Agenda: FC = () => {
|
||||
</td>
|
||||
<td className="p-3 text-sm text-theme-muted hidden md:table-cell">{apt.doctor || '-'}</td>
|
||||
<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 className="p-3 text-center">
|
||||
<button
|
||||
@@ -3369,6 +3374,9 @@ const Agenda: FC = () => {
|
||||
{detailApt.is_first_visit && (
|
||||
<Badge variant="primary">Primera vez</Badge>
|
||||
)}
|
||||
{detailApt.anticipo_estado === 'pendiente' && (
|
||||
<Badge variant="warning">{t('agenda.depositPending')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 bg-theme-bg rounded-xl">
|
||||
|
||||
@@ -25,7 +25,7 @@ import LicenciaPanel from '../components/LicenciaPanel';
|
||||
import ApiKeysPanel from '../components/ApiKeysPanel';
|
||||
import WebhooksPanel from '../components/WebhooksPanel';
|
||||
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 { publicarClinicSettings } from '../lib/clinic-settings';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
@@ -143,8 +143,17 @@ const Configuracion: FC = () => {
|
||||
...CLINIC_SETTINGS_DEFAULT,
|
||||
...next,
|
||||
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 || {}) },
|
||||
membresias: {
|
||||
...MEMBRESIAS_DEFAULT,
|
||||
...(next.membresias || {}),
|
||||
cobro_automatico: { ...COBRO_AUTOMATICO_DEFAULT, ...((next.membresias || {}).cobro_automatico || {}) },
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
191
frontend/src/pages/Pagar.tsx
Normal file
191
frontend/src/pages/Pagar.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
AlertTriangle, Check, CreditCard, Loader2, Lock, ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '../components/ui';
|
||||
import { odooApi, type CobroComprobante, type CobroPublico } from '../services/odoo';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
|
||||
// Página pública de pago de un enlace de cobro (anticipo de reserva o periodo
|
||||
// de membresía). Anónima: el token HMAC del enlace es la única credencial.
|
||||
// Funciona igual con pasarela real (redirige al checkout) o en modo simulado.
|
||||
|
||||
const Pagar: FC = () => {
|
||||
const { token = '' } = useParams();
|
||||
const { t } = useI18n();
|
||||
const [cobro, setCobro] = useState<CobroPublico | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pagando, setPagando] = useState(false);
|
||||
const [comprobante, setComprobante] = useState<CobroComprobante | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
if (!token) { setLoading(false); setError(t('pay.invalidLink')); return; }
|
||||
odooApi.getCobroPublico(token)
|
||||
.then((res) => { if (alive) setCobro(res); })
|
||||
.catch((err) => {
|
||||
const msg = (err as { response?: { data?: { message?: string } } });
|
||||
if (alive) setError(msg?.response?.data?.message || t('pay.notFound'));
|
||||
})
|
||||
.finally(() => { if (alive) setLoading(false); });
|
||||
return () => { alive = false; };
|
||||
}, [token]);
|
||||
|
||||
const pagar = async () => {
|
||||
setPagando(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await odooApi.pagarCobroPublico(token);
|
||||
if (res.requiere_pasarela && res.url) {
|
||||
window.location.href = res.url;
|
||||
return;
|
||||
}
|
||||
if (res.comprobante) {
|
||||
setComprobante(res.comprobante);
|
||||
setCobro((prev) => (prev ? { ...prev, estado: 'pagado', estado_label: t('pay.state.pagado') } : prev));
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
||||
setError(msg || t('pay.payError'));
|
||||
} finally {
|
||||
setPagando(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Centro><Loader2 className="h-6 w-6 animate-spin text-theme-muted" /></Centro>;
|
||||
}
|
||||
|
||||
if (!cobro) {
|
||||
return (
|
||||
<Centro>
|
||||
<Card>
|
||||
<AlertTriangle className="mx-auto mb-3 h-8 w-8 text-amber-500" />
|
||||
<h1 className="font-heading text-lg text-theme-heading">{t('pay.unavailableTitle')}</h1>
|
||||
<p className="mt-2 text-sm text-theme-muted">{error || t('pay.expiredBody')}</p>
|
||||
</Card>
|
||||
</Centro>
|
||||
);
|
||||
}
|
||||
|
||||
const pagado = comprobante || cobro.estado === 'pagado';
|
||||
const montoTxt = `${cobro.moneda} ${cobro.monto.toFixed(2)}`;
|
||||
|
||||
return (
|
||||
<Centro>
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
<header className="text-center">
|
||||
{cobro.brand?.logo_oscuro && (
|
||||
<img src={cobro.brand.logo_oscuro} alt={cobro.brand.nombre}
|
||||
className="mx-auto mb-3 h-9 w-auto object-contain" />
|
||||
)}
|
||||
<h1 className="font-heading text-lg text-theme-heading">{cobro.brand?.nombre || ''}</h1>
|
||||
</header>
|
||||
|
||||
<div className="rounded-3xl border border-theme-border bg-theme-surface p-6 shadow-soft">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-heading text-base text-theme-heading">{t('pay.title')}</h2>
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-medium ${badgeClase(pagado ? 'pagado' : cobro.estado)}`}>
|
||||
{pagado ? t('pay.state.pagado') : cobro.estado_label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-theme-muted">
|
||||
{t('pay.folioWord')} <span className="font-semibold text-theme-heading">{cobro.folio}</span>
|
||||
</p>
|
||||
|
||||
<div className="mt-5 rounded-2xl bg-theme-bg p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-theme-muted">{t('pay.concept')}</p>
|
||||
<p className="mt-0.5 text-sm text-theme-heading">{cobro.concepto}</p>
|
||||
<div className="mt-3 flex items-end justify-between">
|
||||
<span className="text-xs uppercase tracking-wide text-theme-muted">{t('pay.amount')}</span>
|
||||
<span className="font-heading text-2xl text-theme-heading">{montoTxt}</span>
|
||||
</div>
|
||||
{cobro.paciente && (
|
||||
<p className="mt-2 text-xs text-theme-muted">{t('pay.forPatient', { name: cobro.paciente })}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Estado pagado: comprobante */}
|
||||
{pagado && (
|
||||
<div className="mt-4 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 text-center">
|
||||
<Check className="mx-auto mb-2 h-7 w-7 text-emerald-600" />
|
||||
<p className="text-sm font-medium text-emerald-800">{t('pay.paidOk')}</p>
|
||||
<div className="mt-3 space-y-1 text-left text-xs text-emerald-800">
|
||||
<p>{t('pay.receipt')}: <span className="font-semibold">{comprobante?.pago || cobro.folio}</span></p>
|
||||
<p>{t('pay.concept')}: {comprobante?.concepto || cobro.concepto}</p>
|
||||
<p>{t('pay.amount')}: {montoTxt}</p>
|
||||
{comprobante?.fecha && <p>{t('pay.date')}: {String(comprobante.fecha).slice(0, 16)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pendiente: botón de pago */}
|
||||
{!pagado && cobro.estado === 'pendiente' && (
|
||||
<>
|
||||
{cobro.puede_pagar ? (
|
||||
<Button className="mt-5 w-full" loading={pagando} onClick={pagar}>
|
||||
<CreditCard size={16} className="mr-2" />
|
||||
{cobro.simulado ? t('pay.paySimulated') : t('pay.payNow')}
|
||||
</Button>
|
||||
) : (
|
||||
<p className="mt-5 rounded-xl bg-amber-50 p-3 text-sm text-amber-800">
|
||||
{t('pay.notAvailable')}
|
||||
</p>
|
||||
)}
|
||||
{cobro.expira && (
|
||||
<p className="mt-3 text-center text-xs text-theme-muted">
|
||||
{t('pay.expiresAt', { date: String(cobro.expira).slice(0, 16) })}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{(cobro.estado === 'expirado' || cobro.estado === 'cancelado') && !pagado && (
|
||||
<p className="mt-5 rounded-xl bg-rose-50 p-3 text-sm text-rose-700">
|
||||
{cobro.estado === 'expirado' ? t('pay.expiredBody') : t('pay.cancelledBody')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && <p className="mt-3 text-sm text-rose-600">{error}</p>}
|
||||
|
||||
{cobro.simulado && !pagado && (
|
||||
<p className="mt-4 flex items-center justify-center gap-1 text-xs text-theme-muted">
|
||||
<ShieldCheck size={13} /> {t('pay.simulatedNote')}
|
||||
</p>
|
||||
)}
|
||||
{!cobro.simulado && (
|
||||
<p className="mt-4 flex items-center justify-center gap-1 text-xs text-theme-muted">
|
||||
<Lock size={13} /> {t('pay.secureNote')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Centro>
|
||||
);
|
||||
};
|
||||
|
||||
const badgeClase = (estado: string): string => {
|
||||
switch (estado) {
|
||||
case 'pagado': return 'bg-emerald-100 text-emerald-700';
|
||||
case 'pendiente': return 'bg-amber-100 text-amber-700';
|
||||
case 'expirado': return 'bg-rose-100 text-rose-700';
|
||||
default: return 'bg-theme-bg text-theme-muted';
|
||||
}
|
||||
};
|
||||
|
||||
const Centro: FC<{ children: ReactNode }> = ({ children }) => (
|
||||
<div className="flex min-h-screen items-start justify-center bg-theme-bg px-4 py-8 sm:items-center">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Card: FC<{ children: ReactNode }> = ({ children }) => (
|
||||
<div className="w-full max-w-sm rounded-3xl border border-theme-border bg-theme-surface p-8 text-center shadow-soft">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Pagar;
|
||||
@@ -37,6 +37,7 @@ import KpisReport from '../components/reportes/KpisReport';
|
||||
import RecursosReport from '../components/reportes/RecursosReport';
|
||||
import ComprasReport from '../components/reportes/ComprasReport';
|
||||
import MargenReport from '../components/reportes/MargenReport';
|
||||
import CobrosReport from '../components/reportes/CobrosReport';
|
||||
import { Card, Button, Input, PageHeader, Skeleton, EmptyState, toast } from '../components/ui';
|
||||
import { odooApi } 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: '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: 'cobros', label: 'Cobros', desc: 'Anticipos y membresías por cobrar (pendientes y expirados)' },
|
||||
{ key: 'devoluciones', label: 'Devoluciones', desc: 'Ventas devueltas y montos' },
|
||||
],
|
||||
},
|
||||
@@ -478,6 +480,8 @@ const ReportesContent: FC = () => {
|
||||
<MargenReport />
|
||||
) : tab === 'adeudos' ? (
|
||||
<AdeudosReport />
|
||||
) : tab === 'cobros' ? (
|
||||
<CobrosReport />
|
||||
) : tab === 'comisiones' ? (
|
||||
<ComisionesReport />
|
||||
) : tab === 'pagos-servicios' ? (
|
||||
|
||||
@@ -158,11 +158,15 @@ const Reservar: FC = () => {
|
||||
<Centro>
|
||||
<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="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100">
|
||||
<Check className="h-6 w-6 text-emerald-600" />
|
||||
<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'}`}>
|
||||
{resultado.esperando_anticipo
|
||||
? <Clock className="h-6 w-6 text-amber-600" />
|
||||
: <Check className="h-6 w-6 text-emerald-600" />}
|
||||
</div>
|
||||
<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>
|
||||
<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">
|
||||
@@ -171,10 +175,28 @@ const Reservar: FC = () => {
|
||||
{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} />}
|
||||
</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>
|
||||
)}
|
||||
{resultado.requiere_confirmacion && !resultado.mensaje_confirmacion && (
|
||||
{!resultado.esperando_anticipo && resultado.requiere_confirmacion && !resultado.mensaje_confirmacion && (
|
||||
<p className="mt-4 text-sm text-theme-muted">
|
||||
{t('public.willConfirm')}
|
||||
</p>
|
||||
|
||||
@@ -18,9 +18,14 @@ axios.interceptors.response.use(
|
||||
(res) => res,
|
||||
(error) => {
|
||||
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');
|
||||
if (!onLogin && !isLoginCall) {
|
||||
if (!onPublica && !isLoginCall) {
|
||||
localStorage.removeItem('skeen_token');
|
||||
localStorage.removeItem('skeen_user');
|
||||
window.location.href = '/login';
|
||||
@@ -92,6 +97,10 @@ export interface Appointment {
|
||||
time_float?: number;
|
||||
/** Etiqueta del estado con el vocabulario de la clínica */
|
||||
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 */
|
||||
@@ -1213,6 +1222,33 @@ export interface WebhookEventoCatalogo {
|
||||
}
|
||||
|
||||
// ---- 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 {
|
||||
activo: boolean;
|
||||
titulo: string;
|
||||
@@ -1226,6 +1262,7 @@ export interface ReservasOnlineSettings {
|
||||
cancelar_hasta_horas: number;
|
||||
mensaje_confirmacion: string;
|
||||
telefono_contacto: string;
|
||||
anticipo: AnticipoSettings;
|
||||
}
|
||||
|
||||
export const RESERVAS_ONLINE_DEFAULT: ReservasOnlineSettings = {
|
||||
@@ -1241,6 +1278,7 @@ export const RESERVAS_ONLINE_DEFAULT: ReservasOnlineSettings = {
|
||||
cancelar_hasta_horas: 24,
|
||||
mensaje_confirmacion: '',
|
||||
telefono_contacto: '',
|
||||
anticipo: { ...ANTICIPO_DEFAULT },
|
||||
};
|
||||
|
||||
// ---- Notificaciones y recordatorios de citas ----
|
||||
@@ -1309,6 +1347,8 @@ export interface NotificacionesSettings {
|
||||
plantilla_post_visita: string;
|
||||
/** Aviso de un formulario digital: usa {formulario} y {enlace}. */
|
||||
plantilla_formulario: string;
|
||||
/** Aviso con enlace de pago del periodo (cobro automático de membresías). */
|
||||
plantilla_cobro_membresia: string;
|
||||
whatsapp_numero: 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_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_cobro_membresia: 'Hola {nombre}, tu plan {plan} ({periodo}) está por renovarse. Paga {monto} desde este enlace: {enlace}',
|
||||
whatsapp_numero: '',
|
||||
email_remitente: '',
|
||||
};
|
||||
|
||||
/** 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. */
|
||||
export const NOTIF_VENTANAS: { horas: number; label: string }[] = [
|
||||
@@ -1345,6 +1386,31 @@ export const NOTIF_VENTANAS: { horas: number; label: string }[] = [
|
||||
];
|
||||
|
||||
// ---- 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 {
|
||||
/** Enciende TODO el módulo: menú, POS, cron y cargos. Apagado = no cambia nada. */
|
||||
activo: boolean;
|
||||
@@ -1361,6 +1427,8 @@ export interface MembresiasSettings {
|
||||
generar_cargo_al_renovar: boolean;
|
||||
/** Días tras el vencimiento antes de marcar la membresía vencida. */
|
||||
dias_gracia: number;
|
||||
/** Cobro automático del periodo con enlace de pago. */
|
||||
cobro_automatico: CobroAutomaticoSettings;
|
||||
}
|
||||
|
||||
export const MEMBRESIAS_DEFAULT: MembresiasSettings = {
|
||||
@@ -1373,6 +1441,7 @@ export const MEMBRESIAS_DEFAULT: MembresiasSettings = {
|
||||
permitir_pausa_pago: false,
|
||||
generar_cargo_al_renovar: true,
|
||||
dias_gracia: 5,
|
||||
cobro_automatico: { ...COBRO_AUTOMATICO_DEFAULT },
|
||||
};
|
||||
|
||||
// ---- Compras y proveedores ----
|
||||
@@ -2042,6 +2111,16 @@ export interface BookingConfig {
|
||||
descripcion: string;
|
||||
mensaje_confirmacion: 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;
|
||||
mensaje_confirmacion: 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 {
|
||||
@@ -2088,6 +2180,63 @@ export interface BookingCita {
|
||||
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 ----
|
||||
export type FormularioCampoTipo =
|
||||
| 'texto' | 'texto_largo' | 'numero' | 'fecha' | 'seleccion' | 'multi_seleccion'
|
||||
@@ -3298,6 +3447,46 @@ export const odooApi = {
|
||||
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 ----
|
||||
async getFormularioPlantillas(opts?: { includeInactive?: boolean; tipo?: string }): Promise<{ status: string; plantillas: FormularioPlantilla[]; total: number; message?: string }> {
|
||||
const params: Record<string, string | number> = {};
|
||||
|
||||
@@ -184,12 +184,29 @@ RESERVAS_ONLINE_DEFAULT = {
|
||||
'cancelar_hasta_horas': 24,
|
||||
'mensaje_confirmacion': '',
|
||||
'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).
|
||||
RESERVAS_MAX_DIAS_MIN, RESERVAS_MAX_DIAS_MAX = 1, 365
|
||||
RESERVAS_MIN_ANTICIPACION_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):
|
||||
@@ -199,9 +216,77 @@ def _int(valor, por_defecto=0):
|
||||
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):
|
||||
"""Bloque `reservas_online` completo, con defaults. Tolerante: nunca lanza."""
|
||||
out = dict(RESERVAS_ONLINE_DEFAULT)
|
||||
out['anticipo'] = normalizar_anticipo(
|
||||
raw.get('anticipo') if isinstance(raw, dict) else None)
|
||||
if not isinstance(raw, dict):
|
||||
return out
|
||||
for k in ('activo', 'requiere_confirmacion', 'permitir_eleccion_doctor',
|
||||
@@ -261,6 +346,10 @@ def validar_reservas_online(raw):
|
||||
if not (lo <= raw[k] <= hi):
|
||||
raise ValueError('El campo %s debe estar entre %d y %d' % (k, lo, hi))
|
||||
out[k] = raw[k]
|
||||
if 'anticipo' in raw:
|
||||
sub = validar_anticipo(raw['anticipo'])
|
||||
if sub is not None:
|
||||
out['anticipo'] = sub
|
||||
return out
|
||||
|
||||
|
||||
@@ -268,6 +357,11 @@ def get_reservas_online(env):
|
||||
"""Bloque de reservas en línea vigente (copia). Fuente única para la API."""
|
||||
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.
|
||||
#
|
||||
@@ -328,6 +422,11 @@ NOTIFICACIONES_DEFAULT = {
|
||||
'Hola {nombre}, tu plan {plan} se renueva el {fecha} ({periodo}) por '
|
||||
'{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': '',
|
||||
'email_remitente': '',
|
||||
}
|
||||
@@ -339,7 +438,8 @@ NOTIF_VARIABLES = ('nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor',
|
||||
'plan', 'membresia', 'monto', 'sesiones', 'periodo')
|
||||
NOTIF_PLANTILLAS = ('plantilla_recordatorio', 'plantilla_confirmacion',
|
||||
'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_PLANTILLA_MAX = 1000 # caracteres por plantilla
|
||||
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
|
||||
# generar_cargo_al_renovar: el cron genera el adeudo del periodo
|
||||
# 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 = {
|
||||
'activo': False,
|
||||
@@ -527,16 +629,113 @@ MEMBRESIAS_DEFAULT = {
|
||||
'permitir_pausa_pago': False,
|
||||
'generar_cargo_al_renovar': True,
|
||||
'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_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):
|
||||
"""Bloque `membresias` completo, con defaults. Tolerante: nunca lanza."""
|
||||
out = dict(MEMBRESIAS_DEFAULT)
|
||||
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):
|
||||
return out
|
||||
for k in ('activo', 'permitir_congelamiento', 'renovacion_automatica_default',
|
||||
@@ -604,6 +803,10 @@ def validar_membresias(raw):
|
||||
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 'cobro_automatico' in raw:
|
||||
sub = validar_cobro_automatico(raw['cobro_automatico'])
|
||||
if sub is not None:
|
||||
out['cobro_automatico'] = sub
|
||||
return out
|
||||
|
||||
|
||||
@@ -611,6 +814,11 @@ def get_membresias(env):
|
||||
"""Bloque de membresías vigente (copia). Fuente única para modelos y API."""
|
||||
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).
|
||||
#
|
||||
@@ -1283,7 +1491,7 @@ def get_settings(env, aplicar_licencia=True):
|
||||
'moneda_base': MONEDA_DEFAULT,
|
||||
'nota_ticket': '',
|
||||
'impuestos': dict(IMPUESTOS_DEFAULT),
|
||||
'reservas_online': dict(RESERVAS_ONLINE_DEFAULT),
|
||||
'reservas_online': normalizar_reservas_online(None),
|
||||
'notificaciones': normalizar_notificaciones(None),
|
||||
'membresias': normalizar_membresias(None),
|
||||
'recursos': dict(RECURSOS_DEFAULT),
|
||||
@@ -1330,7 +1538,11 @@ def set_settings(env, vals):
|
||||
if 'reservas_online' in vals:
|
||||
bloque = validar_reservas_online(vals['reservas_online'])
|
||||
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:
|
||||
bloque = validar_notificaciones(vals['notificaciones'])
|
||||
if bloque is not None:
|
||||
@@ -1338,7 +1550,12 @@ def set_settings(env, vals):
|
||||
if 'membresias' in vals:
|
||||
bloque = validar_membresias(vals['membresias'])
|
||||
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:
|
||||
bloque = validar_recursos(vals['recursos'])
|
||||
if bloque is not None:
|
||||
|
||||
@@ -201,6 +201,18 @@ class SkeenCita(models.Model):
|
||||
('paid', 'Pagado'),
|
||||
], string='Estado de Pago', default='not_paid')
|
||||
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_conversation_id = fields.Char(string='WACRM Conversation ID')
|
||||
|
||||
4
odoo-addons/skeen_cobros/__init__.py
Normal file
4
odoo-addons/skeen_cobros/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
38
odoo-addons/skeen_cobros/__manifest__.py
Normal file
38
odoo-addons/skeen_cobros/__manifest__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
'name': 'SKEEN Cobros (enlaces de pago)',
|
||||
'version': '1.0.0',
|
||||
'category': 'Healthcare',
|
||||
'summary': 'Enlaces de cobro reutilizables: anticipos de reservas en línea y cobro automático de membresías',
|
||||
'description': """
|
||||
Modelo único de ENLACE DE COBRO (`skeen.cobro.enlace`) reutilizable por
|
||||
anticipos de reservas en línea, cobro automático de membresías y ventas.
|
||||
|
||||
- Token HMAC firmado con caducidad y estado (pendiente/pagado/expirado/
|
||||
cancelado).
|
||||
- Proveedor: Stripe, MercadoPago o `simulado` cuando no hay credenciales
|
||||
de pasarela (permite probar el flujo completo sin dinero real ni llaves).
|
||||
- Endpoints públicos seguros para consultar y pagar el enlace, con límite
|
||||
de peticiones y trampa antiautómatas.
|
||||
- Webhooks de Stripe y MercadoPago que verifican la firma/secreto.
|
||||
- Al confirmarse el pago se crea un `skeen.pago` real ligado a la cita y
|
||||
a la venta, para que aparezca en cortes de caja y reportes.
|
||||
|
||||
Todo el módulo está APAGADO por defecto: los sub-bloques `anticipo` y
|
||||
`cobro_automatico` nacen con `activo=false`, así que una instancia sin
|
||||
configurar se comporta EXACTAMENTE como antes.
|
||||
""",
|
||||
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
|
||||
'website': 'https://skeen.mx',
|
||||
'depends': ['base', 'skeen_citas', 'skeen_pagos', 'skeen_ventas'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/cobro_sequence.xml',
|
||||
'data/cobros_cron.xml',
|
||||
'views/cobro_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
3
odoo-addons/skeen_cobros/controllers/__init__.py
Normal file
3
odoo-addons/skeen_cobros/controllers/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import public_cobro
|
||||
356
odoo-addons/skeen_cobros/controllers/public_cobro.py
Normal file
356
odoo-addons/skeen_cobros/controllers/public_cobro.py
Normal file
@@ -0,0 +1,356 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Página pública de pago de un enlace de cobro (sin sesión).
|
||||
|
||||
Endpoints anónimos bajo `/skeen/frontend/v1/public/cobro`:
|
||||
|
||||
GET /cobro?token=... datos mínimos y no sensibles del enlace
|
||||
POST /cobro/pagar confirma el pago (simulado o pasarela real)
|
||||
POST /cobro/webhook/stripe notificación de Stripe (firma verificada)
|
||||
POST /cobro/webhook/mercadopago notificación de MercadoPago (secreto/token)
|
||||
|
||||
Reglas de oro:
|
||||
- El token es HMAC firmado con caducidad; sin token válido no se expone nada.
|
||||
- El pago en modo `simulado` SOLO se permite si el ajuste correspondiente
|
||||
(`reservas_online.anticipo.permitir_simulado` o
|
||||
`membresias.cobro_automatico.permitir_simulado`) está encendido. Nunca se
|
||||
simula un pago en producción con la configuración por defecto.
|
||||
- Límite de peticiones por IP y trampa antiautómatas (honeypot) como en el
|
||||
portal de reservas.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request, Response
|
||||
|
||||
from odoo.addons.skeen_citas.clinic_settings import (
|
||||
get_settings as get_clinic_settings,
|
||||
get_anticipo,
|
||||
get_cobro_automatico,
|
||||
brand_dict,
|
||||
)
|
||||
from odoo.addons.skeen_citas.glosario import resolve as resolver_glosario
|
||||
|
||||
PREFIX = '/skeen/frontend/v1/public/cobro'
|
||||
|
||||
RATE_LIMITS = {'get': (60, 60), 'pagar': (10, 60), 'webhook': (120, 60)}
|
||||
_RATE_LOCK = threading.Lock()
|
||||
_RATE_HITS = {}
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_ERROR_INTERNO = 'No se pudo procesar la solicitud. Intenta de nuevo.'
|
||||
|
||||
|
||||
def _json(data, status=200):
|
||||
return Response(
|
||||
json.dumps(data, default=str),
|
||||
status=status,
|
||||
mimetype='application/json',
|
||||
headers={
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Stripe-Signature',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _client_ip():
|
||||
xff = request.httprequest.headers.get('X-Forwarded-For') or ''
|
||||
if xff:
|
||||
return xff.split(',')[0].strip()
|
||||
return request.httprequest.remote_addr or 'desconocida'
|
||||
|
||||
|
||||
def _rate_limited(bucket):
|
||||
limit, window = RATE_LIMITS.get(bucket, (30, 60))
|
||||
key = (_client_ip(), bucket)
|
||||
ahora = time.time()
|
||||
with _RATE_LOCK:
|
||||
hits = [t for t in _RATE_HITS.get(key, []) if ahora - t < window]
|
||||
if len(hits) >= limit:
|
||||
_RATE_HITS[key] = hits
|
||||
return True
|
||||
hits.append(ahora)
|
||||
_RATE_HITS[key] = hits
|
||||
return False
|
||||
|
||||
|
||||
class SkeenPublicCobroController(http.Controller):
|
||||
"""Página pública de pago de un enlace."""
|
||||
|
||||
def _service(self):
|
||||
return request.env['skeen.cobro.service'].sudo()
|
||||
|
||||
def _settings(self):
|
||||
return get_clinic_settings(request.env)
|
||||
|
||||
def _brand(self):
|
||||
guardado = brand_dict(request.env)
|
||||
out = {
|
||||
'nombre': self._settings().get('nombre') or 'Clínica',
|
||||
'logo_claro': '/brand/logo-blanco.svg',
|
||||
'logo_oscuro': '/brand/logo-negro.svg',
|
||||
'colores': {'primario': '', 'acento': ''},
|
||||
}
|
||||
if isinstance(guardado, dict):
|
||||
if guardado.get('nombre'):
|
||||
out['nombre'] = str(guardado['nombre'])
|
||||
for k in ('logo_claro', 'logo_oscuro'):
|
||||
if guardado.get(k):
|
||||
out[k] = str(guardado[k])
|
||||
colores = guardado.get('colores')
|
||||
if isinstance(colores, dict):
|
||||
for c in ('primario', 'acento'):
|
||||
if colores.get(c):
|
||||
out['colores'][c] = str(colores[c])
|
||||
return out
|
||||
|
||||
def _simulado_permitido(self, enlace):
|
||||
try:
|
||||
if enlace.origen == 'reserva':
|
||||
return bool(get_anticipo(request.env).get('permitir_simulado'))
|
||||
if enlace.origen == 'membresia':
|
||||
return bool(get_cobro_automatico(request.env).get('permitir_simulado'))
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _estado_label(enlace):
|
||||
return dict(enlace._fields['estado'].selection).get(enlace.estado, enlace.estado)
|
||||
|
||||
def _enlace_de_token(self, token):
|
||||
return self._service().verificar_token(token)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Datos del enlace (mínimos, no sensibles)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX, type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
|
||||
def cobro_get(self, **kw):
|
||||
if request.httprequest.method == 'OPTIONS':
|
||||
return _json({}, 200)
|
||||
if _rate_limited('get'):
|
||||
return _json({'status': 'error',
|
||||
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
|
||||
try:
|
||||
enlace = self._enlace_de_token(kw.get('token'))
|
||||
if not enlace:
|
||||
return _json({'status': 'error',
|
||||
'message': 'Enlace no válido o expirado'}, 404)
|
||||
settings = self._settings()
|
||||
_pack, terms = resolver_glosario(request.env)
|
||||
proveedor = self._service().proveedor_disponible()
|
||||
puede_pagar = (enlace.estado == 'pendiente' and
|
||||
(proveedor != 'simulado' or self._simulado_permitido(enlace)))
|
||||
return _json({
|
||||
'status': 'success',
|
||||
'folio': enlace.name,
|
||||
'concepto': enlace.concepto,
|
||||
'monto': enlace.monto,
|
||||
'moneda': enlace.currency_id.name or 'MXN',
|
||||
'estado': enlace.estado,
|
||||
'estado_label': self._estado_label(enlace),
|
||||
'origen': enlace.origen,
|
||||
'proveedor': enlace.proveedor,
|
||||
'expira': enlace.token_expira,
|
||||
'fecha_pago': enlace.fecha_pago,
|
||||
'paciente': (enlace.partner_id.name or '').split(' ')[0] if enlace.partner_id else '',
|
||||
'puede_pagar': bool(puede_pagar),
|
||||
'simulado': enlace.proveedor == 'simulado',
|
||||
'url_externa': enlace.url_externa or '',
|
||||
'brand': self._brand(),
|
||||
'terms': terms,
|
||||
'zona_horaria': settings.get('zona_horaria') or 'America/Tijuana',
|
||||
})
|
||||
except Exception as exc:
|
||||
_logger.exception('Error consultando enlace de cobro: %s', exc)
|
||||
return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Pagar
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX + '/pagar', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
def cobro_pagar(self, **kw):
|
||||
if request.httprequest.method == 'OPTIONS':
|
||||
return _json({}, 200)
|
||||
if _rate_limited('pagar'):
|
||||
return _json({'status': 'error',
|
||||
'message': 'Demasiadas solicitudes. Intenta más tarde.'}, 429)
|
||||
try:
|
||||
data = json.loads(request.httprequest.get_data(as_text=True) or '{}')
|
||||
except Exception:
|
||||
data = {}
|
||||
if str(data.get('hp') or '').strip():
|
||||
return _json({'status': 'error', 'message': 'Solicitud rechazada'}, 400)
|
||||
try:
|
||||
enlace = self._enlace_de_token(data.get('token') or kw.get('token'))
|
||||
if not enlace:
|
||||
return _json({'status': 'error',
|
||||
'message': 'Enlace no válido o expirado'}, 404)
|
||||
if enlace.estado == 'pagado':
|
||||
return _json({'status': 'error',
|
||||
'message': 'Este enlace ya fue pagado.'}, 409)
|
||||
if enlace.estado == 'expirado':
|
||||
return _json({'status': 'error',
|
||||
'message': 'Este enlace expiró.'}, 410)
|
||||
if enlace.estado == 'cancelado':
|
||||
return _json({'status': 'error',
|
||||
'message': 'Este enlace fue cancelado.'}, 409)
|
||||
|
||||
proveedor = self._service().proveedor_disponible()
|
||||
if proveedor != 'simulado':
|
||||
# Camino real: se crea/recupera el checkout de la pasarela.
|
||||
if not enlace.url_externa:
|
||||
self._service().intentar_checkout(enlace)
|
||||
if enlace.url_externa:
|
||||
return _json({'status': 'success', 'requiere_pasarela': True,
|
||||
'url': enlace.url_externa, 'proveedor': proveedor})
|
||||
return _json({'status': 'error',
|
||||
'message': 'La pasarela no está disponible. Contacta a la clínica.'}, 502)
|
||||
|
||||
# Camino simulado (sin pasarela).
|
||||
if not self._simulado_permitido(enlace):
|
||||
return _json({'status': 'error',
|
||||
'message': 'El pago en línea no está disponible. '
|
||||
'Contacta a la clínica.'}, 403)
|
||||
pago = self._service().confirmar(
|
||||
enlace, proveedor='simulado', referencia='SIMULADO-%s' % enlace.id,
|
||||
response='Pago simulado desde la página pública', user='Portal en línea')
|
||||
if not pago:
|
||||
return _json({'status': 'error',
|
||||
'message': 'No se pudo confirmar el pago.'}, 409)
|
||||
return _json({
|
||||
'status': 'success',
|
||||
'ok': True,
|
||||
'estado': 'pagado',
|
||||
'estado_label': self._estado_label(enlace),
|
||||
'comprobante': {
|
||||
'folio': enlace.name,
|
||||
'concepto': enlace.concepto,
|
||||
'monto': enlace.monto,
|
||||
'moneda': enlace.currency_id.name or 'MXN',
|
||||
'fecha': enlace.fecha_pago,
|
||||
'proveedor': 'simulado',
|
||||
'pago': pago.name,
|
||||
},
|
||||
})
|
||||
except Exception as exc:
|
||||
request.env.cr.rollback()
|
||||
_logger.exception('Error pagando enlace de cobro: %s', exc)
|
||||
return _json({'status': 'error', 'message': _ERROR_INTERNO}, 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Webhooks (notificación de la pasarela)
|
||||
# ------------------------------------------------------------------
|
||||
@http.route(PREFIX + '/webhook/stripe', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
def webhook_stripe(self, **kw):
|
||||
if request.httprequest.method == 'OPTIONS':
|
||||
return _json({}, 200)
|
||||
secret = request.env['ir.config_parameter'].sudo().get_param(
|
||||
'skeen.stripe_webhook_secret')
|
||||
raw = request.httprequest.get_data() or b''
|
||||
if not secret:
|
||||
return _json({'status': 'error',
|
||||
'message': 'Webhook de Stripe no configurado'}, 400)
|
||||
firma = request.httprequest.headers.get('Stripe-Signature') or ''
|
||||
if not self._stripe_firma_valida(raw, firma, secret):
|
||||
return _json({'status': 'error', 'message': 'Firma inválida'}, 400)
|
||||
try:
|
||||
evento = json.loads(raw.decode('utf-8') or '{}')
|
||||
except Exception:
|
||||
return _json({'status': 'error', 'message': 'Cuerpo inválido'}, 400)
|
||||
tipo = evento.get('type') or ''
|
||||
obj = ((evento.get('data') or {}).get('object') or {})
|
||||
if tipo in ('checkout.session.completed', 'payment_intent.succeeded'):
|
||||
meta = obj.get('metadata') or {}
|
||||
enlace_id = int(meta.get('enlace_id') or 0)
|
||||
enlace = request.env['skeen.cobro.enlace'].sudo().browse(enlace_id)
|
||||
if enlace.exists():
|
||||
self._service().confirmar(
|
||||
enlace, proveedor='stripe',
|
||||
referencia=obj.get('id') or obj.get('payment_intent') or '',
|
||||
response=json.dumps(obj)[:2000], user='Webhook Stripe')
|
||||
return _json({'status': 'success'})
|
||||
|
||||
@staticmethod
|
||||
def _stripe_firma_valida(raw, header, secret):
|
||||
partes = {}
|
||||
for item in (header or '').split(','):
|
||||
if '=' in item:
|
||||
k, v = item.split('=', 1)
|
||||
partes.setdefault(k.strip(), v.strip())
|
||||
t = partes.get('t')
|
||||
v1 = partes.get('v1')
|
||||
if not t or not v1:
|
||||
return False
|
||||
firmado = ('%s.%s' % (t, raw.decode('utf-8'))).encode('utf-8')
|
||||
esperada = hmac.new(secret.encode('utf-8'), firmado, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(esperada, v1)
|
||||
|
||||
@http.route(PREFIX + '/webhook/mercadopago', type='http', auth='none',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
def webhook_mercadopago(self, **kw):
|
||||
if request.httprequest.method == 'OPTIONS':
|
||||
return _json({}, 200)
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
token = ICP.get_param('skeen.mercadopago_access_token')
|
||||
secret = ICP.get_param('skeen.mercadopago_webhook_secret')
|
||||
raw = request.httprequest.get_data() or b''
|
||||
try:
|
||||
cuerpo = json.loads(raw.decode('utf-8') or '{}')
|
||||
except Exception:
|
||||
cuerpo = {}
|
||||
data_id = (cuerpo.get('data') or {}).get('id') or request.httprequest.args.get('data.id') or ''
|
||||
tipo = cuerpo.get('type') or request.httprequest.args.get('type') or ''
|
||||
if not token and not secret:
|
||||
return _json({'status': 'error',
|
||||
'message': 'Webhook de MercadoPago no configurado'}, 400)
|
||||
# Verificación por secreto (x-signature) cuando está configurado.
|
||||
if secret:
|
||||
firma = request.httprequest.headers.get('x-signature') or ''
|
||||
if not self._mp_firma_valida(request.httprequest.args.get('data.id') or '', firma, secret):
|
||||
return _json({'status': 'error', 'message': 'Firma inválida'}, 400)
|
||||
elif tipo != 'payment' or not data_id:
|
||||
return _json({'status': 'error', 'message': 'Notificación inválida'}, 400)
|
||||
# Con token, se confirma el pago real consultando la API de MercadoPago.
|
||||
if tipo == 'payment' and data_id and token:
|
||||
try:
|
||||
import requests
|
||||
resp = requests.get('https://api.mercadopago.com/v1/payments/%s' % data_id,
|
||||
headers={'Authorization': 'Bearer %s' % token}, timeout=30)
|
||||
pago = resp.json() if resp.status_code == 200 else {}
|
||||
estado = pago.get('status')
|
||||
ext = (pago.get('metadata') or {}).get('enlace_id')
|
||||
if estado == 'approved' and ext:
|
||||
enlace = request.env['skeen.cobro.enlace'].sudo().browse(int(ext))
|
||||
if enlace.exists():
|
||||
self._service().confirmar(
|
||||
enlace, proveedor='mercadopago', referencia=str(data_id),
|
||||
response=json.dumps(pago)[:2000], user='Webhook MercadoPago')
|
||||
except Exception as exc:
|
||||
_logger.warning('Webhook MercadoPago: %s', exc)
|
||||
return _json({'status': 'error', 'message': 'No se pudo verificar'}, 502)
|
||||
return _json({'status': 'success'})
|
||||
|
||||
@staticmethod
|
||||
def _mp_firma_valida(data_id, header, secret):
|
||||
partes = {}
|
||||
for item in (header or '').split(','):
|
||||
if '=' in item:
|
||||
k, v = item.split('=', 1)
|
||||
partes[k.strip()] = v.strip()
|
||||
ts = partes.get('ts')
|
||||
v1 = partes.get('v1')
|
||||
if not ts or not v1:
|
||||
return False
|
||||
manifiesto = 'id:%s;ts:%s;' % (data_id, ts)
|
||||
esperada = hmac.new(secret.encode('utf-8'), manifiesto.encode('utf-8'),
|
||||
hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(esperada, v1)
|
||||
12
odoo-addons/skeen_cobros/data/cobro_sequence.xml
Normal file
12
odoo-addons/skeen_cobros/data/cobro_sequence.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="seq_skeen_cobro_enlace" model="ir.sequence">
|
||||
<field name="name">Enlaces de cobro SKEEN</field>
|
||||
<field name="code">skeen.cobro.enlace</field>
|
||||
<field name="prefix">COBRO-</field>
|
||||
<field name="padding">5</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
20
odoo-addons/skeen_cobros/data/cobros_cron.xml
Normal file
20
odoo-addons/skeen_cobros/data/cobros_cron.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Expiración de enlaces de cobro. Marca expirados los enlaces vencidos
|
||||
y aplica su consecuencia: cancela la cita retenida (libera el
|
||||
horario) o avisa al periodo de la membresía. Con los ajustes por
|
||||
defecto (anticipo apagado / cobro_automatico apagado) no hay
|
||||
enlaces pendientes y no hace nada. -->
|
||||
<record id="cron_cobros_expirar" model="ir.cron">
|
||||
<field name="name">SKEEN Cobros: expirar enlaces y liberar retenciones</field>
|
||||
<field name="model_id" ref="model_skeen_cobro_service"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_tick()</field>
|
||||
<field name="interval_number">15</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
3
odoo-addons/skeen_cobros/models/__init__.py
Normal file
3
odoo-addons/skeen_cobros/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import cobro_enlace
|
||||
624
odoo-addons/skeen_cobros/models/cobro_enlace.py
Normal file
624
odoo-addons/skeen_cobros/models/cobro_enlace.py
Normal file
@@ -0,0 +1,624 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Enlaces de cobro reutilizables (anticipos y membresías).
|
||||
|
||||
Un ENLACE DE COBRO es una promesa de pago con un token firmado y caducidad.
|
||||
Lo usan dos flujos:
|
||||
|
||||
- `reserva`: anticipo de una reserva creada en el portal público. La cita
|
||||
queda retenida (`anticipo_estado='pendiente'`) hasta que se paga; si el
|
||||
enlace expira, el cron cancela la cita y libera el horario.
|
||||
- `membresia`: cobro automático del periodo de una membresía. Al pagarse, el
|
||||
periodo pasa a `pagado` y la membresía se renueva.
|
||||
|
||||
Regla de oro: NADA de esto se activa solo. El anticipo vive dentro de
|
||||
`reservas_online.anticipo` y el cobro de membresías dentro de
|
||||
`membresias.cobro_automatico`; ambos con `activo=false` por defecto.
|
||||
|
||||
Al confirmarse un pago se crea un `skeen.pago` REAL (state='completed', con
|
||||
`payment_date`) ligado a la cita y a la venta, de modo que el corte de caja y
|
||||
los reportes lo ven como cualquier otro cobro.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
ORIGENES = [
|
||||
('reserva', 'Reserva en línea'),
|
||||
('membresia', 'Membresía'),
|
||||
('venta', 'Venta'),
|
||||
]
|
||||
ESTADOS = [
|
||||
('pendiente', 'Pendiente'),
|
||||
('pagado', 'Pagado'),
|
||||
('expirado', 'Expirado'),
|
||||
('cancelado', 'Cancelado'),
|
||||
]
|
||||
PROVEEDORES = [
|
||||
('simulado', 'Simulado (sin pasarela)'),
|
||||
('stripe', 'Stripe'),
|
||||
('mercadopago', 'MercadoPago'),
|
||||
]
|
||||
|
||||
SECRET_KEY = 'skeen.frontend.secret'
|
||||
TOKEN_KIND = 'cobro'
|
||||
TTL_DEFAULT_HORAS = 24
|
||||
TTL_MAX_HORAS = 720 # 30 días
|
||||
|
||||
# Método de `skeen.pago` con el que se registra cada proveedor. El simulado se
|
||||
# registra como tarjeta (es un cobro en línea ficticio) para el corte de caja.
|
||||
METODO_PAGO = {
|
||||
'stripe': 'stripe',
|
||||
'mercadopago': 'mercadopago',
|
||||
'simulado': 'stripe',
|
||||
}
|
||||
|
||||
|
||||
class SkeenCobroEnlace(models.Model):
|
||||
_name = 'skeen.cobro.enlace'
|
||||
_description = 'Enlace de cobro'
|
||||
_order = 'create_date desc, id desc'
|
||||
|
||||
name = fields.Char(string='Folio', readonly=True, copy=False, index=True)
|
||||
origen = fields.Selection(ORIGENES, string='Origen', required=True, index=True)
|
||||
estado = fields.Selection(ESTADOS, string='Estado', default='pendiente',
|
||||
required=True, index=True)
|
||||
proveedor = fields.Selection(PROVEEDORES, string='Proveedor', default='simulado',
|
||||
required=True)
|
||||
|
||||
# Documentos ligados
|
||||
partner_id = fields.Many2one('res.partner', string='Paciente', index=True,
|
||||
ondelete='set null')
|
||||
cita_id = fields.Many2one('skeen.cita', string='Cita', index=True,
|
||||
ondelete='set null')
|
||||
venta_id = fields.Many2one('skeen.venta', string='Venta', index=True,
|
||||
ondelete='set null')
|
||||
pago_id = fields.Many2one('skeen.pago', string='Pago', ondelete='set null')
|
||||
|
||||
# Monto y concepto
|
||||
concepto = fields.Char(string='Concepto', required=True)
|
||||
monto = fields.Float(string='Monto', required=True, default=0.0)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', string='Moneda',
|
||||
default=lambda self: self.env.ref('base.MXN', raise_if_not_found=False))
|
||||
|
||||
# Token y vigencia
|
||||
token = fields.Char(string='Token', index=True, copy=False)
|
||||
token_expira = fields.Datetime(string='Vence el', index=True)
|
||||
url = fields.Char(string='URL de pago')
|
||||
url_externa = fields.Char(string='URL en la pasarela')
|
||||
|
||||
# Respuesta del proveedor
|
||||
provider_reference = fields.Char(string='Referencia de la pasarela')
|
||||
provider_response = fields.Text(string='Respuesta de la pasarela')
|
||||
|
||||
# Fechas
|
||||
fecha_pago = fields.Datetime(string='Pagado el', readonly=True, copy=False)
|
||||
enviado_at = fields.Datetime(string='Enviado el', copy=False)
|
||||
ultimo_envio = fields.Datetime(string='Último aviso', copy=False)
|
||||
recordatorios = fields.Integer(string='Avisos enviados', default=0)
|
||||
notas = fields.Text(string='Notas')
|
||||
|
||||
log_ids = fields.One2many('skeen.cobro.enlace.log', 'enlace_id',
|
||||
string='Bitácora')
|
||||
|
||||
_sql_constraints = [
|
||||
('token_uniq', 'unique(token)', 'El token del enlace de cobro debe ser único.'),
|
||||
]
|
||||
|
||||
def _log(self, evento, detalle='', user=''):
|
||||
"""Registra un evento en la bitácora. Nunca tumba la operación."""
|
||||
try:
|
||||
self.env['skeen.cobro.enlace.log'].sudo().create({
|
||||
'enlace_id': self.id,
|
||||
'evento': evento,
|
||||
'detalle': detalle or '',
|
||||
'user_name': user or '',
|
||||
})
|
||||
except Exception:
|
||||
_logger.exception('No se pudo registrar la bitácora del enlace %s', self.id)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('name'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code(
|
||||
'skeen.cobro.enlace') or 'COBRO-001'
|
||||
return super().create(vals_list)
|
||||
|
||||
def action_cancelar(self, motivo='', user=''):
|
||||
"""Cancela el enlace pendiente y libera la retención asociada."""
|
||||
svc = self.env['skeen.cobro.service']
|
||||
for enlace in self:
|
||||
if enlace.estado != 'pendiente':
|
||||
continue
|
||||
enlace.write({'estado': 'cancelado'})
|
||||
enlace._log('cancelado', motivo or 'Cancelado manualmente', user=user)
|
||||
try:
|
||||
svc._on_expirado(enlace)
|
||||
except Exception:
|
||||
_logger.exception('No se pudo liberar la retención del enlace %s', enlace.id)
|
||||
return True
|
||||
|
||||
|
||||
class SkeenCobroEnlaceLog(models.Model):
|
||||
_name = 'skeen.cobro.enlace.log'
|
||||
_description = 'Bitácora de enlace de cobro'
|
||||
_order = 'create_date desc, id desc'
|
||||
|
||||
enlace_id = fields.Many2one('skeen.cobro.enlace', string='Enlace',
|
||||
required=True, ondelete='cascade', index=True)
|
||||
evento = fields.Char(string='Evento')
|
||||
detalle = fields.Text(string='Detalle')
|
||||
user_name = fields.Char(string='Usuario')
|
||||
create_date = fields.Datetime(string='Fecha', readonly=True)
|
||||
|
||||
|
||||
class SkeenCobroService(models.AbstractModel):
|
||||
"""Servicio único de enlaces de cobro (crear, firmar, confirmar, expirar)."""
|
||||
_name = 'skeen.cobro.service'
|
||||
_description = 'Servicio de enlaces de cobro'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Token firmado
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _secreto(self):
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
secret = ICP.get_param(SECRET_KEY)
|
||||
if not secret:
|
||||
secret = secrets.token_urlsafe(48)
|
||||
ICP.set_param(SECRET_KEY, secret)
|
||||
return secret
|
||||
|
||||
@api.model
|
||||
def _b64(self, raw):
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
|
||||
|
||||
@api.model
|
||||
def _b64d(self, texto):
|
||||
return base64.urlsafe_b64decode(texto + '=' * (-len(texto) % 4))
|
||||
|
||||
@api.model
|
||||
def firmar_token(self, enlace):
|
||||
payload = {'eid': enlace.id, 'kind': TOKEN_KIND,
|
||||
'exp': int(time.time()) + int(
|
||||
(enlace.token_expira - fields.Datetime.now()).total_seconds())}
|
||||
body = self._b64(json.dumps(payload, separators=(',', ':')).encode('utf-8'))
|
||||
sig = hmac.new(self._secreto().encode('utf-8'), body.encode('ascii'),
|
||||
hashlib.sha256).hexdigest()
|
||||
return '%s.%s' % (body, sig)
|
||||
|
||||
@api.model
|
||||
def verificar_token(self, token):
|
||||
"""Devuelve el enlace del token o None si es inválido/expirado/manipulado."""
|
||||
if not token or '.' not in str(token):
|
||||
return None
|
||||
try:
|
||||
body, sig = str(token).rsplit('.', 1)
|
||||
esperada = hmac.new(self._secreto().encode('utf-8'), body.encode('ascii'),
|
||||
hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(sig, esperada):
|
||||
return None
|
||||
payload = json.loads(self._b64d(body).decode('utf-8'))
|
||||
except Exception:
|
||||
return None
|
||||
if payload.get('kind') != TOKEN_KIND:
|
||||
return None
|
||||
if int(payload.get('exp', 0)) < int(time.time()):
|
||||
return None
|
||||
enlace = self.env['skeen.cobro.enlace'].sudo().browse(int(payload.get('eid') or 0))
|
||||
if not enlace.exists():
|
||||
return None
|
||||
# El token guardado debe coincidir (defensa en profundidad).
|
||||
if enlace.token and not hmac.compare_digest(str(enlace.token), str(token)):
|
||||
return None
|
||||
return enlace
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Proveedor / URL
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def proveedor_disponible(self):
|
||||
"""'stripe' | 'mercadopago' | 'simulado' según las credenciales ICP.
|
||||
|
||||
Sin llaves configuradas devuelve 'simulado': el enlace se puede probar
|
||||
por el camino explícito de simulación (nunca cobra dinero real).
|
||||
"""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if ICP.get_param('skeen.stripe_secret_key'):
|
||||
return 'stripe'
|
||||
if ICP.get_param('skeen.mercadopago_access_token'):
|
||||
return 'mercadopago'
|
||||
return 'simulado'
|
||||
|
||||
@api.model
|
||||
def url_publica(self):
|
||||
from odoo.addons.skeen_citas.clinic_settings import url_publica
|
||||
base = url_publica(self.env, fallback='')
|
||||
if not base:
|
||||
base = self.env['ir.config_parameter'].sudo().get_param('web.base.url') or ''
|
||||
return (base or '').rstrip('/')
|
||||
|
||||
@api.model
|
||||
def url_pago_token(self, token):
|
||||
return '%s/pagar/%s' % (self.url_publica(), token)
|
||||
|
||||
@api.model
|
||||
def _currency(self, moneda):
|
||||
codigo = (moneda or 'MXN').upper()
|
||||
cur = self.env['res.currency'].sudo().search([('name', '=', codigo)], limit=1)
|
||||
if cur:
|
||||
return cur
|
||||
return self.env.ref('base.MXN', raise_if_not_found=False) or self.env['res.currency'].sudo().search([], limit=1)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Crear
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def crear(self, origen, monto, concepto, ttl_horas=None, partner=None,
|
||||
cita=None, moneda='MXN', notas=''):
|
||||
"""Crea un enlace de cobro pendiente con su token y URL.
|
||||
|
||||
No intenta crear el checkout real (eso lo hace `intentar_checkout`),
|
||||
para que la reserva no dependa de la pasarela.
|
||||
"""
|
||||
monto = round(float(monto or 0.0), 2)
|
||||
horas = int(ttl_horas or TTL_DEFAULT_HORAS)
|
||||
horas = min(max(horas, 1), TTL_MAX_HORAS)
|
||||
partner = partner or (cita.partner_id if cita else None)
|
||||
enlace = self.env['skeen.cobro.enlace'].sudo().create({
|
||||
'origen': origen,
|
||||
'estado': 'pendiente',
|
||||
'proveedor': self.proveedor_disponible(),
|
||||
'partner_id': partner.id if partner else False,
|
||||
'cita_id': cita.id if cita else False,
|
||||
'concepto': (concepto or '')[:200],
|
||||
'monto': monto,
|
||||
'currency_id': self._currency(moneda).id,
|
||||
'notas': notas or '',
|
||||
'token_expira': fields.Datetime.now() + timedelta(hours=horas),
|
||||
})
|
||||
token = self.firmar_token(enlace)
|
||||
enlace.write({'token': token, 'url': self.url_pago_token(token)})
|
||||
enlace._log('creado', 'Enlace creado por %s (%s %s)' % (
|
||||
origen, enlace.currency_id.name or '', monto))
|
||||
return enlace
|
||||
|
||||
@api.model
|
||||
def intentar_checkout(self, enlace):
|
||||
"""Crea la sesión en la pasarela real. Sin credenciales no hace nada.
|
||||
|
||||
Devuelve True si quedó una URL externa usable. Nunca lanza hacia el
|
||||
llamador (la reserva/cobro no debe caerse por la pasarela).
|
||||
"""
|
||||
enlace = enlace.exists()
|
||||
if not enlace or enlace.proveedor == 'simulado':
|
||||
return False
|
||||
try:
|
||||
if enlace.proveedor == 'stripe':
|
||||
return self._checkout_stripe(enlace)
|
||||
if enlace.proveedor == 'mercadopago':
|
||||
return self._checkout_mercadopago(enlace)
|
||||
except Exception as exc:
|
||||
_logger.warning('No se pudo crear el checkout %s: %s', enlace.proveedor, exc)
|
||||
enlace.write({'provider_response': str(exc)[:2000]})
|
||||
return False
|
||||
|
||||
@api.model
|
||||
def _checkout_stripe(self, enlace):
|
||||
import requests
|
||||
key = self.env['ir.config_parameter'].sudo().get_param('skeen.stripe_secret_key')
|
||||
if not key:
|
||||
return False
|
||||
base = self.url_publica()
|
||||
data = {
|
||||
'mode': 'payment',
|
||||
'payment_method_types[]': 'card',
|
||||
'line_items[0][price_data][currency]': (enlace.currency_id.name or 'MXN').lower(),
|
||||
'line_items[0][price_data][product_data][name]': enlace.concepto or 'Cobro',
|
||||
'line_items[0][price_data][unit_amount]': int(round(enlace.monto * 100)),
|
||||
'line_items[0][quantity]': 1,
|
||||
'success_url': '%s/pagar/%s?estado=pagado' % (base, enlace.token),
|
||||
'cancel_url': '%s/pagar/%s?estado=cancelado' % (base, enlace.token),
|
||||
'metadata[enlace_id]': str(enlace.id),
|
||||
}
|
||||
resp = requests.post('https://api.stripe.com/v1/checkout/sessions',
|
||||
headers={'Authorization': 'Bearer %s' % key}, data=data, timeout=30)
|
||||
if resp.status_code != 200:
|
||||
enlace.write({'provider_response': resp.text[:2000]})
|
||||
return False
|
||||
sesion = resp.json()
|
||||
enlace.write({'url_externa': sesion.get('url'), 'provider_reference': sesion.get('id'),
|
||||
'provider_response': json.dumps(sesion)[:2000]})
|
||||
return bool(sesion.get('url'))
|
||||
|
||||
@api.model
|
||||
def _checkout_mercadopago(self, enlace):
|
||||
import requests
|
||||
token = self.env['ir.config_parameter'].sudo().get_param('skeen.mercadopago_access_token')
|
||||
if not token:
|
||||
return False
|
||||
base = self.url_publica()
|
||||
data = {
|
||||
'items': [{'title': enlace.concepto or 'Cobro', 'quantity': 1,
|
||||
'unit_price': float(enlace.monto)}],
|
||||
'back_urls': {'success': '%s/pagar/%s?estado=pagado' % (base, enlace.token),
|
||||
'pending': '%s/pagar/%s' % (base, enlace.token),
|
||||
'failure': '%s/pagar/%s?estado=cancelado' % (base, enlace.token)},
|
||||
'notification_url': '%s/skeen/frontend/v1/public/cobro/webhook/mercadopago' % base,
|
||||
'external_reference': enlace.token,
|
||||
'metadata': {'enlace_id': enlace.id},
|
||||
}
|
||||
resp = requests.post('https://api.mercadopago.com/checkout/preferences',
|
||||
headers={'Authorization': 'Bearer %s' % token}, json=data, timeout=30)
|
||||
if resp.status_code not in (200, 201):
|
||||
enlace.write({'provider_response': resp.text[:2000]})
|
||||
return False
|
||||
pref = resp.json()
|
||||
enlace.write({'url_externa': pref.get('init_point'), 'provider_reference': pref.get('id'),
|
||||
'provider_response': json.dumps(pref)[:2000]})
|
||||
return bool(pref.get('init_point'))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Confirmar pago
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def confirmar(self, enlace, proveedor=None, referencia='', monto=None,
|
||||
response='', user='', force=False):
|
||||
"""Marca el enlace como pagado y registra el pago real.
|
||||
|
||||
Idempotente: un enlace ya pagado NO se vuelve a cobrar (devuelve False).
|
||||
Un enlace expirado/cancelado solo se puede forzar con `force=True`
|
||||
(camino de la pantalla de personal, nunca del portal público).
|
||||
"""
|
||||
enlace = enlace.exists()
|
||||
if not enlace:
|
||||
return False
|
||||
if enlace.estado == 'pagado':
|
||||
return False
|
||||
if enlace.estado in ('expirado', 'cancelado') and not force:
|
||||
return False
|
||||
monto = round(float(monto), 2) if monto not in (None, '') else enlace.monto
|
||||
proveedor = proveedor or enlace.proveedor
|
||||
pago = self._registrar_pago(enlace, proveedor, monto, referencia, response)
|
||||
enlace.write({
|
||||
'estado': 'pagado',
|
||||
'fecha_pago': fields.Datetime.now(),
|
||||
'pago_id': pago.id,
|
||||
'proveedor': proveedor,
|
||||
'provider_reference': referencia or enlace.provider_reference,
|
||||
'provider_response': (response or enlace.provider_response or '')[:2000],
|
||||
})
|
||||
enlace._log('pagado', 'Pago %s por %s (%s)%s' % (
|
||||
pago.name, monto, proveedor, ' · forzado' if force else ''), user=user)
|
||||
try:
|
||||
self._post_pago(enlace, pago)
|
||||
except Exception:
|
||||
_logger.exception('No se pudo aplicar el post-pago del enlace %s', enlace.id)
|
||||
return pago
|
||||
|
||||
@api.model
|
||||
def _registrar_pago(self, enlace, proveedor, monto, referencia, response):
|
||||
"""Crea la venta (si falta) y el `skeen.pago` completado."""
|
||||
venta = enlace.venta_id
|
||||
if not venta:
|
||||
venta = self._crear_venta(enlace)
|
||||
metodo = METODO_PAGO.get(proveedor, 'stripe')
|
||||
pago = self.env['skeen.pago'].sudo().create({
|
||||
'partner_id': enlace.partner_id.id,
|
||||
'cita_id': enlace.cita_id.id if enlace.cita_id else False,
|
||||
'venta_id': venta.id if venta else False,
|
||||
'amount': monto,
|
||||
'currency_id': enlace.currency_id.id,
|
||||
'payment_method': metodo,
|
||||
'state': 'completed',
|
||||
'payment_date': fields.Datetime.now(),
|
||||
'provider_reference': referencia or False,
|
||||
'provider_response': (response or '')[:2000] or False,
|
||||
})
|
||||
if venta:
|
||||
pagado = (venta.amount_paid or 0.0) + monto
|
||||
venta.write({
|
||||
'amount_paid': pagado,
|
||||
'state': 'paid' if pagado + 1e-6 >= venta.total else 'partial',
|
||||
})
|
||||
enlace.write({'venta_id': venta.id if venta else False})
|
||||
return pago
|
||||
|
||||
@api.model
|
||||
def _crear_venta(self, enlace):
|
||||
"""Venta del anticipo/concepto. Se liga a la cita cuando existe.
|
||||
|
||||
La línea de `skeen.venta.line` EXIGE un servicio o un artículo. Si el
|
||||
enlace no tiene servicio asociado (caso raro de origen 'venta') no se
|
||||
crea venta: el pago igual queda registrado en el corte de caja.
|
||||
"""
|
||||
cita = enlace.cita_id
|
||||
servicio = cita.servicio_id if cita else False
|
||||
if not servicio:
|
||||
return self.env['skeen.venta']
|
||||
vals = {
|
||||
'partner_id': enlace.partner_id.id,
|
||||
'date': fields.Date.today(),
|
||||
'notes': _('Cobro en línea %s') % (enlace.name or ''),
|
||||
'line_ids': [(0, 0, {
|
||||
'service_id': servicio.id,
|
||||
'description': enlace.concepto or 'Cobro en línea',
|
||||
'quantity': 1.0,
|
||||
'price_unit': enlace.monto,
|
||||
})],
|
||||
}
|
||||
if cita:
|
||||
vals['cita_id'] = cita.id
|
||||
venta = self.env['skeen.venta'].sudo().create(vals)
|
||||
venta.action_confirm()
|
||||
return venta
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Post-pago por origen
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _post_pago(self, enlace, pago):
|
||||
if enlace.origen == 'reserva' and enlace.cita_id:
|
||||
self._confirmar_reserva(enlace, pago)
|
||||
elif enlace.origen == 'membresia':
|
||||
self._confirmar_membresia(enlace, pago)
|
||||
elif enlace.origen == 'venta' and enlace.venta_id:
|
||||
pass
|
||||
|
||||
@api.model
|
||||
def _confirmar_reserva(self, enlace, pago):
|
||||
"""Confirma la cita retenida y refleja el anticipo pagado."""
|
||||
cita = enlace.cita_id
|
||||
if not cita:
|
||||
return
|
||||
vals = {'anticipo_estado': 'pagado'}
|
||||
if enlace.monto:
|
||||
vals['amount_paid'] = (cita.amount_paid or 0.0) + enlace.monto
|
||||
if cita.state == 'pending':
|
||||
cita.write(vals)
|
||||
cita.set_state('confirmed', user='Portal en línea',
|
||||
note='Anticipo pagado (%s)' % (enlace.name or ''))
|
||||
else:
|
||||
cita.write(vals)
|
||||
precio = cita.servicio_price or 0.0
|
||||
if precio and cita.amount_paid + 1e-6 >= precio:
|
||||
cita.write({'payment_state': 'paid'})
|
||||
elif cita.payment_state != 'paid':
|
||||
cita.write({'payment_state': 'partial'})
|
||||
try:
|
||||
cita.message_post(body='Anticipo pagado en línea: %s %s (enlace %s).' % (
|
||||
enlace.currency_id.name or '', enlace.monto, enlace.name))
|
||||
except Exception:
|
||||
pass
|
||||
self.notificar_confirmacion_reserva(cita)
|
||||
|
||||
@api.model
|
||||
def notificar_confirmacion_reserva(self, cita):
|
||||
"""Encola la confirmación al pagarse el anticipo (soft-ref a WhatsApp)."""
|
||||
from odoo.addons.skeen_citas.clinic_settings import get_notificaciones
|
||||
cfg = get_notificaciones(self.env)
|
||||
if not (cfg.get('activo') and cfg.get('confirmacion_al_crear')):
|
||||
return False
|
||||
try:
|
||||
service = self.env['skeen.notificacion.service'].sudo()
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
creadas = service.encolar(cita, 'confirmacion')
|
||||
for notif in creadas:
|
||||
service._intentar(notif, cfg)
|
||||
return bool(creadas)
|
||||
except Exception:
|
||||
_logger.exception('No se pudo avisar la confirmación del anticipo')
|
||||
return False
|
||||
|
||||
@api.model
|
||||
def reembolsar_anticipo(self, cita):
|
||||
"""Reembolsa el anticipo de una cita cancelada si la config lo permite.
|
||||
|
||||
Comportamiento elegido: si `reembolsable=True` se marca el `skeen.pago`
|
||||
como 'refunded' y la cita como `anticipo_estado='reembolsado'` (NO se
|
||||
revierten puntos de monedero, porque el anticipo no los generó). Si
|
||||
`reembolsable=False` el anticipo se retiene y se deja bitácora.
|
||||
"""
|
||||
from odoo.addons.skeen_citas.clinic_settings import get_anticipo
|
||||
cfg = get_anticipo(self.env)
|
||||
if not cfg.get('activo'):
|
||||
return False
|
||||
enlaces = self.env['skeen.cobro.enlace'].sudo().search([
|
||||
('cita_id', '=', cita.id), ('origen', '=', 'reserva'),
|
||||
('estado', '=', 'pagado')])
|
||||
hecho = False
|
||||
for enlace in enlaces:
|
||||
if not cfg.get('reembolsable', True):
|
||||
enlace._log('reembolso_omitido', 'El anticipo no es reembolsable')
|
||||
continue
|
||||
if enlace.pago_id and enlace.pago_id.state != 'refunded':
|
||||
enlace.pago_id.write({'state': 'refunded'})
|
||||
enlace._log('reembolsado', 'Anticipo reembolsado por cancelación')
|
||||
cita.write({'anticipo_estado': 'reembolsado'})
|
||||
hecho = True
|
||||
return hecho
|
||||
|
||||
@api.model
|
||||
def cancelar_enlaces_de_cita(self, cita, motivo=''):
|
||||
"""Cancela los enlaces pendientes de una cita (al cancelarla)."""
|
||||
enlaces = self.env['skeen.cobro.enlace'].sudo().search([
|
||||
('cita_id', '=', cita.id), ('estado', '=', 'pendiente')])
|
||||
for enlace in enlaces:
|
||||
enlace.write({'estado': 'cancelado'})
|
||||
enlace._log('cancelado', motivo or 'La cita se canceló')
|
||||
return len(enlaces)
|
||||
|
||||
@api.model
|
||||
def _confirmar_membresia(self, enlace, pago):
|
||||
"""Delega en el periodo de la membresía (definido en skeen_membresias)."""
|
||||
periodo_id = getattr(enlace, 'periodo_id', None)
|
||||
if periodo_id and hasattr(periodo_id, '_cobro_confirmado'):
|
||||
periodo_id._cobro_confirmado(enlace, pago)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Expiración (cron)
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _cron_tick(self):
|
||||
return self.expirar_vencidos()
|
||||
|
||||
@api.model
|
||||
def expirar_vencidos(self):
|
||||
"""Marca expirados los enlaces vencidos y aplica su consecuencia."""
|
||||
ahora = fields.Datetime.now()
|
||||
enlaces = self.env['skeen.cobro.enlace'].sudo().search([
|
||||
('estado', '=', 'pendiente'),
|
||||
('token_expira', '!=', False),
|
||||
('token_expira', '<', ahora),
|
||||
])
|
||||
total = 0
|
||||
for enlace in enlaces:
|
||||
enlace.write({'estado': 'expirado'})
|
||||
enlace._log('expirado', 'Venció el %s' % enlace.token_expira)
|
||||
try:
|
||||
self._on_expirado(enlace)
|
||||
except Exception:
|
||||
_logger.exception('No se pudo aplicar la expiración del enlace %s', enlace.id)
|
||||
total += 1
|
||||
return total
|
||||
|
||||
@api.model
|
||||
def _on_expirado(self, enlace):
|
||||
if enlace.origen == 'reserva' and enlace.cita_id:
|
||||
self._cancelar_reserva(enlace)
|
||||
elif enlace.origen == 'membresia':
|
||||
periodo_id = getattr(enlace, 'periodo_id', None)
|
||||
if periodo_id and hasattr(periodo_id, '_cobro_expirado'):
|
||||
periodo_id._cobro_expirado(enlace)
|
||||
|
||||
@api.model
|
||||
def _cancelar_reserva(self, enlace):
|
||||
"""Cancela la cita retenida y libera el horario."""
|
||||
cita = enlace.cita_id
|
||||
if not cita or cita.anticipo_estado != 'pendiente':
|
||||
return False
|
||||
if cita.state not in ('pending', 'confirmed'):
|
||||
return False
|
||||
cita.write({'cancellation_reason': 'Anticipo no pagado'})
|
||||
cita.set_state('cancelled', user='Sistema',
|
||||
note='Anticipo no pagado en %s' % (enlace.name or ''))
|
||||
cita.write({'anticipo_estado': 'ninguno'})
|
||||
try:
|
||||
cita.message_post(body='Reserva cancelada automáticamente: no se pagó el anticipo (%s).' % (enlace.name or ''))
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
3
odoo-addons/skeen_cobros/security/ir.model.access.csv
Normal file
3
odoo-addons/skeen_cobros/security/ir.model.access.csv
Normal file
@@ -0,0 +1,3 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_skeen_cobro_enlace_user,Acceso a enlaces de cobro,model_skeen_cobro_enlace,base.group_user,1,1,1,1
|
||||
access_skeen_cobro_enlace_log_user,Acceso a bitácora de cobros,model_skeen_cobro_enlace_log,base.group_user,1,1,1,1
|
||||
|
77
odoo-addons/skeen_cobros/views/cobro_views.xml
Normal file
77
odoo-addons/skeen_cobros/views/cobro_views.xml
Normal file
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_skeen_cobro_enlace_tree" model="ir.ui.view">
|
||||
<field name="name">skeen.cobro.enlace.tree</field>
|
||||
<field name="model">skeen.cobro.enlace</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="Enlaces de cobro" create="false">
|
||||
<field name="name"/>
|
||||
<field name="origen"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="concepto"/>
|
||||
<field name="monto"/>
|
||||
<field name="currency_id"/>
|
||||
<field name="proveedor"/>
|
||||
<field name="estado"/>
|
||||
<field name="token_expira"/>
|
||||
<field name="fecha_pago"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_skeen_cobro_enlace_form" model="ir.ui.view">
|
||||
<field name="name">skeen.cobro.enlace.form</field>
|
||||
<field name="model">skeen.cobro.enlace</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Enlace de cobro" create="false">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="origen"/>
|
||||
<field name="estado"/>
|
||||
<field name="proveedor"/>
|
||||
<field name="partner_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="concepto"/>
|
||||
<field name="monto"/>
|
||||
<field name="currency_id"/>
|
||||
<field name="token_expira"/>
|
||||
<field name="fecha_pago"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="cita_id"/>
|
||||
<field name="venta_id"/>
|
||||
<field name="pago_id"/>
|
||||
<field name="url"/>
|
||||
<field name="provider_reference"/>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Bitácora">
|
||||
<field name="log_ids">
|
||||
<tree create="false" edit="false">
|
||||
<field name="create_date"/>
|
||||
<field name="evento"/>
|
||||
<field name="detalle"/>
|
||||
<field name="user_name"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Respuesta de la pasarela">
|
||||
<field name="provider_response"/>
|
||||
</page>
|
||||
</notebook>
|
||||
<field name="notas"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_skeen_cobro_enlace" model="ir.actions.act_window">
|
||||
<field name="name">Enlaces de cobro</field>
|
||||
<field name="res_model">skeen.cobro.enlace</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -21,7 +21,8 @@
|
||||
""",
|
||||
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
|
||||
'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': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/sequences.xml',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import membresia
|
||||
from . import venta
|
||||
from . import cobro
|
||||
|
||||
250
odoo-addons/skeen_membresias/models/cobro.py
Normal file
250
odoo-addons/skeen_membresias/models/cobro.py
Normal file
@@ -0,0 +1,250 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Cobro automático de membresías con enlace de pago (bloque `cobro_automatico`).
|
||||
|
||||
Añade al enlace de cobro su liga con el periodo de membresía y el motor que,
|
||||
desde el cron diario, genera el enlace del periodo, avisa por el motor de
|
||||
notificaciones (con reintentos) y renueva la membresía al pagarse.
|
||||
|
||||
Con `membresias.cobro_automatico.activo=False` (default) NADA cambia: el cron
|
||||
sigue generando solo el adeudo, sin enlaces ni avisos.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
from odoo.addons.skeen_citas.clinic_settings import (
|
||||
get_cobro_automatico,
|
||||
get_membresias,
|
||||
)
|
||||
|
||||
from .membresia import _delta_periodo
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SkeenCobroEnlace(models.Model):
|
||||
_inherit = 'skeen.cobro.enlace'
|
||||
|
||||
periodo_id = fields.Many2one('skeen.membresia.periodo', string='Periodo de membresía',
|
||||
index=True, ondelete='set null')
|
||||
membresia_id = fields.Many2one(related='periodo_id.membresia_id',
|
||||
string='Membresía', store=True)
|
||||
|
||||
|
||||
class SkeenMembresiaPeriodo(models.Model):
|
||||
_inherit = 'skeen.membresia.periodo'
|
||||
|
||||
def _cobro_confirmado(self, enlace, pago):
|
||||
"""Marca el periodo pagado y renueva la membresía (avanza el ciclo)."""
|
||||
self.ensure_one()
|
||||
vals = {'estado': 'pagado'}
|
||||
if pago and pago.venta_id:
|
||||
vals['venta_id'] = pago.venta_id.id
|
||||
self.write(vals)
|
||||
m = self.membresia_id
|
||||
if self.tipo == 'periodo' and m.proximo_cobro == self.periodo_inicio:
|
||||
m.write({
|
||||
'estado': 'activa',
|
||||
'periodo_actual_inicio': self.periodo_inicio,
|
||||
'periodo_actual_fin': self.periodo_fin,
|
||||
'proximo_cobro': self.periodo_fin + relativedelta(days=1),
|
||||
'sesiones_usadas_periodo': 0,
|
||||
})
|
||||
m._post('Periodo pagado en línea (%s): %s a %s.' % (
|
||||
enlace.name or '', self.periodo_inicio, self.periodo_fin))
|
||||
return True
|
||||
|
||||
def _cobro_expirado(self, enlace):
|
||||
"""El enlace venció sin pago: el periodo queda vencido."""
|
||||
self.ensure_one()
|
||||
if self.estado == 'pendiente':
|
||||
self.write({'estado': 'vencido'})
|
||||
self.membresia_id._post('Enlace de cobro vencido (%s) para el periodo %s a %s.' % (
|
||||
enlace.name or '', self.periodo_inicio, self.periodo_fin))
|
||||
return True
|
||||
|
||||
|
||||
class SkeenMembresiaService(models.AbstractModel):
|
||||
_inherit = 'skeen.membresia.service'
|
||||
|
||||
@api.model
|
||||
def _cobro_automatico(self, cfg):
|
||||
"""Motor del cobro automático. Devuelve cuántos enlaces creó/avisó."""
|
||||
ca = cfg.get('cobro_automatico') or get_cobro_automatico(self.env)
|
||||
if not ca.get('activo'):
|
||||
return 0
|
||||
if (ca.get('metodo') or 'enlace') == 'tarjeta_guardada':
|
||||
return self._cobro_tarjeta_guardada(ca)
|
||||
return self._cobro_por_enlace(ca)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Método 'enlace'
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _cobro_por_enlace(self, ca):
|
||||
hoy = fields.Date.today()
|
||||
dias_antes = int(ca.get('dias_antes') or 3)
|
||||
reintentos = [int(d) for d in (ca.get('reintentos_dias') or [])]
|
||||
gracia = int(ca.get('dias_gracia') or 0)
|
||||
Membresia = self.env['skeen.membresia'].sudo()
|
||||
creados = 0
|
||||
for m in Membresia.search([('estado', '=', 'activa')]):
|
||||
if not m.proximo_cobro:
|
||||
continue
|
||||
faltan = (m.proximo_cobro - hoy).days
|
||||
if faltan > dias_antes:
|
||||
continue
|
||||
try:
|
||||
creados += self._asegurar_enlace_periodo(m, ca)
|
||||
except Exception:
|
||||
# Una membresía sin servicio de cobro (u otro dato faltante) no
|
||||
# debe detener el resto del proceso.
|
||||
_logger.exception('Cobro automático: falló la membresía %s', m.id)
|
||||
return creados
|
||||
|
||||
@api.model
|
||||
def _asegurar_enlace_periodo(self, m, ca):
|
||||
"""Crea (si falta) el enlace del próximo periodo y aplica los avisos."""
|
||||
hoy = fields.Date.today()
|
||||
proximo = m.proximo_cobro
|
||||
delta = _delta_periodo(m.plan_id.periodicidad)
|
||||
fin = proximo + delta - relativedelta(days=1)
|
||||
periodo = m._crear_periodo_pendiente(proximo, fin)
|
||||
if not periodo or periodo.estado == 'pagado':
|
||||
return 0
|
||||
svc = self.env['skeen.cobro.service'].sudo()
|
||||
enlace = self.env['skeen.cobro.enlace'].sudo().search([
|
||||
('origen', '=', 'membresia'), ('periodo_id', '=', periodo.id),
|
||||
('estado', '=', 'pendiente')], limit=1)
|
||||
creado = 0
|
||||
if not enlace:
|
||||
anterior = self.env['skeen.cobro.enlace'].sudo().search([
|
||||
('origen', '=', 'membresia'), ('periodo_id', '=', periodo.id),
|
||||
('estado', '=', 'pagado')], limit=1)
|
||||
if anterior:
|
||||
return 0
|
||||
reintentos = [int(d) for d in (ca.get('reintentos_dias') or [])]
|
||||
ultimo = max(reintentos) if reintentos else 0
|
||||
ttl_horas = max(24, ((proximo + timedelta(days=ultimo + int(ca.get('dias_gracia') or 0) + 2)
|
||||
- hoy).days or 1) * 24)
|
||||
enlace = svc.crear(
|
||||
'membresia', periodo.monto,
|
||||
'Membresía %s (%s a %s)' % (m.plan_id.name, proximo, fin),
|
||||
ttl_horas=ttl_horas, partner=m.partner_id, moneda=periodo.moneda)
|
||||
enlace.write({
|
||||
'periodo_id': periodo.id,
|
||||
'venta_id': periodo.venta_id.id if periodo.venta_id else False,
|
||||
})
|
||||
svc.intentar_checkout(enlace)
|
||||
creado = 1
|
||||
self._avisar_enlace(m, enlace, ca, periodo)
|
||||
return creado
|
||||
|
||||
@api.model
|
||||
def _hitos_aviso(self, proximo_cobro, ca):
|
||||
"""Fechas en que toca avisar: inicial (dias_antes) + reintentos."""
|
||||
dias_antes = int(ca.get('dias_antes') or 3)
|
||||
hitos = [proximo_cobro - timedelta(days=dias_antes)]
|
||||
for d in (ca.get('reintentos_dias') or []):
|
||||
hitos.append(proximo_cobro + timedelta(days=int(d)))
|
||||
return hitos
|
||||
|
||||
@api.model
|
||||
def _avisar_enlace(self, m, enlace, ca, periodo):
|
||||
"""Encola el aviso que toque (inicial o reintento) sin duplicar."""
|
||||
hoy = fields.Date.today()
|
||||
hitos = self._hitos_aviso(m.proximo_cobro, ca)
|
||||
objetivo = None
|
||||
for i, fecha in enumerate(hitos):
|
||||
if hoy >= fecha and (enlace.recordatorios or 0) <= i:
|
||||
objetivo = i
|
||||
break
|
||||
if objetivo is None:
|
||||
return 0
|
||||
self._encolar_aviso_cobro(m, enlace, periodo, objetivo)
|
||||
enlace.write({
|
||||
'recordatorios': objetivo + 1,
|
||||
'ultimo_envio': fields.Datetime.now(),
|
||||
'enviado_at': enlace.enviado_at or fields.Datetime.now(),
|
||||
})
|
||||
# Escalada cuando ya se agotaron los avisos.
|
||||
if (enlace.recordatorios or 0) >= len(hitos):
|
||||
self._escalar_sin_pago(m, enlace, periodo, ca)
|
||||
return 1
|
||||
|
||||
@api.model
|
||||
def _encolar_aviso_cobro(self, m, enlace, periodo, hito):
|
||||
try:
|
||||
service = self.env['skeen.notificacion.service'].sudo()
|
||||
except Exception:
|
||||
return False
|
||||
clave = 'cobro_mem:%s:%s' % (periodo.id, hito)
|
||||
try:
|
||||
service.encolar(
|
||||
partner=m.partner_id, tipo='cobro_membresia',
|
||||
cuando=fields.Datetime.now(), ventana=str(hito),
|
||||
clave_unica=clave,
|
||||
contexto_extra={
|
||||
'enlace': enlace.url or '',
|
||||
'monto': '%s %s' % (enlace.currency_id.name or '',
|
||||
format(enlace.monto, ',.2f')),
|
||||
'plan': m.plan_id.name,
|
||||
'periodo': '%s a %s' % (periodo.periodo_inicio, periodo.periodo_fin),
|
||||
'membresia': m.folio or '',
|
||||
'fecha': (m.proximo_cobro.strftime('%d/%m/%Y')
|
||||
if m.proximo_cobro else ''),
|
||||
})
|
||||
return True
|
||||
except Exception:
|
||||
_logger.exception('No se pudo encolar el aviso de cobro de membresía')
|
||||
return False
|
||||
|
||||
@api.model
|
||||
def _escalar_sin_pago(self, m, enlace, periodo, ca):
|
||||
"""Comportamiento al agotar los reintentos.
|
||||
|
||||
Si `cancelar_si_no_paga` está encendido, al pasar los reintentos + días
|
||||
de gracia del cobro se marca el periodo vencido y la membresía vencida de
|
||||
inmediato. Si está apagado, el periodo queda pendiente y es el proceso
|
||||
normal de vencimiento (`_marcar_vencidas`, con `dias_gracia` de
|
||||
membresías) el que la vence unos días después.
|
||||
"""
|
||||
if not ca.get('cancelar_si_no_paga'):
|
||||
return False
|
||||
hoy = fields.Date.today()
|
||||
reintentos = [int(d) for d in (ca.get('reintentos_dias') or [])]
|
||||
ultimo = max(reintentos) if reintentos else 0
|
||||
limite = m.proximo_cobro + timedelta(days=ultimo + int(ca.get('dias_gracia') or 0))
|
||||
if hoy <= limite:
|
||||
return False
|
||||
if periodo.estado == 'pendiente':
|
||||
periodo.write({'estado': 'vencido'})
|
||||
if m.estado == 'activa':
|
||||
m.write({'estado': 'vencida'})
|
||||
m._post('Membresía vencida por falta de pago del periodo (cobro automático).')
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Método 'tarjeta_guardada' (requiere Stripe real)
|
||||
# ------------------------------------------------------------------
|
||||
@api.model
|
||||
def _cobro_tarjeta_guardada(self, ca):
|
||||
"""Cargo sin presencia del cliente con tarjeta guardada (Stripe).
|
||||
|
||||
Camino técnico dejado INERTE y documentado: sin `skeen.stripe_secret_key`
|
||||
y sin un método de pago guardado del cliente no hay nada que cobrar. No
|
||||
se simula: si no hay llaves, no se hace nada (ni cargos ni avisos).
|
||||
"""
|
||||
ICP = self.env['ir.config_parameter'].sudo()
|
||||
if not ICP.get_param('skeen.stripe_secret_key'):
|
||||
_logger.info('Cobro automático con tarjeta_guardada: sin Stripe configurado, inerte.')
|
||||
return 0
|
||||
# A partir de aquí requeriría tarjetas guardadas por cliente (Stripe
|
||||
# Customer + PaymentMethod) que hoy no se persisten. Se deja el punto de
|
||||
# extensión explícito y no se marca nada como cobrado.
|
||||
_logger.info('Cobro automático con tarjeta_guardada: no hay tarjetas guardadas; inerte.')
|
||||
return 0
|
||||
@@ -688,7 +688,8 @@ class SkeenMembresiaService(models.AbstractModel):
|
||||
@api.model
|
||||
def _cron_tick(self):
|
||||
"""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()
|
||||
if not cfg.get('activo'):
|
||||
return resultado
|
||||
@@ -696,6 +697,7 @@ class SkeenMembresiaService(models.AbstractModel):
|
||||
resultado['vencidas'] = self._marcar_vencidas(cfg)
|
||||
resultado['avisos'] = self._encolar_avisos(cfg)
|
||||
resultado['cargos'] = self._generar_cargos(cfg)
|
||||
resultado['cobros'] = self._cobro_automatico(cfg)
|
||||
if any(resultado.values()):
|
||||
_logger.info('SKEEN membresías: %s', resultado)
|
||||
return resultado
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
""",
|
||||
'author': 'Consultoria Alcaraz Salazar, S.A.S.',
|
||||
'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': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/roles.xml',
|
||||
|
||||
@@ -1921,6 +1921,11 @@ class SkeenFrontendController(http.Controller):
|
||||
'payment_state': cita.payment_state,
|
||||
'price': cita.servicio_price,
|
||||
'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)
|
||||
'duration': cita._duracion(),
|
||||
'duration_override': cita.duration or 0,
|
||||
@@ -5423,6 +5428,146 @@ class SkeenFrontendController(http.Controller):
|
||||
except Exception as e:
|
||||
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
|
||||
# ============================================================
|
||||
|
||||
@@ -333,9 +333,24 @@ class SkeenPublicBookingController(http.Controller):
|
||||
'descripcion': cfg['descripcion'],
|
||||
'mensaje_confirmacion': cfg['mensaje_confirmacion'],
|
||||
'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
|
||||
# ------------------------------------------------------------------
|
||||
@@ -400,6 +415,21 @@ class SkeenPublicBookingController(http.Controller):
|
||||
except Exception as 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
|
||||
# ------------------------------------------------------------------
|
||||
@@ -523,6 +553,13 @@ class SkeenPublicBookingController(http.Controller):
|
||||
creado_paciente = True
|
||||
|
||||
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
|
||||
if notas:
|
||||
nota = '%s\n%s' % (ORIGEN_MARCA, notas)
|
||||
@@ -537,6 +574,9 @@ class SkeenPublicBookingController(http.Controller):
|
||||
'notes': nota,
|
||||
'booking_source': 'public_portal',
|
||||
}
|
||||
if con_anticipo:
|
||||
cita_vals['anticipo_estado'] = 'pendiente'
|
||||
cita_vals['anticipo_monto'] = monto_anticipo
|
||||
if doctor_id:
|
||||
cita_vals['doctor_id'] = doctor_id
|
||||
|
||||
@@ -559,10 +599,30 @@ class SkeenPublicBookingController(http.Controller):
|
||||
pass
|
||||
|
||||
# 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)
|
||||
|
||||
return _json({
|
||||
respuesta = {
|
||||
'ok': True,
|
||||
'folio': cita.name,
|
||||
'fecha': cita.date.strftime('%Y-%m-%d'),
|
||||
@@ -574,7 +634,21 @@ class SkeenPublicBookingController(http.Controller):
|
||||
'requiere_confirmacion': cfg['requiere_confirmacion'],
|
||||
'mensaje_confirmacion': cfg['mensaje_confirmacion'],
|
||||
'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:
|
||||
request.env.cr.rollback()
|
||||
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.set_state('cancelled', user='Portal en línea',
|
||||
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:
|
||||
cita.sudo().message_post(
|
||||
subject='Cancelación en línea',
|
||||
|
||||
@@ -45,6 +45,7 @@ TIPOS = [
|
||||
('recuperacion', 'Recuperación'),
|
||||
('formulario', 'Formulario'),
|
||||
('renovacion_membresia', 'Renovación de membresía'),
|
||||
('cobro_membresia', 'Cobro de membresía'),
|
||||
('manual', 'Manual'),
|
||||
]
|
||||
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')]
|
||||
@@ -70,6 +71,8 @@ PLANTILLA_POR_TIPO = {
|
||||
'formulario': 'plantilla_formulario',
|
||||
# Aviso de renovación/cobro de una membresía (bloque `membresias`).
|
||||
'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',
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,12 @@ PERMISSION_GROUPS = [
|
||||
('comisiones.editar', 'Administrar reglas y metas de comisiones'),
|
||||
('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', 'Administrar usuarios'),
|
||||
('config.roles', 'Administrar roles y menús'),
|
||||
@@ -158,6 +164,7 @@ READ_PERM_KEYS = (
|
||||
'compras.ver',
|
||||
'proveedores.ver',
|
||||
'comisiones.ver',
|
||||
'cobros.ver',
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -273,6 +280,13 @@ DEFAULT_PERM_ROLES = {
|
||||
'comisiones.ver': ALL,
|
||||
'comisiones.editar': 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
|
||||
'config.usuarios': AG,
|
||||
'config.roles': AG,
|
||||
|
||||
Reference in New Issue
Block a user