import prisma from '../config/database'; import { ApiError } from '../middleware/errorHandler'; import logger from '../config/logger'; import { PaymentService } from './payment.service'; import { EquipmentService } from './equipment.service'; // Estados del alquiler export const RentalStatus = { RESERVED: 'RESERVED', PICKED_UP: 'PICKED_UP', RETURNED: 'RETURNED', LATE: 'LATE', DAMAGED: 'DAMAGED', CANCELLED: 'CANCELLED', } as const; export type RentalStatusType = typeof RentalStatus[keyof typeof RentalStatus]; // Interfaces export interface RentalItemInput { itemId: string; quantity: number; } export interface CreateRentalInput { items: RentalItemInput[]; startDate: Date; endDate: Date; bookingId?: string; } export class EquipmentRentalService { /** * Crear un nuevo alquiler */ static async createRental(userId: string, data: CreateRentalInput) { const { items, startDate, endDate, bookingId } = data; // Validar fechas const now = new Date(); if (new Date(startDate) < now) { throw new ApiError('La fecha de inicio debe ser futura', 400); } if (new Date(endDate) <= new Date(startDate)) { throw new ApiError('La fecha de fin debe ser posterior a la de inicio', 400); } // Validar que hay items if (!items || items.length === 0) { throw new ApiError('Debe seleccionar al menos un item', 400); } // Verificar booking si se proporciona if (bookingId) { const booking = await prisma.booking.findFirst({ where: { id: bookingId, userId, }, }); if (!booking) { throw new ApiError('Reserva no encontrada', 404); } // Verificar que las fechas del alquiler coincidan con la reserva const bookingDate = new Date(booking.date); const rentalStart = new Date(startDate); if ( bookingDate.getDate() !== rentalStart.getDate() || bookingDate.getMonth() !== rentalStart.getMonth() || bookingDate.getFullYear() !== rentalStart.getFullYear() ) { throw new ApiError( 'Las fechas del alquiler deben coincidir con la fecha de la reserva', 400 ); } } // Verificar disponibilidad de cada item const rentalItems = []; let totalCost = 0; let totalDeposit = 0; for (const itemInput of items) { const equipment = await prisma.equipmentItem.findUnique({ where: { id: itemInput.itemId, isActive: true, }, }); if (!equipment) { throw new ApiError( `Equipamiento no encontrado: ${itemInput.itemId}`, 404 ); } // Verificar disponibilidad const availability = await EquipmentService.checkAvailability( itemInput.itemId, new Date(startDate), new Date(endDate) ); if (!availability.available || availability.quantityAvailable < itemInput.quantity) { throw new ApiError( `No hay suficiente stock disponible para: ${equipment.name}`, 400 ); } // Calcular duración en horas y días const durationMs = new Date(endDate).getTime() - new Date(startDate).getTime(); const durationHours = Math.ceil(durationMs / (1000 * 60 * 60)); const durationDays = Math.ceil(durationHours / 24); // Calcular costo let itemCost = 0; if (equipment.dailyRate && durationDays >= 1) { itemCost = equipment.dailyRate * durationDays * itemInput.quantity; } else if (equipment.hourlyRate) { itemCost = equipment.hourlyRate * durationHours * itemInput.quantity; } totalCost += itemCost; totalDeposit += equipment.depositRequired * itemInput.quantity; rentalItems.push({ itemId: itemInput.itemId, quantity: itemInput.quantity, hourlyRate: equipment.hourlyRate, dailyRate: equipment.dailyRate, equipment, }); } // Crear el alquiler const rental = await prisma.equipmentRental.create({ data: { userId, bookingId: bookingId || null, startDate: new Date(startDate), endDate: new Date(endDate), totalCost, depositAmount: totalDeposit, status: RentalStatus.RESERVED, items: { create: rentalItems.map((ri) => ({ itemId: ri.itemId, quantity: ri.quantity, hourlyRate: ri.hourlyRate, dailyRate: ri.dailyRate, })), }, }, include: { items: { include: { item: true, }, }, user: { select: { id: true, firstName: true, lastName: true, email: true, }, }, }, }); // Actualizar cantidades disponibles for (const item of rentalItems) { await prisma.equipmentItem.update({ where: { id: item.itemId }, data: { quantityAvailable: { decrement: item.quantity, }, }, }); } // Crear preferencia de pago en MercadoPago let paymentPreference = null; try { const payment = await PaymentService.createPreference(userId, { type: 'EQUIPMENT_RENTAL', referenceId: rental.id, title: `Alquiler de equipamiento - ${rental.items.length} items`, description: `Alquiler de material deportivo desde ${startDate.toLocaleDateString()} hasta ${endDate.toLocaleDateString()}`, amount: totalCost, metadata: { rentalId: rental.id, items: rentalItems.map((ri) => ({ name: ri.equipment.name, quantity: ri.quantity, })), }, }); paymentPreference = { id: payment.id, initPoint: payment.initPoint, sandboxInitPoint: payment.sandboxInitPoint, }; // Actualizar rental con el paymentId await prisma.equipmentRental.update({ where: { id: rental.id }, data: { paymentId: payment.paymentId, }, }); } catch (error) { logger.error('Error creando preferencia de pago para alquiler:', error); // No fallar el alquiler si el pago falla, pero informar } logger.info(`Alquiler creado: ${rental.id} para usuario ${userId}`); return { rental: { id: rental.id, startDate: rental.startDate, endDate: rental.endDate, totalCost: rental.totalCost, depositAmount: rental.depositAmount, status: rental.status, items: rental.items.map((ri) => ({ id: ri.id, quantity: ri.quantity, item: { id: ri.item.id, name: ri.item.name, category: ri.item.category, brand: ri.item.brand, imageUrl: ri.item.imageUrl, }, })), }, user: rental.user, payment: paymentPreference, }; } /** * Procesar webhook de pago de MercadoPago */ static async processPaymentWebhook(payload: any) { // El webhook es procesado por PaymentService // Aquí podemos hacer acciones adicionales si es necesario logger.info('Webhook de pago para alquiler procesado', { payload }); return { processed: true }; } /** * Actualizar estado del alquiler después del pago */ static async confirmRentalPayment(rentalId: string) { const rental = await prisma.equipmentRental.findUnique({ where: { id: rentalId }, }); if (!rental) { throw new ApiError('Alquiler no encontrado', 404); } if (rental.status !== RentalStatus.RESERVED) { throw new ApiError('El alquiler ya no está en estado reservado', 400); } // El alquiler se mantiene en RESERVED hasta que se retire el material logger.info(`Pago confirmado para alquiler: ${rentalId}`); return rental; } /** * Obtener mis alquileres */ static async getMyRentals(userId: string) { const rentals = await prisma.equipmentRental.findMany({ where: { userId }, include: { items: { include: { item: { select: { id: true, name: true, category: true, brand: true, imageUrl: true, }, }, }, }, booking: { select: { id: true, date: true, startTime: true, endTime: true, court: { select: { name: true, }, }, }, }, }, orderBy: { createdAt: 'desc', }, }); return rentals.map((rental) => ({ id: rental.id, startDate: rental.startDate, endDate: rental.endDate, totalCost: rental.totalCost, depositAmount: rental.depositAmount, depositReturned: rental.depositReturned, status: rental.status, pickedUpAt: rental.pickedUpAt, returnedAt: rental.returnedAt, items: rental.items.map((ri) => ({ id: ri.id, quantity: ri.quantity, item: ri.item, })), booking: rental.booking, })); } /** * Obtener detalle de un alquiler */ static async getRentalById(id: string, userId?: string) { const rental = await prisma.equipmentRental.findUnique({ where: { id }, include: { items: { include: { item: true, }, }, user: { select: { id: true, firstName: true, lastName: true, email: true, phone: true, }, }, booking: { select: { id: true, date: true, startTime: true, endTime: true, court: { select: { name: true, }, }, }, }, }, }); if (!rental) { throw new ApiError('Alquiler no encontrado', 404); } // Si se proporciona userId, verificar que sea el dueño if (userId && rental.userId !== userId) { throw new ApiError('No tienes permiso para ver este alquiler', 403); } return rental; } /** * Entregar material (pickup) - Admin */ static async pickUpRental(rentalId: string, adminId: string) { const rental = await prisma.equipmentRental.findUnique({ where: { id: rentalId }, include: { items: { include: { item: true, }, }, user: { select: { id: true, firstName: true, lastName: true, email: true, }, }, }, }); if (!rental) { throw new ApiError('Alquiler no encontrado', 404); } if (rental.status !== RentalStatus.RESERVED) { throw new ApiError( `No se puede entregar el material. Estado actual: ${rental.status}`, 400 ); } const updated = await prisma.equipmentRental.update({ where: { id: rentalId }, data: { status: RentalStatus.PICKED_UP, pickedUpAt: new Date(), }, include: { items: { include: { item: { select: { id: true, name: true, category: true, }, }, }, }, }, }); logger.info(`Material entregado para alquiler: ${rentalId} por admin ${adminId}`); return { rental: updated, user: rental.user, }; } /** * Devolver material - Admin */ static async returnRental( rentalId: string, adminId: string, condition?: string, depositReturned?: number ) { const rental = await prisma.equipmentRental.findUnique({ where: { id: rentalId }, include: { items: { include: { item: true, }, }, }, }); if (!rental) { throw new ApiError('Alquiler no encontrado', 404); } if (rental.status !== RentalStatus.PICKED_UP && rental.status !== RentalStatus.LATE) { throw new ApiError( `No se puede devolver el material. Estado actual: ${rental.status}`, 400 ); } // Determinar nuevo estado let newStatus: RentalStatusType = RentalStatus.RETURNED; const notes = []; if (condition) { notes.push(`Condición al devolver: ${condition}`); if (condition === 'DAMAGED') { newStatus = RentalStatus.DAMAGED; } } // Verificar si está vencido const now = new Date(); if (now > new Date(rental.endDate) && newStatus !== RentalStatus.DAMAGED) { newStatus = RentalStatus.LATE; } const depositReturnAmount = depositReturned ?? rental.depositAmount; const updated = await prisma.equipmentRental.update({ where: { id: rentalId }, data: { status: newStatus, returnedAt: now, depositReturned: depositReturnAmount, notes: notes.length > 0 ? notes.join(' | ') : undefined, }, include: { items: { include: { item: true, }, }, user: { select: { id: true, firstName: true, lastName: true, email: true, }, }, }, }); // Restaurar cantidades disponibles for (const item of rental.items) { await prisma.equipmentItem.update({ where: { id: item.itemId }, data: { quantityAvailable: { increment: item.quantity, }, }, }); } logger.info(`Material devuelto para alquiler: ${rentalId} por admin ${adminId}`); return { rental: updated, user: updated.user, depositReturned: depositReturnAmount, }; } /** * Cancelar alquiler */ static async cancelRental(rentalId: string, userId: string) { const rental = await prisma.equipmentRental.findUnique({ where: { id: rentalId }, include: { items: true, }, }); if (!rental) { throw new ApiError('Alquiler no encontrado', 404); } // Verificar que sea el dueño if (rental.userId !== userId) { throw new ApiError('No tienes permiso para cancelar este alquiler', 403); } // Solo se puede cancelar si está RESERVED if (rental.status !== RentalStatus.RESERVED) { throw new ApiError( `No se puede cancelar el alquiler. Estado actual: ${rental.status}`, 400 ); } const updated = await prisma.equipmentRental.update({ where: { id: rentalId }, data: { status: RentalStatus.CANCELLED, }, }); // Restaurar cantidades disponibles for (const item of rental.items) { await prisma.equipmentItem.update({ where: { id: item.itemId }, data: { quantityAvailable: { increment: item.quantity, }, }, }); } logger.info(`Alquiler cancelado: ${rentalId} por usuario ${userId}`); return updated; } /** * Obtener alquileres vencidos (admin) */ static async getOverdueRentals() { const now = new Date(); const overdue = await prisma.equipmentRental.findMany({ where: { endDate: { lt: now, }, status: { in: [RentalStatus.RESERVED, RentalStatus.PICKED_UP], }, }, include: { items: { include: { item: { select: { id: true, name: true, category: true, }, }, }, }, user: { select: { id: true, firstName: true, lastName: true, email: true, phone: true, }, }, }, orderBy: { endDate: 'asc', }, }); // Actualizar estado a LATE para los que están PICKED_UP for (const rental of overdue) { if (rental.status === RentalStatus.PICKED_UP) { await prisma.equipmentRental.update({ where: { id: rental.id }, data: { status: RentalStatus.LATE }, }); } } return overdue.map((rental) => ({ ...rental, overdueHours: Math.floor( (now.getTime() - new Date(rental.endDate).getTime()) / (1000 * 60 * 60) ), })); } /** * Obtener todos los alquileres (admin) */ static async getAllRentals(filters?: { status?: RentalStatusType; userId?: string }) { const where: any = {}; if (filters?.status) { where.status = filters.status; } if (filters?.userId) { where.userId = filters.userId; } const rentals = await prisma.equipmentRental.findMany({ where, include: { items: { include: { item: { select: { id: true, name: true, category: true, }, }, }, }, user: { select: { id: true, firstName: true, lastName: true, email: true, }, }, }, orderBy: { createdAt: 'desc', }, }); return rentals; } /** * Obtener estadísticas de alquileres */ static async getRentalStats() { const [ totalRentals, activeRentals, reservedRentals, returnedRentals, lateRentals, damagedRentals, totalRevenue, ] = await Promise.all([ prisma.equipmentRental.count(), prisma.equipmentRental.count({ where: { status: RentalStatus.PICKED_UP } }), prisma.equipmentRental.count({ where: { status: RentalStatus.RESERVED } }), prisma.equipmentRental.count({ where: { status: RentalStatus.RETURNED } }), prisma.equipmentRental.count({ where: { status: RentalStatus.LATE } }), prisma.equipmentRental.count({ where: { status: RentalStatus.DAMAGED } }), prisma.equipmentRental.aggregate({ _sum: { totalCost: true, }, where: { status: { in: [RentalStatus.PICKED_UP, RentalStatus.RETURNED], }, }, }), ]); return { total: totalRentals, byStatus: { reserved: reservedRentals, active: activeRentals, returned: returnedRentals, late: lateRentals, damaged: damagedRentals, }, totalRevenue: totalRevenue._sum.totalCost || 0, }; } } export default EquipmentRentalService;