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 |