Files
app-padel/backend/src/services/equipmentRental.service.ts
Ivan Alcaraz e135e7ad24 FASE 6 PARCIAL: Extras y Diferenciadores (base implementada)
Implementados módulos base de Fase 6:

1. WALL OF FAME (base)
   - Modelo de base de datos
   - Servicio CRUD
   - Controladores
   - Endpoints: GET /wall-of-fame/*

2. ACHIEVEMENTS/LOGROS (base)
   - Modelo de logros desbloqueables
   - Servicio de progreso
   - Controladores base
   - Endpoints: GET /achievements/*

3. QR CHECK-IN (completo)
   - Generación de códigos QR
   - Validación y procesamiento
   - Check-in/check-out
   - Endpoints: /checkin/*

4. BASE DE DATOS
   - Tablas: wall_of_fame, achievements, qr_codes, check_ins
   - Tablas preparadas: equipment, orders, notifications, activities

Estructura lista para:
- Equipment/Material rental
- Orders/Servicios del club
- Wearables integration
- Challenges/Retos

Nota: Algunos módulos avanzados requieren ajustes finales.
2026-01-31 21:59:36 +00:00

754 lines
18 KiB
TypeScript

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;