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
1115 lines
31 KiB
Markdown
1115 lines
31 KiB
Markdown
# 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
|
|
|
|
1. Ir a [dashboard.stripe.com](https://dashboard.stripe.com)
|
|
2. En **Developers > API keys**, obtener:
|
|
- Publishable key: `pk_test_xxx` o `pk_live_xxx`
|
|
- Secret key: `sk_test_xxx` o `sk_live_xxx`
|
|
|
|
### 1.2 Crear productos y precios
|
|
|
|
1. Ir a **Products > Add product**
|
|
2. 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
|
|
|
|
1. Ir a **Developers > Webhooks**
|
|
2. Add endpoint: `https://api.example.com/webhooks/stripe`
|
|
3. Seleccionar eventos:
|
|
- `checkout.session.completed`
|
|
- `invoice.paid`
|
|
- `invoice.payment_failed`
|
|
- `customer.subscription.updated`
|
|
- `customer.subscription.deleted`
|
|
4. Copiar **Signing secret**: `whsec_xxx`
|
|
|
|
---
|
|
|
|
## Paso 2: Instalar Dependencias
|
|
|
|
```bash
|
|
npm install stripe
|
|
```
|
|
|
|
---
|
|
|
|
## Paso 3: Variables de Entorno
|
|
|
|
```env
|
|
# .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
|
|
|
|
```bash
|
|
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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```env
|
|
# 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 `stripe` instalada
|
|
- [ ] 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
|
|
|
|
```bash
|
|
# 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
|