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:
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user