[TEMPLATE-SAAS-BE] chore: Update analytics and audit modules
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
7cb64656fd
commit
bb556a7789
@ -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);
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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<string, number> = {};
|
||||
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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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' },
|
||||
});
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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<string, any>;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
new_values: Record<string, any>;
|
||||
|
||||
@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<string, any> | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
new_values: Record<string, any> | 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<string, any>;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp with time zone' })
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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<string, any>;
|
||||
new_values?: Record<string, any>;
|
||||
ip_address?: string;
|
||||
@ -66,7 +66,7 @@ export class AuditService {
|
||||
tenantId: string,
|
||||
query: QueryAuditLogsDto,
|
||||
): Promise<PaginatedResult<AuditLog>> {
|
||||
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<AuditLog[]> {
|
||||
return this.auditLogRepository.find({
|
||||
where: {
|
||||
tenant_id: tenantId,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
resource_type: resourceType,
|
||||
resource_id: resourceId,
|
||||
},
|
||||
order: { created_at: 'DESC' },
|
||||
});
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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<string, any>;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamp with time zone' })
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<Stripe.Subscription.Status, SubscriptionStatus> = {
|
||||
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,
|
||||
};
|
||||
|
||||
@ -201,7 +201,7 @@ describe('OnboardingService', () => {
|
||||
tenant_id: mockTenantId,
|
||||
user_id: mockUserId,
|
||||
action: 'update',
|
||||
entity_type: 'tenant',
|
||||
resource_type: 'tenant',
|
||||
}),
|
||||
);
|
||||
expect(emailService.sendTemplateEmail).toHaveBeenCalledWith(
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 || '',
|
||||
|
||||
@ -49,14 +49,14 @@ export class PdfService {
|
||||
async generateAuditReport(logs: AuditLog[]): Promise<Buffer> {
|
||||
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',
|
||||
}));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user