Sistema NEXUS v3.4 migrado con: Estructura principal: - core/orchestration: Sistema SIMCO + CAPVED (27 directivas, 28 perfiles) - core/catalog: Catalogo de funcionalidades reutilizables - shared/knowledge-base: Base de conocimiento compartida - devtools/scripts: Herramientas de desarrollo - control-plane/registries: Control de servicios y CI/CD - orchestration/: Configuracion de orquestacion de agentes Proyectos incluidos (11): - gamilit (submodule -> GitHub) - trading-platform (OrbiquanTIA) - erp-suite con 5 verticales: - erp-core, construccion, vidrio-templado - mecanicas-diesel, retail, clinicas - betting-analytics - inmobiliaria-analytics - platform_marketing_content - pos-micro, erp-basico Configuracion: - .gitignore completo para Node.js/Python/Docker - gamilit como submodule (git@github.com:rckrdmrd/gamilit-workspace.git) - Sistema de puertos estandarizado (3005-3199) Generated with NEXUS v3.4 Migration System EPIC-010: Configuracion Git y Repositorios
31 KiB
31 KiB
Guía de Implementación: Pagos con Stripe
Versión: 1.0.0 Tiempo estimado: 3-5 horas Complejidad: Alta
Pre-requisitos
- Cuenta de Stripe (test o live)
- Proyecto NestJS existente
- PostgreSQL como base de datos
- URL pública para webhooks (ngrok para desarrollo)
Paso 1: Configurar Stripe Dashboard
1.1 Crear cuenta y obtener claves
- Ir a dashboard.stripe.com
- En Developers > API keys, obtener:
- Publishable key:
pk_test_xxxopk_live_xxx - Secret key:
sk_test_xxxosk_live_xxx
- Publishable key:
1.2 Crear productos y precios
- Ir a Products > Add product
- Crear cada plan con sus precios:
Producto: Basic Plan
├── Price ID (monthly): price_basic_monthly
└── Price ID (yearly): price_basic_yearly
Producto: Pro Plan
├── Price ID (monthly): price_pro_monthly
└── Price ID (yearly): price_pro_yearly
1.3 Configurar webhook
- Ir a Developers > Webhooks
- Add endpoint:
https://api.example.com/webhooks/stripe - Seleccionar eventos:
checkout.session.completedinvoice.paidinvoice.payment_failedcustomer.subscription.updatedcustomer.subscription.deleted
- Copiar Signing secret:
whsec_xxx
Paso 2: Instalar Dependencias
npm install stripe
Paso 3: Variables de Entorno
# .env
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_PUBLISHABLE_KEY=pk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
# URLs
FRONTEND_URL=http://localhost:3000
STRIPE_SUCCESS_URL=${FRONTEND_URL}/checkout/success
STRIPE_CANCEL_URL=${FRONTEND_URL}/pricing
Paso 4: Crear Estructura de Directorios
mkdir -p src/modules/payments/services
mkdir -p src/modules/payments/controllers
mkdir -p src/modules/payments/dto
mkdir -p src/modules/payments/types
Paso 5: Definir Tipos
// src/modules/payments/types/payments.types.ts
export type SubscriptionStatus = 'trialing' | 'active' | 'past_due' | 'cancelled' | 'unpaid';
export type BillingCycle = 'monthly' | 'yearly';
export type PaymentStatus = 'pending' | 'succeeded' | 'failed' | 'refunded';
export interface StripeCustomer {
id: string;
userId: string;
stripeCustomerId: string;
email?: string;
defaultPaymentMethodId?: string;
createdAt: Date;
updatedAt: Date;
}
export interface SubscriptionPlan {
id: string;
name: string;
slug: string;
description?: string;
priceMonthly: number;
priceYearly?: number;
currency: string;
stripePriceIdMonthly?: string;
stripePriceIdYearly?: string;
stripeProductId?: string;
features: { name: string; included: boolean }[];
isActive: boolean;
sortOrder: number;
createdAt: Date;
updatedAt: Date;
}
export interface Subscription {
id: string;
userId: string;
planId: string;
stripeSubscriptionId?: string;
stripeCustomerId?: string;
status: SubscriptionStatus;
billingCycle: BillingCycle;
currentPeriodStart?: Date;
currentPeriodEnd?: Date;
cancelAtPeriodEnd: boolean;
cancelledAt?: Date;
currentPrice?: number;
currency: string;
createdAt: Date;
updatedAt: Date;
}
export interface SubscriptionWithPlan extends Subscription {
plan: SubscriptionPlan;
}
export interface CheckoutSession {
sessionId: string;
url: string;
expiresAt: Date;
}
export interface CreateCheckoutInput {
userId: string;
planId: string;
billingCycle?: BillingCycle;
successUrl: string;
cancelUrl: string;
promoCode?: string;
}
export interface BillingPortalSession {
url: string;
returnUrl: string;
}
Paso 6: Crear Servicio de Stripe
// src/modules/payments/services/stripe.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import Stripe from 'stripe';
import { StripeCustomerEntity } from '../entities/stripe-customer.entity';
import { SubscriptionPlanEntity } from '../entities/subscription-plan.entity';
import {
StripeCustomer,
CheckoutSession,
CreateCheckoutInput,
BillingPortalSession,
} from '../types/payments.types';
@Injectable()
export class StripeService {
private readonly stripe: Stripe;
private readonly logger = new Logger(StripeService.name);
constructor(
@InjectRepository(StripeCustomerEntity)
private readonly customerRepo: Repository<StripeCustomerEntity>,
@InjectRepository(SubscriptionPlanEntity)
private readonly planRepo: Repository<SubscriptionPlanEntity>,
private readonly config: ConfigService,
) {
this.stripe = new Stripe(config.get<string>('STRIPE_SECRET_KEY'), {
apiVersion: '2023-10-16',
});
}
// ==========================================================================
// Customer Management
// ==========================================================================
async getOrCreateCustomer(userId: string, email: string): Promise<StripeCustomer> {
// Check existing
let customer = await this.customerRepo.findOne({ where: { userId } });
if (customer) {
return this.transformCustomer(customer);
}
// Create in Stripe
const stripeCustomer = await this.stripe.customers.create({
email,
metadata: { userId },
});
// Save to DB
customer = this.customerRepo.create({
userId,
stripeCustomerId: stripeCustomer.id,
email,
});
await this.customerRepo.save(customer);
this.logger.log(`Customer created: ${userId} -> ${stripeCustomer.id}`);
return this.transformCustomer(customer);
}
async getCustomerByUserId(userId: string): Promise<StripeCustomer | null> {
const customer = await this.customerRepo.findOne({ where: { userId } });
return customer ? this.transformCustomer(customer) : null;
}
private transformCustomer(entity: StripeCustomerEntity): StripeCustomer {
return {
id: entity.id,
userId: entity.userId,
stripeCustomerId: entity.stripeCustomerId,
email: entity.email,
defaultPaymentMethodId: entity.defaultPaymentMethodId,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
};
}
// ==========================================================================
// Checkout Sessions
// ==========================================================================
async createCheckoutSession(input: CreateCheckoutInput): Promise<CheckoutSession> {
// Get or create customer
const userEmail = await this.getUserEmail(input.userId);
const customer = await this.getOrCreateCustomer(input.userId, userEmail);
// Get plan
const plan = await this.planRepo.findOne({ where: { id: input.planId } });
if (!plan) {
throw new Error('Plan not found');
}
// Get price ID
const priceId = input.billingCycle === 'yearly'
? plan.stripePriceIdYearly
: plan.stripePriceIdMonthly;
if (!priceId) {
throw new Error('Stripe price not configured for this plan');
}
// Create session
const sessionConfig: Stripe.Checkout.SessionCreateParams = {
customer: customer.stripeCustomerId,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
success_url: input.successUrl,
cancel_url: input.cancelUrl,
metadata: {
userId: input.userId,
planId: input.planId,
billingCycle: input.billingCycle || 'monthly',
},
subscription_data: {
metadata: {
userId: input.userId,
planId: input.planId,
},
},
};
// Apply promo code
if (input.promoCode) {
const couponId = await this.getCouponIdByCode(input.promoCode);
if (couponId) {
sessionConfig.discounts = [{ coupon: couponId }];
}
}
const session = await this.stripe.checkout.sessions.create(sessionConfig);
this.logger.log(`Checkout session created: ${session.id}`);
return {
sessionId: session.id,
url: session.url!,
expiresAt: new Date(session.expires_at * 1000),
};
}
private async getCouponIdByCode(code: string): Promise<string | null> {
try {
const promotionCodes = await this.stripe.promotionCodes.list({
code,
limit: 1,
});
if (promotionCodes.data.length > 0 && promotionCodes.data[0].active) {
return promotionCodes.data[0].coupon.id;
}
return null;
} catch {
return null;
}
}
private async getUserEmail(userId: string): Promise<string> {
// Implementar según tu sistema de usuarios
// Por ejemplo: return this.userService.findById(userId).then(u => u.email);
throw new Error('Implement getUserEmail method');
}
// ==========================================================================
// Billing Portal
// ==========================================================================
async createBillingPortalSession(
userId: string,
returnUrl: string,
): Promise<BillingPortalSession> {
const customer = await this.getCustomerByUserId(userId);
if (!customer) {
throw new Error('Customer not found');
}
const session = await this.stripe.billingPortal.sessions.create({
customer: customer.stripeCustomerId,
return_url: returnUrl,
});
return {
url: session.url,
returnUrl,
};
}
// ==========================================================================
// Payment Methods
// ==========================================================================
async listPaymentMethods(userId: string): Promise<Stripe.PaymentMethod[]> {
const customer = await this.getCustomerByUserId(userId);
if (!customer) return [];
const paymentMethods = await this.stripe.paymentMethods.list({
customer: customer.stripeCustomerId,
type: 'card',
});
return paymentMethods.data;
}
async setDefaultPaymentMethod(userId: string, paymentMethodId: string): Promise<void> {
const customer = await this.getCustomerByUserId(userId);
if (!customer) throw new Error('Customer not found');
await this.stripe.customers.update(customer.stripeCustomerId, {
invoice_settings: { default_payment_method: paymentMethodId },
});
await this.customerRepo.update(
{ userId },
{ defaultPaymentMethodId: paymentMethodId },
);
}
// ==========================================================================
// Subscriptions
// ==========================================================================
async cancelSubscription(
stripeSubscriptionId: string,
immediately: boolean = false,
): Promise<Stripe.Subscription> {
if (immediately) {
return this.stripe.subscriptions.cancel(stripeSubscriptionId);
}
return this.stripe.subscriptions.update(stripeSubscriptionId, {
cancel_at_period_end: true,
});
}
async resumeSubscription(stripeSubscriptionId: string): Promise<Stripe.Subscription> {
return this.stripe.subscriptions.update(stripeSubscriptionId, {
cancel_at_period_end: false,
});
}
async updateSubscriptionPlan(
stripeSubscriptionId: string,
newPriceId: string,
): Promise<Stripe.Subscription> {
const subscription = await this.stripe.subscriptions.retrieve(stripeSubscriptionId);
return this.stripe.subscriptions.update(stripeSubscriptionId, {
items: [{
id: subscription.items.data[0].id,
price: newPriceId,
}],
proration_behavior: 'create_prorations',
});
}
// ==========================================================================
// Webhooks
// ==========================================================================
constructWebhookEvent(payload: Buffer, signature: string): Stripe.Event {
const webhookSecret = this.config.get<string>('STRIPE_WEBHOOK_SECRET');
return this.stripe.webhooks.constructEvent(payload, signature, webhookSecret);
}
// ==========================================================================
// Invoices
// ==========================================================================
async listInvoices(userId: string, limit: number = 10): Promise<Stripe.Invoice[]> {
const customer = await this.getCustomerByUserId(userId);
if (!customer) return [];
const invoices = await this.stripe.invoices.list({
customer: customer.stripeCustomerId,
limit,
});
return invoices.data;
}
// ==========================================================================
// Refunds
// ==========================================================================
async createRefund(
chargeId: string,
amount?: number,
reason?: string,
): Promise<Stripe.Refund> {
return this.stripe.refunds.create({
charge: chargeId,
amount: amount ? Math.round(amount * 100) : undefined,
reason: reason as Stripe.RefundCreateParams.Reason,
});
}
}
Paso 7: Crear Servicio de Suscripciones
// src/modules/payments/services/subscription.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { SubscriptionEntity } from '../entities/subscription.entity';
import { SubscriptionPlanEntity } from '../entities/subscription-plan.entity';
import { StripeService } from './stripe.service';
import {
Subscription,
SubscriptionWithPlan,
SubscriptionPlan,
SubscriptionStatus,
BillingCycle,
} from '../types/payments.types';
@Injectable()
export class SubscriptionService {
private readonly logger = new Logger(SubscriptionService.name);
constructor(
@InjectRepository(SubscriptionEntity)
private readonly subscriptionRepo: Repository<SubscriptionEntity>,
@InjectRepository(SubscriptionPlanEntity)
private readonly planRepo: Repository<SubscriptionPlanEntity>,
private readonly stripeService: StripeService,
) {}
async getSubscriptionByUserId(userId: string): Promise<SubscriptionWithPlan | null> {
const subscription = await this.subscriptionRepo.findOne({
where: {
userId,
status: In(['active', 'trialing', 'past_due']),
},
relations: ['plan'],
order: { createdAt: 'DESC' },
});
if (!subscription) return null;
return this.transformSubscription(subscription);
}
async hasActiveSubscription(userId: string): Promise<boolean> {
const count = await this.subscriptionRepo.count({
where: {
userId,
status: In(['active', 'trialing']),
},
});
return count > 0;
}
async getPlans(): Promise<SubscriptionPlan[]> {
const plans = await this.planRepo.find({
where: { isActive: true },
order: { sortOrder: 'ASC' },
});
return plans.map(this.transformPlan);
}
async cancelSubscription(
userId: string,
immediately: boolean = false,
reason?: string,
): Promise<Subscription> {
const subscription = await this.getSubscriptionByUserId(userId);
if (!subscription) {
throw new Error('No active subscription found');
}
// Cancel in Stripe
if (subscription.stripeSubscriptionId) {
await this.stripeService.cancelSubscription(
subscription.stripeSubscriptionId,
immediately,
);
}
// Update local
const updateData: Partial<SubscriptionEntity> = {
cancellationReason: reason,
};
if (immediately) {
updateData.status = 'cancelled';
updateData.cancelledAt = new Date();
} else {
updateData.cancelAtPeriodEnd = true;
}
await this.subscriptionRepo.update({ id: subscription.id }, updateData);
this.logger.log(`Subscription cancelled: ${subscription.id}`);
return this.getSubscriptionByUserId(userId);
}
async resumeSubscription(userId: string): Promise<Subscription> {
const subscription = await this.getSubscriptionByUserId(userId);
if (!subscription || !subscription.cancelAtPeriodEnd) {
throw new Error('No subscription to resume');
}
// Resume in Stripe
if (subscription.stripeSubscriptionId) {
await this.stripeService.resumeSubscription(subscription.stripeSubscriptionId);
}
// Update local
await this.subscriptionRepo.update(
{ id: subscription.id },
{ cancelAtPeriodEnd: false, cancellationReason: null },
);
this.logger.log(`Subscription resumed: ${subscription.id}`);
return this.getSubscriptionByUserId(userId);
}
async changePlan(
userId: string,
newPlanId: string,
billingCycle?: BillingCycle,
): Promise<Subscription> {
const subscription = await this.getSubscriptionByUserId(userId);
if (!subscription) {
throw new Error('No active subscription found');
}
const newPlan = await this.planRepo.findOne({ where: { id: newPlanId } });
if (!newPlan) {
throw new Error('Plan not found');
}
const cycle = billingCycle || subscription.billingCycle;
const priceId = cycle === 'yearly'
? newPlan.stripePriceIdYearly
: newPlan.stripePriceIdMonthly;
// Update in Stripe
if (subscription.stripeSubscriptionId && priceId) {
await this.stripeService.updateSubscriptionPlan(
subscription.stripeSubscriptionId,
priceId,
);
}
// Update local
const newPrice = cycle === 'yearly'
? (newPlan.priceYearly || newPlan.priceMonthly * 12)
: newPlan.priceMonthly;
await this.subscriptionRepo.update(
{ id: subscription.id },
{ planId: newPlanId, billingCycle: cycle, currentPrice: newPrice },
);
this.logger.log(`Subscription plan changed: ${subscription.id} -> ${newPlanId}`);
return this.getSubscriptionByUserId(userId);
}
// Called from webhook handler
async createFromStripeEvent(
userId: string,
planId: string,
stripeSubscriptionId: string,
stripeCustomerId: string,
billingCycle: BillingCycle,
periodStart: Date,
periodEnd: Date,
): Promise<Subscription> {
const plan = await this.planRepo.findOne({ where: { id: planId } });
const price = billingCycle === 'yearly'
? (plan.priceYearly || plan.priceMonthly * 12)
: plan.priceMonthly;
const subscription = this.subscriptionRepo.create({
userId,
planId,
stripeSubscriptionId,
stripeCustomerId,
status: 'active',
billingCycle,
currentPeriodStart: periodStart,
currentPeriodEnd: periodEnd,
currentPrice: price,
currency: plan.currency,
});
await this.subscriptionRepo.save(subscription);
this.logger.log(`Subscription created from Stripe: ${subscription.id}`);
return this.transformSubscription(subscription);
}
async updateFromStripeEvent(
stripeSubscriptionId: string,
data: {
status?: SubscriptionStatus;
currentPeriodStart?: Date;
currentPeriodEnd?: Date;
cancelAtPeriodEnd?: boolean;
},
): Promise<void> {
await this.subscriptionRepo.update(
{ stripeSubscriptionId },
data,
);
}
private transformSubscription(entity: SubscriptionEntity): SubscriptionWithPlan {
return {
id: entity.id,
userId: entity.userId,
planId: entity.planId,
stripeSubscriptionId: entity.stripeSubscriptionId,
stripeCustomerId: entity.stripeCustomerId,
status: entity.status,
billingCycle: entity.billingCycle,
currentPeriodStart: entity.currentPeriodStart,
currentPeriodEnd: entity.currentPeriodEnd,
cancelAtPeriodEnd: entity.cancelAtPeriodEnd,
cancelledAt: entity.cancelledAt,
currentPrice: entity.currentPrice,
currency: entity.currency,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
plan: entity.plan ? this.transformPlan(entity.plan) : null,
};
}
private transformPlan(entity: SubscriptionPlanEntity): SubscriptionPlan {
return {
id: entity.id,
name: entity.name,
slug: entity.slug,
description: entity.description,
priceMonthly: entity.priceMonthly,
priceYearly: entity.priceYearly,
currency: entity.currency,
stripePriceIdMonthly: entity.stripePriceIdMonthly,
stripePriceIdYearly: entity.stripePriceIdYearly,
stripeProductId: entity.stripeProductId,
features: entity.features,
isActive: entity.isActive,
sortOrder: entity.sortOrder,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
};
}
}
Paso 8: Crear Controller
// src/modules/payments/controllers/payments.controller.ts
import {
Controller,
Get,
Post,
Body,
Query,
UseGuards,
Request,
RawBodyRequest,
Headers,
BadRequestException,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
import { StripeService } from '../services/stripe.service';
import { SubscriptionService } from '../services/subscription.service';
import { CreateCheckoutDto, CancelSubscriptionDto } from '../dto';
import Stripe from 'stripe';
@Controller('payments')
export class PaymentsController {
constructor(
private readonly stripeService: StripeService,
private readonly subscriptionService: SubscriptionService,
) {}
// ==========================================================================
// Plans
// ==========================================================================
@Get('plans')
async getPlans() {
return this.subscriptionService.getPlans();
}
// ==========================================================================
// Subscription
// ==========================================================================
@Get('subscription')
@UseGuards(JwtAuthGuard)
async getSubscription(@Request() req) {
return this.subscriptionService.getSubscriptionByUserId(req.user.id);
}
@Post('checkout')
@UseGuards(JwtAuthGuard)
async createCheckout(@Request() req, @Body() dto: CreateCheckoutDto) {
return this.stripeService.createCheckoutSession({
userId: req.user.id,
planId: dto.planId,
billingCycle: dto.billingCycle,
successUrl: dto.successUrl,
cancelUrl: dto.cancelUrl,
promoCode: dto.promoCode,
});
}
@Get('billing-portal')
@UseGuards(JwtAuthGuard)
async getBillingPortal(@Request() req, @Query('returnUrl') returnUrl: string) {
return this.stripeService.createBillingPortalSession(req.user.id, returnUrl);
}
@Post('subscription/cancel')
@UseGuards(JwtAuthGuard)
async cancelSubscription(
@Request() req,
@Body() dto: CancelSubscriptionDto,
) {
return this.subscriptionService.cancelSubscription(
req.user.id,
dto.immediately,
dto.reason,
);
}
@Post('subscription/resume')
@UseGuards(JwtAuthGuard)
async resumeSubscription(@Request() req) {
return this.subscriptionService.resumeSubscription(req.user.id);
}
@Post('subscription/change-plan')
@UseGuards(JwtAuthGuard)
async changePlan(
@Request() req,
@Body() dto: { planId: string; billingCycle?: 'monthly' | 'yearly' },
) {
return this.subscriptionService.changePlan(
req.user.id,
dto.planId,
dto.billingCycle,
);
}
// ==========================================================================
// Payment Methods
// ==========================================================================
@Get('payment-methods')
@UseGuards(JwtAuthGuard)
async listPaymentMethods(@Request() req) {
return this.stripeService.listPaymentMethods(req.user.id);
}
// ==========================================================================
// Invoices
// ==========================================================================
@Get('invoices')
@UseGuards(JwtAuthGuard)
async listInvoices(@Request() req, @Query('limit') limit?: number) {
return this.stripeService.listInvoices(req.user.id, limit || 10);
}
}
// Webhook controller (separado para raw body)
@Controller('webhooks')
export class StripeWebhookController {
constructor(
private readonly stripeService: StripeService,
private readonly subscriptionService: SubscriptionService,
) {}
@Post('stripe')
async handleWebhook(
@Request() req: RawBodyRequest<Request>,
@Headers('stripe-signature') signature: string,
) {
if (!signature) {
throw new BadRequestException('Missing stripe-signature header');
}
let event: Stripe.Event;
try {
event = this.stripeService.constructWebhookEvent(req.rawBody, signature);
} catch (err) {
throw new BadRequestException(`Webhook signature verification failed`);
}
switch (event.type) {
case 'checkout.session.completed':
await this.handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
break;
case 'customer.subscription.updated':
await this.handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
await this.handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break;
case 'invoice.payment_failed':
await this.handlePaymentFailed(event.data.object as Stripe.Invoice);
break;
}
return { received: true };
}
private async handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const { userId, planId, billingCycle } = session.metadata!;
if (session.mode === 'subscription' && session.subscription) {
const stripeSubscription = await this.stripeService.getSubscription(
session.subscription as string,
);
await this.subscriptionService.createFromStripeEvent(
userId,
planId,
stripeSubscription.id,
session.customer as string,
billingCycle as 'monthly' | 'yearly',
new Date(stripeSubscription.current_period_start * 1000),
new Date(stripeSubscription.current_period_end * 1000),
);
}
}
private async handleSubscriptionUpdated(subscription: Stripe.Subscription) {
await this.subscriptionService.updateFromStripeEvent(subscription.id, {
status: this.mapStripeStatus(subscription.status),
currentPeriodStart: new Date(subscription.current_period_start * 1000),
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
});
}
private async handleSubscriptionDeleted(subscription: Stripe.Subscription) {
await this.subscriptionService.updateFromStripeEvent(subscription.id, {
status: 'cancelled',
});
}
private async handlePaymentFailed(invoice: Stripe.Invoice) {
if (invoice.subscription) {
await this.subscriptionService.updateFromStripeEvent(
invoice.subscription as string,
{ status: 'past_due' },
);
}
}
private mapStripeStatus(status: Stripe.Subscription.Status): SubscriptionStatus {
const statusMap: Record<string, SubscriptionStatus> = {
active: 'active',
trialing: 'trialing',
past_due: 'past_due',
canceled: 'cancelled',
unpaid: 'unpaid',
};
return statusMap[status] || 'active';
}
}
Paso 9: Migraciones SQL
-- migrations/001_create_payments_tables.sql
CREATE SCHEMA IF NOT EXISTS financial;
-- Stripe Customers
CREATE TABLE financial.stripe_customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL UNIQUE REFERENCES public.users(id),
stripe_customer_id VARCHAR(100) NOT NULL UNIQUE,
email VARCHAR(255),
default_payment_method_id VARCHAR(100),
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Subscription Plans
CREATE TABLE financial.subscription_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
slug VARCHAR(50) NOT NULL UNIQUE,
description TEXT,
price_monthly DECIMAL(10, 2) NOT NULL,
price_yearly DECIMAL(10, 2),
currency VARCHAR(3) DEFAULT 'usd',
stripe_price_id_monthly VARCHAR(100),
stripe_price_id_yearly VARCHAR(100),
stripe_product_id VARCHAR(100),
features JSONB DEFAULT '[]',
is_active BOOLEAN DEFAULT true,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Subscriptions
CREATE TABLE financial.subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.users(id),
plan_id UUID NOT NULL REFERENCES financial.subscription_plans(id),
stripe_subscription_id VARCHAR(100) UNIQUE,
stripe_customer_id VARCHAR(100),
status VARCHAR(20) NOT NULL DEFAULT 'active',
billing_cycle VARCHAR(20) NOT NULL DEFAULT 'monthly',
current_period_start TIMESTAMP WITH TIME ZONE,
current_period_end TIMESTAMP WITH TIME ZONE,
cancel_at_period_end BOOLEAN DEFAULT false,
cancelled_at TIMESTAMP WITH TIME ZONE,
cancellation_reason TEXT,
current_price DECIMAL(10, 2),
currency VARCHAR(3) DEFAULT 'usd',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Indexes
CREATE INDEX idx_subscriptions_user ON financial.subscriptions(user_id);
CREATE INDEX idx_subscriptions_status ON financial.subscriptions(status);
CREATE INDEX idx_subscriptions_stripe ON financial.subscriptions(stripe_subscription_id);
-- Trigger for updated_at
CREATE TRIGGER update_subscriptions_timestamp
BEFORE UPDATE ON financial.subscriptions
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
Paso 10: Configurar Raw Body para Webhooks
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule, {
rawBody: true, // Enable raw body for webhooks
});
await app.listen(3000);
}
bootstrap();
Variables de Entorno
# Stripe
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_PUBLISHABLE_KEY=pk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
# URLs
FRONTEND_URL=http://localhost:3000
Checklist de Implementación
- Cuenta Stripe configurada
- Productos y precios creados en Stripe
- Webhook endpoint configurado
- Dependencia
stripeinstalada - Variables de entorno configuradas
- Entidades creadas (StripeCustomer, SubscriptionPlan, Subscription)
- StripeService implementado
- SubscriptionService implementado
- Controllers implementados
- Webhook handler implementado
- Raw body habilitado para webhooks
- Migraciones ejecutadas
- Build pasa sin errores
- Test: crear checkout session
- Test: webhook recibido correctamente
Verificar Funcionamiento
# Usar Stripe CLI para testing local
stripe listen --forward-to localhost:3000/webhooks/stripe
# Trigger test events
stripe trigger checkout.session.completed
stripe trigger invoice.paid
Troubleshooting
"No signatures found matching the expected signature"
- Verificar STRIPE_WEBHOOK_SECRET
- Verificar que raw body está habilitado
Webhook no llega
- Verificar URL en Stripe dashboard
- Usar ngrok para desarrollo local
Subscription not created
- Verificar metadata en checkout session
- Revisar logs del webhook handler
Versión: 1.0.0 Sistema: SIMCO Catálogo