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