fase 2.6: membresias y suscripciones (modulo skeen_membresias: planes recurrentes con sesiones incluidas o ilimitadas, alta/cobro/renovacion/congelamiento/cancelacion, consumo de sesiones desde POS y expediente, cron de vencimientos y avisos, reporte de ingresos recurrentes); apagado por defecto

This commit is contained in:
2026-10-11 10:49:44 +00:00
parent 9286ab8ee2
commit 8ff13b9c2a
33 changed files with 3873 additions and 22 deletions

View File

@@ -1014,6 +1014,7 @@ export interface ClinicSettings {
impuestos: ImpuestosSettings;
reservas_online: ReservasOnlineSettings;
notificaciones: NotificacionesSettings;
membresias: MembresiasSettings;
}
// ---- Reservas en línea (portal público de auto-agendado) ----
@@ -1108,6 +1109,194 @@ export const NOTIF_VENTANAS: { horas: number; label: string }[] = [
{ horas: 168, label: '1 semana' },
];
// ---- Membresías y suscripciones ----
export interface MembresiasSettings {
/** Enciende TODO el módulo: menú, POS, cron y cargos. Apagado = no cambia nada. */
activo: boolean;
/** Días antes del cobro en que se avisa (ordenados). */
dias_aviso_renovacion: number[];
permitir_congelamiento: boolean;
/** Tope de días congelados por año. */
dias_max_congelamiento_anio: number;
renovacion_automatica_default: boolean;
/** Descuento por defecto en servicios no incluidos (%). */
descuento_porcentaje_default: number;
permitir_pausa_pago: boolean;
/** El cron genera el adeudo del nuevo periodo. */
generar_cargo_al_renovar: boolean;
/** Días tras el vencimiento antes de marcar la membresía vencida. */
dias_gracia: number;
}
export const MEMBRESIAS_DEFAULT: MembresiasSettings = {
activo: false,
dias_aviso_renovacion: [7, 1],
permitir_congelamiento: true,
dias_max_congelamiento_anio: 30,
renovacion_automatica_default: false,
descuento_porcentaje_default: 0,
permitir_pausa_pago: false,
generar_cargo_al_renovar: true,
dias_gracia: 5,
};
export type PeriodicidadMembresia = 'semanal' | 'mensual' | 'trimestral' | 'semestral' | 'anual';
export type EstadoMembresia = 'activa' | 'congelada' | 'vencida' | 'pausada' | 'cancelada';
export type EstadoPeriodoMembresia = 'pendiente' | 'pagado' | 'vencido' | 'condonado';
export interface MembresiaPlan {
id: number;
name: string;
descripcion: string;
precio: number;
moneda: 'MXN' | 'USD';
periodicidad: PeriodicidadMembresia;
sesiones_incluidas: number;
servicios_incluidos: { id: number; name: string }[];
categorias_incluidas: { id: number; name: string; code: string }[];
descuento_porcentaje: number;
cargo_inscripcion: number;
meses_minimos: number;
permitir_congelamiento: boolean;
activo: boolean;
sequence: number;
sucursales: { id: number; name: string }[];
servicio_id: number | null;
servicio: string;
notas: string;
membresias_count: number;
}
/** Payload de alta/edición de un plan (el backend recibe ids, no objetos). */
export interface MembresiaPlanInput {
name?: string;
descripcion?: string;
precio?: number;
moneda?: 'MXN' | 'USD';
periodicidad?: PeriodicidadMembresia;
sesiones_incluidas?: number;
servicios_incluidos?: number[];
categorias_incluidas?: number[];
descuento_porcentaje?: number;
cargo_inscripcion?: number;
meses_minimos?: number;
permitir_congelamiento?: boolean;
activo?: boolean;
sequence?: number;
sucursales?: number[];
servicio_id?: number | null;
notas?: string;
}
export interface Membresia {
id: number;
folio: string;
partner_id: number;
patient: string;
phone: string;
plan_id: number | null;
plan: string;
periodicidad: string;
precio: number;
moneda: string;
estado: EstadoMembresia;
estado_label: string;
fecha_inicio: string | null;
fecha_fin: string | null;
periodo_actual_inicio: string | null;
periodo_actual_fin: string | null;
proximo_cobro: string | null;
sesiones_incluidas_periodo: number;
sesiones_usadas_periodo: number;
sesiones_restantes: number;
ilimitadas: boolean;
renovacion_automatica: boolean;
descuento_porcentaje: number;
congelada_desde: string | null;
congelada_hasta: string | null;
dias_congelados_anio: number;
motivo_cancelacion: string;
notas: string;
adeudo: number;
periodos_pagados: number;
dias_para_cobro: number;
vigente: boolean;
servicios_incluidos: { id: number; name: string }[];
}
export interface MembresiaPeriodo {
id: number;
tipo: 'periodo' | 'inscripcion' | 'ajuste';
periodo_inicio: string | null;
periodo_fin: string | null;
monto: number;
moneda: string;
estado: EstadoPeriodoMembresia;
venta_id: number | null;
venta: string;
fecha_pago: string | null;
notas: string;
}
export interface MembresiaConsumo {
id: number;
fecha: string | null;
cita_id: number | null;
venta_id: number | null;
servicio_id: number | null;
servicio: string;
sesiones: number;
usuario: string;
notas: string;
state: 'consumed' | 'cancelled';
}
export interface MembresiaResumen {
activas: number;
por_vencer: number;
vencidas: number;
mrr_estimado: number;
}
export interface MembresiasResponse {
status: string;
membresias: Membresia[];
total: number;
page: number;
page_size: number;
total_pages: number;
resumen: MembresiaResumen;
message?: string;
}
export interface MembresiaDetalleResponse {
status: string;
membresia: Membresia;
periodos: MembresiaPeriodo[];
consumos: MembresiaConsumo[];
ventas: { id: number; name: string; date: string | null; total: number; state: string; payment_method: string }[];
saldos: { adeudo: number; sesiones_restantes: number; ilimitadas: boolean; periodos_pagados: number };
message?: string;
}
export interface MembresiasReporte {
status: string;
start: string;
end: string;
altas: number;
cancelaciones: number;
ingresos_por_mes: { mes: string; total: number }[];
recurrentes_por_mes: { mes: string; total: number }[];
total_ingresos: number;
total_recurrente: number;
proximas_renovaciones: { id: number; folio: string; patient: string; plan: string; proximo_cobro: string | null; precio: number; moneda: string }[];
sesiones_consumidas: number;
sesiones_incluidas_activas: number;
membresias_activas: number;
message?: string;
}
export interface Notificacion {
id: number;
partner_id: number;
@@ -1351,6 +1540,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
impuestos: IMPUESTOS_DEFAULT,
reservas_online: RESERVAS_ONLINE_DEFAULT,
notificaciones: NOTIFICACIONES_DEFAULT,
membresias: MEMBRESIAS_DEFAULT,
};
export interface FrontendUser {
@@ -2011,6 +2201,96 @@ export const odooApi = {
return data;
},
// ---- Membresías y suscripciones ----
// Con `membresias.activo=false` (default) el backend responde 403 y estas
// llamadas nunca se hacen desde la UI (la sección no se monta).
async getMembresiaPlanes(opts?: { includeInactive?: boolean; q?: string }): Promise<{ status: string; planes: MembresiaPlan[]; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/membresias/planes`, {
params: { ...(opts?.includeInactive ? { include_inactive: 1 } : {}), ...(opts?.q ? { q: opts.q } : {}) },
});
return data;
},
async createMembresiaPlan(payload: MembresiaPlanInput): Promise<{ status: string; plan: MembresiaPlan; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias/planes`, payload);
return data;
},
async updateMembresiaPlan(id: number, payload: MembresiaPlanInput): Promise<{ status: string; plan: MembresiaPlan; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/membresias/planes/${id}`, payload);
return data;
},
async deleteMembresiaPlan(id: number): Promise<{ status: string; message?: string }> {
const { data } = await axios.delete(`${ODOO_BASE}/skeen/frontend/v1/membresias/planes/${id}`);
return data;
},
async getMembresias(params?: { estado?: string; plan_id?: number; partner_id?: number; por_vencer?: boolean; q?: string; page?: number; page_size?: number }): Promise<MembresiasResponse> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/membresias`, {
params: {
...(params?.estado ? { estado: params.estado } : {}),
...(params?.plan_id ? { plan_id: params.plan_id } : {}),
...(params?.partner_id ? { partner_id: params.partner_id } : {}),
...(params?.por_vencer ? { por_vencer: 1 } : {}),
...(params?.q ? { q: params.q } : {}),
...(params?.page ? { page: params.page } : {}),
...(params?.page_size ? { page_size: params.page_size } : {}),
},
});
return data;
},
async getMembresia(id: number): Promise<MembresiaDetalleResponse> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/membresias/${id}`);
return data;
},
async getMembresiasPorPaciente(partnerId: number): Promise<{ status: string; membresias: Membresia[]; message?: string }> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/membresias/por_paciente`, { params: { partner_id: partnerId } });
return data;
},
async crearMembresia(payload: { partner_id: number; plan_id: number; fecha_inicio?: string; renovacion_automatica?: boolean; descuento_porcentaje?: number; notas?: string }): Promise<{ status: string; membresia: Membresia; inscripcion_cargo: number; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias`, payload);
return data;
},
async congelarMembresia(id: number, payload: { desde: string; hasta: string; motivo?: string }): Promise<{ status: string; membresia: Membresia; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias/${id}/congelar`, payload);
return data;
},
async descongelarMembresia(id: number): Promise<{ status: string; membresia: Membresia; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias/${id}/descongelar`, {});
return data;
},
async renovarMembresia(id: number, generarCargo?: boolean): Promise<{ status: string; membresia: Membresia; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias/${id}/renovar`, { generar_cargo: generarCargo });
return data;
},
async cancelarMembresia(id: number, motivo: string): Promise<{ status: string; membresia: Membresia; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias/${id}/cancelar`, { motivo });
return data;
},
async cobrarMembresia(id: number, payload: { metodo: string; monto?: number; moneda?: string; cobrar_inscripcion?: boolean }): Promise<{ status: string; membresia: Membresia; venta: { id: number; name: string; total: number; state: string }; pago: { id: number; name: string; amount: number; payment_method: string }; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias/${id}/cobrar`, payload);
return data;
},
async consumirMembresia(id: number, payload: { sesiones?: number; cita_id?: number; venta_id?: number; servicio_id?: number; notas?: string }): Promise<{ status: string; consumo: MembresiaConsumo; membresia: Membresia; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/membresias/${id}/consumir`, payload);
return data;
},
async getMembresiasReporte(params: { start: string; end: string }): Promise<MembresiasReporte> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/membresias/reporte`, { params });
return data;
},
// ---- Portal público de reservas en línea (sin autenticación) ----
async getBookingConfig(): Promise<BookingConfig> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/public/booking/config`);