# 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 ```typescript // 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 ```typescript // 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; @Column({ type: 'jsonb', default: {} }) branding: Record; @Column({ type: 'jsonb', default: {} }) regional: Record; @Column({ type: 'jsonb', default: {} }) operational: Record; @Column({ type: 'jsonb', default: {} }) security: Record; @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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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 ```typescript // 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; @ApiPropertyOptional() @IsOptional() @ValidateNested() @Type(() => SecuritySettingsDto) security?: SecuritySettingsDto; } ``` ### Subscription DTOs ```typescript // 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 ```typescript // 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 ```typescript // 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, @InjectRepository(TenantSettings) private settingsRepository: Repository, private dataSource: DataSource, ) {} async create(dto: CreateTenantDto, createdBy: string): Promise { // 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 { 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 { 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 { 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 { 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 { 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 { 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 ```typescript // 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; constructor( @InjectRepository(TenantSettings) private settingsRepository: Repository, @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 { 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 { 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 { 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 ```typescript // 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, @InjectRepository(Plan) private planRepository: Repository, @InjectRepository(Tenant) private tenantRepository: Repository, @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 { 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 { 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 { 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 { 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 { // 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 ```typescript // 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 { // 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> { 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 ```typescript // 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 { const skipCheck = this.reflector.getAllAndOverride( 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 ```typescript // 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 ```typescript // 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 { const limitType = this.reflector.get( 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 ```typescript // 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 { const moduleCode = this.reflector.get( 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 ```typescript // 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) ```typescript // 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 ```typescript // 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 ```typescript // 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 |