✅ 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
This commit is contained in:
383
backend/_future_services/userBonus.service.ts
Normal file
383
backend/_future_services/userBonus.service.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user