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:
2026-01-31 09:02:25 +00:00
parent 6494e2b38b
commit b8a964dc2c
44 changed files with 7084 additions and 9 deletions

View File

@@ -33,3 +33,15 @@ EMAIL_FROM="Canchas Padel <noreply@tudominio.com>"
# ============================================
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
# ============================================
# Configuración de MercadoPago (Fase 4.1)
# ============================================
MERCADOPAGO_ACCESS_TOKEN=TEST-0000000000000000-000000-00000000000000000000000000000000-000000000
MERCADOPAGO_PUBLIC_KEY=TEST-00000000-0000-0000-0000-000000000000
MERCADOPAGO_WEBHOOK_SECRET=webhook_secret_opcional_para_validar_firma
# URLs de retorno (opcional - por defecto usa FRONTEND_URL)
# MERCADOPAGO_SUCCESS_URL=http://localhost:5173/payment/success
# MERCADOPAGO_FAILURE_URL=http://localhost:5173/payment/failure
# MERCADOPAGO_PENDING_URL=http://localhost:5173/payment/pending

View File

@@ -0,0 +1,20 @@
import { Router } from 'express';
import { ClassController } from '../controllers/class.controller';
import { authenticate, authorize } from '../middleware/auth';
import { validate } from '../middleware/validate';
import { createClassSchema, createClassBookingSchema } from '../validators/class.validator';
const router = Router();
// Rutas públicas
router.get('/', ClassController.getClasses);
router.get('/:id', ClassController.getClassById);
router.get('/:id/sessions', ClassController.getClassBookings);
// Rutas protegidas (solo coaches)
router.post('/', authenticate, validate(createClassSchema), ClassController.createClass);
router.put('/:id', authenticate, ClassController.updateClass);
router.delete('/:id', authenticate, ClassController.deleteClass);
router.post('/:id/sessions', authenticate, validate(createClassBookingSchema), ClassController.createClassBooking);
export default router;

View File

@@ -0,0 +1,103 @@
import { Request, Response, NextFunction } from 'express';
import { ClassEnrollmentService } from '../services/classEnrollment.service';
import { ApiError } from '../middleware/errorHandler';
export class ClassEnrollmentController {
// Inscribirse en una clase
static async enrollInClass(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId;
const result = await ClassEnrollmentService.enrollInClass(userId, req.body);
res.status(201).json({
success: true,
message: 'Inscripción creada exitosamente',
data: result,
});
} catch (error) {
next(error);
}
}
// Webhook de MercadoPago
static async webhook(req: Request, res: Response, next: NextFunction) {
try {
// Responder inmediatamente a MP
res.status(200).send('OK');
// Procesar el webhook de forma asíncrona
await ClassEnrollmentService.processPaymentWebhook(req.body);
} catch (error) {
// Loggear error pero no enviar respuesta (ya se envió 200)
console.error('Error procesando webhook:', error);
}
}
// Cancelar inscripción
static async cancelEnrollment(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId;
const { id } = req.params;
const result = await ClassEnrollmentService.cancelEnrollment(userId, id);
res.status(200).json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
}
// Obtener mis inscripciones
static async getMyEnrollments(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId;
const status = req.query.status as string | undefined;
const enrollments = await ClassEnrollmentService.getMyEnrollments(userId, status);
res.status(200).json({
success: true,
count: enrollments.length,
data: enrollments,
});
} catch (error) {
next(error);
}
}
// Obtener inscripción por ID
static async getEnrollmentById(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId;
const { id } = req.params;
const enrollment = await ClassEnrollmentService.getEnrollmentById(id, userId);
res.status(200).json({
success: true,
data: enrollment,
});
} catch (error) {
next(error);
}
}
// Marcar asistencia (solo coach)
static async markAttendance(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId;
const { id } = req.params;
const enrollment = await ClassEnrollmentService.markAttendance(id, userId);
res.status(200).json({
success: true,
message: 'Asistencia marcada exitosamente',
data: enrollment,
});
} catch (error) {
next(error);
}
}
}
export default ClassEnrollmentController;

View File

@@ -0,0 +1,19 @@
import { Router } from 'express';
import { ClassEnrollmentController } from '../controllers/classEnrollment.controller';
import { authenticate, authorize } from '../middleware/auth';
import { validate } from '../middleware/validate';
import { enrollmentSchema } from '../validators/class.validator';
const router = Router();
// Webhook de MercadoPago (público)
router.post('/webhook', ClassEnrollmentController.webhook);
// Rutas protegidas
router.post('/', authenticate, validate(enrollmentSchema), ClassEnrollmentController.enrollInClass);
router.get('/my', authenticate, ClassEnrollmentController.getMyEnrollments);
router.get('/:id', authenticate, ClassEnrollmentController.getEnrollmentById);
router.delete('/:id', authenticate, ClassEnrollmentController.cancelEnrollment);
router.put('/:id/attend', authenticate, ClassEnrollmentController.markAttendance);
export default router;

View File

@@ -0,0 +1,29 @@
import { Router } from 'express';
import { CoachController } from '../controllers/coach.controller';
import { authenticate, authorize } from '../middleware/auth';
import { validate } from '../middleware/validate';
import { registerCoachSchema, reviewSchema } from '../validators/class.validator';
import { UserRole } from '../utils/constants';
const router = Router();
// Rutas públicas
router.get('/', CoachController.getCoaches);
router.get('/:id', CoachController.getCoachById);
router.get('/:id/availability', CoachController.getAvailability);
router.get('/:id/reviews', CoachController.getReviews);
// Rutas protegidas (usuarios autenticados)
router.post('/register', authenticate, validate(registerCoachSchema), CoachController.registerAsCoach);
router.get('/me/profile', authenticate, CoachController.getMyProfile);
router.put('/me', authenticate, CoachController.updateMyProfile);
router.post('/me/availability', authenticate, CoachController.addAvailability);
router.post('/:id/reviews', authenticate, validate(reviewSchema), CoachController.addReview);
// Eliminar disponibilidad
router.delete('/availability/:id', authenticate, CoachController.removeAvailability);
// Rutas de admin
router.put('/:id/verify', authenticate, authorize(UserRole.ADMIN, UserRole.SUPERADMIN), CoachController.verifyCoach);
export default router;

View File

@@ -0,0 +1,136 @@
import prisma from '../config/database';
import { ApiError } from '../middleware/errorHandler';
import logger from '../config/logger';
export interface CreateBonusPackInput {
name: string;
description?: string;
numberOfBookings: number;
price: number;
validityDays: number;
}
export interface UpdateBonusPackInput {
name?: string;
description?: string;
numberOfBookings?: number;
price?: number;
validityDays?: number;
isActive?: boolean;
}
export class BonusPackService {
// Crear un tipo de bono (admin)
static async createBonusPack(adminId: string, data: CreateBonusPackInput) {
// Validar que el precio sea positivo
if (data.price < 0) {
throw new ApiError('El precio no puede ser negativo', 400);
}
// Validar que la cantidad de reservas sea positiva
if (data.numberOfBookings <= 0) {
throw new ApiError('La cantidad de reservas debe ser mayor a 0', 400);
}
// Validar que los días de validez sean positivos
if (data.validityDays <= 0) {
throw new ApiError('Los días de validez deben ser mayor a 0', 400);
}
const bonusPack = await prisma.bonusPack.create({
data: {
name: data.name,
description: data.description,
numberOfBookings: data.numberOfBookings,
price: data.price,
validityDays: data.validityDays,
isActive: true,
},
});
logger.info(`BonusPack creado: ${bonusPack.id} por admin: ${adminId}`);
return bonusPack;
}
// Obtener todos los bonos activos (público)
static async getBonusPacks(includeInactive = false) {
const where = includeInactive ? {} : { isActive: true };
return prisma.bonusPack.findMany({
where,
orderBy: {
price: 'asc',
},
});
}
// Obtener un bono por ID
static async getBonusPackById(id: string) {
const bonusPack = await prisma.bonusPack.findUnique({
where: { id },
});
if (!bonusPack) {
throw new ApiError('Pack de bonos no encontrado', 404);
}
return bonusPack;
}
// Actualizar un tipo de bono (admin)
static async updateBonusPack(id: string, adminId: string, data: UpdateBonusPackInput) {
const bonusPack = await prisma.bonusPack.findUnique({
where: { id },
});
if (!bonusPack) {
throw new ApiError('Pack de bonos no encontrado', 404);
}
// Validaciones si se actualizan ciertos campos
if (data.price !== undefined && data.price < 0) {
throw new ApiError('El precio no puede ser negativo', 400);
}
if (data.numberOfBookings !== undefined && data.numberOfBookings <= 0) {
throw new ApiError('La cantidad de reservas debe ser mayor a 0', 400);
}
if (data.validityDays !== undefined && data.validityDays <= 0) {
throw new ApiError('Los días de validez deben ser mayor a 0', 400);
}
const updated = await prisma.bonusPack.update({
where: { id },
data,
});
logger.info(`BonusPack actualizado: ${id} por admin: ${adminId}`);
return updated;
}
// Eliminar (desactivar) un tipo de bono (admin)
static async deleteBonusPack(id: string, adminId: string) {
const bonusPack = await prisma.bonusPack.findUnique({
where: { id },
});
if (!bonusPack) {
throw new ApiError('Pack de bonos no encontrado', 404);
}
// Desactivar en lugar de eliminar físicamente
const updated = await prisma.bonusPack.update({
where: { id },
data: { isActive: false },
});
logger.info(`BonusPack desactivado: ${id} por admin: ${adminId}`);
return updated;
}
}
export default BonusPackService;

View 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;

View File

@@ -17,6 +17,7 @@
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"mercadopago": "^2.12.0",
"morgan": "^1.10.0",
"nodemailer": "^6.9.8",
"winston": "^3.11.0",
@@ -3057,6 +3058,15 @@
"node": ">= 0.6"
}
},
"node_modules/mercadopago": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/mercadopago/-/mercadopago-2.12.0.tgz",
"integrity": "sha512-9S+ZB/Fltd4BV9/U79r7U/+LrYJP844kxxvtAlVbbeVmhOE9rZt0YhPy1GXO3Yf4XyQaHwZ/SCyL2kebAicaLw==",
"dependencies": {
"node-fetch": "^2.7.0",
"uuid": "^9.0.0"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
@@ -4208,6 +4218,19 @@
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View File

@@ -14,7 +14,12 @@
"lint": "eslint src --ext .ts",
"test": "jest"
},
"keywords": ["padel", "reservas", "api", "nodejs"],
"keywords": [
"padel",
"reservas",
"api",
"nodejs"
],
"author": "Consultoria AS",
"license": "ISC",
"dependencies": {
@@ -26,6 +31,7 @@
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"mercadopago": "^2.12.0",
"morgan": "^1.10.0",
"nodemailer": "^6.9.8",
"winston": "^3.11.0",

Binary file not shown.

View File

@@ -0,0 +1,166 @@
-- CreateTable
CREATE TABLE "payments" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"referenceId" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'ARS',
"provider" TEXT NOT NULL DEFAULT 'MERCADOPAGO',
"providerPaymentId" TEXT,
"providerPreferenceId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"paymentMethod" TEXT,
"installments" INTEGER,
"metadata" TEXT,
"paidAt" DATETIME,
"refundedAt" DATETIME,
"refundAmount" INTEGER,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "payments_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "bonus_packs" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT,
"numberOfBookings" INTEGER NOT NULL,
"price" INTEGER NOT NULL,
"validityDays" INTEGER NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "user_bonuses" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"bonusPackId" TEXT NOT NULL,
"totalBookings" INTEGER NOT NULL,
"usedBookings" INTEGER NOT NULL DEFAULT 0,
"remainingBookings" INTEGER NOT NULL,
"purchaseDate" DATETIME NOT NULL,
"expirationDate" DATETIME NOT NULL,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"paymentId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "user_bonuses_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "user_bonuses_bonusPackId_fkey" FOREIGN KEY ("bonusPackId") REFERENCES "bonus_packs" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "bonus_usages" (
"id" TEXT NOT NULL PRIMARY KEY,
"userBonusId" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"usedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "bonus_usages_userBonusId_fkey" FOREIGN KEY ("userBonusId") REFERENCES "user_bonuses" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "bonus_usages_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "bookings" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "subscription_plans" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT,
"type" TEXT NOT NULL,
"price" INTEGER NOT NULL,
"features" TEXT,
"benefits" TEXT NOT NULL,
"mercadoPagoPlanId" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "user_subscriptions" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"planId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"startDate" DATETIME,
"endDate" DATETIME,
"currentPeriodStart" DATETIME,
"currentPeriodEnd" DATETIME,
"cancelAtPeriodEnd" BOOLEAN NOT NULL DEFAULT false,
"mercadoPagoSubscriptionId" TEXT,
"paymentMethodId" TEXT,
"lastPaymentDate" DATETIME,
"nextPaymentDate" DATETIME,
"freeBookingsUsed" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "user_subscriptions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "user_subscriptions_planId_fkey" FOREIGN KEY ("planId") REFERENCES "subscription_plans" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "payments_providerPreferenceId_key" ON "payments"("providerPreferenceId");
-- CreateIndex
CREATE INDEX "payments_userId_idx" ON "payments"("userId");
-- CreateIndex
CREATE INDEX "payments_status_idx" ON "payments"("status");
-- CreateIndex
CREATE INDEX "payments_type_referenceId_idx" ON "payments"("type", "referenceId");
-- CreateIndex
CREATE INDEX "payments_providerPaymentId_idx" ON "payments"("providerPaymentId");
-- CreateIndex
CREATE INDEX "payments_providerPreferenceId_idx" ON "payments"("providerPreferenceId");
-- CreateIndex
CREATE INDEX "payments_createdAt_idx" ON "payments"("createdAt");
-- CreateIndex
CREATE INDEX "bonus_packs_isActive_idx" ON "bonus_packs"("isActive");
-- CreateIndex
CREATE INDEX "user_bonuses_userId_idx" ON "user_bonuses"("userId");
-- CreateIndex
CREATE INDEX "user_bonuses_status_idx" ON "user_bonuses"("status");
-- CreateIndex
CREATE INDEX "user_bonuses_expirationDate_idx" ON "user_bonuses"("expirationDate");
-- CreateIndex
CREATE INDEX "user_bonuses_userId_status_idx" ON "user_bonuses"("userId", "status");
-- CreateIndex
CREATE INDEX "bonus_usages_userBonusId_idx" ON "bonus_usages"("userBonusId");
-- CreateIndex
CREATE INDEX "bonus_usages_usedAt_idx" ON "bonus_usages"("usedAt");
-- CreateIndex
CREATE UNIQUE INDEX "bonus_usages_bookingId_key" ON "bonus_usages"("bookingId");
-- CreateIndex
CREATE INDEX "subscription_plans_type_idx" ON "subscription_plans"("type");
-- CreateIndex
CREATE INDEX "subscription_plans_isActive_idx" ON "subscription_plans"("isActive");
-- CreateIndex
CREATE INDEX "user_subscriptions_userId_idx" ON "user_subscriptions"("userId");
-- CreateIndex
CREATE INDEX "user_subscriptions_planId_idx" ON "user_subscriptions"("planId");
-- CreateIndex
CREATE INDEX "user_subscriptions_status_idx" ON "user_subscriptions"("status");
-- CreateIndex
CREATE INDEX "user_subscriptions_mercadoPagoSubscriptionId_idx" ON "user_subscriptions"("mercadoPagoSubscriptionId");
-- CreateIndex
CREATE UNIQUE INDEX "user_subscriptions_userId_status_key" ON "user_subscriptions"("userId", "status");

View File

@@ -0,0 +1,178 @@
/*
Warnings:
- You are about to drop the `subscription_plans` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `user_subscriptions` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "subscription_plans";
PRAGMA foreign_keys=on;
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "user_subscriptions";
PRAGMA foreign_keys=on;
-- CreateTable
CREATE TABLE "coaches" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"bio" TEXT,
"specialties" TEXT,
"certifications" TEXT,
"yearsExperience" INTEGER NOT NULL DEFAULT 0,
"hourlyRate" INTEGER NOT NULL DEFAULT 0,
"photoUrl" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"isVerified" BOOLEAN NOT NULL DEFAULT false,
"rating" REAL,
"reviewCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "coaches_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "coach_availabilities" (
"id" TEXT NOT NULL PRIMARY KEY,
"coachId" TEXT NOT NULL,
"dayOfWeek" INTEGER NOT NULL,
"startTime" TEXT NOT NULL,
"endTime" TEXT NOT NULL,
"isAvailable" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "coach_availabilities_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "coaches" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "classes" (
"id" TEXT NOT NULL PRIMARY KEY,
"coachId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"type" TEXT NOT NULL DEFAULT 'INDIVIDUAL',
"maxStudents" INTEGER NOT NULL DEFAULT 1,
"price" INTEGER NOT NULL DEFAULT 0,
"duration" INTEGER NOT NULL DEFAULT 60,
"levelRequired" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "classes_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "coaches" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "class_bookings" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"coachId" TEXT NOT NULL,
"courtId" TEXT,
"date" DATETIME NOT NULL,
"startTime" TEXT NOT NULL,
"students" TEXT NOT NULL DEFAULT '[]',
"maxStudents" INTEGER NOT NULL DEFAULT 1,
"enrolledStudents" INTEGER NOT NULL DEFAULT 0,
"status" TEXT NOT NULL DEFAULT 'AVAILABLE',
"price" INTEGER NOT NULL DEFAULT 0,
"paymentId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "class_bookings_classId_fkey" FOREIGN KEY ("classId") REFERENCES "classes" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "class_bookings_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "coaches" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "class_bookings_courtId_fkey" FOREIGN KEY ("courtId") REFERENCES "courts" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "student_enrollments" (
"id" TEXT NOT NULL PRIMARY KEY,
"classBookingId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"paymentId" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"enrolledAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"cancelledAt" DATETIME,
CONSTRAINT "student_enrollments_classBookingId_fkey" FOREIGN KEY ("classBookingId") REFERENCES "class_bookings" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "student_enrollments_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "coach_reviews" (
"id" TEXT NOT NULL PRIMARY KEY,
"coachId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"rating" INTEGER NOT NULL,
"comment" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "coach_reviews_coachId_fkey" FOREIGN KEY ("coachId") REFERENCES "coaches" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "coach_reviews_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "coaches_userId_key" ON "coaches"("userId");
-- CreateIndex
CREATE INDEX "coaches_isActive_idx" ON "coaches"("isActive");
-- CreateIndex
CREATE INDEX "coaches_isVerified_idx" ON "coaches"("isVerified");
-- CreateIndex
CREATE INDEX "coaches_userId_idx" ON "coaches"("userId");
-- CreateIndex
CREATE INDEX "coach_availabilities_coachId_idx" ON "coach_availabilities"("coachId");
-- CreateIndex
CREATE INDEX "coach_availabilities_coachId_dayOfWeek_idx" ON "coach_availabilities"("coachId", "dayOfWeek");
-- CreateIndex
CREATE INDEX "coach_availabilities_dayOfWeek_idx" ON "coach_availabilities"("dayOfWeek");
-- CreateIndex
CREATE INDEX "classes_coachId_idx" ON "classes"("coachId");
-- CreateIndex
CREATE INDEX "classes_type_idx" ON "classes"("type");
-- CreateIndex
CREATE INDEX "classes_isActive_idx" ON "classes"("isActive");
-- CreateIndex
CREATE INDEX "class_bookings_classId_idx" ON "class_bookings"("classId");
-- CreateIndex
CREATE INDEX "class_bookings_coachId_idx" ON "class_bookings"("coachId");
-- CreateIndex
CREATE INDEX "class_bookings_courtId_idx" ON "class_bookings"("courtId");
-- CreateIndex
CREATE INDEX "class_bookings_date_idx" ON "class_bookings"("date");
-- CreateIndex
CREATE INDEX "class_bookings_status_idx" ON "class_bookings"("status");
-- CreateIndex
CREATE INDEX "student_enrollments_userId_idx" ON "student_enrollments"("userId");
-- CreateIndex
CREATE INDEX "student_enrollments_status_idx" ON "student_enrollments"("status");
-- CreateIndex
CREATE INDEX "student_enrollments_classBookingId_idx" ON "student_enrollments"("classBookingId");
-- CreateIndex
CREATE UNIQUE INDEX "student_enrollments_classBookingId_userId_key" ON "student_enrollments"("classBookingId", "userId");
-- CreateIndex
CREATE INDEX "coach_reviews_coachId_idx" ON "coach_reviews"("coachId");
-- CreateIndex
CREATE INDEX "coach_reviews_userId_idx" ON "coach_reviews"("userId");
-- CreateIndex
CREATE INDEX "coach_reviews_rating_idx" ON "coach_reviews"("rating");
-- CreateIndex
CREATE UNIQUE INDEX "coach_reviews_coachId_userId_key" ON "coach_reviews"("coachId", "userId");

View File

@@ -0,0 +1,57 @@
-- CreateTable
CREATE TABLE "subscription_plans" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT,
"type" TEXT NOT NULL,
"price" INTEGER NOT NULL,
"features" TEXT,
"benefits" TEXT NOT NULL,
"mercadoPagoPlanId" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "user_subscriptions" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"planId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"startDate" DATETIME,
"endDate" DATETIME,
"currentPeriodStart" DATETIME,
"currentPeriodEnd" DATETIME,
"cancelAtPeriodEnd" BOOLEAN NOT NULL DEFAULT false,
"mercadoPagoSubscriptionId" TEXT,
"paymentMethodId" TEXT,
"lastPaymentDate" DATETIME,
"nextPaymentDate" DATETIME,
"freeBookingsUsed" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "user_subscriptions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "user_subscriptions_planId_fkey" FOREIGN KEY ("planId") REFERENCES "subscription_plans" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "subscription_plans_type_idx" ON "subscription_plans"("type");
-- CreateIndex
CREATE INDEX "subscription_plans_isActive_idx" ON "subscription_plans"("isActive");
-- CreateIndex
CREATE INDEX "user_subscriptions_userId_idx" ON "user_subscriptions"("userId");
-- CreateIndex
CREATE INDEX "user_subscriptions_planId_idx" ON "user_subscriptions"("planId");
-- CreateIndex
CREATE INDEX "user_subscriptions_status_idx" ON "user_subscriptions"("status");
-- CreateIndex
CREATE INDEX "user_subscriptions_mercadoPagoSubscriptionId_idx" ON "user_subscriptions"("mercadoPagoSubscriptionId");
-- CreateIndex
CREATE UNIQUE INDEX "user_subscriptions_userId_status_key" ON "user_subscriptions"("userId", "status");

View File

@@ -76,6 +76,20 @@ model User {
tournamentsCreated Tournament[] @relation("TournamentsCreated")
tournamentParticipations TournamentParticipant[]
// Bonos (Fase 4.2)
userBonuses UserBonus[]
// Pagos (Fase 4.1)
payments Payment[]
// Suscripciones (Fase 4.3)
subscriptions UserSubscription[]
// Clases con profesores (Fase 4.4)
coach Coach?
studentEnrollments StudentEnrollment[]
coachReviews CoachReview[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -132,6 +146,7 @@ model Court {
recurringBookings RecurringBooking[]
leagueMatches LeagueMatch[]
tournamentMatches TournamentMatch[]
classBookings ClassBooking[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -193,6 +208,9 @@ model Booking {
recurringBooking RecurringBooking? @relation(fields: [recurringBookingId], references: [id])
recurringBookingId String?
// Uso de bonos
bonusUsages BonusUsage[]
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -757,3 +775,449 @@ model LeagueStanding {
@@index([points])
@@map("league_standings")
}
// ============================================
// Modelo de Pagos (Fase 4.1)
// ============================================
model Payment {
id String @id @default(uuid())
// Usuario que realiza el pago
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
// Tipo de pago: BOOKING, TOURNAMENT, BONUS, SUBSCRIPTION, CLASS
type String
// ID de la entidad relacionada (booking, tournament, etc.)
referenceId String
// Monto en centavos (para evitar decimales)
amount Int
// Moneda (ARS, MXN, etc.)
currency String @default("ARS")
// Proveedor de pago
provider String @default("MERCADOPAGO")
// IDs de MercadoPago
providerPaymentId String? // ID del pago en MP (cuando se confirma)
providerPreferenceId String @unique // ID de la preferencia MP
// Estado del pago
status String @default("PENDING") // PENDING, PROCESSING, COMPLETED, FAILED, REFUNDED, CANCELLED
// Información del método de pago
paymentMethod String?
installments Int? // Cantidad de cuotas
// Metadata adicional (JSON)
metadata String?
// Fechas
paidAt DateTime?
refundedAt DateTime?
refundAmount Int? // Monto reembolsado en centavos
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([status])
@@index([type, referenceId])
@@index([providerPaymentId])
@@index([providerPreferenceId])
@@index([createdAt])
@@map("payments")
}
// ============================================
// Modelos de Sistema de Bonos (Fase 4.2)
// ============================================
// Modelo de Pack de Bonos (tipos de bonos disponibles)
model BonusPack {
id String @id @default(uuid())
name String
description String?
// Configuración del bono
numberOfBookings Int // Cantidad de reservas incluidas
price Int // Precio del bono en centavos
validityDays Int // Días de validez desde la compra
// Estado
isActive Boolean @default(true)
// Relaciones
userBonuses UserBonus[]
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive])
@@map("bonus_packs")
}
// Modelo de Bono de Usuario (bonos comprados)
model UserBonus {
id String @id @default(uuid())
// Relaciones
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
bonusPack BonusPack @relation(fields: [bonusPackId], references: [id])
bonusPackId String
// Uso del bono
totalBookings Int
usedBookings Int @default(0)
remainingBookings Int
// Fechas
purchaseDate DateTime
expirationDate DateTime
// Estado: ACTIVE, EXPIRED, DEPLETED
status String @default("ACTIVE")
// Referencia al pago
paymentId String?
// Relaciones
usages BonusUsage[]
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([status])
@@index([expirationDate])
@@index([userId, status])
@@map("user_bonuses")
}
// Modelo de Uso de Bono (registro de usos)
model BonusUsage {
id String @id @default(uuid())
// Relaciones
userBonus UserBonus @relation(fields: [userBonusId], references: [id], onDelete: Cascade)
userBonusId String
// Reserva asociada
booking Booking @relation(fields: [bookingId], references: [id])
bookingId String
// Fecha de uso
usedAt DateTime @default(now())
@@unique([bookingId])
@@index([userBonusId])
@@index([usedAt])
@@map("bonus_usages")
}
// ============================================
// Modelos de Sistema de Suscripciones (Fase 4.3)
// ============================================
// Modelo de Plan de Suscripción (planes disponibles)
model SubscriptionPlan {
id String @id @default(uuid())
name String
description String?
// Tipo de plan
type String // MONTHLY, QUARTERLY, YEARLY
// Precio en centavos
price Int
// Características (JSON array de strings)
features String? // Ej: ["Reservas ilimitadas", "Prioridad en reservas"]
// Beneficios del plan (almacenados como JSON)
// discountPercentage: porcentaje de descuento en reservas
// freeBookingsPerMonth: cantidad de reservas gratis por mes
// priorityBooking: prioridad en reservas
// tournamentDiscount: descuento en torneos
benefits String // JSON: { discountPercentage, freeBookingsPerMonth, priorityBooking, tournamentDiscount }
// ID del plan en MercadoPago
mercadoPagoPlanId String?
// Estado
isActive Boolean @default(true)
// Relaciones
subscriptions UserSubscription[]
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([type])
@@index([isActive])
@@map("subscription_plans")
}
// Modelo de Suscripción de Usuario
model UserSubscription {
id String @id @default(uuid())
// Usuario
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
// Plan
plan SubscriptionPlan @relation(fields: [planId], references: [id])
planId String
// Estado: PENDING, ACTIVE, PAUSED, CANCELLED, EXPIRED
status String @default("PENDING")
// Fechas de suscripción
startDate DateTime?
endDate DateTime?
// Período actual
currentPeriodStart DateTime?
currentPeriodEnd DateTime?
// Cancelar al final del período
cancelAtPeriodEnd Boolean @default(false)
// Referencia a MercadoPago
mercadoPagoSubscriptionId String?
// Método de pago vinculado
paymentMethodId String?
// Fechas de pagos
lastPaymentDate DateTime?
nextPaymentDate DateTime?
// Contador de reservas gratis usadas en el período actual
freeBookingsUsed Int @default(0)
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, status])
@@index([userId])
@@index([planId])
@@index([status])
@@index([mercadoPagoSubscriptionId])
@@map("user_subscriptions")
}
// ============================================
// Modelos de Clases con Profesores (Fase 4.4)
// ============================================
// Modelo de Profesor (Coach)
model Coach {
id String @id @default(uuid())
// Relación con usuario
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @unique
// Perfil profesional
bio String?
specialties String? // JSON array de especialidades
certifications String? // JSON array de certificaciones
yearsExperience Int @default(0)
hourlyRate Int @default(0) // en centavos
photoUrl String?
// Estado
isActive Boolean @default(true)
isVerified Boolean @default(false)
// Calificaciones
rating Float?
reviewCount Int @default(0)
// Relaciones
availabilities CoachAvailability[]
classes Class[]
classBookings ClassBooking[]
coachReviews CoachReview[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([isActive])
@@index([isVerified])
@@index([userId])
@@map("coaches")
}
// Modelo de Disponibilidad del Coach
model CoachAvailability {
id String @id @default(uuid())
// Relación con coach
coach Coach @relation(fields: [coachId], references: [id], onDelete: Cascade)
coachId String
// Día de la semana (0=Domingo, 1=Lunes, ..., 6=Sábado)
dayOfWeek Int
// Horario
startTime String
endTime String
// Estado
isAvailable Boolean @default(true)
@@index([coachId])
@@index([coachId, dayOfWeek])
@@index([dayOfWeek])
@@map("coach_availabilities")
}
// Modelo de Clase (programa/tipo de clase)
model Class {
id String @id @default(uuid())
// Relación con coach
coach Coach @relation(fields: [coachId], references: [id], onDelete: Cascade)
coachId String
// Información de la clase
title String
description String?
// Tipo: INDIVIDUAL, GROUP, CLINIC
type String @default("INDIVIDUAL")
// Configuración
maxStudents Int @default(1) // Máximo de alumnos
price Int @default(0) // Precio por persona en centavos
duration Int @default(60) // Duración en minutos
// Nivel mínimo requerido
levelRequired String?
// Estado
isActive Boolean @default(true)
// Relaciones
sessions ClassBooking[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([coachId])
@@index([type])
@@index([isActive])
@@map("classes")
}
// Modelo de Sesión de Clase (instancia específica de una clase)
model ClassBooking {
id String @id @default(uuid())
// Relaciones
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
classId String
coach Coach @relation(fields: [coachId], references: [id])
coachId String
court Court? @relation(fields: [courtId], references: [id], onDelete: SetNull)
courtId String?
// Fecha y hora
date DateTime
startTime String
// Estudiantes (JSON array de userIds)
students String @default("[]")
// Cupo
maxStudents Int @default(1)
enrolledStudents Int @default(0)
// Estado: AVAILABLE, FULL, COMPLETED, CANCELLED
status String @default("AVAILABLE")
// Precio
price Int @default(0)
// Pago
paymentId String?
// Relaciones
enrollments StudentEnrollment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([classId])
@@index([coachId])
@@index([courtId])
@@index([date])
@@index([status])
@@map("class_bookings")
}
// Modelo de Inscripción de Estudiante
model StudentEnrollment {
id String @id @default(uuid())
// Relaciones
classBooking ClassBooking @relation(fields: [classBookingId], references: [id], onDelete: Cascade)
classBookingId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
// Referencia al pago
paymentId String?
// Estado: PENDING, CONFIRMED, CANCELLED, ATTENDED
status String @default("PENDING")
// Timestamps
enrolledAt DateTime @default(now())
cancelledAt DateTime?
@@unique([classBookingId, userId])
@@index([userId])
@@index([status])
@@index([classBookingId])
@@map("student_enrollments")
}
// Modelo de Reseña de Coach
model CoachReview {
id String @id @default(uuid())
// Relaciones
coach Coach @relation(fields: [coachId], references: [id], onDelete: Cascade)
coachId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
// Calificación (1-5)
rating Int
comment String?
createdAt DateTime @default(now())
@@unique([coachId, userId])
@@index([coachId])
@@index([userId])
@@index([rating])
@@map("coach_reviews")
}

View File

@@ -0,0 +1,159 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Seeding Fase 4 - Pagos y Monetización...\n');
const admin = await prisma.user.findUnique({ where: { email: 'admin@padel.com' } });
const user = await prisma.user.findUnique({ where: { email: 'user@padel.com' } });
if (!admin || !user) {
console.log('❌ Usuarios no encontrados. Ejecuta seed.ts primero.');
return;
}
// Crear Bonus Packs
const bonusPacks = [
{ name: 'Pack 5 Clases', bookings: 5, price: 9000, validity: 90 },
{ name: 'Pack 10 Clases', bookings: 10, price: 16000, validity: 180 },
{ name: 'Pack Mensual', bookings: 30, price: 40000, validity: 30 },
];
for (const pack of bonusPacks) {
await prisma.bonusPack.upsert({
where: { id: `bonus-${pack.bookings}` },
update: {},
create: {
id: `bonus-${pack.bookings}`,
name: pack.name,
description: `${pack.bookings} reservas de 1 hora cada una`,
numberOfBookings: pack.bookings,
price: pack.price,
validityDays: pack.validity,
isActive: true,
},
});
console.log(`✅ Bonus Pack creado: ${pack.name}`);
}
// Crear planes de suscripción
const plans = [
{
name: 'Básico',
type: 'MONTHLY',
price: 15000,
benefits: { discountPercentage: 10, freeBookingsPerMonth: 2, priorityBooking: false, tournamentDiscount: 5 },
desc: '10% off en reservas, 2 reservas gratis/mes'
},
{
name: 'Premium',
type: 'MONTHLY',
price: 25000,
benefits: { discountPercentage: 20, freeBookingsPerMonth: 5, priorityBooking: true, tournamentDiscount: 10 },
desc: '20% off en reservas, 5 reservas gratis/mes, prioridad'
},
{
name: 'Anual VIP',
type: 'YEARLY',
price: 250000,
benefits: { discountPercentage: 30, freeBookingsPerMonth: 10, priorityBooking: true, tournamentDiscount: 15 },
desc: '30% off en reservas, 10 reservas gratis/mes'
},
];
for (const plan of plans) {
await prisma.subscriptionPlan.upsert({
where: { id: `plan-${plan.name.toLowerCase().replace(/ /g, '-')}` },
update: {},
create: {
id: `plan-${plan.name.toLowerCase().replace(/ /g, '-')}`,
name: plan.name,
description: plan.desc,
type: plan.type,
price: plan.price,
benefits: JSON.stringify(plan.benefits),
features: JSON.stringify([`${plan.benefits.discountPercentage}% descuento`, `${plan.benefits.freeBookingsPerMonth} reservas gratis`, plan.benefits.priorityBooking ? 'Prioridad de reserva' : 'Sin prioridad']),
isActive: true,
},
});
console.log(`✅ Plan de suscripción creado: ${plan.name}`);
}
// Registrar usuario como coach
const coach = await prisma.coach.upsert({
where: { id: 'coach-1' },
update: {},
create: {
id: 'coach-1',
userId: admin.id,
bio: 'Profesor de pádel con 10 años de experiencia. Especialista en técnica y táctica.',
specialties: JSON.stringify(['Técnica', 'Táctica', 'Volea', 'Smash']),
certifications: 'Entrenador Nacional Nivel 3',
yearsExperience: 10,
hourlyRate: 5000,
isActive: true,
isVerified: true,
},
});
console.log(`✅ Coach creado: ${coach.id}`);
// Crear disponibilidad del coach
for (let day = 1; day <= 5; day++) { // Lunes a Viernes
await prisma.coachAvailability.upsert({
where: { id: `avail-${coach.id}-${day}` },
update: {},
create: {
id: `avail-${coach.id}-${day}`,
coachId: coach.id,
dayOfWeek: day,
startTime: '09:00',
endTime: '18:00',
isAvailable: true,
},
});
}
console.log('✅ Disponibilidad del coach creada');
// Crear clases
const classes = [
{ name: 'Clase Individual', type: 'INDIVIDUAL', max: 1, price: 5000, duration: 60 },
{ name: 'Clase en Pareja', type: 'GROUP', max: 2, price: 3500, duration: 60 },
{ name: 'Clínica de Volea', type: 'CLINIC', max: 8, price: 2000, duration: 90 },
];
for (const cls of classes) {
await prisma.class.upsert({
where: { id: `class-${cls.name.toLowerCase().replace(/ /g, '-')}` },
update: {},
create: {
id: `class-${cls.name.toLowerCase().replace(/ /g, '-')}`,
coachId: coach.id,
title: cls.name,
description: `Clase especializada de ${cls.name.toLowerCase()}`,
type: cls.type,
maxStudents: cls.max,
price: cls.price,
duration: cls.duration,
isActive: true,
},
});
console.log(`✅ Clase creada: ${cls.name}`);
}
console.log('\n🎾 Fase 4 seed completado!');
console.log('\nDatos creados:');
console.log(` - 3 Bonus Packs`);
console.log(` - 3 Planes de suscripción`);
console.log(` - 1 Coach verificado`);
console.log(` - 3 Clases disponibles`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -43,6 +43,17 @@ export const config = {
WINDOW_MS: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10),
MAX_REQUESTS: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10),
},
// MercadoPago
MERCADOPAGO: {
ACCESS_TOKEN: process.env.MERCADOPAGO_ACCESS_TOKEN || '',
PUBLIC_KEY: process.env.MERCADOPAGO_PUBLIC_KEY || '',
WEBHOOK_SECRET: process.env.MERCADOPAGO_WEBHOOK_SECRET || '',
// URLs de retorno (se pueden sobreescribir con variables de entorno)
SUCCESS_URL: process.env.MERCADOPAGO_SUCCESS_URL || '',
FAILURE_URL: process.env.MERCADOPAGO_FAILURE_URL || '',
PENDING_URL: process.env.MERCADOPAGO_PENDING_URL || '',
},
};
export default config;

View File

@@ -0,0 +1,30 @@
import { MercadoPagoConfig, Preference, Payment as MPPayment } from 'mercadopago';
import config from './index';
import logger from './logger';
// Determinar si estamos en modo sandbox
const isSandbox = config.NODE_ENV !== 'production';
// Configuración del cliente de MercadoPago
const mpConfig = new MercadoPagoConfig({
accessToken: config.MERCADOPAGO.ACCESS_TOKEN,
options: {
timeout: 30000, // 30 segundos
idempotencyKey: `padel-${Date.now()}`,
},
});
// Clientes específicos
export const preferenceClient = new Preference(mpConfig);
export const paymentClient = new MPPayment(mpConfig);
// Helper para verificar si la configuración es válida
export const isMercadoPagoConfigured = (): boolean => {
return !!config.MERCADOPAGO.ACCESS_TOKEN && config.MERCADOPAGO.ACCESS_TOKEN.length > 0;
};
// Log de configuración (sin exponer credenciales completas)
logger.info(`MercadoPago configurado - Modo: ${isSandbox ? 'sandbox' : 'producción'}`);
logger.info(`MercadoPago Access Token configurado: ${isMercadoPagoConfigured() ? 'Sí' : 'No'}`);
export default mpConfig;

View File

@@ -159,6 +159,35 @@ export class BookingController {
next(error);
}
}
// Calcular precio con beneficios de suscripción
static async calculatePrice(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { courtId, startTime, endTime } = req.query;
if (!courtId || !startTime || !endTime) {
throw new ApiError('Faltan parámetros: courtId, startTime, endTime', 400);
}
const priceInfo = await BookingService.calculatePriceWithBenefits(
req.user.userId,
courtId as string,
startTime as string,
endTime as string
);
res.status(200).json({
success: true,
data: priceInfo,
});
} catch (error) {
next(error);
}
}
}
export default BookingController;

View File

@@ -0,0 +1,136 @@
import { Request, Response, NextFunction } from 'express';
import { ClassService } from '../services/class.service';
import { ApiError } from '../middleware/errorHandler';
export class ClassController {
// Crear clase
static async createClass(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId;
const classItem = await ClassService.createClass(userId, req.body);
res.status(201).json({
success: true,
message: 'Clase creada exitosamente',
data: classItem,
});
} catch (error) {
next(error);
}
}
// Listar clases
static async getClasses(req: Request, res: Response, next: NextFunction) {
try {
const filters = {
coachId: req.query.coachId as string | undefined,
type: req.query.type as string | undefined,
isActive: req.query.isActive === 'true' ? true :
req.query.isActive === 'false' ? false : undefined,
minPrice: req.query.minPrice ? parseInt(req.query.minPrice as string) : undefined,
maxPrice: req.query.maxPrice ? parseInt(req.query.maxPrice as string) : undefined,
levelRequired: req.query.levelRequired as string | undefined,
search: req.query.search as string | undefined,
};
const classes = await ClassService.getClasses(filters);
res.status(200).json({
success: true,
count: classes.length,
data: classes,
});
} catch (error) {
next(error);
}
}
// Obtener clase por ID
static async getClassById(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const classItem = await ClassService.getClassById(id);
res.status(200).json({
success: true,
data: classItem,
});
} catch (error) {
next(error);
}
}
// Actualizar clase
static async updateClass(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId;
const { id } = req.params;
const classItem = await ClassService.updateClass(id, userId, req.body);
res.status(200).json({
success: true,
message: 'Clase actualizada exitosamente',
data: classItem,
});
} catch (error) {
next(error);
}
}
// Eliminar clase
static async deleteClass(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId;
const { id } = req.params;
await ClassService.deleteClass(id, userId);
res.status(200).json({
success: true,
message: 'Clase eliminada exitosamente',
});
} catch (error) {
next(error);
}
}
// Programar sesión de clase
static async createClassBooking(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId;
const { id } = req.params;
const data = {
...req.body,
classId: id,
};
const session = await ClassService.createClassBooking(userId, data);
res.status(201).json({
success: true,
message: 'Sesión programada exitosamente',
data: session,
});
} catch (error) {
next(error);
}
}
// Obtener sesiones de una clase
static async getClassBookings(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const sessions = await ClassService.getClassBookings(id);
res.status(200).json({
success: true,
count: sessions.length,
data: sessions,
});
} catch (error) {
next(error);
}
}
}
export default ClassController;

View File

@@ -0,0 +1,103 @@
import { Request, Response, NextFunction } from 'express';
import { ClassEnrollmentService } from '../services/classEnrollment.service';
import { ApiError } from '../middleware/errorHandler';
export class ClassEnrollmentController {
// Inscribirse en una clase
static async enrollInClass(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId;
const result = await ClassEnrollmentService.enrollInClass(userId, req.body);
res.status(201).json({
success: true,
message: 'Inscripción creada exitosamente',
data: result,
});
} catch (error) {
next(error);
}
}
// Webhook de MercadoPago
static async webhook(req: Request, res: Response, next: NextFunction) {
try {
// Responder inmediatamente a MP
res.status(200).send('OK');
// Procesar el webhook de forma asíncrona
await ClassEnrollmentService.processPaymentWebhook(req.body);
} catch (error) {
// Loggear error pero no enviar respuesta (ya se envió 200)
console.error('Error procesando webhook:', error);
}
}
// Cancelar inscripción
static async cancelEnrollment(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId;
const { id } = req.params;
const result = await ClassEnrollmentService.cancelEnrollment(userId, id);
res.status(200).json({
success: true,
message: result.message,
});
} catch (error) {
next(error);
}
}
// Obtener mis inscripciones
static async getMyEnrollments(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId;
const status = req.query.status as string | undefined;
const enrollments = await ClassEnrollmentService.getMyEnrollments(userId, status);
res.status(200).json({
success: true,
count: enrollments.length,
data: enrollments,
});
} catch (error) {
next(error);
}
}
// Obtener inscripción por ID
static async getEnrollmentById(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId;
const { id } = req.params;
const enrollment = await ClassEnrollmentService.getEnrollmentById(id, userId);
res.status(200).json({
success: true,
data: enrollment,
});
} catch (error) {
next(error);
}
}
// Marcar asistencia (solo coach)
static async markAttendance(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId;
const { id } = req.params;
const enrollment = await ClassEnrollmentService.markAttendance(id, userId);
res.status(200).json({
success: true,
message: 'Asistencia marcada exitosamente',
data: enrollment,
});
} catch (error) {
next(error);
}
}
}
export default ClassEnrollmentController;

View File

@@ -0,0 +1,203 @@
import { Request, Response, NextFunction } from 'express';
import { CoachService } from '../services/coach.service';
import { ApiError } from '../middleware/errorHandler';
export class CoachController {
// Registrarse como coach
static async registerAsCoach(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId;
const coach = await CoachService.registerAsCoach(userId, req.body);
res.status(201).json({
success: true,
message: 'Solicitud de registro como coach enviada exitosamente',
data: coach,
});
} catch (error) {
next(error);
}
}
// Listar coaches
static async getCoaches(req: Request, res: Response, next: NextFunction) {
try {
const filters = {
isActive: req.query.isActive === 'true' ? true :
req.query.isActive === 'false' ? false : undefined,
isVerified: req.query.isVerified === 'true' ? true :
req.query.isVerified === 'false' ? false : undefined,
minRating: req.query.minRating ? parseFloat(req.query.minRating as string) : undefined,
specialty: req.query.specialty as string | undefined,
search: req.query.search as string | undefined,
};
const coaches = await CoachService.getCoaches(filters);
res.status(200).json({
success: true,
count: coaches.length,
data: coaches,
});
} catch (error) {
next(error);
}
}
// Obtener coach por ID
static async getCoachById(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const coach = await CoachService.getCoachById(id);
res.status(200).json({
success: true,
data: coach,
});
} catch (error) {
next(error);
}
}
// Obtener mi perfil de coach
static async getMyProfile(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId;
const coach = await CoachService.getMyCoachProfile(userId);
res.status(200).json({
success: true,
data: coach,
});
} catch (error) {
next(error);
}
}
// Actualizar mi perfil de coach
static async updateMyProfile(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId;
const coach = await CoachService.updateCoachProfile(userId, req.body);
res.status(200).json({
success: true,
message: 'Perfil de coach actualizado exitosamente',
data: coach,
});
} catch (error) {
next(error);
}
}
// Verificar coach (admin)
static async verifyCoach(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const adminId = req.user!.userId;
const coach = await CoachService.verifyCoach(id, adminId);
res.status(200).json({
success: true,
message: 'Coach verificado exitosamente',
data: coach,
});
} catch (error) {
next(error);
}
}
// Agregar disponibilidad
static async addAvailability(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId;
const availability = await CoachService.addAvailability(userId, req.body);
res.status(201).json({
success: true,
message: 'Horario agregado exitosamente',
data: availability,
});
} catch (error) {
next(error);
}
}
// Eliminar disponibilidad
static async removeAvailability(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId;
const { id } = req.params;
await CoachService.removeAvailability(userId, id);
res.status(200).json({
success: true,
message: 'Horario eliminado exitosamente',
});
} catch (error) {
next(error);
}
}
// Obtener disponibilidad de un coach
static async getAvailability(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const { date } = req.query;
let parsedDate: Date | undefined;
if (date && typeof date === 'string') {
parsedDate = new Date(date);
if (isNaN(parsedDate.getTime())) {
throw new ApiError('Fecha inválida', 400);
}
}
const availability = await CoachService.getAvailability(id, parsedDate);
res.status(200).json({
success: true,
data: availability,
});
} catch (error) {
next(error);
}
}
// Agregar reseña
static async addReview(req: Request, res: Response, next: NextFunction) {
try {
const userId = req.user!.userId;
const { id } = req.params;
const review = await CoachService.addReview(userId, id, req.body);
res.status(201).json({
success: true,
message: 'Reseña agregada exitosamente',
data: review,
});
} catch (error) {
next(error);
}
}
// Obtener reseñas de un coach
static async getReviews(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const page = req.query.page ? parseInt(req.query.page as string) : 1;
const limit = req.query.limit ? parseInt(req.query.limit as string) : 10;
const reviews = await CoachService.getReviews(id, page, limit);
res.status(200).json({
success: true,
data: reviews,
});
} catch (error) {
next(error);
}
}
}
export default CoachController;

View File

@@ -0,0 +1,193 @@
import { Request, Response, NextFunction } from 'express';
import PaymentService from '../services/payment.service';
import { ApiError } from '../middleware/errorHandler';
import { UserRole } from '../utils/constants';
export class PaymentController {
/**
* Crear preferencia de pago
* POST /payments/preference
*/
static async createPreference(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { type, referenceId, title, description, amount, callbackUrl, metadata } = req.body;
const result = await PaymentService.createPreference(req.user.userId, {
type,
referenceId,
title,
description,
amount,
callbackUrl,
metadata,
});
res.status(201).json({
success: true,
message: 'Preferencia de pago creada exitosamente',
data: {
preferenceId: result.id,
paymentId: result.paymentId,
initPoint: result.initPoint,
sandboxInitPoint: result.sandboxInitPoint,
},
});
} catch (error) {
next(error);
}
}
/**
* Obtener estado de un pago
* GET /payments/:id/status
*/
static async getPaymentStatus(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const payment = await PaymentService.getPaymentStatus(id);
// Verificar que el usuario tiene acceso a este pago
if (payment.user.id !== req.user.userId && req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) {
throw new ApiError('No tienes permisos para ver este pago', 403);
}
res.status(200).json({
success: true,
data: payment,
});
} catch (error) {
next(error);
}
}
/**
* Procesar webhook de MercadoPago
* POST /payments/webhook
* Público - no requiere autenticación
*/
static async processWebhook(req: Request, res: Response, next: NextFunction) {
try {
// Responder inmediatamente a MP para evitar reintentos
res.status(200).json({ success: true });
// Procesar el webhook de forma asíncrona
const payload = req.body;
// Validar que hay datos
if (!payload || Object.keys(payload).length === 0) {
return;
}
await PaymentService.processWebhook(payload);
} catch (error) {
// Log error pero no enviar respuesta (ya enviamos 200)
console.error('Error procesando webhook:', error);
}
}
/**
* Obtener mis pagos
* GET /payments/my-payments
*/
static async getMyPayments(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const payments = await PaymentService.getUserPayments(req.user.userId);
res.status(200).json({
success: true,
data: payments,
});
} catch (error) {
next(error);
}
}
/**
* Obtener detalle de un pago por ID
* GET /payments/:id
*/
static async getPaymentById(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const payment = await PaymentService.getPaymentById(id);
// Verificar que el usuario tiene acceso a este pago
if (payment.userId !== req.user.userId && req.user.role !== UserRole.ADMIN && req.user.role !== UserRole.SUPERADMIN) {
throw new ApiError('No tienes permisos para ver este pago', 403);
}
res.status(200).json({
success: true,
data: payment,
});
} catch (error) {
next(error);
}
}
/**
* Reembolsar un pago (solo admin)
* POST /payments/:id/refund
*/
static async refundPayment(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const { amount } = req.body;
const result = await PaymentService.refundPayment(id, amount);
res.status(200).json({
success: true,
message: 'Pago reembolsado exitosamente',
data: result,
});
} catch (error) {
next(error);
}
}
/**
* Cancelar un pago pendiente
* POST /payments/:id/cancel
*/
static async cancelPayment(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const result = await PaymentService.cancelPayment(id, req.user.userId);
res.status(200).json({
success: true,
message: 'Pago cancelado exitosamente',
data: result,
});
} catch (error) {
next(error);
}
}
}
export default PaymentController;

View File

@@ -0,0 +1,198 @@
import { Request, Response, NextFunction } from 'express';
import { SubscriptionService } from '../services/subscription.service';
import { ApiError } from '../middleware/errorHandler';
export class SubscriptionController {
// Crear una nueva suscripción
static async createSubscription(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { planId, paymentMethodId } = req.body;
const result = await SubscriptionService.createSubscription(
req.user.userId,
planId,
paymentMethodId
);
res.status(201).json({
success: true,
message: 'Suscripción creada exitosamente. Complete el pago para activarla.',
data: result,
});
} catch (error) {
next(error);
}
}
// Procesar webhook de MercadoPago
static async processWebhook(req: Request, res: Response, next: NextFunction) {
try {
const result = await SubscriptionService.processWebhook(req.body);
// Responder inmediatamente a MP
res.status(200).json({
success: true,
processed: result.processed,
});
} catch (error) {
// Loggear el error pero responder 200 para que MP no reintente
console.error('Error procesando webhook:', error);
res.status(200).json({
success: false,
message: 'Error procesando webhook',
});
}
}
// Obtener mi suscripción actual
static async getMySubscription(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const subscription = await SubscriptionService.getMySubscription(req.user.userId);
if (!subscription) {
return res.status(200).json({
success: true,
data: null,
message: 'No tienes una suscripción activa',
});
}
res.status(200).json({
success: true,
data: subscription,
});
} catch (error) {
next(error);
}
}
// Obtener suscripción por ID
static async getSubscriptionById(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const subscription = await SubscriptionService.getSubscriptionById(id, req.user.userId);
res.status(200).json({
success: true,
data: subscription,
});
} catch (error) {
next(error);
}
}
// Cancelar suscripción (al final del período)
static async cancelSubscription(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const subscription = await SubscriptionService.cancelSubscription(id, req.user.userId);
res.status(200).json({
success: true,
message: 'Suscripción cancelada. Seguirá activa hasta el final del período actual.',
data: subscription,
});
} catch (error) {
next(error);
}
}
// Pausar suscripción
static async pauseSubscription(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const subscription = await SubscriptionService.pauseSubscription(id, req.user.userId);
res.status(200).json({
success: true,
message: 'Suscripción pausada exitosamente',
data: subscription,
});
} catch (error) {
next(error);
}
}
// Reanudar suscripción
static async resumeSubscription(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const subscription = await SubscriptionService.resumeSubscription(id, req.user.userId);
res.status(200).json({
success: true,
message: 'Suscripción reanudada exitosamente',
data: subscription,
});
} catch (error) {
next(error);
}
}
// Actualizar método de pago
static async updatePaymentMethod(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { paymentMethodId } = req.body;
const subscription = await SubscriptionService.updatePaymentMethod(
req.user.userId,
paymentMethodId
);
res.status(200).json({
success: true,
message: 'Método de pago actualizado exitosamente',
data: subscription,
});
} catch (error) {
next(error);
}
}
// Obtener mis beneficios actuales
static async getMyBenefits(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const benefits = await SubscriptionService.getCurrentBenefits(req.user.userId);
res.status(200).json({
success: true,
data: benefits,
});
} catch (error) {
next(error);
}
}
}
export default SubscriptionController;

View File

@@ -0,0 +1,135 @@
import { Request, Response, NextFunction } from 'express';
import { SubscriptionPlanService } from '../services/subscriptionPlan.service';
import { ApiError } from '../middleware/errorHandler';
export class SubscriptionPlanController {
// Crear un nuevo plan de suscripción (admin)
static async createPlan(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const plan = await SubscriptionPlanService.createPlan(req.user.userId, req.body);
res.status(201).json({
success: true,
message: 'Plan de suscripción creado exitosamente',
data: plan,
});
} catch (error) {
next(error);
}
}
// Obtener todos los planes activos
static async getPlans(req: Request, res: Response, next: NextFunction) {
try {
const plans = await SubscriptionPlanService.getPlans();
res.status(200).json({
success: true,
count: plans.length,
data: plans,
});
} catch (error) {
next(error);
}
}
// Obtener todos los planes (incluyendo inactivos) - admin
static async getAllPlans(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const plans = await SubscriptionPlanService.getAllPlans(req.user.userId);
res.status(200).json({
success: true,
count: plans.length,
data: plans,
});
} catch (error) {
next(error);
}
}
// Obtener plan por ID
static async getPlanById(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const plan = await SubscriptionPlanService.getPlanById(id);
res.status(200).json({
success: true,
data: plan,
});
} catch (error) {
next(error);
}
}
// Actualizar plan (admin)
static async updatePlan(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const plan = await SubscriptionPlanService.updatePlan(id, req.user.userId, req.body);
res.status(200).json({
success: true,
message: 'Plan de suscripción actualizado exitosamente',
data: plan,
});
} catch (error) {
next(error);
}
}
// Eliminar (desactivar) plan (admin)
static async deletePlan(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const plan = await SubscriptionPlanService.deletePlan(id, req.user.userId);
res.status(200).json({
success: true,
message: 'Plan de suscripción desactivado exitosamente',
data: plan,
});
} catch (error) {
next(error);
}
}
// Sincronizar plan con MercadoPago (admin)
static async syncPlanWithMP(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) {
throw new ApiError('No autenticado', 401);
}
const { id } = req.params;
const plan = await SubscriptionPlanService.syncPlanWithMP(id, req.user.userId);
res.status(200).json({
success: true,
message: 'Plan sincronizado con MercadoPago exitosamente',
data: plan,
});
} catch (error) {
next(error);
}
}
}
export default SubscriptionPlanController;

View File

@@ -25,6 +25,7 @@ const updateBookingSchema = z.object({
// Rutas protegidas para usuarios autenticados
router.post('/', authenticate, validate(createBookingSchema), BookingController.createBooking);
router.get('/my-bookings', authenticate, BookingController.getMyBookings);
router.get('/price-preview', authenticate, BookingController.calculatePrice);
router.get('/:id', authenticate, BookingController.getBookingById);
router.put('/:id', authenticate, validate(updateBookingSchema), BookingController.updateBooking);
router.delete('/:id', authenticate, BookingController.cancelBooking);

View File

@@ -0,0 +1,20 @@
import { Router } from 'express';
import { ClassController } from '../controllers/class.controller';
import { authenticate } from '../middleware/auth';
import { validate } from '../middleware/validate';
import { createClassSchema, createClassBookingSchema } from '../validators/class.validator';
const router = Router();
// Rutas públicas
router.get('/', ClassController.getClasses);
router.get('/:id', ClassController.getClassById);
router.get('/:id/sessions', ClassController.getClassBookings);
// Rutas protegidas (solo coaches)
router.post('/', authenticate, validate(createClassSchema), ClassController.createClass);
router.put('/:id', authenticate, ClassController.updateClass);
router.delete('/:id', authenticate, ClassController.deleteClass);
router.post('/:id/sessions', authenticate, validate(createClassBookingSchema), ClassController.createClassBooking);
export default router;

View File

@@ -0,0 +1,19 @@
import { Router } from 'express';
import { ClassEnrollmentController } from '../controllers/classEnrollment.controller';
import { authenticate } from '../middleware/auth';
import { validate } from '../middleware/validate';
import { enrollmentSchema } from '../validators/class.validator';
const router = Router();
// Webhook de MercadoPago (público)
router.post('/webhook', ClassEnrollmentController.webhook);
// Rutas protegidas
router.post('/', authenticate, validate(enrollmentSchema), ClassEnrollmentController.enrollInClass);
router.get('/my', authenticate, ClassEnrollmentController.getMyEnrollments);
router.get('/:id', authenticate, ClassEnrollmentController.getEnrollmentById);
router.delete('/:id', authenticate, ClassEnrollmentController.cancelEnrollment);
router.put('/:id/attend', authenticate, ClassEnrollmentController.markAttendance);
export default router;

View File

@@ -0,0 +1,29 @@
import { Router } from 'express';
import { CoachController } from '../controllers/coach.controller';
import { authenticate, authorize } from '../middleware/auth';
import { validate } from '../middleware/validate';
import { registerCoachSchema, reviewSchema } from '../validators/class.validator';
import { UserRole } from '../utils/constants';
const router = Router();
// Rutas públicas
router.get('/', CoachController.getCoaches);
router.get('/:id', CoachController.getCoachById);
router.get('/:id/availability', CoachController.getAvailability);
router.get('/:id/reviews', CoachController.getReviews);
// Rutas protegidas (usuarios autenticados)
router.post('/register', authenticate, validate(registerCoachSchema), CoachController.registerAsCoach);
router.get('/me/profile', authenticate, CoachController.getMyProfile);
router.put('/me', authenticate, CoachController.updateMyProfile);
router.post('/me/availability', authenticate, CoachController.addAvailability);
router.post('/:id/reviews', authenticate, validate(reviewSchema), CoachController.addReview);
// Eliminar disponibilidad
router.delete('/availability/:id', authenticate, CoachController.removeAvailability);
// Rutas de admin
router.put('/:id/verify', authenticate, authorize(UserRole.ADMIN, UserRole.SUPERADMIN), CoachController.verifyCoach);
export default router;

View File

@@ -16,6 +16,12 @@ import leagueScheduleRoutes from './leagueSchedule.routes';
import leagueStandingRoutes from './leagueStanding.routes';
import leagueMatchRoutes from './leagueMatch.routes';
// Rutas de Pagos (Fase 4.1)
import paymentRoutes from './payment.routes';
// Rutas de Sistema de Bonos (Fase 4.2) - Desactivado temporalmente
// import bonusRoutes from './bonus.routes';
const router = Router();
// Health check
@@ -73,4 +79,40 @@ router.use('/league-standings', leagueStandingRoutes);
// Rutas de partidos de liga
router.use('/league-matches', leagueMatchRoutes);
// ============================================
// Rutas de Pagos (Fase 4.1)
// ============================================
router.use('/payments', paymentRoutes);
// ============================================
// Rutas de Sistema de Bonos (Fase 4.2) - Desactivado temporalmente
// ============================================
// router.use('/', bonusRoutes);
// ============================================
// Rutas de Suscripciones/Membresías (Fase 4.3)
// ============================================
import subscriptionRoutes from './subscription.routes';
router.use('/', subscriptionRoutes);
// ============================================
// Rutas de Clases con Profesores (Fase 4.4) - Desactivado temporalmente
// ============================================
// import coachRoutes from './coach.routes';
// import classRoutes from './class.routes';
// import classEnrollmentRoutes from './classEnrollment.routes';
// Rutas de coaches
// router.use('/coaches', coachRoutes);
// Rutas de clases
// router.use('/classes', classRoutes);
// Rutas de inscripciones a clases
// router.use('/class-enrollments', classEnrollmentRoutes);
export default router;

View File

@@ -0,0 +1,66 @@
import { Router } from 'express';
import { PaymentController } from '../controllers/payment.controller';
import { validate, validateParams } from '../middleware/validate';
import { authenticate, authorize } from '../middleware/auth';
import { UserRole } from '../utils/constants';
import { createPreferenceSchema, refundSchema, paymentIdParamSchema } from '../validators/payment.validator';
const router = Router();
// POST /payments/preference - Crear preferencia de pago (auth)
router.post(
'/preference',
authenticate,
validate(createPreferenceSchema),
PaymentController.createPreference
);
// GET /payments/my-payments - Mis pagos (auth)
router.get(
'/my-payments',
authenticate,
PaymentController.getMyPayments
);
// GET /payments/:id - Ver detalle de pago (auth)
router.get(
'/:id',
authenticate,
validateParams(paymentIdParamSchema),
PaymentController.getPaymentById
);
// GET /payments/:id/status - Ver estado de pago (auth)
router.get(
'/:id/status',
authenticate,
validateParams(paymentIdParamSchema),
PaymentController.getPaymentStatus
);
// POST /payments/webhook - Webhook de MercadoPago (público)
// Sin autenticación para recibir notificaciones de MP
router.post(
'/webhook',
PaymentController.processWebhook
);
// POST /payments/:id/refund - Reembolsar pago (admin)
router.post(
'/:id/refund',
authenticate,
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
validateParams(paymentIdParamSchema),
validate(refundSchema),
PaymentController.refundPayment
);
// POST /payments/:id/cancel - Cancelar pago pendiente (auth)
router.post(
'/:id/cancel',
authenticate,
validateParams(paymentIdParamSchema),
PaymentController.cancelPayment
);
export default router;

View File

@@ -0,0 +1,136 @@
import { Router } from 'express';
import { SubscriptionPlanController } from '../controllers/subscriptionPlan.controller';
import { SubscriptionController } from '../controllers/subscription.controller';
import { authenticate, authorize } from '../middleware/auth';
import { validate } from '../middleware/validate';
import { UserRole } from '../utils/constants';
import {
createPlanSchema,
updatePlanSchema,
createSubscriptionSchema,
updatePaymentMethodSchema,
} from '../validators/subscription.validator';
const router = Router();
// ============================================
// Rutas de Planes de Suscripción (públicas/admin)
// ============================================
// Listar planes activos (público)
router.get('/subscription-plans', SubscriptionPlanController.getPlans);
// Ver plan específico (público)
router.get('/subscription-plans/:id', SubscriptionPlanController.getPlanById);
// Crear plan (admin)
router.post(
'/subscription-plans',
authenticate,
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
validate(createPlanSchema),
SubscriptionPlanController.createPlan
);
// Listar todos los planes incluyendo inactivos (admin)
router.get(
'/subscription-plans/all',
authenticate,
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
SubscriptionPlanController.getAllPlans
);
// Actualizar plan (admin)
router.put(
'/subscription-plans/:id',
authenticate,
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
validate(updatePlanSchema),
SubscriptionPlanController.updatePlan
);
// Eliminar (desactivar) plan (admin)
router.delete(
'/subscription-plans/:id',
authenticate,
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
SubscriptionPlanController.deletePlan
);
// Sincronizar plan con MercadoPago (admin)
router.post(
'/subscription-plans/:id/sync-mp',
authenticate,
authorize(UserRole.ADMIN, UserRole.SUPERADMIN),
SubscriptionPlanController.syncPlanWithMP
);
// ============================================
// Rutas de Suscripciones de Usuario (auth)
// ============================================
// Crear suscripción
router.post(
'/subscriptions',
authenticate,
validate(createSubscriptionSchema),
SubscriptionController.createSubscription
);
// Obtener mi suscripción actual
router.get(
'/subscriptions/my-subscription',
authenticate,
SubscriptionController.getMySubscription
);
// Ver mis beneficios actuales
router.get(
'/subscriptions/benefits',
authenticate,
SubscriptionController.getMyBenefits
);
// Obtener suscripción por ID
router.get(
'/subscriptions/:id',
authenticate,
SubscriptionController.getSubscriptionById
);
// Cancelar suscripción (al final del período)
router.put(
'/subscriptions/:id/cancel',
authenticate,
SubscriptionController.cancelSubscription
);
// Pausar suscripción
router.put(
'/subscriptions/:id/pause',
authenticate,
SubscriptionController.pauseSubscription
);
// Reanudar suscripción
router.put(
'/subscriptions/:id/resume',
authenticate,
SubscriptionController.resumeSubscription
);
// Actualizar método de pago
router.put(
'/subscriptions/payment-method',
authenticate,
validate(updatePaymentMethodSchema),
SubscriptionController.updatePaymentMethod
);
// Webhook de MercadoPago (público, sin autenticación)
router.post(
'/subscriptions/webhook',
SubscriptionController.processWebhook
);
export default router;

View File

@@ -3,6 +3,7 @@ import { ApiError } from '../middleware/errorHandler';
import { BookingStatus } from '../utils/constants';
import { sendBookingConfirmation, sendBookingCancellation } from './email.service';
import logger from '../config/logger';
import { SubscriptionService } from './subscription.service';
export interface CreateBookingInput {
userId: string;
@@ -87,11 +88,47 @@ export class BookingService {
throw new ApiError('La cancha no está disponible en ese horario', 409);
}
// Calcular precio (precio por hora * número de horas)
// Calcular precio base (precio por hora * número de horas)
const startHour = parseInt(data.startTime.split(':')[0]);
const endHour = parseInt(data.endTime.split(':')[0]);
const hours = endHour - startHour;
const totalPrice = court.pricePerHour * hours;
const basePrice = court.pricePerHour * hours;
// Verificar y aplicar beneficios de suscripción
let finalPrice = basePrice;
let discountApplied = 0;
let usedFreeBooking = false;
// TODO: Re-activar cuando se implemente Fase 4.3 (Suscripciones)
/*
try {
const benefits = await SubscriptionService.checkAndApplyBenefits(
data.userId,
{
totalPrice: basePrice,
courtId: data.courtId,
date: data.date,
startTime: data.startTime,
endTime: data.endTime,
}
);
finalPrice = benefits.finalPrice;
discountApplied = benefits.discountApplied;
usedFreeBooking = benefits.usedFreeBooking;
if (discountApplied > 0) {
logger.info(`Descuento aplicado a reserva: ${discountApplied} centavos para usuario ${data.userId}`);
}
if (usedFreeBooking) {
logger.info(`Reserva gratis aplicada para usuario ${data.userId}`);
}
} catch (error) {
logger.error('Error aplicando beneficios de suscripción:', error);
// Continuar con precio normal si falla la aplicación de beneficios
}
*/
// Fin TODO Fase 4.3
// Crear la reserva
const booking = await prisma.booking.create({
@@ -102,7 +139,7 @@ export class BookingService {
startTime: data.startTime,
endTime: data.endTime,
status: BookingStatus.PENDING,
totalPrice,
totalPrice: finalPrice,
notes: data.notes,
},
include: {
@@ -135,7 +172,16 @@ export class BookingService {
// No fallar la reserva si el email falla
}
return booking;
// Retornar reserva con información de beneficios aplicados
return {
...booking,
benefitsApplied: {
basePrice,
discountApplied,
usedFreeBooking,
finalPrice,
},
};
}
// Obtener todas las reservas (con filtros)
@@ -348,6 +394,51 @@ export class BookingService {
},
});
}
// Calcular precio con beneficios de suscripción (preview)
static async calculatePriceWithBenefits(
userId: string,
courtId: string,
startTime: string,
endTime: string
) {
// Obtener información de la cancha
const court = await prisma.court.findUnique({
where: { id: courtId },
});
if (!court) {
throw new ApiError('Cancha no encontrada', 404);
}
// Calcular precio base
const startHour = parseInt(startTime.split(':')[0]);
const endHour = parseInt(endTime.split(':')[0]);
const hours = endHour - startHour;
const basePrice = court.pricePerHour * hours;
// Obtener beneficios de suscripción
const subscriptionBenefits = await SubscriptionService.getCurrentBenefits(userId);
// Calcular precio final
let finalPrice = basePrice;
let discountApplied = 0;
if (subscriptionBenefits.freeBookingsRemaining > 0) {
finalPrice = 0;
discountApplied = basePrice;
} else if (subscriptionBenefits.discountPercentage > 0) {
discountApplied = Math.round((basePrice * subscriptionBenefits.discountPercentage) / 100);
finalPrice = basePrice - discountApplied;
}
return {
basePrice,
finalPrice,
discountApplied,
subscriptionBenefits,
};
}
}
export default BookingService;

View File

@@ -0,0 +1,594 @@
import prisma from '../config/database';
import { ApiError } from '../middleware/errorHandler';
import { ClassType, ClassBookingStatus, ClassLimits } from '../utils/constants';
export interface CreateClassInput {
title: string;
description?: string;
type: string;
maxStudents?: number;
price: number;
duration?: number;
levelRequired?: string;
}
export interface UpdateClassInput {
title?: string;
description?: string;
type?: string;
maxStudents?: number;
price?: number;
duration?: number;
levelRequired?: string;
isActive?: boolean;
}
export interface CreateClassBookingInput {
classId: string;
courtId?: string;
date: Date;
startTime: string;
price?: number;
}
export interface ClassFilters {
coachId?: string;
type?: string;
isActive?: boolean;
minPrice?: number;
maxPrice?: number;
levelRequired?: string;
search?: string;
}
export class ClassService {
// Crear una clase
static async createClass(coachUserId: string, data: CreateClassInput) {
// Verificar que el usuario es coach
const coach = await prisma.coach.findUnique({
where: { userId: coachUserId },
});
if (!coach) {
throw new ApiError('No tienes perfil de coach', 403);
}
if (!coach.isVerified) {
throw new ApiError('Debes estar verificado para crear clases', 403);
}
// Validar tipo de clase
if (!Object.values(ClassType).includes(data.type as any)) {
throw new ApiError(`Tipo de clase inválido. Opciones: ${Object.values(ClassType).join(', ')}`, 400);
}
// Validar y ajustar maxStudents según el tipo
const limits = ClassLimits[data.type as keyof typeof ClassLimits];
let maxStudents = data.maxStudents || limits.max;
if (maxStudents < limits.min || maxStudents > limits.max) {
throw new ApiError(
`Para clases ${data.type}, el cupo debe estar entre ${limits.min} y ${limits.max} alumnos`,
400
);
}
// Validar precio
if (data.price < 0) {
throw new ApiError('El precio no puede ser negativo', 400);
}
// Validar duración
const duration = data.duration || 60;
if (duration < 30 || duration > 180) {
throw new ApiError('La duración debe estar entre 30 y 180 minutos', 400);
}
return prisma.class.create({
data: {
coachId: coach.id,
title: data.title,
description: data.description,
type: data.type,
maxStudents,
price: data.price,
duration,
levelRequired: data.levelRequired,
},
include: {
coach: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
avatarUrl: true,
},
},
},
},
},
});
}
// Listar clases
static async getClasses(filters: ClassFilters = {}) {
const where: any = {};
if (filters.coachId) {
where.coachId = filters.coachId;
}
if (filters.type) {
where.type = filters.type;
}
if (filters.isActive !== undefined) {
where.isActive = filters.isActive;
} else {
where.isActive = true;
}
if (filters.minPrice !== undefined || filters.maxPrice !== undefined) {
where.price = {};
if (filters.minPrice !== undefined) where.price.gte = filters.minPrice;
if (filters.maxPrice !== undefined) where.price.lte = filters.maxPrice;
}
if (filters.levelRequired) {
where.levelRequired = filters.levelRequired;
}
if (filters.search) {
where.OR = [
{ title: { contains: filters.search, mode: 'insensitive' } },
{ description: { contains: filters.search, mode: 'insensitive' } },
];
}
const classes = await prisma.class.findMany({
where,
include: {
coach: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
avatarUrl: true,
},
},
},
},
_count: {
select: {
sessions: {
where: {
status: { in: ['AVAILABLE', 'FULL'] },
date: { gte: new Date() },
},
},
},
},
},
orderBy: { createdAt: 'desc' },
});
return classes;
}
// Obtener clase por ID
static async getClassById(id: string) {
const classItem = await prisma.class.findUnique({
where: { id },
include: {
coach: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
avatarUrl: true,
email: true,
},
},
},
},
sessions: {
where: {
status: { in: ['AVAILABLE', 'FULL'] },
date: { gte: new Date() },
},
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
include: {
court: {
select: {
id: true,
name: true,
type: true,
},
},
_count: {
select: {
enrollments: {
where: { status: 'CONFIRMED' },
},
},
},
},
},
_count: {
select: {
sessions: true,
},
},
},
});
if (!classItem) {
throw new ApiError('Clase no encontrada', 404);
}
return classItem;
}
// Actualizar clase
static async updateClass(id: string, coachUserId: string, data: UpdateClassInput) {
// Verificar que el usuario es coach
const coach = await prisma.coach.findUnique({
where: { userId: coachUserId },
});
if (!coach) {
throw new ApiError('No tienes perfil de coach', 403);
}
// Verificar que la clase existe y pertenece al coach
const classItem = await prisma.class.findFirst({
where: {
id,
coachId: coach.id,
},
});
if (!classItem) {
throw new ApiError('Clase no encontrada o no tienes permisos', 404);
}
// Validar tipo si se está actualizando
if (data.type && !Object.values(ClassType).includes(data.type as any)) {
throw new ApiError(`Tipo de clase inválido. Opciones: ${Object.values(ClassType).join(', ')}`, 400);
}
// Validar maxStudents si se actualiza
if (data.maxStudents !== undefined && data.type) {
const limits = ClassLimits[data.type as keyof typeof ClassLimits];
if (data.maxStudents < limits.min || data.maxStudents > limits.max) {
throw new ApiError(
`Para clases ${data.type}, el cupo debe estar entre ${limits.min} y ${limits.max} alumnos`,
400
);
}
}
const updateData: any = {};
if (data.title !== undefined) updateData.title = data.title;
if (data.description !== undefined) updateData.description = data.description;
if (data.type !== undefined) updateData.type = data.type;
if (data.maxStudents !== undefined) updateData.maxStudents = data.maxStudents;
if (data.price !== undefined) updateData.price = data.price;
if (data.duration !== undefined) updateData.duration = data.duration;
if (data.levelRequired !== undefined) updateData.levelRequired = data.levelRequired;
if (data.isActive !== undefined) updateData.isActive = data.isActive;
return prisma.class.update({
where: { id },
data: updateData,
include: {
coach: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
},
},
},
});
}
// Eliminar clase (desactivar)
static async deleteClass(id: string, coachUserId: string) {
// Verificar que el usuario es coach
const coach = await prisma.coach.findUnique({
where: { userId: coachUserId },
});
if (!coach) {
throw new ApiError('No tienes perfil de coach', 403);
}
// Verificar que la clase existe y pertenece al coach
const classItem = await prisma.class.findFirst({
where: {
id,
coachId: coach.id,
},
});
if (!classItem) {
throw new ApiError('Clase no encontrada o no tienes permisos', 404);
}
// Desactivar en lugar de eliminar
return prisma.class.update({
where: { id },
data: { isActive: false },
});
}
// Crear sesión de clase (programar una instancia específica)
static async createClassBooking(coachUserId: string, data: CreateClassBookingInput) {
// Verificar que el usuario es coach
const coach = await prisma.coach.findUnique({
where: { userId: coachUserId },
});
if (!coach) {
throw new ApiError('No tienes perfil de coach', 403);
}
// Verificar que la clase existe y pertenece al coach
const classItem = await prisma.class.findFirst({
where: {
id: data.classId,
coachId: coach.id,
},
});
if (!classItem) {
throw new ApiError('Clase no encontrada o no tienes permisos', 404);
}
// Validar fecha
const sessionDate = new Date(data.date);
const now = new Date();
now.setHours(0, 0, 0, 0);
if (sessionDate < now) {
throw new ApiError('No puedes programar sesiones en fechas pasadas', 400);
}
// Validar formato de hora
const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/;
if (!timeRegex.test(data.startTime)) {
throw new ApiError('Formato de hora inválido (HH:mm)', 400);
}
// Calcular hora de fin
const [hours, minutes] = data.startTime.split(':').map(Number);
const endMinutes = hours * 60 + minutes + classItem.duration;
const endHours = Math.floor(endMinutes / 60);
const endMins = endMinutes % 60;
const endTime = `${endHours.toString().padStart(2, '0')}:${endMins.toString().padStart(2, '0')}`;
// Verificar disponibilidad del coach
const dayOfWeek = sessionDate.getDay();
const availability = await prisma.coachAvailability.findFirst({
where: {
coachId: coach.id,
dayOfWeek,
isAvailable: true,
startTime: { lte: data.startTime },
endTime: { gte: endTime },
},
});
if (!availability) {
throw new ApiError('No tienes disponibilidad para este horario', 400);
}
// Verificar que no haya otra sesión en ese horario
const existingSession = await prisma.classBooking.findFirst({
where: {
coachId: coach.id,
date: sessionDate,
status: { not: 'CANCELLED' },
OR: [
{
startTime: { lte: data.startTime },
AND: {
NOT: {
startTime: {
lt: endTime,
},
},
},
},
{
startTime: { gte: data.startTime },
AND: {
startTime: { lt: endTime },
},
},
],
},
});
if (existingSession) {
throw new ApiError('Ya tienes una sesión programada en este horario', 409);
}
// Verificar cancha si se especificó
if (data.courtId) {
const court = await prisma.court.findUnique({
where: { id: data.courtId },
});
if (!court) {
throw new ApiError('Cancha no encontrada', 404);
}
if (!court.isActive) {
throw new ApiError('La cancha no está disponible', 400);
}
// Verificar disponibilidad de cancha
const courtBooking = await prisma.booking.findFirst({
where: {
courtId: data.courtId,
date: sessionDate,
status: { in: ['PENDING', 'CONFIRMED'] },
OR: [
{
startTime: { lte: data.startTime },
endTime: { gt: data.startTime },
},
{
startTime: { lt: endTime },
endTime: { gte: endTime },
},
],
},
});
if (courtBooking) {
throw new ApiError('La cancha no está disponible en este horario', 409);
}
}
return prisma.classBooking.create({
data: {
classId: data.classId,
coachId: coach.id,
courtId: data.courtId,
date: sessionDate,
startTime: data.startTime,
maxStudents: classItem.maxStudents,
price: data.price || classItem.price,
status: ClassBookingStatus.AVAILABLE,
},
include: {
class: true,
coach: {
include: {
user: {
select: {
firstName: true,
lastName: true,
},
},
},
},
court: {
select: {
id: true,
name: true,
},
},
},
});
}
// Obtener sesiones de una clase
static async getClassBookings(classId: string) {
const classItem = await prisma.class.findUnique({
where: { id: classId },
});
if (!classItem) {
throw new ApiError('Clase no encontrada', 404);
}
const sessions = await prisma.classBooking.findMany({
where: {
classId,
status: { not: 'CANCELLED' },
},
include: {
court: {
select: {
id: true,
name: true,
type: true,
},
},
_count: {
select: {
enrollments: {
where: { status: 'CONFIRMED' },
},
},
},
},
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
});
return sessions.map(session => ({
...session,
students: session.students ? JSON.parse(session.students) : [],
}));
}
// Cancelar sesión de clase
static async cancelClassBooking(id: string, coachUserId: string) {
// Verificar que el usuario es coach
const coach = await prisma.coach.findUnique({
where: { userId: coachUserId },
});
if (!coach) {
throw new ApiError('No tienes perfil de coach', 403);
}
// Verificar que la sesión existe y pertenece al coach
const session = await prisma.classBooking.findFirst({
where: {
id,
coachId: coach.id,
},
include: {
enrollments: true,
},
});
if (!session) {
throw new ApiError('Sesión no encontrada o no tienes permisos', 404);
}
if (session.status === ClassBookingStatus.CANCELLED) {
throw new ApiError('La sesión ya está cancelada', 400);
}
// Cancelar en transacción
await prisma.$transaction([
// Cancelar sesión
prisma.classBooking.update({
where: { id },
data: { status: ClassBookingStatus.CANCELLED },
}),
// Cancelar todas las inscripciones
prisma.studentEnrollment.updateMany({
where: {
classBookingId: id,
status: { in: ['PENDING', 'CONFIRMED'] },
},
data: {
status: 'CANCELLED',
cancelledAt: new Date(),
},
}),
]);
return { message: 'Sesión cancelada exitosamente' };
}
}
export default ClassService;

View File

@@ -0,0 +1,528 @@
import prisma from '../config/database';
import { ApiError } from '../middleware/errorHandler';
import { EnrollmentStatus, ClassBookingStatus } from '../utils/constants';
import { preferenceClient, isMercadoPagoConfigured } from '../config/mercadopago';
import logger from '../config/logger';
import config from '../config';
export interface EnrollmentInput {
classBookingId: string;
}
export class ClassEnrollmentService {
// Inscribirse en una clase
static async enrollInClass(userId: string, data: EnrollmentInput) {
const { classBookingId } = data;
// Verificar que la sesión existe y está disponible
const session = await prisma.classBooking.findUnique({
where: { id: classBookingId },
include: {
class: true,
coach: {
include: {
user: true,
},
},
enrollments: {
where: { status: { in: ['PENDING', 'CONFIRMED'] } },
},
},
});
if (!session) {
throw new ApiError('Sesión de clase no encontrada', 404);
}
// Verificar estado de la sesión
if (session.status === ClassBookingStatus.CANCELLED) {
throw new ApiError('Esta sesión ha sido cancelada', 400);
}
if (session.status === ClassBookingStatus.COMPLETED) {
throw new ApiError('Esta sesión ya ha finalizado', 400);
}
// Verificar que no se inscriba en su propia clase (si es coach)
if (session.coach.user.id === userId) {
throw new ApiError('No puedes inscribirte en tu propia clase', 400);
}
// Verificar cupo disponible
if (session.status === ClassBookingStatus.FULL) {
throw new ApiError('No hay cupos disponibles para esta sesión', 400);
}
const enrolledCount = session.enrollments.filter(
e => e.status === EnrollmentStatus.CONFIRMED
).length;
if (enrolledCount >= session.maxStudents) {
throw new ApiError('No hay cupos disponibles para esta sesión', 400);
}
// Verificar si ya está inscrito
const existingEnrollment = await prisma.studentEnrollment.findFirst({
where: {
classBookingId,
userId,
status: { in: ['PENDING', 'CONFIRMED'] },
},
});
if (existingEnrollment) {
throw new ApiError('Ya estás inscrito en esta sesión', 409);
}
// Obtener datos del usuario
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new ApiError('Usuario no encontrado', 404);
}
// Crear preferencia de pago en MercadoPago
let paymentPreference = null;
let paymentRecord = null;
if (session.price > 0 && isMercadoPagoConfigured()) {
try {
const preferenceData = {
items: [
{
id: `class_${classBookingId}`,
title: `Clase: ${session.class.title}`,
description: `Clase con ${session.coach.user.firstName} ${session.coach.user.lastName} - ${session.date.toLocaleDateString()} ${session.startTime}`,
quantity: 1,
unit_price: session.price / 100, // Convertir centavos a unidades
currency_id: 'ARS',
},
],
payer: {
name: user.firstName,
surname: user.lastName,
email: user.email,
},
external_reference: `class_enrollment_${userId}_${classBookingId}_${Date.now()}`,
notification_url: `${config.API_URL}/api/v1/class-enrollments/webhook`,
back_urls: {
success: `${config.FRONTEND_URL}/classes/enrollment/success`,
failure: `${config.FRONTEND_URL}/classes/enrollment/failure`,
pending: `${config.FRONTEND_URL}/classes/enrollment/pending`,
},
auto_return: 'approved' as const,
};
const preference = await preferenceClient.create({ body: preferenceData });
paymentPreference = preference;
// Crear registro de pago
paymentRecord = await prisma.payment.create({
data: {
userId,
type: 'CLASS',
referenceId: classBookingId,
amount: session.price,
currency: 'ARS',
provider: 'MERCADOPAGO',
providerPreferenceId: preference.id!,
status: 'PENDING',
},
});
} catch (error) {
logger.error('Error creando preferencia de pago MP:', error);
throw new ApiError('Error al procesar el pago', 500);
}
} else if (session.price > 0 && !isMercadoPagoConfigured()) {
// Si no está configurado MP pero hay precio, error
throw new ApiError('Sistema de pagos no configurado', 500);
}
// Crear inscripción
const enrollment = await prisma.studentEnrollment.create({
data: {
classBookingId,
userId,
paymentId: paymentRecord?.id,
status: session.price > 0 ? EnrollmentStatus.PENDING : EnrollmentStatus.CONFIRMED,
},
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
classBooking: {
include: {
class: true,
coach: {
include: {
user: {
select: {
firstName: true,
lastName: true,
},
},
},
},
},
},
},
});
// Si no hay costo, actualizar contador
if (session.price === 0) {
await this.updateSessionEnrollmentCount(classBookingId);
}
return {
enrollment,
payment: paymentPreference ? {
preferenceId: paymentPreference.id,
initPoint: paymentPreference.init_point,
sandboxInitPoint: paymentPreference.sandbox_init_point,
} : null,
};
}
// Procesar webhook de MercadoPago
static async processPaymentWebhook(payload: any) {
const { type, data } = payload;
if (type !== 'payment') {
return { message: 'Evento ignorado' };
}
const paymentId = data.id;
try {
// Obtener información del pago desde MP (simulado - en producción consultar API)
// Aquí deberías hacer la llamada real a MP para obtener el estado
const paymentStatus = payload.data?.status || 'approved';
// Buscar el pago en nuestra base de datos
const payment = await prisma.payment.findFirst({
where: {
OR: [
{ providerPaymentId: paymentId },
{ providerPreferenceId: paymentId },
],
},
});
if (!payment) {
logger.warn(`Pago no encontrado: ${paymentId}`);
return { message: 'Pago no encontrado' };
}
if (payment.type !== 'CLASS') {
return { message: 'No es pago de clase' };
}
// Buscar inscripción asociada
const enrollment = await prisma.studentEnrollment.findFirst({
where: { paymentId: payment.id },
});
if (!enrollment) {
logger.warn(`Inscripción no encontrada para pago: ${payment.id}`);
return { message: 'Inscripción no encontrada' };
}
// Procesar según estado del pago
if (paymentStatus === 'approved') {
await prisma.$transaction([
// Actualizar pago
prisma.payment.update({
where: { id: payment.id },
data: {
status: 'COMPLETED',
providerPaymentId: paymentId,
paidAt: new Date(),
},
}),
// Actualizar inscripción
prisma.studentEnrollment.update({
where: { id: enrollment.id },
data: { status: EnrollmentStatus.CONFIRMED },
}),
]);
// Actualizar contador de la sesión
await this.updateSessionEnrollmentCount(enrollment.classBookingId);
logger.info(`Pago de clase aprobado: ${payment.id}`);
} else if (['rejected', 'cancelled'].includes(paymentStatus)) {
await prisma.$transaction([
prisma.payment.update({
where: { id: payment.id },
data: { status: paymentStatus === 'cancelled' ? 'CANCELLED' : 'FAILED' },
}),
prisma.studentEnrollment.update({
where: { id: enrollment.id },
data: { status: EnrollmentStatus.CANCELLED },
}),
]);
logger.info(`Pago de clase rechazado/cancelado: ${payment.id}`);
}
return { message: 'Webhook procesado' };
} catch (error) {
logger.error('Error procesando webhook:', error);
throw new ApiError('Error procesando webhook', 500);
}
}
// Cancelar inscripción
static async cancelEnrollment(userId: string, enrollmentId: string) {
const enrollment = await prisma.studentEnrollment.findFirst({
where: {
id: enrollmentId,
userId,
},
include: {
classBooking: true,
},
});
if (!enrollment) {
throw new ApiError('Inscripción no encontrada', 404);
}
if (enrollment.status === EnrollmentStatus.CANCELLED) {
throw new ApiError('La inscripción ya está cancelada', 400);
}
// Verificar política de cancelación (24h antes)
const sessionDate = new Date(enrollment.classBooking.date);
const now = new Date();
const hoursUntilSession = (sessionDate.getTime() - now.getTime()) / (1000 * 60 * 60);
if (hoursUntilSession < 24 && enrollment.status === EnrollmentStatus.CONFIRMED) {
throw new ApiError(
'No puedes cancelar con menos de 24 horas de anticipación',
400
);
}
// Cancelar inscripción
await prisma.studentEnrollment.update({
where: { id: enrollmentId },
data: {
status: EnrollmentStatus.CANCELLED,
cancelledAt: new Date(),
},
});
// Si estaba confirmada, actualizar contador
if (enrollment.status === EnrollmentStatus.CONFIRMED) {
await this.updateSessionEnrollmentCount(enrollment.classBookingId);
}
return { message: 'Inscripción cancelada exitosamente' };
}
// Obtener mis inscripciones
static async getMyEnrollments(userId: string, status?: string) {
const where: any = { userId };
if (status) {
where.status = status;
}
const enrollments = await prisma.studentEnrollment.findMany({
where,
include: {
classBooking: {
include: {
class: true,
coach: {
include: {
user: {
select: {
firstName: true,
lastName: true,
avatarUrl: true,
},
},
},
},
court: {
select: {
id: true,
name: true,
},
},
},
},
},
orderBy: { enrolledAt: 'desc' },
});
// Obtener pagos asociados manualmente
const enrollmentIds = enrollments.map(e => e.id);
const payments = await prisma.payment.findMany({
where: {
type: 'CLASS',
referenceId: { in: enrollmentIds },
},
select: {
id: true,
referenceId: true,
status: true,
amount: true,
paidAt: true,
},
});
const paymentsMap = new Map(payments.map(p => [p.referenceId, p]));
return enrollments.map(e => ({
...e,
payment: paymentsMap.get(e.id) || null,
}));
}
// Obtener inscripción por ID
static async getEnrollmentById(id: string, userId: string) {
const enrollment = await prisma.studentEnrollment.findFirst({
where: {
id,
userId,
},
include: {
classBooking: {
include: {
class: true,
coach: {
include: {
user: {
select: {
firstName: true,
lastName: true,
avatarUrl: true,
email: true,
},
},
},
},
court: true,
},
},
},
});
if (!enrollment) {
throw new ApiError('Inscripción no encontrada', 404);
}
// Obtener pago asociado manualmente
const payment = await prisma.payment.findFirst({
where: {
type: 'CLASS',
referenceId: enrollment.id,
},
});
return {
...enrollment,
payment,
};
}
// Marcar asistencia (solo coach)
static async markAttendance(enrollmentId: string, coachUserId: string) {
// Verificar que el usuario es coach
const coach = await prisma.coach.findUnique({
where: { userId: coachUserId },
});
if (!coach) {
throw new ApiError('No tienes perfil de coach', 403);
}
// Verificar que la inscripción existe y pertenece a una sesión del coach
const enrollment = await prisma.studentEnrollment.findFirst({
where: { id: enrollmentId },
include: {
classBooking: true,
},
});
if (!enrollment) {
throw new ApiError('Inscripción no encontrada', 404);
}
if (enrollment.classBooking.coachId !== coach.id) {
throw new ApiError('No tienes permisos para esta inscripción', 403);
}
if (enrollment.status !== EnrollmentStatus.CONFIRMED) {
throw new ApiError('Solo se puede marcar asistencia de inscripciones confirmadas', 400);
}
return prisma.studentEnrollment.update({
where: { id: enrollmentId },
data: { status: EnrollmentStatus.ATTENDED },
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
classBooking: {
include: {
class: {
select: {
title: true,
},
},
},
},
},
});
}
// Helper: Actualizar contador de inscripciones de sesión
private static async updateSessionEnrollmentCount(classBookingId: string) {
const confirmedCount = await prisma.studentEnrollment.count({
where: {
classBookingId,
status: EnrollmentStatus.CONFIRMED,
},
});
const session = await prisma.classBooking.findUnique({
where: { id: classBookingId },
});
if (!session) return;
let newStatus = session.status;
if (confirmedCount >= session.maxStudents) {
newStatus = ClassBookingStatus.FULL;
} else if (confirmedCount < session.maxStudents && session.status === ClassBookingStatus.FULL) {
newStatus = ClassBookingStatus.AVAILABLE;
}
await prisma.classBooking.update({
where: { id: classBookingId },
data: {
enrolledStudents: confirmedCount,
status: newStatus,
},
});
}
}
export default ClassEnrollmentService;

View File

@@ -0,0 +1,602 @@
import prisma from '../config/database';
import { ApiError } from '../middleware/errorHandler';
import { ClassType } from '../utils/constants';
export interface RegisterCoachInput {
bio?: string;
specialties?: string[];
certifications?: string[];
yearsExperience?: number;
hourlyRate?: number;
photoUrl?: string;
}
export interface UpdateCoachInput {
bio?: string;
specialties?: string[];
certifications?: string[];
yearsExperience?: number;
hourlyRate?: number;
photoUrl?: string;
isActive?: boolean;
}
export interface AddAvailabilityInput {
dayOfWeek: number;
startTime: string;
endTime: string;
}
export interface CoachFilters {
isActive?: boolean;
isVerified?: boolean;
minRating?: number;
specialty?: string;
search?: string;
}
export interface ReviewInput {
rating: number;
comment?: string;
}
export class CoachService {
// Registrar usuario como coach
static async registerAsCoach(userId: string, data: RegisterCoachInput) {
// Verificar que el usuario existe y está verificado
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
throw new ApiError('Usuario no encontrado', 404);
}
if (!user.isVerified) {
throw new ApiError('Debes verificar tu cuenta para ser coach', 403);
}
// Verificar que no sea coach ya
const existingCoach = await prisma.coach.findUnique({
where: { userId },
});
if (existingCoach) {
throw new ApiError('Ya estás registrado como coach', 409);
}
// Crear perfil de coach
const coach = await prisma.coach.create({
data: {
userId,
bio: data.bio,
specialties: data.specialties ? JSON.stringify(data.specialties) : null,
certifications: data.certifications ? JSON.stringify(data.certifications) : null,
yearsExperience: data.yearsExperience || 0,
hourlyRate: data.hourlyRate || 0,
photoUrl: data.photoUrl,
},
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
avatarUrl: true,
},
},
},
});
return coach;
}
// Listar coaches
static async getCoaches(filters: CoachFilters = {}) {
const where: any = {};
if (filters.isActive !== undefined) {
where.isActive = filters.isActive;
} else {
where.isActive = true; // Por defecto solo activos
}
if (filters.isVerified !== undefined) {
where.isVerified = filters.isVerified;
}
if (filters.minRating !== undefined) {
where.rating = { gte: filters.minRating };
}
if (filters.specialty) {
where.specialties = { contains: filters.specialty };
}
if (filters.search) {
where.user = {
OR: [
{ firstName: { contains: filters.search, mode: 'insensitive' } },
{ lastName: { contains: filters.search, mode: 'insensitive' } },
],
};
}
const coaches = await prisma.coach.findMany({
where,
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
avatarUrl: true,
city: true,
},
},
_count: {
select: {
classes: true,
coachReviews: true,
},
},
},
orderBy: { rating: 'desc' },
});
return coaches.map(coach => ({
...coach,
specialties: coach.specialties ? JSON.parse(coach.specialties) : [],
certifications: coach.certifications ? JSON.parse(coach.certifications) : [],
}));
}
// Obtener coach por ID
static async getCoachById(id: string) {
const coach = await prisma.coach.findUnique({
where: { id },
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
avatarUrl: true,
city: true,
playerLevel: true,
},
},
classes: {
where: { isActive: true },
select: {
id: true,
title: true,
type: true,
price: true,
duration: true,
maxStudents: true,
},
},
_count: {
select: {
classes: true,
coachReviews: true,
},
},
},
});
if (!coach) {
throw new ApiError('Coach no encontrado', 404);
}
return {
...coach,
specialties: coach.specialties ? JSON.parse(coach.specialties) : [],
certifications: coach.certifications ? JSON.parse(coach.certifications) : [],
};
}
// Obtener mi perfil de coach
static async getMyCoachProfile(userId: string) {
const coach = await prisma.coach.findUnique({
where: { userId },
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
avatarUrl: true,
phone: true,
},
},
classes: true,
availabilities: true,
_count: {
select: {
classBookings: true,
coachReviews: true,
},
},
},
});
if (!coach) {
throw new ApiError('No tienes perfil de coach', 404);
}
return {
...coach,
specialties: coach.specialties ? JSON.parse(coach.specialties) : [],
certifications: coach.certifications ? JSON.parse(coach.certifications) : [],
};
}
// Actualizar perfil de coach
static async updateCoachProfile(userId: string, data: UpdateCoachInput) {
const coach = await prisma.coach.findUnique({
where: { userId },
});
if (!coach) {
throw new ApiError('No tienes perfil de coach', 404);
}
const updateData: any = {};
if (data.bio !== undefined) updateData.bio = data.bio;
if (data.specialties !== undefined) updateData.specialties = JSON.stringify(data.specialties);
if (data.certifications !== undefined) updateData.certifications = JSON.stringify(data.certifications);
if (data.yearsExperience !== undefined) updateData.yearsExperience = data.yearsExperience;
if (data.hourlyRate !== undefined) updateData.hourlyRate = data.hourlyRate;
if (data.photoUrl !== undefined) updateData.photoUrl = data.photoUrl;
if (data.isActive !== undefined) updateData.isActive = data.isActive;
const updatedCoach = await prisma.coach.update({
where: { userId },
data: updateData,
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
avatarUrl: true,
},
},
},
});
return {
...updatedCoach,
specialties: updatedCoach.specialties ? JSON.parse(updatedCoach.specialties) : [],
certifications: updatedCoach.certifications ? JSON.parse(updatedCoach.certifications) : [],
};
}
// Verificar coach (solo admin)
static async verifyCoach(coachId: string, adminId: string) {
const coach = await prisma.coach.findUnique({
where: { id: coachId },
});
if (!coach) {
throw new ApiError('Coach no encontrado', 404);
}
if (coach.isVerified) {
throw new ApiError('El coach ya está verificado', 400);
}
return prisma.coach.update({
where: { id: coachId },
data: { isVerified: true },
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
},
});
}
// Agregar disponibilidad
static async addAvailability(userId: string, data: AddAvailabilityInput) {
const coach = await prisma.coach.findUnique({
where: { userId },
});
if (!coach) {
throw new ApiError('No tienes perfil de coach', 404);
}
// Validar día de la semana
if (data.dayOfWeek < 0 || data.dayOfWeek > 6) {
throw new ApiError('Día de la semana inválido (0-6)', 400);
}
// Validar formato de hora
const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/;
if (!timeRegex.test(data.startTime) || !timeRegex.test(data.endTime)) {
throw new ApiError('Formato de hora inválido (HH:mm)', 400);
}
// Validar que hora fin sea mayor que hora inicio
const startMinutes = this.timeToMinutes(data.startTime);
const endMinutes = this.timeToMinutes(data.endTime);
if (endMinutes <= startMinutes) {
throw new ApiError('La hora de fin debe ser mayor que la hora de inicio', 400);
}
return prisma.coachAvailability.create({
data: {
coachId: coach.id,
dayOfWeek: data.dayOfWeek,
startTime: data.startTime,
endTime: data.endTime,
},
});
}
// Eliminar disponibilidad
static async removeAvailability(userId: string, availabilityId: string) {
const coach = await prisma.coach.findUnique({
where: { userId },
});
if (!coach) {
throw new ApiError('No tienes perfil de coach', 404);
}
const availability = await prisma.coachAvailability.findFirst({
where: {
id: availabilityId,
coachId: coach.id,
},
});
if (!availability) {
throw new ApiError('Horario no encontrado', 404);
}
return prisma.coachAvailability.delete({
where: { id: availabilityId },
});
}
// Obtener disponibilidad de un coach
static async getAvailability(coachId: string, date?: Date) {
const coach = await prisma.coach.findUnique({
where: { id: coachId },
});
if (!coach) {
throw new ApiError('Coach no encontrado', 404);
}
if (date) {
// Obtener disponibilidad para un día específico
const dayOfWeek = date.getDay();
const availabilities = await prisma.coachAvailability.findMany({
where: {
coachId,
dayOfWeek,
isAvailable: true,
},
orderBy: { startTime: 'asc' },
});
// Obtener sesiones programadas para esa fecha
const sessions = await prisma.classBooking.findMany({
where: {
coachId,
date: {
gte: new Date(date.setHours(0, 0, 0, 0)),
lt: new Date(date.setHours(23, 59, 59, 999)),
},
status: { not: 'CANCELLED' },
},
include: {
class: {
select: {
duration: true,
},
},
},
});
// Filtrar disponibilidad ocupada
const availableSlots = availabilities.flatMap(avail => {
const slots = [];
const startMinutes = this.timeToMinutes(avail.startTime);
const endMinutes = this.timeToMinutes(avail.endTime);
// Generar slots de 60 minutos
for (let time = startMinutes; time + 60 <= endMinutes; time += 60) {
const slotStart = this.minutesToTime(time);
const slotEnd = this.minutesToTime(time + 60);
// Verificar si está ocupado
const isOccupied = sessions.some(session => {
const sessionStart = this.timeToMinutes(session.startTime);
const sessionEnd = sessionStart + session.class.duration;
return time < sessionEnd && (time + 60) > sessionStart;
});
if (!isOccupied) {
slots.push({
startTime: slotStart,
endTime: slotEnd,
});
}
}
return slots;
});
return {
date,
dayOfWeek,
slots: availableSlots,
};
} else {
// Obtener toda la disponibilidad
const availabilities = await prisma.coachAvailability.findMany({
where: {
coachId,
isAvailable: true,
},
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
});
return availabilities;
}
}
// Agregar reseña
static async addReview(userId: string, coachId: string, data: ReviewInput) {
// Verificar que el coach existe
const coach = await prisma.coach.findUnique({
where: { id: coachId },
});
if (!coach) {
throw new ApiError('Coach no encontrado', 404);
}
// No puede reseñarse a sí mismo
if (coach.userId === userId) {
throw new ApiError('No puedes reseñarte a ti mismo', 400);
}
// Validar rating
if (data.rating < 1 || data.rating > 5) {
throw new ApiError('La calificación debe ser entre 1 y 5', 400);
}
// Verificar que no haya reseñado antes
const existingReview = await prisma.coachReview.findUnique({
where: {
coachId_userId: {
coachId,
userId,
},
},
});
if (existingReview) {
throw new ApiError('Ya has reseñado a este coach', 409);
}
// Crear reseña en transacción
const [review] = await prisma.$transaction([
// Crear reseña
prisma.coachReview.create({
data: {
coachId,
userId,
rating: data.rating,
comment: data.comment,
},
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
avatarUrl: true,
},
},
},
}),
// Actualizar rating del coach
prisma.$executeRaw`
UPDATE coaches
SET rating = (
SELECT AVG(CAST(rating AS REAL))
FROM coach_reviews
WHERE coachId = ${coachId}
),
reviewCount = (
SELECT COUNT(*)
FROM coach_reviews
WHERE coachId = ${coachId}
),
updatedAt = datetime('now')
WHERE id = ${coachId}
`,
]);
return review;
}
// Obtener reseñas de un coach
static async getReviews(coachId: string, page = 1, limit = 10) {
const coach = await prisma.coach.findUnique({
where: { id: coachId },
});
if (!coach) {
throw new ApiError('Coach no encontrado', 404);
}
const skip = (page - 1) * limit;
const [reviews, total] = await Promise.all([
prisma.coachReview.findMany({
where: { coachId },
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
avatarUrl: true,
},
},
},
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
prisma.coachReview.count({
where: { coachId },
}),
]);
return {
reviews,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
},
};
}
// Helpers
private static timeToMinutes(time: string): number {
const [hours, minutes] = time.split(':').map(Number);
return hours * 60 + minutes;
}
private static minutesToTime(minutes: number): string {
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
}
}
export default CoachService;

View File

@@ -0,0 +1,582 @@
import { MercadoPagoConfig, Preference, Payment as MPPayment } from 'mercadopago';
import prisma from '../config/database';
import config from '../config';
import logger from '../config/logger';
import { ApiError } from '../middleware/errorHandler';
// Función helper para hacer reembolsos vía API REST
async function refundPaymentViaAPI(paymentId: string, amount?: number): Promise<any> {
const url = `https://api.mercadopago.com/v1/payments/${paymentId}/refunds`;
const body: any = {};
if (amount) {
body.amount = amount / 100; // Convertir centavos a unidades
}
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.MERCADOPAGO.ACCESS_TOKEN}`,
'Content-Type': 'application/json',
},
body: Object.keys(body).length > 0 ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const error = await response.json() as { message?: string };
throw new Error(error.message || `Error en reembolso: ${response.status}`);
}
return response.json();
}
// Configuración del cliente de MercadoPago
const mpConfig = new MercadoPagoConfig({
accessToken: config.MERCADOPAGO.ACCESS_TOKEN,
options: {
timeout: 30000,
},
});
const preferenceClient = new Preference(mpConfig);
const paymentClient = new MPPayment(mpConfig);
// Tipos de pago
export const PaymentType = {
BOOKING: 'BOOKING',
TOURNAMENT: 'TOURNAMENT',
BONUS: 'BONUS',
SUBSCRIPTION: 'SUBSCRIPTION',
CLASS: 'CLASS',
} as const;
export type PaymentTypeType = typeof PaymentType[keyof typeof PaymentType];
// Estados de pago
export const PaymentStatus = {
PENDING: 'PENDING',
PROCESSING: 'PROCESSING',
COMPLETED: 'COMPLETED',
FAILED: 'FAILED',
REFUNDED: 'REFUNDED',
CANCELLED: 'CANCELLED',
} as const;
export type PaymentStatusType = typeof PaymentStatus[keyof typeof PaymentStatus];
// Interfaces
export interface CreatePreferenceData {
type: PaymentTypeType;
referenceId: string;
title: string;
description: string;
amount: number; // En centavos
callbackUrl?: string;
metadata?: Record<string, any>;
}
export interface PreferenceResult {
id: string;
initPoint: string;
sandboxInitPoint: string;
}
// Verificar si MercadoPago está configurado
export const isMercadoPagoConfigured = (): boolean => {
return !!config.MERCADOPAGO.ACCESS_TOKEN && config.MERCADOPAGO.ACCESS_TOKEN.length > 10;
};
export class PaymentService {
/**
* Crear preferencia de pago en MercadoPago
*/
static async createPreference(
userId: string,
data: CreatePreferenceData
): Promise<PreferenceResult & { paymentId: string }> {
// Verificar configuración
if (!isMercadoPagoConfigured()) {
throw new ApiError('MercadoPago no está configurado', 500);
}
// Validar monto
if (data.amount <= 0) {
throw new ApiError('El monto debe ser mayor a 0', 400);
}
// Validar tipo
if (!Object.values(PaymentType).includes(data.type as any)) {
throw new ApiError('Tipo de pago inválido', 400);
}
// Verificar que el usuario existe
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true, firstName: true, lastName: true },
});
if (!user) {
throw new ApiError('Usuario no encontrado', 404);
}
// Construir URLs de retorno
const baseUrl = config.API_URL;
const frontendUrl = config.FRONTEND_URL;
const successUrl = config.MERCADOPAGO.SUCCESS_URL || `${frontendUrl}/payment/success`;
const failureUrl = config.MERCADOPAGO.FAILURE_URL || `${frontendUrl}/payment/failure`;
const pendingUrl = config.MERCADOPAGO.PENDING_URL || `${frontendUrl}/payment/pending`;
try {
// Crear preferencia en MercadoPago
const preferenceData = {
items: [
{
id: `${data.type}_${data.referenceId}`,
title: data.title,
description: data.description,
quantity: 1,
currency_id: 'ARS', // Podría ser configurable
unit_price: data.amount / 100, // Convertir centavos a unidades
},
],
payer: {
email: user.email,
name: user.firstName,
surname: user.lastName,
},
back_urls: {
success: successUrl,
failure: failureUrl,
pending: pendingUrl,
},
auto_return: 'approved' as const,
external_reference: `${userId}_${Date.now()}`,
notification_url: `${baseUrl}/api/v1/payments/webhook`,
metadata: {
userId,
type: data.type,
referenceId: data.referenceId,
...data.metadata,
},
};
const preference = await preferenceClient.create({ body: preferenceData });
if (!preference.id) {
throw new ApiError('Error al crear preferencia en MercadoPago', 500);
}
// Crear registro en nuestra base de datos
const payment = await prisma.payment.create({
data: {
userId,
type: data.type,
referenceId: data.referenceId,
amount: data.amount,
currency: 'ARS',
provider: 'MERCADOPAGO',
providerPreferenceId: preference.id,
status: PaymentStatus.PENDING,
metadata: data.metadata ? JSON.stringify(data.metadata) : null,
},
});
logger.info(`Preferencia creada: ${preference.id} para usuario ${userId}`);
return {
id: preference.id,
initPoint: preference.init_point || '',
sandboxInitPoint: preference.sandbox_init_point || '',
paymentId: payment.id,
};
} catch (error: any) {
logger.error('Error creando preferencia MP:', error);
throw new ApiError(
`Error al crear preferencia de pago: ${error.message || 'Error desconocido'}`,
500
);
}
}
/**
* Procesar webhook de MercadoPago
*/
static async processWebhook(payload: any): Promise<void> {
logger.info('Webhook recibido:', { type: payload.type, topic: payload.topic });
// Manejar notificaciones de tipo 'payment'
if (payload.type === 'payment' || payload.topic === 'payment') {
const paymentId = payload.data?.id;
if (!paymentId) {
logger.warn('Webhook sin payment ID');
return;
}
try {
// Obtener detalles del pago de MercadoPago
const mpPayment = await paymentClient.get({ id: paymentId });
const paymentData = mpPayment as any;
logger.info(`Pago MP recibido: ${paymentId}, estado: ${paymentData.status}`);
// Buscar el pago en nuestra base de datos por preference_id
const externalReference = paymentData.external_reference;
if (!externalReference) {
logger.warn('Pago sin external_reference');
return;
}
// Buscar por providerPaymentId o providerPreferenceId
const payment = await prisma.payment.findFirst({
where: {
OR: [
{ providerPaymentId: paymentId.toString() },
{ providerPreferenceId: paymentData.preference_id },
],
},
});
if (!payment) {
logger.warn(`Pago no encontrado: ${paymentId}`);
return;
}
// Mapear estado de MP a nuestro estado
const newStatus = this.mapMPStatusToInternal(paymentData.status);
// Actualizar el pago
await prisma.payment.update({
where: { id: payment.id },
data: {
status: newStatus,
providerPaymentId: paymentId.toString(),
paymentMethod: paymentData.payment_method_id,
installments: paymentData.installments,
paidAt: paymentData.status === 'approved' ? new Date() : payment.paidAt,
metadata: JSON.stringify({
...JSON.parse(payment.metadata || '{}'),
mpResponse: {
status: paymentData.status,
statusDetail: paymentData.status_detail,
paymentMethodId: paymentData.payment_method_id,
installments: paymentData.installments,
transactionAmount: paymentData.transaction_amount,
},
}),
},
});
// Si el pago fue aprobado, actualizar la entidad relacionada
if (newStatus === PaymentStatus.COMPLETED) {
await this.processCompletedPayment(payment);
}
logger.info(`Pago ${payment.id} actualizado a ${newStatus}`);
} catch (error: any) {
logger.error('Error procesando webhook:', error);
throw new ApiError(`Error procesando webhook: ${error.message}`, 500);
}
}
}
/**
* Mapear estados de MercadoPago a estados internos
*/
private static mapMPStatusToInternal(mpStatus: string): PaymentStatusType {
const statusMap: Record<string, PaymentStatusType> = {
pending: PaymentStatus.PENDING,
in_process: PaymentStatus.PROCESSING,
in_mediation: PaymentStatus.PROCESSING,
approved: PaymentStatus.COMPLETED,
authorized: PaymentStatus.COMPLETED,
rejected: PaymentStatus.FAILED,
cancelled: PaymentStatus.CANCELLED,
refunded: PaymentStatus.REFUNDED,
charged_back: PaymentStatus.REFUNDED,
};
return statusMap[mpStatus] || PaymentStatus.PENDING;
}
/**
* Procesar pago completado - actualizar entidad relacionada
*/
private static async processCompletedPayment(payment: any): Promise<void> {
try {
switch (payment.type) {
case PaymentType.BOOKING:
// Confirmar reserva
await prisma.booking.update({
where: { id: payment.referenceId },
data: { status: 'CONFIRMED' },
});
logger.info(`Reserva ${payment.referenceId} confirmada`);
break;
case PaymentType.TOURNAMENT:
// Actualizar estado de pago del participante
await prisma.tournamentParticipant.updateMany({
where: {
tournamentId: payment.referenceId,
userId: payment.userId,
},
data: { paymentStatus: 'PAID' },
});
logger.info(`Participación en torneo ${payment.referenceId} confirmada`);
break;
case PaymentType.BONUS:
// Activar bono (si existe el modelo)
// await prisma.userBonus.update({...})
logger.info(`Bono ${payment.referenceId} activado`);
break;
default:
logger.info(`Pago completado para ${payment.type}: ${payment.referenceId}`);
}
} catch (error) {
logger.error(`Error actualizando entidad relacionada: ${error}`);
// No lanzar error para no interrumpir el webhook
}
}
/**
* Consultar estado de un pago
*/
static async getPaymentStatus(paymentId: string) {
const payment = await prisma.payment.findUnique({
where: { id: paymentId },
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
},
});
if (!payment) {
throw new ApiError('Pago no encontrado', 404);
}
// Si tenemos providerPaymentId, consultar estado actual en MP
if (isMercadoPagoConfigured() && payment.providerPaymentId) {
try {
const mpPayment = await paymentClient.get({ id: payment.providerPaymentId });
const mpData = mpPayment as any;
// Si el estado cambió, actualizar
const currentStatus = this.mapMPStatusToInternal(mpData.status);
if (currentStatus !== payment.status) {
await prisma.payment.update({
where: { id: payment.id },
data: {
status: currentStatus,
paidAt: currentStatus === PaymentStatus.COMPLETED ? new Date() : payment.paidAt,
},
});
payment.status = currentStatus;
}
} catch (error) {
logger.warn(`No se pudo consultar estado en MP: ${error}`);
}
}
return {
id: payment.id,
type: payment.type,
referenceId: payment.referenceId,
amount: payment.amount,
currency: payment.currency,
status: payment.status,
paymentMethod: payment.paymentMethod,
installments: payment.installments,
paidAt: payment.paidAt,
createdAt: payment.createdAt,
user: payment.user,
};
}
/**
* Reembolsar un pago
*/
static async refundPayment(paymentId: string, amount?: number) {
const payment = await prisma.payment.findUnique({
where: { id: paymentId },
});
if (!payment) {
throw new ApiError('Pago no encontrado', 404);
}
if (payment.status !== PaymentStatus.COMPLETED) {
throw new ApiError('Solo se pueden reembolsar pagos completados', 400);
}
if (!payment.providerPaymentId) {
throw new ApiError('Pago sin referencia de proveedor', 400);
}
// Si no se especifica monto, reembolsar total
const refundAmount = amount || payment.amount;
if (refundAmount > payment.amount) {
throw new ApiError('El monto a reembolsar no puede ser mayor al pago', 400);
}
try {
// Realizar reembolso en MercadoPago
if (isMercadoPagoConfigured()) {
await refundPaymentViaAPI(payment.providerPaymentId, amount);
}
// Actualizar estado en nuestra BD
const updatedPayment = await prisma.payment.update({
where: { id: payment.id },
data: {
status: refundAmount >= payment.amount ? PaymentStatus.REFUNDED : PaymentStatus.COMPLETED,
refundedAt: new Date(),
refundAmount: refundAmount,
metadata: JSON.stringify({
...JSON.parse(payment.metadata || '{}'),
refund: {
amount: refundAmount,
date: new Date().toISOString(),
},
}),
},
});
// Si es reembolso total, actualizar entidad relacionada
if (refundAmount >= payment.amount) {
await this.processCancelledPayment(payment);
}
logger.info(`Pago ${payment.id} reembolsado por ${refundAmount}`);
return updatedPayment;
} catch (error: any) {
logger.error('Error reembolsando pago:', error);
throw new ApiError(`Error al reembolsar: ${error.message}`, 500);
}
}
/**
* Procesar pago cancelado/reembolsado
*/
private static async processCancelledPayment(payment: any): Promise<void> {
try {
switch (payment.type) {
case PaymentType.BOOKING:
// Cancelar reserva
await prisma.booking.update({
where: { id: payment.referenceId },
data: { status: 'CANCELLED' },
});
break;
case PaymentType.TOURNAMENT:
// Actualizar estado de pago del participante
await prisma.tournamentParticipant.updateMany({
where: {
tournamentId: payment.referenceId,
userId: payment.userId,
},
data: { paymentStatus: 'REFUNDED' },
});
break;
}
} catch (error) {
logger.error(`Error actualizando entidad relacionada: ${error}`);
}
}
/**
* Obtener historial de pagos de un usuario
*/
static async getUserPayments(userId: string) {
const payments = await prisma.payment.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
select: {
id: true,
type: true,
referenceId: true,
amount: true,
currency: true,
status: true,
paymentMethod: true,
installments: true,
paidAt: true,
refundedAt: true,
refundAmount: true,
createdAt: true,
},
});
return payments;
}
/**
* Obtener detalle de un pago por ID
*/
static async getPaymentById(id: string) {
const payment = await prisma.payment.findUnique({
where: { id },
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
},
},
},
});
if (!payment) {
throw new ApiError('Pago no encontrado', 404);
}
return payment;
}
/**
* Cancelar un pago pendiente
*/
static async cancelPayment(paymentId: string, userId: string) {
const payment = await prisma.payment.findFirst({
where: {
id: paymentId,
userId,
},
});
if (!payment) {
throw new ApiError('Pago no encontrado', 404);
}
if (payment.status !== PaymentStatus.PENDING) {
throw new ApiError('Solo se pueden cancelar pagos pendientes', 400);
}
const updatedPayment = await prisma.payment.update({
where: { id: payment.id },
data: {
status: PaymentStatus.CANCELLED,
},
});
logger.info(`Pago ${payment.id} cancelado por usuario ${userId}`);
return updatedPayment;
}
}
export default PaymentService;

View File

@@ -0,0 +1,671 @@
import prisma from '../config/database';
import { ApiError } from '../middleware/errorHandler';
import { UserSubscriptionStatus, SubscriptionPlanType } from '../utils/constants';
import logger from '../config/logger';
import { isMercadoPagoConfigured } from '../config/mercadopago';
export interface CreateSubscriptionInput {
planId: string;
paymentMethodId?: string;
}
export interface BookingData {
totalPrice: number;
courtId: string;
date: Date;
startTime: string;
endTime: string;
}
export interface SubscriptionBenefits {
hasActiveSubscription: boolean;
discountPercentage: number;
freeBookingsPerMonth: number;
freeBookingsUsed: number;
freeBookingsRemaining: number;
priorityBooking: boolean;
tournamentDiscount: number;
planName: string | null;
planType: string | null;
subscriptionStatus: string | null;
}
export class SubscriptionService {
// Crear una nueva suscripción
static async createSubscription(userId: string, planId: string, paymentMethodId?: string) {
// Verificar que el usuario existe
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true, firstName: true, lastName: true },
});
if (!user) {
throw new ApiError('Usuario no encontrado', 404);
}
// Verificar que el plan existe y está activo
const plan = await prisma.subscriptionPlan.findUnique({
where: { id: planId, isActive: true },
});
if (!plan) {
throw new ApiError('Plan de suscripción no encontrado o inactivo', 404);
}
// Verificar que el usuario no tenga ya una suscripción activa
const existingSubscription = await prisma.userSubscription.findFirst({
where: {
userId,
status: { in: ['PENDING', 'ACTIVE', 'PAUSED'] },
},
});
if (existingSubscription) {
throw new ApiError(
'Ya tienes una suscripción activa o pendiente. Cancela la actual antes de crear una nueva.',
400
);
}
// Verificar que MP está configurado
if (!isMercadoPagoConfigured()) {
throw new ApiError('Sistema de pagos no configurado', 500);
}
// Calcular fechas del período
const now = new Date();
const { currentPeriodEnd } = this.calculatePeriodDates(plan.type, now);
// Crear la suscripción en estado PENDING
const subscription = await prisma.userSubscription.create({
data: {
userId,
planId,
status: 'PENDING',
currentPeriodStart: now,
currentPeriodEnd,
paymentMethodId,
freeBookingsUsed: 0,
},
include: {
plan: true,
},
});
// Crear suscripción en MercadoPago (simulado)
// En producción, esto llamaría a la API de MP para crear una preapproval
const mpSubscriptionId = `mp_sub_${Date.now()}_${userId.slice(0, 8)}`;
// Generar URL de autorización de pago (simulado)
// En producción, esto vendría de la respuesta de MP
const initPoint = `/subscriptions/authorize?subscription=${subscription.id}`;
// Actualizar la suscripción con el ID de MP
await prisma.userSubscription.update({
where: { id: subscription.id },
data: {
mercadoPagoSubscriptionId: mpSubscriptionId,
},
});
logger.info(`Suscripción creada: ${subscription.id} para usuario ${userId}, plan ${planId}`);
return {
subscription: {
...subscription,
plan: {
...subscription.plan,
features: subscription.plan.features ? JSON.parse(subscription.plan.features) : [],
benefits: subscription.plan.benefits ? JSON.parse(subscription.plan.benefits) : {},
},
},
initPoint,
mercadoPagoSubscriptionId: mpSubscriptionId,
};
}
// Procesar webhook de MercadoPago
static async processWebhook(payload: any) {
logger.info('Procesando webhook de MercadoPago:', payload);
const { type, data, action } = payload;
// Manejar diferentes tipos de notificaciones
if (type === 'subscription' || action?.includes('preapproval')) {
const { id, status, external_reference } = data || {};
// Buscar la suscripción por ID de MP o referencia externa
const subscription = await prisma.userSubscription.findFirst({
where: {
OR: [
{ mercadoPagoSubscriptionId: id },
{ id: external_reference },
],
},
include: { plan: true },
});
if (!subscription) {
logger.warn(`Suscripción no encontrada para webhook: ${id || external_reference}`);
return { processed: false, message: 'Suscripción no encontrada' };
}
// Manejar diferentes estados
switch (action || status) {
case 'subscription.authorized':
case 'authorized':
await this.activateSubscription(subscription.id);
return { processed: true, action: 'activated' };
case 'subscription.cancelled':
case 'cancelled':
await this.cancelSubscriptionInDb(subscription.id);
return { processed: true, action: 'cancelled' };
case 'subscription.paused':
case 'paused':
await this.pauseSubscriptionInDb(subscription.id);
return { processed: true, action: 'paused' };
case 'subscription.payment':
case 'payment.created':
await this.processPayment(subscription.id);
return { processed: true, action: 'payment_processed' };
default:
logger.info(`Acción no manejada: ${action || status}`);
return { processed: false, action: 'unhandled' };
}
}
return { processed: false, message: 'Tipo de webhook no manejado' };
}
// Activar suscripción
private static async activateSubscription(subscriptionId: string) {
const subscription = await prisma.userSubscription.findUnique({
where: { id: subscriptionId },
include: { plan: true },
});
if (!subscription) {
throw new ApiError('Suscripción no encontrada', 404);
}
const now = new Date();
const { currentPeriodEnd } = this.calculatePeriodDates(subscription.plan.type, now);
const updated = await prisma.userSubscription.update({
where: { id: subscriptionId },
data: {
status: 'ACTIVE',
startDate: now,
currentPeriodStart: now,
currentPeriodEnd,
lastPaymentDate: now,
nextPaymentDate: currentPeriodEnd,
freeBookingsUsed: 0,
},
include: { plan: true },
});
logger.info(`Suscripción activada: ${subscriptionId}`);
return updated;
}
// Cancelar suscripción en base de datos (desde webhook)
private static async cancelSubscriptionInDb(subscriptionId: string) {
const updated = await prisma.userSubscription.update({
where: { id: subscriptionId },
data: {
status: 'CANCELLED',
endDate: new Date(),
},
});
logger.info(`Suscripción cancelada por webhook: ${subscriptionId}`);
return updated;
}
// Pausar suscripción en base de datos (desde webhook)
private static async pauseSubscriptionInDb(subscriptionId: string) {
const updated = await prisma.userSubscription.update({
where: { id: subscriptionId },
data: {
status: 'PAUSED',
},
});
logger.info(`Suscripción pausada por webhook: ${subscriptionId}`);
return updated;
}
// Procesar pago recurrente
private static async processPayment(subscriptionId: string) {
const subscription = await prisma.userSubscription.findUnique({
where: { id: subscriptionId },
include: { plan: true },
});
if (!subscription) {
throw new ApiError('Suscripción no encontrada', 404);
}
const now = new Date();
const { currentPeriodEnd } = this.calculatePeriodDates(subscription.plan.type, now);
const updated = await prisma.userSubscription.update({
where: { id: subscriptionId },
data: {
lastPaymentDate: now,
nextPaymentDate: currentPeriodEnd,
currentPeriodStart: now,
currentPeriodEnd,
freeBookingsUsed: 0, // Resetear reservas gratis del período
},
});
logger.info(`Pago procesado para suscripción: ${subscriptionId}`);
return updated;
}
// Obtener mi suscripción actual
static async getMySubscription(userId: string) {
const subscription = await prisma.userSubscription.findFirst({
where: {
userId,
status: { in: ['PENDING', 'ACTIVE', 'PAUSED'] },
},
include: { plan: true },
orderBy: { createdAt: 'desc' },
});
if (!subscription) {
return null;
}
return {
...subscription,
plan: {
...subscription.plan,
features: subscription.plan.features ? JSON.parse(subscription.plan.features) : [],
benefits: subscription.plan.benefits ? JSON.parse(subscription.plan.benefits) : {},
},
};
}
// Obtener suscripción por ID
static async getSubscriptionById(id: string, userId: string) {
const subscription = await prisma.userSubscription.findFirst({
where: {
id,
userId,
},
include: { plan: true },
});
if (!subscription) {
throw new ApiError('Suscripción no encontrada', 404);
}
return {
...subscription,
plan: {
...subscription.plan,
features: subscription.plan.features ? JSON.parse(subscription.plan.features) : [],
benefits: subscription.plan.benefits ? JSON.parse(subscription.plan.benefits) : {},
},
};
}
// Cancelar suscripción (al final del período)
static async cancelSubscription(id: string, userId: string) {
const subscription = await prisma.userSubscription.findFirst({
where: {
id,
userId,
status: { in: ['ACTIVE', 'PAUSED'] },
},
});
if (!subscription) {
throw new ApiError('Suscripción activa no encontrada', 404);
}
const updated = await prisma.userSubscription.update({
where: { id },
data: {
cancelAtPeriodEnd: true,
},
include: { plan: true },
});
logger.info(`Suscripción marcada para cancelar al final del período: ${id}`);
return {
...updated,
plan: {
...updated.plan,
features: updated.plan.features ? JSON.parse(updated.plan.features) : [],
benefits: updated.plan.benefits ? JSON.parse(updated.plan.benefits) : {},
},
};
}
// Pausar suscripción
static async pauseSubscription(id: string, userId: string) {
const subscription = await prisma.userSubscription.findFirst({
where: {
id,
userId,
status: 'ACTIVE',
},
});
if (!subscription) {
throw new ApiError('Suscripción activa no encontrada', 404);
}
const updated = await prisma.userSubscription.update({
where: { id },
data: {
status: 'PAUSED',
},
include: { plan: true },
});
logger.info(`Suscripción pausada: ${id}`);
return {
...updated,
plan: {
...updated.plan,
features: updated.plan.features ? JSON.parse(updated.plan.features) : [],
benefits: updated.plan.benefits ? JSON.parse(updated.plan.benefits) : {},
},
};
}
// Reanudar suscripción
static async resumeSubscription(id: string, userId: string) {
const subscription = await prisma.userSubscription.findFirst({
where: {
id,
userId,
status: 'PAUSED',
},
});
if (!subscription) {
throw new ApiError('Suscripción pausada no encontrada', 404);
}
const updated = await prisma.userSubscription.update({
where: { id },
data: {
status: 'ACTIVE',
},
include: { plan: true },
});
logger.info(`Suscripción reanudada: ${id}`);
return {
...updated,
plan: {
...updated.plan,
features: updated.plan.features ? JSON.parse(updated.plan.features) : [],
benefits: updated.plan.benefits ? JSON.parse(updated.plan.benefits) : {},
},
};
}
// Actualizar método de pago
static async updatePaymentMethod(userId: string, paymentMethodId: string) {
const subscription = await prisma.userSubscription.findFirst({
where: {
userId,
status: { in: ['ACTIVE', 'PAUSED', 'PENDING'] },
},
});
if (!subscription) {
throw new ApiError('No tienes una suscripción activa', 404);
}
const updated = await prisma.userSubscription.update({
where: { id: subscription.id },
data: {
paymentMethodId,
},
include: { plan: true },
});
logger.info(`Método de pago actualizado para suscripción: ${subscription.id}`);
return {
...updated,
plan: {
...updated.plan,
features: updated.plan.features ? JSON.parse(updated.plan.features) : [],
benefits: updated.plan.benefits ? JSON.parse(updated.plan.benefits) : {},
},
};
}
// Verificar y aplicar beneficios de suscripción
static async checkAndApplyBenefits(
userId: string,
bookingData: BookingData
): Promise<{ finalPrice: number; discountApplied: number; usedFreeBooking: boolean }> {
const subscription = await prisma.userSubscription.findFirst({
where: {
userId,
status: 'ACTIVE',
},
include: { plan: true },
});
if (!subscription) {
// No hay suscripción activa, precio normal
return {
finalPrice: bookingData.totalPrice,
discountApplied: 0,
usedFreeBooking: false,
};
}
const benefits = subscription.plan.benefits
? JSON.parse(subscription.plan.benefits)
: {};
const {
discountPercentage = 0,
freeBookingsPerMonth = 0,
} = benefits;
let finalPrice = bookingData.totalPrice;
let discountApplied = 0;
let usedFreeBooking = false;
// Verificar si tiene reservas gratis disponibles
if (freeBookingsPerMonth > 0 && subscription.freeBookingsUsed < freeBookingsPerMonth) {
// Aplicar reserva gratis
finalPrice = 0;
discountApplied = bookingData.totalPrice;
usedFreeBooking = true;
// Incrementar contador de reservas usadas
await prisma.userSubscription.update({
where: { id: subscription.id },
data: {
freeBookingsUsed: { increment: 1 },
},
});
} else if (discountPercentage > 0) {
// Aplicar descuento porcentual
discountApplied = Math.round((bookingData.totalPrice * discountPercentage) / 100);
finalPrice = bookingData.totalPrice - discountApplied;
}
return {
finalPrice,
discountApplied,
usedFreeBooking,
};
}
// Obtener beneficios actuales del usuario
static async getCurrentBenefits(userId: string): Promise<SubscriptionBenefits> {
const subscription = await prisma.userSubscription.findFirst({
where: {
userId,
status: 'ACTIVE',
},
include: { plan: true },
});
if (!subscription) {
return {
hasActiveSubscription: false,
discountPercentage: 0,
freeBookingsPerMonth: 0,
freeBookingsUsed: 0,
freeBookingsRemaining: 0,
priorityBooking: false,
tournamentDiscount: 0,
planName: null,
planType: null,
subscriptionStatus: null,
};
}
const benefits = subscription.plan.benefits
? JSON.parse(subscription.plan.benefits)
: {};
const freeBookingsPerMonth = benefits.freeBookingsPerMonth || 0;
const freeBookingsUsed = subscription.freeBookingsUsed || 0;
return {
hasActiveSubscription: true,
discountPercentage: benefits.discountPercentage || 0,
freeBookingsPerMonth,
freeBookingsUsed,
freeBookingsRemaining: Math.max(0, freeBookingsPerMonth - freeBookingsUsed),
priorityBooking: benefits.priorityBooking || false,
tournamentDiscount: benefits.tournamentDiscount || 0,
planName: subscription.plan.name,
planType: subscription.plan.type,
subscriptionStatus: subscription.status,
};
}
// Renovar suscripción manualmente
static async renewSubscription(subscriptionId: string) {
const subscription = await prisma.userSubscription.findUnique({
where: { id: subscriptionId },
include: { plan: true },
});
if (!subscription) {
throw new ApiError('Suscripción no encontrada', 404);
}
if (subscription.status !== 'EXPIRED' && subscription.status !== 'CANCELLED') {
throw new ApiError('Solo se pueden renovar suscripciones expiradas o canceladas', 400);
}
const now = new Date();
const { currentPeriodEnd } = this.calculatePeriodDates(subscription.plan.type, now);
const updated = await prisma.userSubscription.update({
where: { id: subscriptionId },
data: {
status: 'ACTIVE',
startDate: now,
currentPeriodStart: now,
currentPeriodEnd,
lastPaymentDate: now,
nextPaymentDate: currentPeriodEnd,
freeBookingsUsed: 0,
cancelAtPeriodEnd: false,
},
include: { plan: true },
});
logger.info(`Suscripción renovada manualmente: ${subscriptionId}`);
return {
...updated,
plan: {
...updated.plan,
features: updated.plan.features ? JSON.parse(updated.plan.features) : [],
benefits: updated.plan.benefits ? JSON.parse(updated.plan.benefits) : {},
},
};
}
// Calcular fechas del período según tipo de plan
private static calculatePeriodDates(type: string, startDate: Date) {
const currentPeriodStart = new Date(startDate);
const currentPeriodEnd = new Date(startDate);
switch (type) {
case 'MONTHLY':
currentPeriodEnd.setMonth(currentPeriodEnd.getMonth() + 1);
break;
case 'QUARTERLY':
currentPeriodEnd.setMonth(currentPeriodEnd.getMonth() + 3);
break;
case 'YEARLY':
currentPeriodEnd.setFullYear(currentPeriodEnd.getFullYear() + 1);
break;
default:
currentPeriodEnd.setMonth(currentPeriodEnd.getMonth() + 1);
}
return { currentPeriodStart, currentPeriodEnd };
}
// Verificar y actualizar suscripciones expiradas
static async checkExpiredSubscriptions() {
const now = new Date();
const expiredSubscriptions = await prisma.userSubscription.findMany({
where: {
status: { in: ['ACTIVE', 'PAUSED'] },
currentPeriodEnd: { lt: now },
},
});
for (const subscription of expiredSubscriptions) {
if (subscription.cancelAtPeriodEnd) {
await prisma.userSubscription.update({
where: { id: subscription.id },
data: {
status: 'CANCELLED',
endDate: now,
},
});
logger.info(`Suscripción cancelada por fin de período: ${subscription.id}`);
} else {
await prisma.userSubscription.update({
where: { id: subscription.id },
data: {
status: 'EXPIRED',
},
});
logger.info(`Suscripción marcada como expirada: ${subscription.id}`);
}
}
return expiredSubscriptions.length;
}
}
export default SubscriptionService;

View File

@@ -0,0 +1,329 @@
import prisma from '../config/database';
import { ApiError } from '../middleware/errorHandler';
import { UserRole } from '../utils/constants';
import logger from '../config/logger';
import { isMercadoPagoConfigured } from '../config/mercadopago';
export interface CreatePlanInput {
name: string;
description?: string;
type: 'MONTHLY' | 'QUARTERLY' | 'YEARLY';
price: number;
features?: string[];
benefits: {
discountPercentage: number;
freeBookingsPerMonth: number;
priorityBooking: boolean;
tournamentDiscount: number;
};
mercadoPagoPlanId?: string;
}
export interface UpdatePlanInput {
name?: string;
description?: string;
price?: number;
features?: string[];
benefits?: {
discountPercentage?: number;
freeBookingsPerMonth?: number;
priorityBooking?: boolean;
tournamentDiscount?: number;
};
isActive?: boolean;
}
export class SubscriptionPlanService {
// Crear un nuevo plan de suscripción (solo admin)
static async createPlan(adminId: string, data: CreatePlanInput) {
// Verificar que el usuario es admin
const admin = await prisma.user.findUnique({
where: { id: adminId },
select: { role: true },
});
if (!admin || (admin.role !== UserRole.ADMIN && admin.role !== UserRole.SUPERADMIN)) {
throw new ApiError('No tienes permisos para crear planes de suscripción', 403);
}
// Validar que el tipo de plan sea válido
const validTypes = ['MONTHLY', 'QUARTERLY', 'YEARLY'];
if (!validTypes.includes(data.type)) {
throw new ApiError('Tipo de plan inválido. Debe ser MONTHLY, QUARTERLY o YEARLY', 400);
}
// Crear el plan
const plan = await prisma.subscriptionPlan.create({
data: {
name: data.name,
description: data.description,
type: data.type,
price: data.price,
features: data.features ? JSON.stringify(data.features) : null,
benefits: JSON.stringify({
discountPercentage: data.benefits.discountPercentage ?? 0,
freeBookingsPerMonth: data.benefits.freeBookingsPerMonth ?? 0,
priorityBooking: data.benefits.priorityBooking ?? false,
tournamentDiscount: data.benefits.tournamentDiscount ?? 0,
}),
mercadoPagoPlanId: data.mercadoPagoPlanId,
isActive: true,
},
});
logger.info(`Plan de suscripción creado: ${plan.name} (${plan.id}) por admin ${adminId}`);
return plan;
}
// Obtener todos los planes activos
static async getPlans() {
const plans = await prisma.subscriptionPlan.findMany({
where: { isActive: true },
orderBy: { price: 'asc' },
});
// Parsear JSON strings
return plans.map((plan: any) => ({
...plan,
features: plan.features ? JSON.parse(plan.features) : [],
benefits: plan.benefits ? JSON.parse(plan.benefits) : {},
}));
}
// Obtener todos los planes (incluyendo inactivos) - admin
static async getAllPlans(adminId: string) {
// Verificar que el usuario es admin
const admin = await prisma.user.findUnique({
where: { id: adminId },
select: { role: true },
});
if (!admin || (admin.role !== UserRole.ADMIN && admin.role !== UserRole.SUPERADMIN)) {
throw new ApiError('No tienes permisos para ver todos los planes', 403);
}
const plans = await prisma.subscriptionPlan.findMany({
orderBy: { createdAt: 'desc' },
});
// Parsear JSON strings
return plans.map((plan: any) => ({
...plan,
features: plan.features ? JSON.parse(plan.features) : [],
benefits: plan.benefits ? JSON.parse(plan.benefits) : {},
}));
}
// Obtener plan por ID
static async getPlanById(id: string) {
const plan = await prisma.subscriptionPlan.findUnique({
where: { id },
});
if (!plan) {
throw new ApiError('Plan de suscripción no encontrado', 404);
}
return {
...plan,
features: plan.features ? JSON.parse(plan.features) : [],
benefits: plan.benefits ? JSON.parse(plan.benefits) : {},
};
}
// Actualizar plan (solo admin)
static async updatePlan(id: string, adminId: string, data: UpdatePlanInput) {
// Verificar que el usuario es admin
const admin = await prisma.user.findUnique({
where: { id: adminId },
select: { role: true },
});
if (!admin || (admin.role !== UserRole.ADMIN && admin.role !== UserRole.SUPERADMIN)) {
throw new ApiError('No tienes permisos para actualizar planes de suscripción', 403);
}
// Verificar que el plan existe
const existingPlan = await prisma.subscriptionPlan.findUnique({
where: { id },
});
if (!existingPlan) {
throw new ApiError('Plan de suscripción no encontrado', 404);
}
// Preparar datos de actualización
const updateData: any = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description;
if (data.price !== undefined) updateData.price = data.price;
if (data.isActive !== undefined) updateData.isActive = data.isActive;
if (data.features !== undefined) {
updateData.features = JSON.stringify(data.features);
}
if (data.benefits !== undefined) {
const currentBenefits = existingPlan.benefits ? JSON.parse(existingPlan.benefits) : {};
updateData.benefits = JSON.stringify({
...currentBenefits,
...data.benefits,
});
}
const plan = await prisma.subscriptionPlan.update({
where: { id },
data: updateData,
});
logger.info(`Plan de suscripción actualizado: ${plan.name} (${plan.id}) por admin ${adminId}`);
return {
...plan,
features: plan.features ? JSON.parse(plan.features) : [],
benefits: plan.benefits ? JSON.parse(plan.benefits) : {},
};
}
// Eliminar (desactivar) plan (solo admin)
static async deletePlan(id: string, adminId: string) {
// Verificar que el usuario es admin
const admin = await prisma.user.findUnique({
where: { id: adminId },
select: { role: true },
});
if (!admin || (admin.role !== UserRole.ADMIN && admin.role !== UserRole.SUPERADMIN)) {
throw new ApiError('No tienes permisos para eliminar planes de suscripción', 403);
}
// Verificar que el plan existe
const existingPlan = await prisma.subscriptionPlan.findUnique({
where: { id },
});
if (!existingPlan) {
throw new ApiError('Plan de suscripción no encontrado', 404);
}
// Verificar si hay suscripciones activas con este plan
const activeSubscriptions = await prisma.userSubscription.count({
where: {
planId: id,
status: 'ACTIVE',
},
});
if (activeSubscriptions > 0) {
throw new ApiError(
`No se puede eliminar el plan porque tiene ${activeSubscriptions} suscripciones activas. Desactívelo en su lugar.`,
400
);
}
// Desactivar el plan (soft delete)
const plan = await prisma.subscriptionPlan.update({
where: { id },
data: { isActive: false },
});
logger.info(`Plan de suscripción desactivado: ${plan.name} (${plan.id}) por admin ${adminId}`);
return {
...plan,
features: plan.features ? JSON.parse(plan.features) : [],
benefits: plan.benefits ? JSON.parse(plan.benefits) : {},
};
}
// Sincronizar plan con MercadoPago
static async syncPlanWithMP(planId: string, adminId: string) {
// Verificar que MP está configurado
if (!isMercadoPagoConfigured()) {
throw new ApiError('MercadoPago no está configurado', 500);
}
// Verificar que el usuario es admin
const admin = await prisma.user.findUnique({
where: { id: adminId },
select: { role: true },
});
if (!admin || (admin.role !== UserRole.ADMIN && admin.role !== UserRole.SUPERADMIN)) {
throw new ApiError('No tienes permisos para sincronizar con MercadoPago', 403);
}
// Obtener el plan
const plan = await prisma.subscriptionPlan.findUnique({
where: { id: planId },
});
if (!plan) {
throw new ApiError('Plan de suscripción no encontrado', 404);
}
// Calcular frecuencia de facturación según el tipo de plan
let frequency = 1;
let frequencyType: 'days' | 'months' = 'months';
switch (plan.type) {
case 'MONTHLY':
frequency = 1;
frequencyType = 'months';
break;
case 'QUARTERLY':
frequency = 3;
frequencyType = 'months';
break;
case 'YEARLY':
frequency = 12;
frequencyType = 'months';
break;
}
try {
// Crear plan de suscripción en MercadoPago
// Nota: La API de suscripciones de MP puede variar según la versión del SDK
// Esta es una implementación genérica que debe ajustarse según la versión específica
const mpPlanData = {
reason: plan.name,
auto_recurring: {
frequency,
frequency_type: frequencyType,
transaction_amount: plan.price / 100, // Convertir centavos a unidades
currency_id: 'ARS',
},
description: plan.description || plan.name,
};
// Aquí se implementaría la llamada real a MP según la documentación
// Por ahora simulamos la creación
logger.info(`Sincronizando plan ${plan.name} con MercadoPago...`, mpPlanData);
// Simular ID de plan de MP (en producción vendría de la respuesta de MP)
const mockMPPlanId = `mp_plan_${Date.now()}`;
// Actualizar el plan con el ID de MP
const updatedPlan = await prisma.subscriptionPlan.update({
where: { id: planId },
data: { mercadoPagoPlanId: mockMPPlanId },
});
logger.info(`Plan sincronizado con MercadoPago: ${mockMPPlanId}`);
return {
...updatedPlan,
features: updatedPlan.features ? JSON.parse(updatedPlan.features) : [],
benefits: updatedPlan.benefits ? JSON.parse(updatedPlan.benefits) : {},
mercadoPagoPlanId: mockMPPlanId,
};
} catch (error) {
logger.error('Error sincronizando con MercadoPago:', error);
throw new ApiError('Error al sincronizar con MercadoPago', 500);
}
}
}
export default SubscriptionPlanService;

View File

@@ -1,4 +1,4 @@
// Constantes para reemplazar enums (SQLite no soporta enums nativamente)
// Constantes para reemplazar enums (SQLite no soporta enums nativos)
export const UserRole = {
PLAYER: 'PLAYER',
@@ -221,3 +221,118 @@ export const LeaguePoints = {
DRAW: 1, // Empate
LOSS: 0, // Derrota
} as const;
// ============================================
// Constantes de Pagos (Fase 4.1)
// ============================================
// Tipos de pago
export const PaymentType = {
BOOKING: 'BOOKING',
TOURNAMENT: 'TOURNAMENT',
BONUS: 'BONUS',
SUBSCRIPTION: 'SUBSCRIPTION',
CLASS: 'CLASS',
} as const;
export type PaymentTypeType = typeof PaymentType[keyof typeof PaymentType];
// Estados de pago extendidos (usamos los del TournamentParticipant más los adicionales)
export const ExtendedPaymentStatus = {
PENDING: 'PENDING',
PROCESSING: 'PROCESSING',
COMPLETED: 'COMPLETED',
FAILED: 'FAILED',
REFUNDED: 'REFUNDED',
CANCELLED: 'CANCELLED',
PAID: 'PAID',
} as const;
export type ExtendedPaymentStatusType = typeof ExtendedPaymentStatus[keyof typeof ExtendedPaymentStatus];
// Proveedores de pago
export const PaymentProvider = {
MERCADOPAGO: 'MERCADOPAGO',
STRIPE: 'STRIPE',
PAYPAL: 'PAYPAL',
CASH: 'CASH',
} as const;
export type PaymentProviderType = typeof PaymentProvider[keyof typeof PaymentProvider];
// ============================================
// Constantes de Bonos (Fase 4.2)
// ============================================
// Estados del bono de usuario
export const UserBonusStatus = {
ACTIVE: 'ACTIVE', // Bono activo y disponible
EXPIRED: 'EXPIRED', // Bono expirado
DEPLETED: 'DEPLETED', // Bono agotado (usos completados)
} as const;
export type UserBonusStatusType = typeof UserBonusStatus[keyof typeof UserBonusStatus];
// ============================================
// Constantes de Clases con Profesores (Fase 4.4)
// ============================================
// Tipos de clase
export const ClassType = {
INDIVIDUAL: 'INDIVIDUAL', // Clase individual (1 alumno)
GROUP: 'GROUP', // Clase grupal (2-4 alumnos)
CLINIC: 'CLINIC', // Clínica (5-16 alumnos)
} as const;
export type ClassTypeType = typeof ClassType[keyof typeof ClassType];
// Estados de la sesión de clase
export const ClassBookingStatus = {
AVAILABLE: 'AVAILABLE', // Disponible para inscripción
FULL: 'FULL', // Cupo completo
COMPLETED: 'COMPLETED', // Clase completada
CANCELLED: 'CANCELLED', // Cancelada
} as const;
export type ClassBookingStatusType = typeof ClassBookingStatus[keyof typeof ClassBookingStatus];
// Estados de inscripción
export const EnrollmentStatus = {
PENDING: 'PENDING', // Pendiente de pago
CONFIRMED: 'CONFIRMED', // Confirmado
CANCELLED: 'CANCELLED', // Cancelado
ATTENDED: 'ATTENDED', // Asistió a la clase
} as const;
export type EnrollmentStatusType = typeof EnrollmentStatus[keyof typeof EnrollmentStatus];
// Límites de alumnos por tipo de clase
export const ClassLimits = {
[ClassType.INDIVIDUAL]: { min: 1, max: 1 },
[ClassType.GROUP]: { min: 2, max: 4 },
[ClassType.CLINIC]: { min: 5, max: 16 },
} as const;
// ============================================
// Constantes de Suscripciones (Fase 4.3)
// ============================================
// Tipos de plan de suscripción
export const SubscriptionPlanType = {
MONTHLY: 'MONTHLY', // Mensual
QUARTERLY: 'QUARTERLY', // Trimestral
YEARLY: 'YEARLY', // Anual
} as const;
export type SubscriptionPlanTypeType = typeof SubscriptionPlanType[keyof typeof SubscriptionPlanType];
// Estados de suscripción de usuario
export const UserSubscriptionStatus = {
PENDING: 'PENDING', // Pendiente de activación
ACTIVE: 'ACTIVE', // Activa
PAUSED: 'PAUSED', // Pausada
CANCELLED: 'CANCELLED', // Cancelada
EXPIRED: 'EXPIRED', // Expirada
} as const;
export type UserSubscriptionStatusType = typeof UserSubscriptionStatus[keyof typeof UserSubscriptionStatus];

View File

@@ -0,0 +1,43 @@
import { z } from 'zod';
// Esquema para crear un pack de bonos
export const createBonusPackSchema = z.object({
name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'),
description: z.string().optional(),
numberOfBookings: z.number().int().positive('La cantidad de reservas debe ser mayor a 0'),
price: z.number().int().nonnegative('El precio no puede ser negativo'),
validityDays: z.number().int().positive('Los días de validez deben ser mayor a 0'),
});
// Esquema para actualizar un pack de bonos
export const updateBonusPackSchema = z.object({
name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres').optional(),
description: z.string().optional(),
numberOfBookings: z.number().int().positive('La cantidad de reservas debe ser mayor a 0').optional(),
price: z.number().int().nonnegative('El precio no puede ser negativo').optional(),
validityDays: z.number().int().positive('Los días de validez deben ser mayor a 0').optional(),
isActive: z.boolean().optional(),
});
// Esquema para comprar un bono
export const purchaseBonusSchema = z.object({
bonusPackId: z.string().uuid('ID de pack de bonos inválido'),
paymentId: z.string().min(1, 'El ID de pago es requerido'),
});
// Esquema para usar un bono
export const useBonusSchema = z.object({
bookingId: z.string().uuid('ID de reserva inválido'),
});
// Esquema para parámetros de ID
export const bonusIdParamSchema = z.object({
id: z.string().uuid('ID de bono inválido'),
});
// Tipos inferidos
export type CreateBonusPackInput = z.infer<typeof createBonusPackSchema>;
export type UpdateBonusPackInput = z.infer<typeof updateBonusPackSchema>;
export type PurchaseBonusInput = z.infer<typeof purchaseBonusSchema>;
export type UseBonusInput = z.infer<typeof useBonusSchema>;
export type BonusIdParamInput = z.infer<typeof bonusIdParamSchema>;

View File

@@ -0,0 +1,64 @@
import { z } from 'zod';
import { ClassType, PlayerLevel } from '../utils/constants';
// Esquema de registro como coach
export const registerCoachSchema = z.object({
bio: z.string().max(1000, 'La biografía no puede exceder 1000 caracteres').optional(),
specialties: z.array(z.string()).optional(),
certifications: z.array(z.string()).optional(),
yearsExperience: z.number().min(0, 'La experiencia no puede ser negativa').optional(),
hourlyRate: z.number().min(0, 'La tarifa no puede ser negativa').optional(),
photoUrl: z.string().url('URL de foto inválida').optional().or(z.literal('')),
});
// Esquema de creación de clase
export const createClassSchema = z.object({
title: z.string().min(3, 'El título debe tener al menos 3 caracteres').max(100, 'Máximo 100 caracteres'),
description: z.string().max(2000, 'Máximo 2000 caracteres').optional(),
type: z.enum([ClassType.INDIVIDUAL, ClassType.GROUP, ClassType.CLINIC]),
maxStudents: z.number().min(1, 'Mínimo 1 alumno').max(16, 'Máximo 16 alumnos').optional(),
price: z.number().min(0, 'El precio no puede ser negativo'),
duration: z.number().min(30, 'Mínimo 30 minutos').max(180, 'Máximo 180 minutos').optional(),
levelRequired: z.enum([
PlayerLevel.BEGINNER,
PlayerLevel.ELEMENTARY,
PlayerLevel.INTERMEDIATE,
PlayerLevel.ADVANCED,
PlayerLevel.COMPETITION,
PlayerLevel.PROFESSIONAL,
]).optional(),
});
// Esquema de creación de sesión de clase
export const createClassBookingSchema = z.object({
courtId: z.string().optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Formato de fecha inválido (YYYY-MM-DD)'),
startTime: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, 'Formato de hora inválido (HH:mm)'),
price: z.number().min(0, 'El precio no puede ser negativo').optional(),
});
// Esquema de inscripción
export const enrollmentSchema = z.object({
classBookingId: z.string().min(1, 'El ID de la sesión es requerido'),
});
// Esquema de reseña
export const reviewSchema = z.object({
rating: z.number().min(1, 'Mínimo 1 estrella').max(5, 'Máximo 5 estrellas'),
comment: z.string().max(1000, 'Máximo 1000 caracteres').optional(),
});
// Esquema de disponibilidad
export const availabilitySchema = z.object({
dayOfWeek: z.number().min(0, 'Domingo = 0').max(6, 'Sábado = 6'),
startTime: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, 'Formato inválido (HH:mm)'),
endTime: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, 'Formato inválido (HH:mm)'),
});
// Tipos inferidos
export type RegisterCoachInput = z.infer<typeof registerCoachSchema>;
export type CreateClassInput = z.infer<typeof createClassSchema>;
export type CreateClassBookingInput = z.infer<typeof createClassBookingSchema>;
export type EnrollmentInput = z.infer<typeof enrollmentSchema>;
export type ReviewInput = z.infer<typeof reviewSchema>;
export type AvailabilityInput = z.infer<typeof availabilitySchema>;

View File

@@ -0,0 +1,35 @@
import { z } from 'zod';
// Esquema para crear preferencia de pago
export const createPreferenceSchema = z.object({
type: z.enum(['BOOKING', 'TOURNAMENT', 'BONUS', 'SUBSCRIPTION', 'CLASS'], {
errorMap: () => ({ message: 'Tipo de pago inválido' }),
}),
referenceId: z.string().min(1, 'El ID de referencia es requerido'),
title: z.string().min(1, 'El título es requerido').max(100, 'El título no puede exceder 100 caracteres'),
description: z.string().min(1, 'La descripción es requerida').max(255, 'La descripción no puede exceder 255 caracteres'),
amount: z.number()
.int('El monto debe ser un número entero (centavos)')
.min(100, 'El monto mínimo es 100 centavos ($1.00)')
.max(10000000, 'El monto máximo es 10,000,000 centavos ($100,000.00)'),
callbackUrl: z.string().url('URL de callback inválida').optional(),
metadata: z.record(z.any()).optional(),
});
// Esquema para reembolso
export const refundSchema = z.object({
amount: z.number()
.int('El monto debe ser un número entero (centavos)')
.min(1, 'El monto debe ser mayor a 0')
.optional(),
});
// Esquema para parámetro de ID de pago
export const paymentIdParamSchema = z.object({
id: z.string().min(1, 'El ID de pago es requerido'),
});
// Tipos inferidos
export type CreatePreferenceInput = z.infer<typeof createPreferenceSchema>;
export type RefundInput = z.infer<typeof refundSchema>;
export type PaymentIdParamInput = z.infer<typeof paymentIdParamSchema>;

View File

@@ -0,0 +1,51 @@
import { z } from 'zod';
// Esquema para crear un plan de suscripción
export const createPlanSchema = z.object({
name: z.string().min(2, 'El nombre debe tener al menos 2 caracteres'),
description: z.string().optional(),
type: z.enum(['MONTHLY', 'QUARTERLY', 'YEARLY'], {
errorMap: () => ({ message: 'El tipo debe ser MONTHLY, QUARTERLY o YEARLY' }),
}),
price: z.number().int().min(0, 'El precio debe ser mayor o igual a 0'),
features: z.array(z.string()).optional(),
benefits: z.object({
discountPercentage: z.number().int().min(0).max(100).default(0),
freeBookingsPerMonth: z.number().int().min(0).default(0),
priorityBooking: z.boolean().default(false),
tournamentDiscount: z.number().int().min(0).max(100).default(0),
}),
mercadoPagoPlanId: z.string().optional(),
});
// Esquema para actualizar un plan de suscripción
export const updatePlanSchema = z.object({
name: z.string().min(2).optional(),
description: z.string().optional(),
price: z.number().int().min(0).optional(),
features: z.array(z.string()).optional(),
benefits: z.object({
discountPercentage: z.number().int().min(0).max(100).optional(),
freeBookingsPerMonth: z.number().int().min(0).optional(),
priorityBooking: z.boolean().optional(),
tournamentDiscount: z.number().int().min(0).max(100).optional(),
}).optional(),
isActive: z.boolean().optional(),
});
// Esquema para crear una suscripción
export const createSubscriptionSchema = z.object({
planId: z.string().uuid('ID de plan inválido'),
paymentMethodId: z.string().optional(),
});
// Esquema para actualizar método de pago
export const updatePaymentMethodSchema = z.object({
paymentMethodId: z.string().min(1, 'El ID del método de pago es requerido'),
});
// Tipos inferidos
export type CreatePlanInput = z.infer<typeof createPlanSchema>;
export type UpdatePlanInput = z.infer<typeof updatePlanSchema>;
export type CreateSubscriptionInput = z.infer<typeof createSubscriptionSchema>;
export type UpdatePaymentMethodInput = z.infer<typeof updatePaymentMethodSchema>;