[TEMPLATE-SAAS-BE] chore: Update analytics and audit modules

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-20 04:38:45 -06:00
parent 7cb64656fd
commit bb556a7789
24 changed files with 340 additions and 164 deletions

View File

@ -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);

View File

@ -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' },

View File

@ -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();

View File

@ -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,

View File

@ -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' },
});

View File

@ -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()

View File

@ -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' })

View File

@ -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),

View File

@ -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' },
});

View File

@ -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 },
);
});

View File

@ -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;

View File

@ -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,
});
}

View File

@ -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);
});
});
});

View File

@ -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);
});
});
});

View File

@ -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', () => {

View File

@ -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;

View File

@ -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;

View File

@ -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' })

View File

@ -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 {

View File

@ -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,
};

View File

@ -201,7 +201,7 @@ describe('OnboardingService', () => {
tenant_id: mockTenantId,
user_id: mockUserId,
action: 'update',
entity_type: 'tenant',
resource_type: 'tenant',
}),
);
expect(emailService.sendTemplateEmail).toHaveBeenCalledWith(

View File

@ -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',

View File

@ -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 || '',

View File

@ -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',
}));