workspace-v1/shared/catalog/template-saas/IMPLEMENTATION.md
rckrdmrd cb4c0681d3 feat(workspace): Add new projects and update architecture
New projects created:
- michangarrito (marketplace mobile)
- template-saas (SaaS template)
- clinica-dental (dental ERP)
- clinica-veterinaria (veterinary ERP)

Architecture updates:
- Move catalog from core/ to shared/
- Add MCP servers structure and templates
- Add git management scripts
- Update SUBREPOSITORIOS.md with 15 new repos
- Update .gitignore for new projects

Repository infrastructure:
- 4 main repositories
- 11 subrepositorios
- Gitea remotes configured

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 04:43:28 -06:00

13 KiB

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

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

# 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

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

// 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<string, boolean>;

  @Column({ type: 'jsonb', default: {} })
  limits: Record<string, number>;

  @Column({ default: true })
  isActive: boolean;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

3.2 Servicio de Suscripciones

// 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<Subscription>,
    @InjectRepository(Plan)
    private planRepo: Repository<Plan>,
  ) {
    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<string> {
    // Implementar logica de customer
    // ...
  }
}

3.3 Guard de Plan

// 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<boolean> {
    const requiredFeature = this.reflector.get<string>('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

// 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

// 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<void>;
  fetchPlans: () => Promise<void>;
  subscribe: (planCode: string, billingCycle: string) => Promise<void>;
  cancel: () => Promise<void>;
}

export const useSubscriptionStore = create<SubscriptionState>((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

// frontend/src/components/billing/PricingTable.tsx

import { useSubscriptionStore } from '../../stores/subscription.store';

export function PricingTable() {
  const { plans, subscription, subscribe, loading } = useSubscriptionStore();

  return (
    <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
      {plans.map((plan) => (
        <div
          key={plan.id}
          className={`border rounded-lg p-6 ${
            subscription?.plan.code === plan.code ? 'border-blue-500' : ''
          }`}
        >
          <h3 className="text-xl font-bold">{plan.name}</h3>
          <p className="text-gray-600 mt-2">{plan.description}</p>

          <div className="mt-4">
            <span className="text-3xl font-bold">${plan.priceMonthly}</span>
            <span className="text-gray-500">/mes</span>
          </div>

          <ul className="mt-6 space-y-2">
            {Object.entries(plan.features).map(([feature, enabled]) => (
              <li key={feature} className="flex items-center">
                {enabled ? '✓' : '✗'} {feature}
              </li>
            ))}
          </ul>

          <button
            onClick={() => subscribe(plan.code, 'monthly')}
            disabled={loading || subscription?.plan.code === plan.code}
            className="mt-6 w-full py-2 bg-blue-600 text-white rounded"
          >
            {subscription?.plan.code === plan.code ? 'Plan Actual' : 'Seleccionar'}
          </button>
        </div>
      ))}
    </div>
  );
}

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

// 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

// backend/src/modules/billing/billing.webhook.controller.ts

@Controller('webhooks/stripe')
export class StripeWebhookController {
  @Post()
  async handleWebhook(@Req() req: RawBodyRequest<Request>) {
    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