From bb556a77896573046737af8bbda4ab244d7b247b Mon Sep 17 00:00:00 2001 From: rckrdmrd Date: Tue, 20 Jan 2026 04:38:45 -0600 Subject: [PATCH] [TEMPLATE-SAAS-BE] chore: Update analytics and audit modules Co-Authored-By: Claude Opus 4.5 --- .../__tests__/analytics.controller.spec.ts | 6 +- .../__tests__/analytics.service.spec.ts | 6 +- src/modules/analytics/analytics.service.ts | 16 +-- .../audit/__tests__/audit.controller.spec.ts | 14 +- .../audit/__tests__/audit.service.spec.ts | 30 ++--- src/modules/audit/dto/query-audit.dto.ts | 8 +- .../audit/entities/audit-log.entity.ts | 126 +++++++++++++----- .../audit/interceptors/audit.interceptor.ts | 4 +- src/modules/audit/services/audit.service.ts | 22 +-- .../auth/__tests__/auth.service.spec.ts | 14 +- src/modules/auth/entities/session.entity.ts | 48 ++++++- src/modules/auth/services/auth.service.ts | 16 +-- .../__tests__/billing-edge-cases.spec.ts | 31 +++-- .../billing/__tests__/billing.service.spec.ts | 16 +-- .../billing/__tests__/stripe.service.spec.ts | 8 +- .../billing/entities/invoice-item.entity.ts | 7 + .../billing/entities/invoice.entity.ts | 6 + .../billing/entities/subscription.entity.ts | 76 +++++++++-- .../billing/services/billing.service.ts | 16 ++- .../billing/services/stripe.service.ts | 8 +- .../__tests__/onboarding.service.spec.ts | 2 +- src/modules/onboarding/onboarding.service.ts | 4 +- src/modules/reports/services/excel.service.ts | 12 +- src/modules/reports/services/pdf.service.ts | 8 +- 24 files changed, 340 insertions(+), 164 deletions(-) diff --git a/src/modules/analytics/__tests__/analytics.controller.spec.ts b/src/modules/analytics/__tests__/analytics.controller.spec.ts index 26b52ef..a0d8659 100644 --- a/src/modules/analytics/__tests__/analytics.controller.spec.ts +++ b/src/modules/analytics/__tests__/analytics.controller.spec.ts @@ -18,10 +18,9 @@ describe('AnalyticsController', () => { const mockUserId = '550e8400-e29b-41d4-a716-446655440002'; const mockUser: RequestUser = { - sub: mockUserId, + id: mockUserId, tenant_id: mockTenantId, email: 'test@example.com', - role: 'admin', }; const mockUserMetrics: UserMetricsDto = { @@ -267,10 +266,9 @@ describe('AnalyticsController', () => { describe('Multi-tenant isolation', () => { it('should always use tenant_id from authenticated user', async () => { const anotherUser: RequestUser = { - sub: 'another-user-id', + id: 'another-user-id', tenant_id: 'another-tenant-id', email: 'other@example.com', - role: 'user', }; service.getUserMetrics.mockResolvedValue(mockUserMetrics); diff --git a/src/modules/analytics/__tests__/analytics.service.spec.ts b/src/modules/analytics/__tests__/analytics.service.spec.ts index c6dca84..64c5d41 100644 --- a/src/modules/analytics/__tests__/analytics.service.spec.ts +++ b/src/modules/analytics/__tests__/analytics.service.spec.ts @@ -374,7 +374,7 @@ describe('AnalyticsService', () => { it('should detect trial period', async () => { subscriptionRepo.findOne.mockResolvedValue({ ...mockSubscription, - status: SubscriptionStatus.TRIAL, + status: SubscriptionStatus.TRIALING, } as Subscription); invoiceRepo.find.mockResolvedValue([]); @@ -427,8 +427,8 @@ describe('AnalyticsService', () => { { action: 'UPDATE', count: '30' }, ]) .mockResolvedValueOnce([ - { entity_type: 'User', count: '25' }, - { entity_type: 'Invoice', count: '25' }, + { resource_type: 'User', count: '25' }, + { resource_type: 'Invoice', count: '25' }, ]) .mockResolvedValueOnce([ { entityType: 'User', count: '25' }, diff --git a/src/modules/analytics/analytics.service.ts b/src/modules/analytics/analytics.service.ts index f8d2086..009c157 100644 --- a/src/modules/analytics/analytics.service.ts +++ b/src/modules/analytics/analytics.service.ts @@ -190,7 +190,7 @@ export class AnalyticsService { : 0; // Calculate days until expiration - const daysUntilExpiration = subscription + const daysUntilExpiration = subscription?.current_period_end ? Math.max(0, Math.ceil( (new Date(subscription.current_period_end).getTime() - Date.now()) / (1000 * 60 * 60 * 24) )) @@ -210,7 +210,7 @@ export class AnalyticsService { totalDue: Math.round(totalDue * 100) / 100, averageInvoiceAmount: Math.round(averageInvoiceAmount * 100) / 100, daysUntilExpiration, - isTrialPeriod: subscription?.status === SubscriptionStatus.TRIAL, + isTrialPeriod: subscription?.status === SubscriptionStatus.TRIALING, revenueTrend: Math.round(revenueTrend * 100) / 100, }; } @@ -255,19 +255,19 @@ export class AnalyticsService { actionsByType[row.action] = parseInt(row.count, 10); }); - // Actions by entity type + // Actions by resource type const actionsByEntityQuery = await this.auditLogRepo .createQueryBuilder('audit') - .select('audit.entity_type', 'entity_type') + .select('audit.resource_type', 'resource_type') .addSelect('COUNT(*)', 'count') .where('audit.tenant_id = :tenantId', { tenantId }) .andWhere('audit.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) - .groupBy('audit.entity_type') + .groupBy('audit.resource_type') .getRawMany(); const actionsByEntity: Record = {}; actionsByEntityQuery.forEach((row) => { - actionsByEntity[row.entity_type] = parseInt(row.count, 10); + actionsByEntity[row.resource_type] = parseInt(row.count, 10); }); // Peak usage hour @@ -307,11 +307,11 @@ export class AnalyticsService { // Top entities const topEntitiesQuery = await this.auditLogRepo .createQueryBuilder('audit') - .select('audit.entity_type', 'entityType') + .select('audit.resource_type', 'entityType') .addSelect('COUNT(*)', 'count') .where('audit.tenant_id = :tenantId', { tenantId }) .andWhere('audit.created_at BETWEEN :startDate AND :endDate', { startDate, endDate }) - .groupBy('audit.entity_type') + .groupBy('audit.resource_type') .orderBy('count', 'DESC') .limit(5) .getRawMany(); diff --git a/src/modules/audit/__tests__/audit.controller.spec.ts b/src/modules/audit/__tests__/audit.controller.spec.ts index 2a47195..7de523a 100644 --- a/src/modules/audit/__tests__/audit.controller.spec.ts +++ b/src/modules/audit/__tests__/audit.controller.spec.ts @@ -34,8 +34,8 @@ describe('AuditController', () => { tenant_id: mockTenantId, user_id: mockUserId, action: AuditAction.CREATE, - entity_type: 'user', - entity_id: 'user-001', + resource_type: 'user', + resource_id: 'user-001', new_values: { email: 'test@example.com' }, changed_fields: ['email'], ip_address: '192.168.1.1', @@ -155,8 +155,8 @@ describe('AuditController', () => { ); }); - it('should filter audit logs by entity_type', async () => { - const query: QueryAuditLogsDto = { entity_type: 'user' }; + it('should filter audit logs by resource_type', async () => { + const query: QueryAuditLogsDto = { resource_type: 'user' }; auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs); const result = await controller.queryAuditLogs(mockRequestUser, query); @@ -168,9 +168,9 @@ describe('AuditController', () => { ); }); - it('should filter audit logs by entity_id', async () => { + it('should filter audit logs by resource_id', async () => { const query: QueryAuditLogsDto = { - entity_id: '550e8400-e29b-41d4-a716-446655440003', + resource_id: '550e8400-e29b-41d4-a716-446655440003', }; auditService.queryAuditLogs.mockResolvedValue(mockPaginatedAuditLogs); @@ -224,7 +224,7 @@ describe('AuditController', () => { const query: QueryAuditLogsDto = { user_id: mockUserId, action: AuditAction.UPDATE, - entity_type: 'document', + resource_type: 'document', from_date: '2026-01-01', page: 1, limit: 50, diff --git a/src/modules/audit/__tests__/audit.service.spec.ts b/src/modules/audit/__tests__/audit.service.spec.ts index 6bb86f8..5fdd4de 100644 --- a/src/modules/audit/__tests__/audit.service.spec.ts +++ b/src/modules/audit/__tests__/audit.service.spec.ts @@ -18,8 +18,8 @@ describe('AuditService', () => { tenant_id: mockTenantId, user_id: mockUserId, action: AuditAction.CREATE, - entity_type: 'user', - entity_id: 'user-001', + resource_type: 'user', + resource_id: 'user-001', new_values: { email: 'test@example.com' }, changed_fields: ['email'], ip_address: '192.168.1.1', @@ -83,8 +83,8 @@ describe('AuditService', () => { tenant_id: mockTenantId, user_id: mockUserId, action: AuditAction.CREATE, - entity_type: 'user', - entity_id: 'user-001', + resource_type: 'user', + resource_id: 'user-001', new_values: { email: 'test@example.com' }, }); @@ -101,8 +101,8 @@ describe('AuditService', () => { tenant_id: mockTenantId, user_id: mockUserId, action: AuditAction.UPDATE, - entity_type: 'user', - entity_id: 'user-001', + resource_type: 'user', + resource_id: 'user-001', old_values: { email: 'old@example.com', name: 'Old Name' }, new_values: { email: 'new@example.com', name: 'Old Name' }, }); @@ -121,8 +121,8 @@ describe('AuditService', () => { await service.createAuditLog({ tenant_id: mockTenantId, action: AuditAction.DELETE, - entity_type: 'user', - entity_id: 'user-001', + resource_type: 'user', + resource_id: 'user-001', }); expect(auditLogRepo.create).toHaveBeenCalledWith( @@ -140,7 +140,7 @@ describe('AuditService', () => { tenant_id: mockTenantId, user_id: mockUserId, action: AuditAction.READ, - entity_type: 'document', + resource_type: 'document', ip_address: '192.168.1.1', user_agent: 'Mozilla/5.0', endpoint: '/api/documents/1', @@ -217,7 +217,7 @@ describe('AuditService', () => { }); }); - it('should filter by entity_type', async () => { + it('should filter by resource_type', async () => { const qb = { where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), @@ -228,10 +228,10 @@ describe('AuditService', () => { }; auditLogRepo.createQueryBuilder.mockReturnValue(qb as any); - await service.queryAuditLogs(mockTenantId, { entity_type: 'user' }); + await service.queryAuditLogs(mockTenantId, { resource_type: 'user' }); - expect(qb.andWhere).toHaveBeenCalledWith('audit.entity_type = :entity_type', { - entity_type: 'user', + expect(qb.andWhere).toHaveBeenCalledWith('audit.resource_type = :resource_type', { + resource_type: 'user', }); }); @@ -318,8 +318,8 @@ describe('AuditService', () => { expect(auditLogRepo.find).toHaveBeenCalledWith({ where: { tenant_id: mockTenantId, - entity_type: 'user', - entity_id: 'user-001', + resource_type: 'user', + resource_id: 'user-001', }, order: { created_at: 'DESC' }, }); diff --git a/src/modules/audit/dto/query-audit.dto.ts b/src/modules/audit/dto/query-audit.dto.ts index 1329ec0..fd347f8 100644 --- a/src/modules/audit/dto/query-audit.dto.ts +++ b/src/modules/audit/dto/query-audit.dto.ts @@ -23,15 +23,15 @@ export class QueryAuditLogsDto { @IsEnum(AuditAction) action?: AuditAction; - @ApiPropertyOptional({ description: 'Filter by entity type' }) + @ApiPropertyOptional({ description: 'Filter by resource type' }) @IsOptional() @IsString() - entity_type?: string; + resource_type?: string; - @ApiPropertyOptional({ description: 'Filter by entity ID' }) + @ApiPropertyOptional({ description: 'Filter by resource ID' }) @IsOptional() @IsUUID() - entity_id?: string; + resource_id?: string; @ApiPropertyOptional({ description: 'Start date filter (ISO 8601)' }) @IsOptional() diff --git a/src/modules/audit/entities/audit-log.entity.ts b/src/modules/audit/entities/audit-log.entity.ts index 906ade5..3573610 100644 --- a/src/modules/audit/entities/audit-log.entity.ts +++ b/src/modules/audit/entities/audit-log.entity.ts @@ -6,6 +6,9 @@ import { Index, } from 'typeorm'; +/** + * Audit action enum - matches DDL audit.action_type + */ export enum AuditAction { CREATE = 'create', UPDATE = 'update', @@ -17,10 +20,39 @@ export enum AuditAction { IMPORT = 'import', } +/** + * Audit severity enum - matches DDL audit.severity + */ +export enum AuditSeverity { + INFO = 'info', + WARNING = 'warning', + ERROR = 'error', + CRITICAL = 'critical', +} + +/** + * Actor type for audit logs + */ +export enum ActorType { + USER = 'user', + SYSTEM = 'system', + API_KEY = 'api_key', + WEBHOOK = 'webhook', +} + +/** + * AuditLog Entity + * Maps to audit.audit_logs DDL table + * Comprehensive audit trail for compliance + * + * Note: Uses old_values/new_values/changed_fields structure instead of + * single 'changes' JSONB for better query flexibility and filtering. + */ @Entity({ name: 'audit_logs', schema: 'audit' }) @Index(['tenant_id', 'created_at']) -@Index(['entity_type', 'entity_id']) +@Index(['resource_type', 'resource_id']) @Index(['user_id', 'created_at']) +@Index(['tenant_id', 'action', 'created_at']) export class AuditLog { @PrimaryGeneratedColumn('uuid') id: string; @@ -28,52 +60,82 @@ export class AuditLog { @Column({ type: 'uuid' }) tenant_id: string; + // Actor information @Column({ type: 'uuid', nullable: true }) - user_id: string; + user_id: string | null; + @Column({ type: 'varchar', length: 255, nullable: true }) + user_email: string | null; + + @Column({ type: 'varchar', length: 50, default: ActorType.USER }) + actor_type: string; + + // Action @Column({ type: 'enum', enum: AuditAction, }) action: AuditAction; + // Resource identification (aligned with DDL naming) @Column({ type: 'varchar', length: 100 }) - entity_type: string; - - @Column({ type: 'uuid', nullable: true }) - entity_id: string; - - @Column({ type: 'jsonb', nullable: true }) - old_values: Record; - - @Column({ type: 'jsonb', nullable: true }) - new_values: Record; - - @Column({ type: 'jsonb', nullable: true }) - changed_fields: string[]; - - @Column({ type: 'varchar', length: 45, nullable: true }) - ip_address: string; - - @Column({ type: 'varchar', length: 500, nullable: true }) - user_agent: string; + resource_type: string; @Column({ type: 'varchar', length: 255, nullable: true }) - endpoint: string; + resource_id: string | null; - @Column({ type: 'varchar', length: 10, nullable: true }) - http_method: string; + @Column({ type: 'varchar', length: 255, nullable: true }) + resource_name: string | null; - @Column({ type: 'smallint', nullable: true }) - response_status: number; - - @Column({ type: 'integer', nullable: true }) - duration_ms: number; - - @Column({ type: 'text', nullable: true }) - description: string; + // Change tracking (Entity structure - more flexible for queries) + @Column({ type: 'jsonb', nullable: true }) + old_values: Record | null; @Column({ type: 'jsonb', nullable: true }) + new_values: Record | null; + + @Column({ type: 'jsonb', nullable: true }) + changed_fields: string[] | null; + + // Severity + @Column({ + type: 'enum', + enum: AuditSeverity, + default: AuditSeverity.INFO, + }) + severity: AuditSeverity; + + // Context + @Column({ type: 'inet', nullable: true }) + ip_address: string | null; + + @Column({ type: 'text', nullable: true }) + user_agent: string | null; + + @Column({ type: 'varchar', length: 100, nullable: true }) + request_id: string | null; + + @Column({ type: 'uuid', nullable: true }) + session_id: string | null; + + // HTTP context (for API audit) + @Column({ type: 'varchar', length: 255, nullable: true }) + endpoint: string | null; + + @Column({ type: 'varchar', length: 10, nullable: true }) + http_method: string | null; + + @Column({ type: 'smallint', nullable: true }) + response_status: number | null; + + @Column({ type: 'integer', nullable: true }) + duration_ms: number | null; + + // Description and metadata + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ type: 'jsonb', default: {} }) metadata: Record; @CreateDateColumn({ type: 'timestamp with time zone' }) diff --git a/src/modules/audit/interceptors/audit.interceptor.ts b/src/modules/audit/interceptors/audit.interceptor.ts index a9262b3..95a61cc 100644 --- a/src/modules/audit/interceptors/audit.interceptor.ts +++ b/src/modules/audit/interceptors/audit.interceptor.ts @@ -120,8 +120,8 @@ export class AuditInterceptor implements NestInterceptor { tenant_id: tenantId, user_id: request.user?.sub || request.user?.id, action, - entity_type: entityType || this.inferEntityFromPath(request.path), - entity_id: request.params?.id, + resource_type: entityType || this.inferEntityFromPath(request.path), + resource_id: request.params?.id, old_values: request.body?._oldValues, // If provided by controller new_values: this.sanitizeBody(request.body), ip_address: this.getClientIp(request), diff --git a/src/modules/audit/services/audit.service.ts b/src/modules/audit/services/audit.service.ts index 5669be4..b4dbbb2 100644 --- a/src/modules/audit/services/audit.service.ts +++ b/src/modules/audit/services/audit.service.ts @@ -11,8 +11,8 @@ export interface CreateAuditLogParams { tenant_id: string; user_id?: string; action: AuditAction; - entity_type: string; - entity_id?: string; + resource_type: string; + resource_id?: string; old_values?: Record; new_values?: Record; ip_address?: string; @@ -66,7 +66,7 @@ export class AuditService { tenantId: string, query: QueryAuditLogsDto, ): Promise> { - const { user_id, action, entity_type, entity_id, from_date, to_date, page = 1, limit = 20 } = query; + const { user_id, action, resource_type, resource_id, from_date, to_date, page = 1, limit = 20 } = query; const queryBuilder = this.auditLogRepository .createQueryBuilder('audit') @@ -80,12 +80,12 @@ export class AuditService { queryBuilder.andWhere('audit.action = :action', { action }); } - if (entity_type) { - queryBuilder.andWhere('audit.entity_type = :entity_type', { entity_type }); + if (resource_type) { + queryBuilder.andWhere('audit.resource_type = :resource_type', { resource_type }); } - if (entity_id) { - queryBuilder.andWhere('audit.entity_id = :entity_id', { entity_id }); + if (resource_id) { + queryBuilder.andWhere('audit.resource_id = :resource_id', { resource_id }); } if (from_date && to_date) { @@ -129,14 +129,14 @@ export class AuditService { */ async getEntityAuditHistory( tenantId: string, - entityType: string, - entityId: string, + resourceType: string, + resourceId: string, ): Promise { return this.auditLogRepository.find({ where: { tenant_id: tenantId, - entity_type: entityType, - entity_id: entityId, + resource_type: resourceType, + resource_id: resourceId, }, order: { created_at: 'DESC' }, }); diff --git a/src/modules/auth/__tests__/auth.service.spec.ts b/src/modules/auth/__tests__/auth.service.spec.ts index 1b0a008..7249e40 100644 --- a/src/modules/auth/__tests__/auth.service.spec.ts +++ b/src/modules/auth/__tests__/auth.service.spec.ts @@ -11,7 +11,7 @@ import { } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; import { AuthService } from '../services/auth.service'; -import { User, Session, Token } from '../entities'; +import { User, Session, Token, SessionStatus } from '../entities'; // Mock bcrypt jest.mock('bcrypt'); @@ -210,8 +210,8 @@ describe('AuthService', () => { await service.logout(mockUser.id!, 'session_token'); expect(sessionRepository.update).toHaveBeenCalledWith( - { user_id: mockUser.id, session_token: 'session_token' }, - { is_active: false }, + { user_id: mockUser.id, token_hash: 'session_token' }, + { status: SessionStatus.REVOKED }, ); }); }); @@ -224,7 +224,7 @@ describe('AuthService', () => { expect(sessionRepository.update).toHaveBeenCalledWith( { user_id: mockUser.id }, - { is_active: false }, + { status: SessionStatus.REVOKED }, ); }); }); @@ -422,7 +422,7 @@ describe('AuthService', () => { user_id: mockUser.id, tenant_id: mockTenantId, refresh_token_hash: 'hashed_refresh_token', - is_active: true, + status: SessionStatus.ACTIVE, expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now }; @@ -527,7 +527,7 @@ describe('AuthService', () => { expect(sessionRepository.update).toHaveBeenCalledWith( { id: expiredSession.id }, - { is_active: false }, + { status: SessionStatus.EXPIRED }, ); }); }); @@ -805,7 +805,7 @@ describe('AuthService', () => { expect(sessionRepository.update).toHaveBeenCalledWith( { user_id: mockToken.user_id }, - { is_active: false }, + { status: SessionStatus.REVOKED }, ); }); diff --git a/src/modules/auth/entities/session.entity.ts b/src/modules/auth/entities/session.entity.ts index 3151415..379643e 100644 --- a/src/modules/auth/entities/session.entity.ts +++ b/src/modules/auth/entities/session.entity.ts @@ -6,7 +6,22 @@ import { Index, } from 'typeorm'; +/** + * Session status enum - matches DDL auth.session_status + */ +export enum SessionStatus { + ACTIVE = 'active', + REVOKED = 'revoked', + EXPIRED = 'expired', +} + +/** + * Session Entity + * Maps to auth.sessions DDL table + * Manages user authentication sessions with device info + */ @Entity({ schema: 'auth', name: 'sessions' }) +@Index(['tenant_id', 'user_id']) export class Session { @PrimaryGeneratedColumn('uuid') id: string; @@ -19,14 +34,20 @@ export class Session { @Index() tenant_id: string; - @Column({ type: 'varchar', length: 64 }) - @Index({ unique: true }) - session_token: string; + @Column({ type: 'varchar', length: 255, unique: true }) + token_hash: string; @Column({ type: 'varchar', length: 64, nullable: true }) refresh_token_hash: string | null; - @Column({ type: 'varchar', length: 45, nullable: true }) + @Column({ + type: 'enum', + enum: SessionStatus, + default: SessionStatus.ACTIVE, + }) + status: SessionStatus; + + @Column({ type: 'inet', nullable: true }) ip_address: string | null; @Column({ type: 'text', nullable: true }) @@ -35,14 +56,29 @@ export class Session { @Column({ type: 'varchar', length: 50, nullable: true }) device_type: string | null; + @Column({ type: 'varchar', length: 200, nullable: true }) + device_name: string | null; + + @Column({ type: 'varchar', length: 100, nullable: true }) + browser: string | null; + + @Column({ type: 'varchar', length: 100, nullable: true }) + os: string | null; + + @Column({ type: 'varchar', length: 200, nullable: true }) + location: string | null; + @Column({ type: 'timestamp with time zone' }) expires_at: Date; @Column({ type: 'timestamp with time zone', nullable: true }) last_activity_at: Date | null; - @Column({ type: 'boolean', default: true }) - is_active: boolean; + @Column({ type: 'timestamp with time zone', nullable: true }) + revoked_at: Date | null; + + @Column({ type: 'varchar', length: 100, nullable: true }) + revoked_reason: string | null; @CreateDateColumn({ type: 'timestamp with time zone' }) created_at: Date; diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index 845aa89..be3569a 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -11,7 +11,7 @@ import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import * as bcrypt from 'bcrypt'; import * as crypto from 'crypto'; -import { User, Session, Token } from '../entities'; +import { User, Session, Token, SessionStatus } from '../entities'; import { RegisterDto, LoginDto, ChangePasswordDto } from '../dto'; export interface AuthResponse { @@ -139,8 +139,8 @@ export class AuthService { */ async logout(userId: string, sessionToken: string): Promise { await this.sessionRepository.update( - { user_id: userId, session_token: sessionToken }, - { is_active: false }, + { user_id: userId, token_hash: sessionToken }, + { status: SessionStatus.REVOKED }, ); } @@ -150,7 +150,7 @@ export class AuthService { async logoutAll(userId: string): Promise { await this.sessionRepository.update( { user_id: userId }, - { is_active: false }, + { status: SessionStatus.REVOKED }, ); } @@ -183,7 +183,7 @@ export class AuthService { where: { user_id: user.id, refresh_token_hash: refreshTokenHash, - is_active: true, + status: SessionStatus.ACTIVE, }, }); @@ -193,7 +193,7 @@ export class AuthService { // Check if session expired if (new Date() > session.expires_at) { - await this.sessionRepository.update({ id: session.id }, { is_active: false }); + await this.sessionRepository.update({ id: session.id }, { status: SessionStatus.EXPIRED }); throw new UnauthorizedException('Sesión expirada'); } @@ -434,14 +434,14 @@ export class AuthService { await this.sessionRepository.save({ user_id: user.id, tenant_id: user.tenant_id, - session_token: sessionToken, + token_hash: sessionToken, refresh_token_hash: refreshTokenHash, ip_address: ip || null, user_agent: userAgent || null, device_type: this.detectDeviceType(userAgent), expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), last_activity_at: new Date(), - is_active: true, + status: SessionStatus.ACTIVE, }); } diff --git a/src/modules/billing/__tests__/billing-edge-cases.spec.ts b/src/modules/billing/__tests__/billing-edge-cases.spec.ts index e30cc2c..84c655f 100644 --- a/src/modules/billing/__tests__/billing-edge-cases.spec.ts +++ b/src/modules/billing/__tests__/billing-edge-cases.spec.ts @@ -26,12 +26,20 @@ describe('BillingService - Edge Cases', () => { tenant_id: mockTenantId, plan_id: mockPlanId, plan: null, + stripe_subscription_id: null, + stripe_customer_id: null, status: SubscriptionStatus.ACTIVE, + interval: null, current_period_start: new Date('2026-01-01'), current_period_end: new Date('2026-02-01'), + trial_start: null, trial_end: null, - cancelled_at: null, - external_subscription_id: '', + cancel_at: null, + canceled_at: null, + cancel_reason: null, + price_amount: null, + currency: 'USD', + external_subscription_id: null, payment_provider: 'stripe', metadata: {}, created_at: new Date(), @@ -55,6 +63,7 @@ describe('BillingService - Edge Cases', () => { pdf_url: null, line_items: [{ description: 'Pro Plan', quantity: 1, unit_price: 100, amount: 100 }], billing_details: null as unknown as { name?: string; email?: string; address?: string; tax_id?: string }, + items: [], created_at: new Date(), updated_at: new Date(), ...overrides, @@ -131,7 +140,7 @@ describe('BillingService - Edge Cases', () => { farFutureTrial.setFullYear(farFutureTrial.getFullYear() + 1); const trialSub = createMockSubscription({ - status: SubscriptionStatus.TRIAL, + status: SubscriptionStatus.TRIALING, trial_end: farFutureTrial, }); @@ -147,7 +156,7 @@ describe('BillingService - Edge Cases', () => { const result = await service.createSubscription(dto); - expect(result.status).toBe(SubscriptionStatus.TRIAL); + expect(result.status).toBe(SubscriptionStatus.TRIALING); expect(result.trial_end).toEqual(farFutureTrial); }); @@ -205,7 +214,7 @@ describe('BillingService - Edge Cases', () => { }); const cancelledSub = createMockSubscription({ - cancelled_at: new Date(), + canceled_at: new Date(), metadata: { existing_key: 'existing_value', cancellation_reason: 'Too expensive', @@ -226,13 +235,13 @@ describe('BillingService - Edge Cases', () => { it('should cancel trial subscription immediately', async () => { const trialSub = createMockSubscription({ - status: SubscriptionStatus.TRIAL, + status: SubscriptionStatus.TRIALING, trial_end: new Date('2026-01-20'), }); const cancelledSub = createMockSubscription({ status: SubscriptionStatus.CANCELLED, - cancelled_at: new Date(), + canceled_at: new Date(), }); subscriptionRepo.findOne.mockResolvedValue(trialSub); @@ -252,7 +261,7 @@ describe('BillingService - Edge Cases', () => { subscriptionRepo.findOne.mockResolvedValue(activeSub); subscriptionRepo.save.mockImplementation((sub) => - Promise.resolve({ ...sub, cancelled_at: new Date() } as Subscription), + Promise.resolve({ ...sub, canceled_at: new Date() } as Subscription), ); const result = await service.cancelSubscription(mockTenantId, { @@ -260,7 +269,7 @@ describe('BillingService - Edge Cases', () => { }); expect(result.status).toBe(SubscriptionStatus.ACTIVE); - expect(result.cancelled_at).toBeDefined(); + expect(result.canceled_at).toBeDefined(); }); }); @@ -887,7 +896,7 @@ describe('BillingService - Edge Cases', () => { it('should return active for trial subscription', async () => { const trialSub = createMockSubscription({ - status: SubscriptionStatus.TRIAL, + status: SubscriptionStatus.TRIALING, current_period_end: new Date('2026-02-01'), trial_end: new Date('2026-01-15'), }); @@ -897,7 +906,7 @@ describe('BillingService - Edge Cases', () => { const result = await service.checkSubscriptionStatus(mockTenantId); expect(result.isActive).toBe(true); - expect(result.status).toBe(SubscriptionStatus.TRIAL); + expect(result.status).toBe(SubscriptionStatus.TRIALING); }); }); }); diff --git a/src/modules/billing/__tests__/billing.service.spec.ts b/src/modules/billing/__tests__/billing.service.spec.ts index c4df805..5abe648 100644 --- a/src/modules/billing/__tests__/billing.service.spec.ts +++ b/src/modules/billing/__tests__/billing.service.spec.ts @@ -118,7 +118,7 @@ describe('BillingService', () => { it('should create trial subscription when trial_end provided', async () => { const trialSub = { ...mockSubscription, - status: SubscriptionStatus.TRIAL, + status: SubscriptionStatus.TRIALING, trial_end: new Date('2026-01-15'), }; subscriptionRepo.create.mockReturnValue(trialSub as Subscription); @@ -133,7 +133,7 @@ describe('BillingService', () => { const result = await service.createSubscription(dto); - expect(result.status).toBe(SubscriptionStatus.TRIAL); + expect(result.status).toBe(SubscriptionStatus.TRIALING); }); }); @@ -189,7 +189,7 @@ describe('BillingService', () => { subscriptionRepo.save.mockResolvedValue({ ...mockSubscription, status: SubscriptionStatus.CANCELLED, - cancelled_at: new Date(), + canceled_at: new Date(), } as Subscription); const result = await service.cancelSubscription(mockTenantId, { @@ -198,12 +198,12 @@ describe('BillingService', () => { }); expect(result.status).toBe(SubscriptionStatus.CANCELLED); - expect(result.cancelled_at).toBeDefined(); + expect(result.canceled_at).toBeDefined(); }); it('should schedule cancellation at period end', async () => { const activeSub = { ...mockSubscription, status: SubscriptionStatus.ACTIVE }; - const savedSub = { ...activeSub, cancelled_at: new Date() }; + const savedSub = { ...activeSub, canceled_at: new Date() }; subscriptionRepo.findOne.mockResolvedValue(activeSub as Subscription); subscriptionRepo.save.mockResolvedValue(savedSub as Subscription); @@ -211,7 +211,7 @@ describe('BillingService', () => { immediately: false, }); - expect(result.cancelled_at).toBeDefined(); + expect(result.canceled_at).toBeDefined(); expect(result.status).toBe(SubscriptionStatus.ACTIVE); }); @@ -539,14 +539,14 @@ describe('BillingService', () => { subscriptionRepo.findOne.mockResolvedValue({ ...mockSubscription, - status: SubscriptionStatus.TRIAL, + status: SubscriptionStatus.TRIALING, current_period_end: futureDate, } as Subscription); const result = await service.checkSubscriptionStatus(mockTenantId); expect(result.isActive).toBe(true); - expect(result.status).toBe(SubscriptionStatus.TRIAL); + expect(result.status).toBe(SubscriptionStatus.TRIALING); }); }); }); diff --git a/src/modules/billing/__tests__/stripe.service.spec.ts b/src/modules/billing/__tests__/stripe.service.spec.ts index 75d515f..0c0f990 100644 --- a/src/modules/billing/__tests__/stripe.service.spec.ts +++ b/src/modules/billing/__tests__/stripe.service.spec.ts @@ -752,7 +752,7 @@ describe('StripeService', () => { id: 'local-sub-123', tenant_id: mockTenantId, external_subscription_id: mockSubscriptionId, - status: SubscriptionStatus.TRIAL, + status: SubscriptionStatus.TRIALING, }; const event: Stripe.Event = { @@ -854,7 +854,7 @@ describe('StripeService', () => { expect(subscriptionRepo.save).toHaveBeenCalledWith( expect.objectContaining({ status: SubscriptionStatus.CANCELLED, - cancelled_at: expect.any(Date), + canceled_at: expect.any(Date), }), ); }); @@ -1383,7 +1383,7 @@ describe('StripeService', () => { describe('mapStripeStatus', () => { it('should map trialing to TRIAL', () => { const result = (service as any).mapStripeStatus('trialing'); - expect(result).toBe(SubscriptionStatus.TRIAL); + expect(result).toBe(SubscriptionStatus.TRIALING); }); it('should map active to ACTIVE', () => { @@ -1408,7 +1408,7 @@ describe('StripeService', () => { it('should map incomplete to TRIAL', () => { const result = (service as any).mapStripeStatus('incomplete'); - expect(result).toBe(SubscriptionStatus.TRIAL); + expect(result).toBe(SubscriptionStatus.TRIALING); }); it('should map incomplete_expired to EXPIRED', () => { diff --git a/src/modules/billing/entities/invoice-item.entity.ts b/src/modules/billing/entities/invoice-item.entity.ts index c340e71..e2c78fa 100644 --- a/src/modules/billing/entities/invoice-item.entity.ts +++ b/src/modules/billing/entities/invoice-item.entity.ts @@ -4,7 +4,10 @@ import { PrimaryGeneratedColumn, CreateDateColumn, Index, + ManyToOne, + JoinColumn, } from 'typeorm'; +import { Invoice } from './invoice.entity'; /** * InvoiceItem Entity @@ -20,6 +23,10 @@ export class InvoiceItem { @Index() invoice_id: string; + @ManyToOne(() => Invoice, (invoice) => invoice.items, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'invoice_id' }) + invoice: Invoice; + @Column({ type: 'varchar', length: 500 }) description: string; diff --git a/src/modules/billing/entities/invoice.entity.ts b/src/modules/billing/entities/invoice.entity.ts index 55cbcb5..81454d1 100644 --- a/src/modules/billing/entities/invoice.entity.ts +++ b/src/modules/billing/entities/invoice.entity.ts @@ -4,7 +4,9 @@ import { Column, CreateDateColumn, UpdateDateColumn, + OneToMany, } from 'typeorm'; +import { InvoiceItem } from './invoice-item.entity'; export enum InvoiceStatus { DRAFT = 'draft', @@ -75,6 +77,10 @@ export class Invoice { tax_id?: string; }; + // Relation to invoice items + @OneToMany(() => InvoiceItem, (item) => item.invoice, { cascade: true }) + items: InvoiceItem[]; + @CreateDateColumn({ type: 'timestamp with time zone' }) created_at: Date; diff --git a/src/modules/billing/entities/subscription.entity.ts b/src/modules/billing/entities/subscription.entity.ts index 162bc97..855392c 100644 --- a/src/modules/billing/entities/subscription.entity.ts +++ b/src/modules/billing/entities/subscription.entity.ts @@ -6,23 +6,41 @@ import { UpdateDateColumn, ManyToOne, JoinColumn, + Index, } from 'typeorm'; import { Plan } from './plan.entity'; +/** + * Subscription status enum - matches DDL tenants.subscription_status + */ export enum SubscriptionStatus { - TRIAL = 'trial', + TRIALING = 'trialing', ACTIVE = 'active', PAST_DUE = 'past_due', CANCELLED = 'cancelled', EXPIRED = 'expired', } +/** + * Billing interval enum - matches DDL billing.billing_interval + */ +export enum BillingInterval { + MONTH = 'month', + YEAR = 'year', +} + +/** + * Subscription Entity + * Maps to billing.subscriptions DDL table + * Manages tenant subscriptions with Stripe integration + */ @Entity({ name: 'subscriptions', schema: 'billing' }) +@Index(['tenant_id']) export class Subscription { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ type: 'uuid' }) + @Column({ type: 'uuid', unique: true }) tenant_id: string; @Column({ type: 'uuid' }) @@ -32,32 +50,68 @@ export class Subscription { @JoinColumn({ name: 'plan_id' }) plan: Plan | null; + // Stripe integration + @Column({ type: 'varchar', length: 255, unique: true, nullable: true }) + @Index() + stripe_subscription_id: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + stripe_customer_id: string | null; + @Column({ type: 'enum', enum: SubscriptionStatus, - default: SubscriptionStatus.TRIAL, + default: SubscriptionStatus.TRIALING, }) status: SubscriptionStatus; - @Column({ type: 'timestamp with time zone' }) - current_period_start: Date; + @Column({ + type: 'enum', + enum: BillingInterval, + default: BillingInterval.MONTH, + nullable: true, + }) + interval: BillingInterval | null; - @Column({ type: 'timestamp with time zone' }) - current_period_end: Date; + // Current billing period + @Column({ type: 'timestamp with time zone', nullable: true }) + current_period_start: Date | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + current_period_end: Date | null; + + // Trial period + @Column({ type: 'timestamp with time zone', nullable: true }) + trial_start: Date | null; @Column({ type: 'timestamp with time zone', nullable: true }) trial_end: Date | null; + // Cancellation @Column({ type: 'timestamp with time zone', nullable: true }) - cancelled_at: Date | null; + cancel_at: Date | null; + @Column({ type: 'timestamp with time zone', nullable: true }) + canceled_at: Date | null; + + @Column({ type: 'varchar', length: 500, nullable: true }) + cancel_reason: string | null; + + // Pricing at subscription time + @Column({ type: 'decimal', precision: 10, scale: 2, nullable: true }) + price_amount: number | null; + + @Column({ type: 'varchar', length: 3, default: 'USD' }) + currency: string; + + // Legacy/compatibility fields @Column({ type: 'varchar', length: 255, nullable: true }) - external_subscription_id: string; + external_subscription_id: string | null; @Column({ type: 'varchar', length: 50, nullable: true }) - payment_provider: string; + payment_provider: string | null; - @Column({ type: 'jsonb', nullable: true }) + @Column({ type: 'jsonb', default: {} }) metadata: Record; @CreateDateColumn({ type: 'timestamp with time zone' }) diff --git a/src/modules/billing/services/billing.service.ts b/src/modules/billing/services/billing.service.ts index 0915f12..dbeafdf 100644 --- a/src/modules/billing/services/billing.service.ts +++ b/src/modules/billing/services/billing.service.ts @@ -35,7 +35,7 @@ export class BillingService { const subscription = this.subscriptionRepo.create({ tenant_id: dto.tenant_id, plan_id: dto.plan_id, - status: dto.trial_end ? SubscriptionStatus.TRIAL : SubscriptionStatus.ACTIVE, + status: dto.trial_end ? SubscriptionStatus.TRIALING : SubscriptionStatus.ACTIVE, current_period_start: now, current_period_end: periodEnd, trial_end: dto.trial_end ? new Date(dto.trial_end) : null, @@ -74,7 +74,7 @@ export class BillingService { throw new NotFoundException('Subscription not found'); } - subscription.cancelled_at = new Date(); + subscription.canceled_at = new Date(); if (dto.immediately) { subscription.status = SubscriptionStatus.CANCELLED; @@ -112,10 +112,12 @@ export class BillingService { } const now = new Date(); - const newPeriodEnd = new Date(subscription.current_period_end); + const newPeriodEnd = subscription.current_period_end + ? new Date(subscription.current_period_end) + : new Date(); newPeriodEnd.setMonth(newPeriodEnd.getMonth() + 1); - subscription.current_period_start = subscription.current_period_end; + subscription.current_period_start = subscription.current_period_end ?? new Date(); subscription.current_period_end = newPeriodEnd; subscription.status = SubscriptionStatus.ACTIVE; @@ -336,14 +338,16 @@ export class BillingService { } const now = new Date(); - const periodEnd = new Date(subscription.current_period_end); + const periodEnd = subscription.current_period_end + ? new Date(subscription.current_period_end) + : new Date(); const daysRemaining = Math.ceil( (periodEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), ); const isActive = [ SubscriptionStatus.ACTIVE, - SubscriptionStatus.TRIAL, + SubscriptionStatus.TRIALING, ].includes(subscription.status); return { diff --git a/src/modules/billing/services/stripe.service.ts b/src/modules/billing/services/stripe.service.ts index 192971a..e2ccb0e 100644 --- a/src/modules/billing/services/stripe.service.ts +++ b/src/modules/billing/services/stripe.service.ts @@ -360,7 +360,7 @@ export class StripeService implements OnModuleInit { } if (stripeSubscription.canceled_at) { - subscription.cancelled_at = new Date(stripeSubscription.canceled_at * 1000); + subscription.canceled_at = new Date(stripeSubscription.canceled_at * 1000); } subscription.metadata = { @@ -380,7 +380,7 @@ export class StripeService implements OnModuleInit { if (subscription) { subscription.status = SubscriptionStatus.CANCELLED; - subscription.cancelled_at = new Date(); + subscription.canceled_at = new Date(); await this.subscriptionRepo.save(subscription); this.logger.log(`Cancelled subscription ${stripeSubscription.id}`); } @@ -517,12 +517,12 @@ export class StripeService implements OnModuleInit { private mapStripeStatus(stripeStatus: Stripe.Subscription.Status): SubscriptionStatus { const statusMap: Record = { - trialing: SubscriptionStatus.TRIAL, + trialing: SubscriptionStatus.TRIALING, active: SubscriptionStatus.ACTIVE, past_due: SubscriptionStatus.PAST_DUE, canceled: SubscriptionStatus.CANCELLED, unpaid: SubscriptionStatus.PAST_DUE, - incomplete: SubscriptionStatus.TRIAL, + incomplete: SubscriptionStatus.TRIALING, incomplete_expired: SubscriptionStatus.EXPIRED, paused: SubscriptionStatus.CANCELLED, }; diff --git a/src/modules/onboarding/__tests__/onboarding.service.spec.ts b/src/modules/onboarding/__tests__/onboarding.service.spec.ts index 2f8f178..5bfb415 100644 --- a/src/modules/onboarding/__tests__/onboarding.service.spec.ts +++ b/src/modules/onboarding/__tests__/onboarding.service.spec.ts @@ -201,7 +201,7 @@ describe('OnboardingService', () => { tenant_id: mockTenantId, user_id: mockUserId, action: 'update', - entity_type: 'tenant', + resource_type: 'tenant', }), ); expect(emailService.sendTemplateEmail).toHaveBeenCalledWith( diff --git a/src/modules/onboarding/onboarding.service.ts b/src/modules/onboarding/onboarding.service.ts index 6430d33..2166866 100644 --- a/src/modules/onboarding/onboarding.service.ts +++ b/src/modules/onboarding/onboarding.service.ts @@ -119,8 +119,8 @@ export class OnboardingService { tenant_id: tenantId, user_id: userId, action: AuditAction.UPDATE, - entity_type: 'tenant', - entity_id: tenantId, + resource_type: 'tenant', + resource_id: tenantId, old_values: { status: oldStatus }, new_values: { status: 'active' }, description: 'Onboarding completed', diff --git a/src/modules/reports/services/excel.service.ts b/src/modules/reports/services/excel.service.ts index b7a95e4..223664d 100644 --- a/src/modules/reports/services/excel.service.ts +++ b/src/modules/reports/services/excel.service.ts @@ -49,7 +49,7 @@ export class ExcelService { { header: 'Period Start', key: 'period_start', width: 20 }, { header: 'Period End', key: 'period_end', width: 20 }, { header: 'Trial End', key: 'trial_end', width: 20 }, - { header: 'Cancelled At', key: 'cancelled_at', width: 20 }, + { header: 'Canceled At', key: 'canceled_at', width: 20 }, { header: 'Created At', key: 'created_at', width: 20 }, ]; @@ -62,7 +62,7 @@ export class ExcelService { period_start: sub.current_period_start ? new Date(sub.current_period_start).toLocaleString() : '', period_end: sub.current_period_end ? new Date(sub.current_period_end).toLocaleString() : '', trial_end: sub.trial_end ? new Date(sub.trial_end).toLocaleString() : '', - cancelled_at: sub.cancelled_at ? new Date(sub.cancelled_at).toLocaleString() : '', + canceled_at: sub.canceled_at ? new Date(sub.canceled_at).toLocaleString() : '', created_at: sub.created_at ? new Date(sub.created_at).toLocaleString() : '', })); @@ -76,8 +76,8 @@ export class ExcelService { const columns = [ { header: 'ID', key: 'id', width: 40 }, { header: 'Action', key: 'action', width: 15 }, - { header: 'Entity Type', key: 'entity_type', width: 20 }, - { header: 'Entity ID', key: 'entity_id', width: 40 }, + { header: 'Resource Type', key: 'resource_type', width: 20 }, + { header: 'Resource ID', key: 'resource_id', width: 40 }, { header: 'User ID', key: 'user_id', width: 40 }, { header: 'IP Address', key: 'ip_address', width: 15 }, { header: 'Endpoint', key: 'endpoint', width: 30 }, @@ -91,8 +91,8 @@ export class ExcelService { const data = logs.map(log => ({ id: log.id, action: log.action, - entity_type: log.entity_type, - entity_id: log.entity_id || '', + resource_type: log.resource_type, + resource_id: log.resource_id || '', user_id: log.user_id || '', ip_address: log.ip_address || '', endpoint: log.endpoint || '', diff --git a/src/modules/reports/services/pdf.service.ts b/src/modules/reports/services/pdf.service.ts index c8d0769..5a6c161 100644 --- a/src/modules/reports/services/pdf.service.ts +++ b/src/modules/reports/services/pdf.service.ts @@ -49,14 +49,14 @@ export class PdfService { async generateAuditReport(logs: AuditLog[]): Promise { return this.generatePdf('Audit Report', logs, [ { header: 'Action', key: 'action', width: 80 }, - { header: 'Entity Type', key: 'entity_type', width: 100 }, - { header: 'Entity ID', key: 'entity_id', width: 100 }, + { header: 'Resource Type', key: 'resource_type', width: 100 }, + { header: 'Resource ID', key: 'resource_id', width: 100 }, { header: 'User ID', key: 'user_id', width: 100 }, { header: 'Created At', key: 'created_at', width: 120 }, ], (log: AuditLog) => ({ action: log.action, - entity_type: log.entity_type, - entity_id: this.truncateString(log.entity_id || 'N/A', 15), + resource_type: log.resource_type, + resource_id: this.truncateString(log.resource_id || 'N/A', 15), user_id: this.truncateString(log.user_id || 'N/A', 15), created_at: log.created_at ? new Date(log.created_at).toLocaleString() : 'N/A', }));