- Backend NestJS con módulos de autenticación, inventario, créditos - Frontend React con dashboard y componentes UI - Base de datos PostgreSQL con migraciones - Tests E2E configurados - Configuración de Docker y deployment Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
417 lines
12 KiB
TypeScript
417 lines
12 KiB
TypeScript
import {
|
|
Injectable,
|
|
BadRequestException,
|
|
NotFoundException,
|
|
Logger,
|
|
Inject,
|
|
forwardRef,
|
|
} from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import Stripe from 'stripe';
|
|
import {
|
|
Payment,
|
|
PaymentStatus,
|
|
PaymentMethod,
|
|
} from './entities/payment.entity';
|
|
import { CreatePaymentDto } from './dto/create-payment.dto';
|
|
import { CreditsService } from '../credits/credits.service';
|
|
import { TransactionType } from '../credits/entities/credit-transaction.entity';
|
|
import { UsersService } from '../users/users.service';
|
|
import { NotificationsService } from '../notifications/notifications.service';
|
|
|
|
@Injectable()
|
|
export class PaymentsService {
|
|
private readonly logger = new Logger(PaymentsService.name);
|
|
private stripe: Stripe;
|
|
|
|
constructor(
|
|
@InjectRepository(Payment)
|
|
private readonly paymentsRepository: Repository<Payment>,
|
|
private readonly creditsService: CreditsService,
|
|
private readonly usersService: UsersService,
|
|
private readonly configService: ConfigService,
|
|
@Inject(forwardRef(() => NotificationsService))
|
|
private readonly notificationsService: NotificationsService,
|
|
) {
|
|
const stripeKey = this.configService.get('STRIPE_SECRET_KEY');
|
|
if (stripeKey && stripeKey !== 'sk_test_your_stripe_key') {
|
|
this.stripe = new Stripe(stripeKey, { apiVersion: '2023-10-16' });
|
|
}
|
|
}
|
|
|
|
async createPayment(userId: string, dto: CreatePaymentDto) {
|
|
// Get package
|
|
const pkg = await this.creditsService.getPackageById(dto.packageId);
|
|
|
|
// Get or create Stripe customer
|
|
const user = await this.usersService.findById(userId);
|
|
if (!user) {
|
|
throw new BadRequestException('Usuario no encontrado');
|
|
}
|
|
|
|
let customerId = user.stripeCustomerId;
|
|
|
|
if (!customerId && this.stripe) {
|
|
const customer = await this.stripe.customers.create({
|
|
phone: user.phone || undefined,
|
|
name: user.name || undefined,
|
|
metadata: { userId },
|
|
});
|
|
customerId = customer.id;
|
|
await this.usersService.update(userId, { stripeCustomerId: customerId });
|
|
}
|
|
|
|
// Create payment record
|
|
const payment = this.paymentsRepository.create({
|
|
userId,
|
|
packageId: pkg.id,
|
|
amountMXN: Number(pkg.priceMXN),
|
|
creditsGranted: pkg.credits,
|
|
method: dto.method,
|
|
status: PaymentStatus.PENDING,
|
|
provider: 'stripe',
|
|
});
|
|
|
|
await this.paymentsRepository.save(payment);
|
|
|
|
try {
|
|
switch (dto.method) {
|
|
case PaymentMethod.CARD:
|
|
return this.processCardPayment(payment, customerId, dto.paymentMethodId);
|
|
case PaymentMethod.OXXO:
|
|
return this.processOxxoPayment(payment, customerId);
|
|
case PaymentMethod.SEVEN_ELEVEN:
|
|
return this.process7ElevenPayment(payment);
|
|
default:
|
|
throw new BadRequestException('Metodo de pago no soportado');
|
|
}
|
|
} catch (error) {
|
|
payment.status = PaymentStatus.FAILED;
|
|
payment.errorMessage = error.message;
|
|
await this.paymentsRepository.save(payment);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async processCardPayment(
|
|
payment: Payment,
|
|
customerId: string,
|
|
paymentMethodId?: string,
|
|
) {
|
|
if (!this.stripe) {
|
|
// Development mode - simulate success
|
|
return this.simulatePaymentSuccess(payment);
|
|
}
|
|
|
|
if (!paymentMethodId) {
|
|
// Create PaymentIntent for new card
|
|
const paymentIntent = await this.stripe.paymentIntents.create({
|
|
amount: Math.round(payment.amountMXN * 100),
|
|
currency: 'mxn',
|
|
customer: customerId,
|
|
metadata: { paymentId: payment.id },
|
|
});
|
|
|
|
payment.externalId = paymentIntent.id;
|
|
await this.paymentsRepository.save(payment);
|
|
|
|
return {
|
|
paymentId: payment.id,
|
|
clientSecret: paymentIntent.client_secret,
|
|
status: 'requires_payment_method',
|
|
};
|
|
}
|
|
|
|
// Charge with existing payment method
|
|
const paymentIntent = await this.stripe.paymentIntents.create({
|
|
amount: Math.round(payment.amountMXN * 100),
|
|
currency: 'mxn',
|
|
customer: customerId,
|
|
payment_method: paymentMethodId,
|
|
confirm: true,
|
|
metadata: { paymentId: payment.id },
|
|
});
|
|
|
|
payment.externalId = paymentIntent.id;
|
|
|
|
if (paymentIntent.status === 'succeeded') {
|
|
await this.completePayment(payment);
|
|
return {
|
|
paymentId: payment.id,
|
|
status: 'completed',
|
|
creditsGranted: payment.creditsGranted,
|
|
};
|
|
}
|
|
|
|
payment.status = PaymentStatus.PROCESSING;
|
|
await this.paymentsRepository.save(payment);
|
|
|
|
return {
|
|
paymentId: payment.id,
|
|
status: paymentIntent.status,
|
|
};
|
|
}
|
|
|
|
private async processOxxoPayment(payment: Payment, customerId: string) {
|
|
if (!this.stripe) {
|
|
return this.simulateOxxoVoucher(payment);
|
|
}
|
|
|
|
// Create OXXO payment intent
|
|
const paymentIntent = await this.stripe.paymentIntents.create({
|
|
amount: Math.round(payment.amountMXN * 100),
|
|
currency: 'mxn',
|
|
customer: customerId,
|
|
payment_method_types: ['oxxo'],
|
|
metadata: { paymentId: payment.id },
|
|
});
|
|
|
|
// Confirm with OXXO
|
|
const confirmedIntent = await this.stripe.paymentIntents.confirm(
|
|
paymentIntent.id,
|
|
{
|
|
payment_method_data: {
|
|
type: 'oxxo',
|
|
billing_details: {
|
|
name: 'Cliente MiInventario',
|
|
email: 'cliente@miinventario.com',
|
|
},
|
|
},
|
|
},
|
|
);
|
|
|
|
payment.externalId = paymentIntent.id;
|
|
payment.status = PaymentStatus.PENDING;
|
|
payment.expiresAt = new Date(Date.now() + 72 * 60 * 60 * 1000); // 72 hours
|
|
|
|
// Get OXXO voucher details
|
|
const nextAction = confirmedIntent.next_action;
|
|
if (nextAction?.oxxo_display_details) {
|
|
payment.voucherCode = nextAction.oxxo_display_details.number || undefined;
|
|
payment.voucherUrl = nextAction.oxxo_display_details.hosted_voucher_url || undefined;
|
|
}
|
|
|
|
await this.paymentsRepository.save(payment);
|
|
|
|
return {
|
|
paymentId: payment.id,
|
|
status: 'pending',
|
|
method: 'oxxo',
|
|
voucherCode: payment.voucherCode,
|
|
voucherUrl: payment.voucherUrl,
|
|
expiresAt: payment.expiresAt,
|
|
amountMXN: payment.amountMXN,
|
|
};
|
|
}
|
|
|
|
private async process7ElevenPayment(payment: Payment) {
|
|
// TODO: Implement Conekta 7-Eleven integration
|
|
// For now, return simulated voucher
|
|
return this.simulate7ElevenVoucher(payment);
|
|
}
|
|
|
|
private async simulatePaymentSuccess(payment: Payment) {
|
|
this.logger.warn('Development mode: Simulating payment success');
|
|
payment.externalId = `sim_${Date.now()}`;
|
|
await this.completePayment(payment);
|
|
|
|
return {
|
|
paymentId: payment.id,
|
|
status: 'completed',
|
|
creditsGranted: payment.creditsGranted,
|
|
simulated: true,
|
|
};
|
|
}
|
|
|
|
private async simulateOxxoVoucher(payment: Payment) {
|
|
this.logger.warn('Development mode: Simulating OXXO voucher');
|
|
|
|
payment.externalId = `sim_oxxo_${Date.now()}`;
|
|
payment.voucherCode = Math.random().toString().slice(2, 16);
|
|
payment.voucherUrl = `https://example.com/voucher/${payment.id}`;
|
|
payment.expiresAt = new Date(Date.now() + 72 * 60 * 60 * 1000);
|
|
await this.paymentsRepository.save(payment);
|
|
|
|
return {
|
|
paymentId: payment.id,
|
|
status: 'pending',
|
|
method: 'oxxo',
|
|
voucherCode: payment.voucherCode,
|
|
voucherUrl: payment.voucherUrl,
|
|
expiresAt: payment.expiresAt,
|
|
amountMXN: payment.amountMXN,
|
|
simulated: true,
|
|
};
|
|
}
|
|
|
|
private async simulate7ElevenVoucher(payment: Payment) {
|
|
this.logger.warn('Development mode: Simulating 7-Eleven voucher');
|
|
|
|
payment.externalId = `sim_7eleven_${Date.now()}`;
|
|
payment.voucherCode = Math.random().toString().slice(2, 14);
|
|
payment.expiresAt = new Date(Date.now() + 48 * 60 * 60 * 1000);
|
|
await this.paymentsRepository.save(payment);
|
|
|
|
return {
|
|
paymentId: payment.id,
|
|
status: 'pending',
|
|
method: '7eleven',
|
|
voucherCode: payment.voucherCode,
|
|
expiresAt: payment.expiresAt,
|
|
amountMXN: payment.amountMXN,
|
|
simulated: true,
|
|
};
|
|
}
|
|
|
|
async handleStripeWebhook(payload: Buffer, signature: string) {
|
|
const webhookSecret = this.configService.get('STRIPE_WEBHOOK_SECRET');
|
|
|
|
if (!this.stripe || !webhookSecret) {
|
|
this.logger.warn('Stripe webhook not configured');
|
|
return { received: true, processed: false };
|
|
}
|
|
|
|
let event: Stripe.Event;
|
|
|
|
try {
|
|
event = this.stripe.webhooks.constructEvent(
|
|
payload,
|
|
signature,
|
|
webhookSecret,
|
|
);
|
|
} catch (err) {
|
|
this.logger.error(`Webhook signature verification failed: ${err.message}`);
|
|
throw new BadRequestException('Webhook signature verification failed');
|
|
}
|
|
|
|
this.logger.log(`Processing Stripe webhook: ${event.type}`);
|
|
|
|
switch (event.type) {
|
|
case 'payment_intent.succeeded':
|
|
await this.handlePaymentSuccess(event.data.object as Stripe.PaymentIntent);
|
|
break;
|
|
case 'payment_intent.payment_failed':
|
|
await this.handlePaymentFailed(event.data.object as Stripe.PaymentIntent);
|
|
break;
|
|
case 'payment_intent.processing':
|
|
await this.handlePaymentProcessing(event.data.object as Stripe.PaymentIntent);
|
|
break;
|
|
case 'charge.succeeded':
|
|
// Additional confirmation for OXXO/cash payments
|
|
this.logger.log(`Charge succeeded: ${(event.data.object as Stripe.Charge).id}`);
|
|
break;
|
|
case 'charge.pending':
|
|
// OXXO payment is pending (customer needs to pay at store)
|
|
this.logger.log(`Charge pending: ${(event.data.object as Stripe.Charge).id}`);
|
|
break;
|
|
default:
|
|
this.logger.log(`Unhandled event type: ${event.type}`);
|
|
}
|
|
|
|
return { received: true, processed: true, eventType: event.type };
|
|
}
|
|
|
|
private async handlePaymentProcessing(paymentIntent: Stripe.PaymentIntent) {
|
|
const paymentId = paymentIntent.metadata.paymentId;
|
|
if (!paymentId) return;
|
|
|
|
await this.paymentsRepository.update(paymentId, {
|
|
status: PaymentStatus.PROCESSING,
|
|
});
|
|
|
|
this.logger.log(`Payment ${paymentId} is now processing`);
|
|
}
|
|
|
|
private async handlePaymentSuccess(paymentIntent: Stripe.PaymentIntent) {
|
|
const paymentId = paymentIntent.metadata.paymentId;
|
|
if (!paymentId) return;
|
|
|
|
const payment = await this.paymentsRepository.findOne({
|
|
where: { id: paymentId },
|
|
});
|
|
|
|
if (payment && payment.status !== PaymentStatus.COMPLETED) {
|
|
await this.completePayment(payment);
|
|
}
|
|
}
|
|
|
|
private async handlePaymentFailed(paymentIntent: Stripe.PaymentIntent) {
|
|
const paymentId = paymentIntent.metadata.paymentId;
|
|
if (!paymentId) return;
|
|
|
|
const payment = await this.paymentsRepository.findOne({
|
|
where: { id: paymentId },
|
|
});
|
|
|
|
if (payment) {
|
|
await this.paymentsRepository.update(paymentId, {
|
|
status: PaymentStatus.FAILED,
|
|
errorMessage: paymentIntent.last_payment_error?.message,
|
|
});
|
|
|
|
// Send notification
|
|
try {
|
|
await this.notificationsService.notifyPaymentFailed(payment.userId, paymentId);
|
|
} catch (error) {
|
|
this.logger.error(`Failed to send payment failed notification: ${error.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async completePayment(payment: Payment) {
|
|
payment.status = PaymentStatus.COMPLETED;
|
|
payment.completedAt = new Date();
|
|
await this.paymentsRepository.save(payment);
|
|
|
|
// Grant credits
|
|
await this.creditsService.addCredits(
|
|
payment.userId,
|
|
payment.creditsGranted,
|
|
TransactionType.PURCHASE,
|
|
`Compra de ${payment.creditsGranted} creditos`,
|
|
payment.id,
|
|
'payment',
|
|
);
|
|
|
|
// Send notification
|
|
try {
|
|
await this.notificationsService.notifyPaymentComplete(
|
|
payment.userId,
|
|
payment.id,
|
|
payment.creditsGranted,
|
|
);
|
|
} catch (error) {
|
|
this.logger.error(`Failed to send payment notification: ${error.message}`);
|
|
}
|
|
|
|
this.logger.log(
|
|
`Payment ${payment.id} completed. Granted ${payment.creditsGranted} credits to user ${payment.userId}`,
|
|
);
|
|
}
|
|
|
|
async getPaymentHistory(userId: string, page = 1, limit = 20) {
|
|
const [payments, total] = await this.paymentsRepository.findAndCount({
|
|
where: { userId },
|
|
order: { createdAt: 'DESC' },
|
|
skip: (page - 1) * limit,
|
|
take: limit,
|
|
});
|
|
|
|
return { payments, total };
|
|
}
|
|
|
|
async getPaymentById(paymentId: string, userId: string) {
|
|
const payment = await this.paymentsRepository.findOne({
|
|
where: { id: paymentId, userId },
|
|
});
|
|
|
|
if (!payment) {
|
|
throw new NotFoundException('Pago no encontrado');
|
|
}
|
|
|
|
return payment;
|
|
}
|
|
}
|