erp-core/docs/01-fase-foundation/MGN-004-tenants/especificaciones/ET-tenants-backend.md

58 KiB

Especificacion Tecnica Backend - MGN-004 Tenants

Identificacion

Campo Valor
Modulo MGN-004
Nombre Multi-Tenancy Backend
Version 1.0
Fecha 2025-12-05

Estructura del Modulo

src/
├── modules/
│   └── tenants/
│       ├── tenants.module.ts
│       ├── controllers/
│       │   ├── tenants.controller.ts
│       │   ├── tenant-settings.controller.ts
│       │   ├── plans.controller.ts
│       │   ├── subscriptions.controller.ts
│       │   └── platform-tenants.controller.ts
│       ├── services/
│       │   ├── tenants.service.ts
│       │   ├── tenant-settings.service.ts
│       │   ├── plans.service.ts
│       │   ├── subscriptions.service.ts
│       │   ├── tenant-usage.service.ts
│       │   └── billing.service.ts
│       ├── entities/
│       │   ├── tenant.entity.ts
│       │   ├── tenant-settings.entity.ts
│       │   ├── plan.entity.ts
│       │   ├── subscription.entity.ts
│       │   ├── module.entity.ts
│       │   ├── plan-module.entity.ts
│       │   ├── tenant-module.entity.ts
│       │   └── invoice.entity.ts
│       ├── dto/
│       │   ├── create-tenant.dto.ts
│       │   ├── update-tenant.dto.ts
│       │   ├── tenant-settings.dto.ts
│       │   ├── create-plan.dto.ts
│       │   ├── update-plan.dto.ts
│       │   ├── create-subscription.dto.ts
│       │   ├── upgrade-subscription.dto.ts
│       │   └── cancel-subscription.dto.ts
│       ├── guards/
│       │   ├── tenant.guard.ts
│       │   ├── tenant-status.guard.ts
│       │   └── limit.guard.ts
│       ├── middleware/
│       │   └── tenant-context.middleware.ts
│       ├── decorators/
│       │   ├── tenant.decorator.ts
│       │   ├── check-limit.decorator.ts
│       │   └── check-module.decorator.ts
│       ├── interceptors/
│       │   └── tenant-context.interceptor.ts
│       └── interfaces/
│           ├── tenant-settings.interface.ts
│           └── subscription-limits.interface.ts

Entidades

Tenant Entity

// entities/tenant.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  UpdateDateColumn,
  OneToMany,
  OneToOne,
  Index,
} from 'typeorm';
import { TenantSettings } from './tenant-settings.entity';
import { Subscription } from './subscription.entity';
import { TenantModule } from './tenant-module.entity';

export enum TenantStatus {
  CREATED = 'created',
  TRIAL = 'trial',
  TRIAL_EXPIRED = 'trial_expired',
  ACTIVE = 'active',
  SUSPENDED = 'suspended',
  PENDING_DELETION = 'pending_deletion',
  DELETED = 'deleted',
}

@Entity({ schema: 'core_tenants', name: 'tenants' })
export class Tenant {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ length: 100 })
  @Index()
  name: string;

  @Column({ length: 50, unique: true })
  @Index()
  slug: string;

  @Column({ length: 50, unique: true, nullable: true })
  subdomain: string;

  @Column({ length: 100, nullable: true })
  custom_domain: string;

  @Column({
    type: 'enum',
    enum: TenantStatus,
    default: TenantStatus.CREATED,
  })
  @Index()
  status: TenantStatus;

  @Column({ type: 'timestamptz', nullable: true })
  trial_ends_at: Date;

  @Column({ type: 'timestamptz', nullable: true })
  suspended_at: Date;

  @Column({ type: 'text', nullable: true })
  suspension_reason: string;

  @Column({ type: 'timestamptz', nullable: true })
  deletion_scheduled_at: Date;

  @Column({ type: 'timestamptz', nullable: true })
  deleted_at: Date;

  @Column({ type: 'uuid', nullable: true })
  deleted_by: string;

  @CreateDateColumn({ type: 'timestamptz' })
  created_at: Date;

  @Column({ type: 'uuid', nullable: true })
  created_by: string;

  @UpdateDateColumn({ type: 'timestamptz' })
  updated_at: Date;

  @Column({ type: 'uuid', nullable: true })
  updated_by: string;

  // Relations
  @OneToOne(() => TenantSettings, (settings) => settings.tenant)
  settings: TenantSettings;

  @OneToMany(() => Subscription, (subscription) => subscription.tenant)
  subscriptions: Subscription[];

  @OneToMany(() => TenantModule, (tenantModule) => tenantModule.tenant)
  enabledModules: TenantModule[];
}

TenantSettings Entity

// entities/tenant-settings.entity.ts
import {
  Entity,
  PrimaryColumn,
  Column,
  OneToOne,
  JoinColumn,
  UpdateDateColumn,
} from 'typeorm';
import { Tenant } from './tenant.entity';

@Entity({ schema: 'core_tenants', name: 'tenant_settings' })
export class TenantSettings {
  @PrimaryColumn('uuid')
  tenant_id: string;

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

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

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

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

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

  @UpdateDateColumn({ type: 'timestamptz' })
  updated_at: Date;

  @Column({ type: 'uuid', nullable: true })
  updated_by: string;

  // Relations
  @OneToOne(() => Tenant, (tenant) => tenant.settings)
  @JoinColumn({ name: 'tenant_id' })
  tenant: Tenant;
}

Plan Entity

// entities/plan.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  UpdateDateColumn,
  OneToMany,
  Index,
} from 'typeorm';
import { PlanModule } from './plan-module.entity';
import { Subscription } from './subscription.entity';

export enum BillingInterval {
  MONTHLY = 'monthly',
  YEARLY = 'yearly',
}

@Entity({ schema: 'core_tenants', name: 'plans' })
export class Plan {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ length: 50, unique: true })
  @Index()
  code: string;

  @Column({ length: 100 })
  name: string;

  @Column({ type: 'text', nullable: true })
  description: string;

  @Column({ type: 'decimal', precision: 10, scale: 2 })
  price: number;

  @Column({ length: 3, default: 'USD' })
  currency: string;

  @Column({
    type: 'enum',
    enum: BillingInterval,
    default: BillingInterval.MONTHLY,
  })
  billing_interval: BillingInterval;

  @Column({ type: 'int', default: 5 })
  max_users: number;

  @Column({ type: 'bigint', default: 1073741824 }) // 1GB
  max_storage_bytes: bigint;

  @Column({ type: 'int', default: 1000 })
  max_api_calls_per_month: number;

  @Column({ type: 'int', nullable: true })
  trial_days: number;

  @Column({ type: 'jsonb', default: [] })
  features: string[];

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

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

  @Column({ type: 'int', default: 0 })
  sort_order: number;

  @CreateDateColumn({ type: 'timestamptz' })
  created_at: Date;

  @UpdateDateColumn({ type: 'timestamptz' })
  updated_at: Date;

  // Relations
  @OneToMany(() => PlanModule, (planModule) => planModule.plan)
  includedModules: PlanModule[];

  @OneToMany(() => Subscription, (subscription) => subscription.plan)
  subscriptions: Subscription[];
}

Subscription Entity

// entities/subscription.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  UpdateDateColumn,
  ManyToOne,
  JoinColumn,
  Index,
} from 'typeorm';
import { Tenant } from './tenant.entity';
import { Plan } from './plan.entity';

export enum SubscriptionStatus {
  TRIAL = 'trial',
  ACTIVE = 'active',
  PAST_DUE = 'past_due',
  CANCELED = 'canceled',
  UNPAID = 'unpaid',
}

@Entity({ schema: 'core_tenants', name: 'subscriptions' })
export class Subscription {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column('uuid')
  @Index()
  tenant_id: string;

  @Column('uuid')
  plan_id: string;

  @Column({
    type: 'enum',
    enum: SubscriptionStatus,
    default: SubscriptionStatus.TRIAL,
  })
  @Index()
  status: SubscriptionStatus;

  @Column({ type: 'timestamptz' })
  current_period_start: Date;

  @Column({ type: 'timestamptz' })
  current_period_end: Date;

  @Column({ type: 'timestamptz', nullable: true })
  trial_end: Date;

  @Column({ default: false })
  cancel_at_period_end: boolean;

  @Column({ type: 'timestamptz', nullable: true })
  canceled_at: Date;

  @Column({ type: 'text', nullable: true })
  cancellation_reason: string;

  @Column({ length: 100, nullable: true })
  external_subscription_id: string; // Stripe subscription ID

  @Column({ length: 100, nullable: true })
  payment_method_id: string;

  @CreateDateColumn({ type: 'timestamptz' })
  created_at: Date;

  @UpdateDateColumn({ type: 'timestamptz' })
  updated_at: Date;

  // Relations
  @ManyToOne(() => Tenant, (tenant) => tenant.subscriptions)
  @JoinColumn({ name: 'tenant_id' })
  tenant: Tenant;

  @ManyToOne(() => Plan, (plan) => plan.subscriptions)
  @JoinColumn({ name: 'plan_id' })
  plan: Plan;
}

Module Entity

// entities/module.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  OneToMany,
  Index,
} from 'typeorm';
import { PlanModule } from './plan-module.entity';
import { TenantModule } from './tenant-module.entity';

@Entity({ schema: 'core_tenants', name: 'modules' })
export class Module {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ length: 50, unique: true })
  @Index()
  code: string;

  @Column({ length: 100 })
  name: string;

  @Column({ type: 'text', nullable: true })
  description: string;

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

  @Column({ default: false })
  is_core: boolean; // Core modules always enabled

  // Relations
  @OneToMany(() => PlanModule, (planModule) => planModule.module)
  planModules: PlanModule[];

  @OneToMany(() => TenantModule, (tenantModule) => tenantModule.module)
  tenantModules: TenantModule[];
}

PlanModule Entity

// entities/plan-module.entity.ts
import {
  Entity,
  PrimaryColumn,
  ManyToOne,
  JoinColumn,
} from 'typeorm';
import { Plan } from './plan.entity';
import { Module } from './module.entity';

@Entity({ schema: 'core_tenants', name: 'plan_modules' })
export class PlanModule {
  @PrimaryColumn('uuid')
  plan_id: string;

  @PrimaryColumn('uuid')
  module_id: string;

  // Relations
  @ManyToOne(() => Plan, (plan) => plan.includedModules)
  @JoinColumn({ name: 'plan_id' })
  plan: Plan;

  @ManyToOne(() => Module, (module) => module.planModules)
  @JoinColumn({ name: 'module_id' })
  module: Module;
}

TenantModule Entity

// entities/tenant-module.entity.ts
import {
  Entity,
  PrimaryColumn,
  Column,
  CreateDateColumn,
  ManyToOne,
  JoinColumn,
} from 'typeorm';
import { Tenant } from './tenant.entity';
import { Module } from './module.entity';

@Entity({ schema: 'core_tenants', name: 'tenant_modules' })
export class TenantModule {
  @PrimaryColumn('uuid')
  tenant_id: string;

  @PrimaryColumn('uuid')
  module_id: string;

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

  @CreateDateColumn({ type: 'timestamptz' })
  enabled_at: Date;

  @Column({ type: 'uuid', nullable: true })
  enabled_by: string;

  // Relations
  @ManyToOne(() => Tenant, (tenant) => tenant.enabledModules)
  @JoinColumn({ name: 'tenant_id' })
  tenant: Tenant;

  @ManyToOne(() => Module, (module) => module.tenantModules)
  @JoinColumn({ name: 'module_id' })
  module: Module;
}

Invoice Entity

// entities/invoice.entity.ts
import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  ManyToOne,
  JoinColumn,
  Index,
} from 'typeorm';
import { Tenant } from './tenant.entity';
import { Subscription } from './subscription.entity';

export enum InvoiceStatus {
  DRAFT = 'draft',
  OPEN = 'open',
  PAID = 'paid',
  VOID = 'void',
  UNCOLLECTIBLE = 'uncollectible',
}

@Entity({ schema: 'core_tenants', name: 'invoices' })
export class Invoice {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column('uuid')
  @Index()
  tenant_id: string;

  @Column('uuid')
  subscription_id: string;

  @Column({ length: 20, unique: true })
  invoice_number: string;

  @Column({
    type: 'enum',
    enum: InvoiceStatus,
    default: InvoiceStatus.DRAFT,
  })
  status: InvoiceStatus;

  @Column({ type: 'decimal', precision: 10, scale: 2 })
  subtotal: number;

  @Column({ type: 'decimal', precision: 10, scale: 2, default: 0 })
  tax: number;

  @Column({ type: 'decimal', precision: 10, scale: 2 })
  total: number;

  @Column({ length: 3, default: 'USD' })
  currency: string;

  @Column({ type: 'timestamptz' })
  period_start: Date;

  @Column({ type: 'timestamptz' })
  period_end: Date;

  @Column({ type: 'timestamptz', nullable: true })
  due_date: Date;

  @Column({ type: 'timestamptz', nullable: true })
  paid_at: Date;

  @Column({ length: 100, nullable: true })
  external_invoice_id: string; // Stripe invoice ID

  @Column({ type: 'text', nullable: true })
  pdf_url: string;

  @Column({ type: 'jsonb', default: [] })
  line_items: Array<{
    description: string;
    quantity: number;
    unit_price: number;
    amount: number;
  }>;

  @CreateDateColumn({ type: 'timestamptz' })
  created_at: Date;

  // Relations
  @ManyToOne(() => Tenant)
  @JoinColumn({ name: 'tenant_id' })
  tenant: Tenant;

  @ManyToOne(() => Subscription)
  @JoinColumn({ name: 'subscription_id' })
  subscription: Subscription;
}

DTOs

CreateTenantDto

// dto/create-tenant.dto.ts
import { IsString, IsOptional, MaxLength, Matches, IsEnum } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { TenantStatus } from '../entities/tenant.entity';

export class CreateTenantDto {
  @ApiProperty({ description: 'Nombre del tenant', maxLength: 100 })
  @IsString()
  @MaxLength(100)
  name: string;

  @ApiProperty({ description: 'Slug unico (URL-friendly)', maxLength: 50 })
  @IsString()
  @MaxLength(50)
  @Matches(/^[a-z0-9-]+$/, {
    message: 'Slug solo puede contener letras minusculas, numeros y guiones',
  })
  slug: string;

  @ApiPropertyOptional({ description: 'Subdominio', maxLength: 50 })
  @IsOptional()
  @IsString()
  @MaxLength(50)
  @Matches(/^[a-z0-9-]+$/, {
    message: 'Subdominio solo puede contener letras minusculas, numeros y guiones',
  })
  subdomain?: string;

  @ApiPropertyOptional({ description: 'Dominio personalizado' })
  @IsOptional()
  @IsString()
  @MaxLength(100)
  custom_domain?: string;

  @ApiPropertyOptional({ description: 'Estado inicial', enum: TenantStatus })
  @IsOptional()
  @IsEnum(TenantStatus)
  status?: TenantStatus;

  @ApiPropertyOptional({ description: 'ID del plan inicial' })
  @IsOptional()
  @IsString()
  plan_id?: string;
}

UpdateTenantDto

// dto/update-tenant.dto.ts
import { IsString, IsOptional, MaxLength, Matches, IsEnum } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { TenantStatus } from '../entities/tenant.entity';

export class UpdateTenantDto {
  @ApiPropertyOptional({ description: 'Nombre del tenant' })
  @IsOptional()
  @IsString()
  @MaxLength(100)
  name?: string;

  @ApiPropertyOptional({ description: 'Subdominio' })
  @IsOptional()
  @IsString()
  @MaxLength(50)
  @Matches(/^[a-z0-9-]+$/)
  subdomain?: string;

  @ApiPropertyOptional({ description: 'Dominio personalizado' })
  @IsOptional()
  @IsString()
  @MaxLength(100)
  custom_domain?: string;
}

export class UpdateTenantStatusDto {
  @ApiProperty({ enum: TenantStatus })
  @IsEnum(TenantStatus)
  status: TenantStatus;

  @ApiPropertyOptional({ description: 'Razon del cambio de estado' })
  @IsOptional()
  @IsString()
  @MaxLength(500)
  reason?: string;
}

TenantSettingsDto

// dto/tenant-settings.dto.ts
import { IsObject, IsOptional, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';

export class CompanySettingsDto {
  @IsOptional()
  @IsString()
  @MaxLength(200)
  companyName?: string;

  @IsOptional()
  @IsString()
  @MaxLength(100)
  tradeName?: string;

  @IsOptional()
  @IsString()
  @MaxLength(50)
  taxId?: string;

  @IsOptional()
  @IsString()
  @MaxLength(500)
  address?: string;

  @IsOptional()
  @IsString()
  @MaxLength(100)
  city?: string;

  @IsOptional()
  @IsString()
  @MaxLength(100)
  state?: string;

  @IsOptional()
  @IsString()
  @MaxLength(2)
  country?: string;

  @IsOptional()
  @IsString()
  @MaxLength(20)
  postalCode?: string;

  @IsOptional()
  @IsString()
  @MaxLength(20)
  phone?: string;

  @IsOptional()
  @IsEmail()
  email?: string;

  @IsOptional()
  @IsUrl()
  website?: string;
}

export class BrandingSettingsDto {
  @IsOptional()
  @IsString()
  logo?: string;

  @IsOptional()
  @IsString()
  logoSmall?: string;

  @IsOptional()
  @IsString()
  favicon?: string;

  @IsOptional()
  @Matches(/^#[0-9A-Fa-f]{6}$/)
  primaryColor?: string;

  @IsOptional()
  @Matches(/^#[0-9A-Fa-f]{6}$/)
  secondaryColor?: string;

  @IsOptional()
  @Matches(/^#[0-9A-Fa-f]{6}$/)
  accentColor?: string;
}

export class RegionalSettingsDto {
  @IsOptional()
  @IsString()
  @MaxLength(5)
  defaultLanguage?: string;

  @IsOptional()
  @IsString()
  defaultTimezone?: string;

  @IsOptional()
  @IsString()
  @MaxLength(3)
  defaultCurrency?: string;

  @IsOptional()
  @IsString()
  dateFormat?: string;

  @IsOptional()
  @IsIn(['12h', '24h'])
  timeFormat?: string;

  @IsOptional()
  @IsString()
  numberFormat?: string;

  @IsOptional()
  @IsInt()
  @Min(0)
  @Max(6)
  firstDayOfWeek?: number;
}

export class SecuritySettingsDto {
  @IsOptional()
  @IsInt()
  @Min(6)
  @Max(128)
  passwordMinLength?: number;

  @IsOptional()
  @IsBoolean()
  passwordRequireSpecial?: boolean;

  @IsOptional()
  @IsInt()
  @Min(5)
  @Max(1440)
  sessionTimeout?: number;

  @IsOptional()
  @IsInt()
  @Min(3)
  @Max(10)
  maxLoginAttempts?: number;

  @IsOptional()
  @IsInt()
  @Min(1)
  @Max(1440)
  lockoutDuration?: number;

  @IsOptional()
  @IsBoolean()
  mfaRequired?: boolean;

  @IsOptional()
  @IsArray()
  @IsString({ each: true })
  ipWhitelist?: string[];
}

export class UpdateTenantSettingsDto {
  @ApiPropertyOptional()
  @IsOptional()
  @ValidateNested()
  @Type(() => CompanySettingsDto)
  company?: CompanySettingsDto;

  @ApiPropertyOptional()
  @IsOptional()
  @ValidateNested()
  @Type(() => BrandingSettingsDto)
  branding?: BrandingSettingsDto;

  @ApiPropertyOptional()
  @IsOptional()
  @ValidateNested()
  @Type(() => RegionalSettingsDto)
  regional?: RegionalSettingsDto;

  @ApiPropertyOptional()
  @IsOptional()
  @IsObject()
  operational?: Record<string, any>;

  @ApiPropertyOptional()
  @IsOptional()
  @ValidateNested()
  @Type(() => SecuritySettingsDto)
  security?: SecuritySettingsDto;
}

Subscription DTOs

// dto/create-subscription.dto.ts
import { IsUUID, IsOptional, IsString } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class CreateSubscriptionDto {
  @ApiProperty({ description: 'ID del plan' })
  @IsUUID()
  plan_id: string;

  @ApiPropertyOptional({ description: 'ID del metodo de pago' })
  @IsOptional()
  @IsString()
  payment_method_id?: string;
}

// dto/upgrade-subscription.dto.ts
export class UpgradeSubscriptionDto {
  @ApiProperty({ description: 'ID del nuevo plan' })
  @IsUUID()
  plan_id: string;

  @ApiPropertyOptional({ description: 'Aplicar inmediatamente' })
  @IsOptional()
  @IsBoolean()
  apply_immediately?: boolean = true;
}

// dto/cancel-subscription.dto.ts
export class CancelSubscriptionDto {
  @ApiPropertyOptional({ description: 'Razon de cancelacion' })
  @IsOptional()
  @IsString()
  @MaxLength(500)
  reason?: string;

  @ApiPropertyOptional({ description: 'Feedback adicional' })
  @IsOptional()
  @IsString()
  @MaxLength(2000)
  feedback?: string;

  @ApiPropertyOptional({ description: 'Cancelar al fin del periodo' })
  @IsOptional()
  @IsBoolean()
  cancel_at_period_end?: boolean = true;
}

Plan DTOs

// dto/create-plan.dto.ts
import {
  IsString,
  IsNumber,
  IsOptional,
  IsBoolean,
  IsArray,
  IsEnum,
  IsInt,
  Min,
  MaxLength,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { BillingInterval } from '../entities/plan.entity';

export class CreatePlanDto {
  @ApiProperty({ description: 'Codigo unico del plan' })
  @IsString()
  @MaxLength(50)
  code: string;

  @ApiProperty({ description: 'Nombre del plan' })
  @IsString()
  @MaxLength(100)
  name: string;

  @ApiPropertyOptional({ description: 'Descripcion' })
  @IsOptional()
  @IsString()
  description?: string;

  @ApiProperty({ description: 'Precio' })
  @IsNumber({ maxDecimalPlaces: 2 })
  @Min(0)
  price: number;

  @ApiPropertyOptional({ description: 'Moneda', default: 'USD' })
  @IsOptional()
  @IsString()
  @MaxLength(3)
  currency?: string;

  @ApiPropertyOptional({ enum: BillingInterval })
  @IsOptional()
  @IsEnum(BillingInterval)
  billing_interval?: BillingInterval;

  @ApiProperty({ description: 'Maximo de usuarios' })
  @IsInt()
  @Min(1)
  max_users: number;

  @ApiProperty({ description: 'Maximo storage en bytes' })
  @IsInt()
  @Min(0)
  max_storage_bytes: number;

  @ApiProperty({ description: 'Maximo API calls por mes' })
  @IsInt()
  @Min(0)
  max_api_calls_per_month: number;

  @ApiPropertyOptional({ description: 'Dias de prueba' })
  @IsOptional()
  @IsInt()
  @Min(0)
  trial_days?: number;

  @ApiPropertyOptional({ description: 'Features incluidas' })
  @IsOptional()
  @IsArray()
  @IsString({ each: true })
  features?: string[];

  @ApiPropertyOptional({ description: 'IDs de modulos incluidos' })
  @IsOptional()
  @IsArray()
  @IsUUID('4', { each: true })
  module_ids?: string[];

  @ApiPropertyOptional({ description: 'Plan publico' })
  @IsOptional()
  @IsBoolean()
  is_public?: boolean;

  @ApiPropertyOptional({ description: 'Orden de visualizacion' })
  @IsOptional()
  @IsInt()
  sort_order?: number;
}

// dto/update-plan.dto.ts
export class UpdatePlanDto extends PartialType(
  OmitType(CreatePlanDto, ['code'] as const),
) {}

API Endpoints

Tenants (Platform Admin)

Metodo Endpoint Descripcion Permisos
GET /api/v1/platform/tenants Listar todos los tenants platform:tenants:read
GET /api/v1/platform/tenants/:id Obtener tenant por ID platform:tenants:read
POST /api/v1/platform/tenants Crear nuevo tenant platform:tenants:create
PATCH /api/v1/platform/tenants/:id Actualizar tenant platform:tenants:update
PATCH /api/v1/platform/tenants/:id/status Cambiar estado platform:tenants:update
DELETE /api/v1/platform/tenants/:id Programar eliminacion platform:tenants:delete
POST /api/v1/platform/tenants/:id/restore Restaurar tenant platform:tenants:update
POST /api/v1/platform/switch-tenant/:id Cambiar contexto a tenant platform:tenants:switch

Tenant Self-Service

Metodo Endpoint Descripcion Permisos
GET /api/v1/tenant Obtener mi tenant tenants:read
PATCH /api/v1/tenant Actualizar mi tenant tenants:update

Tenant Settings

Metodo Endpoint Descripcion Permisos
GET /api/v1/tenant/settings Obtener configuracion settings:read
PATCH /api/v1/tenant/settings Actualizar configuracion settings:update
POST /api/v1/tenant/settings/logo Subir logo settings:update
POST /api/v1/tenant/settings/reset Resetear a defaults settings:update

Plans

Metodo Endpoint Descripcion Permisos
GET /api/v1/subscription/plans Listar planes publicos Public
GET /api/v1/subscription/plans/:id Obtener plan Public
POST /api/v1/platform/plans Crear plan platform:plans:create
PATCH /api/v1/platform/plans/:id Actualizar plan platform:plans:update
DELETE /api/v1/platform/plans/:id Desactivar plan platform:plans:delete

Subscriptions

Metodo Endpoint Descripcion Permisos
GET /api/v1/tenant/subscription Ver mi subscripcion subscriptions:read
POST /api/v1/tenant/subscription Crear subscripcion subscriptions:create
POST /api/v1/tenant/subscription/upgrade Upgrade de plan subscriptions:update
POST /api/v1/tenant/subscription/cancel Cancelar subscripcion subscriptions:update
GET /api/v1/tenant/subscription/check-limit Verificar limite subscriptions:read
GET /api/v1/tenant/subscription/usage Ver uso actual subscriptions:read
GET /api/v1/tenant/invoices Listar facturas invoices:read
GET /api/v1/tenant/invoices/:id Obtener factura invoices:read

Services

TenantsService

// services/tenants.service.ts
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource } from 'typeorm';
import { Tenant, TenantStatus } from '../entities/tenant.entity';
import { TenantSettings } from '../entities/tenant-settings.entity';
import { CreateTenantDto, UpdateTenantDto, UpdateTenantStatusDto } from '../dto';

@Injectable()
export class TenantsService {
  constructor(
    @InjectRepository(Tenant)
    private tenantRepository: Repository<Tenant>,
    @InjectRepository(TenantSettings)
    private settingsRepository: Repository<TenantSettings>,
    private dataSource: DataSource,
  ) {}

  async create(dto: CreateTenantDto, createdBy: string): Promise<Tenant> {
    // Verificar slug unico
    const existing = await this.tenantRepository.findOne({
      where: { slug: dto.slug },
    });
    if (existing) {
      throw new ConflictException(`Slug "${dto.slug}" ya existe`);
    }

    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      // Crear tenant
      const tenant = this.tenantRepository.create({
        ...dto,
        status: dto.status || TenantStatus.CREATED,
        created_by: createdBy,
      });
      await queryRunner.manager.save(tenant);

      // Crear settings vacios
      const settings = this.settingsRepository.create({
        tenant_id: tenant.id,
      });
      await queryRunner.manager.save(settings);

      await queryRunner.commitTransaction();

      return this.findOne(tenant.id);
    } catch (error) {
      await queryRunner.rollbackTransaction();
      throw error;
    } finally {
      await queryRunner.release();
    }
  }

  async findAll(filters?: {
    status?: TenantStatus;
    search?: string;
    page?: number;
    limit?: number;
  }): Promise<{ data: Tenant[]; total: number }> {
    const query = this.tenantRepository
      .createQueryBuilder('tenant')
      .leftJoinAndSelect('tenant.settings', 'settings')
      .where('tenant.deleted_at IS NULL');

    if (filters?.status) {
      query.andWhere('tenant.status = :status', { status: filters.status });
    }

    if (filters?.search) {
      query.andWhere(
        '(tenant.name ILIKE :search OR tenant.slug ILIKE :search)',
        { search: `%${filters.search}%` },
      );
    }

    const page = filters?.page || 1;
    const limit = filters?.limit || 20;

    const [data, total] = await query
      .orderBy('tenant.created_at', 'DESC')
      .skip((page - 1) * limit)
      .take(limit)
      .getManyAndCount();

    return { data, total };
  }

  async findOne(id: string): Promise<Tenant> {
    const tenant = await this.tenantRepository.findOne({
      where: { id, deleted_at: IsNull() },
      relations: ['settings'],
    });

    if (!tenant) {
      throw new NotFoundException(`Tenant ${id} no encontrado`);
    }

    return tenant;
  }

  async findBySlug(slug: string): Promise<Tenant> {
    const tenant = await this.tenantRepository.findOne({
      where: { slug, deleted_at: IsNull() },
      relations: ['settings'],
    });

    if (!tenant) {
      throw new NotFoundException(`Tenant con slug "${slug}" no encontrado`);
    }

    return tenant;
  }

  async update(id: string, dto: UpdateTenantDto, updatedBy: string): Promise<Tenant> {
    const tenant = await this.findOne(id);

    Object.assign(tenant, dto, { updated_by: updatedBy });

    await this.tenantRepository.save(tenant);
    return this.findOne(id);
  }

  async updateStatus(
    id: string,
    dto: UpdateTenantStatusDto,
    updatedBy: string,
  ): Promise<Tenant> {
    const tenant = await this.findOne(id);

    tenant.status = dto.status;
    tenant.updated_by = updatedBy;

    // Manejar transiciones especiales
    switch (dto.status) {
      case TenantStatus.SUSPENDED:
        tenant.suspended_at = new Date();
        tenant.suspension_reason = dto.reason;
        break;
      case TenantStatus.PENDING_DELETION:
        tenant.deletion_scheduled_at = new Date();
        tenant.deletion_scheduled_at.setDate(
          tenant.deletion_scheduled_at.getDate() + 30,
        );
        break;
      case TenantStatus.ACTIVE:
        // Limpiar campos de suspension
        tenant.suspended_at = null;
        tenant.suspension_reason = null;
        tenant.deletion_scheduled_at = null;
        break;
    }

    await this.tenantRepository.save(tenant);
    return this.findOne(id);
  }

  async softDelete(id: string, deletedBy: string): Promise<void> {
    const tenant = await this.findOne(id);

    tenant.status = TenantStatus.PENDING_DELETION;
    tenant.deletion_scheduled_at = new Date();
    tenant.deletion_scheduled_at.setDate(
      tenant.deletion_scheduled_at.getDate() + 30,
    );
    tenant.updated_by = deletedBy;

    await this.tenantRepository.save(tenant);
  }

  async restore(id: string, restoredBy: string): Promise<Tenant> {
    const tenant = await this.tenantRepository.findOne({
      where: { id },
    });

    if (!tenant) {
      throw new NotFoundException(`Tenant ${id} no encontrado`);
    }

    if (tenant.status !== TenantStatus.PENDING_DELETION) {
      throw new ConflictException('Solo se pueden restaurar tenants pendientes de eliminacion');
    }

    tenant.status = TenantStatus.ACTIVE;
    tenant.deletion_scheduled_at = null;
    tenant.deleted_at = null;
    tenant.deleted_by = null;
    tenant.updated_by = restoredBy;

    await this.tenantRepository.save(tenant);
    return this.findOne(id);
  }
}

TenantSettingsService

// services/tenant-settings.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
import { TenantSettings } from '../entities/tenant-settings.entity';
import { UpdateTenantSettingsDto } from '../dto/tenant-settings.dto';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class TenantSettingsService {
  private readonly defaultSettings: Partial<TenantSettings>;

  constructor(
    @InjectRepository(TenantSettings)
    private settingsRepository: Repository<TenantSettings>,
    @Inject(REQUEST)
    private request: Request,
    private configService: ConfigService,
  ) {
    // Cargar defaults de configuracion
    this.defaultSettings = {
      company: {},
      branding: {
        primaryColor: '#3B82F6',
        secondaryColor: '#10B981',
        accentColor: '#F59E0B',
      },
      regional: {
        defaultLanguage: 'es',
        defaultTimezone: 'America/Mexico_City',
        defaultCurrency: 'MXN',
        dateFormat: 'DD/MM/YYYY',
        timeFormat: '24h',
        numberFormat: 'es-MX',
        firstDayOfWeek: 1,
      },
      operational: {
        fiscalYearStart: '01-01',
        workingDays: [1, 2, 3, 4, 5],
        businessHoursStart: '09:00',
        businessHoursEnd: '18:00',
        defaultTaxRate: 16,
      },
      security: {
        passwordMinLength: 8,
        passwordRequireSpecial: true,
        sessionTimeout: 30,
        maxLoginAttempts: 5,
        lockoutDuration: 15,
        mfaRequired: false,
        ipWhitelist: [],
      },
    };
  }

  private get tenantId(): string {
    return this.request['tenantId'];
  }

  async getSettings(): Promise<TenantSettings & { _defaults: any }> {
    const settings = await this.settingsRepository.findOne({
      where: { tenant_id: this.tenantId },
    });

    // Merge con defaults
    const merged = {
      tenant_id: this.tenantId,
      company: { ...this.defaultSettings.company, ...settings?.company },
      branding: { ...this.defaultSettings.branding, ...settings?.branding },
      regional: { ...this.defaultSettings.regional, ...settings?.regional },
      operational: { ...this.defaultSettings.operational, ...settings?.operational },
      security: { ...this.defaultSettings.security, ...settings?.security },
      updated_at: settings?.updated_at,
      updated_by: settings?.updated_by,
      _defaults: this.defaultSettings,
    };

    return merged as any;
  }

  async updateSettings(
    dto: UpdateTenantSettingsDto,
    updatedBy: string,
  ): Promise<TenantSettings> {
    let settings = await this.settingsRepository.findOne({
      where: { tenant_id: this.tenantId },
    });

    if (!settings) {
      settings = this.settingsRepository.create({
        tenant_id: this.tenantId,
      });
    }

    // Merge parcial de cada seccion
    if (dto.company) {
      settings.company = { ...settings.company, ...dto.company };
    }
    if (dto.branding) {
      settings.branding = { ...settings.branding, ...dto.branding };
    }
    if (dto.regional) {
      settings.regional = { ...settings.regional, ...dto.regional };
    }
    if (dto.operational) {
      settings.operational = { ...settings.operational, ...dto.operational };
    }
    if (dto.security) {
      settings.security = { ...settings.security, ...dto.security };
    }

    settings.updated_by = updatedBy;

    await this.settingsRepository.save(settings);
    return this.getSettings();
  }

  async resetToDefaults(sections?: string[]): Promise<TenantSettings> {
    const settings = await this.settingsRepository.findOne({
      where: { tenant_id: this.tenantId },
    });

    if (!settings) {
      return this.getSettings();
    }

    const sectionsToReset = sections || ['company', 'branding', 'regional', 'operational', 'security'];

    for (const section of sectionsToReset) {
      if (section in settings) {
        settings[section] = {};
      }
    }

    await this.settingsRepository.save(settings);
    return this.getSettings();
  }
}

SubscriptionsService

// services/subscriptions.service.ts
import {
  Injectable,
  NotFoundException,
  BadRequestException,
  Inject,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
import { Subscription, SubscriptionStatus } from '../entities/subscription.entity';
import { Plan } from '../entities/plan.entity';
import { Tenant, TenantStatus } from '../entities/tenant.entity';
import {
  CreateSubscriptionDto,
  UpgradeSubscriptionDto,
  CancelSubscriptionDto,
} from '../dto';
import { BillingService } from './billing.service';
import { TenantUsageService } from './tenant-usage.service';

@Injectable()
export class SubscriptionsService {
  constructor(
    @InjectRepository(Subscription)
    private subscriptionRepository: Repository<Subscription>,
    @InjectRepository(Plan)
    private planRepository: Repository<Plan>,
    @InjectRepository(Tenant)
    private tenantRepository: Repository<Tenant>,
    @Inject(REQUEST)
    private request: Request,
    private billingService: BillingService,
    private usageService: TenantUsageService,
  ) {}

  private get tenantId(): string {
    return this.request['tenantId'];
  }

  async getCurrentSubscription(): Promise<{
    subscription: Subscription;
    plan: Plan;
    usage: any;
  }> {
    const subscription = await this.subscriptionRepository.findOne({
      where: { tenant_id: this.tenantId },
      relations: ['plan'],
      order: { created_at: 'DESC' },
    });

    if (!subscription) {
      throw new NotFoundException('No hay subscripcion activa');
    }

    const usage = await this.usageService.getCurrentUsage(this.tenantId);

    return {
      subscription,
      plan: subscription.plan,
      usage,
    };
  }

  async create(dto: CreateSubscriptionDto): Promise<Subscription> {
    const plan = await this.planRepository.findOne({
      where: { id: dto.plan_id, is_active: true },
    });

    if (!plan) {
      throw new NotFoundException('Plan no encontrado');
    }

    const now = new Date();
    const periodEnd = new Date(now);
    periodEnd.setMonth(periodEnd.getMonth() + 1);

    let status = SubscriptionStatus.ACTIVE;
    let trialEnd: Date | null = null;

    if (plan.trial_days > 0) {
      status = SubscriptionStatus.TRIAL;
      trialEnd = new Date(now);
      trialEnd.setDate(trialEnd.getDate() + plan.trial_days);
    }

    const subscription = this.subscriptionRepository.create({
      tenant_id: this.tenantId,
      plan_id: plan.id,
      status,
      current_period_start: now,
      current_period_end: periodEnd,
      trial_end: trialEnd,
      payment_method_id: dto.payment_method_id,
    });

    await this.subscriptionRepository.save(subscription);

    // Actualizar estado del tenant
    await this.tenantRepository.update(this.tenantId, {
      status: status === SubscriptionStatus.TRIAL
        ? TenantStatus.TRIAL
        : TenantStatus.ACTIVE,
      trial_ends_at: trialEnd,
    });

    return this.subscriptionRepository.findOne({
      where: { id: subscription.id },
      relations: ['plan'],
    });
  }

  async upgrade(dto: UpgradeSubscriptionDto): Promise<Subscription> {
    const { subscription: current } = await this.getCurrentSubscription();

    const newPlan = await this.planRepository.findOne({
      where: { id: dto.plan_id, is_active: true },
    });

    if (!newPlan) {
      throw new NotFoundException('Plan no encontrado');
    }

    // Calcular prorrateo
    const prorationAmount = await this.billingService.calculateProration(
      current,
      newPlan,
    );

    // Procesar pago si aplica
    if (prorationAmount > 0 && current.payment_method_id) {
      await this.billingService.chargeProration(
        this.tenantId,
        current.payment_method_id,
        prorationAmount,
      );
    }

    // Actualizar subscripcion
    current.plan_id = newPlan.id;
    current.status = SubscriptionStatus.ACTIVE;

    await this.subscriptionRepository.save(current);

    // Actualizar modulos habilitados
    await this.syncTenantModules(newPlan.id);

    return this.subscriptionRepository.findOne({
      where: { id: current.id },
      relations: ['plan'],
    });
  }

  async cancel(dto: CancelSubscriptionDto): Promise<Subscription> {
    const { subscription } = await this.getCurrentSubscription();

    subscription.cancel_at_period_end = dto.cancel_at_period_end ?? true;
    subscription.canceled_at = new Date();
    subscription.cancellation_reason = dto.reason;

    if (!dto.cancel_at_period_end) {
      subscription.status = SubscriptionStatus.CANCELED;
    }

    await this.subscriptionRepository.save(subscription);

    return subscription;
  }

  async checkLimit(type: string): Promise<{
    type: string;
    current: number;
    limit: number;
    canAdd: boolean;
    remaining: number;
    upgradeOptions?: Plan[];
  }> {
    const { subscription, plan, usage } = await this.getCurrentSubscription();

    let current: number;
    let limit: number;

    switch (type) {
      case 'users':
        current = usage.users;
        limit = plan.max_users;
        break;
      case 'storage':
        current = usage.storageBytes;
        limit = Number(plan.max_storage_bytes);
        break;
      case 'api_calls':
        current = usage.apiCallsThisMonth;
        limit = plan.max_api_calls_per_month;
        break;
      default:
        throw new BadRequestException(`Tipo de limite desconocido: ${type}`);
    }

    const canAdd = current < limit;
    const remaining = Math.max(0, limit - current);

    const result = {
      type,
      current,
      limit,
      canAdd,
      remaining,
    };

    if (!canAdd) {
      // Obtener planes con mayor limite
      const upgradeOptions = await this.planRepository
        .createQueryBuilder('plan')
        .where('plan.is_active = true')
        .andWhere('plan.is_public = true')
        .andWhere(
          type === 'users'
            ? 'plan.max_users > :limit'
            : type === 'storage'
            ? 'plan.max_storage_bytes > :limit'
            : 'plan.max_api_calls_per_month > :limit',
          { limit },
        )
        .orderBy('plan.price', 'ASC')
        .take(3)
        .getMany();

      return { ...result, upgradeOptions };
    }

    return result;
  }

  async checkModuleAccess(moduleCode: string): Promise<boolean> {
    const { plan } = await this.getCurrentSubscription();

    const planModule = await this.planRepository
      .createQueryBuilder('plan')
      .innerJoin('plan.includedModules', 'pm')
      .innerJoin('pm.module', 'module')
      .where('plan.id = :planId', { planId: plan.id })
      .andWhere('module.code = :moduleCode', { moduleCode })
      .getOne();

    return !!planModule;
  }

  private async syncTenantModules(planId: string): Promise<void> {
    // Sincronizar modulos habilitados segun el plan
    await this.subscriptionRepository.query(
      `
      INSERT INTO core_tenants.tenant_modules (tenant_id, module_id, is_enabled, enabled_at)
      SELECT $1, pm.module_id, true, NOW()
      FROM core_tenants.plan_modules pm
      WHERE pm.plan_id = $2
      ON CONFLICT (tenant_id, module_id) DO UPDATE SET is_enabled = true
      `,
      [this.tenantId, planId],
    );
  }
}

TenantUsageService

// services/tenant-usage.service.ts
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';

export interface TenantUsage {
  users: number;
  storageBytes: number;
  apiCallsThisMonth: number;
  apiCallsToday: number;
}

@Injectable()
export class TenantUsageService {
  constructor(private dataSource: DataSource) {}

  async getCurrentUsage(tenantId: string): Promise<TenantUsage> {
    // Contar usuarios activos
    const usersResult = await this.dataSource.query(
      `SELECT COUNT(*) as count FROM core_users.users
       WHERE tenant_id = $1 AND deleted_at IS NULL`,
      [tenantId],
    );

    // Obtener storage usado (de alguna tabla de tracking)
    const storageResult = await this.dataSource.query(
      `SELECT COALESCE(SUM(size_bytes), 0) as total
       FROM core_storage.files
       WHERE tenant_id = $1 AND deleted_at IS NULL`,
      [tenantId],
    );

    // Contar API calls del mes
    const startOfMonth = new Date();
    startOfMonth.setDate(1);
    startOfMonth.setHours(0, 0, 0, 0);

    const apiCallsResult = await this.dataSource.query(
      `SELECT COUNT(*) as count FROM core_audit.api_logs
       WHERE tenant_id = $1 AND created_at >= $2`,
      [tenantId, startOfMonth],
    );

    // Contar API calls de hoy
    const startOfDay = new Date();
    startOfDay.setHours(0, 0, 0, 0);

    const apiCallsTodayResult = await this.dataSource.query(
      `SELECT COUNT(*) as count FROM core_audit.api_logs
       WHERE tenant_id = $1 AND created_at >= $2`,
      [tenantId, startOfDay],
    );

    return {
      users: parseInt(usersResult[0].count, 10),
      storageBytes: parseInt(storageResult[0].total, 10),
      apiCallsThisMonth: parseInt(apiCallsResult[0].count, 10),
      apiCallsToday: parseInt(apiCallsTodayResult[0].count, 10),
    };
  }

  async getUsageHistory(
    tenantId: string,
    days: number = 30,
  ): Promise<Array<{ date: string; apiCalls: number; storage: number }>> {
    const result = await this.dataSource.query(
      `
      WITH dates AS (
        SELECT generate_series(
          CURRENT_DATE - $2::int,
          CURRENT_DATE,
          '1 day'::interval
        )::date as date
      )
      SELECT
        d.date,
        COALESCE(COUNT(a.id), 0) as api_calls,
        0 as storage -- Placeholder para storage historico
      FROM dates d
      LEFT JOIN core_audit.api_logs a
        ON DATE(a.created_at) = d.date AND a.tenant_id = $1
      GROUP BY d.date
      ORDER BY d.date
      `,
      [tenantId, days],
    );

    return result;
  }
}

Guards y Middleware

TenantGuard

// guards/tenant.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  UnauthorizedException,
  ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { TenantsService } from '../services/tenants.service';
import { TenantStatus } from '../entities/tenant.entity';
import { SKIP_TENANT_CHECK } from '../decorators/tenant.decorator';

@Injectable()
export class TenantGuard implements CanActivate {
  constructor(
    private tenantsService: TenantsService,
    private reflector: Reflector,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const skipCheck = this.reflector.getAllAndOverride<boolean>(
      SKIP_TENANT_CHECK,
      [context.getHandler(), context.getClass()],
    );

    if (skipCheck) {
      return true;
    }

    const request = context.switchToHttp().getRequest();
    const user = request.user;

    if (!user?.tenantId) {
      throw new UnauthorizedException('Tenant no identificado en token');
    }

    try {
      const tenant = await this.tenantsService.findOne(user.tenantId);

      // Verificar estado del tenant
      const activeStatuses = [
        TenantStatus.TRIAL,
        TenantStatus.ACTIVE,
      ];

      if (!activeStatuses.includes(tenant.status)) {
        throw new ForbiddenException(
          `Tenant ${tenant.status}: acceso denegado`,
        );
      }

      // Verificar trial expirado
      if (
        tenant.status === TenantStatus.TRIAL &&
        tenant.trial_ends_at &&
        new Date() > tenant.trial_ends_at
      ) {
        throw new ForbiddenException(
          'Periodo de prueba expirado. Por favor actualiza tu subscripcion.',
        );
      }

      // Inyectar tenant en request
      request.tenant = tenant;
      request.tenantId = tenant.id;

      return true;
    } catch (error) {
      if (
        error instanceof UnauthorizedException ||
        error instanceof ForbiddenException
      ) {
        throw error;
      }
      throw new UnauthorizedException('Tenant no valido');
    }
  }
}

TenantContextMiddleware

// middleware/tenant-context.middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { Request, Response, NextFunction } from 'express';

@Injectable()
export class TenantContextMiddleware implements NestMiddleware {
  constructor(private dataSource: DataSource) {}

  async use(req: Request, res: Response, next: NextFunction) {
    const tenantId = req['tenantId'];

    if (tenantId) {
      // Setear variable de sesion PostgreSQL para RLS
      // Usar parametrizacion para prevenir SQL injection
      await this.dataSource.query(
        `SELECT set_config('app.current_tenant_id', $1, true)`,
        [tenantId],
      );
    }

    next();
  }
}

LimitGuard

// guards/limit.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { SubscriptionsService } from '../services/subscriptions.service';
import { CHECK_LIMIT_KEY } from '../decorators/check-limit.decorator';

@Injectable()
export class LimitGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private subscriptionsService: SubscriptionsService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const limitType = this.reflector.get<string>(
      CHECK_LIMIT_KEY,
      context.getHandler(),
    );

    if (!limitType) {
      return true;
    }

    const check = await this.subscriptionsService.checkLimit(limitType);

    if (!check.canAdd) {
      throw new HttpException(
        {
          statusCode: HttpStatus.PAYMENT_REQUIRED,
          error: 'Payment Required',
          message: `Limite de ${limitType} alcanzado (${check.current}/${check.limit})`,
          upgradeOptions: check.upgradeOptions,
        },
        HttpStatus.PAYMENT_REQUIRED,
      );
    }

    return true;
  }
}

ModuleGuard

// guards/module.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { SubscriptionsService } from '../services/subscriptions.service';
import { CHECK_MODULE_KEY } from '../decorators/check-module.decorator';

@Injectable()
export class ModuleGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private subscriptionsService: SubscriptionsService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const moduleCode = this.reflector.get<string>(
      CHECK_MODULE_KEY,
      context.getHandler(),
    );

    if (!moduleCode) {
      return true;
    }

    const hasAccess = await this.subscriptionsService.checkModuleAccess(moduleCode);

    if (!hasAccess) {
      throw new HttpException(
        {
          statusCode: HttpStatus.PAYMENT_REQUIRED,
          error: 'Payment Required',
          message: `El modulo "${moduleCode}" no esta incluido en tu plan`,
        },
        HttpStatus.PAYMENT_REQUIRED,
      );
    }

    return true;
  }
}

Decorators

// decorators/tenant.decorator.ts
import { createParamDecorator, ExecutionContext, SetMetadata } from '@nestjs/common';

export const SKIP_TENANT_CHECK = 'skipTenantCheck';

export const SkipTenantCheck = () => SetMetadata(SKIP_TENANT_CHECK, true);

export const CurrentTenant = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    return request.tenant;
  },
);

export const TenantId = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    return request.tenantId;
  },
);

// decorators/check-limit.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const CHECK_LIMIT_KEY = 'checkLimit';
export const CheckLimit = (type: 'users' | 'storage' | 'api_calls') =>
  SetMetadata(CHECK_LIMIT_KEY, type);

// decorators/check-module.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const CHECK_MODULE_KEY = 'checkModule';
export const CheckModule = (moduleCode: string) =>
  SetMetadata(CHECK_MODULE_KEY, moduleCode);

Controllers

TenantsController (Platform Admin)

// controllers/platform-tenants.controller.ts
import {
  Controller,
  Get,
  Post,
  Patch,
  Delete,
  Body,
  Param,
  Query,
  ParseUUIDPipe,
  HttpCode,
  HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { TenantsService } from '../services/tenants.service';
import { CreateTenantDto, UpdateTenantDto, UpdateTenantStatusDto } from '../dto';
import { Permissions } from '../../rbac/decorators/permissions.decorator';
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
import { TenantStatus } from '../entities/tenant.entity';

@ApiTags('Platform - Tenants')
@ApiBearerAuth()
@Controller('platform/tenants')
export class PlatformTenantsController {
  constructor(private tenantsService: TenantsService) {}

  @Get()
  @Permissions('platform:tenants:read')
  @ApiOperation({ summary: 'Listar todos los tenants' })
  async findAll(
    @Query('status') status?: TenantStatus,
    @Query('search') search?: string,
    @Query('page') page?: number,
    @Query('limit') limit?: number,
  ) {
    return this.tenantsService.findAll({ status, search, page, limit });
  }

  @Get(':id')
  @Permissions('platform:tenants:read')
  @ApiOperation({ summary: 'Obtener tenant por ID' })
  async findOne(@Param('id', ParseUUIDPipe) id: string) {
    return this.tenantsService.findOne(id);
  }

  @Post()
  @Permissions('platform:tenants:create')
  @ApiOperation({ summary: 'Crear nuevo tenant' })
  async create(
    @Body() dto: CreateTenantDto,
    @CurrentUser('id') userId: string,
  ) {
    return this.tenantsService.create(dto, userId);
  }

  @Patch(':id')
  @Permissions('platform:tenants:update')
  @ApiOperation({ summary: 'Actualizar tenant' })
  async update(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() dto: UpdateTenantDto,
    @CurrentUser('id') userId: string,
  ) {
    return this.tenantsService.update(id, dto, userId);
  }

  @Patch(':id/status')
  @Permissions('platform:tenants:update')
  @ApiOperation({ summary: 'Cambiar estado del tenant' })
  async updateStatus(
    @Param('id', ParseUUIDPipe) id: string,
    @Body() dto: UpdateTenantStatusDto,
    @CurrentUser('id') userId: string,
  ) {
    return this.tenantsService.updateStatus(id, dto, userId);
  }

  @Delete(':id')
  @Permissions('platform:tenants:delete')
  @HttpCode(HttpStatus.NO_CONTENT)
  @ApiOperation({ summary: 'Programar eliminacion de tenant' })
  async softDelete(
    @Param('id', ParseUUIDPipe) id: string,
    @CurrentUser('id') userId: string,
  ) {
    await this.tenantsService.softDelete(id, userId);
  }

  @Post(':id/restore')
  @Permissions('platform:tenants:update')
  @ApiOperation({ summary: 'Restaurar tenant pendiente de eliminacion' })
  async restore(
    @Param('id', ParseUUIDPipe) id: string,
    @CurrentUser('id') userId: string,
  ) {
    return this.tenantsService.restore(id, userId);
  }
}

SubscriptionsController

// controllers/subscriptions.controller.ts
import {
  Controller,
  Get,
  Post,
  Body,
  Query,
  HttpCode,
  HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { SubscriptionsService } from '../services/subscriptions.service';
import {
  CreateSubscriptionDto,
  UpgradeSubscriptionDto,
  CancelSubscriptionDto,
} from '../dto';
import { Permissions } from '../../rbac/decorators/permissions.decorator';

@ApiTags('Tenant - Subscriptions')
@ApiBearerAuth()
@Controller('tenant/subscription')
export class SubscriptionsController {
  constructor(private subscriptionsService: SubscriptionsService) {}

  @Get()
  @Permissions('subscriptions:read')
  @ApiOperation({ summary: 'Ver subscripcion actual' })
  async getCurrentSubscription() {
    return this.subscriptionsService.getCurrentSubscription();
  }

  @Post()
  @Permissions('subscriptions:create')
  @ApiOperation({ summary: 'Crear subscripcion' })
  async create(@Body() dto: CreateSubscriptionDto) {
    return this.subscriptionsService.create(dto);
  }

  @Post('upgrade')
  @Permissions('subscriptions:update')
  @ApiOperation({ summary: 'Upgrade de plan' })
  async upgrade(@Body() dto: UpgradeSubscriptionDto) {
    return this.subscriptionsService.upgrade(dto);
  }

  @Post('cancel')
  @Permissions('subscriptions:update')
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: 'Cancelar subscripcion' })
  async cancel(@Body() dto: CancelSubscriptionDto) {
    return this.subscriptionsService.cancel(dto);
  }

  @Get('check-limit')
  @Permissions('subscriptions:read')
  @ApiOperation({ summary: 'Verificar limite de uso' })
  async checkLimit(@Query('type') type: string) {
    return this.subscriptionsService.checkLimit(type);
  }

  @Get('usage')
  @Permissions('subscriptions:read')
  @ApiOperation({ summary: 'Ver uso actual' })
  async getUsage() {
    const { usage } = await this.subscriptionsService.getCurrentSubscription();
    return usage;
  }
}

Module Configuration

// tenants.module.ts
import { Module, MiddlewareConsumer, RequestMethod } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { APP_GUARD } from '@nestjs/core';

// Entities
import { Tenant } from './entities/tenant.entity';
import { TenantSettings } from './entities/tenant-settings.entity';
import { Plan } from './entities/plan.entity';
import { Subscription } from './entities/subscription.entity';
import { Module as ModuleEntity } from './entities/module.entity';
import { PlanModule } from './entities/plan-module.entity';
import { TenantModule as TenantModuleEntity } from './entities/tenant-module.entity';
import { Invoice } from './entities/invoice.entity';

// Services
import { TenantsService } from './services/tenants.service';
import { TenantSettingsService } from './services/tenant-settings.service';
import { PlansService } from './services/plans.service';
import { SubscriptionsService } from './services/subscriptions.service';
import { TenantUsageService } from './services/tenant-usage.service';
import { BillingService } from './services/billing.service';

// Controllers
import { PlatformTenantsController } from './controllers/platform-tenants.controller';
import { TenantSettingsController } from './controllers/tenant-settings.controller';
import { PlansController } from './controllers/plans.controller';
import { SubscriptionsController } from './controllers/subscriptions.controller';

// Guards & Middleware
import { TenantGuard } from './guards/tenant.guard';
import { LimitGuard } from './guards/limit.guard';
import { ModuleGuard } from './guards/module.guard';
import { TenantContextMiddleware } from './middleware/tenant-context.middleware';

@Module({
  imports: [
    TypeOrmModule.forFeature([
      Tenant,
      TenantSettings,
      Plan,
      Subscription,
      ModuleEntity,
      PlanModule,
      TenantModuleEntity,
      Invoice,
    ]),
  ],
  controllers: [
    PlatformTenantsController,
    TenantSettingsController,
    PlansController,
    SubscriptionsController,
  ],
  providers: [
    TenantsService,
    TenantSettingsService,
    PlansService,
    SubscriptionsService,
    TenantUsageService,
    BillingService,
    TenantGuard,
    LimitGuard,
    ModuleGuard,
  ],
  exports: [
    TenantsService,
    TenantSettingsService,
    SubscriptionsService,
    TenantUsageService,
    TenantGuard,
    LimitGuard,
    ModuleGuard,
  ],
})
export class TenantsModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(TenantContextMiddleware)
      .forRoutes({ path: '*', method: RequestMethod.ALL });
  }
}

Resumen de Endpoints

Categoria Endpoints Metodos
Platform Tenants 8 GET, POST, PATCH, DELETE
Tenant Self-Service 2 GET, PATCH
Tenant Settings 4 GET, PATCH, POST
Plans 5 GET, POST, PATCH, DELETE
Subscriptions 6 GET, POST
Total 25 endpoints

Historial

Version Fecha Autor Cambios
1.0 2025-12-05 System Creacion inicial