# Implementacion de Template SaaS **Version:** 1.0.0 **Tiempo estimado:** 2-3 sprints base **Prerequisitos:** NestJS, PostgreSQL, Stripe account --- ## Paso 1: Estructura Base del Proyecto ### 1.1 Crear estructura de directorios ```bash mkdir -p backend/src/modules/{billing,tenants,plans,usage,onboarding} mkdir -p backend/src/shared/{guards,decorators,interceptors} mkdir -p frontend/src/pages/{auth,onboarding,dashboard,billing,settings} mkdir -p database/ddl ``` ### 1.2 Instalar dependencias ```bash # Backend npm install stripe @nestjs/config class-validator class-transformer npm install @nestjs/typeorm typeorm pg # Frontend npm install @stripe/stripe-js zustand react-hook-form zod ``` --- ## Paso 2: Schema de Base de Datos ### 2.1 Schema de Billing ```sql -- database/ddl/001-billing-schema.sql CREATE SCHEMA IF NOT EXISTS billing; -- Planes disponibles CREATE TABLE billing.plans ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(100) NOT NULL, code VARCHAR(50) UNIQUE NOT NULL, description TEXT, price_monthly DECIMAL(10,2) NOT NULL, price_yearly DECIMAL(10,2), stripe_price_id_monthly VARCHAR(255), stripe_price_id_yearly VARCHAR(255), features JSONB DEFAULT '{}', limits JSONB DEFAULT '{}', is_active BOOLEAN DEFAULT true, sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); -- Suscripciones de tenants CREATE TABLE billing.subscriptions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id), plan_id UUID NOT NULL REFERENCES billing.plans(id), stripe_subscription_id VARCHAR(255), stripe_customer_id VARCHAR(255), status VARCHAR(50) NOT NULL DEFAULT 'trialing', billing_cycle VARCHAR(20) DEFAULT 'monthly', current_period_start TIMESTAMPTZ, current_period_end TIMESTAMPTZ, trial_ends_at TIMESTAMPTZ, canceled_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), CONSTRAINT valid_status CHECK (status IN ('trialing', 'active', 'past_due', 'canceled', 'paused')) ); -- Historial de pagos CREATE TABLE billing.payments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), subscription_id UUID REFERENCES billing.subscriptions(id), tenant_id UUID NOT NULL REFERENCES auth.tenants(id), stripe_payment_intent_id VARCHAR(255), amount DECIMAL(10,2) NOT NULL, currency VARCHAR(3) DEFAULT 'MXN', status VARCHAR(50) NOT NULL, description TEXT, metadata JSONB DEFAULT '{}', created_at TIMESTAMPTZ DEFAULT NOW() ); -- Uso por tenant (para billing basado en uso) CREATE TABLE billing.usage_records ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id UUID NOT NULL REFERENCES auth.tenants(id), metric_name VARCHAR(100) NOT NULL, quantity INTEGER NOT NULL DEFAULT 1, recorded_at TIMESTAMPTZ DEFAULT NOW(), period_start TIMESTAMPTZ NOT NULL, period_end TIMESTAMPTZ NOT NULL ); CREATE INDEX idx_subscriptions_tenant ON billing.subscriptions(tenant_id); CREATE INDEX idx_subscriptions_status ON billing.subscriptions(status); CREATE INDEX idx_payments_tenant ON billing.payments(tenant_id); CREATE INDEX idx_usage_tenant_metric ON billing.usage_records(tenant_id, metric_name); ``` --- ## Paso 3: Backend - Modulo de Billing ### 3.1 Entidad de Plan ```typescript // backend/src/modules/plans/entities/plan.entity.ts import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; @Entity({ schema: 'billing', name: 'plans' }) export class Plan { @PrimaryGeneratedColumn('uuid') id: string; @Column() name: string; @Column({ unique: true }) code: string; @Column({ type: 'text', nullable: true }) description: string; @Column({ type: 'decimal', precision: 10, scale: 2 }) priceMonthly: number; @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) priceYearly: number; @Column({ nullable: true }) stripePriceIdMonthly: string; @Column({ nullable: true }) stripePriceIdYearly: string; @Column({ type: 'jsonb', default: {} }) features: Record; @Column({ type: 'jsonb', default: {} }) limits: Record; @Column({ default: true }) isActive: boolean; @CreateDateColumn() createdAt: Date; @UpdateDateColumn() updatedAt: Date; } ``` ### 3.2 Servicio de Suscripciones ```typescript // backend/src/modules/billing/services/subscription.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import Stripe from 'stripe'; import { Subscription } from '../entities/subscription.entity'; import { Plan } from '../../plans/entities/plan.entity'; @Injectable() export class SubscriptionService { private stripe: Stripe; constructor( @InjectRepository(Subscription) private subscriptionRepo: Repository, @InjectRepository(Plan) private planRepo: Repository, ) { this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY); } async createSubscription(tenantId: string, planCode: string, billingCycle: 'monthly' | 'yearly') { const plan = await this.planRepo.findOneBy({ code: planCode }); if (!plan) throw new Error('Plan not found'); const priceId = billingCycle === 'yearly' ? plan.stripePriceIdYearly : plan.stripePriceIdMonthly; // Crear suscripcion en Stripe const stripeSubscription = await this.stripe.subscriptions.create({ customer: await this.getOrCreateStripeCustomer(tenantId), items: [{ price: priceId }], trial_period_days: 14, }); // Guardar en BD const subscription = this.subscriptionRepo.create({ tenantId, planId: plan.id, stripeSubscriptionId: stripeSubscription.id, status: 'trialing', billingCycle, trialEndsAt: new Date(stripeSubscription.trial_end * 1000), }); return this.subscriptionRepo.save(subscription); } async getCurrentSubscription(tenantId: string) { return this.subscriptionRepo.findOne({ where: { tenantId }, relations: ['plan'], order: { createdAt: 'DESC' }, }); } async cancelSubscription(tenantId: string) { const subscription = await this.getCurrentSubscription(tenantId); if (!subscription) throw new Error('No subscription found'); await this.stripe.subscriptions.cancel(subscription.stripeSubscriptionId); subscription.status = 'canceled'; subscription.canceledAt = new Date(); return this.subscriptionRepo.save(subscription); } private async getOrCreateStripeCustomer(tenantId: string): Promise { // Implementar logica de customer // ... } } ``` ### 3.3 Guard de Plan ```typescript // backend/src/shared/guards/plan.guard.ts import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { SubscriptionService } from '../../modules/billing/services/subscription.service'; @Injectable() export class PlanGuard implements CanActivate { constructor( private reflector: Reflector, private subscriptionService: SubscriptionService, ) {} async canActivate(context: ExecutionContext): Promise { const requiredFeature = this.reflector.get('feature', context.getHandler()); if (!requiredFeature) return true; const request = context.switchToHttp().getRequest(); const tenantId = request.user?.tenantId; if (!tenantId) throw new ForbiddenException('Tenant not found'); const subscription = await this.subscriptionService.getCurrentSubscription(tenantId); if (!subscription || subscription.status !== 'active') { throw new ForbiddenException('Active subscription required'); } const hasFeature = subscription.plan.features[requiredFeature]; if (!hasFeature) { throw new ForbiddenException(`Feature "${requiredFeature}" not available in your plan`); } return true; } } ``` ### 3.4 Decorador RequiresPlan ```typescript // backend/src/shared/decorators/requires-plan.decorator.ts import { SetMetadata } from '@nestjs/common'; export const RequiresPlan = (feature: string) => SetMetadata('feature', feature); ``` --- ## Paso 4: Frontend - Componentes de Billing ### 4.1 Store de Suscripcion ```typescript // frontend/src/stores/subscription.store.ts import { create } from 'zustand'; import { api } from '../services/api'; interface SubscriptionState { subscription: Subscription | null; plans: Plan[]; loading: boolean; fetchSubscription: () => Promise; fetchPlans: () => Promise; subscribe: (planCode: string, billingCycle: string) => Promise; cancel: () => Promise; } export const useSubscriptionStore = create((set, get) => ({ subscription: null, plans: [], loading: false, fetchSubscription: async () => { set({ loading: true }); try { const subscription = await api.get('/billing/subscription'); set({ subscription: subscription.data }); } finally { set({ loading: false }); } }, fetchPlans: async () => { const plans = await api.get('/billing/plans'); set({ plans: plans.data }); }, subscribe: async (planCode, billingCycle) => { set({ loading: true }); try { const result = await api.post('/billing/subscribe', { planCode, billingCycle }); set({ subscription: result.data }); } finally { set({ loading: false }); } }, cancel: async () => { set({ loading: true }); try { await api.post('/billing/cancel'); await get().fetchSubscription(); } finally { set({ loading: false }); } }, })); ``` ### 4.2 Componente de Pricing ```tsx // frontend/src/components/billing/PricingTable.tsx import { useSubscriptionStore } from '../../stores/subscription.store'; export function PricingTable() { const { plans, subscription, subscribe, loading } = useSubscriptionStore(); return (
{plans.map((plan) => (

{plan.name}

{plan.description}

${plan.priceMonthly} /mes
    {Object.entries(plan.features).map(([feature, enabled]) => (
  • {enabled ? '✓' : '✗'} {feature}
  • ))}
))}
); } ``` --- ## Paso 5: Flujo de Onboarding ### 5.1 Pasos del Wizard 1. **Registro** - Email, password, nombre de empresa 2. **Seleccion de Plan** - Elegir plan (con trial) 3. **Configuracion Inicial** - Datos de empresa, logo 4. **Invitar Equipo** - Invitar primeros usuarios 5. **Checkout** - Pago (si no trial) ### 5.2 Endpoint de Onboarding ```typescript // backend/src/modules/onboarding/onboarding.controller.ts @Controller('onboarding') export class OnboardingController { @Post('start') async startOnboarding(@Body() dto: StartOnboardingDto) { // 1. Crear usuario // 2. Crear tenant // 3. Crear suscripcion trial // 4. Enviar email de bienvenida } @Post('complete') @UseGuards(AuthGuard) async completeOnboarding(@CurrentUser() user, @Body() dto: CompleteOnboardingDto) { // 1. Actualizar datos de empresa // 2. Configurar preferencias // 3. Marcar onboarding como completo } } ``` --- ## Paso 6: Webhooks de Stripe ### 6.1 Controller de Webhooks ```typescript // backend/src/modules/billing/billing.webhook.controller.ts @Controller('webhooks/stripe') export class StripeWebhookController { @Post() async handleWebhook(@Req() req: RawBodyRequest) { const sig = req.headers['stripe-signature']; const event = this.stripe.webhooks.constructEvent( req.rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET ); switch (event.type) { case 'customer.subscription.updated': await this.handleSubscriptionUpdated(event.data.object); break; case 'customer.subscription.deleted': await this.handleSubscriptionCanceled(event.data.object); break; case 'invoice.paid': await this.handlePaymentSucceeded(event.data.object); break; case 'invoice.payment_failed': await this.handlePaymentFailed(event.data.object); break; } return { received: true }; } } ``` --- ## Checklist de Implementacion - [ ] Schema de BD creado - [ ] Entidades TypeORM creadas - [ ] Servicio de planes implementado - [ ] Servicio de suscripciones implementado - [ ] Guard de plan funcionando - [ ] Webhooks de Stripe configurados - [ ] Frontend de pricing - [ ] Flujo de onboarding - [ ] Tests unitarios - [ ] Tests e2e --- *Catalogo de Funcionalidades - SIMCO v3.4*