bloque 1: anticipo al reservar en linea y cobro automatico de membresias con enlace de pago (modulo skeen_cobros: enlaces con token firmado, webhooks de Stripe/MercadoPago, modo simulado para pruebas, pagina publica de pago, expiracion automatica y reembolso, reintentos de cobro, permisos y reporte); apagado por defecto

This commit is contained in:
2026-10-11 23:54:47 +00:00
parent 5fc0d2a780
commit 6a6c2a928b
35 changed files with 2841 additions and 32 deletions

View File

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