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:
@@ -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> = {};
|
||||
|
||||
Reference in New Issue
Block a user