2366 lines
58 KiB
Markdown
2366 lines
58 KiB
Markdown
# 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<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
|
|
|
|
```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<string, any>;
|
|
|
|
@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<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
|
|
|
|
```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<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
|
|
|
|
```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<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
|
|
|
|
```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<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
|
|
|
|
```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<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
|
|
|
|
```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<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
|
|
|
|
```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<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
|
|
|
|
```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 |
|