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>
13 KiB
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
- Registro - Email, password, nombre de empresa
- Seleccion de Plan - Elegir plan (con trial)
- Configuracion Inicial - Datos de empresa, logo
- Invitar Equipo - Invitar primeros usuarios
- 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