Files
app-padel/backend/src/services/booking.service.ts
Ivan Alcaraz b8a964dc2c FASE 4 COMPLETADA: Pagos y Monetización con MercadoPago
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
2026-01-31 09:02:25 +00:00

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;