diff --git a/src/modules/billing-usage/__tests__/invoices.service.test.ts b/src/modules/billing-usage/__tests__/invoices.service.test.ts new file mode 100644 index 0000000..6db5b4d --- /dev/null +++ b/src/modules/billing-usage/__tests__/invoices.service.test.ts @@ -0,0 +1,786 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { createMockRepository, createMockQueryBuilder } from '../../../__tests__/helpers.js'; + +// Mock factories +function createMockInvoice(overrides: Record = {}) { + return { + id: 'invoice-uuid-1', + tenantId: 'tenant-uuid-1', + subscriptionId: 'sub-uuid-1', + invoiceNumber: 'INV-202601-0001', + invoiceDate: new Date('2026-01-15'), + periodStart: new Date('2026-01-01'), + periodEnd: new Date('2026-01-31'), + billingName: 'Test Company', + billingEmail: 'billing@test.com', + billingAddress: { street: '123 Main St', city: 'Mexico City' }, + taxId: 'RFC123456789', + subtotal: 499, + taxAmount: 79.84, + discountAmount: 0, + total: 578.84, + paidAmount: 0, + currency: 'MXN', + status: 'draft', + dueDate: new Date('2026-01-30'), + notes: '', + internalNotes: '', + items: [], + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function createMockInvoiceItem(overrides: Record = {}) { + return { + id: 'item-uuid-1', + invoiceId: 'invoice-uuid-1', + itemType: 'subscription', + description: 'Suscripcion Starter - Mensual', + quantity: 1, + unitPrice: 499, + subtotal: 499, + metadata: {}, + ...overrides, + }; +} + +function createMockSubscription(overrides: Record = {}) { + return { + id: 'sub-uuid-1', + tenantId: 'tenant-uuid-1', + planId: 'plan-uuid-1', + currentPrice: 499, + billingCycle: 'monthly', + contractedUsers: 10, + contractedBranches: 3, + billingName: 'Test Company', + billingEmail: 'billing@test.com', + billingAddress: { street: '123 Main St' }, + taxId: 'RFC123456789', + plan: { + id: 'plan-uuid-1', + name: 'Starter', + maxUsers: 10, + maxBranches: 3, + storageGb: 20, + }, + ...overrides, + }; +} + +function createMockUsage(overrides: Record = {}) { + return { + id: 'usage-uuid-1', + tenantId: 'tenant-uuid-1', + periodStart: new Date('2026-01-01'), + periodEnd: new Date('2026-01-31'), + activeUsers: 5, + activeBranches: 2, + storageUsedGb: 10, + apiCalls: 5000, + ...overrides, + }; +} + +// Mock repositories +const mockInvoiceRepository = { + ...createMockRepository(), + softDelete: jest.fn().mockResolvedValue({ affected: 1 }), +}; +const mockItemRepository = createMockRepository(); +const mockSubscriptionRepository = createMockRepository(); +const mockUsageRepository = createMockRepository(); +const mockQueryBuilder = createMockQueryBuilder(); + +// Mock DataSource +const mockDataSource = { + getRepository: jest.fn((entity: any) => { + const entityName = entity.name || entity; + if (entityName === 'Invoice') return mockInvoiceRepository; + if (entityName === 'InvoiceItem') return mockItemRepository; + if (entityName === 'TenantSubscription') return mockSubscriptionRepository; + if (entityName === 'UsageTracking') return mockUsageRepository; + return mockInvoiceRepository; + }), +}; + +jest.mock('../../../shared/utils/logger.js', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + }, +})); + +// Import after mocking +import { InvoicesService } from '../services/invoices.service.js'; + +describe('InvoicesService', () => { + let service: InvoicesService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new InvoicesService(mockDataSource as any); + mockInvoiceRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + }); + + describe('create', () => { + it('should create invoice with items', async () => { + const dto = { + tenantId: 'tenant-uuid-1', + subscriptionId: 'sub-uuid-1', + periodStart: new Date('2026-01-01'), + periodEnd: new Date('2026-01-31'), + billingName: 'Test Company', + billingEmail: 'billing@test.com', + dueDate: new Date('2026-01-30'), + items: [ + { + itemType: 'subscription' as const, + description: 'Suscripcion Starter', + quantity: 1, + unitPrice: 499, + }, + ], + }; + + // Mock invoice number generation + mockQueryBuilder.getOne.mockResolvedValueOnce(null); + + const mockInvoice = createMockInvoice({ ...dto, id: 'new-invoice-uuid' }); + mockInvoiceRepository.create.mockReturnValue(mockInvoice); + mockInvoiceRepository.save.mockResolvedValue(mockInvoice); + mockItemRepository.create.mockReturnValue(createMockInvoiceItem()); + mockItemRepository.save.mockResolvedValue(createMockInvoiceItem()); + mockInvoiceRepository.findOne.mockResolvedValue({ + ...mockInvoice, + items: [createMockInvoiceItem()], + }); + + const result = await service.create(dto); + + expect(mockInvoiceRepository.create).toHaveBeenCalled(); + expect(mockInvoiceRepository.save).toHaveBeenCalled(); + expect(mockItemRepository.create).toHaveBeenCalled(); + expect(result.tenantId).toBe('tenant-uuid-1'); + }); + + it('should calculate totals with tax', async () => { + const dto = { + tenantId: 'tenant-uuid-1', + items: [ + { + itemType: 'subscription' as const, + description: 'Plan', + quantity: 1, + unitPrice: 1000, + }, + ], + }; + + mockQueryBuilder.getOne.mockResolvedValueOnce(null); + + mockInvoiceRepository.create.mockImplementation((data: any) => ({ + ...createMockInvoice(), + ...data, + id: 'invoice-id', + })); + mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv)); + mockInvoiceRepository.findOne.mockImplementation((opts: any) => Promise.resolve({ + ...createMockInvoice(), + id: opts.where.id, + items: [], + })); + mockItemRepository.create.mockReturnValue(createMockInvoiceItem()); + mockItemRepository.save.mockResolvedValue(createMockInvoiceItem()); + + await service.create(dto); + + // Verify subtotal calculation (1000) + // Tax should be 16% = 160 + // Total should be 1160 + expect(mockInvoiceRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + subtotal: 1000, + taxAmount: 160, + total: 1160, + }) + ); + }); + + it('should apply item discounts', async () => { + const dto = { + tenantId: 'tenant-uuid-1', + items: [ + { + itemType: 'subscription' as const, + description: 'Plan', + quantity: 1, + unitPrice: 1000, + discountPercent: 10, // 10% off + }, + ], + }; + + mockQueryBuilder.getOne.mockResolvedValueOnce(null); + mockInvoiceRepository.create.mockImplementation((data: any) => data); + mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve({ ...inv, id: 'inv-id' })); + mockInvoiceRepository.findOne.mockResolvedValue(createMockInvoice()); + mockItemRepository.create.mockReturnValue(createMockInvoiceItem()); + mockItemRepository.save.mockResolvedValue(createMockInvoiceItem()); + + await service.create(dto); + + // Subtotal after 10% discount: 1000 - 100 = 900 + // Tax 16%: 144 + // Total: 1044 + expect(mockInvoiceRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + subtotal: 900, + taxAmount: 144, + total: 1044, + }) + ); + }); + }); + + describe('generateFromSubscription', () => { + it('should generate invoice from subscription', async () => { + const dto = { + tenantId: 'tenant-uuid-1', + subscriptionId: 'sub-uuid-1', + periodStart: new Date('2026-01-01'), + periodEnd: new Date('2026-01-31'), + }; + + const mockSub = createMockSubscription(); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockQueryBuilder.getOne.mockResolvedValueOnce(null); + mockInvoiceRepository.create.mockImplementation((data: any) => ({ + ...createMockInvoice(), + ...data, + })); + mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve({ ...inv, id: 'inv-id' })); + mockInvoiceRepository.findOne.mockResolvedValue(createMockInvoice()); + mockItemRepository.create.mockReturnValue(createMockInvoiceItem()); + mockItemRepository.save.mockResolvedValue(createMockInvoiceItem()); + + const result = await service.generateFromSubscription(dto); + + expect(mockSubscriptionRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'sub-uuid-1' }, + relations: ['plan'], + }); + expect(result).toBeDefined(); + }); + + it('should throw error if subscription not found', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue(null); + + await expect( + service.generateFromSubscription({ + tenantId: 'tenant-uuid-1', + subscriptionId: 'invalid-id', + periodStart: new Date(), + periodEnd: new Date(), + }) + ).rejects.toThrow('Subscription not found'); + }); + + it('should include usage charges when requested', async () => { + const dto = { + tenantId: 'tenant-uuid-1', + subscriptionId: 'sub-uuid-1', + periodStart: new Date('2026-01-01'), + periodEnd: new Date('2026-01-31'), + includeUsageCharges: true, + }; + + const mockSub = createMockSubscription(); + const mockUsage = createMockUsage({ + activeUsers: 15, // 5 extra users + activeBranches: 5, // 2 extra branches + storageUsedGb: 25, // 5 extra GB + }); + + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockUsageRepository.findOne.mockResolvedValue(mockUsage); + mockQueryBuilder.getOne.mockResolvedValueOnce(null); + + let createdItems: any[] = []; + mockInvoiceRepository.create.mockImplementation((data: any) => ({ + ...createMockInvoice(), + ...data, + })); + mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve({ ...inv, id: 'inv-id' })); + mockInvoiceRepository.findOne.mockResolvedValue(createMockInvoice()); + mockItemRepository.create.mockImplementation((item: any) => { + createdItems.push(item); + return item; + }); + mockItemRepository.save.mockImplementation((item: any) => Promise.resolve(item)); + + await service.generateFromSubscription(dto); + + // Should have created items for: subscription + extra users + extra branches + extra storage + expect(createdItems.length).toBeGreaterThan(1); + expect(createdItems.some((i: any) => i.description.includes('Usuarios adicionales'))).toBe(true); + expect(createdItems.some((i: any) => i.description.includes('Sucursales adicionales'))).toBe(true); + expect(createdItems.some((i: any) => i.description.includes('Almacenamiento adicional'))).toBe(true); + }); + }); + + describe('findById', () => { + it('should return invoice by id with items', async () => { + const mockInvoice = createMockInvoice(); + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + + const result = await service.findById('invoice-uuid-1'); + + expect(mockInvoiceRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'invoice-uuid-1' }, + relations: ['items'], + }); + expect(result?.id).toBe('invoice-uuid-1'); + }); + + it('should return null if invoice not found', async () => { + mockInvoiceRepository.findOne.mockResolvedValue(null); + + const result = await service.findById('non-existent'); + + expect(result).toBeNull(); + }); + }); + + describe('findByNumber', () => { + it('should return invoice by invoice number', async () => { + const mockInvoice = createMockInvoice(); + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + + const result = await service.findByNumber('INV-202601-0001'); + + expect(mockInvoiceRepository.findOne).toHaveBeenCalledWith({ + where: { invoiceNumber: 'INV-202601-0001' }, + relations: ['items'], + }); + expect(result?.invoiceNumber).toBe('INV-202601-0001'); + }); + }); + + describe('findAll', () => { + it('should return invoices with filters', async () => { + const mockInvoices = [ + createMockInvoice({ id: 'inv-1' }), + createMockInvoice({ id: 'inv-2' }), + ]; + mockQueryBuilder.getMany.mockResolvedValue(mockInvoices); + mockQueryBuilder.getCount.mockResolvedValue(2); + + const result = await service.findAll({ tenantId: 'tenant-uuid-1' }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'invoice.tenantId = :tenantId', + { tenantId: 'tenant-uuid-1' } + ); + expect(result.data).toHaveLength(2); + expect(result.total).toBe(2); + }); + + it('should filter by status', async () => { + mockQueryBuilder.getMany.mockResolvedValue([]); + mockQueryBuilder.getCount.mockResolvedValue(0); + + await service.findAll({ status: 'paid' }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'invoice.status = :status', + { status: 'paid' } + ); + }); + + it('should filter by date range', async () => { + mockQueryBuilder.getMany.mockResolvedValue([]); + mockQueryBuilder.getCount.mockResolvedValue(0); + + const dateFrom = new Date('2026-01-01'); + const dateTo = new Date('2026-01-31'); + + await service.findAll({ dateFrom, dateTo }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'invoice.invoiceDate >= :dateFrom', + { dateFrom } + ); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'invoice.invoiceDate <= :dateTo', + { dateTo } + ); + }); + + it('should filter overdue invoices', async () => { + mockQueryBuilder.getMany.mockResolvedValue([]); + mockQueryBuilder.getCount.mockResolvedValue(0); + + await service.findAll({ overdue: true }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'invoice.dueDate < :now', + expect.any(Object) + ); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + "invoice.status IN ('sent', 'partial')" + ); + }); + + it('should apply pagination', async () => { + mockQueryBuilder.getMany.mockResolvedValue([]); + mockQueryBuilder.getCount.mockResolvedValue(100); + + await service.findAll({ limit: 10, offset: 20 }); + + expect(mockQueryBuilder.take).toHaveBeenCalledWith(10); + expect(mockQueryBuilder.skip).toHaveBeenCalledWith(20); + }); + }); + + describe('update', () => { + it('should update draft invoice', async () => { + const mockInvoice = createMockInvoice({ status: 'draft' }); + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv)); + + const result = await service.update('invoice-uuid-1', { notes: 'Updated note' }); + + expect(result.notes).toBe('Updated note'); + }); + + it('should throw error if invoice not found', async () => { + mockInvoiceRepository.findOne.mockResolvedValue(null); + + await expect(service.update('invalid-id', { notes: 'test' })).rejects.toThrow( + 'Invoice not found' + ); + }); + + it('should throw error if invoice is not draft', async () => { + const mockInvoice = createMockInvoice({ status: 'sent' }); + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + + await expect(service.update('invoice-uuid-1', { notes: 'test' })).rejects.toThrow( + 'Only draft invoices can be updated' + ); + }); + }); + + describe('send', () => { + it('should send draft invoice', async () => { + const mockInvoice = createMockInvoice({ status: 'draft' }); + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv)); + + const result = await service.send('invoice-uuid-1'); + + expect(result.status).toBe('sent'); + }); + + it('should throw error if invoice not found', async () => { + mockInvoiceRepository.findOne.mockResolvedValue(null); + + await expect(service.send('invalid-id')).rejects.toThrow('Invoice not found'); + }); + + it('should throw error if invoice is not draft', async () => { + const mockInvoice = createMockInvoice({ status: 'paid' }); + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + + await expect(service.send('invoice-uuid-1')).rejects.toThrow( + 'Only draft invoices can be sent' + ); + }); + }); + + describe('recordPayment', () => { + it('should record full payment', async () => { + const mockInvoice = createMockInvoice({ status: 'sent', total: 578.84, paidAmount: 0 }); + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv)); + + const result = await service.recordPayment('invoice-uuid-1', { + amount: 578.84, + paymentMethod: 'card', + paymentReference: 'PAY-123', + }); + + expect(result.status).toBe('paid'); + expect(result.paidAmount).toBe(578.84); + }); + + it('should record partial payment', async () => { + const mockInvoice = createMockInvoice({ status: 'sent', total: 578.84, paidAmount: 0 }); + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv)); + + const result = await service.recordPayment('invoice-uuid-1', { + amount: 300, + paymentMethod: 'transfer', + }); + + expect(result.status).toBe('partial'); + expect(result.paidAmount).toBe(300); + }); + + it('should throw error if invoice not found', async () => { + mockInvoiceRepository.findOne.mockResolvedValue(null); + + await expect( + service.recordPayment('invalid-id', { amount: 100, paymentMethod: 'card' }) + ).rejects.toThrow('Invoice not found'); + }); + + it('should throw error for voided invoice', async () => { + const mockInvoice = createMockInvoice({ status: 'void' }); + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + + await expect( + service.recordPayment('invoice-uuid-1', { amount: 100, paymentMethod: 'card' }) + ).rejects.toThrow('Cannot record payment for voided or refunded invoice'); + }); + + it('should throw error for refunded invoice', async () => { + const mockInvoice = createMockInvoice({ status: 'refunded' }); + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + + await expect( + service.recordPayment('invoice-uuid-1', { amount: 100, paymentMethod: 'card' }) + ).rejects.toThrow('Cannot record payment for voided or refunded invoice'); + }); + }); + + describe('void', () => { + it('should void draft invoice', async () => { + const mockInvoice = createMockInvoice({ status: 'draft' }); + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv)); + + const result = await service.void('invoice-uuid-1', { reason: 'Created by mistake' }); + + expect(result.status).toBe('void'); + expect(result.internalNotes).toContain('Voided: Created by mistake'); + }); + + it('should void sent invoice', async () => { + const mockInvoice = createMockInvoice({ status: 'sent' }); + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv)); + + const result = await service.void('invoice-uuid-1', { reason: 'Customer cancelled' }); + + expect(result.status).toBe('void'); + }); + + it('should throw error if invoice not found', async () => { + mockInvoiceRepository.findOne.mockResolvedValue(null); + + await expect(service.void('invalid-id', { reason: 'test' })).rejects.toThrow( + 'Invoice not found' + ); + }); + + it('should throw error for paid invoice', async () => { + const mockInvoice = createMockInvoice({ status: 'paid' }); + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + + await expect(service.void('invoice-uuid-1', { reason: 'test' })).rejects.toThrow( + 'Cannot void paid or refunded invoice' + ); + }); + + it('should throw error for already refunded invoice', async () => { + const mockInvoice = createMockInvoice({ status: 'refunded' }); + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + + await expect(service.void('invoice-uuid-1', { reason: 'test' })).rejects.toThrow( + 'Cannot void paid or refunded invoice' + ); + }); + }); + + describe('refund', () => { + it('should refund paid invoice fully', async () => { + const mockInvoice = createMockInvoice({ status: 'paid', paidAmount: 578.84 }); + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv)); + + const result = await service.refund('invoice-uuid-1', { reason: 'Customer requested' }); + + expect(result.status).toBe('refunded'); + expect(result.internalNotes).toContain('Refunded: 578.84'); + }); + + it('should refund partial amount', async () => { + const mockInvoice = createMockInvoice({ status: 'paid', paidAmount: 578.84 }); + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve(inv)); + + const result = await service.refund('invoice-uuid-1', { + amount: 200, + reason: 'Partial service', + }); + + expect(result.status).toBe('refunded'); + expect(result.internalNotes).toContain('Refunded: 200'); + }); + + it('should throw error if invoice not found', async () => { + mockInvoiceRepository.findOne.mockResolvedValue(null); + + await expect(service.refund('invalid-id', { reason: 'test' })).rejects.toThrow( + 'Invoice not found' + ); + }); + + it('should throw error for unpaid invoice', async () => { + const mockInvoice = createMockInvoice({ status: 'draft' }); + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + + await expect(service.refund('invoice-uuid-1', { reason: 'test' })).rejects.toThrow( + 'Only paid invoices can be refunded' + ); + }); + + it('should throw error if refund amount exceeds paid amount', async () => { + const mockInvoice = createMockInvoice({ status: 'paid', paidAmount: 100 }); + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + + await expect( + service.refund('invoice-uuid-1', { amount: 200, reason: 'test' }) + ).rejects.toThrow('Refund amount cannot exceed paid amount'); + }); + }); + + describe('markOverdueInvoices', () => { + it('should mark overdue invoices', async () => { + const mockUpdateBuilder = { + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 5 }), + }; + mockInvoiceRepository.createQueryBuilder.mockReturnValue(mockUpdateBuilder); + + const result = await service.markOverdueInvoices(); + + expect(result).toBe(5); + expect(mockUpdateBuilder.set).toHaveBeenCalledWith({ status: 'overdue' }); + }); + + it('should return 0 when no invoices are overdue', async () => { + const mockUpdateBuilder = { + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({ affected: 0 }), + }; + mockInvoiceRepository.createQueryBuilder.mockReturnValue(mockUpdateBuilder); + + const result = await service.markOverdueInvoices(); + + expect(result).toBe(0); + }); + }); + + describe('getStats', () => { + it('should return invoice statistics', async () => { + const mockInvoices = [ + createMockInvoice({ status: 'paid', paidAmount: 500, total: 500 }), + createMockInvoice({ status: 'paid', paidAmount: 300, total: 300 }), + createMockInvoice({ status: 'sent', paidAmount: 0, total: 400, dueDate: new Date('2025-01-01') }), + createMockInvoice({ status: 'draft', paidAmount: 0, total: 200 }), + ]; + mockQueryBuilder.getMany.mockResolvedValue(mockInvoices); + + const result = await service.getStats('tenant-uuid-1'); + + expect(mockQueryBuilder.where).toHaveBeenCalledWith( + 'invoice.tenantId = :tenantId', + { tenantId: 'tenant-uuid-1' } + ); + expect(result.total).toBe(4); + expect(result.byStatus.paid).toBe(2); + expect(result.byStatus.sent).toBe(1); + expect(result.byStatus.draft).toBe(1); + expect(result.totalRevenue).toBe(800); + expect(result.pendingAmount).toBe(400); + expect(result.overdueAmount).toBe(400); // The sent invoice is overdue + }); + + it('should return stats without tenant filter', async () => { + mockQueryBuilder.getMany.mockResolvedValue([]); + + const result = await service.getStats(); + + expect(mockQueryBuilder.where).not.toHaveBeenCalled(); + expect(result.total).toBe(0); + }); + }); + + describe('generateInvoiceNumber (via create)', () => { + it('should generate sequential invoice numbers', async () => { + // First invoice of the month + mockQueryBuilder.getOne.mockResolvedValueOnce(null); + + const dto = { + tenantId: 'tenant-uuid-1', + items: [{ itemType: 'subscription' as const, description: 'Test', quantity: 1, unitPrice: 100 }], + }; + + mockInvoiceRepository.create.mockImplementation((data: any) => data); + mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve({ ...inv, id: 'inv-1' })); + mockInvoiceRepository.findOne.mockResolvedValue(createMockInvoice()); + mockItemRepository.create.mockReturnValue(createMockInvoiceItem()); + mockItemRepository.save.mockResolvedValue(createMockInvoiceItem()); + + await service.create(dto); + + // Verify the invoice number format (INV-YYYYMM-0001) + expect(mockInvoiceRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + invoiceNumber: expect.stringMatching(/^INV-\d{6}-0001$/), + }) + ); + }); + + it('should increment sequence for existing invoices', async () => { + // Return existing invoice for the month + mockQueryBuilder.getOne.mockResolvedValueOnce( + createMockInvoice({ invoiceNumber: 'INV-202601-0005' }) + ); + + const dto = { + tenantId: 'tenant-uuid-1', + items: [{ itemType: 'subscription' as const, description: 'Test', quantity: 1, unitPrice: 100 }], + }; + + mockInvoiceRepository.create.mockImplementation((data: any) => data); + mockInvoiceRepository.save.mockImplementation((inv: any) => Promise.resolve({ ...inv, id: 'inv-1' })); + mockInvoiceRepository.findOne.mockResolvedValue(createMockInvoice()); + mockItemRepository.create.mockReturnValue(createMockInvoiceItem()); + mockItemRepository.save.mockResolvedValue(createMockInvoiceItem()); + + await service.create(dto); + + // Should be 0006 + expect(mockInvoiceRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + invoiceNumber: expect.stringMatching(/^INV-\d{6}-0006$/), + }) + ); + }); + }); +}); diff --git a/src/modules/billing-usage/__tests__/subscription-plans.service.test.ts b/src/modules/billing-usage/__tests__/subscription-plans.service.test.ts new file mode 100644 index 0000000..1b67fc0 --- /dev/null +++ b/src/modules/billing-usage/__tests__/subscription-plans.service.test.ts @@ -0,0 +1,408 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { createMockRepository, createMockQueryBuilder } from '../../../__tests__/helpers.js'; + +// Mock factories for subscription plan entities +function createMockSubscriptionPlan(overrides: Record = {}) { + return { + id: 'plan-uuid-1', + code: 'STARTER', + name: 'Starter Plan', + description: 'Perfect for small businesses', + planType: 'saas', + baseMonthlyPrice: 499, + baseAnnualPrice: 4990, + setupFee: 0, + maxUsers: 5, + maxBranches: 1, + storageGb: 10, + apiCallsMonthly: 10000, + includedModules: ['core', 'sales', 'inventory'], + includedPlatforms: ['web'], + features: { analytics: true, reports: false }, + isActive: true, + isPublic: true, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + ...overrides, + }; +} + +// Mock repositories +const mockPlanRepository = { + ...createMockRepository(), + softDelete: jest.fn().mockResolvedValue({ affected: 1 }), +}; +const mockQueryBuilder = createMockQueryBuilder(); + +// Mock DataSource +const mockDataSource = { + getRepository: jest.fn(() => mockPlanRepository), + createQueryBuilder: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ count: '0' }), + })), +}; + +jest.mock('../../../shared/utils/logger.js', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + }, +})); + +// Import after mocking +import { SubscriptionPlansService } from '../services/subscription-plans.service.js'; + +describe('SubscriptionPlansService', () => { + let service: SubscriptionPlansService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new SubscriptionPlansService(mockDataSource as any); + mockPlanRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + }); + + describe('create', () => { + it('should create a new subscription plan successfully', async () => { + const dto = { + code: 'NEWPLAN', + name: 'New Plan', + baseMonthlyPrice: 999, + maxUsers: 10, + }; + + const mockPlan = createMockSubscriptionPlan({ ...dto, id: 'new-plan-uuid' }); + mockPlanRepository.findOne.mockResolvedValue(null); + mockPlanRepository.create.mockReturnValue(mockPlan); + mockPlanRepository.save.mockResolvedValue(mockPlan); + + const result = await service.create(dto); + + expect(mockPlanRepository.findOne).toHaveBeenCalledWith({ where: { code: 'NEWPLAN' } }); + expect(mockPlanRepository.create).toHaveBeenCalled(); + expect(mockPlanRepository.save).toHaveBeenCalled(); + expect(result.code).toBe('NEWPLAN'); + }); + + it('should throw error if plan code already exists', async () => { + const dto = { + code: 'STARTER', + name: 'Duplicate Plan', + baseMonthlyPrice: 999, + }; + + mockPlanRepository.findOne.mockResolvedValue(createMockSubscriptionPlan()); + + await expect(service.create(dto)).rejects.toThrow('Plan with code STARTER already exists'); + }); + + it('should use default values when not provided', async () => { + const dto = { + code: 'MINIMAL', + name: 'Minimal Plan', + baseMonthlyPrice: 199, + }; + + mockPlanRepository.findOne.mockResolvedValue(null); + mockPlanRepository.create.mockImplementation((data: any) => ({ + ...data, + id: 'minimal-plan-uuid', + })); + mockPlanRepository.save.mockImplementation((plan: any) => Promise.resolve(plan)); + + await service.create(dto); + + expect(mockPlanRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + planType: 'saas', + setupFee: 0, + maxUsers: 5, + maxBranches: 1, + storageGb: 10, + apiCallsMonthly: 10000, + includedModules: [], + includedPlatforms: ['web'], + features: {}, + isActive: true, + isPublic: true, + }) + ); + }); + }); + + describe('findAll', () => { + it('should return all plans without filters', async () => { + const mockPlans = [ + createMockSubscriptionPlan({ id: 'plan-1', code: 'STARTER' }), + createMockSubscriptionPlan({ id: 'plan-2', code: 'PRO' }), + ]; + mockQueryBuilder.getMany.mockResolvedValue(mockPlans); + + const result = await service.findAll(); + + expect(mockPlanRepository.createQueryBuilder).toHaveBeenCalledWith('plan'); + expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('plan.baseMonthlyPrice', 'ASC'); + expect(result).toHaveLength(2); + }); + + it('should filter by isActive', async () => { + mockQueryBuilder.getMany.mockResolvedValue([createMockSubscriptionPlan()]); + + await service.findAll({ isActive: true }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'plan.isActive = :isActive', + { isActive: true } + ); + }); + + it('should filter by isPublic', async () => { + mockQueryBuilder.getMany.mockResolvedValue([]); + + await service.findAll({ isPublic: false }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'plan.isPublic = :isPublic', + { isPublic: false } + ); + }); + + it('should filter by planType', async () => { + mockQueryBuilder.getMany.mockResolvedValue([]); + + await service.findAll({ planType: 'on_premise' }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'plan.planType = :planType', + { planType: 'on_premise' } + ); + }); + + it('should apply multiple filters', async () => { + mockQueryBuilder.getMany.mockResolvedValue([]); + + await service.findAll({ isActive: true, isPublic: true, planType: 'saas' }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledTimes(3); + }); + }); + + describe('findPublicPlans', () => { + it('should return only active and public plans', async () => { + const publicPlans = [createMockSubscriptionPlan({ isActive: true, isPublic: true })]; + mockQueryBuilder.getMany.mockResolvedValue(publicPlans); + + const result = await service.findPublicPlans(); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'plan.isActive = :isActive', + { isActive: true } + ); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + 'plan.isPublic = :isPublic', + { isPublic: true } + ); + expect(result).toHaveLength(1); + }); + }); + + describe('findById', () => { + it('should return plan by id', async () => { + const mockPlan = createMockSubscriptionPlan(); + mockPlanRepository.findOne.mockResolvedValue(mockPlan); + + const result = await service.findById('plan-uuid-1'); + + expect(mockPlanRepository.findOne).toHaveBeenCalledWith({ where: { id: 'plan-uuid-1' } }); + expect(result?.id).toBe('plan-uuid-1'); + }); + + it('should return null if plan not found', async () => { + mockPlanRepository.findOne.mockResolvedValue(null); + + const result = await service.findById('non-existent-id'); + + expect(result).toBeNull(); + }); + }); + + describe('findByCode', () => { + it('should return plan by code', async () => { + const mockPlan = createMockSubscriptionPlan({ code: 'STARTER' }); + mockPlanRepository.findOne.mockResolvedValue(mockPlan); + + const result = await service.findByCode('STARTER'); + + expect(mockPlanRepository.findOne).toHaveBeenCalledWith({ where: { code: 'STARTER' } }); + expect(result?.code).toBe('STARTER'); + }); + + it('should return null if code not found', async () => { + mockPlanRepository.findOne.mockResolvedValue(null); + + const result = await service.findByCode('UNKNOWN'); + + expect(result).toBeNull(); + }); + }); + + describe('update', () => { + it('should update plan successfully', async () => { + const existingPlan = createMockSubscriptionPlan(); + const updateDto = { name: 'Updated Plan Name', baseMonthlyPrice: 599 }; + + mockPlanRepository.findOne.mockResolvedValue(existingPlan); + mockPlanRepository.save.mockImplementation((plan: any) => Promise.resolve(plan)); + + const result = await service.update('plan-uuid-1', updateDto); + + expect(mockPlanRepository.findOne).toHaveBeenCalledWith({ where: { id: 'plan-uuid-1' } }); + expect(result.name).toBe('Updated Plan Name'); + expect(result.baseMonthlyPrice).toBe(599); + }); + + it('should throw error if plan not found', async () => { + mockPlanRepository.findOne.mockResolvedValue(null); + + await expect(service.update('non-existent-id', { name: 'Test' })) + .rejects.toThrow('Plan not found'); + }); + }); + + describe('delete', () => { + it('should soft delete plan with no active subscriptions', async () => { + const mockPlan = createMockSubscriptionPlan(); + mockPlanRepository.findOne.mockResolvedValue(mockPlan); + mockDataSource.createQueryBuilder().getRawOne.mockResolvedValue({ count: '0' }); + + await service.delete('plan-uuid-1'); + + expect(mockPlanRepository.softDelete).toHaveBeenCalledWith('plan-uuid-1'); + }); + + it('should throw error if plan not found', async () => { + mockPlanRepository.findOne.mockResolvedValue(null); + + await expect(service.delete('non-existent-id')) + .rejects.toThrow('Plan not found'); + }); + + it('should throw error if plan has active subscriptions', async () => { + const mockPlan = createMockSubscriptionPlan(); + mockPlanRepository.findOne.mockResolvedValue(mockPlan); + + // Need to reset the mock to return count > 0 for this test + const mockQb = { + select: jest.fn().mockReturnThis(), + from: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getRawOne: jest.fn().mockResolvedValue({ count: '5' }), + }; + mockDataSource.createQueryBuilder.mockReturnValue(mockQb); + + await expect(service.delete('plan-uuid-1')) + .rejects.toThrow('Cannot delete plan with active subscriptions'); + }); + }); + + describe('setActive', () => { + it('should activate a plan', async () => { + const mockPlan = createMockSubscriptionPlan({ isActive: false }); + mockPlanRepository.findOne.mockResolvedValue(mockPlan); + mockPlanRepository.save.mockImplementation((plan: any) => Promise.resolve({ + ...plan, + isActive: true, + })); + + const result = await service.setActive('plan-uuid-1', true); + + expect(result.isActive).toBe(true); + }); + + it('should deactivate a plan', async () => { + const mockPlan = createMockSubscriptionPlan({ isActive: true }); + mockPlanRepository.findOne.mockResolvedValue(mockPlan); + mockPlanRepository.save.mockImplementation((plan: any) => Promise.resolve({ + ...plan, + isActive: false, + })); + + const result = await service.setActive('plan-uuid-1', false); + + expect(result.isActive).toBe(false); + }); + }); + + describe('comparePlans', () => { + it('should compare two plans and return differences', async () => { + const plan1 = createMockSubscriptionPlan({ + id: 'plan-1', + code: 'STARTER', + baseMonthlyPrice: 499, + maxUsers: 5, + includedModules: ['core', 'sales'], + }); + const plan2 = createMockSubscriptionPlan({ + id: 'plan-2', + code: 'PRO', + baseMonthlyPrice: 999, + maxUsers: 20, + includedModules: ['core', 'sales', 'inventory', 'reports'], + }); + + mockPlanRepository.findOne + .mockResolvedValueOnce(plan1) + .mockResolvedValueOnce(plan2); + + const result = await service.comparePlans('plan-1', 'plan-2'); + + expect(result.plan1.code).toBe('STARTER'); + expect(result.plan2.code).toBe('PRO'); + expect(result.differences.baseMonthlyPrice).toEqual({ + plan1: 499, + plan2: 999, + }); + expect(result.differences.maxUsers).toEqual({ + plan1: 5, + plan2: 20, + }); + expect(result.differences.includedModules).toBeDefined(); + }); + + it('should throw error if plan1 not found', async () => { + mockPlanRepository.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(createMockSubscriptionPlan()); + + await expect(service.comparePlans('invalid-1', 'plan-2')) + .rejects.toThrow('One or both plans not found'); + }); + + it('should throw error if plan2 not found', async () => { + mockPlanRepository.findOne + .mockResolvedValueOnce(createMockSubscriptionPlan()) + .mockResolvedValueOnce(null); + + await expect(service.comparePlans('plan-1', 'invalid-2')) + .rejects.toThrow('One or both plans not found'); + }); + + it('should return empty differences for identical plans', async () => { + const plan = createMockSubscriptionPlan(); + mockPlanRepository.findOne + .mockResolvedValueOnce(plan) + .mockResolvedValueOnce({ ...plan, id: 'plan-2' }); + + const result = await service.comparePlans('plan-1', 'plan-2'); + + expect(Object.keys(result.differences)).toHaveLength(0); + }); + }); +}); diff --git a/src/modules/billing-usage/__tests__/subscriptions.service.test.ts b/src/modules/billing-usage/__tests__/subscriptions.service.test.ts new file mode 100644 index 0000000..aa2b215 --- /dev/null +++ b/src/modules/billing-usage/__tests__/subscriptions.service.test.ts @@ -0,0 +1,502 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { createMockRepository, createMockQueryBuilder } from '../../../__tests__/helpers.js'; + +// Mock factories +function createMockSubscriptionPlan(overrides: Record = {}) { + return { + id: 'plan-uuid-1', + code: 'STARTER', + name: 'Starter Plan', + baseMonthlyPrice: 499, + baseAnnualPrice: 4990, + maxUsers: 5, + maxBranches: 1, + ...overrides, + }; +} + +function createMockSubscription(overrides: Record = {}) { + return { + id: 'sub-uuid-1', + tenantId: 'tenant-uuid-1', + planId: 'plan-uuid-1', + billingCycle: 'monthly', + currentPeriodStart: new Date('2026-01-01'), + currentPeriodEnd: new Date('2026-02-01'), + status: 'active', + trialStart: null, + trialEnd: null, + billingEmail: 'billing@example.com', + billingName: 'Test Company', + billingAddress: {}, + taxId: 'RFC123456', + paymentMethodId: null, + paymentProvider: null, + currentPrice: 499, + discountPercent: 0, + discountReason: null, + contractedUsers: 5, + contractedBranches: 1, + autoRenew: true, + nextInvoiceDate: new Date('2026-02-01'), + cancelAtPeriodEnd: false, + cancelledAt: null, + cancellationReason: null, + createdAt: new Date(), + updatedAt: new Date(), + plan: createMockSubscriptionPlan(), + ...overrides, + }; +} + +// Mock repositories +const mockSubscriptionRepository = createMockRepository(); +const mockPlanRepository = createMockRepository(); +const mockQueryBuilder = createMockQueryBuilder(); + +// Mock DataSource +const mockDataSource = { + getRepository: jest.fn((entity: any) => { + const entityName = entity.name || entity; + if (entityName === 'TenantSubscription') return mockSubscriptionRepository; + if (entityName === 'SubscriptionPlan') return mockPlanRepository; + return mockSubscriptionRepository; + }), +}; + +jest.mock('../../../shared/utils/logger.js', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + }, +})); + +// Import after mocking +import { SubscriptionsService } from '../services/subscriptions.service.js'; + +describe('SubscriptionsService', () => { + let service: SubscriptionsService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new SubscriptionsService(mockDataSource as any); + mockSubscriptionRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder); + }); + + describe('create', () => { + it('should create a new subscription successfully', async () => { + const dto = { + tenantId: 'tenant-uuid-new', + planId: 'plan-uuid-1', + billingEmail: 'test@example.com', + currentPrice: 499, + }; + + const mockPlan = createMockSubscriptionPlan(); + const mockSub = createMockSubscription({ tenantId: dto.tenantId }); + + mockSubscriptionRepository.findOne.mockResolvedValue(null); + mockPlanRepository.findOne.mockResolvedValue(mockPlan); + mockSubscriptionRepository.create.mockReturnValue(mockSub); + mockSubscriptionRepository.save.mockResolvedValue(mockSub); + + const result = await service.create(dto); + + expect(mockSubscriptionRepository.findOne).toHaveBeenCalledWith({ + where: { tenantId: 'tenant-uuid-new' }, + }); + expect(mockPlanRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'plan-uuid-1' }, + }); + expect(result.tenantId).toBe('tenant-uuid-new'); + }); + + it('should throw error if tenant already has subscription', async () => { + const dto = { + tenantId: 'tenant-uuid-1', + planId: 'plan-uuid-1', + currentPrice: 499, + }; + + mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription()); + + await expect(service.create(dto)).rejects.toThrow('Tenant already has a subscription'); + }); + + it('should throw error if plan not found', async () => { + const dto = { + tenantId: 'tenant-uuid-new', + planId: 'invalid-plan', + currentPrice: 499, + }; + + mockSubscriptionRepository.findOne.mockResolvedValue(null); + mockPlanRepository.findOne.mockResolvedValue(null); + + await expect(service.create(dto)).rejects.toThrow('Plan not found'); + }); + + it('should create subscription with trial', async () => { + const dto = { + tenantId: 'tenant-uuid-new', + planId: 'plan-uuid-1', + currentPrice: 499, + startWithTrial: true, + trialDays: 14, + }; + + const mockPlan = createMockSubscriptionPlan(); + mockSubscriptionRepository.findOne.mockResolvedValue(null); + mockPlanRepository.findOne.mockResolvedValue(mockPlan); + mockSubscriptionRepository.create.mockImplementation((data: any) => ({ + ...data, + id: 'new-sub-id', + })); + mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub)); + + const result = await service.create(dto); + + expect(mockSubscriptionRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'trial', + }) + ); + expect(result.trialStart).toBeDefined(); + expect(result.trialEnd).toBeDefined(); + }); + }); + + describe('findByTenantId', () => { + it('should return subscription with plan relation', async () => { + const mockSub = createMockSubscription(); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + + const result = await service.findByTenantId('tenant-uuid-1'); + + expect(mockSubscriptionRepository.findOne).toHaveBeenCalledWith({ + where: { tenantId: 'tenant-uuid-1' }, + relations: ['plan'], + }); + expect(result?.tenantId).toBe('tenant-uuid-1'); + }); + + it('should return null if not found', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue(null); + + const result = await service.findByTenantId('non-existent'); + + expect(result).toBeNull(); + }); + }); + + describe('findById', () => { + it('should return subscription by id', async () => { + const mockSub = createMockSubscription(); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + + const result = await service.findById('sub-uuid-1'); + + expect(mockSubscriptionRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'sub-uuid-1' }, + relations: ['plan'], + }); + expect(result?.id).toBe('sub-uuid-1'); + }); + }); + + describe('update', () => { + it('should update subscription successfully', async () => { + const mockSub = createMockSubscription(); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub)); + + const result = await service.update('sub-uuid-1', { + billingEmail: 'new@example.com', + }); + + expect(result.billingEmail).toBe('new@example.com'); + }); + + it('should throw error if subscription not found', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue(null); + + await expect(service.update('invalid-id', { billingEmail: 'test@example.com' })) + .rejects.toThrow('Subscription not found'); + }); + + it('should validate plan when changing plan', async () => { + const mockSub = createMockSubscription(); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockPlanRepository.findOne.mockResolvedValue(null); + + await expect(service.update('sub-uuid-1', { planId: 'new-plan-id' })) + .rejects.toThrow('Plan not found'); + }); + }); + + describe('cancel', () => { + it('should cancel at period end by default', async () => { + const mockSub = createMockSubscription(); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub)); + + const result = await service.cancel('sub-uuid-1', { reason: 'Too expensive' }); + + expect(result.cancelAtPeriodEnd).toBe(true); + expect(result.autoRenew).toBe(false); + expect(result.cancellationReason).toBe('Too expensive'); + expect(result.status).toBe('active'); // Still active until period end + }); + + it('should cancel immediately when specified', async () => { + const mockSub = createMockSubscription(); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub)); + + const result = await service.cancel('sub-uuid-1', { + reason: 'Closing business', + cancelImmediately: true, + }); + + expect(result.status).toBe('cancelled'); + }); + + it('should throw error if already cancelled', async () => { + const mockSub = createMockSubscription({ status: 'cancelled' }); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + + await expect(service.cancel('sub-uuid-1', {})) + .rejects.toThrow('Subscription is already cancelled'); + }); + + it('should throw error if not found', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue(null); + + await expect(service.cancel('invalid-id', {})) + .rejects.toThrow('Subscription not found'); + }); + }); + + describe('reactivate', () => { + it('should reactivate cancelled subscription', async () => { + const mockSub = createMockSubscription({ status: 'cancelled', cancelAtPeriodEnd: false }); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub)); + + const result = await service.reactivate('sub-uuid-1'); + + expect(result.status).toBe('active'); + expect(result.cancelAtPeriodEnd).toBe(false); + expect(result.autoRenew).toBe(true); + }); + + it('should reactivate subscription pending cancellation', async () => { + const mockSub = createMockSubscription({ status: 'active', cancelAtPeriodEnd: true }); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub)); + + const result = await service.reactivate('sub-uuid-1'); + + expect(result.cancelAtPeriodEnd).toBe(false); + expect(result.autoRenew).toBe(true); + }); + + it('should throw error if not cancelled', async () => { + const mockSub = createMockSubscription({ status: 'active', cancelAtPeriodEnd: false }); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + + await expect(service.reactivate('sub-uuid-1')) + .rejects.toThrow('Subscription is not cancelled'); + }); + }); + + describe('changePlan', () => { + it('should change to new plan', async () => { + const mockSub = createMockSubscription(); + const newPlan = createMockSubscriptionPlan({ + id: 'plan-uuid-2', + code: 'PRO', + baseMonthlyPrice: 999, + maxUsers: 20, + maxBranches: 5, + }); + + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockPlanRepository.findOne.mockResolvedValue(newPlan); + mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub)); + + const result = await service.changePlan('sub-uuid-1', { newPlanId: 'plan-uuid-2' }); + + expect(result.planId).toBe('plan-uuid-2'); + expect(result.currentPrice).toBe(999); + expect(result.contractedUsers).toBe(20); + expect(result.contractedBranches).toBe(5); + }); + + it('should throw error if new plan not found', async () => { + const mockSub = createMockSubscription(); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockPlanRepository.findOne.mockResolvedValue(null); + + await expect(service.changePlan('sub-uuid-1', { newPlanId: 'invalid-plan' })) + .rejects.toThrow('New plan not found'); + }); + + it('should apply existing discount to new plan price', async () => { + const mockSub = createMockSubscription({ discountPercent: 20 }); + const newPlan = createMockSubscriptionPlan({ + id: 'plan-uuid-2', + baseMonthlyPrice: 1000, + }); + + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockPlanRepository.findOne.mockResolvedValue(newPlan); + mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub)); + + const result = await service.changePlan('sub-uuid-1', { newPlanId: 'plan-uuid-2' }); + + expect(result.currentPrice).toBe(800); // 1000 - 20% + }); + }); + + describe('setPaymentMethod', () => { + it('should set payment method', async () => { + const mockSub = createMockSubscription(); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub)); + + const result = await service.setPaymentMethod('sub-uuid-1', { + paymentMethodId: 'pm_123', + paymentProvider: 'stripe', + }); + + expect(result.paymentMethodId).toBe('pm_123'); + expect(result.paymentProvider).toBe('stripe'); + }); + }); + + describe('renew', () => { + it('should renew subscription and advance period', async () => { + const mockSub = createMockSubscription({ + currentPeriodStart: new Date('2026-01-01'), + currentPeriodEnd: new Date('2026-02-01'), + }); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub)); + + const result = await service.renew('sub-uuid-1'); + + expect(result.currentPeriodStart.getTime()).toBe(new Date('2026-02-01').getTime()); + }); + + it('should cancel if cancelAtPeriodEnd is true', async () => { + const mockSub = createMockSubscription({ cancelAtPeriodEnd: true }); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub)); + + const result = await service.renew('sub-uuid-1'); + + expect(result.status).toBe('cancelled'); + }); + + it('should throw error if autoRenew is disabled', async () => { + const mockSub = createMockSubscription({ autoRenew: false }); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + + await expect(service.renew('sub-uuid-1')) + .rejects.toThrow('Subscription auto-renew is disabled'); + }); + + it('should transition from trial to active', async () => { + const mockSub = createMockSubscription({ status: 'trial' }); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub)); + + const result = await service.renew('sub-uuid-1'); + + expect(result.status).toBe('active'); + }); + }); + + describe('status updates', () => { + it('should mark as past due', async () => { + const mockSub = createMockSubscription(); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub)); + + const result = await service.markPastDue('sub-uuid-1'); + + expect(result.status).toBe('past_due'); + }); + + it('should suspend subscription', async () => { + const mockSub = createMockSubscription(); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub)); + + const result = await service.suspend('sub-uuid-1'); + + expect(result.status).toBe('suspended'); + }); + + it('should activate subscription', async () => { + const mockSub = createMockSubscription({ status: 'suspended' }); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockSubscriptionRepository.save.mockImplementation((sub: any) => Promise.resolve(sub)); + + const result = await service.activate('sub-uuid-1'); + + expect(result.status).toBe('active'); + }); + }); + + describe('findExpiringSoon', () => { + it('should find subscriptions expiring within days', async () => { + const mockSubs = [createMockSubscription()]; + mockQueryBuilder.getMany.mockResolvedValue(mockSubs); + + const result = await service.findExpiringSoon(7); + + expect(mockSubscriptionRepository.createQueryBuilder).toHaveBeenCalledWith('sub'); + expect(mockQueryBuilder.leftJoinAndSelect).toHaveBeenCalledWith('sub.plan', 'plan'); + expect(result).toHaveLength(1); + }); + }); + + describe('findTrialsEndingSoon', () => { + it('should find trials ending within days', async () => { + const mockSubs = [createMockSubscription({ status: 'trial' })]; + mockQueryBuilder.getMany.mockResolvedValue(mockSubs); + + const result = await service.findTrialsEndingSoon(3); + + expect(mockQueryBuilder.where).toHaveBeenCalledWith("sub.status = 'trial'"); + expect(result).toHaveLength(1); + }); + }); + + describe('getStats', () => { + it('should return subscription statistics', async () => { + const mockSubs = [ + createMockSubscription({ status: 'active', currentPrice: 499, plan: { code: 'STARTER' } }), + createMockSubscription({ status: 'active', currentPrice: 999, plan: { code: 'PRO' } }), + createMockSubscription({ status: 'trial', currentPrice: 499, plan: { code: 'STARTER' } }), + createMockSubscription({ status: 'cancelled', currentPrice: 499, plan: { code: 'STARTER' } }), + ]; + mockSubscriptionRepository.find.mockResolvedValue(mockSubs); + + const result = await service.getStats(); + + expect(result.total).toBe(4); + expect(result.byStatus.active).toBe(2); + expect(result.byStatus.trial).toBe(1); + expect(result.byStatus.cancelled).toBe(1); + expect(result.byPlan['STARTER']).toBe(3); + expect(result.byPlan['PRO']).toBe(1); + expect(result.totalMRR).toBe(499 + 999 + 499); // Active and trial subscriptions + expect(result.totalARR).toBe(result.totalMRR * 12); + }); + }); +}); diff --git a/src/modules/billing-usage/__tests__/usage-tracking.service.test.ts b/src/modules/billing-usage/__tests__/usage-tracking.service.test.ts new file mode 100644 index 0000000..0066aef --- /dev/null +++ b/src/modules/billing-usage/__tests__/usage-tracking.service.test.ts @@ -0,0 +1,423 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { createMockRepository } from '../../../__tests__/helpers.js'; + +// Mock factories +function createMockUsageTracking(overrides: Record = {}) { + return { + id: 'usage-uuid-1', + tenantId: 'tenant-uuid-1', + periodStart: new Date('2026-01-01'), + periodEnd: new Date('2026-01-31'), + activeUsers: 5, + peakConcurrentUsers: 3, + usersByProfile: { ADM: 1, VNT: 2, ALM: 2 }, + usersByPlatform: { web: 5, mobile: 2 }, + activeBranches: 2, + storageUsedGb: 5.5, + documentsCount: 1500, + apiCalls: 5000, + apiErrors: 50, + salesCount: 200, + salesAmount: 150000, + invoicesGenerated: 150, + mobileSessions: 100, + offlineSyncs: 25, + paymentTransactions: 180, + totalBillableAmount: 499, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function createMockSubscription(overrides: Record = {}) { + return { + id: 'sub-uuid-1', + tenantId: 'tenant-uuid-1', + planId: 'plan-uuid-1', + currentPrice: 499, + contractedUsers: 10, + contractedBranches: 3, + plan: { + id: 'plan-uuid-1', + code: 'STARTER', + maxUsers: 10, + maxBranches: 3, + storageGb: 20, + apiCallsMonthly: 10000, + }, + ...overrides, + }; +} + +// Mock repositories +const mockUsageRepository = createMockRepository(); +const mockSubscriptionRepository = createMockRepository(); +const mockPlanRepository = createMockRepository(); + +// Mock DataSource +const mockDataSource = { + getRepository: jest.fn((entity: any) => { + const entityName = entity.name || entity; + if (entityName === 'UsageTracking') return mockUsageRepository; + if (entityName === 'TenantSubscription') return mockSubscriptionRepository; + if (entityName === 'SubscriptionPlan') return mockPlanRepository; + return mockUsageRepository; + }), +}; + +jest.mock('../../../shared/utils/logger.js', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + }, +})); + +// Import after mocking +import { UsageTrackingService } from '../services/usage-tracking.service.js'; + +describe('UsageTrackingService', () => { + let service: UsageTrackingService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new UsageTrackingService(mockDataSource as any); + }); + + describe('recordUsage', () => { + it('should create new usage record', async () => { + const dto = { + tenantId: 'tenant-uuid-1', + periodStart: new Date('2026-01-01'), + periodEnd: new Date('2026-01-31'), + activeUsers: 5, + apiCalls: 1000, + }; + + const mockUsage = createMockUsageTracking(dto); + mockUsageRepository.findOne.mockResolvedValueOnce(null); // No existing record + mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription()); + mockUsageRepository.create.mockReturnValue(mockUsage); + mockUsageRepository.save.mockResolvedValue(mockUsage); + + const result = await service.recordUsage(dto); + + expect(mockUsageRepository.findOne).toHaveBeenCalled(); + expect(mockUsageRepository.create).toHaveBeenCalled(); + expect(result.tenantId).toBe('tenant-uuid-1'); + }); + + it('should update existing record if one exists for period', async () => { + const dto = { + tenantId: 'tenant-uuid-1', + periodStart: new Date('2026-01-01'), + periodEnd: new Date('2026-01-31'), + activeUsers: 10, + }; + + const existingUsage = createMockUsageTracking(); + mockUsageRepository.findOne + .mockResolvedValueOnce(existingUsage) // First call - check existing + .mockResolvedValueOnce(existingUsage); // Second call - in update + mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription()); + mockUsageRepository.save.mockImplementation((usage: any) => Promise.resolve(usage)); + + const result = await service.recordUsage(dto); + + expect(result.activeUsers).toBe(10); + }); + }); + + describe('update', () => { + it('should update usage record', async () => { + const mockUsage = createMockUsageTracking(); + mockUsageRepository.findOne.mockResolvedValue(mockUsage); + mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription()); + mockUsageRepository.save.mockImplementation((usage: any) => Promise.resolve(usage)); + + const result = await service.update('usage-uuid-1', { apiCalls: 8000 }); + + expect(result.apiCalls).toBe(8000); + }); + + it('should throw error if record not found', async () => { + mockUsageRepository.findOne.mockResolvedValue(null); + + await expect(service.update('invalid-id', { apiCalls: 100 })) + .rejects.toThrow('Usage record not found'); + }); + + it('should recalculate billable amount on update', async () => { + const mockUsage = createMockUsageTracking(); + mockUsageRepository.findOne.mockResolvedValue(mockUsage); + mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription()); + mockUsageRepository.save.mockImplementation((usage: any) => Promise.resolve(usage)); + + await service.update('usage-uuid-1', { activeUsers: 15 }); // Exceeds limit + + expect(mockUsageRepository.save).toHaveBeenCalled(); + }); + }); + + describe('incrementMetric', () => { + it('should increment metric on existing record', async () => { + const mockUsage = createMockUsageTracking({ apiCalls: 5000 }); + mockUsageRepository.findOne.mockResolvedValue(mockUsage); + mockUsageRepository.save.mockImplementation((usage: any) => Promise.resolve(usage)); + + await service.incrementMetric('tenant-uuid-1', 'apiCalls', 100); + + expect(mockUsageRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ apiCalls: 5100 }) + ); + }); + + it('should create record if none exists for period', async () => { + mockUsageRepository.findOne.mockResolvedValue(null); + mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription()); + mockUsageRepository.create.mockImplementation((data: any) => ({ + ...createMockUsageTracking(), + ...data, + apiCalls: 0, + })); + mockUsageRepository.save.mockImplementation((usage: any) => Promise.resolve(usage)); + + await service.incrementMetric('tenant-uuid-1', 'apiCalls', 50); + + expect(mockUsageRepository.create).toHaveBeenCalled(); + }); + }); + + describe('getCurrentUsage', () => { + it('should return current period usage', async () => { + const mockUsage = createMockUsageTracking(); + mockUsageRepository.findOne.mockResolvedValue(mockUsage); + + const result = await service.getCurrentUsage('tenant-uuid-1'); + + expect(result?.tenantId).toBe('tenant-uuid-1'); + }); + + it('should return null if no usage for current period', async () => { + mockUsageRepository.findOne.mockResolvedValue(null); + + const result = await service.getCurrentUsage('tenant-uuid-1'); + + expect(result).toBeNull(); + }); + }); + + describe('getUsageHistory', () => { + it('should return usage records within date range', async () => { + const mockUsages = [ + createMockUsageTracking({ id: 'usage-1' }), + createMockUsageTracking({ id: 'usage-2' }), + ]; + mockUsageRepository.find.mockResolvedValue(mockUsages); + + const result = await service.getUsageHistory( + 'tenant-uuid-1', + new Date('2026-01-01'), + new Date('2026-03-31') + ); + + expect(mockUsageRepository.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ tenantId: 'tenant-uuid-1' }), + order: { periodStart: 'DESC' }, + }) + ); + expect(result).toHaveLength(2); + }); + }); + + describe('getUsageSummary', () => { + it('should return usage summary with limits', async () => { + const mockSub = createMockSubscription(); + const mockUsage = createMockUsageTracking(); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockUsageRepository.findOne.mockResolvedValue(mockUsage); + + const result = await service.getUsageSummary('tenant-uuid-1'); + + expect(result.tenantId).toBe('tenant-uuid-1'); + expect(result.currentUsers).toBe(5); + expect(result.limits.maxUsers).toBe(10); + expect(result.percentages.usersUsed).toBe(50); + }); + + it('should throw error if subscription not found', async () => { + mockSubscriptionRepository.findOne.mockResolvedValue(null); + + await expect(service.getUsageSummary('tenant-uuid-1')) + .rejects.toThrow('Subscription not found'); + }); + + it('should handle missing current usage gracefully', async () => { + const mockSub = createMockSubscription(); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockUsageRepository.findOne.mockResolvedValue(null); + + const result = await service.getUsageSummary('tenant-uuid-1'); + + expect(result.currentUsers).toBe(0); + expect(result.apiCallsThisMonth).toBe(0); + }); + }); + + describe('checkLimits', () => { + it('should return no violations when within limits', async () => { + const mockSub = createMockSubscription(); + const mockUsage = createMockUsageTracking({ + activeUsers: 5, + activeBranches: 2, + storageUsedGb: 10, + apiCalls: 5000, + }); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockUsageRepository.findOne.mockResolvedValue(mockUsage); + + const result = await service.checkLimits('tenant-uuid-1'); + + expect(result.exceeds).toBe(false); + expect(result.violations).toHaveLength(0); + }); + + it('should return violations when limits exceeded', async () => { + const mockSub = createMockSubscription(); + const mockUsage = createMockUsageTracking({ + activeUsers: 15, // Exceeds 10 + activeBranches: 5, // Exceeds 3 + storageUsedGb: 10, + apiCalls: 5000, + }); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockUsageRepository.findOne.mockResolvedValue(mockUsage); + + const result = await service.checkLimits('tenant-uuid-1'); + + expect(result.exceeds).toBe(true); + expect(result.violations.length).toBeGreaterThan(0); + expect(result.violations.some((v: string) => v.includes('Users'))).toBe(true); + expect(result.violations.some((v: string) => v.includes('Branches'))).toBe(true); + }); + + it('should return warnings at 80% threshold', async () => { + const mockSub = createMockSubscription(); + const mockUsage = createMockUsageTracking({ + activeUsers: 8, // 80% of 10 + activeBranches: 2, + storageUsedGb: 16, // 80% of 20 + apiCalls: 8000, // 80% of 10000 + }); + mockSubscriptionRepository.findOne.mockResolvedValue(mockSub); + mockUsageRepository.findOne.mockResolvedValue(mockUsage); + + const result = await service.checkLimits('tenant-uuid-1'); + + expect(result.exceeds).toBe(false); + expect(result.warnings.length).toBeGreaterThan(0); + expect(result.warnings.some((w: string) => w.includes('Users'))).toBe(true); + expect(result.warnings.some((w: string) => w.includes('Storage'))).toBe(true); + }); + }); + + describe('getUsageReport', () => { + it('should generate usage report with totals and averages', async () => { + const mockUsages = [ + createMockUsageTracking({ + activeUsers: 5, + activeBranches: 2, + storageUsedGb: 5, + apiCalls: 5000, + salesCount: 100, + salesAmount: 50000, + }), + createMockUsageTracking({ + activeUsers: 7, + activeBranches: 3, + storageUsedGb: 6, + apiCalls: 6000, + salesCount: 150, + salesAmount: 75000, + }), + ]; + mockUsageRepository.find.mockResolvedValue(mockUsages); + + const result = await service.getUsageReport( + 'tenant-uuid-1', + new Date('2026-01-01'), + new Date('2026-02-28') + ); + + expect(result.tenantId).toBe('tenant-uuid-1'); + expect(result.data).toHaveLength(2); + expect(result.totals.apiCalls).toBe(11000); + expect(result.totals.salesCount).toBe(250); + expect(result.totals.salesAmount).toBe(125000); + expect(result.averages.activeUsers).toBe(6); + expect(result.averages.activeBranches).toBe(3); + }); + + it('should handle empty usage data', async () => { + mockUsageRepository.find.mockResolvedValue([]); + + const result = await service.getUsageReport( + 'tenant-uuid-1', + new Date('2026-01-01'), + new Date('2026-02-28') + ); + + expect(result.data).toHaveLength(0); + expect(result.totals.apiCalls).toBe(0); + expect(result.averages.activeUsers).toBe(0); + }); + }); + + describe('calculateBillableAmount (via recordUsage)', () => { + it('should calculate base price for usage within limits', async () => { + const dto = { + tenantId: 'tenant-uuid-1', + periodStart: new Date('2026-01-01'), + periodEnd: new Date('2026-01-31'), + activeUsers: 5, + activeBranches: 2, + storageUsedGb: 10, + apiCalls: 5000, + }; + + mockUsageRepository.findOne.mockResolvedValue(null); + mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription()); + mockUsageRepository.create.mockImplementation((data: any) => data); + mockUsageRepository.save.mockImplementation((usage: any) => Promise.resolve(usage)); + + const result = await service.recordUsage(dto); + + expect(result.totalBillableAmount).toBe(499); // Base price, no overages + }); + + it('should add overage charges when limits exceeded', async () => { + const dto = { + tenantId: 'tenant-uuid-1', + periodStart: new Date('2026-01-01'), + periodEnd: new Date('2026-01-31'), + activeUsers: 15, // 5 extra users at $10 each = $50 + activeBranches: 5, // 2 extra branches at $20 each = $40 + storageUsedGb: 25, // 5 extra GB at $0.50 each = $2.50 + apiCalls: 15000, // 5000 extra at $0.001 each = $5 + }; + + mockUsageRepository.findOne.mockResolvedValue(null); + mockSubscriptionRepository.findOne.mockResolvedValue(createMockSubscription()); + mockUsageRepository.create.mockImplementation((data: any) => data); + mockUsageRepository.save.mockImplementation((usage: any) => Promise.resolve(usage)); + + const result = await service.recordUsage(dto); + + // Base: 499 + Extra users: 50 + Extra branches: 40 + Extra storage: 2.5 + Extra API: 5 = 596.5 + expect(result.totalBillableAmount).toBe(596.5); + }); + }); +});