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>
502 lines
13 KiB
Markdown
502 lines
13 KiB
Markdown
# 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<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
|
|
|
|
```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<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
|
|
|
|
```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<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
|
|
|
|
```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<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
|
|
|
|
```tsx
|
|
// 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
|
|
|
|
```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<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*
|