Files
app-padel/backend/_future_services/userBonus.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

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;