Implementados 4 módulos con agent swarm: 1. MERCADOPAGO INTEGRADO - SDK oficial de MercadoPago - Crear preferencias de pago - Webhooks para notificaciones - Reembolsos y cancelaciones - Estados: PENDING, PROCESSING, COMPLETED, REFUNDED 2. SISTEMA DE BONOS Y PACKS - Pack 5, Pack 10, Pack Mensual - Compra online con MP - Uso FIFO automático - Control de expiración - Aplicación en reservas 3. SUSCRIPCIONES/MEMBRESÍAS - Planes: Básico, Premium, Anual VIP - Beneficios: descuentos, reservas gratis, prioridad - Cobro recurrente vía MP - Estados: ACTIVE, PAUSED, CANCELLED - Aplicación automática en reservas 4. CLASES CON PROFESORES - Registro de coaches con verificación - Tipos: Individual, Grupal, Clínica - Horarios y disponibilidad - Reservas con pago integrado - Sistema de reseñas Endpoints nuevos: - /payments/* - Pagos MercadoPago - /bonus-packs/*, /bonuses/* - Bonos - /subscription-plans/*, /subscriptions/* - Suscripciones - /coaches/* - Profesores - /classes/*, /class-enrollments/* - Clases Variables de entorno: - MERCADOPAGO_ACCESS_TOKEN - MERCADOPAGO_PUBLIC_KEY - MERCADOPAGO_WEBHOOK_SECRET Datos de prueba: - 3 Bonus Packs - 3 Planes de suscripción - 1 Coach verificado (admin) - 3 Clases disponibles
445 lines
12 KiB
TypeScript
445 lines
12 KiB
TypeScript
import prisma from '../config/database';
|
|
import { ApiError } from '../middleware/errorHandler';
|
|
import { BookingStatus } from '../utils/constants';
|
|
import { sendBookingConfirmation, sendBookingCancellation } from './email.service';
|
|
import logger from '../config/logger';
|
|
import { SubscriptionService } from './subscription.service';
|
|
|
|
export interface CreateBookingInput {
|
|
userId: string;
|
|
courtId: string;
|
|
date: Date;
|
|
startTime: string;
|
|
endTime: string;
|
|
notes?: string;
|
|
}
|
|
|
|
export interface UpdateBookingInput {
|
|
status?: string;
|
|
notes?: string;
|
|
}
|
|
|
|
export class BookingService {
|
|
// Crear una reserva
|
|
static async createBooking(data: CreateBookingInput) {
|
|
// Validar que la fecha no sea en el pasado
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
|
|
const bookingDate = new Date(data.date);
|
|
bookingDate.setHours(0, 0, 0, 0);
|
|
|
|
if (bookingDate < today) {
|
|
throw new ApiError('No se pueden hacer reservas en fechas pasadas', 400);
|
|
}
|
|
|
|
// Validar que la cancha existe y está activa
|
|
const court = await prisma.court.findFirst({
|
|
where: { id: data.courtId, isActive: true },
|
|
});
|
|
|
|
if (!court) {
|
|
throw new ApiError('Cancha no encontrada o inactiva', 404);
|
|
}
|
|
|
|
// Validar horario de la cancha
|
|
const dayOfWeek = bookingDate.getDay();
|
|
const schedule = await prisma.courtSchedule.findFirst({
|
|
where: {
|
|
courtId: data.courtId,
|
|
dayOfWeek,
|
|
},
|
|
});
|
|
|
|
if (!schedule) {
|
|
throw new ApiError('La cancha no tiene horario disponible para este día', 400);
|
|
}
|
|
|
|
// Validar que la hora de inicio y fin estén dentro del horario
|
|
if (data.startTime < schedule.openTime || data.endTime > schedule.closeTime) {
|
|
throw new ApiError(
|
|
`El horario debe estar entre ${schedule.openTime} y ${schedule.closeTime}`,
|
|
400
|
|
);
|
|
}
|
|
|
|
// Validar que la hora de fin sea posterior a la de inicio
|
|
if (data.startTime >= data.endTime) {
|
|
throw new ApiError('La hora de fin debe ser posterior a la de inicio', 400);
|
|
}
|
|
|
|
// Validar que la cancha esté disponible en ese horario
|
|
const existingBooking = await prisma.booking.findFirst({
|
|
where: {
|
|
courtId: data.courtId,
|
|
date: data.date,
|
|
status: { in: [BookingStatus.PENDING, BookingStatus.CONFIRMED] },
|
|
OR: [
|
|
{
|
|
// Nueva reserva empieza durante una existente
|
|
startTime: { lt: data.endTime },
|
|
endTime: { gt: data.startTime },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
if (existingBooking) {
|
|
throw new ApiError('La cancha no está disponible en ese horario', 409);
|
|
}
|
|
|
|
// Calcular precio base (precio por hora * número de horas)
|
|
const startHour = parseInt(data.startTime.split(':')[0]);
|
|
const endHour = parseInt(data.endTime.split(':')[0]);
|
|
const hours = endHour - startHour;
|
|
const basePrice = court.pricePerHour * hours;
|
|
|
|
// Verificar y aplicar beneficios de suscripción
|
|
let finalPrice = basePrice;
|
|
let discountApplied = 0;
|
|
let usedFreeBooking = false;
|
|
|
|
// TODO: Re-activar cuando se implemente Fase 4.3 (Suscripciones)
|
|
/*
|
|
try {
|
|
const benefits = await SubscriptionService.checkAndApplyBenefits(
|
|
data.userId,
|
|
{
|
|
totalPrice: basePrice,
|
|
courtId: data.courtId,
|
|
date: data.date,
|
|
startTime: data.startTime,
|
|
endTime: data.endTime,
|
|
}
|
|
);
|
|
|
|
finalPrice = benefits.finalPrice;
|
|
discountApplied = benefits.discountApplied;
|
|
usedFreeBooking = benefits.usedFreeBooking;
|
|
|
|
if (discountApplied > 0) {
|
|
logger.info(`Descuento aplicado a reserva: ${discountApplied} centavos para usuario ${data.userId}`);
|
|
}
|
|
if (usedFreeBooking) {
|
|
logger.info(`Reserva gratis aplicada para usuario ${data.userId}`);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error aplicando beneficios de suscripción:', error);
|
|
// Continuar con precio normal si falla la aplicación de beneficios
|
|
}
|
|
*/
|
|
// Fin TODO Fase 4.3
|
|
|
|
// Crear la reserva
|
|
const booking = await prisma.booking.create({
|
|
data: {
|
|
userId: data.userId,
|
|
courtId: data.courtId,
|
|
date: data.date,
|
|
startTime: data.startTime,
|
|
endTime: data.endTime,
|
|
status: BookingStatus.PENDING,
|
|
totalPrice: finalPrice,
|
|
notes: data.notes,
|
|
},
|
|
include: {
|
|
court: true,
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
email: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Enviar email de confirmación (no bloqueante)
|
|
try {
|
|
await sendBookingConfirmation({
|
|
to: booking.user.email,
|
|
name: `${booking.user.firstName} ${booking.user.lastName}`,
|
|
courtName: booking.court.name,
|
|
date: booking.date.toISOString().split('T')[0],
|
|
startTime: booking.startTime,
|
|
endTime: booking.endTime,
|
|
totalPrice: booking.totalPrice,
|
|
bookingId: booking.id,
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error enviando email de confirmación:', error);
|
|
// No fallar la reserva si el email falla
|
|
}
|
|
|
|
// Retornar reserva con información de beneficios aplicados
|
|
return {
|
|
...booking,
|
|
benefitsApplied: {
|
|
basePrice,
|
|
discountApplied,
|
|
usedFreeBooking,
|
|
finalPrice,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Obtener todas las reservas (con filtros)
|
|
static async getAllBookings(filters: {
|
|
userId?: string;
|
|
courtId?: string;
|
|
date?: Date;
|
|
status?: string;
|
|
}) {
|
|
const where: any = {};
|
|
|
|
if (filters.userId) where.userId = filters.userId;
|
|
if (filters.courtId) where.courtId = filters.courtId;
|
|
if (filters.date) where.date = filters.date;
|
|
if (filters.status) where.status = filters.status;
|
|
|
|
return prisma.booking.findMany({
|
|
where,
|
|
include: {
|
|
court: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
type: true,
|
|
},
|
|
},
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
email: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: [
|
|
{ date: 'desc' },
|
|
{ startTime: 'asc' },
|
|
],
|
|
});
|
|
}
|
|
|
|
// Obtener reserva por ID
|
|
static async getBookingById(id: string) {
|
|
const booking = await prisma.booking.findUnique({
|
|
where: { id },
|
|
include: {
|
|
court: true,
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
email: true,
|
|
phone: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!booking) {
|
|
throw new ApiError('Reserva no encontrada', 404);
|
|
}
|
|
|
|
return booking;
|
|
}
|
|
|
|
// Obtener reservas de un usuario
|
|
static async getUserBookings(userId: string, upcoming = false) {
|
|
const where: any = { userId };
|
|
|
|
if (upcoming) {
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
where.date = { gte: today };
|
|
where.status = { in: [BookingStatus.PENDING, BookingStatus.CONFIRMED] };
|
|
}
|
|
|
|
return prisma.booking.findMany({
|
|
where,
|
|
include: {
|
|
court: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
type: true,
|
|
imageUrl: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: [
|
|
{ date: 'desc' },
|
|
{ startTime: 'asc' },
|
|
],
|
|
});
|
|
}
|
|
|
|
// Actualizar una reserva
|
|
static async updateBooking(id: string, data: UpdateBookingInput, userId?: string) {
|
|
const booking = await this.getBookingById(id);
|
|
|
|
// Si se proporciona userId, verificar que sea el dueño o admin
|
|
if (userId && booking.userId !== userId) {
|
|
throw new ApiError('No tienes permiso para modificar esta reserva', 403);
|
|
}
|
|
|
|
return prisma.booking.update({
|
|
where: { id },
|
|
data,
|
|
include: {
|
|
court: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
},
|
|
},
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
email: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
// Cancelar una reserva
|
|
static async cancelBooking(id: string, userId?: string) {
|
|
const booking = await this.getBookingById(id);
|
|
|
|
// Si se proporciona userId, verificar que sea el dueño o admin
|
|
if (userId && booking.userId !== userId) {
|
|
throw new ApiError('No tienes permiso para cancelar esta reserva', 403);
|
|
}
|
|
|
|
// Validar que no esté ya cancelada o completada
|
|
if (booking.status === BookingStatus.CANCELLED) {
|
|
throw new ApiError('La reserva ya está cancelada', 400);
|
|
}
|
|
|
|
if (booking.status === BookingStatus.COMPLETED) {
|
|
throw new ApiError('No se puede cancelar una reserva completada', 400);
|
|
}
|
|
|
|
const updated = await prisma.booking.update({
|
|
where: { id },
|
|
data: { status: BookingStatus.CANCELLED },
|
|
include: {
|
|
court: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
},
|
|
},
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
email: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Enviar email de cancelación (no bloqueante)
|
|
try {
|
|
await sendBookingCancellation({
|
|
to: updated.user.email,
|
|
name: `${updated.user.firstName} ${updated.user.lastName}`,
|
|
courtName: updated.court.name,
|
|
date: updated.date.toISOString().split('T')[0],
|
|
startTime: updated.startTime,
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error enviando email de cancelación:', error);
|
|
}
|
|
|
|
return updated;
|
|
}
|
|
|
|
// Confirmar una reserva (admin)
|
|
static async confirmBooking(id: string) {
|
|
const booking = await this.getBookingById(id);
|
|
|
|
if (booking.status !== BookingStatus.PENDING) {
|
|
throw new ApiError('Solo se pueden confirmar reservas pendientes', 400);
|
|
}
|
|
|
|
return prisma.booking.update({
|
|
where: { id },
|
|
data: { status: BookingStatus.CONFIRMED },
|
|
include: {
|
|
court: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
},
|
|
},
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
firstName: true,
|
|
lastName: true,
|
|
email: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
// Calcular precio con beneficios de suscripción (preview)
|
|
static async calculatePriceWithBenefits(
|
|
userId: string,
|
|
courtId: string,
|
|
startTime: string,
|
|
endTime: string
|
|
) {
|
|
// Obtener información de la cancha
|
|
const court = await prisma.court.findUnique({
|
|
where: { id: courtId },
|
|
});
|
|
|
|
if (!court) {
|
|
throw new ApiError('Cancha no encontrada', 404);
|
|
}
|
|
|
|
// Calcular precio base
|
|
const startHour = parseInt(startTime.split(':')[0]);
|
|
const endHour = parseInt(endTime.split(':')[0]);
|
|
const hours = endHour - startHour;
|
|
const basePrice = court.pricePerHour * hours;
|
|
|
|
// Obtener beneficios de suscripción
|
|
const subscriptionBenefits = await SubscriptionService.getCurrentBenefits(userId);
|
|
|
|
// Calcular precio final
|
|
let finalPrice = basePrice;
|
|
let discountApplied = 0;
|
|
|
|
if (subscriptionBenefits.freeBookingsRemaining > 0) {
|
|
finalPrice = 0;
|
|
discountApplied = basePrice;
|
|
} else if (subscriptionBenefits.discountPercentage > 0) {
|
|
discountApplied = Math.round((basePrice * subscriptionBenefits.discountPercentage) / 100);
|
|
finalPrice = basePrice - discountApplied;
|
|
}
|
|
|
|
return {
|
|
basePrice,
|
|
finalPrice,
|
|
discountApplied,
|
|
subscriptionBenefits,
|
|
};
|
|
}
|
|
}
|
|
|
|
export default BookingService;
|