feat: Add complete module structure for ERP backend

- Add modules: ai, audit, billing-usage, biometrics, branches, dashboard,
  feature-flags, invoices, mcp, mobile, notifications, partners,
  payment-terminals, products, profiles, purchases, reports, sales,
  storage, warehouses, webhooks, whatsapp
- Add controllers, DTOs, entities, and services for each module
- Add shared services and utilities

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-16 00:40:54 -06:00
parent 12fb6eeee8
commit ca07b4268d
308 changed files with 31420 additions and 147 deletions

503
src/app.integration.ts Normal file
View File

@ -0,0 +1,503 @@
/**
* Application Integration
*
* Integrates all modules and configures the application
*/
import express, { Express, Router } from 'express';
import { DataSource } from 'typeorm';
// Import modules
import { ProfilesModule } from './modules/profiles';
import { BranchesModule } from './modules/branches';
import { BillingUsageModule } from './modules/billing-usage';
import { PaymentTerminalsModule } from './modules/payment-terminals';
// Import new business modules
import { PartnersModule } from './modules/partners';
import { ProductsModule } from './modules/products';
import { WarehousesModule } from './modules/warehouses';
import { InventoryModule } from './modules/inventory';
import { SalesModule } from './modules/sales';
import { PurchasesModule } from './modules/purchases';
import { InvoicesModule } from './modules/invoices';
import { ReportsModule } from './modules/reports';
import { DashboardModule } from './modules/dashboard';
// Import entities from all modules for TypeORM
import {
Person,
UserProfile,
ProfileTool,
ProfileModule,
UserProfileAssignment,
} from './modules/profiles/entities';
import {
Device,
BiometricCredential,
DeviceSession,
DeviceActivityLog,
} from './modules/biometrics/entities';
import {
Branch,
UserBranchAssignment,
BranchSchedule,
BranchPaymentTerminal,
} from './modules/branches/entities';
import {
MobileSession,
OfflineSyncQueue,
PushToken,
PaymentTransaction,
} from './modules/mobile/entities';
import {
SubscriptionPlan,
TenantSubscription,
UsageTracking,
Invoice as BillingInvoice,
InvoiceItem as BillingInvoiceItem,
} from './modules/billing-usage/entities';
// Import entities from new business modules
import {
Partner,
PartnerAddress,
PartnerContact,
PartnerBankAccount,
} from './modules/partners/entities';
import {
ProductCategory,
Product,
ProductPrice,
ProductSupplier,
} from './modules/products/entities';
import {
Warehouse,
WarehouseLocation,
WarehouseZone,
} from './modules/warehouses/entities';
import {
StockLevel,
StockMovement,
InventoryCount,
InventoryCountLine,
TransferOrder,
TransferOrderLine,
} from './modules/inventory/entities';
import {
Quotation,
QuotationItem,
SalesOrder,
SalesOrderItem,
} from './modules/sales/entities';
import {
PurchaseOrder,
PurchaseOrderItem,
PurchaseReceipt,
PurchaseReceiptItem,
} from './modules/purchases/entities';
import {
Invoice,
InvoiceItem,
Payment,
PaymentAllocation,
} from './modules/invoices/entities';
/**
* Get all entities for TypeORM configuration
*/
export function getAllEntities() {
return [
// Profiles
Person,
UserProfile,
ProfileTool,
ProfileModule,
UserProfileAssignment,
// Biometrics
Device,
BiometricCredential,
DeviceSession,
DeviceActivityLog,
// Branches
Branch,
UserBranchAssignment,
BranchSchedule,
BranchPaymentTerminal,
// Mobile
MobileSession,
OfflineSyncQueue,
PushToken,
PaymentTransaction,
// Billing
SubscriptionPlan,
TenantSubscription,
UsageTracking,
BillingInvoice,
BillingInvoiceItem,
// Partners
Partner,
PartnerAddress,
PartnerContact,
PartnerBankAccount,
// Products
ProductCategory,
Product,
ProductPrice,
ProductSupplier,
// Warehouses
Warehouse,
WarehouseLocation,
WarehouseZone,
// Inventory
StockLevel,
StockMovement,
InventoryCount,
InventoryCountLine,
TransferOrder,
TransferOrderLine,
// Sales
Quotation,
QuotationItem,
SalesOrder,
SalesOrderItem,
// Purchases
PurchaseOrder,
PurchaseOrderItem,
PurchaseReceipt,
PurchaseReceiptItem,
// Invoices
Invoice,
InvoiceItem,
Payment,
PaymentAllocation,
];
}
/**
* Module configuration options
*/
export interface ModuleOptions {
profiles?: {
enabled: boolean;
basePath?: string;
};
branches?: {
enabled: boolean;
basePath?: string;
};
billing?: {
enabled: boolean;
basePath?: string;
};
payments?: {
enabled: boolean;
basePath?: string;
};
partners?: {
enabled: boolean;
basePath?: string;
};
products?: {
enabled: boolean;
basePath?: string;
};
warehouses?: {
enabled: boolean;
basePath?: string;
};
inventory?: {
enabled: boolean;
basePath?: string;
};
sales?: {
enabled: boolean;
basePath?: string;
};
purchases?: {
enabled: boolean;
basePath?: string;
};
invoices?: {
enabled: boolean;
basePath?: string;
};
reports?: {
enabled: boolean;
basePath?: string;
};
dashboard?: {
enabled: boolean;
basePath?: string;
};
}
/**
* Default module options
*/
const defaultModuleOptions: ModuleOptions = {
profiles: { enabled: true, basePath: '/api' },
branches: { enabled: true, basePath: '/api' },
billing: { enabled: true, basePath: '/api' },
payments: { enabled: true, basePath: '/api' },
partners: { enabled: true, basePath: '/api' },
products: { enabled: true, basePath: '/api' },
warehouses: { enabled: true, basePath: '/api' },
inventory: { enabled: true, basePath: '/api' },
sales: { enabled: true, basePath: '/api' },
purchases: { enabled: true, basePath: '/api' },
invoices: { enabled: true, basePath: '/api' },
reports: { enabled: true, basePath: '/api' },
dashboard: { enabled: true, basePath: '/api' },
};
/**
* Initialize and integrate all modules
*/
export function initializeModules(
app: Express,
dataSource: DataSource,
options: ModuleOptions = {}
): void {
const config = { ...defaultModuleOptions, ...options };
// Initialize Profiles Module
if (config.profiles?.enabled) {
const profilesModule = new ProfilesModule({
dataSource,
basePath: config.profiles.basePath,
});
app.use(profilesModule.router);
console.log('✅ Profiles module initialized');
}
// Initialize Branches Module
if (config.branches?.enabled) {
const branchesModule = new BranchesModule({
dataSource,
basePath: config.branches.basePath,
});
app.use(branchesModule.router);
console.log('✅ Branches module initialized');
}
// Initialize Billing Module
if (config.billing?.enabled) {
const billingModule = new BillingUsageModule({
dataSource,
basePath: config.billing.basePath,
});
app.use(billingModule.router);
console.log('✅ Billing module initialized');
}
// Initialize Payment Terminals Module
if (config.payments?.enabled) {
const paymentModule = new PaymentTerminalsModule({
dataSource,
basePath: config.payments.basePath,
});
app.use(paymentModule.router);
console.log('✅ Payment Terminals module initialized');
}
// Initialize Partners Module
if (config.partners?.enabled) {
const partnersModule = new PartnersModule({
dataSource,
basePath: config.partners.basePath,
});
app.use(partnersModule.router);
console.log('✅ Partners module initialized');
}
// Initialize Products Module
if (config.products?.enabled) {
const productsModule = new ProductsModule({
dataSource,
basePath: config.products.basePath,
});
app.use(productsModule.router);
console.log('✅ Products module initialized');
}
// Initialize Warehouses Module
if (config.warehouses?.enabled) {
const warehousesModule = new WarehousesModule({
dataSource,
basePath: config.warehouses.basePath,
});
app.use(warehousesModule.router);
console.log('✅ Warehouses module initialized');
}
// Initialize Inventory Module
if (config.inventory?.enabled) {
const inventoryModule = new InventoryModule({
dataSource,
basePath: config.inventory.basePath,
});
app.use(inventoryModule.router);
console.log('✅ Inventory module initialized');
}
// Initialize Sales Module
if (config.sales?.enabled) {
const salesModule = new SalesModule({
dataSource,
basePath: config.sales.basePath,
});
app.use(salesModule.router);
console.log('✅ Sales module initialized');
}
// Initialize Purchases Module
if (config.purchases?.enabled) {
const purchasesModule = new PurchasesModule({
dataSource,
basePath: config.purchases.basePath,
});
app.use(purchasesModule.router);
console.log('✅ Purchases module initialized');
}
// Initialize Invoices Module
if (config.invoices?.enabled) {
const invoicesModule = new InvoicesModule({
dataSource,
basePath: config.invoices.basePath,
});
app.use(invoicesModule.router);
console.log('✅ Invoices module initialized');
}
// Initialize Reports Module
if (config.reports?.enabled) {
const reportsModule = new ReportsModule({
dataSource,
basePath: config.reports.basePath,
});
app.use(reportsModule.router);
console.log('✅ Reports module initialized');
}
// Initialize Dashboard Module
if (config.dashboard?.enabled) {
const dashboardModule = new DashboardModule({
dataSource,
basePath: config.dashboard.basePath,
});
app.use(dashboardModule.router);
console.log('✅ Dashboard module initialized');
}
}
/**
* Create TypeORM DataSource configuration
*/
export function createDataSourceConfig(options: {
host: string;
port: number;
username: string;
password: string;
database: string;
ssl?: boolean;
logging?: boolean;
}) {
return {
type: 'postgres' as const,
host: options.host,
port: options.port,
username: options.username,
password: options.password,
database: options.database,
ssl: options.ssl ? { rejectUnauthorized: false } : false,
logging: options.logging ?? false,
entities: getAllEntities(),
synchronize: false, // Use migrations instead
migrations: ['src/migrations/*.ts'],
};
}
/**
* Example application setup
*/
export async function createApplication(dataSourceConfig: any): Promise<Express> {
// Create Express app
const app = express();
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// CORS middleware (configure for production)
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Tenant-ID');
if (req.method === 'OPTIONS') {
res.sendStatus(200);
return;
}
next();
});
// Initialize database
const dataSource = new DataSource(dataSourceConfig);
await dataSource.initialize();
console.log('✅ Database connected');
// Initialize all modules
initializeModules(app, dataSource);
// Health check endpoint
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
modules: {
profiles: true,
branches: true,
billing: true,
payments: true,
partners: true,
products: true,
warehouses: true,
inventory: true,
sales: true,
purchases: true,
invoices: true,
reports: true,
dashboard: true,
},
});
});
// Error handling middleware
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error('Error:', err);
res.status(err.status || 500).json({
error: err.message || 'Internal Server Error',
code: err.code || 'INTERNAL_ERROR',
});
});
return app;
}
export default {
getAllEntities,
initializeModules,
createDataSourceConfig,
createApplication,
};

View File

@ -0,0 +1,66 @@
import { Router } from 'express';
import { DataSource } from 'typeorm';
import { AIService } from './services';
import { AIController } from './controllers';
import {
AIModel,
AIPrompt,
AIConversation,
AIMessage,
AIUsageLog,
AITenantQuota,
} from './entities';
export interface AIModuleOptions {
dataSource: DataSource;
basePath?: string;
}
export class AIModule {
public router: Router;
public aiService: AIService;
private dataSource: DataSource;
private basePath: string;
constructor(options: AIModuleOptions) {
this.dataSource = options.dataSource;
this.basePath = options.basePath || '';
this.router = Router();
this.initializeServices();
this.initializeRoutes();
}
private initializeServices(): void {
const modelRepository = this.dataSource.getRepository(AIModel);
const conversationRepository = this.dataSource.getRepository(AIConversation);
const messageRepository = this.dataSource.getRepository(AIMessage);
const promptRepository = this.dataSource.getRepository(AIPrompt);
const usageLogRepository = this.dataSource.getRepository(AIUsageLog);
const quotaRepository = this.dataSource.getRepository(AITenantQuota);
this.aiService = new AIService(
modelRepository,
conversationRepository,
messageRepository,
promptRepository,
usageLogRepository,
quotaRepository
);
}
private initializeRoutes(): void {
const aiController = new AIController(this.aiService);
this.router.use(`${this.basePath}/ai`, aiController.router);
}
static getEntities(): Function[] {
return [
AIModel,
AIPrompt,
AIConversation,
AIMessage,
AIUsageLog,
AITenantQuota,
];
}
}

View File

@ -0,0 +1,381 @@
import { Request, Response, NextFunction, Router } from 'express';
import { AIService, ConversationFilters } from '../services/ai.service';
export class AIController {
public router: Router;
constructor(private readonly aiService: AIService) {
this.router = Router();
this.initializeRoutes();
}
private initializeRoutes(): void {
// Models
this.router.get('/models', this.findAllModels.bind(this));
this.router.get('/models/:id', this.findModel.bind(this));
this.router.get('/models/code/:code', this.findModelByCode.bind(this));
this.router.get('/models/provider/:provider', this.findModelsByProvider.bind(this));
this.router.get('/models/type/:type', this.findModelsByType.bind(this));
// Prompts
this.router.get('/prompts', this.findAllPrompts.bind(this));
this.router.get('/prompts/:id', this.findPrompt.bind(this));
this.router.get('/prompts/code/:code', this.findPromptByCode.bind(this));
this.router.post('/prompts', this.createPrompt.bind(this));
this.router.patch('/prompts/:id', this.updatePrompt.bind(this));
// Conversations
this.router.get('/conversations', this.findConversations.bind(this));
this.router.get('/conversations/user/:userId', this.findUserConversations.bind(this));
this.router.get('/conversations/:id', this.findConversation.bind(this));
this.router.post('/conversations', this.createConversation.bind(this));
this.router.patch('/conversations/:id', this.updateConversation.bind(this));
this.router.post('/conversations/:id/archive', this.archiveConversation.bind(this));
// Messages
this.router.get('/conversations/:conversationId/messages', this.findMessages.bind(this));
this.router.post('/conversations/:conversationId/messages', this.addMessage.bind(this));
this.router.get('/conversations/:conversationId/tokens', this.getConversationTokenCount.bind(this));
// Usage & Quotas
this.router.post('/usage', this.logUsage.bind(this));
this.router.get('/usage/stats', this.getUsageStats.bind(this));
this.router.get('/quotas', this.getTenantQuota.bind(this));
this.router.patch('/quotas', this.updateTenantQuota.bind(this));
this.router.get('/quotas/check', this.checkQuotaAvailable.bind(this));
}
// ============================================
// MODELS
// ============================================
private async findAllModels(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const models = await this.aiService.findAllModels();
res.json({ data: models, total: models.length });
} catch (error) {
next(error);
}
}
private async findModel(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const model = await this.aiService.findModel(id);
if (!model) {
res.status(404).json({ error: 'Model not found' });
return;
}
res.json({ data: model });
} catch (error) {
next(error);
}
}
private async findModelByCode(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { code } = req.params;
const model = await this.aiService.findModelByCode(code);
if (!model) {
res.status(404).json({ error: 'Model not found' });
return;
}
res.json({ data: model });
} catch (error) {
next(error);
}
}
private async findModelsByProvider(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { provider } = req.params;
const models = await this.aiService.findModelsByProvider(provider);
res.json({ data: models, total: models.length });
} catch (error) {
next(error);
}
}
private async findModelsByType(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { type } = req.params;
const models = await this.aiService.findModelsByType(type);
res.json({ data: models, total: models.length });
} catch (error) {
next(error);
}
}
// ============================================
// PROMPTS
// ============================================
private async findAllPrompts(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const prompts = await this.aiService.findAllPrompts(tenantId);
res.json({ data: prompts, total: prompts.length });
} catch (error) {
next(error);
}
}
private async findPrompt(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const prompt = await this.aiService.findPrompt(id);
if (!prompt) {
res.status(404).json({ error: 'Prompt not found' });
return;
}
res.json({ data: prompt });
} catch (error) {
next(error);
}
}
private async findPromptByCode(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { code } = req.params;
const tenantId = req.headers['x-tenant-id'] as string;
const prompt = await this.aiService.findPromptByCode(code, tenantId);
if (!prompt) {
res.status(404).json({ error: 'Prompt not found' });
return;
}
// Increment usage count
await this.aiService.incrementPromptUsage(prompt.id);
res.json({ data: prompt });
} catch (error) {
next(error);
}
}
private async createPrompt(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const prompt = await this.aiService.createPrompt(tenantId, req.body, userId);
res.status(201).json({ data: prompt });
} catch (error) {
next(error);
}
}
private async updatePrompt(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const userId = req.headers['x-user-id'] as string;
const prompt = await this.aiService.updatePrompt(id, req.body, userId);
if (!prompt) {
res.status(404).json({ error: 'Prompt not found' });
return;
}
res.json({ data: prompt });
} catch (error) {
next(error);
}
}
// ============================================
// CONVERSATIONS
// ============================================
private async findConversations(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const filters: ConversationFilters = {
userId: req.query.userId as string,
modelId: req.query.modelId as string,
status: req.query.status as string,
};
if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string);
if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string);
const limit = parseInt(req.query.limit as string) || 50;
const conversations = await this.aiService.findConversations(tenantId, filters, limit);
res.json({ data: conversations, total: conversations.length });
} catch (error) {
next(error);
}
}
private async findUserConversations(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { userId } = req.params;
const limit = parseInt(req.query.limit as string) || 20;
const conversations = await this.aiService.findUserConversations(tenantId, userId, limit);
res.json({ data: conversations, total: conversations.length });
} catch (error) {
next(error);
}
}
private async findConversation(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const conversation = await this.aiService.findConversation(id);
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
res.json({ data: conversation });
} catch (error) {
next(error);
}
}
private async createConversation(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const conversation = await this.aiService.createConversation(tenantId, userId, req.body);
res.status(201).json({ data: conversation });
} catch (error) {
next(error);
}
}
private async updateConversation(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const conversation = await this.aiService.updateConversation(id, req.body);
if (!conversation) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
res.json({ data: conversation });
} catch (error) {
next(error);
}
}
private async archiveConversation(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const archived = await this.aiService.archiveConversation(id);
if (!archived) {
res.status(404).json({ error: 'Conversation not found' });
return;
}
res.json({ data: { success: true } });
} catch (error) {
next(error);
}
}
// ============================================
// MESSAGES
// ============================================
private async findMessages(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { conversationId } = req.params;
const messages = await this.aiService.findMessages(conversationId);
res.json({ data: messages, total: messages.length });
} catch (error) {
next(error);
}
}
private async addMessage(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { conversationId } = req.params;
const message = await this.aiService.addMessage(conversationId, req.body);
res.status(201).json({ data: message });
} catch (error) {
next(error);
}
}
private async getConversationTokenCount(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { conversationId } = req.params;
const tokenCount = await this.aiService.getConversationTokenCount(conversationId);
res.json({ data: { tokenCount } });
} catch (error) {
next(error);
}
}
// ============================================
// USAGE & QUOTAS
// ============================================
private async logUsage(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const log = await this.aiService.logUsage(tenantId, req.body);
res.status(201).json({ data: log });
} catch (error) {
next(error);
}
}
private async getUsageStats(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const startDate = new Date(req.query.startDate as string || Date.now() - 30 * 24 * 60 * 60 * 1000);
const endDate = new Date(req.query.endDate as string || Date.now());
const stats = await this.aiService.getUsageStats(tenantId, startDate, endDate);
res.json({ data: stats });
} catch (error) {
next(error);
}
}
private async getTenantQuota(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const quota = await this.aiService.getTenantQuota(tenantId);
res.json({ data: quota });
} catch (error) {
next(error);
}
}
private async updateTenantQuota(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const quota = await this.aiService.updateTenantQuota(tenantId, req.body);
res.json({ data: quota });
} catch (error) {
next(error);
}
}
private async checkQuotaAvailable(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const result = await this.aiService.checkQuotaAvailable(tenantId);
res.json({ data: result });
} catch (error) {
next(error);
}
}
}

View File

@ -0,0 +1 @@
export { AIController } from './ai.controller';

View File

@ -0,0 +1,343 @@
import {
IsString,
IsOptional,
IsBoolean,
IsNumber,
IsArray,
IsObject,
IsUUID,
MaxLength,
MinLength,
Min,
Max,
} from 'class-validator';
// ============================================
// PROMPT DTOs
// ============================================
export class CreatePromptDto {
@IsString()
@MinLength(2)
@MaxLength(50)
code: string;
@IsString()
@MinLength(2)
@MaxLength(100)
name: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
@MaxLength(30)
category?: string;
@IsString()
systemPrompt: string;
@IsOptional()
@IsString()
userPromptTemplate?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
variables?: string[];
@IsOptional()
@IsNumber()
@Min(0)
@Max(2)
temperature?: number;
@IsOptional()
@IsNumber()
@Min(1)
maxTokens?: number;
@IsOptional()
@IsArray()
@IsString({ each: true })
stopSequences?: string[];
@IsOptional()
@IsObject()
modelParameters?: Record<string, any>;
@IsOptional()
@IsArray()
@IsString({ each: true })
allowedModels?: string[];
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}
export class UpdatePromptDto {
@IsOptional()
@IsString()
@MaxLength(100)
name?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
@MaxLength(30)
category?: string;
@IsOptional()
@IsString()
systemPrompt?: string;
@IsOptional()
@IsString()
userPromptTemplate?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
variables?: string[];
@IsOptional()
@IsNumber()
@Min(0)
@Max(2)
temperature?: number;
@IsOptional()
@IsNumber()
@Min(1)
maxTokens?: number;
@IsOptional()
@IsArray()
@IsString({ each: true })
stopSequences?: string[];
@IsOptional()
@IsObject()
modelParameters?: Record<string, any>;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
// ============================================
// CONVERSATION DTOs
// ============================================
export class CreateConversationDto {
@IsOptional()
@IsUUID()
modelId?: string;
@IsOptional()
@IsUUID()
promptId?: string;
@IsOptional()
@IsString()
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
systemPrompt?: string;
@IsOptional()
@IsNumber()
@Min(0)
@Max(2)
temperature?: number;
@IsOptional()
@IsNumber()
@Min(1)
maxTokens?: number;
@IsOptional()
@IsObject()
context?: Record<string, any>;
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}
export class UpdateConversationDto {
@IsOptional()
@IsString()
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
systemPrompt?: string;
@IsOptional()
@IsNumber()
@Min(0)
@Max(2)
temperature?: number;
@IsOptional()
@IsNumber()
@Min(1)
maxTokens?: number;
@IsOptional()
@IsObject()
context?: Record<string, any>;
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}
// ============================================
// MESSAGE DTOs
// ============================================
export class AddMessageDto {
@IsString()
@MaxLength(20)
role: string;
@IsString()
content: string;
@IsOptional()
@IsString()
@MaxLength(50)
modelCode?: string;
@IsOptional()
@IsNumber()
@Min(0)
promptTokens?: number;
@IsOptional()
@IsNumber()
@Min(0)
completionTokens?: number;
@IsOptional()
@IsNumber()
@Min(0)
totalTokens?: number;
@IsOptional()
@IsString()
@MaxLength(30)
finishReason?: string;
@IsOptional()
@IsNumber()
@Min(0)
latencyMs?: number;
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}
// ============================================
// USAGE DTOs
// ============================================
export class LogUsageDto {
@IsOptional()
@IsUUID()
userId?: string;
@IsOptional()
@IsUUID()
conversationId?: string;
@IsUUID()
modelId: string;
@IsString()
@MaxLength(20)
usageType: string;
@IsNumber()
@Min(0)
inputTokens: number;
@IsNumber()
@Min(0)
outputTokens: number;
@IsOptional()
@IsNumber()
@Min(0)
costUsd?: number;
@IsOptional()
@IsNumber()
@Min(0)
latencyMs?: number;
@IsOptional()
@IsBoolean()
wasSuccessful?: boolean;
@IsOptional()
@IsString()
errorMessage?: string;
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
}
// ============================================
// QUOTA DTOs
// ============================================
export class UpdateQuotaDto {
@IsOptional()
@IsNumber()
@Min(0)
maxRequestsPerMonth?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxTokensPerMonth?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxSpendPerMonth?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxRequestsPerDay?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxTokensPerDay?: number;
@IsOptional()
@IsArray()
@IsString({ each: true })
allowedModels?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
blockedModels?: string[];
}

View File

@ -0,0 +1,9 @@
export {
CreatePromptDto,
UpdatePromptDto,
CreateConversationDto,
UpdateConversationDto,
AddMessageDto,
LogUsageDto,
UpdateQuotaDto,
} from './ai.dto';

View File

@ -0,0 +1,92 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { AIModel } from './model.entity';
import { AIPrompt } from './prompt.entity';
export type CompletionStatus = 'pending' | 'processing' | 'completed' | 'failed';
@Entity({ name: 'completions', schema: 'ai' })
export class AICompletion {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId: string;
@Index()
@Column({ name: 'prompt_id', type: 'uuid', nullable: true })
promptId: string;
@Column({ name: 'prompt_code', type: 'varchar', length: 100, nullable: true })
promptCode: string;
@Column({ name: 'model_id', type: 'uuid', nullable: true })
modelId: string;
@Column({ name: 'input_text', type: 'text' })
inputText: string;
@Column({ name: 'input_variables', type: 'jsonb', default: {} })
inputVariables: Record<string, any>;
@Column({ name: 'output_text', type: 'text', nullable: true })
outputText: string;
@Column({ name: 'prompt_tokens', type: 'int', nullable: true })
promptTokens: number;
@Column({ name: 'completion_tokens', type: 'int', nullable: true })
completionTokens: number;
@Column({ name: 'total_tokens', type: 'int', nullable: true })
totalTokens: number;
@Column({ name: 'cost', type: 'decimal', precision: 10, scale: 6, nullable: true })
cost: number;
@Column({ name: 'latency_ms', type: 'int', nullable: true })
latencyMs: number;
@Column({ name: 'finish_reason', type: 'varchar', length: 30, nullable: true })
finishReason: string;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' })
status: CompletionStatus;
@Column({ name: 'error_message', type: 'text', nullable: true })
errorMessage: string;
@Index()
@Column({ name: 'context_type', type: 'varchar', length: 50, nullable: true })
contextType: string;
@Column({ name: 'context_id', type: 'uuid', nullable: true })
contextId: string;
@Column({ name: 'metadata', type: 'jsonb', default: {} })
metadata: Record<string, any>;
@Index()
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@ManyToOne(() => AIModel, { nullable: true })
@JoinColumn({ name: 'model_id' })
model: AIModel;
@ManyToOne(() => AIPrompt, { nullable: true })
@JoinColumn({ name: 'prompt_id' })
prompt: AIPrompt;
}

View File

@ -0,0 +1,160 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
OneToMany,
} from 'typeorm';
import { AIModel } from './model.entity';
export type ConversationStatus = 'active' | 'archived' | 'deleted';
export type MessageRole = 'system' | 'user' | 'assistant' | 'function';
export type FinishReason = 'stop' | 'length' | 'function_call' | 'content_filter';
@Entity({ name: 'conversations', schema: 'ai' })
export class AIConversation {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({ name: 'title', type: 'varchar', length: 255, nullable: true })
title: string;
@Column({ name: 'summary', type: 'text', nullable: true })
summary: string;
@Column({ name: 'context_type', type: 'varchar', length: 50, nullable: true })
contextType: string;
@Column({ name: 'context_data', type: 'jsonb', default: {} })
contextData: Record<string, any>;
@Column({ name: 'model_id', type: 'uuid', nullable: true })
modelId: string;
@Column({ name: 'prompt_id', type: 'uuid', nullable: true })
promptId: string;
@Index()
@Column({ name: 'status', type: 'varchar', length: 20, default: 'active' })
status: ConversationStatus;
@Column({ name: 'is_pinned', type: 'boolean', default: false })
isPinned: boolean;
@Column({ name: 'message_count', type: 'int', default: 0 })
messageCount: number;
@Column({ name: 'total_tokens', type: 'int', default: 0 })
totalTokens: number;
@Column({ name: 'total_cost', type: 'decimal', precision: 10, scale: 4, default: 0 })
totalCost: number;
@Column({ name: 'metadata', type: 'jsonb', default: {} })
metadata: Record<string, any>;
@Column({ name: 'tags', type: 'text', array: true, default: [] })
tags: string[];
@Column({ name: 'last_message_at', type: 'timestamptz', nullable: true })
lastMessageAt: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@ManyToOne(() => AIModel, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'model_id' })
model: AIModel;
@OneToMany(() => AIMessage, (message) => message.conversation)
messages: AIMessage[];
}
@Entity({ name: 'messages', schema: 'ai' })
export class AIMessage {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'conversation_id', type: 'uuid' })
conversationId: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'role', type: 'varchar', length: 20 })
role: MessageRole;
@Column({ name: 'content', type: 'text' })
content: string;
@Column({ name: 'function_name', type: 'varchar', length: 100, nullable: true })
functionName: string;
@Column({ name: 'function_arguments', type: 'jsonb', nullable: true })
functionArguments: Record<string, any>;
@Column({ name: 'function_result', type: 'jsonb', nullable: true })
functionResult: Record<string, any>;
@Column({ name: 'model_id', type: 'uuid', nullable: true })
modelId: string;
@Column({ name: 'model_response_id', type: 'varchar', length: 255, nullable: true })
modelResponseId: string;
@Column({ name: 'prompt_tokens', type: 'int', nullable: true })
promptTokens: number;
@Column({ name: 'completion_tokens', type: 'int', nullable: true })
completionTokens: number;
@Column({ name: 'total_tokens', type: 'int', nullable: true })
totalTokens: number;
@Column({ name: 'cost', type: 'decimal', precision: 10, scale: 6, nullable: true })
cost: number;
@Column({ name: 'latency_ms', type: 'int', nullable: true })
latencyMs: number;
@Column({ name: 'finish_reason', type: 'varchar', length: 30, nullable: true })
finishReason: FinishReason;
@Column({ name: 'metadata', type: 'jsonb', default: {} })
metadata: Record<string, any>;
@Column({ name: 'feedback_rating', type: 'int', nullable: true })
feedbackRating: number;
@Column({ name: 'feedback_text', type: 'text', nullable: true })
feedbackText: string;
@Index()
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@ManyToOne(() => AIConversation, (conversation) => conversation.messages, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'conversation_id' })
conversation: AIConversation;
@ManyToOne(() => AIModel, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'model_id' })
model: AIModel;
}

View File

@ -0,0 +1,77 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { AIModel } from './model.entity';
@Entity({ name: 'embeddings', schema: 'ai' })
export class AIEmbedding {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'content', type: 'text' })
content: string;
@Index()
@Column({ name: 'content_hash', type: 'varchar', length: 64, nullable: true })
contentHash: string;
// Note: If pgvector is enabled, use 'vector' type instead of 'jsonb'
@Column({ name: 'embedding_json', type: 'jsonb', nullable: true })
embeddingJson: number[];
@Column({ name: 'model_id', type: 'uuid', nullable: true })
modelId: string;
@Column({ name: 'model_name', type: 'varchar', length: 100, nullable: true })
modelName: string;
@Column({ name: 'dimensions', type: 'int', nullable: true })
dimensions: number;
@Index()
@Column({ name: 'entity_type', type: 'varchar', length: 100, nullable: true })
entityType: string;
@Column({ name: 'entity_id', type: 'uuid', nullable: true })
entityId: string;
@Column({ name: 'metadata', type: 'jsonb', default: {} })
metadata: Record<string, any>;
@Column({ name: 'tags', type: 'text', array: true, default: [] })
tags: string[];
@Column({ name: 'chunk_index', type: 'int', nullable: true })
chunkIndex: number;
@Column({ name: 'chunk_total', type: 'int', nullable: true })
chunkTotal: number;
@Column({ name: 'parent_embedding_id', type: 'uuid', nullable: true })
parentEmbeddingId: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@ManyToOne(() => AIModel, { nullable: true })
@JoinColumn({ name: 'model_id' })
model: AIModel;
@ManyToOne(() => AIEmbedding, { nullable: true })
@JoinColumn({ name: 'parent_embedding_id' })
parentEmbedding: AIEmbedding;
}

View File

@ -0,0 +1,7 @@
export { AIModel, AIProvider, ModelType } from './model.entity';
export { AIConversation, AIMessage, ConversationStatus, MessageRole, FinishReason } from './conversation.entity';
export { AIPrompt, PromptCategory } from './prompt.entity';
export { AIUsageLog, AITenantQuota, UsageType } from './usage.entity';
export { AICompletion, CompletionStatus } from './completion.entity';
export { AIEmbedding } from './embedding.entity';
export { AIKnowledgeBase, KnowledgeSourceType, KnowledgeContentType } from './knowledge-base.entity';

View File

@ -0,0 +1,98 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
Unique,
} from 'typeorm';
import { AIEmbedding } from './embedding.entity';
export type KnowledgeSourceType = 'manual' | 'document' | 'website' | 'api';
export type KnowledgeContentType = 'faq' | 'documentation' | 'policy' | 'procedure';
@Entity({ name: 'knowledge_base', schema: 'ai' })
@Unique(['tenantId', 'code'])
export class AIKnowledgeBase {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
tenantId: string;
@Column({ name: 'code', type: 'varchar', length: 100 })
code: string;
@Column({ name: 'name', type: 'varchar', length: 200 })
name: string;
@Column({ name: 'description', type: 'text', nullable: true })
description: string;
@Column({ name: 'source_type', type: 'varchar', length: 30, nullable: true })
sourceType: KnowledgeSourceType;
@Column({ name: 'source_url', type: 'text', nullable: true })
sourceUrl: string;
@Column({ name: 'source_file_id', type: 'uuid', nullable: true })
sourceFileId: string;
@Column({ name: 'content', type: 'text' })
content: string;
@Column({ name: 'content_type', type: 'varchar', length: 50, nullable: true })
contentType: KnowledgeContentType;
@Index()
@Column({ name: 'category', type: 'varchar', length: 100, nullable: true })
category: string;
@Column({ name: 'subcategory', type: 'varchar', length: 100, nullable: true })
subcategory: string;
@Column({ name: 'tags', type: 'text', array: true, default: [] })
tags: string[];
@Column({ name: 'embedding_id', type: 'uuid', nullable: true })
embeddingId: string;
@Column({ name: 'priority', type: 'int', default: 0 })
priority: number;
@Column({ name: 'relevance_score', type: 'decimal', precision: 5, scale: 4, nullable: true })
relevanceScore: number;
@Index()
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'is_verified', type: 'boolean', default: false })
isVerified: boolean;
@Column({ name: 'verified_by', type: 'uuid', nullable: true })
verifiedBy: string;
@Column({ name: 'verified_at', type: 'timestamptz', nullable: true })
verifiedAt: Date;
@Column({ name: 'metadata', type: 'jsonb', default: {} })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@ManyToOne(() => AIEmbedding, { nullable: true })
@JoinColumn({ name: 'embedding_id' })
embedding: AIEmbedding;
}

View File

@ -0,0 +1,78 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export type AIProvider = 'openai' | 'anthropic' | 'google' | 'azure' | 'local';
export type ModelType = 'chat' | 'completion' | 'embedding' | 'image' | 'audio';
@Entity({ name: 'models', schema: 'ai' })
export class AIModel {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index({ unique: true })
@Column({ name: 'code', type: 'varchar', length: 100 })
code: string;
@Column({ name: 'name', type: 'varchar', length: 200 })
name: string;
@Column({ name: 'description', type: 'text', nullable: true })
description: string;
@Index()
@Column({ name: 'provider', type: 'varchar', length: 50 })
provider: AIProvider;
@Column({ name: 'model_id', type: 'varchar', length: 100 })
modelId: string;
@Index()
@Column({ name: 'model_type', type: 'varchar', length: 30 })
modelType: ModelType;
@Column({ name: 'max_tokens', type: 'int', nullable: true })
maxTokens: number;
@Column({ name: 'supports_functions', type: 'boolean', default: false })
supportsFunctions: boolean;
@Column({ name: 'supports_vision', type: 'boolean', default: false })
supportsVision: boolean;
@Column({ name: 'supports_streaming', type: 'boolean', default: true })
supportsStreaming: boolean;
@Column({ name: 'input_cost_per_1k', type: 'decimal', precision: 10, scale: 6, nullable: true })
inputCostPer1k: number;
@Column({ name: 'output_cost_per_1k', type: 'decimal', precision: 10, scale: 6, nullable: true })
outputCostPer1k: number;
@Column({ name: 'rate_limit_rpm', type: 'int', nullable: true })
rateLimitRpm: number;
@Column({ name: 'rate_limit_tpm', type: 'int', nullable: true })
rateLimitTpm: number;
@Index()
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'is_default', type: 'boolean', default: false })
isDefault: boolean;
@Column({ name: 'metadata', type: 'jsonb', default: {} })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -0,0 +1,110 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
Unique,
} from 'typeorm';
import { AIModel } from './model.entity';
export type PromptCategory = 'assistant' | 'analysis' | 'generation' | 'extraction';
@Entity({ name: 'prompts', schema: 'ai' })
@Unique(['tenantId', 'code', 'version'])
export class AIPrompt {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
tenantId: string;
@Index()
@Column({ name: 'code', type: 'varchar', length: 100 })
code: string;
@Column({ name: 'name', type: 'varchar', length: 200 })
name: string;
@Column({ name: 'description', type: 'text', nullable: true })
description: string;
@Index()
@Column({ name: 'category', type: 'varchar', length: 50, nullable: true })
category: PromptCategory;
@Column({ name: 'system_prompt', type: 'text', nullable: true })
systemPrompt: string;
@Column({ name: 'user_prompt_template', type: 'text' })
userPromptTemplate: string;
@Column({ name: 'model_id', type: 'uuid', nullable: true })
modelId: string;
@Column({ name: 'temperature', type: 'decimal', precision: 3, scale: 2, default: 0.7 })
temperature: number;
@Column({ name: 'max_tokens', type: 'int', nullable: true })
maxTokens: number;
@Column({ name: 'top_p', type: 'decimal', precision: 3, scale: 2, nullable: true })
topP: number;
@Column({ name: 'frequency_penalty', type: 'decimal', precision: 3, scale: 2, nullable: true })
frequencyPenalty: number;
@Column({ name: 'presence_penalty', type: 'decimal', precision: 3, scale: 2, nullable: true })
presencePenalty: number;
@Column({ name: 'required_variables', type: 'text', array: true, default: [] })
requiredVariables: string[];
@Column({ name: 'variable_schema', type: 'jsonb', default: {} })
variableSchema: Record<string, any>;
@Column({ name: 'functions', type: 'jsonb', default: [] })
functions: Record<string, any>[];
@Column({ name: 'version', type: 'int', default: 1 })
version: number;
@Column({ name: 'is_latest', type: 'boolean', default: true })
isLatest: boolean;
@Column({ name: 'parent_version_id', type: 'uuid', nullable: true })
parentVersionId: string;
@Index()
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'is_system', type: 'boolean', default: false })
isSystem: boolean;
@Column({ name: 'usage_count', type: 'int', default: 0 })
usageCount: number;
@Column({ name: 'avg_tokens_used', type: 'int', nullable: true })
avgTokensUsed: number;
@Column({ name: 'avg_latency_ms', type: 'int', nullable: true })
avgLatencyMs: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@ManyToOne(() => AIModel, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'model_id' })
model: AIModel;
}

View File

@ -0,0 +1,120 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
Unique,
} from 'typeorm';
export type UsageType = 'chat' | 'completion' | 'embedding' | 'image';
@Entity({ name: 'usage_logs', schema: 'ai' })
export class AIUsageLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId: string;
@Index()
@Column({ name: 'model_id', type: 'uuid', nullable: true })
modelId: string;
@Column({ name: 'model_name', type: 'varchar', length: 100, nullable: true })
modelName: string;
@Column({ name: 'provider', type: 'varchar', length: 50, nullable: true })
provider: string;
@Column({ name: 'usage_type', type: 'varchar', length: 30 })
usageType: UsageType;
@Column({ name: 'prompt_tokens', type: 'int', default: 0 })
promptTokens: number;
@Column({ name: 'completion_tokens', type: 'int', default: 0 })
completionTokens: number;
@Column({ name: 'total_tokens', type: 'int', default: 0 })
totalTokens: number;
@Column({ name: 'cost', type: 'decimal', precision: 10, scale: 6, default: 0 })
cost: number;
@Column({ name: 'conversation_id', type: 'uuid', nullable: true })
conversationId: string;
@Column({ name: 'completion_id', type: 'uuid', nullable: true })
completionId: string;
@Column({ name: 'request_id', type: 'varchar', length: 255, nullable: true })
requestId: string;
@Index()
@Column({ name: 'usage_date', type: 'date', default: () => 'CURRENT_DATE' })
usageDate: Date;
@Index()
@Column({ name: 'usage_month', type: 'varchar', length: 7, nullable: true })
usageMonth: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}
@Entity({ name: 'tenant_quotas', schema: 'ai' })
@Unique(['tenantId', 'quotaMonth'])
export class AITenantQuota {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'monthly_token_limit', type: 'int', nullable: true })
monthlyTokenLimit: number;
@Column({ name: 'monthly_request_limit', type: 'int', nullable: true })
monthlyRequestLimit: number;
@Column({ name: 'monthly_cost_limit', type: 'decimal', precision: 10, scale: 2, nullable: true })
monthlyCostLimit: number;
@Column({ name: 'current_tokens', type: 'int', default: 0 })
currentTokens: number;
@Column({ name: 'current_requests', type: 'int', default: 0 })
currentRequests: number;
@Column({ name: 'current_cost', type: 'decimal', precision: 10, scale: 4, default: 0 })
currentCost: number;
@Index()
@Column({ name: 'quota_month', type: 'varchar', length: 7 })
quotaMonth: string;
@Column({ name: 'is_exceeded', type: 'boolean', default: false })
isExceeded: boolean;
@Column({ name: 'exceeded_at', type: 'timestamptz', nullable: true })
exceededAt: Date;
@Column({ name: 'alert_threshold_percent', type: 'int', default: 80 })
alertThresholdPercent: number;
@Column({ name: 'alert_sent_at', type: 'timestamptz', nullable: true })
alertSentAt: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

5
src/modules/ai/index.ts Normal file
View File

@ -0,0 +1,5 @@
export { AIModule, AIModuleOptions } from './ai.module';
export * from './entities';
export * from './services';
export * from './controllers';
export * from './dto';

View File

@ -0,0 +1,384 @@
import { Repository, FindOptionsWhere, LessThan, MoreThanOrEqual } from 'typeorm';
import { AIModel, AIConversation, AIMessage, AIPrompt, AIUsageLog, AITenantQuota } from '../entities';
export interface ConversationFilters {
userId?: string;
modelId?: string;
status?: string;
startDate?: Date;
endDate?: Date;
}
export class AIService {
constructor(
private readonly modelRepository: Repository<AIModel>,
private readonly conversationRepository: Repository<AIConversation>,
private readonly messageRepository: Repository<AIMessage>,
private readonly promptRepository: Repository<AIPrompt>,
private readonly usageLogRepository: Repository<AIUsageLog>,
private readonly quotaRepository: Repository<AITenantQuota>
) {}
// ============================================
// MODELS
// ============================================
async findAllModels(): Promise<AIModel[]> {
return this.modelRepository.find({
where: { isActive: true },
order: { provider: 'ASC', name: 'ASC' },
});
}
async findModel(id: string): Promise<AIModel | null> {
return this.modelRepository.findOne({ where: { id } });
}
async findModelByCode(code: string): Promise<AIModel | null> {
return this.modelRepository.findOne({ where: { code } });
}
async findModelsByProvider(provider: string): Promise<AIModel[]> {
return this.modelRepository.find({
where: { provider: provider as any, isActive: true },
order: { name: 'ASC' },
});
}
async findModelsByType(modelType: string): Promise<AIModel[]> {
return this.modelRepository.find({
where: { modelType: modelType as any, isActive: true },
order: { name: 'ASC' },
});
}
// ============================================
// PROMPTS
// ============================================
async findAllPrompts(tenantId?: string): Promise<AIPrompt[]> {
if (tenantId) {
return this.promptRepository.find({
where: [{ tenantId, isActive: true }, { isSystem: true, isActive: true }],
order: { category: 'ASC', name: 'ASC' },
});
}
return this.promptRepository.find({
where: { isActive: true },
order: { category: 'ASC', name: 'ASC' },
});
}
async findPrompt(id: string): Promise<AIPrompt | null> {
return this.promptRepository.findOne({ where: { id } });
}
async findPromptByCode(code: string, tenantId?: string): Promise<AIPrompt | null> {
if (tenantId) {
// Try tenant-specific first, then system prompt
const tenantPrompt = await this.promptRepository.findOne({
where: { code, tenantId, isActive: true },
});
if (tenantPrompt) return tenantPrompt;
return this.promptRepository.findOne({
where: { code, isSystem: true, isActive: true },
});
}
return this.promptRepository.findOne({ where: { code, isActive: true } });
}
async createPrompt(
tenantId: string,
data: Partial<AIPrompt>,
createdBy?: string
): Promise<AIPrompt> {
const prompt = this.promptRepository.create({
...data,
tenantId,
createdBy,
version: 1,
});
return this.promptRepository.save(prompt);
}
async updatePrompt(
id: string,
data: Partial<AIPrompt>,
updatedBy?: string
): Promise<AIPrompt | null> {
const prompt = await this.findPrompt(id);
if (!prompt) return null;
if (prompt.isSystem) {
throw new Error('Cannot update system prompts');
}
Object.assign(prompt, data, { updatedBy, version: prompt.version + 1 });
return this.promptRepository.save(prompt);
}
async incrementPromptUsage(id: string): Promise<void> {
await this.promptRepository
.createQueryBuilder()
.update()
.set({
usageCount: () => 'usage_count + 1',
lastUsedAt: new Date(),
})
.where('id = :id', { id })
.execute();
}
// ============================================
// CONVERSATIONS
// ============================================
async findConversations(
tenantId: string,
filters: ConversationFilters = {},
limit: number = 50
): Promise<AIConversation[]> {
const where: FindOptionsWhere<AIConversation> = { tenantId };
if (filters.userId) where.userId = filters.userId;
if (filters.modelId) where.modelId = filters.modelId;
if (filters.status) where.status = filters.status as any;
return this.conversationRepository.find({
where,
order: { updatedAt: 'DESC' },
take: limit,
});
}
async findConversation(id: string): Promise<AIConversation | null> {
return this.conversationRepository.findOne({
where: { id },
relations: ['messages'],
});
}
async findUserConversations(
tenantId: string,
userId: string,
limit: number = 20
): Promise<AIConversation[]> {
return this.conversationRepository.find({
where: { tenantId, userId },
order: { updatedAt: 'DESC' },
take: limit,
});
}
async createConversation(
tenantId: string,
userId: string,
data: Partial<AIConversation>
): Promise<AIConversation> {
const conversation = this.conversationRepository.create({
...data,
tenantId,
userId,
status: 'active',
});
return this.conversationRepository.save(conversation);
}
async updateConversation(
id: string,
data: Partial<AIConversation>
): Promise<AIConversation | null> {
const conversation = await this.conversationRepository.findOne({ where: { id } });
if (!conversation) return null;
Object.assign(conversation, data);
return this.conversationRepository.save(conversation);
}
async archiveConversation(id: string): Promise<boolean> {
const result = await this.conversationRepository.update(id, { status: 'archived' });
return (result.affected ?? 0) > 0;
}
// ============================================
// MESSAGES
// ============================================
async findMessages(conversationId: string): Promise<AIMessage[]> {
return this.messageRepository.find({
where: { conversationId },
order: { createdAt: 'ASC' },
});
}
async addMessage(conversationId: string, data: Partial<AIMessage>): Promise<AIMessage> {
const message = this.messageRepository.create({
...data,
conversationId,
});
const savedMessage = await this.messageRepository.save(message);
// Update conversation
await this.conversationRepository
.createQueryBuilder()
.update()
.set({
messageCount: () => 'message_count + 1',
totalTokens: () => `total_tokens + ${data.totalTokens || 0}`,
updatedAt: new Date(),
})
.where('id = :id', { id: conversationId })
.execute();
return savedMessage;
}
async getConversationTokenCount(conversationId: string): Promise<number> {
const result = await this.messageRepository
.createQueryBuilder('message')
.select('SUM(message.total_tokens)', 'total')
.where('message.conversation_id = :conversationId', { conversationId })
.getRawOne();
return parseInt(result?.total) || 0;
}
// ============================================
// USAGE & QUOTAS
// ============================================
async logUsage(tenantId: string, data: Partial<AIUsageLog>): Promise<AIUsageLog> {
const log = this.usageLogRepository.create({
...data,
tenantId,
});
return this.usageLogRepository.save(log);
}
async getUsageStats(
tenantId: string,
startDate: Date,
endDate: Date
): Promise<{
totalRequests: number;
totalInputTokens: number;
totalOutputTokens: number;
totalCost: number;
byModel: Record<string, { requests: number; tokens: number; cost: number }>;
}> {
const stats = await this.usageLogRepository
.createQueryBuilder('log')
.select('COUNT(*)', 'totalRequests')
.addSelect('SUM(log.input_tokens)', 'totalInputTokens')
.addSelect('SUM(log.output_tokens)', 'totalOutputTokens')
.addSelect('SUM(log.cost_usd)', 'totalCost')
.where('log.tenant_id = :tenantId', { tenantId })
.andWhere('log.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
.getRawOne();
const byModelStats = await this.usageLogRepository
.createQueryBuilder('log')
.select('log.model_id', 'modelId')
.addSelect('COUNT(*)', 'requests')
.addSelect('SUM(log.input_tokens + log.output_tokens)', 'tokens')
.addSelect('SUM(log.cost_usd)', 'cost')
.where('log.tenant_id = :tenantId', { tenantId })
.andWhere('log.created_at BETWEEN :startDate AND :endDate', { startDate, endDate })
.groupBy('log.model_id')
.getRawMany();
const byModel: Record<string, { requests: number; tokens: number; cost: number }> = {};
for (const stat of byModelStats) {
byModel[stat.modelId] = {
requests: parseInt(stat.requests) || 0,
tokens: parseInt(stat.tokens) || 0,
cost: parseFloat(stat.cost) || 0,
};
}
return {
totalRequests: parseInt(stats?.totalRequests) || 0,
totalInputTokens: parseInt(stats?.totalInputTokens) || 0,
totalOutputTokens: parseInt(stats?.totalOutputTokens) || 0,
totalCost: parseFloat(stats?.totalCost) || 0,
byModel,
};
}
async getTenantQuota(tenantId: string): Promise<AITenantQuota | null> {
return this.quotaRepository.findOne({ where: { tenantId } });
}
async updateTenantQuota(
tenantId: string,
data: Partial<AITenantQuota>
): Promise<AITenantQuota> {
let quota = await this.getTenantQuota(tenantId);
if (!quota) {
quota = this.quotaRepository.create({
tenantId,
...data,
});
} else {
Object.assign(quota, data);
}
return this.quotaRepository.save(quota);
}
async incrementQuotaUsage(
tenantId: string,
requestCount: number,
tokenCount: number,
costUsd: number
): Promise<void> {
await this.quotaRepository
.createQueryBuilder()
.update()
.set({
currentRequestsMonth: () => `current_requests_month + ${requestCount}`,
currentTokensMonth: () => `current_tokens_month + ${tokenCount}`,
currentSpendMonth: () => `current_spend_month + ${costUsd}`,
})
.where('tenant_id = :tenantId', { tenantId })
.execute();
}
async checkQuotaAvailable(tenantId: string): Promise<{
available: boolean;
reason?: string;
}> {
const quota = await this.getTenantQuota(tenantId);
if (!quota) return { available: true };
if (quota.maxRequestsPerMonth && quota.currentRequestsMonth >= quota.maxRequestsPerMonth) {
return { available: false, reason: 'Monthly request limit reached' };
}
if (quota.maxTokensPerMonth && quota.currentTokensMonth >= quota.maxTokensPerMonth) {
return { available: false, reason: 'Monthly token limit reached' };
}
if (quota.maxSpendPerMonth && quota.currentSpendMonth >= quota.maxSpendPerMonth) {
return { available: false, reason: 'Monthly spend limit reached' };
}
return { available: true };
}
async resetMonthlyQuotas(): Promise<number> {
const result = await this.quotaRepository.update(
{},
{
currentRequestsMonth: 0,
currentTokensMonth: 0,
currentSpendMonth: 0,
lastResetAt: new Date(),
}
);
return result.affected ?? 0;
}
}

View File

@ -0,0 +1 @@
export { AIService, ConversationFilters } from './ai.service';

View File

@ -0,0 +1,70 @@
import { Router } from 'express';
import { DataSource } from 'typeorm';
import { AuditService } from './services';
import { AuditController } from './controllers';
import {
AuditLog,
EntityChange,
LoginHistory,
SensitiveDataAccess,
DataExport,
PermissionChange,
ConfigChange,
} from './entities';
export interface AuditModuleOptions {
dataSource: DataSource;
basePath?: string;
}
export class AuditModule {
public router: Router;
public auditService: AuditService;
private dataSource: DataSource;
private basePath: string;
constructor(options: AuditModuleOptions) {
this.dataSource = options.dataSource;
this.basePath = options.basePath || '';
this.router = Router();
this.initializeServices();
this.initializeRoutes();
}
private initializeServices(): void {
const auditLogRepository = this.dataSource.getRepository(AuditLog);
const entityChangeRepository = this.dataSource.getRepository(EntityChange);
const loginHistoryRepository = this.dataSource.getRepository(LoginHistory);
const sensitiveDataAccessRepository = this.dataSource.getRepository(SensitiveDataAccess);
const dataExportRepository = this.dataSource.getRepository(DataExport);
const permissionChangeRepository = this.dataSource.getRepository(PermissionChange);
const configChangeRepository = this.dataSource.getRepository(ConfigChange);
this.auditService = new AuditService(
auditLogRepository,
entityChangeRepository,
loginHistoryRepository,
sensitiveDataAccessRepository,
dataExportRepository,
permissionChangeRepository,
configChangeRepository
);
}
private initializeRoutes(): void {
const auditController = new AuditController(this.auditService);
this.router.use(`${this.basePath}/audit`, auditController.router);
}
static getEntities(): Function[] {
return [
AuditLog,
EntityChange,
LoginHistory,
SensitiveDataAccess,
DataExport,
PermissionChange,
ConfigChange,
];
}
}

View File

@ -0,0 +1,342 @@
import { Request, Response, NextFunction, Router } from 'express';
import { AuditService, AuditLogFilters } from '../services/audit.service';
export class AuditController {
public router: Router;
constructor(private readonly auditService: AuditService) {
this.router = Router();
this.initializeRoutes();
}
private initializeRoutes(): void {
// Audit Logs
this.router.get('/logs', this.findAuditLogs.bind(this));
this.router.get('/logs/entity/:entityType/:entityId', this.findAuditLogsByEntity.bind(this));
this.router.post('/logs', this.createAuditLog.bind(this));
// Entity Changes
this.router.get('/changes/:entityType/:entityId', this.findEntityChanges.bind(this));
this.router.get('/changes/:entityType/:entityId/version/:version', this.getEntityVersion.bind(this));
this.router.post('/changes', this.createEntityChange.bind(this));
// Login History
this.router.get('/logins/user/:userId', this.findLoginHistory.bind(this));
this.router.get('/logins/user/:userId/active-sessions', this.getActiveSessionsCount.bind(this));
this.router.post('/logins', this.createLoginHistory.bind(this));
this.router.post('/logins/:sessionId/logout', this.markSessionLogout.bind(this));
// Sensitive Data Access
this.router.get('/sensitive-access', this.findSensitiveDataAccess.bind(this));
this.router.post('/sensitive-access', this.logSensitiveDataAccess.bind(this));
// Data Exports
this.router.get('/exports', this.findUserDataExports.bind(this));
this.router.get('/exports/:id', this.findDataExport.bind(this));
this.router.post('/exports', this.createDataExport.bind(this));
this.router.patch('/exports/:id/status', this.updateDataExportStatus.bind(this));
// Permission Changes
this.router.get('/permission-changes', this.findPermissionChanges.bind(this));
this.router.post('/permission-changes', this.logPermissionChange.bind(this));
// Config Changes
this.router.get('/config-changes', this.findConfigChanges.bind(this));
this.router.post('/config-changes', this.logConfigChange.bind(this));
}
// ============================================
// AUDIT LOGS
// ============================================
private async findAuditLogs(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const filters: AuditLogFilters = {
userId: req.query.userId as string,
entityType: req.query.entityType as string,
action: req.query.action as string,
category: req.query.category as string,
ipAddress: req.query.ipAddress as string,
};
if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string);
if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string);
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 50;
const result = await this.auditService.findAuditLogs(tenantId, filters, { page, limit });
res.json({ data: result.data, total: result.total, page, limit });
} catch (error) {
next(error);
}
}
private async findAuditLogsByEntity(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { entityType, entityId } = req.params;
const logs = await this.auditService.findAuditLogsByEntity(tenantId, entityType, entityId);
res.json({ data: logs, total: logs.length });
} catch (error) {
next(error);
}
}
private async createAuditLog(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const log = await this.auditService.createAuditLog(tenantId, req.body);
res.status(201).json({ data: log });
} catch (error) {
next(error);
}
}
// ============================================
// ENTITY CHANGES
// ============================================
private async findEntityChanges(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { entityType, entityId } = req.params;
const changes = await this.auditService.findEntityChanges(tenantId, entityType, entityId);
res.json({ data: changes, total: changes.length });
} catch (error) {
next(error);
}
}
private async getEntityVersion(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { entityType, entityId, version } = req.params;
const change = await this.auditService.getEntityVersion(
tenantId,
entityType,
entityId,
parseInt(version)
);
if (!change) {
res.status(404).json({ error: 'Version not found' });
return;
}
res.json({ data: change });
} catch (error) {
next(error);
}
}
private async createEntityChange(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const change = await this.auditService.createEntityChange(tenantId, req.body);
res.status(201).json({ data: change });
} catch (error) {
next(error);
}
}
// ============================================
// LOGIN HISTORY
// ============================================
private async findLoginHistory(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { userId } = req.params;
const limit = parseInt(req.query.limit as string) || 20;
const history = await this.auditService.findLoginHistory(userId, tenantId, limit);
res.json({ data: history, total: history.length });
} catch (error) {
next(error);
}
}
private async getActiveSessionsCount(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { userId } = req.params;
const count = await this.auditService.getActiveSessionsCount(userId);
res.json({ data: { activeSessions: count } });
} catch (error) {
next(error);
}
}
private async createLoginHistory(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const login = await this.auditService.createLoginHistory(req.body);
res.status(201).json({ data: login });
} catch (error) {
next(error);
}
}
private async markSessionLogout(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { sessionId } = req.params;
const marked = await this.auditService.markSessionLogout(sessionId);
if (!marked) {
res.status(404).json({ error: 'Session not found' });
return;
}
res.json({ data: { success: true } });
} catch (error) {
next(error);
}
}
// ============================================
// SENSITIVE DATA ACCESS
// ============================================
private async findSensitiveDataAccess(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const filters: { userId?: string; dataType?: string; startDate?: Date; endDate?: Date } = {
userId: req.query.userId as string,
dataType: req.query.dataType as string,
};
if (req.query.startDate) filters.startDate = new Date(req.query.startDate as string);
if (req.query.endDate) filters.endDate = new Date(req.query.endDate as string);
const access = await this.auditService.findSensitiveDataAccess(tenantId, filters);
res.json({ data: access, total: access.length });
} catch (error) {
next(error);
}
}
private async logSensitiveDataAccess(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const access = await this.auditService.logSensitiveDataAccess(tenantId, req.body);
res.status(201).json({ data: access });
} catch (error) {
next(error);
}
}
// ============================================
// DATA EXPORTS
// ============================================
private async findUserDataExports(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const exports = await this.auditService.findUserDataExports(tenantId, userId);
res.json({ data: exports, total: exports.length });
} catch (error) {
next(error);
}
}
private async findDataExport(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const exportRecord = await this.auditService.findDataExport(id);
if (!exportRecord) {
res.status(404).json({ error: 'Export not found' });
return;
}
res.json({ data: exportRecord });
} catch (error) {
next(error);
}
}
private async createDataExport(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const exportRecord = await this.auditService.createDataExport(tenantId, req.body);
res.status(201).json({ data: exportRecord });
} catch (error) {
next(error);
}
}
private async updateDataExportStatus(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const { status, ...updates } = req.body;
const exportRecord = await this.auditService.updateDataExportStatus(id, status, updates);
if (!exportRecord) {
res.status(404).json({ error: 'Export not found' });
return;
}
res.json({ data: exportRecord });
} catch (error) {
next(error);
}
}
// ============================================
// PERMISSION CHANGES
// ============================================
private async findPermissionChanges(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const targetUserId = req.query.targetUserId as string;
const changes = await this.auditService.findPermissionChanges(tenantId, targetUserId);
res.json({ data: changes, total: changes.length });
} catch (error) {
next(error);
}
}
private async logPermissionChange(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const change = await this.auditService.logPermissionChange(tenantId, req.body);
res.status(201).json({ data: change });
} catch (error) {
next(error);
}
}
// ============================================
// CONFIG CHANGES
// ============================================
private async findConfigChanges(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const configType = req.query.configType as string;
const changes = await this.auditService.findConfigChanges(tenantId, configType);
res.json({ data: changes, total: changes.length });
} catch (error) {
next(error);
}
}
private async logConfigChange(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const change = await this.auditService.logConfigChange(tenantId, req.body);
res.status(201).json({ data: change });
} catch (error) {
next(error);
}
}
}

View File

@ -0,0 +1 @@
export { AuditController } from './audit.controller';

View File

@ -0,0 +1,346 @@
import {
IsString,
IsOptional,
IsBoolean,
IsNumber,
IsArray,
IsObject,
IsUUID,
IsEnum,
IsIP,
MaxLength,
MinLength,
} from 'class-validator';
// ============================================
// AUDIT LOG DTOs
// ============================================
export class CreateAuditLogDto {
@IsOptional()
@IsUUID()
userId?: string;
@IsString()
@MaxLength(20)
action: string;
@IsOptional()
@IsString()
@MaxLength(30)
category?: string;
@IsOptional()
@IsString()
@MaxLength(100)
entityType?: string;
@IsOptional()
@IsUUID()
entityId?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsObject()
oldValues?: Record<string, any>;
@IsOptional()
@IsObject()
newValues?: Record<string, any>;
@IsOptional()
@IsObject()
metadata?: Record<string, any>;
@IsOptional()
@IsString()
@MaxLength(45)
ipAddress?: string;
@IsOptional()
@IsString()
@MaxLength(500)
userAgent?: string;
@IsOptional()
@IsString()
@MaxLength(100)
requestId?: string;
}
// ============================================
// ENTITY CHANGE DTOs
// ============================================
export class CreateEntityChangeDto {
@IsString()
@MaxLength(100)
entityType: string;
@IsUUID()
entityId: string;
@IsString()
@MaxLength(20)
changeType: string;
@IsOptional()
@IsUUID()
changedBy?: string;
@IsNumber()
version: number;
@IsOptional()
@IsArray()
@IsString({ each: true })
changedFields?: string[];
@IsOptional()
@IsObject()
previousData?: Record<string, any>;
@IsOptional()
@IsObject()
newData?: Record<string, any>;
@IsOptional()
@IsString()
changeReason?: string;
}
// ============================================
// LOGIN HISTORY DTOs
// ============================================
export class CreateLoginHistoryDto {
@IsUUID()
userId: string;
@IsOptional()
@IsUUID()
tenantId?: string;
@IsString()
@MaxLength(20)
status: string;
@IsOptional()
@IsString()
@MaxLength(30)
authMethod?: string;
@IsOptional()
@IsString()
@MaxLength(30)
mfaMethod?: string;
@IsOptional()
@IsBoolean()
mfaUsed?: boolean;
@IsOptional()
@IsString()
@MaxLength(45)
ipAddress?: string;
@IsOptional()
@IsString()
@MaxLength(500)
userAgent?: string;
@IsOptional()
@IsString()
@MaxLength(100)
deviceFingerprint?: string;
@IsOptional()
@IsString()
@MaxLength(100)
location?: string;
@IsOptional()
@IsString()
@MaxLength(100)
sessionId?: string;
@IsOptional()
@IsString()
failureReason?: string;
}
// ============================================
// SENSITIVE DATA ACCESS DTOs
// ============================================
export class CreateSensitiveDataAccessDto {
@IsUUID()
userId: string;
@IsString()
@MaxLength(50)
dataType: string;
@IsString()
@MaxLength(20)
accessType: string;
@IsOptional()
@IsString()
@MaxLength(100)
entityType?: string;
@IsOptional()
@IsUUID()
entityId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
fieldsAccessed?: string[];
@IsOptional()
@IsString()
accessReason?: string;
@IsOptional()
@IsBoolean()
wasExported?: boolean;
@IsOptional()
@IsString()
@MaxLength(45)
ipAddress?: string;
}
// ============================================
// DATA EXPORT DTOs
// ============================================
export class CreateDataExportDto {
@IsString()
@MaxLength(30)
exportType: string;
@IsString()
@MaxLength(20)
format: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
entities?: string[];
@IsOptional()
@IsObject()
filters?: Record<string, any>;
@IsOptional()
@IsArray()
@IsString({ each: true })
fields?: string[];
@IsOptional()
@IsString()
exportReason?: string;
}
export class UpdateDataExportStatusDto {
@IsString()
@MaxLength(20)
status: string;
@IsOptional()
@IsString()
filePath?: string;
@IsOptional()
@IsNumber()
fileSize?: number;
@IsOptional()
@IsNumber()
recordCount?: number;
@IsOptional()
@IsString()
errorMessage?: string;
}
// ============================================
// PERMISSION CHANGE DTOs
// ============================================
export class CreatePermissionChangeDto {
@IsUUID()
targetUserId: string;
@IsUUID()
changedBy: string;
@IsString()
@MaxLength(20)
changeType: string;
@IsString()
@MaxLength(30)
scope: string;
@IsOptional()
@IsString()
@MaxLength(100)
resourceType?: string;
@IsOptional()
@IsUUID()
resourceId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
previousPermissions?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
newPermissions?: string[];
@IsOptional()
@IsString()
changeReason?: string;
}
// ============================================
// CONFIG CHANGE DTOs
// ============================================
export class CreateConfigChangeDto {
@IsString()
@MaxLength(30)
configType: string;
@IsString()
@MaxLength(200)
configKey: string;
@IsUUID()
changedBy: string;
@IsNumber()
version: number;
@IsOptional()
@IsObject()
previousValue?: Record<string, any>;
@IsOptional()
@IsObject()
newValue?: Record<string, any>;
@IsOptional()
@IsString()
changeReason?: string;
}

View File

@ -0,0 +1,10 @@
export {
CreateAuditLogDto,
CreateEntityChangeDto,
CreateLoginHistoryDto,
CreateSensitiveDataAccessDto,
CreateDataExportDto,
UpdateDataExportStatusDto,
CreatePermissionChangeDto,
CreateConfigChangeDto,
} from './audit.dto';

View File

@ -0,0 +1,108 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
export type AuditAction = 'create' | 'read' | 'update' | 'delete' | 'login' | 'logout' | 'export';
export type AuditCategory = 'data' | 'auth' | 'system' | 'config' | 'billing';
export type AuditStatus = 'success' | 'failure' | 'partial';
@Entity({ name: 'audit_logs', schema: 'audit' })
export class AuditLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId: string;
@Column({ name: 'user_email', type: 'varchar', length: 255, nullable: true })
userEmail: string;
@Column({ name: 'user_name', type: 'varchar', length: 200, nullable: true })
userName: string;
@Column({ name: 'session_id', type: 'uuid', nullable: true })
sessionId: string;
@Column({ name: 'impersonator_id', type: 'uuid', nullable: true })
impersonatorId: string;
@Index()
@Column({ name: 'action', type: 'varchar', length: 50 })
action: AuditAction;
@Index()
@Column({ name: 'action_category', type: 'varchar', length: 50, nullable: true })
actionCategory: AuditCategory;
@Index()
@Column({ name: 'resource_type', type: 'varchar', length: 100 })
resourceType: string;
@Column({ name: 'resource_id', type: 'uuid', nullable: true })
resourceId: string;
@Column({ name: 'resource_name', type: 'varchar', length: 255, nullable: true })
resourceName: string;
@Column({ name: 'old_values', type: 'jsonb', nullable: true })
oldValues: Record<string, any>;
@Column({ name: 'new_values', type: 'jsonb', nullable: true })
newValues: Record<string, any>;
@Column({ name: 'changed_fields', type: 'text', array: true, nullable: true })
changedFields: string[];
@Column({ name: 'ip_address', type: 'inet', nullable: true })
ipAddress: string;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent: string;
@Column({ name: 'device_info', type: 'jsonb', default: {} })
deviceInfo: Record<string, any>;
@Column({ name: 'location', type: 'jsonb', default: {} })
location: Record<string, any>;
@Column({ name: 'request_id', type: 'varchar', length: 100, nullable: true })
requestId: string;
@Column({ name: 'request_method', type: 'varchar', length: 10, nullable: true })
requestMethod: string;
@Column({ name: 'request_path', type: 'text', nullable: true })
requestPath: string;
@Column({ name: 'request_params', type: 'jsonb', default: {} })
requestParams: Record<string, any>;
@Index()
@Column({ name: 'status', type: 'varchar', length: 20, default: 'success' })
status: AuditStatus;
@Column({ name: 'error_message', type: 'text', nullable: true })
errorMessage: string;
@Column({ name: 'duration_ms', type: 'int', nullable: true })
durationMs: number;
@Column({ name: 'metadata', type: 'jsonb', default: {} })
metadata: Record<string, any>;
@Column({ name: 'tags', type: 'text', array: true, default: [] })
tags: string[];
@Index()
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}

View File

@ -0,0 +1,47 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
} from 'typeorm';
export type ConfigType = 'tenant_settings' | 'user_settings' | 'system_settings' | 'feature_flags';
@Entity({ name: 'config_changes', schema: 'audit' })
export class ConfigChange {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
tenantId: string;
@Column({ name: 'changed_by', type: 'uuid' })
changedBy: string;
@Index()
@Column({ name: 'config_type', type: 'varchar', length: 50 })
configType: ConfigType;
@Column({ name: 'config_key', type: 'varchar', length: 100 })
configKey: string;
@Column({ name: 'config_path', type: 'text', nullable: true })
configPath: string;
@Column({ name: 'old_value', type: 'jsonb', nullable: true })
oldValue: Record<string, any>;
@Column({ name: 'new_value', type: 'jsonb', nullable: true })
newValue: Record<string, any>;
@Column({ name: 'reason', type: 'text', nullable: true })
reason: string;
@Column({ name: 'ticket_id', type: 'varchar', length: 50, nullable: true })
ticketId: string;
@Index()
@Column({ name: 'changed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
changedAt: Date;
}

View File

@ -0,0 +1,80 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
} from 'typeorm';
export type ExportType = 'report' | 'backup' | 'gdpr_request' | 'bulk_export';
export type ExportFormat = 'csv' | 'xlsx' | 'pdf' | 'json';
export type ExportStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'expired';
@Entity({ name: 'data_exports', schema: 'audit' })
export class DataExport {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({ name: 'export_type', type: 'varchar', length: 50 })
exportType: ExportType;
@Column({ name: 'export_format', type: 'varchar', length: 20, nullable: true })
exportFormat: ExportFormat;
@Column({ name: 'entity_types', type: 'text', array: true })
entityTypes: string[];
@Column({ name: 'filters', type: 'jsonb', default: {} })
filters: Record<string, any>;
@Column({ name: 'date_range_start', type: 'timestamptz', nullable: true })
dateRangeStart: Date;
@Column({ name: 'date_range_end', type: 'timestamptz', nullable: true })
dateRangeEnd: Date;
@Column({ name: 'record_count', type: 'int', nullable: true })
recordCount: number;
@Column({ name: 'file_size_bytes', type: 'bigint', nullable: true })
fileSizeBytes: number;
@Column({ name: 'file_hash', type: 'varchar', length: 64, nullable: true })
fileHash: string;
@Index()
@Column({ name: 'status', type: 'varchar', length: 20, default: 'pending' })
status: ExportStatus;
@Column({ name: 'download_url', type: 'text', nullable: true })
downloadUrl: string;
@Column({ name: 'download_expires_at', type: 'timestamptz', nullable: true })
downloadExpiresAt: Date;
@Column({ name: 'download_count', type: 'int', default: 0 })
downloadCount: number;
@Column({ name: 'ip_address', type: 'inet', nullable: true })
ipAddress: string;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent: string;
@Index()
@Column({ name: 'requested_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
requestedAt: Date;
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt: Date;
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
expiresAt: Date;
}

View File

@ -0,0 +1,55 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
} from 'typeorm';
export type ChangeType = 'create' | 'update' | 'delete' | 'restore';
@Entity({ name: 'entity_changes', schema: 'audit' })
export class EntityChange {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'entity_type', type: 'varchar', length: 100 })
entityType: string;
@Index()
@Column({ name: 'entity_id', type: 'uuid' })
entityId: string;
@Column({ name: 'entity_name', type: 'varchar', length: 255, nullable: true })
entityName: string;
@Column({ name: 'version', type: 'int', default: 1 })
version: number;
@Column({ name: 'previous_version', type: 'int', nullable: true })
previousVersion: number;
@Column({ name: 'data_snapshot', type: 'jsonb' })
dataSnapshot: Record<string, any>;
@Column({ name: 'changes', type: 'jsonb', default: [] })
changes: Record<string, any>[];
@Index()
@Column({ name: 'changed_by', type: 'uuid', nullable: true })
changedBy: string;
@Column({ name: 'change_reason', type: 'text', nullable: true })
changeReason: string;
@Column({ name: 'change_type', type: 'varchar', length: 20 })
changeType: ChangeType;
@Index()
@Column({ name: 'changed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
changedAt: Date;
}

View File

@ -0,0 +1,7 @@
export { AuditLog, AuditAction, AuditCategory, AuditStatus } from './audit-log.entity';
export { EntityChange, ChangeType } from './entity-change.entity';
export { LoginHistory, LoginStatus, AuthMethod, MfaMethod } from './login-history.entity';
export { SensitiveDataAccess, DataType, AccessType } from './sensitive-data-access.entity';
export { DataExport, ExportType, ExportFormat, ExportStatus } from './data-export.entity';
export { PermissionChange, PermissionChangeType, PermissionScope } from './permission-change.entity';
export { ConfigChange, ConfigType } from './config-change.entity';

View File

@ -0,0 +1,106 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
} from 'typeorm';
export type LoginStatus = 'success' | 'failed' | 'blocked' | 'mfa_required' | 'mfa_failed';
export type AuthMethod = 'password' | 'sso' | 'oauth' | 'mfa' | 'magic_link' | 'biometric';
export type MfaMethod = 'totp' | 'sms' | 'email' | 'push';
@Entity({ name: 'login_history', schema: 'audit' })
export class LoginHistory {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
tenantId: string;
@Index()
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId: string;
@Column({ name: 'email', type: 'varchar', length: 255, nullable: true })
email: string;
@Column({ name: 'username', type: 'varchar', length: 100, nullable: true })
username: string;
@Index()
@Column({ name: 'status', type: 'varchar', length: 20 })
status: LoginStatus;
@Column({ name: 'auth_method', type: 'varchar', length: 30, nullable: true })
authMethod: AuthMethod;
@Column({ name: 'oauth_provider', type: 'varchar', length: 30, nullable: true })
oauthProvider: string;
@Column({ name: 'mfa_method', type: 'varchar', length: 20, nullable: true })
mfaMethod: MfaMethod;
@Column({ name: 'mfa_verified', type: 'boolean', nullable: true })
mfaVerified: boolean;
@Column({ name: 'device_id', type: 'uuid', nullable: true })
deviceId: string;
@Column({ name: 'device_fingerprint', type: 'varchar', length: 255, nullable: true })
deviceFingerprint: string;
@Column({ name: 'device_type', type: 'varchar', length: 30, nullable: true })
deviceType: string;
@Column({ name: 'device_os', type: 'varchar', length: 50, nullable: true })
deviceOs: string;
@Column({ name: 'device_browser', type: 'varchar', length: 50, nullable: true })
deviceBrowser: string;
@Index()
@Column({ name: 'ip_address', type: 'inet', nullable: true })
ipAddress: string;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent: string;
@Column({ name: 'country_code', type: 'varchar', length: 2, nullable: true })
countryCode: string;
@Column({ name: 'city', type: 'varchar', length: 100, nullable: true })
city: string;
@Column({ name: 'latitude', type: 'decimal', precision: 10, scale: 8, nullable: true })
latitude: number;
@Column({ name: 'longitude', type: 'decimal', precision: 11, scale: 8, nullable: true })
longitude: number;
@Column({ name: 'risk_score', type: 'int', nullable: true })
riskScore: number;
@Column({ name: 'risk_factors', type: 'jsonb', default: [] })
riskFactors: string[];
@Index()
@Column({ name: 'is_suspicious', type: 'boolean', default: false })
isSuspicious: boolean;
@Column({ name: 'is_new_device', type: 'boolean', default: false })
isNewDevice: boolean;
@Column({ name: 'is_new_location', type: 'boolean', default: false })
isNewLocation: boolean;
@Column({ name: 'failure_reason', type: 'varchar', length: 100, nullable: true })
failureReason: string;
@Column({ name: 'failure_count', type: 'int', nullable: true })
failureCount: number;
@Index()
@Column({ name: 'attempted_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
attemptedAt: Date;
}

View File

@ -0,0 +1,63 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
} from 'typeorm';
export type PermissionChangeType = 'role_assigned' | 'role_revoked' | 'permission_granted' | 'permission_revoked';
export type PermissionScope = 'global' | 'tenant' | 'branch';
@Entity({ name: 'permission_changes', schema: 'audit' })
export class PermissionChange {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'changed_by', type: 'uuid' })
changedBy: string;
@Index()
@Column({ name: 'target_user_id', type: 'uuid' })
targetUserId: string;
@Column({ name: 'target_user_email', type: 'varchar', length: 255, nullable: true })
targetUserEmail: string;
@Column({ name: 'change_type', type: 'varchar', length: 30 })
changeType: PermissionChangeType;
@Column({ name: 'role_id', type: 'uuid', nullable: true })
roleId: string;
@Column({ name: 'role_code', type: 'varchar', length: 50, nullable: true })
roleCode: string;
@Column({ name: 'permission_id', type: 'uuid', nullable: true })
permissionId: string;
@Column({ name: 'permission_code', type: 'varchar', length: 100, nullable: true })
permissionCode: string;
@Column({ name: 'branch_id', type: 'uuid', nullable: true })
branchId: string;
@Column({ name: 'scope', type: 'varchar', length: 30, nullable: true })
scope: PermissionScope;
@Column({ name: 'previous_roles', type: 'text', array: true, nullable: true })
previousRoles: string[];
@Column({ name: 'previous_permissions', type: 'text', array: true, nullable: true })
previousPermissions: string[];
@Column({ name: 'reason', type: 'text', nullable: true })
reason: string;
@Index()
@Column({ name: 'changed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
changedAt: Date;
}

View File

@ -0,0 +1,62 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
Index,
} from 'typeorm';
export type DataType = 'pii' | 'financial' | 'medical' | 'credentials';
export type AccessType = 'view' | 'export' | 'modify' | 'decrypt';
@Entity({ name: 'sensitive_data_access', schema: 'audit' })
export class SensitiveDataAccess {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Column({ name: 'session_id', type: 'uuid', nullable: true })
sessionId: string;
@Index()
@Column({ name: 'data_type', type: 'varchar', length: 100 })
dataType: DataType;
@Column({ name: 'data_category', type: 'varchar', length: 100, nullable: true })
dataCategory: string;
@Column({ name: 'entity_type', type: 'varchar', length: 100, nullable: true })
entityType: string;
@Column({ name: 'entity_id', type: 'uuid', nullable: true })
entityId: string;
@Column({ name: 'access_type', type: 'varchar', length: 30 })
accessType: AccessType;
@Column({ name: 'access_reason', type: 'text', nullable: true })
accessReason: string;
@Column({ name: 'ip_address', type: 'inet', nullable: true })
ipAddress: string;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent: string;
@Index()
@Column({ name: 'was_authorized', type: 'boolean', default: true })
wasAuthorized: boolean;
@Column({ name: 'denial_reason', type: 'text', nullable: true })
denialReason: string;
@Index()
@Column({ name: 'accessed_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
accessedAt: Date;
}

View File

@ -0,0 +1,5 @@
export { AuditModule, AuditModuleOptions } from './audit.module';
export * from './entities';
export * from './services';
export * from './controllers';
export * from './dto';

View File

@ -0,0 +1,303 @@
import { Repository, FindOptionsWhere, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import {
AuditLog,
EntityChange,
LoginHistory,
SensitiveDataAccess,
DataExport,
PermissionChange,
ConfigChange,
} from '../entities';
export interface AuditLogFilters {
userId?: string;
entityType?: string;
action?: string;
category?: string;
startDate?: Date;
endDate?: Date;
ipAddress?: string;
}
export interface PaginationOptions {
page?: number;
limit?: number;
}
export class AuditService {
constructor(
private readonly auditLogRepository: Repository<AuditLog>,
private readonly entityChangeRepository: Repository<EntityChange>,
private readonly loginHistoryRepository: Repository<LoginHistory>,
private readonly sensitiveDataAccessRepository: Repository<SensitiveDataAccess>,
private readonly dataExportRepository: Repository<DataExport>,
private readonly permissionChangeRepository: Repository<PermissionChange>,
private readonly configChangeRepository: Repository<ConfigChange>
) {}
// ============================================
// AUDIT LOGS
// ============================================
async createAuditLog(tenantId: string, data: Partial<AuditLog>): Promise<AuditLog> {
const log = this.auditLogRepository.create({
...data,
tenantId,
});
return this.auditLogRepository.save(log);
}
async findAuditLogs(
tenantId: string,
filters: AuditLogFilters = {},
pagination: PaginationOptions = {}
): Promise<{ data: AuditLog[]; total: number }> {
const { page = 1, limit = 50 } = pagination;
const where: FindOptionsWhere<AuditLog> = { tenantId };
if (filters.userId) where.userId = filters.userId;
if (filters.entityType) where.entityType = filters.entityType;
if (filters.action) where.action = filters.action as any;
if (filters.category) where.category = filters.category as any;
if (filters.ipAddress) where.ipAddress = filters.ipAddress;
if (filters.startDate && filters.endDate) {
where.createdAt = Between(filters.startDate, filters.endDate);
} else if (filters.startDate) {
where.createdAt = MoreThanOrEqual(filters.startDate);
} else if (filters.endDate) {
where.createdAt = LessThanOrEqual(filters.endDate);
}
const [data, total] = await this.auditLogRepository.findAndCount({
where,
order: { createdAt: 'DESC' },
skip: (page - 1) * limit,
take: limit,
});
return { data, total };
}
async findAuditLogsByEntity(
tenantId: string,
entityType: string,
entityId: string
): Promise<AuditLog[]> {
return this.auditLogRepository.find({
where: { tenantId, entityType, entityId },
order: { createdAt: 'DESC' },
});
}
// ============================================
// ENTITY CHANGES
// ============================================
async createEntityChange(tenantId: string, data: Partial<EntityChange>): Promise<EntityChange> {
const change = this.entityChangeRepository.create({
...data,
tenantId,
});
return this.entityChangeRepository.save(change);
}
async findEntityChanges(
tenantId: string,
entityType: string,
entityId: string
): Promise<EntityChange[]> {
return this.entityChangeRepository.find({
where: { tenantId, entityType, entityId },
order: { changedAt: 'DESC' },
});
}
async getEntityVersion(
tenantId: string,
entityType: string,
entityId: string,
version: number
): Promise<EntityChange | null> {
return this.entityChangeRepository.findOne({
where: { tenantId, entityType, entityId, version },
});
}
// ============================================
// LOGIN HISTORY
// ============================================
async createLoginHistory(data: Partial<LoginHistory>): Promise<LoginHistory> {
const login = this.loginHistoryRepository.create(data);
return this.loginHistoryRepository.save(login);
}
async findLoginHistory(
userId: string,
tenantId?: string,
limit: number = 20
): Promise<LoginHistory[]> {
const where: FindOptionsWhere<LoginHistory> = { userId };
if (tenantId) where.tenantId = tenantId;
return this.loginHistoryRepository.find({
where,
order: { loginAt: 'DESC' },
take: limit,
});
}
async getActiveSessionsCount(userId: string): Promise<number> {
return this.loginHistoryRepository.count({
where: { userId, logoutAt: undefined, status: 'success' },
});
}
async markSessionLogout(sessionId: string): Promise<boolean> {
const result = await this.loginHistoryRepository.update(
{ sessionId },
{ logoutAt: new Date() }
);
return (result.affected ?? 0) > 0;
}
// ============================================
// SENSITIVE DATA ACCESS
// ============================================
async logSensitiveDataAccess(
tenantId: string,
data: Partial<SensitiveDataAccess>
): Promise<SensitiveDataAccess> {
const access = this.sensitiveDataAccessRepository.create({
...data,
tenantId,
});
return this.sensitiveDataAccessRepository.save(access);
}
async findSensitiveDataAccess(
tenantId: string,
filters: { userId?: string; dataType?: string; startDate?: Date; endDate?: Date } = {}
): Promise<SensitiveDataAccess[]> {
const where: FindOptionsWhere<SensitiveDataAccess> = { tenantId };
if (filters.userId) where.userId = filters.userId;
if (filters.dataType) where.dataType = filters.dataType as any;
if (filters.startDate && filters.endDate) {
where.accessedAt = Between(filters.startDate, filters.endDate);
}
return this.sensitiveDataAccessRepository.find({
where,
order: { accessedAt: 'DESC' },
take: 100,
});
}
// ============================================
// DATA EXPORTS
// ============================================
async createDataExport(tenantId: string, data: Partial<DataExport>): Promise<DataExport> {
const exportRecord = this.dataExportRepository.create({
...data,
tenantId,
status: 'pending',
});
return this.dataExportRepository.save(exportRecord);
}
async findDataExport(id: string): Promise<DataExport | null> {
return this.dataExportRepository.findOne({ where: { id } });
}
async findUserDataExports(tenantId: string, userId: string): Promise<DataExport[]> {
return this.dataExportRepository.find({
where: { tenantId, requestedBy: userId },
order: { requestedAt: 'DESC' },
});
}
async updateDataExportStatus(
id: string,
status: string,
updates: Partial<DataExport> = {}
): Promise<DataExport | null> {
const exportRecord = await this.findDataExport(id);
if (!exportRecord) return null;
exportRecord.status = status as any;
Object.assign(exportRecord, updates);
if (status === 'completed') {
exportRecord.completedAt = new Date();
}
return this.dataExportRepository.save(exportRecord);
}
// ============================================
// PERMISSION CHANGES
// ============================================
async logPermissionChange(
tenantId: string,
data: Partial<PermissionChange>
): Promise<PermissionChange> {
const change = this.permissionChangeRepository.create({
...data,
tenantId,
});
return this.permissionChangeRepository.save(change);
}
async findPermissionChanges(
tenantId: string,
targetUserId?: string
): Promise<PermissionChange[]> {
const where: FindOptionsWhere<PermissionChange> = { tenantId };
if (targetUserId) where.targetUserId = targetUserId;
return this.permissionChangeRepository.find({
where,
order: { changedAt: 'DESC' },
take: 100,
});
}
// ============================================
// CONFIG CHANGES
// ============================================
async logConfigChange(tenantId: string, data: Partial<ConfigChange>): Promise<ConfigChange> {
const change = this.configChangeRepository.create({
...data,
tenantId,
});
return this.configChangeRepository.save(change);
}
async findConfigChanges(tenantId: string, configType?: string): Promise<ConfigChange[]> {
const where: FindOptionsWhere<ConfigChange> = { tenantId };
if (configType) where.configType = configType as any;
return this.configChangeRepository.find({
where,
order: { changedAt: 'DESC' },
take: 100,
});
}
async getConfigVersion(
tenantId: string,
configKey: string,
version: number
): Promise<ConfigChange | null> {
return this.configChangeRepository.findOne({
where: { tenantId, configKey, version },
});
}
}

View File

@ -0,0 +1 @@
export { AuditService, AuditLogFilters, PaginationOptions } from './audit.service';

View File

@ -0,0 +1,60 @@
/**
* Billing Usage Module
*
* Module registration for billing and usage tracking
*/
import { Router } from 'express';
import { DataSource } from 'typeorm';
import {
SubscriptionPlansController,
SubscriptionsController,
UsageController,
InvoicesController,
} from './controllers';
export interface BillingUsageModuleOptions {
dataSource: DataSource;
basePath?: string;
}
export class BillingUsageModule {
public router: Router;
private subscriptionPlansController: SubscriptionPlansController;
private subscriptionsController: SubscriptionsController;
private usageController: UsageController;
private invoicesController: InvoicesController;
constructor(options: BillingUsageModuleOptions) {
const { dataSource, basePath = '/billing' } = options;
this.router = Router();
// Initialize controllers
this.subscriptionPlansController = new SubscriptionPlansController(dataSource);
this.subscriptionsController = new SubscriptionsController(dataSource);
this.usageController = new UsageController(dataSource);
this.invoicesController = new InvoicesController(dataSource);
// Register routes
this.router.use(`${basePath}/subscription-plans`, this.subscriptionPlansController.router);
this.router.use(`${basePath}/subscriptions`, this.subscriptionsController.router);
this.router.use(`${basePath}/usage`, this.usageController.router);
this.router.use(`${basePath}/invoices`, this.invoicesController.router);
}
/**
* Get all entities for this module (for TypeORM configuration)
*/
static getEntities() {
return [
require('./entities/subscription-plan.entity').SubscriptionPlan,
require('./entities/tenant-subscription.entity').TenantSubscription,
require('./entities/usage-tracking.entity').UsageTracking,
require('./entities/invoice.entity').Invoice,
require('./entities/invoice-item.entity').InvoiceItem,
];
}
}
export default BillingUsageModule;

View File

@ -0,0 +1,8 @@
/**
* Billing Usage Controllers Index
*/
export { SubscriptionPlansController } from './subscription-plans.controller';
export { SubscriptionsController } from './subscriptions.controller';
export { UsageController } from './usage.controller';
export { InvoicesController } from './invoices.controller';

View File

@ -0,0 +1,258 @@
/**
* Invoices Controller
*
* REST API endpoints for invoice management
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { InvoicesService } from '../services';
import {
CreateInvoiceDto,
UpdateInvoiceDto,
RecordPaymentDto,
VoidInvoiceDto,
RefundInvoiceDto,
GenerateInvoiceDto,
InvoiceFilterDto,
} from '../dto';
export class InvoicesController {
public router: Router;
private service: InvoicesService;
constructor(dataSource: DataSource) {
this.router = Router();
this.service = new InvoicesService(dataSource);
this.initializeRoutes();
}
private initializeRoutes(): void {
// Stats
this.router.get('/stats', this.getStats.bind(this));
// List and search
this.router.get('/', this.getAll.bind(this));
this.router.get('/tenant/:tenantId', this.getByTenant.bind(this));
this.router.get('/:id', this.getById.bind(this));
this.router.get('/number/:invoiceNumber', this.getByNumber.bind(this));
// Create
this.router.post('/', this.create.bind(this));
this.router.post('/generate', this.generate.bind(this));
// Update
this.router.put('/:id', this.update.bind(this));
// Actions
this.router.post('/:id/send', this.send.bind(this));
this.router.post('/:id/payment', this.recordPayment.bind(this));
this.router.post('/:id/void', this.void.bind(this));
this.router.post('/:id/refund', this.refund.bind(this));
// Batch operations
this.router.post('/mark-overdue', this.markOverdue.bind(this));
}
/**
* GET /invoices/stats
* Get invoice statistics
*/
private async getStats(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { tenantId } = req.query;
const stats = await this.service.getStats(tenantId as string);
res.json({ data: stats });
} catch (error) {
next(error);
}
}
/**
* GET /invoices
* Get all invoices with filters
*/
private async getAll(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const filter: InvoiceFilterDto = {
tenantId: req.query.tenantId as string,
status: req.query.status as any,
dateFrom: req.query.dateFrom ? new Date(req.query.dateFrom as string) : undefined,
dateTo: req.query.dateTo ? new Date(req.query.dateTo as string) : undefined,
overdue: req.query.overdue === 'true',
limit: req.query.limit ? parseInt(req.query.limit as string) : undefined,
offset: req.query.offset ? parseInt(req.query.offset as string) : undefined,
};
const result = await this.service.findAll(filter);
res.json(result);
} catch (error) {
next(error);
}
}
/**
* GET /invoices/tenant/:tenantId
* Get invoices for specific tenant
*/
private async getByTenant(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const result = await this.service.findAll({
tenantId: req.params.tenantId,
limit: req.query.limit ? parseInt(req.query.limit as string) : 50,
offset: req.query.offset ? parseInt(req.query.offset as string) : 0,
});
res.json(result);
} catch (error) {
next(error);
}
}
/**
* GET /invoices/:id
* Get invoice by ID
*/
private async getById(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const invoice = await this.service.findById(req.params.id);
if (!invoice) {
res.status(404).json({ error: 'Invoice not found' });
return;
}
res.json({ data: invoice });
} catch (error) {
next(error);
}
}
/**
* GET /invoices/number/:invoiceNumber
* Get invoice by number
*/
private async getByNumber(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const invoice = await this.service.findByNumber(req.params.invoiceNumber);
if (!invoice) {
res.status(404).json({ error: 'Invoice not found' });
return;
}
res.json({ data: invoice });
} catch (error) {
next(error);
}
}
/**
* POST /invoices
* Create invoice manually
*/
private async create(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: CreateInvoiceDto = req.body;
const invoice = await this.service.create(dto);
res.status(201).json({ data: invoice });
} catch (error) {
next(error);
}
}
/**
* POST /invoices/generate
* Generate invoice from subscription
*/
private async generate(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: GenerateInvoiceDto = req.body;
const invoice = await this.service.generateFromSubscription(dto);
res.status(201).json({ data: invoice });
} catch (error) {
next(error);
}
}
/**
* PUT /invoices/:id
* Update invoice
*/
private async update(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: UpdateInvoiceDto = req.body;
const invoice = await this.service.update(req.params.id, dto);
res.json({ data: invoice });
} catch (error) {
next(error);
}
}
/**
* POST /invoices/:id/send
* Send invoice to customer
*/
private async send(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const invoice = await this.service.send(req.params.id);
res.json({ data: invoice });
} catch (error) {
next(error);
}
}
/**
* POST /invoices/:id/payment
* Record payment on invoice
*/
private async recordPayment(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: RecordPaymentDto = req.body;
const invoice = await this.service.recordPayment(req.params.id, dto);
res.json({ data: invoice });
} catch (error) {
next(error);
}
}
/**
* POST /invoices/:id/void
* Void an invoice
*/
private async void(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: VoidInvoiceDto = req.body;
const invoice = await this.service.void(req.params.id, dto);
res.json({ data: invoice });
} catch (error) {
next(error);
}
}
/**
* POST /invoices/:id/refund
* Refund an invoice
*/
private async refund(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: RefundInvoiceDto = req.body;
const invoice = await this.service.refund(req.params.id, dto);
res.json({ data: invoice });
} catch (error) {
next(error);
}
}
/**
* POST /invoices/mark-overdue
* Mark all overdue invoices (scheduled job endpoint)
*/
private async markOverdue(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const count = await this.service.markOverdueInvoices();
res.json({ data: { markedOverdue: count } });
} catch (error) {
next(error);
}
}
}

View File

@ -0,0 +1,168 @@
/**
* Subscription Plans Controller
*
* REST API endpoints for subscription plan management
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { SubscriptionPlansService } from '../services';
import { CreateSubscriptionPlanDto, UpdateSubscriptionPlanDto } from '../dto';
export class SubscriptionPlansController {
public router: Router;
private service: SubscriptionPlansService;
constructor(dataSource: DataSource) {
this.router = Router();
this.service = new SubscriptionPlansService(dataSource);
this.initializeRoutes();
}
private initializeRoutes(): void {
// Public routes
this.router.get('/public', this.getPublicPlans.bind(this));
this.router.get('/:id/compare/:otherId', this.comparePlans.bind(this));
// Protected routes (require admin)
this.router.get('/', this.getAll.bind(this));
this.router.get('/:id', this.getById.bind(this));
this.router.post('/', this.create.bind(this));
this.router.put('/:id', this.update.bind(this));
this.router.delete('/:id', this.delete.bind(this));
this.router.patch('/:id/activate', this.activate.bind(this));
this.router.patch('/:id/deactivate', this.deactivate.bind(this));
}
/**
* GET /subscription-plans/public
* Get public plans for pricing page
*/
private async getPublicPlans(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const plans = await this.service.findPublicPlans();
res.json({ data: plans });
} catch (error) {
next(error);
}
}
/**
* GET /subscription-plans
* Get all plans (admin only)
*/
private async getAll(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { isActive, isPublic, planType } = req.query;
const plans = await this.service.findAll({
isActive: isActive !== undefined ? isActive === 'true' : undefined,
isPublic: isPublic !== undefined ? isPublic === 'true' : undefined,
planType: planType as any,
});
res.json({ data: plans });
} catch (error) {
next(error);
}
}
/**
* GET /subscription-plans/:id
* Get plan by ID
*/
private async getById(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const plan = await this.service.findById(req.params.id);
if (!plan) {
res.status(404).json({ error: 'Plan not found' });
return;
}
res.json({ data: plan });
} catch (error) {
next(error);
}
}
/**
* POST /subscription-plans
* Create new plan
*/
private async create(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: CreateSubscriptionPlanDto = req.body;
const plan = await this.service.create(dto);
res.status(201).json({ data: plan });
} catch (error) {
next(error);
}
}
/**
* PUT /subscription-plans/:id
* Update plan
*/
private async update(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: UpdateSubscriptionPlanDto = req.body;
const plan = await this.service.update(req.params.id, dto);
res.json({ data: plan });
} catch (error) {
next(error);
}
}
/**
* DELETE /subscription-plans/:id
* Delete plan (soft delete)
*/
private async delete(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
await this.service.delete(req.params.id);
res.status(204).send();
} catch (error) {
next(error);
}
}
/**
* PATCH /subscription-plans/:id/activate
* Activate plan
*/
private async activate(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const plan = await this.service.setActive(req.params.id, true);
res.json({ data: plan });
} catch (error) {
next(error);
}
}
/**
* PATCH /subscription-plans/:id/deactivate
* Deactivate plan
*/
private async deactivate(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const plan = await this.service.setActive(req.params.id, false);
res.json({ data: plan });
} catch (error) {
next(error);
}
}
/**
* GET /subscription-plans/:id/compare/:otherId
* Compare two plans
*/
private async comparePlans(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const comparison = await this.service.comparePlans(req.params.id, req.params.otherId);
res.json({ data: comparison });
} catch (error) {
next(error);
}
}
}

View File

@ -0,0 +1,232 @@
/**
* Subscriptions Controller
*
* REST API endpoints for tenant subscription management
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { SubscriptionsService } from '../services';
import {
CreateTenantSubscriptionDto,
UpdateTenantSubscriptionDto,
CancelSubscriptionDto,
ChangePlanDto,
SetPaymentMethodDto,
} from '../dto';
export class SubscriptionsController {
public router: Router;
private service: SubscriptionsService;
constructor(dataSource: DataSource) {
this.router = Router();
this.service = new SubscriptionsService(dataSource);
this.initializeRoutes();
}
private initializeRoutes(): void {
// Stats (admin)
this.router.get('/stats', this.getStats.bind(this));
// Tenant subscription
this.router.get('/tenant/:tenantId', this.getByTenant.bind(this));
this.router.post('/', this.create.bind(this));
this.router.put('/:id', this.update.bind(this));
// Subscription actions
this.router.post('/:id/cancel', this.cancel.bind(this));
this.router.post('/:id/reactivate', this.reactivate.bind(this));
this.router.post('/:id/change-plan', this.changePlan.bind(this));
this.router.post('/:id/payment-method', this.setPaymentMethod.bind(this));
this.router.post('/:id/renew', this.renew.bind(this));
this.router.post('/:id/suspend', this.suspend.bind(this));
this.router.post('/:id/activate', this.activate.bind(this));
// Alerts/expiring
this.router.get('/expiring', this.getExpiring.bind(this));
this.router.get('/trials-ending', this.getTrialsEnding.bind(this));
}
/**
* GET /subscriptions/stats
* Get subscription statistics
*/
private async getStats(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const stats = await this.service.getStats();
res.json({ data: stats });
} catch (error) {
next(error);
}
}
/**
* GET /subscriptions/tenant/:tenantId
* Get subscription by tenant ID
*/
private async getByTenant(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const subscription = await this.service.findByTenantId(req.params.tenantId);
if (!subscription) {
res.status(404).json({ error: 'Subscription not found' });
return;
}
res.json({ data: subscription });
} catch (error) {
next(error);
}
}
/**
* POST /subscriptions
* Create new subscription
*/
private async create(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: CreateTenantSubscriptionDto = req.body;
const subscription = await this.service.create(dto);
res.status(201).json({ data: subscription });
} catch (error) {
next(error);
}
}
/**
* PUT /subscriptions/:id
* Update subscription
*/
private async update(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: UpdateTenantSubscriptionDto = req.body;
const subscription = await this.service.update(req.params.id, dto);
res.json({ data: subscription });
} catch (error) {
next(error);
}
}
/**
* POST /subscriptions/:id/cancel
* Cancel subscription
*/
private async cancel(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: CancelSubscriptionDto = req.body;
const subscription = await this.service.cancel(req.params.id, dto);
res.json({ data: subscription });
} catch (error) {
next(error);
}
}
/**
* POST /subscriptions/:id/reactivate
* Reactivate cancelled subscription
*/
private async reactivate(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const subscription = await this.service.reactivate(req.params.id);
res.json({ data: subscription });
} catch (error) {
next(error);
}
}
/**
* POST /subscriptions/:id/change-plan
* Change subscription plan
*/
private async changePlan(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: ChangePlanDto = req.body;
const subscription = await this.service.changePlan(req.params.id, dto);
res.json({ data: subscription });
} catch (error) {
next(error);
}
}
/**
* POST /subscriptions/:id/payment-method
* Set payment method
*/
private async setPaymentMethod(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: SetPaymentMethodDto = req.body;
const subscription = await this.service.setPaymentMethod(req.params.id, dto);
res.json({ data: subscription });
} catch (error) {
next(error);
}
}
/**
* POST /subscriptions/:id/renew
* Renew subscription
*/
private async renew(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const subscription = await this.service.renew(req.params.id);
res.json({ data: subscription });
} catch (error) {
next(error);
}
}
/**
* POST /subscriptions/:id/suspend
* Suspend subscription
*/
private async suspend(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const subscription = await this.service.suspend(req.params.id);
res.json({ data: subscription });
} catch (error) {
next(error);
}
}
/**
* POST /subscriptions/:id/activate
* Activate subscription
*/
private async activate(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const subscription = await this.service.activate(req.params.id);
res.json({ data: subscription });
} catch (error) {
next(error);
}
}
/**
* GET /subscriptions/expiring
* Get subscriptions expiring soon
*/
private async getExpiring(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const days = parseInt(req.query.days as string) || 7;
const subscriptions = await this.service.findExpiringSoon(days);
res.json({ data: subscriptions });
} catch (error) {
next(error);
}
}
/**
* GET /subscriptions/trials-ending
* Get trials ending soon
*/
private async getTrialsEnding(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const days = parseInt(req.query.days as string) || 3;
const subscriptions = await this.service.findTrialsEndingSoon(days);
res.json({ data: subscriptions });
} catch (error) {
next(error);
}
}
}

View File

@ -0,0 +1,173 @@
/**
* Usage Controller
*
* REST API endpoints for usage tracking
*/
import { Router, Request, Response, NextFunction } from 'express';
import { DataSource } from 'typeorm';
import { UsageTrackingService } from '../services';
import { RecordUsageDto, UpdateUsageDto, IncrementUsageDto, UsageMetrics } from '../dto';
export class UsageController {
public router: Router;
private service: UsageTrackingService;
constructor(dataSource: DataSource) {
this.router = Router();
this.service = new UsageTrackingService(dataSource);
this.initializeRoutes();
}
private initializeRoutes(): void {
// Current usage
this.router.get('/tenant/:tenantId/current', this.getCurrentUsage.bind(this));
this.router.get('/tenant/:tenantId/summary', this.getUsageSummary.bind(this));
this.router.get('/tenant/:tenantId/limits', this.checkLimits.bind(this));
// Usage history
this.router.get('/tenant/:tenantId/history', this.getUsageHistory.bind(this));
this.router.get('/tenant/:tenantId/report', this.getUsageReport.bind(this));
// Record usage
this.router.post('/', this.recordUsage.bind(this));
this.router.put('/:id', this.updateUsage.bind(this));
this.router.post('/increment', this.incrementMetric.bind(this));
}
/**
* GET /usage/tenant/:tenantId/current
* Get current usage for tenant
*/
private async getCurrentUsage(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const usage = await this.service.getCurrentUsage(req.params.tenantId);
res.json({ data: usage });
} catch (error) {
next(error);
}
}
/**
* GET /usage/tenant/:tenantId/summary
* Get usage summary with limits
*/
private async getUsageSummary(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const summary = await this.service.getUsageSummary(req.params.tenantId);
res.json({ data: summary });
} catch (error) {
next(error);
}
}
/**
* GET /usage/tenant/:tenantId/limits
* Check if tenant exceeds limits
*/
private async checkLimits(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const limits = await this.service.checkLimits(req.params.tenantId);
res.json({ data: limits });
} catch (error) {
next(error);
}
}
/**
* GET /usage/tenant/:tenantId/history
* Get usage history
*/
private async getUsageHistory(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { startDate, endDate } = req.query;
if (!startDate || !endDate) {
res.status(400).json({ error: 'startDate and endDate are required' });
return;
}
const history = await this.service.getUsageHistory(
req.params.tenantId,
new Date(startDate as string),
new Date(endDate as string)
);
res.json({ data: history });
} catch (error) {
next(error);
}
}
/**
* GET /usage/tenant/:tenantId/report
* Get usage report
*/
private async getUsageReport(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { startDate, endDate, granularity } = req.query;
if (!startDate || !endDate) {
res.status(400).json({ error: 'startDate and endDate are required' });
return;
}
const report = await this.service.getUsageReport(
req.params.tenantId,
new Date(startDate as string),
new Date(endDate as string),
(granularity as 'daily' | 'weekly' | 'monthly') || 'monthly'
);
res.json({ data: report });
} catch (error) {
next(error);
}
}
/**
* POST /usage
* Record usage for period
*/
private async recordUsage(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: RecordUsageDto = req.body;
const usage = await this.service.recordUsage(dto);
res.status(201).json({ data: usage });
} catch (error) {
next(error);
}
}
/**
* PUT /usage/:id
* Update usage record
*/
private async updateUsage(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: UpdateUsageDto = req.body;
const usage = await this.service.update(req.params.id, dto);
res.json({ data: usage });
} catch (error) {
next(error);
}
}
/**
* POST /usage/increment
* Increment a specific metric
*/
private async incrementMetric(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const dto: IncrementUsageDto = req.body;
await this.service.incrementMetric(
dto.tenantId,
dto.metric as keyof UsageMetrics,
dto.amount || 1
);
res.json({ success: true });
} catch (error) {
next(error);
}
}
}

View File

@ -0,0 +1,75 @@
/**
* Create Invoice DTO
*/
import { InvoiceStatus, InvoiceItemType } from '../entities';
export class CreateInvoiceDto {
tenantId: string;
subscriptionId?: string;
invoiceDate?: Date;
periodStart: Date;
periodEnd: Date;
billingName?: string;
billingEmail?: string;
billingAddress?: Record<string, any>;
taxId?: string;
dueDate: Date;
currency?: string;
notes?: string;
internalNotes?: string;
items: CreateInvoiceItemDto[];
}
export class CreateInvoiceItemDto {
itemType: InvoiceItemType;
description: string;
quantity: number;
unitPrice: number;
discountPercent?: number;
metadata?: Record<string, any>;
}
export class UpdateInvoiceDto {
billingName?: string;
billingEmail?: string;
billingAddress?: Record<string, any>;
taxId?: string;
dueDate?: Date;
notes?: string;
internalNotes?: string;
}
export class RecordPaymentDto {
amount: number;
paymentMethod: string;
paymentReference?: string;
paymentDate?: Date;
}
export class VoidInvoiceDto {
reason: string;
}
export class RefundInvoiceDto {
amount?: number;
reason: string;
}
export class GenerateInvoiceDto {
tenantId: string;
subscriptionId: string;
periodStart: Date;
periodEnd: Date;
includeUsageCharges?: boolean;
}
export class InvoiceFilterDto {
tenantId?: string;
status?: InvoiceStatus;
dateFrom?: Date;
dateTo?: Date;
overdue?: boolean;
limit?: number;
offset?: number;
}

View File

@ -0,0 +1,41 @@
/**
* Create Subscription Plan DTO
*/
import { PlanType } from '../entities';
export class CreateSubscriptionPlanDto {
code: string;
name: string;
description?: string;
planType?: PlanType;
baseMonthlyPrice: number;
baseAnnualPrice?: number;
setupFee?: number;
maxUsers?: number;
maxBranches?: number;
storageGb?: number;
apiCallsMonthly?: number;
includedModules?: string[];
includedPlatforms?: string[];
features?: Record<string, boolean>;
isActive?: boolean;
isPublic?: boolean;
}
export class UpdateSubscriptionPlanDto {
name?: string;
description?: string;
baseMonthlyPrice?: number;
baseAnnualPrice?: number;
setupFee?: number;
maxUsers?: number;
maxBranches?: number;
storageGb?: number;
apiCallsMonthly?: number;
includedModules?: string[];
includedPlatforms?: string[];
features?: Record<string, boolean>;
isActive?: boolean;
isPublic?: boolean;
}

View File

@ -0,0 +1,57 @@
/**
* Create Tenant Subscription DTO
*/
import { BillingCycle, SubscriptionStatus } from '../entities';
export class CreateTenantSubscriptionDto {
tenantId: string;
planId: string;
billingCycle?: BillingCycle;
currentPeriodStart?: Date;
currentPeriodEnd?: Date;
billingEmail?: string;
billingName?: string;
billingAddress?: Record<string, any>;
taxId?: string;
currentPrice: number;
discountPercent?: number;
discountReason?: string;
contractedUsers?: number;
contractedBranches?: number;
autoRenew?: boolean;
// Trial
startWithTrial?: boolean;
trialDays?: number;
}
export class UpdateTenantSubscriptionDto {
planId?: string;
billingCycle?: BillingCycle;
billingEmail?: string;
billingName?: string;
billingAddress?: Record<string, any>;
taxId?: string;
currentPrice?: number;
discountPercent?: number;
discountReason?: string;
contractedUsers?: number;
contractedBranches?: number;
autoRenew?: boolean;
}
export class CancelSubscriptionDto {
reason?: string;
cancelImmediately?: boolean;
}
export class ChangePlanDto {
newPlanId: string;
effectiveDate?: Date;
prorateBilling?: boolean;
}
export class SetPaymentMethodDto {
paymentMethodId: string;
paymentProvider: string;
}

View File

@ -0,0 +1,8 @@
/**
* Billing Usage DTOs Index
*/
export * from './create-subscription-plan.dto';
export * from './create-subscription.dto';
export * from './create-invoice.dto';
export * from './usage-tracking.dto';

View File

@ -0,0 +1,90 @@
/**
* Usage Tracking DTO
*/
export class RecordUsageDto {
tenantId: string;
periodStart: Date;
periodEnd: Date;
activeUsers?: number;
peakConcurrentUsers?: number;
usersByProfile?: Record<string, number>;
usersByPlatform?: Record<string, number>;
activeBranches?: number;
storageUsedGb?: number;
documentsCount?: number;
apiCalls?: number;
apiErrors?: number;
salesCount?: number;
salesAmount?: number;
invoicesGenerated?: number;
mobileSessions?: number;
offlineSyncs?: number;
paymentTransactions?: number;
}
export class UpdateUsageDto {
activeUsers?: number;
peakConcurrentUsers?: number;
usersByProfile?: Record<string, number>;
usersByPlatform?: Record<string, number>;
activeBranches?: number;
storageUsedGb?: number;
documentsCount?: number;
apiCalls?: number;
apiErrors?: number;
salesCount?: number;
salesAmount?: number;
invoicesGenerated?: number;
mobileSessions?: number;
offlineSyncs?: number;
paymentTransactions?: number;
}
export class IncrementUsageDto {
tenantId: string;
metric: keyof UsageMetrics;
amount?: number;
}
export interface UsageMetrics {
apiCalls: number;
apiErrors: number;
salesCount: number;
salesAmount: number;
invoicesGenerated: number;
mobileSessions: number;
offlineSyncs: number;
paymentTransactions: number;
documentsCount: number;
storageUsedGb: number;
}
export class UsageReportDto {
tenantId: string;
startDate: Date;
endDate: Date;
granularity?: 'daily' | 'weekly' | 'monthly';
}
export class UsageSummaryDto {
tenantId: string;
currentUsers: number;
currentBranches: number;
currentStorageGb: number;
apiCallsThisMonth: number;
salesThisMonth: number;
salesAmountThisMonth: number;
limits: {
maxUsers: number;
maxBranches: number;
maxStorageGb: number;
maxApiCalls: number;
};
percentages: {
usersUsed: number;
branchesUsed: number;
storageUsed: number;
apiCallsUsed: number;
};
}

View File

@ -0,0 +1,72 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export type BillingAlertType =
| 'usage_limit'
| 'payment_due'
| 'payment_failed'
| 'trial_ending'
| 'subscription_ending';
export type AlertSeverity = 'info' | 'warning' | 'critical';
export type AlertStatus = 'active' | 'acknowledged' | 'resolved';
/**
* Entidad para alertas de facturacion y limites de uso.
* Mapea a billing.billing_alerts (DDL: 05-billing-usage.sql)
*/
@Entity({ name: 'billing_alerts', schema: 'billing' })
export class BillingAlert {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
// Tipo de alerta
@Index()
@Column({ name: 'alert_type', type: 'varchar', length: 30 })
alertType: BillingAlertType;
// Detalles
@Column({ type: 'varchar', length: 200 })
title: string;
@Column({ type: 'text', nullable: true })
message: string;
@Column({ type: 'varchar', length: 20, default: 'info' })
severity: AlertSeverity;
// Estado
@Index()
@Column({ type: 'varchar', length: 20, default: 'active' })
status: AlertStatus;
// Notificacion
@Column({ name: 'notified_at', type: 'timestamptz', nullable: true })
notifiedAt: Date;
@Column({ name: 'acknowledged_at', type: 'timestamptz', nullable: true })
acknowledgedAt: Date;
@Column({ name: 'acknowledged_by', type: 'uuid', nullable: true })
acknowledgedBy: string;
// Metadata
@Column({ type: 'jsonb', default: {} })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -0,0 +1,8 @@
export { SubscriptionPlan, PlanType } from './subscription-plan.entity';
export { TenantSubscription, BillingCycle, SubscriptionStatus } from './tenant-subscription.entity';
export { UsageTracking } from './usage-tracking.entity';
export { UsageEvent, EventCategory } from './usage-event.entity';
export { Invoice, InvoiceStatus } from './invoice.entity';
export { InvoiceItem, InvoiceItemType } from './invoice-item.entity';
export { BillingPaymentMethod, PaymentProvider, PaymentMethodType } from './payment-method.entity';
export { BillingAlert, BillingAlertType, AlertSeverity, AlertStatus } from './billing-alert.entity';

View File

@ -0,0 +1,65 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Invoice } from './invoice.entity';
export type InvoiceItemType = 'subscription' | 'user' | 'profile' | 'overage' | 'addon';
@Entity({ name: 'invoice_items', schema: 'billing' })
export class InvoiceItem {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'invoice_id', type: 'uuid' })
invoiceId: string;
// Descripcion
@Column({ type: 'varchar', length: 500 })
description: string;
@Index()
@Column({ name: 'item_type', type: 'varchar', length: 30 })
itemType: InvoiceItemType;
// Cantidades
@Column({ type: 'integer', default: 1 })
quantity: number;
@Column({ name: 'unit_price', type: 'decimal', precision: 12, scale: 2 })
unitPrice: number;
@Column({ type: 'decimal', precision: 12, scale: 2 })
subtotal: number;
// Detalles adicionales
@Column({ name: 'profile_code', type: 'varchar', length: 10, nullable: true })
profileCode: string;
@Column({ type: 'varchar', length: 20, nullable: true })
platform: string;
@Column({ name: 'period_start', type: 'date', nullable: true })
periodStart: Date;
@Column({ name: 'period_end', type: 'date', nullable: true })
periodEnd: Date;
// Metadata
@Column({ type: 'jsonb', default: {} })
metadata: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
// Relaciones
@ManyToOne(() => Invoice, (invoice) => invoice.items, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'invoice_id' })
invoice: Invoice;
}

View File

@ -0,0 +1,121 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
OneToMany,
} from 'typeorm';
import { InvoiceItem } from './invoice-item.entity';
export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'partial' | 'overdue' | 'void' | 'refunded';
@Entity({ name: 'invoices', schema: 'billing' })
export class Invoice {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'subscription_id', type: 'uuid', nullable: true })
subscriptionId: string;
// Numero de factura
@Index({ unique: true })
@Column({ name: 'invoice_number', type: 'varchar', length: 30 })
invoiceNumber: string;
@Index()
@Column({ name: 'invoice_date', type: 'date' })
invoiceDate: Date;
// Periodo facturado
@Column({ name: 'period_start', type: 'date' })
periodStart: Date;
@Column({ name: 'period_end', type: 'date' })
periodEnd: Date;
// Cliente
@Column({ name: 'billing_name', type: 'varchar', length: 200, nullable: true })
billingName: string;
@Column({ name: 'billing_email', type: 'varchar', length: 255, nullable: true })
billingEmail: string;
@Column({ name: 'billing_address', type: 'jsonb', default: {} })
billingAddress: Record<string, any>;
@Column({ name: 'tax_id', type: 'varchar', length: 20, nullable: true })
taxId: string;
// Montos
@Column({ type: 'decimal', precision: 12, scale: 2 })
subtotal: number;
@Column({ name: 'tax_amount', type: 'decimal', precision: 12, scale: 2, default: 0 })
taxAmount: number;
@Column({ name: 'discount_amount', type: 'decimal', precision: 12, scale: 2, default: 0 })
discountAmount: number;
@Column({ type: 'decimal', precision: 12, scale: 2 })
total: number;
@Column({ type: 'varchar', length: 3, default: 'MXN' })
currency: string;
// Estado
@Index()
@Column({ type: 'varchar', length: 20, default: 'draft' })
status: InvoiceStatus;
// Fechas de pago
@Index()
@Column({ name: 'due_date', type: 'date' })
dueDate: Date;
@Column({ name: 'paid_at', type: 'timestamptz', nullable: true })
paidAt: Date;
@Column({ name: 'paid_amount', type: 'decimal', precision: 12, scale: 2, default: 0 })
paidAmount: number;
// Detalles de pago
@Column({ name: 'payment_method', type: 'varchar', length: 30, nullable: true })
paymentMethod: string;
@Column({ name: 'payment_reference', type: 'varchar', length: 100, nullable: true })
paymentReference: string;
// CFDI (para Mexico)
@Column({ name: 'cfdi_uuid', type: 'varchar', length: 36, nullable: true })
cfdiUuid: string;
@Column({ name: 'cfdi_xml', type: 'text', nullable: true })
cfdiXml: string;
@Column({ name: 'cfdi_pdf_url', type: 'text', nullable: true })
cfdiPdfUrl: string;
// Metadata
@Column({ type: 'text', nullable: true })
notes: string;
@Column({ name: 'internal_notes', type: 'text', nullable: true })
internalNotes: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relaciones
@OneToMany(() => InvoiceItem, (item) => item.invoice, { cascade: true })
items: InvoiceItem[];
}

View File

@ -0,0 +1,85 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
Index,
} from 'typeorm';
export type PaymentProvider = 'stripe' | 'mercadopago' | 'bank_transfer';
export type PaymentMethodType = 'card' | 'bank_account' | 'wallet';
/**
* Entidad para metodos de pago guardados por tenant.
* Almacena informacion tokenizada/encriptada de metodos de pago.
* Mapea a billing.payment_methods (DDL: 05-billing-usage.sql)
*/
@Entity({ name: 'payment_methods', schema: 'billing' })
export class BillingPaymentMethod {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
// Proveedor
@Index()
@Column({ type: 'varchar', length: 30 })
provider: PaymentProvider;
// Tipo
@Column({ name: 'method_type', type: 'varchar', length: 20 })
methodType: PaymentMethodType;
// Datos tokenizados del proveedor
@Column({ name: 'provider_customer_id', type: 'varchar', length: 255, nullable: true })
providerCustomerId: string;
@Column({ name: 'provider_method_id', type: 'varchar', length: 255, nullable: true })
providerMethodId: string;
// Display info (no sensible)
@Column({ name: 'display_name', type: 'varchar', length: 100, nullable: true })
displayName: string;
@Column({ name: 'card_brand', type: 'varchar', length: 20, nullable: true })
cardBrand: string;
@Column({ name: 'card_last_four', type: 'varchar', length: 4, nullable: true })
cardLastFour: string;
@Column({ name: 'card_exp_month', type: 'integer', nullable: true })
cardExpMonth: number;
@Column({ name: 'card_exp_year', type: 'integer', nullable: true })
cardExpYear: number;
@Column({ name: 'bank_name', type: 'varchar', length: 100, nullable: true })
bankName: string;
@Column({ name: 'bank_last_four', type: 'varchar', length: 4, nullable: true })
bankLastFour: string;
// Estado
@Index()
@Column({ name: 'is_default', type: 'boolean', default: false })
isDefault: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'is_verified', type: 'boolean', default: false })
isVerified: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date;
}

View File

@ -0,0 +1,83 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
Index,
} from 'typeorm';
export type PlanType = 'saas' | 'on_premise' | 'hybrid';
@Entity({ name: 'subscription_plans', schema: 'billing' })
export class SubscriptionPlan {
@PrimaryGeneratedColumn('uuid')
id: string;
// Identificacion
@Index({ unique: true })
@Column({ type: 'varchar', length: 30 })
code: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
// Tipo
@Column({ name: 'plan_type', type: 'varchar', length: 20, default: 'saas' })
planType: PlanType;
// Precios base
@Column({ name: 'base_monthly_price', type: 'decimal', precision: 12, scale: 2, default: 0 })
baseMonthlyPrice: number;
@Column({ name: 'base_annual_price', type: 'decimal', precision: 12, scale: 2, nullable: true })
baseAnnualPrice: number;
@Column({ name: 'setup_fee', type: 'decimal', precision: 12, scale: 2, default: 0 })
setupFee: number;
// Limites base
@Column({ name: 'max_users', type: 'integer', default: 5 })
maxUsers: number;
@Column({ name: 'max_branches', type: 'integer', default: 1 })
maxBranches: number;
@Column({ name: 'storage_gb', type: 'integer', default: 10 })
storageGb: number;
@Column({ name: 'api_calls_monthly', type: 'integer', default: 10000 })
apiCallsMonthly: number;
// Modulos incluidos
@Column({ name: 'included_modules', type: 'text', array: true, default: [] })
includedModules: string[];
// Plataformas incluidas
@Column({ name: 'included_platforms', type: 'text', array: true, default: ['web'] })
includedPlatforms: string[];
// Features
@Column({ type: 'jsonb', default: {} })
features: Record<string, boolean>;
// Estado
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'is_public', type: 'boolean', default: true })
isPublic: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date;
}

View File

@ -0,0 +1,117 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
Unique,
} from 'typeorm';
import { SubscriptionPlan } from './subscription-plan.entity';
export type BillingCycle = 'monthly' | 'annual';
export type SubscriptionStatus = 'trial' | 'active' | 'past_due' | 'cancelled' | 'suspended';
@Entity({ name: 'tenant_subscriptions', schema: 'billing' })
@Unique(['tenantId'])
export class TenantSubscription {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'plan_id', type: 'uuid' })
planId: string;
// Periodo
@Column({ name: 'billing_cycle', type: 'varchar', length: 20, default: 'monthly' })
billingCycle: BillingCycle;
@Column({ name: 'current_period_start', type: 'timestamptz' })
currentPeriodStart: Date;
@Column({ name: 'current_period_end', type: 'timestamptz' })
currentPeriodEnd: Date;
// Estado
@Index()
@Column({ type: 'varchar', length: 20, default: 'active' })
status: SubscriptionStatus;
// Trial
@Column({ name: 'trial_start', type: 'timestamptz', nullable: true })
trialStart: Date;
@Column({ name: 'trial_end', type: 'timestamptz', nullable: true })
trialEnd: Date;
// Configuracion de facturacion
@Column({ name: 'billing_email', type: 'varchar', length: 255, nullable: true })
billingEmail: string;
@Column({ name: 'billing_name', type: 'varchar', length: 200, nullable: true })
billingName: string;
@Column({ name: 'billing_address', type: 'jsonb', default: {} })
billingAddress: Record<string, any>;
@Column({ name: 'tax_id', type: 'varchar', length: 20, nullable: true })
taxId: string; // RFC para Mexico
// Metodo de pago
@Column({ name: 'payment_method_id', type: 'uuid', nullable: true })
paymentMethodId: string;
@Column({ name: 'payment_provider', type: 'varchar', length: 30, nullable: true })
paymentProvider: string; // stripe, mercadopago, bank_transfer
// Precios actuales
@Column({ name: 'current_price', type: 'decimal', precision: 12, scale: 2 })
currentPrice: number;
@Column({ name: 'discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
discountPercent: number;
@Column({ name: 'discount_reason', type: 'varchar', length: 100, nullable: true })
discountReason: string;
// Uso contratado
@Column({ name: 'contracted_users', type: 'integer', nullable: true })
contractedUsers: number;
@Column({ name: 'contracted_branches', type: 'integer', nullable: true })
contractedBranches: number;
// Facturacion automatica
@Column({ name: 'auto_renew', type: 'boolean', default: true })
autoRenew: boolean;
@Column({ name: 'next_invoice_date', type: 'date', nullable: true })
nextInvoiceDate: Date;
// Cancelacion
@Column({ name: 'cancel_at_period_end', type: 'boolean', default: false })
cancelAtPeriodEnd: boolean;
@Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true })
cancelledAt: Date;
@Column({ name: 'cancellation_reason', type: 'text', nullable: true })
cancellationReason: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relaciones
@ManyToOne(() => SubscriptionPlan)
@JoinColumn({ name: 'plan_id' })
plan: SubscriptionPlan;
}

View File

@ -0,0 +1,73 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
export type EventCategory = 'user' | 'api' | 'storage' | 'transaction' | 'mobile';
/**
* Entidad para eventos de uso en tiempo real.
* Utilizada para calculo de billing y tracking granular.
* Mapea a billing.usage_events (DDL: 05-billing-usage.sql)
*/
@Entity({ name: 'usage_events', schema: 'billing' })
export class UsageEvent {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId: string;
@Column({ name: 'device_id', type: 'uuid', nullable: true })
deviceId: string;
@Column({ name: 'branch_id', type: 'uuid', nullable: true })
branchId: string;
// Evento
@Index()
@Column({ name: 'event_type', type: 'varchar', length: 50 })
eventType: string; // login, api_call, document_upload, sale, invoice, sync
@Index()
@Column({ name: 'event_category', type: 'varchar', length: 30 })
eventCategory: EventCategory;
// Detalles
@Column({ name: 'profile_code', type: 'varchar', length: 10, nullable: true })
profileCode: string;
@Column({ type: 'varchar', length: 20, nullable: true })
platform: string;
@Column({ name: 'resource_id', type: 'uuid', nullable: true })
resourceId: string;
@Column({ name: 'resource_type', type: 'varchar', length: 50, nullable: true })
resourceType: string;
// Metricas
@Column({ type: 'integer', default: 1 })
quantity: number;
@Column({ name: 'bytes_used', type: 'bigint', default: 0 })
bytesUsed: number;
@Column({ name: 'duration_ms', type: 'integer', nullable: true })
durationMs: number;
// Metadata
@Column({ type: 'jsonb', default: {} })
metadata: Record<string, any>;
@Index()
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}

View File

@ -0,0 +1,91 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
Unique,
} from 'typeorm';
@Entity({ name: 'usage_tracking', schema: 'billing' })
@Unique(['tenantId', 'periodStart'])
export class UsageTracking {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
// Periodo
@Index()
@Column({ name: 'period_start', type: 'date' })
periodStart: Date;
@Column({ name: 'period_end', type: 'date' })
periodEnd: Date;
// Usuarios
@Column({ name: 'active_users', type: 'integer', default: 0 })
activeUsers: number;
@Column({ name: 'peak_concurrent_users', type: 'integer', default: 0 })
peakConcurrentUsers: number;
// Por perfil
@Column({ name: 'users_by_profile', type: 'jsonb', default: {} })
usersByProfile: Record<string, number>; // {"ADM": 2, "VNT": 5, "ALM": 3}
// Por plataforma
@Column({ name: 'users_by_platform', type: 'jsonb', default: {} })
usersByPlatform: Record<string, number>; // {"web": 8, "mobile": 5, "desktop": 0}
// Sucursales
@Column({ name: 'active_branches', type: 'integer', default: 0 })
activeBranches: number;
// Storage
@Column({ name: 'storage_used_gb', type: 'decimal', precision: 10, scale: 2, default: 0 })
storageUsedGb: number;
@Column({ name: 'documents_count', type: 'integer', default: 0 })
documentsCount: number;
// API
@Column({ name: 'api_calls', type: 'integer', default: 0 })
apiCalls: number;
@Column({ name: 'api_errors', type: 'integer', default: 0 })
apiErrors: number;
// Transacciones
@Column({ name: 'sales_count', type: 'integer', default: 0 })
salesCount: number;
@Column({ name: 'sales_amount', type: 'decimal', precision: 14, scale: 2, default: 0 })
salesAmount: number;
@Column({ name: 'invoices_generated', type: 'integer', default: 0 })
invoicesGenerated: number;
// Mobile
@Column({ name: 'mobile_sessions', type: 'integer', default: 0 })
mobileSessions: number;
@Column({ name: 'offline_syncs', type: 'integer', default: 0 })
offlineSyncs: number;
@Column({ name: 'payment_transactions', type: 'integer', default: 0 })
paymentTransactions: number;
// Calculado
@Column({ name: 'total_billable_amount', type: 'decimal', precision: 12, scale: 2, default: 0 })
totalBillableAmount: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -0,0 +1,18 @@
/**
* Billing Usage Module Index
*/
// Module
export { BillingUsageModule, BillingUsageModuleOptions } from './billing-usage.module';
// Entities
export * from './entities';
// DTOs
export * from './dto';
// Services
export * from './services';
// Controllers
export * from './controllers';

View File

@ -0,0 +1,8 @@
/**
* Billing Usage Services Index
*/
export { SubscriptionPlansService } from './subscription-plans.service';
export { SubscriptionsService } from './subscriptions.service';
export { UsageTrackingService } from './usage-tracking.service';
export { InvoicesService } from './invoices.service';

View File

@ -0,0 +1,471 @@
/**
* Invoices Service
*
* Service for managing invoices
*/
import { Repository, DataSource, MoreThanOrEqual, LessThanOrEqual } from 'typeorm';
import { Invoice, InvoiceItem, InvoiceStatus, TenantSubscription, UsageTracking } from '../entities';
import {
CreateInvoiceDto,
UpdateInvoiceDto,
RecordPaymentDto,
VoidInvoiceDto,
RefundInvoiceDto,
GenerateInvoiceDto,
InvoiceFilterDto,
} from '../dto';
export class InvoicesService {
private invoiceRepository: Repository<Invoice>;
private itemRepository: Repository<InvoiceItem>;
private subscriptionRepository: Repository<TenantSubscription>;
private usageRepository: Repository<UsageTracking>;
constructor(private dataSource: DataSource) {
this.invoiceRepository = dataSource.getRepository(Invoice);
this.itemRepository = dataSource.getRepository(InvoiceItem);
this.subscriptionRepository = dataSource.getRepository(TenantSubscription);
this.usageRepository = dataSource.getRepository(UsageTracking);
}
/**
* Create invoice manually
*/
async create(dto: CreateInvoiceDto): Promise<Invoice> {
const invoiceNumber = await this.generateInvoiceNumber();
// Calculate totals
let subtotal = 0;
for (const item of dto.items) {
const itemTotal = item.quantity * item.unitPrice;
const discount = itemTotal * ((item.discountPercent || 0) / 100);
subtotal += itemTotal - discount;
}
const taxAmount = subtotal * 0.16; // 16% IVA for Mexico
const total = subtotal + taxAmount;
const invoice = this.invoiceRepository.create({
tenantId: dto.tenantId,
subscriptionId: dto.subscriptionId,
invoiceNumber,
invoiceDate: dto.invoiceDate || new Date(),
periodStart: dto.periodStart,
periodEnd: dto.periodEnd,
billingName: dto.billingName,
billingEmail: dto.billingEmail,
billingAddress: dto.billingAddress || {},
taxId: dto.taxId,
subtotal,
taxAmount,
discountAmount: 0,
total,
currency: dto.currency || 'MXN',
status: 'draft',
dueDate: dto.dueDate,
notes: dto.notes,
internalNotes: dto.internalNotes,
});
const savedInvoice = await this.invoiceRepository.save(invoice);
// Create items
for (const itemDto of dto.items) {
const itemTotal = itemDto.quantity * itemDto.unitPrice;
const discount = itemTotal * ((itemDto.discountPercent || 0) / 100);
const item = this.itemRepository.create({
invoiceId: savedInvoice.id,
itemType: itemDto.itemType,
description: itemDto.description,
quantity: itemDto.quantity,
unitPrice: itemDto.unitPrice,
discountPercent: itemDto.discountPercent || 0,
subtotal: itemTotal - discount,
metadata: itemDto.metadata || {},
});
await this.itemRepository.save(item);
}
return this.findById(savedInvoice.id) as Promise<Invoice>;
}
/**
* Generate invoice automatically from subscription
*/
async generateFromSubscription(dto: GenerateInvoiceDto): Promise<Invoice> {
const subscription = await this.subscriptionRepository.findOne({
where: { id: dto.subscriptionId },
relations: ['plan'],
});
if (!subscription) {
throw new Error('Subscription not found');
}
const items: CreateInvoiceDto['items'] = [];
// Base subscription fee
items.push({
itemType: 'subscription',
description: `Suscripcion ${subscription.plan.name} - ${subscription.billingCycle === 'annual' ? 'Anual' : 'Mensual'}`,
quantity: 1,
unitPrice: Number(subscription.currentPrice),
});
// Include usage charges if requested
if (dto.includeUsageCharges) {
const usage = await this.usageRepository.findOne({
where: {
tenantId: dto.tenantId,
periodStart: dto.periodStart,
},
});
if (usage) {
// Extra users
const extraUsers = Math.max(
0,
usage.activeUsers - (subscription.contractedUsers || subscription.plan.maxUsers)
);
if (extraUsers > 0) {
items.push({
itemType: 'overage',
description: `Usuarios adicionales (${extraUsers})`,
quantity: extraUsers,
unitPrice: 10, // $10 per extra user
metadata: { metric: 'extra_users' },
});
}
// Extra branches
const extraBranches = Math.max(
0,
usage.activeBranches - (subscription.contractedBranches || subscription.plan.maxBranches)
);
if (extraBranches > 0) {
items.push({
itemType: 'overage',
description: `Sucursales adicionales (${extraBranches})`,
quantity: extraBranches,
unitPrice: 20, // $20 per extra branch
metadata: { metric: 'extra_branches' },
});
}
// Extra storage
const extraStorageGb = Math.max(
0,
Number(usage.storageUsedGb) - subscription.plan.storageGb
);
if (extraStorageGb > 0) {
items.push({
itemType: 'overage',
description: `Almacenamiento adicional (${extraStorageGb} GB)`,
quantity: Math.ceil(extraStorageGb),
unitPrice: 0.5, // $0.50 per GB
metadata: { metric: 'extra_storage' },
});
}
}
}
// Calculate due date (15 days from invoice date)
const dueDate = new Date();
dueDate.setDate(dueDate.getDate() + 15);
return this.create({
tenantId: dto.tenantId,
subscriptionId: dto.subscriptionId,
periodStart: dto.periodStart,
periodEnd: dto.periodEnd,
billingName: subscription.billingName,
billingEmail: subscription.billingEmail,
billingAddress: subscription.billingAddress,
taxId: subscription.taxId,
dueDate,
items,
});
}
/**
* Find invoice by ID
*/
async findById(id: string): Promise<Invoice | null> {
return this.invoiceRepository.findOne({
where: { id },
relations: ['items'],
});
}
/**
* Find invoice by number
*/
async findByNumber(invoiceNumber: string): Promise<Invoice | null> {
return this.invoiceRepository.findOne({
where: { invoiceNumber },
relations: ['items'],
});
}
/**
* Find invoices with filters
*/
async findAll(filter: InvoiceFilterDto): Promise<{ data: Invoice[]; total: number }> {
const query = this.invoiceRepository
.createQueryBuilder('invoice')
.leftJoinAndSelect('invoice.items', 'items');
if (filter.tenantId) {
query.andWhere('invoice.tenantId = :tenantId', { tenantId: filter.tenantId });
}
if (filter.status) {
query.andWhere('invoice.status = :status', { status: filter.status });
}
if (filter.dateFrom) {
query.andWhere('invoice.invoiceDate >= :dateFrom', { dateFrom: filter.dateFrom });
}
if (filter.dateTo) {
query.andWhere('invoice.invoiceDate <= :dateTo', { dateTo: filter.dateTo });
}
if (filter.overdue) {
query.andWhere('invoice.dueDate < :now', { now: new Date() });
query.andWhere("invoice.status IN ('sent', 'partial')");
}
const total = await query.getCount();
query.orderBy('invoice.invoiceDate', 'DESC');
if (filter.limit) {
query.take(filter.limit);
}
if (filter.offset) {
query.skip(filter.offset);
}
const data = await query.getMany();
return { data, total };
}
/**
* Update invoice
*/
async update(id: string, dto: UpdateInvoiceDto): Promise<Invoice> {
const invoice = await this.findById(id);
if (!invoice) {
throw new Error('Invoice not found');
}
if (invoice.status !== 'draft') {
throw new Error('Only draft invoices can be updated');
}
Object.assign(invoice, dto);
return this.invoiceRepository.save(invoice);
}
/**
* Send invoice
*/
async send(id: string): Promise<Invoice> {
const invoice = await this.findById(id);
if (!invoice) {
throw new Error('Invoice not found');
}
if (invoice.status !== 'draft') {
throw new Error('Only draft invoices can be sent');
}
invoice.status = 'sent';
// TODO: Send email notification to billing email
return this.invoiceRepository.save(invoice);
}
/**
* Record payment
*/
async recordPayment(id: string, dto: RecordPaymentDto): Promise<Invoice> {
const invoice = await this.findById(id);
if (!invoice) {
throw new Error('Invoice not found');
}
if (invoice.status === 'void' || invoice.status === 'refunded') {
throw new Error('Cannot record payment for voided or refunded invoice');
}
const newPaidAmount = Number(invoice.paidAmount) + dto.amount;
const total = Number(invoice.total);
invoice.paidAmount = newPaidAmount;
invoice.paymentMethod = dto.paymentMethod;
invoice.paymentReference = dto.paymentReference;
if (newPaidAmount >= total) {
invoice.status = 'paid';
invoice.paidAt = dto.paymentDate || new Date();
} else if (newPaidAmount > 0) {
invoice.status = 'partial';
}
return this.invoiceRepository.save(invoice);
}
/**
* Void invoice
*/
async void(id: string, dto: VoidInvoiceDto): Promise<Invoice> {
const invoice = await this.findById(id);
if (!invoice) {
throw new Error('Invoice not found');
}
if (invoice.status === 'paid' || invoice.status === 'refunded') {
throw new Error('Cannot void paid or refunded invoice');
}
invoice.status = 'void';
invoice.internalNotes = `${invoice.internalNotes || ''}\n\nVoided: ${dto.reason}`.trim();
return this.invoiceRepository.save(invoice);
}
/**
* Refund invoice
*/
async refund(id: string, dto: RefundInvoiceDto): Promise<Invoice> {
const invoice = await this.findById(id);
if (!invoice) {
throw new Error('Invoice not found');
}
if (invoice.status !== 'paid' && invoice.status !== 'partial') {
throw new Error('Only paid invoices can be refunded');
}
const refundAmount = dto.amount || Number(invoice.paidAmount);
if (refundAmount > Number(invoice.paidAmount)) {
throw new Error('Refund amount cannot exceed paid amount');
}
invoice.status = 'refunded';
invoice.internalNotes =
`${invoice.internalNotes || ''}\n\nRefunded: ${refundAmount} - ${dto.reason}`.trim();
// TODO: Process actual refund through payment provider
return this.invoiceRepository.save(invoice);
}
/**
* Mark overdue invoices
*/
async markOverdueInvoices(): Promise<number> {
const now = new Date();
const result = await this.invoiceRepository
.createQueryBuilder()
.update(Invoice)
.set({ status: 'overdue' })
.where("status IN ('sent', 'partial')")
.andWhere('dueDate < :now', { now })
.execute();
return result.affected || 0;
}
/**
* Get invoice statistics
*/
async getStats(tenantId?: string): Promise<{
total: number;
byStatus: Record<InvoiceStatus, number>;
totalRevenue: number;
pendingAmount: number;
overdueAmount: number;
}> {
const query = this.invoiceRepository.createQueryBuilder('invoice');
if (tenantId) {
query.where('invoice.tenantId = :tenantId', { tenantId });
}
const invoices = await query.getMany();
const byStatus: Record<InvoiceStatus, number> = {
draft: 0,
sent: 0,
paid: 0,
partial: 0,
overdue: 0,
void: 0,
refunded: 0,
};
let totalRevenue = 0;
let pendingAmount = 0;
let overdueAmount = 0;
const now = new Date();
for (const invoice of invoices) {
byStatus[invoice.status]++;
if (invoice.status === 'paid') {
totalRevenue += Number(invoice.paidAmount);
}
if (invoice.status === 'sent' || invoice.status === 'partial') {
const pending = Number(invoice.total) - Number(invoice.paidAmount);
pendingAmount += pending;
if (invoice.dueDate < now) {
overdueAmount += pending;
}
}
}
return {
total: invoices.length,
byStatus,
totalRevenue,
pendingAmount,
overdueAmount,
};
}
/**
* Generate unique invoice number
*/
private async generateInvoiceNumber(): Promise<string> {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
// Get last invoice number for this month
const lastInvoice = await this.invoiceRepository
.createQueryBuilder('invoice')
.where('invoice.invoiceNumber LIKE :pattern', { pattern: `INV-${year}${month}%` })
.orderBy('invoice.invoiceNumber', 'DESC')
.getOne();
let sequence = 1;
if (lastInvoice) {
const lastSequence = parseInt(lastInvoice.invoiceNumber.slice(-4), 10);
sequence = lastSequence + 1;
}
return `INV-${year}${month}-${String(sequence).padStart(4, '0')}`;
}
}

View File

@ -0,0 +1,200 @@
/**
* Subscription Plans Service
*
* Service for managing subscription plans
*/
import { Repository, DataSource } from 'typeorm';
import { SubscriptionPlan, PlanType } from '../entities';
import { CreateSubscriptionPlanDto, UpdateSubscriptionPlanDto } from '../dto';
export class SubscriptionPlansService {
private planRepository: Repository<SubscriptionPlan>;
constructor(private dataSource: DataSource) {
this.planRepository = dataSource.getRepository(SubscriptionPlan);
}
/**
* Create a new subscription plan
*/
async create(dto: CreateSubscriptionPlanDto): Promise<SubscriptionPlan> {
// Check if code already exists
const existing = await this.planRepository.findOne({
where: { code: dto.code },
});
if (existing) {
throw new Error(`Plan with code ${dto.code} already exists`);
}
const plan = this.planRepository.create({
code: dto.code,
name: dto.name,
description: dto.description,
planType: dto.planType || 'saas',
baseMonthlyPrice: dto.baseMonthlyPrice,
baseAnnualPrice: dto.baseAnnualPrice,
setupFee: dto.setupFee || 0,
maxUsers: dto.maxUsers || 5,
maxBranches: dto.maxBranches || 1,
storageGb: dto.storageGb || 10,
apiCallsMonthly: dto.apiCallsMonthly || 10000,
includedModules: dto.includedModules || [],
includedPlatforms: dto.includedPlatforms || ['web'],
features: dto.features || {},
isActive: dto.isActive !== false,
isPublic: dto.isPublic !== false,
});
return this.planRepository.save(plan);
}
/**
* Find all plans
*/
async findAll(options?: {
isActive?: boolean;
isPublic?: boolean;
planType?: PlanType;
}): Promise<SubscriptionPlan[]> {
const query = this.planRepository.createQueryBuilder('plan');
if (options?.isActive !== undefined) {
query.andWhere('plan.isActive = :isActive', { isActive: options.isActive });
}
if (options?.isPublic !== undefined) {
query.andWhere('plan.isPublic = :isPublic', { isPublic: options.isPublic });
}
if (options?.planType) {
query.andWhere('plan.planType = :planType', { planType: options.planType });
}
return query.orderBy('plan.baseMonthlyPrice', 'ASC').getMany();
}
/**
* Find public plans (for pricing page)
*/
async findPublicPlans(): Promise<SubscriptionPlan[]> {
return this.findAll({ isActive: true, isPublic: true });
}
/**
* Find plan by ID
*/
async findById(id: string): Promise<SubscriptionPlan | null> {
return this.planRepository.findOne({ where: { id } });
}
/**
* Find plan by code
*/
async findByCode(code: string): Promise<SubscriptionPlan | null> {
return this.planRepository.findOne({ where: { code } });
}
/**
* Update a plan
*/
async update(id: string, dto: UpdateSubscriptionPlanDto): Promise<SubscriptionPlan> {
const plan = await this.findById(id);
if (!plan) {
throw new Error('Plan not found');
}
Object.assign(plan, dto);
return this.planRepository.save(plan);
}
/**
* Soft delete a plan
*/
async delete(id: string): Promise<void> {
const plan = await this.findById(id);
if (!plan) {
throw new Error('Plan not found');
}
// Check if plan has active subscriptions
const subscriptionCount = await this.dataSource
.createQueryBuilder()
.select('COUNT(*)')
.from('billing.tenant_subscriptions', 'ts')
.where('ts.plan_id = :planId', { planId: id })
.andWhere("ts.status IN ('active', 'trial')")
.getRawOne();
if (parseInt(subscriptionCount.count) > 0) {
throw new Error('Cannot delete plan with active subscriptions');
}
await this.planRepository.softDelete(id);
}
/**
* Activate/deactivate a plan
*/
async setActive(id: string, isActive: boolean): Promise<SubscriptionPlan> {
return this.update(id, { isActive });
}
/**
* Compare two plans
*/
async comparePlans(
planId1: string,
planId2: string
): Promise<{
plan1: SubscriptionPlan;
plan2: SubscriptionPlan;
differences: Record<string, { plan1: any; plan2: any }>;
}> {
const [plan1, plan2] = await Promise.all([
this.findById(planId1),
this.findById(planId2),
]);
if (!plan1 || !plan2) {
throw new Error('One or both plans not found');
}
const fieldsToCompare = [
'baseMonthlyPrice',
'baseAnnualPrice',
'maxUsers',
'maxBranches',
'storageGb',
'apiCallsMonthly',
];
const differences: Record<string, { plan1: any; plan2: any }> = {};
for (const field of fieldsToCompare) {
if ((plan1 as any)[field] !== (plan2 as any)[field]) {
differences[field] = {
plan1: (plan1 as any)[field],
plan2: (plan2 as any)[field],
};
}
}
// Compare included modules
const modules1 = new Set(plan1.includedModules);
const modules2 = new Set(plan2.includedModules);
const modulesDiff = {
onlyInPlan1: plan1.includedModules.filter((m) => !modules2.has(m)),
onlyInPlan2: plan2.includedModules.filter((m) => !modules1.has(m)),
};
if (modulesDiff.onlyInPlan1.length > 0 || modulesDiff.onlyInPlan2.length > 0) {
differences.includedModules = {
plan1: modulesDiff.onlyInPlan1,
plan2: modulesDiff.onlyInPlan2,
};
}
return { plan1, plan2, differences };
}
}

View File

@ -0,0 +1,384 @@
/**
* Subscriptions Service
*
* Service for managing tenant subscriptions
*/
import { Repository, DataSource } from 'typeorm';
import {
TenantSubscription,
SubscriptionPlan,
BillingCycle,
SubscriptionStatus,
} from '../entities';
import {
CreateTenantSubscriptionDto,
UpdateTenantSubscriptionDto,
CancelSubscriptionDto,
ChangePlanDto,
SetPaymentMethodDto,
} from '../dto';
export class SubscriptionsService {
private subscriptionRepository: Repository<TenantSubscription>;
private planRepository: Repository<SubscriptionPlan>;
constructor(private dataSource: DataSource) {
this.subscriptionRepository = dataSource.getRepository(TenantSubscription);
this.planRepository = dataSource.getRepository(SubscriptionPlan);
}
/**
* Create a new subscription
*/
async create(dto: CreateTenantSubscriptionDto): Promise<TenantSubscription> {
// Check if tenant already has a subscription
const existing = await this.subscriptionRepository.findOne({
where: { tenantId: dto.tenantId },
});
if (existing) {
throw new Error('Tenant already has a subscription');
}
// Validate plan exists
const plan = await this.planRepository.findOne({ where: { id: dto.planId } });
if (!plan) {
throw new Error('Plan not found');
}
const now = new Date();
const currentPeriodStart = dto.currentPeriodStart || now;
const currentPeriodEnd =
dto.currentPeriodEnd || this.calculatePeriodEnd(currentPeriodStart, dto.billingCycle || 'monthly');
const subscription = this.subscriptionRepository.create({
tenantId: dto.tenantId,
planId: dto.planId,
billingCycle: dto.billingCycle || 'monthly',
currentPeriodStart,
currentPeriodEnd,
status: dto.startWithTrial ? 'trial' : 'active',
billingEmail: dto.billingEmail,
billingName: dto.billingName,
billingAddress: dto.billingAddress || {},
taxId: dto.taxId,
currentPrice: dto.currentPrice,
discountPercent: dto.discountPercent || 0,
discountReason: dto.discountReason,
contractedUsers: dto.contractedUsers || plan.maxUsers,
contractedBranches: dto.contractedBranches || plan.maxBranches,
autoRenew: dto.autoRenew !== false,
nextInvoiceDate: currentPeriodEnd,
});
// Set trial dates if starting with trial
if (dto.startWithTrial) {
subscription.trialStart = now;
subscription.trialEnd = new Date(now.getTime() + (dto.trialDays || 14) * 24 * 60 * 60 * 1000);
}
return this.subscriptionRepository.save(subscription);
}
/**
* Find subscription by tenant ID
*/
async findByTenantId(tenantId: string): Promise<TenantSubscription | null> {
return this.subscriptionRepository.findOne({
where: { tenantId },
relations: ['plan'],
});
}
/**
* Find subscription by ID
*/
async findById(id: string): Promise<TenantSubscription | null> {
return this.subscriptionRepository.findOne({
where: { id },
relations: ['plan'],
});
}
/**
* Update subscription
*/
async update(id: string, dto: UpdateTenantSubscriptionDto): Promise<TenantSubscription> {
const subscription = await this.findById(id);
if (!subscription) {
throw new Error('Subscription not found');
}
// If changing plan, validate it exists
if (dto.planId && dto.planId !== subscription.planId) {
const plan = await this.planRepository.findOne({ where: { id: dto.planId } });
if (!plan) {
throw new Error('Plan not found');
}
}
Object.assign(subscription, dto);
return this.subscriptionRepository.save(subscription);
}
/**
* Cancel subscription
*/
async cancel(id: string, dto: CancelSubscriptionDto): Promise<TenantSubscription> {
const subscription = await this.findById(id);
if (!subscription) {
throw new Error('Subscription not found');
}
if (subscription.status === 'cancelled') {
throw new Error('Subscription is already cancelled');
}
subscription.cancellationReason = dto.reason;
subscription.cancelledAt = new Date();
if (dto.cancelImmediately) {
subscription.status = 'cancelled';
} else {
subscription.cancelAtPeriodEnd = true;
subscription.autoRenew = false;
}
return this.subscriptionRepository.save(subscription);
}
/**
* Reactivate cancelled subscription
*/
async reactivate(id: string): Promise<TenantSubscription> {
const subscription = await this.findById(id);
if (!subscription) {
throw new Error('Subscription not found');
}
if (subscription.status !== 'cancelled' && !subscription.cancelAtPeriodEnd) {
throw new Error('Subscription is not cancelled');
}
subscription.status = 'active';
subscription.cancelAtPeriodEnd = false;
subscription.cancellationReason = null as any;
subscription.cancelledAt = null as any;
subscription.autoRenew = true;
return this.subscriptionRepository.save(subscription);
}
/**
* Change subscription plan
*/
async changePlan(id: string, dto: ChangePlanDto): Promise<TenantSubscription> {
const subscription = await this.findById(id);
if (!subscription) {
throw new Error('Subscription not found');
}
const newPlan = await this.planRepository.findOne({ where: { id: dto.newPlanId } });
if (!newPlan) {
throw new Error('New plan not found');
}
// Calculate new price
const newPrice =
subscription.billingCycle === 'annual' && newPlan.baseAnnualPrice
? newPlan.baseAnnualPrice
: newPlan.baseMonthlyPrice;
// Apply existing discount if any
const discountedPrice = newPrice * (1 - (subscription.discountPercent || 0) / 100);
subscription.planId = dto.newPlanId;
subscription.currentPrice = discountedPrice;
subscription.contractedUsers = newPlan.maxUsers;
subscription.contractedBranches = newPlan.maxBranches;
// If effective immediately and prorate, calculate adjustment
// This would typically create a credit/debit memo
return this.subscriptionRepository.save(subscription);
}
/**
* Set payment method
*/
async setPaymentMethod(id: string, dto: SetPaymentMethodDto): Promise<TenantSubscription> {
const subscription = await this.findById(id);
if (!subscription) {
throw new Error('Subscription not found');
}
subscription.paymentMethodId = dto.paymentMethodId;
subscription.paymentProvider = dto.paymentProvider;
return this.subscriptionRepository.save(subscription);
}
/**
* Renew subscription (for periodic billing)
*/
async renew(id: string): Promise<TenantSubscription> {
const subscription = await this.findById(id);
if (!subscription) {
throw new Error('Subscription not found');
}
if (!subscription.autoRenew) {
throw new Error('Subscription auto-renew is disabled');
}
if (subscription.cancelAtPeriodEnd) {
subscription.status = 'cancelled';
return this.subscriptionRepository.save(subscription);
}
// Calculate new period
const newPeriodStart = subscription.currentPeriodEnd;
const newPeriodEnd = this.calculatePeriodEnd(newPeriodStart, subscription.billingCycle);
subscription.currentPeriodStart = newPeriodStart;
subscription.currentPeriodEnd = newPeriodEnd;
subscription.nextInvoiceDate = newPeriodEnd;
// Reset trial status if was in trial
if (subscription.status === 'trial') {
subscription.status = 'active';
}
return this.subscriptionRepository.save(subscription);
}
/**
* Mark subscription as past due
*/
async markPastDue(id: string): Promise<TenantSubscription> {
return this.updateStatus(id, 'past_due');
}
/**
* Suspend subscription
*/
async suspend(id: string): Promise<TenantSubscription> {
return this.updateStatus(id, 'suspended');
}
/**
* Activate subscription (from suspended or past_due)
*/
async activate(id: string): Promise<TenantSubscription> {
return this.updateStatus(id, 'active');
}
/**
* Update subscription status
*/
private async updateStatus(id: string, status: SubscriptionStatus): Promise<TenantSubscription> {
const subscription = await this.findById(id);
if (!subscription) {
throw new Error('Subscription not found');
}
subscription.status = status;
return this.subscriptionRepository.save(subscription);
}
/**
* Find subscriptions expiring soon
*/
async findExpiringSoon(days: number = 7): Promise<TenantSubscription[]> {
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + days);
return this.subscriptionRepository
.createQueryBuilder('sub')
.leftJoinAndSelect('sub.plan', 'plan')
.where('sub.currentPeriodEnd <= :futureDate', { futureDate })
.andWhere("sub.status IN ('active', 'trial')")
.andWhere('sub.cancelAtPeriodEnd = false')
.orderBy('sub.currentPeriodEnd', 'ASC')
.getMany();
}
/**
* Find subscriptions with trials ending soon
*/
async findTrialsEndingSoon(days: number = 3): Promise<TenantSubscription[]> {
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + days);
return this.subscriptionRepository
.createQueryBuilder('sub')
.leftJoinAndSelect('sub.plan', 'plan')
.where("sub.status = 'trial'")
.andWhere('sub.trialEnd <= :futureDate', { futureDate })
.orderBy('sub.trialEnd', 'ASC')
.getMany();
}
/**
* Calculate period end date based on billing cycle
*/
private calculatePeriodEnd(start: Date, cycle: BillingCycle): Date {
const end = new Date(start);
if (cycle === 'annual') {
end.setFullYear(end.getFullYear() + 1);
} else {
end.setMonth(end.getMonth() + 1);
}
return end;
}
/**
* Get subscription statistics
*/
async getStats(): Promise<{
total: number;
byStatus: Record<SubscriptionStatus, number>;
byPlan: Record<string, number>;
totalMRR: number;
totalARR: number;
}> {
const subscriptions = await this.subscriptionRepository.find({
relations: ['plan'],
});
const byStatus: Record<SubscriptionStatus, number> = {
trial: 0,
active: 0,
past_due: 0,
cancelled: 0,
suspended: 0,
};
const byPlan: Record<string, number> = {};
let totalMRR = 0;
for (const sub of subscriptions) {
byStatus[sub.status]++;
const planCode = sub.plan?.code || 'unknown';
byPlan[planCode] = (byPlan[planCode] || 0) + 1;
if (sub.status === 'active' || sub.status === 'trial') {
const monthlyPrice =
sub.billingCycle === 'annual'
? Number(sub.currentPrice) / 12
: Number(sub.currentPrice);
totalMRR += monthlyPrice;
}
}
return {
total: subscriptions.length,
byStatus,
byPlan,
totalMRR,
totalARR: totalMRR * 12,
};
}
}

View File

@ -0,0 +1,381 @@
/**
* Usage Tracking Service
*
* Service for tracking and reporting usage metrics
*/
import { Repository, DataSource, Between, MoreThanOrEqual, LessThanOrEqual } from 'typeorm';
import { UsageTracking, TenantSubscription, SubscriptionPlan } from '../entities';
import { RecordUsageDto, UpdateUsageDto, UsageMetrics, UsageSummaryDto } from '../dto';
export class UsageTrackingService {
private usageRepository: Repository<UsageTracking>;
private subscriptionRepository: Repository<TenantSubscription>;
private planRepository: Repository<SubscriptionPlan>;
constructor(private dataSource: DataSource) {
this.usageRepository = dataSource.getRepository(UsageTracking);
this.subscriptionRepository = dataSource.getRepository(TenantSubscription);
this.planRepository = dataSource.getRepository(SubscriptionPlan);
}
/**
* Record usage for a period
*/
async recordUsage(dto: RecordUsageDto): Promise<UsageTracking> {
// Check if record exists for this tenant/period
const existing = await this.usageRepository.findOne({
where: {
tenantId: dto.tenantId,
periodStart: dto.periodStart,
},
});
if (existing) {
// Update existing record
return this.update(existing.id, dto);
}
const usage = this.usageRepository.create({
tenantId: dto.tenantId,
periodStart: dto.periodStart,
periodEnd: dto.periodEnd,
activeUsers: dto.activeUsers || 0,
peakConcurrentUsers: dto.peakConcurrentUsers || 0,
usersByProfile: dto.usersByProfile || {},
usersByPlatform: dto.usersByPlatform || {},
activeBranches: dto.activeBranches || 0,
storageUsedGb: dto.storageUsedGb || 0,
documentsCount: dto.documentsCount || 0,
apiCalls: dto.apiCalls || 0,
apiErrors: dto.apiErrors || 0,
salesCount: dto.salesCount || 0,
salesAmount: dto.salesAmount || 0,
invoicesGenerated: dto.invoicesGenerated || 0,
mobileSessions: dto.mobileSessions || 0,
offlineSyncs: dto.offlineSyncs || 0,
paymentTransactions: dto.paymentTransactions || 0,
});
// Calculate billable amount
usage.totalBillableAmount = await this.calculateBillableAmount(dto.tenantId, usage);
return this.usageRepository.save(usage);
}
/**
* Update usage record
*/
async update(id: string, dto: UpdateUsageDto): Promise<UsageTracking> {
const usage = await this.usageRepository.findOne({ where: { id } });
if (!usage) {
throw new Error('Usage record not found');
}
Object.assign(usage, dto);
usage.totalBillableAmount = await this.calculateBillableAmount(usage.tenantId, usage);
return this.usageRepository.save(usage);
}
/**
* Increment a specific metric
*/
async incrementMetric(
tenantId: string,
metric: keyof UsageMetrics,
amount: number = 1
): Promise<void> {
const currentPeriod = this.getCurrentPeriodDates();
let usage = await this.usageRepository.findOne({
where: {
tenantId,
periodStart: currentPeriod.start,
},
});
if (!usage) {
usage = await this.recordUsage({
tenantId,
periodStart: currentPeriod.start,
periodEnd: currentPeriod.end,
});
}
// Increment the specific metric
(usage as any)[metric] = ((usage as any)[metric] || 0) + amount;
await this.usageRepository.save(usage);
}
/**
* Get current usage for tenant
*/
async getCurrentUsage(tenantId: string): Promise<UsageTracking | null> {
const currentPeriod = this.getCurrentPeriodDates();
return this.usageRepository.findOne({
where: {
tenantId,
periodStart: currentPeriod.start,
},
});
}
/**
* Get usage history for tenant
*/
async getUsageHistory(
tenantId: string,
startDate: Date,
endDate: Date
): Promise<UsageTracking[]> {
return this.usageRepository.find({
where: {
tenantId,
periodStart: MoreThanOrEqual(startDate),
periodEnd: LessThanOrEqual(endDate),
},
order: { periodStart: 'DESC' },
});
}
/**
* Get usage summary with limits comparison
*/
async getUsageSummary(tenantId: string): Promise<UsageSummaryDto> {
const subscription = await this.subscriptionRepository.findOne({
where: { tenantId },
relations: ['plan'],
});
if (!subscription) {
throw new Error('Subscription not found');
}
const currentUsage = await this.getCurrentUsage(tenantId);
const plan = subscription.plan;
const summary: UsageSummaryDto = {
tenantId,
currentUsers: currentUsage?.activeUsers || 0,
currentBranches: currentUsage?.activeBranches || 0,
currentStorageGb: Number(currentUsage?.storageUsedGb || 0),
apiCallsThisMonth: currentUsage?.apiCalls || 0,
salesThisMonth: currentUsage?.salesCount || 0,
salesAmountThisMonth: Number(currentUsage?.salesAmount || 0),
limits: {
maxUsers: subscription.contractedUsers || plan.maxUsers,
maxBranches: subscription.contractedBranches || plan.maxBranches,
maxStorageGb: plan.storageGb,
maxApiCalls: plan.apiCallsMonthly,
},
percentages: {
usersUsed: 0,
branchesUsed: 0,
storageUsed: 0,
apiCallsUsed: 0,
},
};
// Calculate percentages
summary.percentages.usersUsed =
Math.round((summary.currentUsers / summary.limits.maxUsers) * 100);
summary.percentages.branchesUsed =
Math.round((summary.currentBranches / summary.limits.maxBranches) * 100);
summary.percentages.storageUsed =
Math.round((summary.currentStorageGb / summary.limits.maxStorageGb) * 100);
summary.percentages.apiCallsUsed =
Math.round((summary.apiCallsThisMonth / summary.limits.maxApiCalls) * 100);
return summary;
}
/**
* Check if tenant exceeds limits
*/
async checkLimits(tenantId: string): Promise<{
exceeds: boolean;
violations: string[];
warnings: string[];
}> {
const summary = await this.getUsageSummary(tenantId);
const violations: string[] = [];
const warnings: string[] = [];
// Check hard limits
if (summary.currentUsers > summary.limits.maxUsers) {
violations.push(`Users: ${summary.currentUsers}/${summary.limits.maxUsers}`);
}
if (summary.currentBranches > summary.limits.maxBranches) {
violations.push(`Branches: ${summary.currentBranches}/${summary.limits.maxBranches}`);
}
if (summary.currentStorageGb > summary.limits.maxStorageGb) {
violations.push(
`Storage: ${summary.currentStorageGb}GB/${summary.limits.maxStorageGb}GB`
);
}
// Check warnings (80% threshold)
if (summary.percentages.usersUsed >= 80 && summary.percentages.usersUsed < 100) {
warnings.push(`Users at ${summary.percentages.usersUsed}% capacity`);
}
if (summary.percentages.branchesUsed >= 80 && summary.percentages.branchesUsed < 100) {
warnings.push(`Branches at ${summary.percentages.branchesUsed}% capacity`);
}
if (summary.percentages.storageUsed >= 80 && summary.percentages.storageUsed < 100) {
warnings.push(`Storage at ${summary.percentages.storageUsed}% capacity`);
}
if (summary.percentages.apiCallsUsed >= 80 && summary.percentages.apiCallsUsed < 100) {
warnings.push(`API calls at ${summary.percentages.apiCallsUsed}% capacity`);
}
return {
exceeds: violations.length > 0,
violations,
warnings,
};
}
/**
* Get usage report
*/
async getUsageReport(
tenantId: string,
startDate: Date,
endDate: Date,
granularity: 'daily' | 'weekly' | 'monthly' = 'monthly'
): Promise<{
tenantId: string;
startDate: Date;
endDate: Date;
granularity: string;
data: UsageTracking[];
totals: {
apiCalls: number;
salesCount: number;
salesAmount: number;
mobileSessions: number;
paymentTransactions: number;
};
averages: {
activeUsers: number;
activeBranches: number;
storageUsedGb: number;
};
}> {
const data = await this.getUsageHistory(tenantId, startDate, endDate);
// Calculate totals
const totals = {
apiCalls: 0,
salesCount: 0,
salesAmount: 0,
mobileSessions: 0,
paymentTransactions: 0,
};
let totalUsers = 0;
let totalBranches = 0;
let totalStorage = 0;
for (const record of data) {
totals.apiCalls += record.apiCalls;
totals.salesCount += record.salesCount;
totals.salesAmount += Number(record.salesAmount);
totals.mobileSessions += record.mobileSessions;
totals.paymentTransactions += record.paymentTransactions;
totalUsers += record.activeUsers;
totalBranches += record.activeBranches;
totalStorage += Number(record.storageUsedGb);
}
const count = data.length || 1;
return {
tenantId,
startDate,
endDate,
granularity,
data,
totals,
averages: {
activeUsers: Math.round(totalUsers / count),
activeBranches: Math.round(totalBranches / count),
storageUsedGb: Math.round((totalStorage / count) * 100) / 100,
},
};
}
/**
* Calculate billable amount based on usage
*/
private async calculateBillableAmount(
tenantId: string,
usage: UsageTracking
): Promise<number> {
const subscription = await this.subscriptionRepository.findOne({
where: { tenantId },
relations: ['plan'],
});
if (!subscription) {
return 0;
}
let billableAmount = Number(subscription.currentPrice);
// Add overage charges if applicable
const plan = subscription.plan;
// Extra users
const extraUsers = Math.max(0, usage.activeUsers - (subscription.contractedUsers || plan.maxUsers));
if (extraUsers > 0) {
// Assume $10 per extra user per month
billableAmount += extraUsers * 10;
}
// Extra branches
const extraBranches = Math.max(
0,
usage.activeBranches - (subscription.contractedBranches || plan.maxBranches)
);
if (extraBranches > 0) {
// Assume $20 per extra branch per month
billableAmount += extraBranches * 20;
}
// Extra storage
const extraStorageGb = Math.max(0, Number(usage.storageUsedGb) - plan.storageGb);
if (extraStorageGb > 0) {
// Assume $0.50 per extra GB
billableAmount += extraStorageGb * 0.5;
}
// Extra API calls
const extraApiCalls = Math.max(0, usage.apiCalls - plan.apiCallsMonthly);
if (extraApiCalls > 0) {
// Assume $0.001 per extra API call
billableAmount += extraApiCalls * 0.001;
}
return billableAmount;
}
/**
* Get current period dates (first and last day of current month)
*/
private getCurrentPeriodDates(): { start: Date; end: Date } {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return { start, end };
}
}

View File

@ -0,0 +1,81 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
Index,
ManyToOne,
JoinColumn,
Unique,
} from 'typeorm';
import { Device, BiometricType } from './device.entity';
@Entity({ name: 'biometric_credentials', schema: 'auth' })
@Unique(['deviceId', 'credentialId'])
export class BiometricCredential {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'device_id', type: 'uuid' })
deviceId: string;
@Index()
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
// Tipo de biometrico
@Index()
@Column({ name: 'biometric_type', type: 'varchar', length: 50 })
biometricType: BiometricType;
// Credencial (public key para WebAuthn/FIDO2)
@Column({ name: 'credential_id', type: 'text' })
credentialId: string;
@Column({ name: 'public_key', type: 'text' })
publicKey: string;
@Column({ type: 'varchar', length: 20, default: 'ES256' })
algorithm: string;
// Metadata
@Column({ name: 'credential_name', type: 'varchar', length: 100, nullable: true })
credentialName: string; // "Huella indice derecho", "Face ID iPhone"
@Column({ name: 'is_primary', type: 'boolean', default: false })
isPrimary: boolean;
// Estado
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'last_used_at', type: 'timestamptz', nullable: true })
lastUsedAt: Date;
@Column({ name: 'use_count', type: 'integer', default: 0 })
useCount: number;
// Seguridad
@Column({ name: 'failed_attempts', type: 'integer', default: 0 })
failedAttempts: number;
@Column({ name: 'locked_until', type: 'timestamptz', nullable: true })
lockedUntil: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date;
// Relaciones
@ManyToOne(() => Device, (device) => device.biometricCredentials, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'device_id' })
device: Device;
}

View File

@ -0,0 +1,50 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
} from 'typeorm';
export type ActivityType = 'login' | 'logout' | 'biometric_auth' | 'location_update' | 'app_open' | 'app_close';
export type ActivityStatus = 'success' | 'failed' | 'blocked';
@Entity({ name: 'device_activity_log', schema: 'auth' })
export class DeviceActivityLog {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'device_id', type: 'uuid' })
deviceId: string;
@Index()
@Column({ name: 'user_id', type: 'uuid', nullable: true })
userId: string;
// Actividad
@Index()
@Column({ name: 'activity_type', type: 'varchar', length: 50 })
activityType: ActivityType;
@Column({ name: 'activity_status', type: 'varchar', length: 20 })
activityStatus: ActivityStatus;
// Detalles
@Column({ type: 'jsonb', default: {} })
details: Record<string, any>;
// Ubicacion
@Column({ name: 'ip_address', type: 'inet', nullable: true })
ipAddress: string;
@Column({ type: 'decimal', precision: 10, scale: 8, nullable: true })
latitude: number;
@Column({ type: 'decimal', precision: 11, scale: 8, nullable: true })
longitude: number;
@Index()
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}

View File

@ -0,0 +1,84 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Device } from './device.entity';
export type AuthMethod = 'password' | 'biometric' | 'oauth' | 'mfa';
@Entity({ name: 'device_sessions', schema: 'auth' })
export class DeviceSession {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'device_id', type: 'uuid' })
deviceId: string;
@Index()
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
tenantId: string;
// Tokens
@Index()
@Column({ name: 'access_token_hash', type: 'varchar', length: 255 })
accessTokenHash: string;
@Column({ name: 'refresh_token_hash', type: 'varchar', length: 255, nullable: true })
refreshTokenHash: string;
// Metodo de autenticacion
@Column({ name: 'auth_method', type: 'varchar', length: 50 })
authMethod: AuthMethod;
// Validez
@Column({ name: 'issued_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
issuedAt: Date;
@Index()
@Column({ name: 'expires_at', type: 'timestamptz' })
expiresAt: Date;
@Column({ name: 'refresh_expires_at', type: 'timestamptz', nullable: true })
refreshExpiresAt: Date;
// Estado
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
revokedAt: Date;
@Column({ name: 'revoked_reason', type: 'varchar', length: 100, nullable: true })
revokedReason: string;
// Ubicacion
@Column({ name: 'ip_address', type: 'inet', nullable: true })
ipAddress: string;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent: string;
@Column({ type: 'decimal', precision: 10, scale: 8, nullable: true })
latitude: number;
@Column({ type: 'decimal', precision: 11, scale: 8, nullable: true })
longitude: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
// Relaciones
@ManyToOne(() => Device, (device) => device.sessions, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'device_id' })
device: Device;
}

View File

@ -0,0 +1,121 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
Index,
OneToMany,
Unique,
} from 'typeorm';
import { BiometricCredential } from './biometric-credential.entity';
import { DeviceSession } from './device-session.entity';
export type DevicePlatform = 'ios' | 'android' | 'web' | 'desktop';
export type BiometricType = 'fingerprint' | 'face_id' | 'face_recognition' | 'iris';
@Entity({ name: 'devices', schema: 'auth' })
@Unique(['userId', 'deviceUuid'])
export class Device {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid', nullable: true })
tenantId: string;
// Identificacion del dispositivo
@Index()
@Column({ name: 'device_uuid', type: 'varchar', length: 100 })
deviceUuid: string;
@Column({ name: 'device_name', type: 'varchar', length: 100, nullable: true })
deviceName: string;
@Column({ name: 'device_model', type: 'varchar', length: 100, nullable: true })
deviceModel: string;
@Column({ name: 'device_brand', type: 'varchar', length: 50, nullable: true })
deviceBrand: string;
// Plataforma
@Index()
@Column({ type: 'varchar', length: 20 })
platform: DevicePlatform;
@Column({ name: 'platform_version', type: 'varchar', length: 20, nullable: true })
platformVersion: string;
@Column({ name: 'app_version', type: 'varchar', length: 20, nullable: true })
appVersion: string;
// Estado
@Index()
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'is_trusted', type: 'boolean', default: false })
isTrusted: boolean;
@Column({ name: 'trust_level', type: 'integer', default: 0 })
trustLevel: number; // 0=none, 1=low, 2=medium, 3=high
// Biometricos habilitados
@Column({ name: 'biometric_enabled', type: 'boolean', default: false })
biometricEnabled: boolean;
@Column({ name: 'biometric_type', type: 'varchar', length: 50, nullable: true })
biometricType: BiometricType;
// Push notifications
@Column({ name: 'push_token', type: 'text', nullable: true })
pushToken: string;
@Column({ name: 'push_token_updated_at', type: 'timestamptz', nullable: true })
pushTokenUpdatedAt: Date;
// Ubicacion ultima conocida
@Column({ name: 'last_latitude', type: 'decimal', precision: 10, scale: 8, nullable: true })
lastLatitude: number;
@Column({ name: 'last_longitude', type: 'decimal', precision: 11, scale: 8, nullable: true })
lastLongitude: number;
@Column({ name: 'last_location_at', type: 'timestamptz', nullable: true })
lastLocationAt: Date;
// Seguridad
@Column({ name: 'last_ip_address', type: 'inet', nullable: true })
lastIpAddress: string;
@Column({ name: 'last_user_agent', type: 'text', nullable: true })
lastUserAgent: string;
// Registro
@Column({ name: 'first_seen_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
firstSeenAt: Date;
@Column({ name: 'last_seen_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
lastSeenAt: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date;
// Relaciones
@OneToMany(() => BiometricCredential, (credential) => credential.device)
biometricCredentials: BiometricCredential[];
@OneToMany(() => DeviceSession, (session) => session.device)
sessions: DeviceSession[];
}

View File

@ -0,0 +1,4 @@
export { Device, DevicePlatform, BiometricType } from './device.entity';
export { BiometricCredential } from './biometric-credential.entity';
export { DeviceSession, AuthMethod } from './device-session.entity';
export { DeviceActivityLog, ActivityType, ActivityStatus } from './device-activity-log.entity';

View File

@ -0,0 +1,48 @@
import { Router } from 'express';
import { DataSource } from 'typeorm';
import { BranchesService } from './services';
import { BranchesController } from './controllers';
import { Branch, UserBranchAssignment, BranchSchedule, BranchPaymentTerminal } from './entities';
export interface BranchesModuleOptions {
dataSource: DataSource;
basePath?: string;
}
export class BranchesModule {
public router: Router;
public branchesService: BranchesService;
private dataSource: DataSource;
private basePath: string;
constructor(options: BranchesModuleOptions) {
this.dataSource = options.dataSource;
this.basePath = options.basePath || '';
this.router = Router();
this.initializeServices();
this.initializeRoutes();
}
private initializeServices(): void {
const branchRepository = this.dataSource.getRepository(Branch);
const assignmentRepository = this.dataSource.getRepository(UserBranchAssignment);
const scheduleRepository = this.dataSource.getRepository(BranchSchedule);
const terminalRepository = this.dataSource.getRepository(BranchPaymentTerminal);
this.branchesService = new BranchesService(
branchRepository,
assignmentRepository,
scheduleRepository,
terminalRepository
);
}
private initializeRoutes(): void {
const branchesController = new BranchesController(this.branchesService);
this.router.use(`${this.basePath}/branches`, branchesController.router);
}
static getEntities(): Function[] {
return [Branch, UserBranchAssignment, BranchSchedule, BranchPaymentTerminal];
}
}

View File

@ -0,0 +1,364 @@
import { Request, Response, NextFunction, Router } from 'express';
import { BranchesService } from '../services/branches.service';
import { CreateBranchDto, UpdateBranchDto, AssignUserToBranchDto, CreateBranchScheduleDto } from '../dto';
export class BranchesController {
public router: Router;
constructor(private readonly branchesService: BranchesService) {
this.router = Router();
this.initializeRoutes();
}
private initializeRoutes(): void {
// Branch CRUD
this.router.get('/', this.findAll.bind(this));
this.router.get('/hierarchy', this.getHierarchy.bind(this));
this.router.get('/main', this.getMainBranch.bind(this));
this.router.get('/nearby', this.findNearbyBranches.bind(this));
this.router.get('/:id', this.findOne.bind(this));
this.router.get('/code/:code', this.findByCode.bind(this));
this.router.post('/', this.create.bind(this));
this.router.patch('/:id', this.update.bind(this));
this.router.delete('/:id', this.delete.bind(this));
this.router.post('/:id/set-main', this.setAsMainBranch.bind(this));
// Hierarchy
this.router.get('/:id/children', this.getChildren.bind(this));
this.router.get('/:id/parents', this.getParents.bind(this));
// User Assignments
this.router.post('/assign', this.assignUser.bind(this));
this.router.delete('/assign/:userId/:branchId', this.unassignUser.bind(this));
this.router.get('/user/:userId', this.getUserBranches.bind(this));
this.router.get('/user/:userId/primary', this.getPrimaryBranch.bind(this));
this.router.get('/:id/users', this.getBranchUsers.bind(this));
// Geofencing
this.router.post('/validate-geofence', this.validateGeofence.bind(this));
// Schedules
this.router.get('/:id/schedules', this.getSchedules.bind(this));
this.router.post('/:id/schedules', this.addSchedule.bind(this));
this.router.get('/:id/is-open', this.isOpenNow.bind(this));
}
// ============================================
// BRANCH CRUD
// ============================================
private async findAll(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { search, branchType, isActive, parentId, limit, offset } = req.query;
const result = await this.branchesService.findAll(tenantId, {
search: search as string,
branchType: branchType as string,
isActive: isActive ? isActive === 'true' : undefined,
parentId: parentId as string,
limit: limit ? parseInt(limit as string, 10) : undefined,
offset: offset ? parseInt(offset as string, 10) : undefined,
});
res.json(result);
} catch (error) {
next(error);
}
}
private async findOne(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const branch = await this.branchesService.findOne(id);
if (!branch) {
res.status(404).json({ error: 'Branch not found' });
return;
}
res.json({ data: branch });
} catch (error) {
next(error);
}
}
private async findByCode(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { code } = req.params;
const branch = await this.branchesService.findByCode(tenantId, code);
if (!branch) {
res.status(404).json({ error: 'Branch not found' });
return;
}
res.json({ data: branch });
} catch (error) {
next(error);
}
}
private async create(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
const dto: CreateBranchDto = req.body;
const branch = await this.branchesService.create(tenantId, dto, userId);
res.status(201).json({ data: branch });
} catch (error) {
next(error);
}
}
private async update(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const userId = req.headers['x-user-id'] as string;
const dto: UpdateBranchDto = req.body;
const branch = await this.branchesService.update(id, dto, userId);
if (!branch) {
res.status(404).json({ error: 'Branch not found' });
return;
}
res.json({ data: branch });
} catch (error) {
next(error);
}
}
private async delete(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const deleted = await this.branchesService.delete(id);
if (!deleted) {
res.status(404).json({ error: 'Branch not found' });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
}
// ============================================
// HIERARCHY
// ============================================
private async getHierarchy(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const hierarchy = await this.branchesService.getHierarchy(tenantId);
res.json({ data: hierarchy });
} catch (error) {
next(error);
}
}
private async getChildren(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const { recursive } = req.query;
const children = await this.branchesService.getChildren(id, recursive === 'true');
res.json({ data: children });
} catch (error) {
next(error);
}
}
private async getParents(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const parents = await this.branchesService.getParents(id);
res.json({ data: parents });
} catch (error) {
next(error);
}
}
// ============================================
// USER ASSIGNMENTS
// ============================================
private async assignUser(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const assignedBy = req.headers['x-user-id'] as string;
const dto: AssignUserToBranchDto = req.body;
const assignment = await this.branchesService.assignUser(tenantId, dto, assignedBy);
res.status(201).json({ data: assignment });
} catch (error) {
next(error);
}
}
private async unassignUser(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { userId, branchId } = req.params;
const unassigned = await this.branchesService.unassignUser(userId, branchId);
if (!unassigned) {
res.status(404).json({ error: 'Assignment not found' });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
}
private async getUserBranches(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { userId } = req.params;
const branches = await this.branchesService.getUserBranches(userId);
res.json({ data: branches });
} catch (error) {
next(error);
}
}
private async getPrimaryBranch(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { userId } = req.params;
const branch = await this.branchesService.getPrimaryBranch(userId);
if (!branch) {
res.status(404).json({ error: 'No primary branch found' });
return;
}
res.json({ data: branch });
} catch (error) {
next(error);
}
}
private async getBranchUsers(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const users = await this.branchesService.getBranchUsers(id);
res.json({ data: users });
} catch (error) {
next(error);
}
}
// ============================================
// GEOFENCING
// ============================================
private async validateGeofence(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { branchId, latitude, longitude } = req.body;
const result = await this.branchesService.validateGeofence(branchId, latitude, longitude);
res.json({ data: result });
} catch (error) {
next(error);
}
}
private async findNearbyBranches(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const { latitude, longitude, radius } = req.query;
if (!latitude || !longitude) {
res.status(400).json({ error: 'Latitude and longitude are required' });
return;
}
const branches = await this.branchesService.findNearbyBranches(
tenantId,
parseFloat(latitude as string),
parseFloat(longitude as string),
radius ? parseInt(radius as string, 10) : undefined
);
res.json({ data: branches });
} catch (error) {
next(error);
}
}
// ============================================
// SCHEDULES
// ============================================
private async getSchedules(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const schedules = await this.branchesService.getSchedules(id);
res.json({ data: schedules });
} catch (error) {
next(error);
}
}
private async addSchedule(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const dto: CreateBranchScheduleDto = req.body;
const schedule = await this.branchesService.addSchedule(id, dto);
res.status(201).json({ data: schedule });
} catch (error) {
next(error);
}
}
private async isOpenNow(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const isOpen = await this.branchesService.isOpenNow(id);
res.json({ data: { isOpen } });
} catch (error) {
next(error);
}
}
// ============================================
// MAIN BRANCH
// ============================================
private async getMainBranch(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const branch = await this.branchesService.getMainBranch(tenantId);
if (!branch) {
res.status(404).json({ error: 'No main branch found' });
return;
}
res.json({ data: branch });
} catch (error) {
next(error);
}
}
private async setAsMainBranch(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const branch = await this.branchesService.setAsMainBranch(id);
if (!branch) {
res.status(404).json({ error: 'Branch not found' });
return;
}
res.json({ data: branch });
} catch (error) {
next(error);
}
}
}

View File

@ -0,0 +1 @@
export { BranchesController } from './branches.controller';

View File

@ -0,0 +1,100 @@
import {
IsString,
IsOptional,
IsBoolean,
IsNumber,
IsArray,
IsEnum,
MaxLength,
IsDateString,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ScheduleType } from '../entities/branch-schedule.entity';
class ShiftDto {
@IsString()
@MaxLength(50)
name: string;
@IsString()
start: string;
@IsString()
end: string;
}
export class CreateBranchScheduleDto {
@IsString()
@MaxLength(100)
name: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(['regular', 'holiday', 'special'])
scheduleType?: ScheduleType;
@IsOptional()
@IsNumber()
dayOfWeek?: number; // 0=domingo, 1=lunes, ..., 6=sabado
@IsOptional()
@IsDateString()
specificDate?: string;
@IsString()
openTime: string;
@IsString()
closeTime: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ShiftDto)
shifts?: ShiftDto[];
}
export class UpdateBranchScheduleDto {
@IsOptional()
@IsString()
@MaxLength(100)
name?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsEnum(['regular', 'holiday', 'special'])
scheduleType?: ScheduleType;
@IsOptional()
@IsNumber()
dayOfWeek?: number;
@IsOptional()
@IsDateString()
specificDate?: string;
@IsOptional()
@IsString()
openTime?: string;
@IsOptional()
@IsString()
closeTime?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ShiftDto)
shifts?: ShiftDto[];
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@ -0,0 +1,265 @@
import {
IsString,
IsOptional,
IsBoolean,
IsNumber,
IsObject,
IsUUID,
IsArray,
MaxLength,
MinLength,
IsEnum,
IsLatitude,
IsLongitude,
} from 'class-validator';
import { BranchType } from '../entities/branch.entity';
export class CreateBranchDto {
@IsString()
@MinLength(2)
@MaxLength(20)
code: string;
@IsString()
@MinLength(2)
@MaxLength(100)
name: string;
@IsOptional()
@IsString()
@MaxLength(50)
shortName?: string;
@IsOptional()
@IsEnum(['headquarters', 'regional', 'store', 'warehouse', 'office', 'factory'])
branchType?: BranchType;
@IsOptional()
@IsUUID()
parentId?: string;
@IsOptional()
@IsString()
@MaxLength(20)
phone?: string;
@IsOptional()
@IsString()
@MaxLength(255)
email?: string;
@IsOptional()
@IsUUID()
managerId?: string;
@IsOptional()
@IsString()
@MaxLength(200)
addressLine1?: string;
@IsOptional()
@IsString()
@MaxLength(200)
addressLine2?: string;
@IsOptional()
@IsString()
@MaxLength(100)
city?: string;
@IsOptional()
@IsString()
@MaxLength(100)
state?: string;
@IsOptional()
@IsString()
@MaxLength(20)
postalCode?: string;
@IsOptional()
@IsString()
@MaxLength(3)
country?: string;
@IsOptional()
@IsNumber()
latitude?: number;
@IsOptional()
@IsNumber()
longitude?: number;
@IsOptional()
@IsNumber()
geofenceRadius?: number;
@IsOptional()
@IsBoolean()
geofenceEnabled?: boolean;
@IsOptional()
@IsString()
@MaxLength(50)
timezone?: string;
@IsOptional()
@IsString()
@MaxLength(3)
currency?: string;
@IsOptional()
@IsBoolean()
isMain?: boolean;
@IsOptional()
@IsObject()
operatingHours?: Record<string, { open: string; close: string }>;
@IsOptional()
@IsObject()
settings?: Record<string, any>;
}
export class UpdateBranchDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(100)
name?: string;
@IsOptional()
@IsString()
@MaxLength(50)
shortName?: string;
@IsOptional()
@IsEnum(['headquarters', 'regional', 'store', 'warehouse', 'office', 'factory'])
branchType?: BranchType;
@IsOptional()
@IsUUID()
parentId?: string;
@IsOptional()
@IsString()
@MaxLength(20)
phone?: string;
@IsOptional()
@IsString()
@MaxLength(255)
email?: string;
@IsOptional()
@IsUUID()
managerId?: string;
@IsOptional()
@IsString()
@MaxLength(200)
addressLine1?: string;
@IsOptional()
@IsString()
@MaxLength(200)
addressLine2?: string;
@IsOptional()
@IsString()
@MaxLength(100)
city?: string;
@IsOptional()
@IsString()
@MaxLength(100)
state?: string;
@IsOptional()
@IsString()
@MaxLength(20)
postalCode?: string;
@IsOptional()
@IsString()
@MaxLength(3)
country?: string;
@IsOptional()
@IsNumber()
latitude?: number;
@IsOptional()
@IsNumber()
longitude?: number;
@IsOptional()
@IsNumber()
geofenceRadius?: number;
@IsOptional()
@IsBoolean()
geofenceEnabled?: boolean;
@IsOptional()
@IsString()
@MaxLength(50)
timezone?: string;
@IsOptional()
@IsString()
@MaxLength(3)
currency?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsBoolean()
isMain?: boolean;
@IsOptional()
@IsObject()
operatingHours?: Record<string, { open: string; close: string }>;
@IsOptional()
@IsObject()
settings?: Record<string, any>;
}
export class AssignUserToBranchDto {
@IsUUID()
userId: string;
@IsUUID()
branchId: string;
@IsOptional()
@IsEnum(['primary', 'secondary', 'temporary', 'floating'])
assignmentType?: string;
@IsOptional()
@IsEnum(['manager', 'supervisor', 'staff'])
branchRole?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissions?: string[];
@IsOptional()
@IsString()
validUntil?: string;
}
export class ValidateGeofenceDto {
@IsUUID()
branchId: string;
@IsNumber()
latitude: number;
@IsNumber()
longitude: number;
}

View File

@ -0,0 +1,11 @@
export {
CreateBranchDto,
UpdateBranchDto,
AssignUserToBranchDto,
ValidateGeofenceDto,
} from './create-branch.dto';
export {
CreateBranchScheduleDto,
UpdateBranchScheduleDto,
} from './branch-schedule.dto';

View File

@ -0,0 +1,63 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
JoinColumn,
Index,
} from 'typeorm';
import { Branch } from './branch.entity';
/**
* Configuración de inventario por sucursal.
* Mapea a core.branch_inventory_settings (DDL: 03-core-branches.sql)
*/
@Entity({ name: 'branch_inventory_settings', schema: 'core' })
export class BranchInventorySettings {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'branch_id', type: 'uuid' })
branchId: string;
@OneToOne(() => Branch, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'branch_id' })
branch: Branch;
// Almacén asociado (referencia externa a inventory.warehouses)
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
warehouseId: string;
// Configuración de stock
@Column({ name: 'default_stock_min', type: 'integer', default: 0 })
defaultStockMin: number;
@Column({ name: 'default_stock_max', type: 'integer', default: 1000 })
defaultStockMax: number;
@Column({ name: 'auto_reorder_enabled', type: 'boolean', default: false })
autoReorderEnabled: boolean;
// Configuración de precios (referencia externa a sales.price_lists)
@Column({ name: 'price_list_id', type: 'uuid', nullable: true })
priceListId: string;
@Column({ name: 'allow_price_override', type: 'boolean', default: false })
allowPriceOverride: boolean;
@Column({ name: 'max_discount_percent', type: 'decimal', precision: 5, scale: 2, default: 0 })
maxDiscountPercent: number;
// Configuración de impuestos
@Column({ name: 'tax_config', type: 'jsonb', default: {} })
taxConfig: Record<string, any>;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -0,0 +1,77 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
Unique,
} from 'typeorm';
import { Branch } from './branch.entity';
export type TerminalProvider = 'clip' | 'mercadopago' | 'stripe';
export type HealthStatus = 'healthy' | 'degraded' | 'offline' | 'unknown';
@Entity({ name: 'branch_payment_terminals', schema: 'core' })
@Unique(['branchId', 'terminalProvider', 'terminalId'])
export class BranchPaymentTerminal {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'branch_id', type: 'uuid' })
branchId: string;
// Terminal
@Index()
@Column({ name: 'terminal_provider', type: 'varchar', length: 30 })
terminalProvider: TerminalProvider;
@Column({ name: 'terminal_id', type: 'varchar', length: 100 })
terminalId: string;
@Column({ name: 'terminal_name', type: 'varchar', length: 100, nullable: true })
terminalName: string;
// Credenciales (encriptadas)
@Column({ type: 'jsonb', default: {} })
credentials: Record<string, any>;
// Configuracion
@Column({ name: 'is_primary', type: 'boolean', default: false })
isPrimary: boolean;
@Index()
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
// Limites
@Column({ name: 'daily_limit', type: 'decimal', precision: 12, scale: 2, nullable: true })
dailyLimit: number;
@Column({ name: 'transaction_limit', type: 'decimal', precision: 12, scale: 2, nullable: true })
transactionLimit: number;
// Ultima actividad
@Column({ name: 'last_transaction_at', type: 'timestamptz', nullable: true })
lastTransactionAt: Date;
@Column({ name: 'last_health_check_at', type: 'timestamptz', nullable: true })
lastHealthCheckAt: Date;
@Column({ name: 'health_status', type: 'varchar', length: 20, default: 'unknown' })
healthStatus: HealthStatus;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relaciones
@ManyToOne(() => Branch, (branch) => branch.paymentTerminals, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'branch_id' })
branch: Branch;
}

View File

@ -0,0 +1,73 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Branch } from './branch.entity';
export type ScheduleType = 'regular' | 'holiday' | 'special';
@Entity({ name: 'branch_schedules', schema: 'core' })
export class BranchSchedule {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'branch_id', type: 'uuid' })
branchId: string;
// Identificacion
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
// Tipo
@Column({ name: 'schedule_type', type: 'varchar', length: 30, default: 'regular' })
scheduleType: ScheduleType;
// Dia de la semana (0=domingo, 1=lunes, ..., 6=sabado) o fecha especifica
@Index()
@Column({ name: 'day_of_week', type: 'integer', nullable: true })
dayOfWeek: number;
@Index()
@Column({ name: 'specific_date', type: 'date', nullable: true })
specificDate: Date;
// Horarios
@Column({ name: 'open_time', type: 'time' })
openTime: string;
@Column({ name: 'close_time', type: 'time' })
closeTime: string;
// Turnos (si aplica)
@Column({ type: 'jsonb', default: [] })
shifts: Array<{
name: string;
start: string;
end: string;
}>;
// Estado
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relaciones
@ManyToOne(() => Branch, (branch) => branch.schedules, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'branch_id' })
branch: Branch;
}

View File

@ -0,0 +1,158 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
Index,
ManyToOne,
OneToMany,
JoinColumn,
Unique,
} from 'typeorm';
import { UserBranchAssignment } from './user-branch-assignment.entity';
import { BranchSchedule } from './branch-schedule.entity';
import { BranchPaymentTerminal } from './branch-payment-terminal.entity';
export type BranchType = 'headquarters' | 'regional' | 'store' | 'warehouse' | 'office' | 'factory';
@Entity({ name: 'branches', schema: 'core' })
@Unique(['tenantId', 'code'])
export class Branch {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Index()
@Column({ name: 'parent_id', type: 'uuid', nullable: true })
parentId: string;
// Identificacion
@Index()
@Column({ type: 'varchar', length: 20 })
code: string;
@Column({ type: 'varchar', length: 100 })
name: string;
@Column({ name: 'short_name', type: 'varchar', length: 50, nullable: true })
shortName: string;
// Tipo
@Index()
@Column({ name: 'branch_type', type: 'varchar', length: 30, default: 'store' })
branchType: BranchType;
// Contacto
@Column({ type: 'varchar', length: 20, nullable: true })
phone: string;
@Column({ type: 'varchar', length: 255, nullable: true })
email: string;
@Column({ name: 'manager_id', type: 'uuid', nullable: true })
managerId: string;
// Direccion
@Column({ name: 'address_line1', type: 'varchar', length: 200, nullable: true })
addressLine1: string;
@Column({ name: 'address_line2', type: 'varchar', length: 200, nullable: true })
addressLine2: string;
@Column({ type: 'varchar', length: 100, nullable: true })
city: string;
@Column({ type: 'varchar', length: 100, nullable: true })
state: string;
@Column({ name: 'postal_code', type: 'varchar', length: 20, nullable: true })
postalCode: string;
@Column({ type: 'varchar', length: 3, default: 'MEX' })
country: string;
// Geolocalizacion
@Column({ type: 'decimal', precision: 10, scale: 8, nullable: true })
latitude: number;
@Column({ type: 'decimal', precision: 11, scale: 8, nullable: true })
longitude: number;
@Column({ name: 'geofence_radius', type: 'integer', default: 100 })
geofenceRadius: number; // Radio en metros
@Column({ name: 'geofence_enabled', type: 'boolean', default: true })
geofenceEnabled: boolean;
// Configuracion
@Column({ type: 'varchar', length: 50, default: 'America/Mexico_City' })
timezone: string;
@Column({ type: 'varchar', length: 3, default: 'MXN' })
currency: string;
@Index()
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@Column({ name: 'is_main', type: 'boolean', default: false })
isMain: boolean; // Sucursal principal/matriz
// Horarios de operacion
@Column({ name: 'operating_hours', type: 'jsonb', default: {} })
operatingHours: Record<string, { open: string; close: string }>;
// Configuraciones especificas
@Column({ type: 'jsonb', default: {} })
settings: {
allowPos?: boolean;
allowWarehouse?: boolean;
allowCheckIn?: boolean;
[key: string]: any;
};
// Jerarquia (path materializado)
@Index()
@Column({ name: 'hierarchy_path', type: 'text', nullable: true })
hierarchyPath: string;
@Column({ name: 'hierarchy_level', type: 'integer', default: 0 })
hierarchyLevel: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy: string;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
deletedAt: Date;
// Relaciones
@ManyToOne(() => Branch, { nullable: true })
@JoinColumn({ name: 'parent_id' })
parent: Branch;
@OneToMany(() => Branch, (branch) => branch.parent)
children: Branch[];
@OneToMany(() => UserBranchAssignment, (assignment) => assignment.branch)
userAssignments: UserBranchAssignment[];
@OneToMany(() => BranchSchedule, (schedule) => schedule.branch)
schedules: BranchSchedule[];
@OneToMany(() => BranchPaymentTerminal, (terminal) => terminal.branch)
paymentTerminals: BranchPaymentTerminal[];
}

View File

@ -0,0 +1,5 @@
export { Branch, BranchType } from './branch.entity';
export { UserBranchAssignment, AssignmentType, BranchRole } from './user-branch-assignment.entity';
export { BranchSchedule, ScheduleType } from './branch-schedule.entity';
export { BranchPaymentTerminal, TerminalProvider, HealthStatus } from './branch-payment-terminal.entity';
export { BranchInventorySettings } from './branch-inventory-settings.entity';

View File

@ -0,0 +1,72 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
ManyToOne,
JoinColumn,
Unique,
} from 'typeorm';
import { Branch } from './branch.entity';
export type AssignmentType = 'primary' | 'secondary' | 'temporary' | 'floating';
export type BranchRole = 'manager' | 'supervisor' | 'staff';
@Entity({ name: 'user_branch_assignments', schema: 'core' })
@Unique(['userId', 'branchId', 'assignmentType'])
export class UserBranchAssignment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@Index()
@Column({ name: 'branch_id', type: 'uuid' })
branchId: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
// Tipo de asignacion
@Column({ name: 'assignment_type', type: 'varchar', length: 30, default: 'primary' })
assignmentType: AssignmentType;
// Rol en la sucursal
@Column({ name: 'branch_role', type: 'varchar', length: 50, nullable: true })
branchRole: BranchRole;
// Permisos especificos
@Column({ type: 'jsonb', default: [] })
permissions: string[];
// Vigencia (para asignaciones temporales)
@Column({ name: 'valid_from', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
validFrom: Date;
@Column({ name: 'valid_until', type: 'timestamptz', nullable: true })
validUntil: Date;
// Estado
@Index()
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
// Relaciones
@ManyToOne(() => Branch, (branch) => branch.userAssignments, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'branch_id' })
branch: Branch;
}

View File

@ -0,0 +1,5 @@
export { BranchesModule, BranchesModuleOptions } from './branches.module';
export * from './entities';
export * from './services';
export * from './controllers';
export * from './dto';

View File

@ -0,0 +1,435 @@
import { Repository, FindOptionsWhere, ILike, IsNull, In } from 'typeorm';
import { Branch, UserBranchAssignment, BranchSchedule, BranchPaymentTerminal } from '../entities';
import { CreateBranchDto, UpdateBranchDto, AssignUserToBranchDto, CreateBranchScheduleDto } from '../dto';
export interface BranchSearchParams {
search?: string;
branchType?: string;
isActive?: boolean;
parentId?: string;
includeChildren?: boolean;
limit?: number;
offset?: number;
}
export class BranchesService {
constructor(
private readonly branchRepository: Repository<Branch>,
private readonly assignmentRepository: Repository<UserBranchAssignment>,
private readonly scheduleRepository: Repository<BranchSchedule>,
private readonly terminalRepository: Repository<BranchPaymentTerminal>
) {}
// ============================================
// BRANCH CRUD
// ============================================
async findAll(tenantId: string, params: BranchSearchParams = {}): Promise<{ data: Branch[]; total: number }> {
const { search, branchType, isActive, parentId, limit = 50, offset = 0 } = params;
const where: FindOptionsWhere<Branch> = { tenantId };
if (branchType) where.branchType = branchType as any;
if (isActive !== undefined) where.isActive = isActive;
if (parentId) where.parentId = parentId;
if (parentId === null) where.parentId = IsNull();
const queryBuilder = this.branchRepository
.createQueryBuilder('branch')
.where('branch.tenant_id = :tenantId', { tenantId })
.leftJoinAndSelect('branch.schedules', 'schedules')
.leftJoinAndSelect('branch.paymentTerminals', 'terminals');
if (search) {
queryBuilder.andWhere('(branch.name ILIKE :search OR branch.code ILIKE :search OR branch.city ILIKE :search)', {
search: `%${search}%`,
});
}
if (branchType) {
queryBuilder.andWhere('branch.branch_type = :branchType', { branchType });
}
if (isActive !== undefined) {
queryBuilder.andWhere('branch.is_active = :isActive', { isActive });
}
if (parentId) {
queryBuilder.andWhere('branch.parent_id = :parentId', { parentId });
} else if (parentId === null) {
queryBuilder.andWhere('branch.parent_id IS NULL');
}
queryBuilder.orderBy('branch.hierarchy_path', 'ASC').addOrderBy('branch.name', 'ASC');
const total = await queryBuilder.getCount();
const data = await queryBuilder.skip(offset).take(limit).getMany();
return { data, total };
}
async findOne(id: string): Promise<Branch | null> {
return this.branchRepository.findOne({
where: { id },
relations: ['parent', 'children', 'schedules', 'paymentTerminals', 'userAssignments'],
});
}
async findByCode(tenantId: string, code: string): Promise<Branch | null> {
return this.branchRepository.findOne({
where: { tenantId, code },
relations: ['schedules', 'paymentTerminals'],
});
}
async create(tenantId: string, dto: CreateBranchDto, createdBy?: string): Promise<Branch> {
// Check for duplicate code
const existing = await this.findByCode(tenantId, dto.code);
if (existing) {
throw new Error(`Branch with code '${dto.code}' already exists`);
}
// Build hierarchy path
let hierarchyPath = `/${dto.code}`;
let hierarchyLevel = 0;
if (dto.parentId) {
const parent = await this.findOne(dto.parentId);
if (!parent) {
throw new Error('Parent branch not found');
}
hierarchyPath = `${parent.hierarchyPath}/${dto.code}`;
hierarchyLevel = parent.hierarchyLevel + 1;
}
const branch = this.branchRepository.create({
...dto,
tenantId,
hierarchyPath,
hierarchyLevel,
createdBy,
});
return this.branchRepository.save(branch);
}
async update(id: string, dto: UpdateBranchDto, updatedBy?: string): Promise<Branch | null> {
const branch = await this.findOne(id);
if (!branch) return null;
// If changing parent, update hierarchy
if (dto.parentId !== undefined && dto.parentId !== branch.parentId) {
if (dto.parentId) {
const newParent = await this.findOne(dto.parentId);
if (!newParent) {
throw new Error('New parent branch not found');
}
// Check for circular reference
if (newParent.hierarchyPath.includes(`/${branch.code}/`) || newParent.id === branch.id) {
throw new Error('Cannot create circular reference in branch hierarchy');
}
branch.hierarchyPath = `${newParent.hierarchyPath}/${branch.code}`;
branch.hierarchyLevel = newParent.hierarchyLevel + 1;
} else {
branch.hierarchyPath = `/${branch.code}`;
branch.hierarchyLevel = 0;
}
// Update children hierarchy paths
await this.updateChildrenHierarchy(branch);
}
Object.assign(branch, dto, { updatedBy });
return this.branchRepository.save(branch);
}
private async updateChildrenHierarchy(parent: Branch): Promise<void> {
const children = await this.branchRepository.find({
where: { parentId: parent.id },
});
for (const child of children) {
child.hierarchyPath = `${parent.hierarchyPath}/${child.code}`;
child.hierarchyLevel = parent.hierarchyLevel + 1;
await this.branchRepository.save(child);
await this.updateChildrenHierarchy(child);
}
}
async delete(id: string): Promise<boolean> {
const branch = await this.findOne(id);
if (!branch) return false;
// Check if has children
const childrenCount = await this.branchRepository.count({ where: { parentId: id } });
if (childrenCount > 0) {
throw new Error('Cannot delete branch with children. Delete children first or move them to another parent.');
}
await this.branchRepository.softDelete(id);
return true;
}
// ============================================
// HIERARCHY
// ============================================
async getHierarchy(tenantId: string): Promise<Branch[]> {
const branches = await this.branchRepository.find({
where: { tenantId, isActive: true },
order: { hierarchyPath: 'ASC' },
});
return this.buildTree(branches);
}
private buildTree(branches: Branch[], parentId: string | null = null): Branch[] {
return branches
.filter((b) => b.parentId === parentId)
.map((branch) => ({
...branch,
children: this.buildTree(branches, branch.id),
}));
}
async getChildren(branchId: string, recursive: boolean = false): Promise<Branch[]> {
if (!recursive) {
return this.branchRepository.find({
where: { parentId: branchId, isActive: true },
order: { name: 'ASC' },
});
}
const parent = await this.findOne(branchId);
if (!parent) return [];
return this.branchRepository
.createQueryBuilder('branch')
.where('branch.hierarchy_path LIKE :path', { path: `${parent.hierarchyPath}/%` })
.andWhere('branch.is_active = true')
.orderBy('branch.hierarchy_path', 'ASC')
.getMany();
}
async getParents(branchId: string): Promise<Branch[]> {
const branch = await this.findOne(branchId);
if (!branch || !branch.hierarchyPath) return [];
const codes = branch.hierarchyPath.split('/').filter((c) => c && c !== branch.code);
if (codes.length === 0) return [];
return this.branchRepository.find({
where: { tenantId: branch.tenantId, code: In(codes) },
order: { hierarchyLevel: 'ASC' },
});
}
// ============================================
// USER ASSIGNMENTS
// ============================================
async assignUser(tenantId: string, dto: AssignUserToBranchDto, assignedBy?: string): Promise<UserBranchAssignment> {
// Check if branch exists
const branch = await this.findOne(dto.branchId);
if (!branch || branch.tenantId !== tenantId) {
throw new Error('Branch not found');
}
// Check for existing assignment of same type
const existing = await this.assignmentRepository.findOne({
where: {
userId: dto.userId,
branchId: dto.branchId,
assignmentType: (dto.assignmentType as any) ?? 'primary',
},
});
if (existing) {
// Update existing
Object.assign(existing, {
branchRole: dto.branchRole ?? existing.branchRole,
permissions: dto.permissions ?? existing.permissions,
validUntil: dto.validUntil ? new Date(dto.validUntil) : existing.validUntil,
isActive: true,
});
return this.assignmentRepository.save(existing);
}
const assignment = this.assignmentRepository.create({
...dto,
tenantId,
validUntil: dto.validUntil ? new Date(dto.validUntil) : undefined,
createdBy: assignedBy,
} as any);
return this.assignmentRepository.save(assignment);
}
async unassignUser(userId: string, branchId: string): Promise<boolean> {
const result = await this.assignmentRepository.update({ userId, branchId }, { isActive: false });
return (result.affected ?? 0) > 0;
}
async getUserBranches(userId: string): Promise<Branch[]> {
const assignments = await this.assignmentRepository.find({
where: { userId, isActive: true },
relations: ['branch'],
});
return assignments.map((a) => a.branch).filter((b) => b != null);
}
async getBranchUsers(branchId: string): Promise<UserBranchAssignment[]> {
return this.assignmentRepository.find({
where: { branchId, isActive: true },
order: { branchRole: 'ASC' },
});
}
async getPrimaryBranch(userId: string): Promise<Branch | null> {
const assignment = await this.assignmentRepository.findOne({
where: { userId, assignmentType: 'primary' as any, isActive: true },
relations: ['branch'],
});
return assignment?.branch ?? null;
}
// ============================================
// GEOFENCING
// ============================================
async validateGeofence(branchId: string, latitude: number, longitude: number): Promise<{ valid: boolean; distance: number }> {
const branch = await this.findOne(branchId);
if (!branch) {
throw new Error('Branch not found');
}
if (!branch.geofenceEnabled) {
return { valid: true, distance: 0 };
}
if (!branch.latitude || !branch.longitude) {
return { valid: true, distance: 0 };
}
// Calculate distance using Haversine formula
const R = 6371000; // Earth's radius in meters
const dLat = this.toRad(latitude - branch.latitude);
const dLon = this.toRad(longitude - branch.longitude);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRad(branch.latitude)) * Math.cos(this.toRad(latitude)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distance = R * c;
return {
valid: distance <= branch.geofenceRadius,
distance: Math.round(distance),
};
}
private toRad(deg: number): number {
return deg * (Math.PI / 180);
}
async findNearbyBranches(tenantId: string, latitude: number, longitude: number, radiusMeters: number = 5000): Promise<Branch[]> {
// Use PostgreSQL's earthdistance extension if available, otherwise calculate in app
const branches = await this.branchRepository.find({
where: { tenantId, isActive: true },
});
return branches
.filter((b) => {
if (!b.latitude || !b.longitude) return false;
const result = this.calculateDistance(latitude, longitude, b.latitude, b.longitude);
return result <= radiusMeters;
})
.sort((a, b) => {
const distA = this.calculateDistance(latitude, longitude, a.latitude!, a.longitude!);
const distB = this.calculateDistance(latitude, longitude, b.latitude!, b.longitude!);
return distA - distB;
});
}
private calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371000;
const dLat = this.toRad(lat2 - lat1);
const dLon = this.toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRad(lat1)) * Math.cos(this.toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
// ============================================
// SCHEDULES
// ============================================
async addSchedule(branchId: string, dto: CreateBranchScheduleDto): Promise<BranchSchedule> {
const schedule = this.scheduleRepository.create({
...dto,
branchId,
specificDate: dto.specificDate ? new Date(dto.specificDate) : undefined,
});
return this.scheduleRepository.save(schedule);
}
async getSchedules(branchId: string): Promise<BranchSchedule[]> {
return this.scheduleRepository.find({
where: { branchId, isActive: true },
order: { dayOfWeek: 'ASC', specificDate: 'ASC' },
});
}
async isOpenNow(branchId: string): Promise<boolean> {
const schedules = await this.getSchedules(branchId);
const now = new Date();
const dayOfWeek = now.getDay();
const currentTime = now.toTimeString().slice(0, 5);
// Check for specific date schedule first
const today = now.toISOString().slice(0, 10);
const specificSchedule = schedules.find((s) => s.specificDate?.toISOString().slice(0, 10) === today);
if (specificSchedule) {
return currentTime >= specificSchedule.openTime && currentTime <= specificSchedule.closeTime;
}
// Check regular schedule
const regularSchedule = schedules.find((s) => s.dayOfWeek === dayOfWeek && s.scheduleType === 'regular');
if (regularSchedule) {
return currentTime >= regularSchedule.openTime && currentTime <= regularSchedule.closeTime;
}
return false;
}
// ============================================
// MAIN BRANCH
// ============================================
async getMainBranch(tenantId: string): Promise<Branch | null> {
return this.branchRepository.findOne({
where: { tenantId, isMain: true, isActive: true },
relations: ['schedules', 'paymentTerminals'],
});
}
async setAsMainBranch(branchId: string): Promise<Branch | null> {
const branch = await this.findOne(branchId);
if (!branch) return null;
// Unset current main branch
await this.branchRepository.update({ tenantId: branch.tenantId, isMain: true }, { isMain: false });
// Set new main branch
branch.isMain = true;
return this.branchRepository.save(branch);
}
}

View File

@ -0,0 +1 @@
export { BranchesService, BranchSearchParams } from './branches.service';

View File

@ -0,0 +1,96 @@
import { Request, Response, NextFunction, Router } from 'express';
import { DashboardService } from '../services';
export class DashboardController {
public router: Router;
constructor(private readonly dashboardService: DashboardService) {
this.router = Router();
this.router.get('/', this.getDashboard.bind(this));
this.router.get('/kpis', this.getKPIs.bind(this));
this.router.get('/activity', this.getActivity.bind(this));
this.router.get('/sales-chart', this.getSalesChart.bind(this));
}
private async getDashboard(req: Request, res: Response, next: NextFunction) {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
res.status(400).json({ error: 'Tenant ID required' });
return;
}
const [kpis, activity, salesChart] = await Promise.all([
this.dashboardService.getKPIs(tenantId),
this.dashboardService.getActivity(tenantId),
this.dashboardService.getSalesChart(tenantId, 'month'),
]);
res.json({
kpis,
activity,
salesChart,
timestamp: new Date().toISOString(),
});
} catch (e) {
next(e);
}
}
private async getKPIs(req: Request, res: Response, next: NextFunction) {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
res.status(400).json({ error: 'Tenant ID required' });
return;
}
const kpis = await this.dashboardService.getKPIs(tenantId);
res.json({ data: kpis });
} catch (e) {
next(e);
}
}
private async getActivity(req: Request, res: Response, next: NextFunction) {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
res.status(400).json({ error: 'Tenant ID required' });
return;
}
const { limit } = req.query;
const activity = await this.dashboardService.getActivity(
tenantId,
limit ? parseInt(limit as string) : 5
);
res.json({ data: activity });
} catch (e) {
next(e);
}
}
private async getSalesChart(req: Request, res: Response, next: NextFunction) {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
res.status(400).json({ error: 'Tenant ID required' });
return;
}
const { period } = req.query;
const validPeriods = ['week', 'month', 'year'];
const selectedPeriod = validPeriods.includes(period as string)
? (period as 'week' | 'month' | 'year')
: 'month';
const chartData = await this.dashboardService.getSalesChart(tenantId, selectedPeriod);
res.json({ data: chartData });
} catch (e) {
next(e);
}
}
}

View File

@ -0,0 +1,38 @@
import { Router } from 'express';
import { DataSource } from 'typeorm';
import { DashboardService } from './services';
import { DashboardController } from './controllers';
export interface DashboardModuleOptions {
dataSource: DataSource;
basePath?: string;
}
export class DashboardModule {
public router: Router;
public dashboardService: DashboardService;
private dataSource: DataSource;
private basePath: string;
constructor(options: DashboardModuleOptions) {
this.dataSource = options.dataSource;
this.basePath = options.basePath || '';
this.router = Router();
this.initializeServices();
this.initializeRoutes();
}
private initializeServices(): void {
this.dashboardService = new DashboardService(this.dataSource);
}
private initializeRoutes(): void {
const dashboardController = new DashboardController(this.dashboardService);
this.router.use(`${this.basePath}/dashboard`, dashboardController.router);
}
// Dashboard module doesn't have its own entities - it uses data from other modules
static getEntities(): Function[] {
return [];
}
}

View File

@ -0,0 +1,3 @@
export { DashboardModule, DashboardModuleOptions } from './dashboard.module';
export { DashboardService } from './services';
export { DashboardController } from './controllers';

View File

@ -0,0 +1,386 @@
import { DataSource } from 'typeorm';
export interface DashboardKPIs {
sales: {
todayRevenue: number;
monthRevenue: number;
todayOrders: number;
monthOrders: number;
pendingOrders: number;
};
inventory: {
totalProducts: number;
lowStockItems: number;
outOfStockItems: number;
pendingMovements: number;
};
invoices: {
pendingInvoices: number;
overdueInvoices: number;
totalReceivable: number;
totalPayable: number;
};
partners: {
totalCustomers: number;
totalSuppliers: number;
newCustomersMonth: number;
};
}
export interface DashboardActivity {
recentOrders: any[];
recentInvoices: any[];
recentMovements: any[];
alerts: any[];
}
export class DashboardService {
constructor(private readonly dataSource: DataSource) {}
async getKPIs(tenantId: string): Promise<DashboardKPIs> {
const [sales, inventory, invoices, partners] = await Promise.all([
this.getSalesKPIs(tenantId),
this.getInventoryKPIs(tenantId),
this.getInvoiceKPIs(tenantId),
this.getPartnerKPIs(tenantId),
]);
return { sales, inventory, invoices, partners };
}
private async getSalesKPIs(tenantId: string): Promise<DashboardKPIs['sales']> {
const today = new Date();
const startOfDay = new Date(today.setHours(0, 0, 0, 0));
const startOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
try {
// Today's sales
const todayQuery = `
SELECT
COALESCE(SUM(total), 0) as revenue,
COUNT(*) as orders
FROM sales.sales_orders
WHERE tenant_id = $1
AND status NOT IN ('cancelled', 'draft')
AND order_date >= $2
`;
const todayResult = await this.dataSource.query(todayQuery, [tenantId, startOfDay]);
// Month's sales
const monthQuery = `
SELECT
COALESCE(SUM(total), 0) as revenue,
COUNT(*) as orders
FROM sales.sales_orders
WHERE tenant_id = $1
AND status NOT IN ('cancelled', 'draft')
AND order_date >= $2
`;
const monthResult = await this.dataSource.query(monthQuery, [tenantId, startOfMonth]);
// Pending orders
const pendingQuery = `
SELECT COUNT(*) as count
FROM sales.sales_orders
WHERE tenant_id = $1
AND status IN ('confirmed', 'processing')
`;
const pendingResult = await this.dataSource.query(pendingQuery, [tenantId]);
return {
todayRevenue: parseFloat(todayResult[0]?.revenue) || 0,
monthRevenue: parseFloat(monthResult[0]?.revenue) || 0,
todayOrders: parseInt(todayResult[0]?.orders) || 0,
monthOrders: parseInt(monthResult[0]?.orders) || 0,
pendingOrders: parseInt(pendingResult[0]?.count) || 0,
};
} catch {
return {
todayRevenue: 0,
monthRevenue: 0,
todayOrders: 0,
monthOrders: 0,
pendingOrders: 0,
};
}
}
private async getInventoryKPIs(tenantId: string): Promise<DashboardKPIs['inventory']> {
try {
const stockQuery = `
SELECT
COUNT(DISTINCT product_id) as total_products,
COUNT(CASE WHEN quantity_on_hand <= 0 THEN 1 END) as out_of_stock,
COUNT(CASE WHEN quantity_on_hand > 0 AND quantity_on_hand <= 10 THEN 1 END) as low_stock
FROM inventory.stock_levels
WHERE tenant_id = $1
`;
const stockResult = await this.dataSource.query(stockQuery, [tenantId]);
const movementsQuery = `
SELECT COUNT(*) as count
FROM inventory.stock_movements
WHERE tenant_id = $1
AND status = 'draft'
`;
const movementsResult = await this.dataSource.query(movementsQuery, [tenantId]);
return {
totalProducts: parseInt(stockResult[0]?.total_products) || 0,
lowStockItems: parseInt(stockResult[0]?.low_stock) || 0,
outOfStockItems: parseInt(stockResult[0]?.out_of_stock) || 0,
pendingMovements: parseInt(movementsResult[0]?.count) || 0,
};
} catch {
return {
totalProducts: 0,
lowStockItems: 0,
outOfStockItems: 0,
pendingMovements: 0,
};
}
}
private async getInvoiceKPIs(tenantId: string): Promise<DashboardKPIs['invoices']> {
try {
const invoicesQuery = `
SELECT
COUNT(CASE WHEN status IN ('validated', 'sent') THEN 1 END) as pending,
COUNT(CASE WHEN status IN ('validated', 'sent', 'partial') AND due_date < CURRENT_DATE THEN 1 END) as overdue,
COALESCE(SUM(CASE WHEN invoice_type = 'sale' AND status IN ('validated', 'sent', 'partial') THEN (total - amount_paid) END), 0) as receivable,
COALESCE(SUM(CASE WHEN invoice_type = 'purchase' AND status IN ('validated', 'sent', 'partial') THEN (total - amount_paid) END), 0) as payable
FROM billing.invoices
WHERE tenant_id = $1
`;
const result = await this.dataSource.query(invoicesQuery, [tenantId]);
return {
pendingInvoices: parseInt(result[0]?.pending) || 0,
overdueInvoices: parseInt(result[0]?.overdue) || 0,
totalReceivable: parseFloat(result[0]?.receivable) || 0,
totalPayable: parseFloat(result[0]?.payable) || 0,
};
} catch {
return {
pendingInvoices: 0,
overdueInvoices: 0,
totalReceivable: 0,
totalPayable: 0,
};
}
}
private async getPartnerKPIs(tenantId: string): Promise<DashboardKPIs['partners']> {
const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1);
try {
const partnersQuery = `
SELECT
COUNT(CASE WHEN partner_type IN ('customer', 'both') THEN 1 END) as customers,
COUNT(CASE WHEN partner_type IN ('supplier', 'both') THEN 1 END) as suppliers,
COUNT(CASE WHEN partner_type IN ('customer', 'both') AND created_at >= $2 THEN 1 END) as new_customers
FROM partners.partners
WHERE tenant_id = $1
AND deleted_at IS NULL
`;
const result = await this.dataSource.query(partnersQuery, [tenantId, startOfMonth]);
return {
totalCustomers: parseInt(result[0]?.customers) || 0,
totalSuppliers: parseInt(result[0]?.suppliers) || 0,
newCustomersMonth: parseInt(result[0]?.new_customers) || 0,
};
} catch {
return {
totalCustomers: 0,
totalSuppliers: 0,
newCustomersMonth: 0,
};
}
}
async getActivity(tenantId: string, limit: number = 5): Promise<DashboardActivity> {
const [recentOrders, recentInvoices, recentMovements, alerts] = await Promise.all([
this.getRecentOrders(tenantId, limit),
this.getRecentInvoices(tenantId, limit),
this.getRecentMovements(tenantId, limit),
this.getAlerts(tenantId),
]);
return { recentOrders, recentInvoices, recentMovements, alerts };
}
private async getRecentOrders(tenantId: string, limit: number): Promise<any[]> {
try {
const query = `
SELECT
id,
order_number,
partner_name,
total,
status,
order_date,
created_at
FROM sales.sales_orders
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT $2
`;
return await this.dataSource.query(query, [tenantId, limit]);
} catch {
return [];
}
}
private async getRecentInvoices(tenantId: string, limit: number): Promise<any[]> {
try {
const query = `
SELECT
id,
invoice_number,
invoice_type,
partner_name,
total,
status,
invoice_date,
created_at
FROM billing.invoices
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT $2
`;
return await this.dataSource.query(query, [tenantId, limit]);
} catch {
return [];
}
}
private async getRecentMovements(tenantId: string, limit: number): Promise<any[]> {
try {
const query = `
SELECT
id,
movement_number,
movement_type,
product_id,
quantity,
status,
created_at
FROM inventory.stock_movements
WHERE tenant_id = $1
ORDER BY created_at DESC
LIMIT $2
`;
return await this.dataSource.query(query, [tenantId, limit]);
} catch {
return [];
}
}
private async getAlerts(tenantId: string): Promise<any[]> {
const alerts: any[] = [];
try {
// Low stock alerts
const lowStockQuery = `
SELECT COUNT(*) as count
FROM inventory.stock_levels
WHERE tenant_id = $1
AND quantity_on_hand > 0
AND quantity_on_hand <= 10
`;
const lowStockResult = await this.dataSource.query(lowStockQuery, [tenantId]);
const lowStockCount = parseInt(lowStockResult[0]?.count) || 0;
if (lowStockCount > 0) {
alerts.push({
type: 'warning',
category: 'inventory',
message: `${lowStockCount} productos con stock bajo`,
count: lowStockCount,
});
}
// Overdue invoices alerts
const overdueQuery = `
SELECT COUNT(*) as count
FROM billing.invoices
WHERE tenant_id = $1
AND invoice_type = 'sale'
AND status IN ('validated', 'sent', 'partial')
AND due_date < CURRENT_DATE
`;
const overdueResult = await this.dataSource.query(overdueQuery, [tenantId]);
const overdueCount = parseInt(overdueResult[0]?.count) || 0;
if (overdueCount > 0) {
alerts.push({
type: 'error',
category: 'invoices',
message: `${overdueCount} facturas vencidas`,
count: overdueCount,
});
}
// Pending orders alerts
const pendingOrdersQuery = `
SELECT COUNT(*) as count
FROM sales.sales_orders
WHERE tenant_id = $1
AND status IN ('confirmed')
AND order_date < CURRENT_DATE - INTERVAL '3 days'
`;
const pendingResult = await this.dataSource.query(pendingOrdersQuery, [tenantId]);
const pendingCount = parseInt(pendingResult[0]?.count) || 0;
if (pendingCount > 0) {
alerts.push({
type: 'warning',
category: 'sales',
message: `${pendingCount} pedidos pendientes hace mas de 3 dias`,
count: pendingCount,
});
}
} catch {
// Ignore errors - tables might not exist yet
}
return alerts;
}
async getSalesChart(
tenantId: string,
period: 'week' | 'month' | 'year' = 'month'
): Promise<{ labels: string[]; data: number[] }> {
try {
const intervals = {
week: { interval: '7 days', format: 'Dy', group: 'day' },
month: { interval: '30 days', format: 'DD', group: 'day' },
year: { interval: '12 months', format: 'Mon', group: 'month' },
};
const config = intervals[period];
const query = `
SELECT
TO_CHAR(order_date, '${config.format}') as label,
COALESCE(SUM(total), 0) as total
FROM sales.sales_orders
WHERE tenant_id = $1
AND status NOT IN ('cancelled', 'draft')
AND order_date >= CURRENT_DATE - INTERVAL '${config.interval}'
GROUP BY DATE_TRUNC('${config.group}', order_date), TO_CHAR(order_date, '${config.format}')
ORDER BY DATE_TRUNC('${config.group}', order_date)
`;
const result = await this.dataSource.query(query, [tenantId]);
return {
labels: result.map((r: any) => r.label),
data: result.map((r: any) => parseFloat(r.total) || 0),
};
} catch {
return { labels: [], data: [] };
}
}
}

View File

@ -0,0 +1,367 @@
import { Request, Response, NextFunction, Router } from 'express';
import { FeatureFlagsService } from '../services/feature-flags.service';
export class FeatureFlagsController {
public router: Router;
constructor(private readonly featureFlagsService: FeatureFlagsService) {
this.router = Router();
this.initializeRoutes();
}
private initializeRoutes(): void {
// Flag CRUD
this.router.get('/flags', this.findAllFlags.bind(this));
this.router.get('/flags/all', this.findAllFlagsIncludingInactive.bind(this));
this.router.get('/flags/tags/:tags', this.findFlagsByTags.bind(this));
this.router.get('/flags/:id', this.findFlagById.bind(this));
this.router.get('/flags/code/:code', this.findFlagByCode.bind(this));
this.router.post('/flags', this.createFlag.bind(this));
this.router.patch('/flags/:id', this.updateFlag.bind(this));
this.router.delete('/flags/:id', this.deleteFlag.bind(this));
this.router.patch('/flags/:id/toggle', this.toggleFlag.bind(this));
this.router.get('/flags/:id/stats', this.getFlagStats.bind(this));
// Tenant Overrides
this.router.get('/flags/:flagId/overrides', this.findOverridesForFlag.bind(this));
this.router.get('/tenants/:tenantId/overrides', this.findOverridesForTenant.bind(this));
this.router.get('/overrides/:id', this.findOverrideById.bind(this));
this.router.post('/overrides', this.createOverride.bind(this));
this.router.patch('/overrides/:id', this.updateOverride.bind(this));
this.router.delete('/overrides/:id', this.deleteOverride.bind(this));
// Evaluation
this.router.get('/evaluate/:code', this.evaluateFlag.bind(this));
this.router.post('/evaluate', this.evaluateFlags.bind(this));
this.router.get('/is-enabled/:code', this.isEnabled.bind(this));
// Maintenance
this.router.post('/maintenance/cleanup', this.cleanupExpiredOverrides.bind(this));
}
// ============================================
// FLAGS
// ============================================
private async findAllFlags(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const flags = await this.featureFlagsService.findAllFlags();
res.json({ data: flags, total: flags.length });
} catch (error) {
next(error);
}
}
private async findAllFlagsIncludingInactive(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const flags = await this.featureFlagsService.findAllFlagsIncludingInactive();
res.json({ data: flags, total: flags.length });
} catch (error) {
next(error);
}
}
private async findFlagById(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const flag = await this.featureFlagsService.findFlagById(id);
if (!flag) {
res.status(404).json({ error: 'Flag not found' });
return;
}
res.json({ data: flag });
} catch (error) {
next(error);
}
}
private async findFlagByCode(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { code } = req.params;
const flag = await this.featureFlagsService.findFlagByCode(code);
if (!flag) {
res.status(404).json({ error: 'Flag not found' });
return;
}
res.json({ data: flag });
} catch (error) {
next(error);
}
}
private async findFlagsByTags(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { tags } = req.params;
const tagList = tags.split(',');
const flags = await this.featureFlagsService.findFlagsByTags(tagList);
res.json({ data: flags, total: flags.length });
} catch (error) {
next(error);
}
}
private async createFlag(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.headers['x-user-id'] as string;
const flag = await this.featureFlagsService.createFlag(req.body, userId);
res.status(201).json({ data: flag });
} catch (error) {
next(error);
}
}
private async updateFlag(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const userId = req.headers['x-user-id'] as string;
const flag = await this.featureFlagsService.updateFlag(id, req.body, userId);
if (!flag) {
res.status(404).json({ error: 'Flag not found' });
return;
}
res.json({ data: flag });
} catch (error) {
next(error);
}
}
private async deleteFlag(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const { soft } = req.query;
const userId = req.headers['x-user-id'] as string;
let result: boolean;
if (soft === 'true') {
const flag = await this.featureFlagsService.softDeleteFlag(id, userId);
result = flag !== null;
} else {
result = await this.featureFlagsService.deleteFlag(id);
}
if (!result) {
res.status(404).json({ error: 'Flag not found' });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
}
private async toggleFlag(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const { enabled } = req.body;
const userId = req.headers['x-user-id'] as string;
const flag = await this.featureFlagsService.toggleFlag(id, enabled, userId);
if (!flag) {
res.status(404).json({ error: 'Flag not found' });
return;
}
res.json({ data: flag });
} catch (error) {
next(error);
}
}
private async getFlagStats(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const stats = await this.featureFlagsService.getFlagStats(id);
if (!stats.flag) {
res.status(404).json({ error: 'Flag not found' });
return;
}
res.json({ data: stats });
} catch (error) {
next(error);
}
}
// ============================================
// OVERRIDES
// ============================================
private async findOverridesForFlag(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const { flagId } = req.params;
const overrides = await this.featureFlagsService.findOverridesForFlag(flagId);
res.json({ data: overrides, total: overrides.length });
} catch (error) {
next(error);
}
}
private async findOverridesForTenant(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const { tenantId } = req.params;
const overrides = await this.featureFlagsService.findOverridesForTenant(tenantId);
res.json({ data: overrides, total: overrides.length });
} catch (error) {
next(error);
}
}
private async findOverrideById(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const { id } = req.params;
const override = await this.featureFlagsService.findOverrideById(id);
if (!override) {
res.status(404).json({ error: 'Override not found' });
return;
}
res.json({ data: override });
} catch (error) {
next(error);
}
}
private async createOverride(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const userId = req.headers['x-user-id'] as string;
const override = await this.featureFlagsService.createOverride(req.body, userId);
res.status(201).json({ data: override });
} catch (error) {
next(error);
}
}
private async updateOverride(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const override = await this.featureFlagsService.updateOverride(id, req.body);
if (!override) {
res.status(404).json({ error: 'Override not found' });
return;
}
res.json({ data: override });
} catch (error) {
next(error);
}
}
private async deleteOverride(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { id } = req.params;
const deleted = await this.featureFlagsService.deleteOverride(id);
if (!deleted) {
res.status(404).json({ error: 'Override not found' });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
}
// ============================================
// EVALUATION
// ============================================
private async evaluateFlag(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { code } = req.params;
const tenantId = req.headers['x-tenant-id'] as string || req.query.tenantId as string;
if (!tenantId) {
res.status(400).json({ error: 'tenantId is required (header x-tenant-id or query param)' });
return;
}
const result = await this.featureFlagsService.evaluateFlag(code, tenantId);
res.json({ data: result });
} catch (error) {
next(error);
}
}
private async evaluateFlags(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { flagCodes, tenantId } = req.body;
if (!flagCodes || !Array.isArray(flagCodes)) {
res.status(400).json({ error: 'flagCodes array is required' });
return;
}
if (!tenantId) {
res.status(400).json({ error: 'tenantId is required' });
return;
}
const results = await this.featureFlagsService.evaluateFlags(flagCodes, tenantId);
res.json({ data: results, total: results.length });
} catch (error) {
next(error);
}
}
private async isEnabled(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const { code } = req.params;
const tenantId = req.headers['x-tenant-id'] as string || req.query.tenantId as string;
if (!tenantId) {
res.status(400).json({ error: 'tenantId is required (header x-tenant-id or query param)' });
return;
}
const enabled = await this.featureFlagsService.isEnabled(code, tenantId);
res.json({ data: { code, enabled } });
} catch (error) {
next(error);
}
}
// ============================================
// MAINTENANCE
// ============================================
private async cleanupExpiredOverrides(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const count = await this.featureFlagsService.cleanupExpiredOverrides();
res.json({ data: { cleanedUp: count } });
} catch (error) {
next(error);
}
}
}

View File

@ -0,0 +1 @@
export { FeatureFlagsController } from './feature-flags.controller';

View File

@ -0,0 +1,53 @@
// =====================================================
// DTOs: Feature Flags
// Modulo: MGN-019
// Version: 1.0.0
// =====================================================
export interface CreateFlagDto {
code: string;
name: string;
description?: string;
enabled?: boolean;
rolloutPercentage?: number;
tags?: string[];
}
export interface UpdateFlagDto {
name?: string;
description?: string;
enabled?: boolean;
rolloutPercentage?: number;
tags?: string[];
isActive?: boolean;
}
export interface CreateTenantOverrideDto {
flagId: string;
tenantId: string;
enabled: boolean;
reason?: string;
expiresAt?: Date;
}
export interface UpdateTenantOverrideDto {
enabled?: boolean;
reason?: string;
expiresAt?: Date | null;
}
export interface EvaluateFlagDto {
flagCode: string;
tenantId: string;
}
export interface EvaluateFlagsDto {
flagCodes: string[];
tenantId: string;
}
export interface FlagEvaluationResult {
code: string;
enabled: boolean;
source: 'override' | 'global' | 'rollout' | 'default';
}

View File

@ -0,0 +1 @@
export * from './feature-flag.dto';

View File

@ -0,0 +1,57 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
Unique,
OneToMany,
} from 'typeorm';
import { TenantOverride } from './tenant-override.entity';
@Entity({ name: 'flags', schema: 'feature_flags' })
@Unique(['code'])
export class Flag {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'code', type: 'varchar', length: 50 })
code: string;
@Column({ name: 'name', type: 'varchar', length: 100 })
name: string;
@Column({ name: 'description', type: 'text', nullable: true })
description: string;
@Index()
@Column({ name: 'enabled', type: 'boolean', default: false })
enabled: boolean;
@Column({ name: 'rollout_percentage', type: 'int', default: 100 })
rolloutPercentage: number;
@Column({ name: 'tags', type: 'text', array: true, nullable: true })
tags: string[];
@Index()
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive: boolean;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy: string;
@OneToMany(() => TenantOverride, (override) => override.flag)
overrides: TenantOverride[];
}

View File

@ -0,0 +1,2 @@
export { Flag } from './flag.entity';
export { TenantOverride } from './tenant-override.entity';

View File

@ -0,0 +1,50 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
Unique,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Flag } from './flag.entity';
@Entity({ name: 'tenant_overrides', schema: 'feature_flags' })
@Unique(['flagId', 'tenantId'])
export class TenantOverride {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'flag_id', type: 'uuid' })
flagId: string;
@Index()
@Column({ name: 'tenant_id', type: 'uuid' })
tenantId: string;
@Column({ name: 'enabled', type: 'boolean' })
enabled: boolean;
@Column({ name: 'reason', type: 'text', nullable: true })
reason: string;
@Index()
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
expiresAt: Date;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy: string;
@ManyToOne(() => Flag, (flag) => flag.overrides, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'flag_id' })
flag: Flag;
}

View File

@ -0,0 +1,44 @@
import { Router } from 'express';
import { DataSource } from 'typeorm';
import { FeatureFlagsService } from './services';
import { FeatureFlagsController } from './controllers';
import { Flag, TenantOverride } from './entities';
export interface FeatureFlagsModuleOptions {
dataSource: DataSource;
basePath?: string;
}
export class FeatureFlagsModule {
public router: Router;
public featureFlagsService: FeatureFlagsService;
private dataSource: DataSource;
private basePath: string;
constructor(options: FeatureFlagsModuleOptions) {
this.dataSource = options.dataSource;
this.basePath = options.basePath || '';
this.router = Router();
this.initializeServices();
this.initializeRoutes();
}
private initializeServices(): void {
const flagRepository = this.dataSource.getRepository(Flag);
const overrideRepository = this.dataSource.getRepository(TenantOverride);
this.featureFlagsService = new FeatureFlagsService(
flagRepository,
overrideRepository
);
}
private initializeRoutes(): void {
const featureFlagsController = new FeatureFlagsController(this.featureFlagsService);
this.router.use(`${this.basePath}/feature-flags`, featureFlagsController.router);
}
static getEntities(): Function[] {
return [Flag, TenantOverride];
}
}

View File

@ -0,0 +1,5 @@
export { FeatureFlagsModule, FeatureFlagsModuleOptions } from './feature-flags.module';
export { FeatureFlagsService } from './services';
export { FeatureFlagsController } from './controllers';
export { Flag, TenantOverride } from './entities';
export * from './dto';

View File

@ -0,0 +1,345 @@
import { Repository, In } from 'typeorm';
import { createHash } from 'crypto';
import { Flag, TenantOverride } from '../entities';
import {
CreateFlagDto,
UpdateFlagDto,
CreateTenantOverrideDto,
UpdateTenantOverrideDto,
FlagEvaluationResult,
} from '../dto';
export class FeatureFlagsService {
constructor(
private readonly flagRepository: Repository<Flag>,
private readonly overrideRepository: Repository<TenantOverride>
) {}
// ============================================
// FLAGS - CRUD
// ============================================
async findAllFlags(): Promise<Flag[]> {
return this.flagRepository.find({
where: { isActive: true },
order: { code: 'ASC' },
});
}
async findAllFlagsIncludingInactive(): Promise<Flag[]> {
return this.flagRepository.find({
order: { code: 'ASC' },
});
}
async findFlagById(id: string): Promise<Flag | null> {
return this.flagRepository.findOne({
where: { id },
relations: ['overrides'],
});
}
async findFlagByCode(code: string): Promise<Flag | null> {
return this.flagRepository.findOne({
where: { code },
relations: ['overrides'],
});
}
async findFlagsByTags(tags: string[]): Promise<Flag[]> {
return this.flagRepository
.createQueryBuilder('flag')
.where('flag.is_active = true')
.andWhere('flag.tags && :tags', { tags })
.orderBy('flag.code', 'ASC')
.getMany();
}
async createFlag(data: CreateFlagDto, createdBy?: string): Promise<Flag> {
const flag = this.flagRepository.create({
...data,
createdBy,
});
return this.flagRepository.save(flag);
}
async updateFlag(
id: string,
data: UpdateFlagDto,
updatedBy?: string
): Promise<Flag | null> {
const flag = await this.flagRepository.findOne({ where: { id } });
if (!flag) return null;
Object.assign(flag, data, { updatedBy });
return this.flagRepository.save(flag);
}
async deleteFlag(id: string): Promise<boolean> {
const result = await this.flagRepository.delete(id);
return (result.affected ?? 0) > 0;
}
async softDeleteFlag(id: string, updatedBy?: string): Promise<Flag | null> {
return this.updateFlag(id, { isActive: false }, updatedBy);
}
async toggleFlag(id: string, enabled: boolean, updatedBy?: string): Promise<Flag | null> {
return this.updateFlag(id, { enabled }, updatedBy);
}
// ============================================
// TENANT OVERRIDES - CRUD
// ============================================
async findOverridesForFlag(flagId: string): Promise<TenantOverride[]> {
return this.overrideRepository.find({
where: { flagId },
order: { createdAt: 'DESC' },
});
}
async findOverridesForTenant(tenantId: string): Promise<TenantOverride[]> {
return this.overrideRepository.find({
where: { tenantId },
relations: ['flag'],
order: { createdAt: 'DESC' },
});
}
async findOverride(flagId: string, tenantId: string): Promise<TenantOverride | null> {
return this.overrideRepository.findOne({
where: { flagId, tenantId },
relations: ['flag'],
});
}
async findOverrideById(id: string): Promise<TenantOverride | null> {
return this.overrideRepository.findOne({
where: { id },
relations: ['flag'],
});
}
async createOverride(
data: CreateTenantOverrideDto,
createdBy?: string
): Promise<TenantOverride> {
const override = this.overrideRepository.create({
...data,
createdBy,
});
return this.overrideRepository.save(override);
}
async updateOverride(
id: string,
data: UpdateTenantOverrideDto
): Promise<TenantOverride | null> {
const override = await this.overrideRepository.findOne({ where: { id } });
if (!override) return null;
Object.assign(override, data);
return this.overrideRepository.save(override);
}
async deleteOverride(id: string): Promise<boolean> {
const result = await this.overrideRepository.delete(id);
return (result.affected ?? 0) > 0;
}
async deleteOverrideByFlagAndTenant(flagId: string, tenantId: string): Promise<boolean> {
const result = await this.overrideRepository.delete({ flagId, tenantId });
return (result.affected ?? 0) > 0;
}
// ============================================
// FLAG EVALUATION
// ============================================
/**
* Evaluates a single flag for a tenant.
* Priority: tenant override > global flag > rollout > default (false)
*/
async evaluateFlag(flagCode: string, tenantId: string): Promise<FlagEvaluationResult> {
// 1. Find the flag
const flag = await this.flagRepository.findOne({
where: { code: flagCode, isActive: true },
});
if (!flag) {
return { code: flagCode, enabled: false, source: 'default' };
}
// 2. Check tenant override
const override = await this.overrideRepository.findOne({
where: { flagId: flag.id, tenantId },
});
if (override) {
// Check if override is expired
if (!override.expiresAt || override.expiresAt > new Date()) {
return { code: flagCode, enabled: override.enabled, source: 'override' };
}
}
// 3. Check global flag state
if (!flag.enabled) {
return { code: flagCode, enabled: false, source: 'global' };
}
// 4. Evaluate rollout percentage
if (flag.rolloutPercentage >= 100) {
return { code: flagCode, enabled: true, source: 'global' };
}
if (flag.rolloutPercentage <= 0) {
return { code: flagCode, enabled: false, source: 'rollout' };
}
// 5. Deterministic hash-based rollout
const bucket = this.calculateBucket(flagCode, tenantId);
const enabled = bucket < flag.rolloutPercentage;
return { code: flagCode, enabled, source: 'rollout' };
}
/**
* Evaluates multiple flags for a tenant in a single call.
* More efficient than calling evaluateFlag multiple times.
*/
async evaluateFlags(
flagCodes: string[],
tenantId: string
): Promise<FlagEvaluationResult[]> {
// Get all requested flags in one query
const flags = await this.flagRepository.find({
where: { code: In(flagCodes), isActive: true },
});
const flagMap = new Map(flags.map((f) => [f.code, f]));
// Get all overrides for this tenant and these flags in one query
const flagIds = flags.map((f) => f.id);
const overrides = await this.overrideRepository.find({
where: { flagId: In(flagIds), tenantId },
});
const overrideMap = new Map(overrides.map((o) => [o.flagId, o]));
const now = new Date();
// Evaluate each flag
return flagCodes.map((code) => {
const flag = flagMap.get(code);
if (!flag) {
return { code, enabled: false, source: 'default' as const };
}
const override = overrideMap.get(flag.id);
if (override && (!override.expiresAt || override.expiresAt > now)) {
return { code, enabled: override.enabled, source: 'override' as const };
}
if (!flag.enabled) {
return { code, enabled: false, source: 'global' as const };
}
if (flag.rolloutPercentage >= 100) {
return { code, enabled: true, source: 'global' as const };
}
if (flag.rolloutPercentage <= 0) {
return { code, enabled: false, source: 'rollout' as const };
}
const bucket = this.calculateBucket(code, tenantId);
const enabled = bucket < flag.rolloutPercentage;
return { code, enabled, source: 'rollout' as const };
});
}
/**
* Quick boolean check for a single flag.
*/
async isEnabled(flagCode: string, tenantId: string): Promise<boolean> {
const result = await this.evaluateFlag(flagCode, tenantId);
return result.enabled;
}
// ============================================
// MAINTENANCE
// ============================================
/**
* Removes expired overrides from the database.
* Should be called periodically via cron job.
*/
async cleanupExpiredOverrides(): Promise<number> {
const now = new Date();
const result = await this.overrideRepository
.createQueryBuilder()
.delete()
.where('expires_at IS NOT NULL')
.andWhere('expires_at < :now', { now })
.execute();
return result.affected ?? 0;
}
/**
* Gets statistics for a flag including override counts.
*/
async getFlagStats(flagId: string): Promise<{
flag: Flag | null;
overrideCount: number;
enabledOverrides: number;
disabledOverrides: number;
}> {
const flag = await this.flagRepository.findOne({ where: { id: flagId } });
if (!flag) {
return {
flag: null,
overrideCount: 0,
enabledOverrides: 0,
disabledOverrides: 0,
};
}
const now = new Date();
const overrides = await this.overrideRepository
.createQueryBuilder('o')
.where('o.flag_id = :flagId', { flagId })
.andWhere('(o.expires_at IS NULL OR o.expires_at > :now)', { now })
.getMany();
const enabledOverrides = overrides.filter((o) => o.enabled).length;
const disabledOverrides = overrides.filter((o) => !o.enabled).length;
return {
flag,
overrideCount: overrides.length,
enabledOverrides,
disabledOverrides,
};
}
// ============================================
// PRIVATE HELPERS
// ============================================
/**
* Calculates a deterministic bucket (0-99) for rollout evaluation.
* Uses MD5 hash of flag code + tenant ID for consistent results.
*/
private calculateBucket(flagCode: string, tenantId: string): number {
const input = `${flagCode}:${tenantId}`;
const hash = createHash('md5').update(input).digest('hex');
// Take first 8 chars of hash and convert to number
const num = parseInt(hash.substring(0, 8), 16);
return Math.abs(num % 100);
}
}

View File

@ -0,0 +1 @@
export { FeatureFlagsService } from './feature-flags.service';

View File

@ -0,0 +1 @@
export { InventoryController } from './inventory.controller';

View File

@ -0,0 +1,342 @@
import { Request, Response, NextFunction, Router } from 'express';
import { InventoryService } from '../services/inventory.service';
import {
CreateStockMovementDto,
AdjustStockDto,
TransferStockDto,
ReserveStockDto,
} from '../dto';
export class InventoryController {
public router: Router;
constructor(private readonly inventoryService: InventoryService) {
this.router = Router();
this.initializeRoutes();
}
private initializeRoutes(): void {
// Stock Levels
this.router.get('/stock', this.getStockLevels.bind(this));
this.router.get('/stock/product/:productId', this.getStockByProduct.bind(this));
this.router.get('/stock/warehouse/:warehouseId', this.getStockByWarehouse.bind(this));
this.router.get(
'/stock/available/:productId/:warehouseId',
this.getAvailableStock.bind(this)
);
// Movements
this.router.get('/movements', this.getMovements.bind(this));
this.router.get('/movements/:id', this.getMovement.bind(this));
this.router.post('/movements', this.createMovement.bind(this));
this.router.post('/movements/:id/confirm', this.confirmMovement.bind(this));
this.router.post('/movements/:id/cancel', this.cancelMovement.bind(this));
// Operations
this.router.post('/adjust', this.adjustStock.bind(this));
this.router.post('/transfer', this.transferStock.bind(this));
this.router.post('/reserve', this.reserveStock.bind(this));
this.router.post('/release', this.releaseReservation.bind(this));
}
// ==================== Stock Levels ====================
private async getStockLevels(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
res.status(400).json({ error: 'Tenant ID is required' });
return;
}
const {
productId,
warehouseId,
locationId,
lotNumber,
hasStock,
lowStock,
limit,
offset,
} = req.query;
const result = await this.inventoryService.getStockLevels({
tenantId,
productId: productId as string,
warehouseId: warehouseId as string,
locationId: locationId as string,
lotNumber: lotNumber as string,
hasStock: hasStock ? hasStock === 'true' : undefined,
lowStock: lowStock ? lowStock === 'true' : undefined,
limit: limit ? parseInt(limit as string, 10) : undefined,
offset: offset ? parseInt(offset as string, 10) : undefined,
});
res.json(result);
} catch (error) {
next(error);
}
}
private async getStockByProduct(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
res.status(400).json({ error: 'Tenant ID is required' });
return;
}
const { productId } = req.params;
const stock = await this.inventoryService.getStockByProduct(productId, tenantId);
res.json({ data: stock });
} catch (error) {
next(error);
}
}
private async getStockByWarehouse(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
res.status(400).json({ error: 'Tenant ID is required' });
return;
}
const { warehouseId } = req.params;
const stock = await this.inventoryService.getStockByWarehouse(warehouseId, tenantId);
res.json({ data: stock });
} catch (error) {
next(error);
}
}
private async getAvailableStock(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
res.status(400).json({ error: 'Tenant ID is required' });
return;
}
const { productId, warehouseId } = req.params;
const available = await this.inventoryService.getAvailableStock(
productId,
warehouseId,
tenantId
);
res.json({ data: { available } });
} catch (error) {
next(error);
}
}
// ==================== Movements ====================
private async getMovements(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
res.status(400).json({ error: 'Tenant ID is required' });
return;
}
const {
movementType,
productId,
warehouseId,
status,
referenceType,
referenceId,
fromDate,
toDate,
limit,
offset,
} = req.query;
const result = await this.inventoryService.getMovements({
tenantId,
movementType: movementType as string,
productId: productId as string,
warehouseId: warehouseId as string,
status: status as 'draft' | 'confirmed' | 'cancelled',
referenceType: referenceType as string,
referenceId: referenceId as string,
fromDate: fromDate ? new Date(fromDate as string) : undefined,
toDate: toDate ? new Date(toDate as string) : undefined,
limit: limit ? parseInt(limit as string, 10) : undefined,
offset: offset ? parseInt(offset as string, 10) : undefined,
});
res.json(result);
} catch (error) {
next(error);
}
}
private async getMovement(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
res.status(400).json({ error: 'Tenant ID is required' });
return;
}
const { id } = req.params;
const movement = await this.inventoryService.getMovement(id, tenantId);
if (!movement) {
res.status(404).json({ error: 'Movement not found' });
return;
}
res.json({ data: movement });
} catch (error) {
next(error);
}
}
private async createMovement(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
if (!tenantId) {
res.status(400).json({ error: 'Tenant ID is required' });
return;
}
const dto: CreateStockMovementDto = req.body;
const movement = await this.inventoryService.createMovement(tenantId, dto, userId);
res.status(201).json({ data: movement });
} catch (error) {
next(error);
}
}
private async confirmMovement(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
if (!tenantId) {
res.status(400).json({ error: 'Tenant ID is required' });
return;
}
const { id } = req.params;
const movement = await this.inventoryService.confirmMovement(id, tenantId, userId);
if (!movement) {
res.status(404).json({ error: 'Movement not found' });
return;
}
res.json({ data: movement });
} catch (error) {
next(error);
}
}
private async cancelMovement(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
res.status(400).json({ error: 'Tenant ID is required' });
return;
}
const { id } = req.params;
const movement = await this.inventoryService.cancelMovement(id, tenantId);
if (!movement) {
res.status(404).json({ error: 'Movement not found' });
return;
}
res.json({ data: movement });
} catch (error) {
next(error);
}
}
// ==================== Operations ====================
private async adjustStock(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
if (!tenantId) {
res.status(400).json({ error: 'Tenant ID is required' });
return;
}
const dto: AdjustStockDto = req.body;
const movement = await this.inventoryService.adjustStock(tenantId, dto, userId);
res.status(201).json({ data: movement });
} catch (error) {
next(error);
}
}
private async transferStock(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
const userId = req.headers['x-user-id'] as string;
if (!tenantId) {
res.status(400).json({ error: 'Tenant ID is required' });
return;
}
const dto: TransferStockDto = req.body;
const movement = await this.inventoryService.transferStock(tenantId, dto, userId);
res.status(201).json({ data: movement });
} catch (error) {
next(error);
}
}
private async reserveStock(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
res.status(400).json({ error: 'Tenant ID is required' });
return;
}
const dto: ReserveStockDto = req.body;
await this.inventoryService.reserveStock(tenantId, dto);
res.json({ success: true });
} catch (error) {
next(error);
}
}
private async releaseReservation(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const tenantId = req.headers['x-tenant-id'] as string;
if (!tenantId) {
res.status(400).json({ error: 'Tenant ID is required' });
return;
}
const { productId, warehouseId, quantity } = req.body;
await this.inventoryService.releaseReservation(productId, warehouseId, quantity, tenantId);
res.json({ success: true });
} catch (error) {
next(error);
}
}
}

View File

@ -0,0 +1,192 @@
import {
IsString,
IsOptional,
IsNumber,
IsUUID,
IsDateString,
MaxLength,
IsEnum,
Min,
} from 'class-validator';
export class CreateStockMovementDto {
@IsEnum(['receipt', 'shipment', 'transfer', 'adjustment', 'return', 'production', 'consumption'])
movementType:
| 'receipt'
| 'shipment'
| 'transfer'
| 'adjustment'
| 'return'
| 'production'
| 'consumption';
@IsUUID()
productId: string;
@IsOptional()
@IsUUID()
sourceWarehouseId?: string;
@IsOptional()
@IsUUID()
sourceLocationId?: string;
@IsOptional()
@IsUUID()
destWarehouseId?: string;
@IsOptional()
@IsUUID()
destLocationId?: string;
@IsNumber()
@Min(0)
quantity: number;
@IsOptional()
@IsString()
@MaxLength(20)
uom?: string;
@IsOptional()
@IsString()
@MaxLength(50)
lotNumber?: string;
@IsOptional()
@IsString()
@MaxLength(50)
serialNumber?: string;
@IsOptional()
@IsDateString()
expiryDate?: string;
@IsOptional()
@IsNumber()
@Min(0)
unitCost?: number;
@IsOptional()
@IsString()
@MaxLength(30)
referenceType?: string;
@IsOptional()
@IsUUID()
referenceId?: string;
@IsOptional()
@IsString()
@MaxLength(50)
referenceNumber?: string;
@IsOptional()
@IsString()
@MaxLength(100)
reason?: string;
@IsOptional()
@IsString()
notes?: string;
}
export class AdjustStockDto {
@IsUUID()
productId: string;
@IsUUID()
warehouseId: string;
@IsOptional()
@IsUUID()
locationId?: string;
@IsNumber()
newQuantity: number;
@IsOptional()
@IsString()
@MaxLength(50)
lotNumber?: string;
@IsOptional()
@IsString()
@MaxLength(50)
serialNumber?: string;
@IsString()
@MaxLength(100)
reason: string;
@IsOptional()
@IsString()
notes?: string;
}
export class TransferStockDto {
@IsUUID()
productId: string;
@IsUUID()
sourceWarehouseId: string;
@IsOptional()
@IsUUID()
sourceLocationId?: string;
@IsUUID()
destWarehouseId: string;
@IsOptional()
@IsUUID()
destLocationId?: string;
@IsNumber()
@Min(0)
quantity: number;
@IsOptional()
@IsString()
@MaxLength(50)
lotNumber?: string;
@IsOptional()
@IsString()
@MaxLength(50)
serialNumber?: string;
@IsOptional()
@IsString()
notes?: string;
}
export class ReserveStockDto {
@IsUUID()
productId: string;
@IsUUID()
warehouseId: string;
@IsOptional()
@IsUUID()
locationId?: string;
@IsNumber()
@Min(0)
quantity: number;
@IsOptional()
@IsString()
@MaxLength(50)
lotNumber?: string;
@IsOptional()
@IsString()
@MaxLength(30)
referenceType?: string;
@IsOptional()
@IsUUID()
referenceId?: string;
}

View File

@ -0,0 +1,6 @@
export {
CreateStockMovementDto,
AdjustStockDto,
TransferStockDto,
ReserveStockDto,
} from './create-inventory.dto';

View File

@ -1,11 +1,6 @@
// Export all inventory entities
export * from './product.entity.js';
export * from './warehouse.entity.js';
export * from './location.entity.js';
export * from './stock-quant.entity.js';
export * from './lot.entity.js';
export * from './picking.entity.js';
export * from './stock-move.entity.js';
export * from './inventory-adjustment.entity.js';
export * from './inventory-adjustment-line.entity.js';
export * from './stock-valuation-layer.entity.js';
export { StockLevel } from './stock-level.entity';
export { StockMovement } from './stock-movement.entity';
export { InventoryCount } from './inventory-count.entity';
export { InventoryCountLine } from './inventory-count-line.entity';
export { TransferOrder } from './transfer-order.entity';
export { TransferOrderLine } from './transfer-order-line.entity';

View File

@ -0,0 +1,56 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, ManyToOne, JoinColumn } from 'typeorm';
import { InventoryCount } from './inventory-count.entity';
@Entity({ name: 'inventory_count_lines', schema: 'inventory' })
export class InventoryCountLine {
@PrimaryGeneratedColumn('uuid')
id: string;
@Index()
@Column({ name: 'count_id', type: 'uuid' })
countId: string;
@ManyToOne(() => InventoryCount, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'count_id' })
count: InventoryCount;
@Index()
@Column({ name: 'product_id', type: 'uuid' })
productId: string;
@Index()
@Column({ name: 'location_id', type: 'uuid', nullable: true })
locationId?: string;
@Column({ name: 'system_quantity', type: 'decimal', precision: 15, scale: 4, nullable: true })
systemQuantity?: number;
@Column({ name: 'counted_quantity', type: 'decimal', precision: 15, scale: 4, nullable: true })
countedQuantity?: number;
// Note: difference is GENERATED in DDL, but we calculate it in app layer
@Column({ name: 'lot_number', type: 'varchar', length: 50, nullable: true })
lotNumber?: string;
@Column({ name: 'serial_number', type: 'varchar', length: 50, nullable: true })
serialNumber?: string;
@Index()
@Column({ name: 'is_counted', type: 'boolean', default: false })
isCounted: boolean;
@Column({ name: 'counted_at', type: 'timestamptz', nullable: true })
countedAt?: Date;
@Column({ name: 'counted_by', type: 'uuid', nullable: true })
countedBy?: string;
@Column({ type: 'text', nullable: true })
notes?: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt: Date;
}

Some files were not shown because too many files have changed in this diff Show More