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
384 lines
9.8 KiB
TypeScript
384 lines
9.8 KiB
TypeScript
import prisma from '../config/database';
|
|
import { ApiError } from '../middleware/errorHandler';
|
|
import { UserBonusStatus, BookingStatus } from '../utils/constants';
|
|
import logger from '../config/logger';
|
|
|
|
export interface PurchaseBonusInput {
|
|
userId: string;
|
|
bonusPackId: string;
|
|
paymentId: string;
|
|
}
|
|
|
|
export interface UseBonusInput {
|
|
userId: string;
|
|
bookingId: string;
|
|
}
|
|
|
|
export class UserBonusService {
|
|
// Comprar un bono
|
|
static async purchaseBonus(userId: string, bonusPackId: string, paymentId: string) {
|
|
// Verificar que el bono exista y esté activo
|
|
const bonusPack = await prisma.bonusPack.findFirst({
|
|
where: { id: bonusPackId, isActive: true },
|
|
});
|
|
|
|
if (!bonusPack) {
|
|
throw new ApiError('Pack de bonos no encontrado o inactivo', 404);
|
|
}
|
|
|
|
// Calcular fechas
|
|
const purchaseDate = new Date();
|
|
const expirationDate = new Date();
|
|
expirationDate.setDate(expirationDate.getDate() + bonusPack.validityDays);
|
|
|
|
// Crear el bono del usuario
|
|
const userBonus = await prisma.userBonus.create({
|
|
data: {
|
|
userId,
|
|
bonusPackId,
|
|
totalBookings: bonusPack.numberOfBookings,
|
|
usedBookings: 0,
|
|
remainingBookings: bonusPack.numberOfBookings,
|
|
purchaseDate,
|
|
expirationDate,
|
|
status: UserBonusStatus.ACTIVE,
|
|
paymentId,
|
|
},
|
|
include: {
|
|
bonusPack: {
|
|
select: {
|
|
name: true,
|
|
numberOfBookings: true,
|
|
validityDays: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
logger.info(`Bono comprado: ${userBonus.id} por usuario: ${userId}`);
|
|
|
|
return userBonus;
|
|
}
|
|
|
|
// Obtener mis bonos activos
|
|
static async getMyBonuses(userId: string, includeExpired = false) {
|
|
const where: any = { userId };
|
|
|
|
if (!includeExpired) {
|
|
where.status = UserBonusStatus.ACTIVE;
|
|
}
|
|
|
|
const bonuses = await prisma.userBonus.findMany({
|
|
where,
|
|
include: {
|
|
bonusPack: {
|
|
select: {
|
|
name: true,
|
|
description: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: [
|
|
{ expirationDate: 'asc' },
|
|
{ createdAt: 'desc' },
|
|
],
|
|
});
|
|
|
|
// Verificar y actualizar bonos expirados
|
|
const now = new Date();
|
|
const updatedBonuses = [];
|
|
|
|
for (const bonus of bonuses) {
|
|
if (bonus.status === UserBonusStatus.ACTIVE && bonus.expirationDate < now) {
|
|
// Actualizar a expirado
|
|
await prisma.userBonus.update({
|
|
where: { id: bonus.id },
|
|
data: { status: UserBonusStatus.EXPIRED },
|
|
});
|
|
bonus.status = UserBonusStatus.EXPIRED;
|
|
}
|
|
updatedBonuses.push(bonus);
|
|
}
|
|
|
|
return updatedBonuses;
|
|
}
|
|
|
|
// Obtener bono por ID (verificar que pertenezca al usuario)
|
|
static async getBonusById(id: string, userId: string) {
|
|
const bonus = await prisma.userBonus.findFirst({
|
|
where: { id, userId },
|
|
include: {
|
|
bonusPack: {
|
|
select: {
|
|
name: true,
|
|
description: true,
|
|
price: true,
|
|
},
|
|
},
|
|
usages: {
|
|
include: {
|
|
booking: {
|
|
select: {
|
|
id: true,
|
|
date: true,
|
|
startTime: true,
|
|
endTime: true,
|
|
court: {
|
|
select: {
|
|
name: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
usedAt: 'desc',
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!bonus) {
|
|
throw new ApiError('Bono no encontrado', 404);
|
|
}
|
|
|
|
// Verificar si está expirado
|
|
if (bonus.status === UserBonusStatus.ACTIVE && bonus.expirationDate < new Date()) {
|
|
await prisma.userBonus.update({
|
|
where: { id: bonus.id },
|
|
data: { status: UserBonusStatus.EXPIRED },
|
|
});
|
|
bonus.status = UserBonusStatus.EXPIRED;
|
|
}
|
|
|
|
return bonus;
|
|
}
|
|
|
|
// Usar un bono para una reserva
|
|
static async useBonusForBooking(userId: string, bookingId: string, userBonusId?: string) {
|
|
// Verificar que la reserva exista y pertenezca al usuario
|
|
const booking = await prisma.booking.findFirst({
|
|
where: { id: bookingId, userId },
|
|
include: {
|
|
court: true,
|
|
},
|
|
});
|
|
|
|
if (!booking) {
|
|
throw new ApiError('Reserva no encontrada', 404);
|
|
}
|
|
|
|
// Verificar que la reserva esté confirmada o pendiente
|
|
if (booking.status !== BookingStatus.PENDING && booking.status !== BookingStatus.CONFIRMED) {
|
|
throw new ApiError('Solo se pueden aplicar bonos a reservas pendientes o confirmadas', 400);
|
|
}
|
|
|
|
// Verificar que no tenga ya un bono aplicado
|
|
const existingUsage = await prisma.bonusUsage.findUnique({
|
|
where: { bookingId },
|
|
});
|
|
|
|
if (existingUsage) {
|
|
throw new ApiError('Esta reserva ya tiene un bono aplicado', 400);
|
|
}
|
|
|
|
// Si se especifica un bono específico, usar ese
|
|
let userBonus;
|
|
if (userBonusId) {
|
|
userBonus = await prisma.userBonus.findFirst({
|
|
where: {
|
|
id: userBonusId,
|
|
userId,
|
|
status: UserBonusStatus.ACTIVE,
|
|
remainingBookings: { gt: 0 },
|
|
},
|
|
include: {
|
|
bonusPack: true,
|
|
},
|
|
});
|
|
|
|
if (!userBonus) {
|
|
throw new ApiError('Bono no encontrado, no activo o sin reservas disponibles', 404);
|
|
}
|
|
} else {
|
|
// Buscar el bono activo más próximo a expirar (FIFO)
|
|
const now = new Date();
|
|
userBonus = await prisma.userBonus.findFirst({
|
|
where: {
|
|
userId,
|
|
status: UserBonusStatus.ACTIVE,
|
|
remainingBookings: { gt: 0 },
|
|
expirationDate: { gt: now },
|
|
},
|
|
include: {
|
|
bonusPack: true,
|
|
},
|
|
orderBy: {
|
|
expirationDate: 'asc',
|
|
},
|
|
});
|
|
|
|
if (!userBonus) {
|
|
throw new ApiError('No tienes bonos activos disponibles para usar', 400);
|
|
}
|
|
}
|
|
|
|
// Verificar que el bono no esté expirado
|
|
if (userBonus.expirationDate < new Date()) {
|
|
await prisma.userBonus.update({
|
|
where: { id: userBonus.id },
|
|
data: { status: UserBonusStatus.EXPIRED },
|
|
});
|
|
throw new ApiError('El bono ha expirado', 400);
|
|
}
|
|
|
|
// Ejecutar la transacción
|
|
const result = await prisma.$transaction(async (tx) => {
|
|
// Descontar 1 del bono
|
|
const newRemaining = userBonus!.remainingBookings - 1;
|
|
const newStatus = newRemaining === 0 ? UserBonusStatus.DEPLETED : UserBonusStatus.ACTIVE;
|
|
|
|
const updatedBonus = await tx.userBonus.update({
|
|
where: { id: userBonus!.id },
|
|
data: {
|
|
usedBookings: { increment: 1 },
|
|
remainingBookings: newRemaining,
|
|
status: newStatus,
|
|
},
|
|
});
|
|
|
|
// Crear registro de uso
|
|
const usage = await tx.bonusUsage.create({
|
|
data: {
|
|
userBonusId: userBonus!.id,
|
|
bookingId,
|
|
usedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
// Actualizar la reserva (precio = 0 cuando se usa bono)
|
|
const updatedBooking = await tx.booking.update({
|
|
where: { id: bookingId },
|
|
data: { totalPrice: 0 },
|
|
});
|
|
|
|
return { updatedBonus, usage, updatedBooking };
|
|
});
|
|
|
|
logger.info(`Bono usado: ${userBonus.id} para booking: ${bookingId}`);
|
|
|
|
return {
|
|
message: 'Bono aplicado exitosamente',
|
|
bonusUsage: result.usage,
|
|
remainingBookings: result.updatedBonus.remainingBookings,
|
|
booking: result.updatedBooking,
|
|
};
|
|
}
|
|
|
|
// Verificar y marcar bonos expirados (para cron job)
|
|
static async checkExpiredBonuses() {
|
|
const now = new Date();
|
|
|
|
const expiredBonuses = await prisma.userBonus.findMany({
|
|
where: {
|
|
status: UserBonusStatus.ACTIVE,
|
|
expirationDate: { lt: now },
|
|
},
|
|
});
|
|
|
|
if (expiredBonuses.length === 0) {
|
|
return { count: 0, bonuses: [] };
|
|
}
|
|
|
|
// Actualizar todos los bonos expirados
|
|
const updatePromises = expiredBonuses.map((bonus) =>
|
|
prisma.userBonus.update({
|
|
where: { id: bonus.id },
|
|
data: { status: UserBonusStatus.EXPIRED },
|
|
})
|
|
);
|
|
|
|
await Promise.all(updatePromises);
|
|
|
|
logger.info(`${expiredBonuses.length} bonos marcados como expirados`);
|
|
|
|
return {
|
|
count: expiredBonuses.length,
|
|
bonuses: expiredBonuses.map((b) => ({
|
|
id: b.id,
|
|
userId: b.userId,
|
|
expirationDate: b.expirationDate,
|
|
})),
|
|
};
|
|
}
|
|
|
|
// Obtener historial de uso de un bono
|
|
static async getBonusUsageHistory(userBonusId: string, userId: string) {
|
|
// Verificar que el bono pertenezca al usuario
|
|
const bonus = await prisma.userBonus.findFirst({
|
|
where: { id: userBonusId, userId },
|
|
});
|
|
|
|
if (!bonus) {
|
|
throw new ApiError('Bono no encontrado', 404);
|
|
}
|
|
|
|
const usages = await prisma.bonusUsage.findMany({
|
|
where: { userBonusId },
|
|
include: {
|
|
booking: {
|
|
select: {
|
|
id: true,
|
|
date: true,
|
|
startTime: true,
|
|
endTime: true,
|
|
court: {
|
|
select: {
|
|
name: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
usedAt: 'desc',
|
|
},
|
|
});
|
|
|
|
return {
|
|
bonusId: userBonusId,
|
|
totalBookings: bonus.totalBookings,
|
|
usedBookings: bonus.usedBookings,
|
|
remainingBookings: bonus.remainingBookings,
|
|
usages,
|
|
};
|
|
}
|
|
|
|
// Obtener bonos disponibles para un usuario (para mostrar en checkout)
|
|
static async getAvailableBonuses(userId: string) {
|
|
const now = new Date();
|
|
|
|
return prisma.userBonus.findMany({
|
|
where: {
|
|
userId,
|
|
status: UserBonusStatus.ACTIVE,
|
|
remainingBookings: { gt: 0 },
|
|
expirationDate: { gt: now },
|
|
},
|
|
include: {
|
|
bonusPack: {
|
|
select: {
|
|
name: true,
|
|
description: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
expirationDate: 'asc',
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
export default UserBonusService;
|