erp-transportistas-backend-v2/src/modules/billing-usage/__tests__/subscriptions.service.test.ts
Adrian Flores Cortes 95c6b58449 feat: Add base modules from erp-core following SIMCO-REUSE directive
Phase 0 - Base modules (100% copy):
- shared/ (errors, middleware, services, utils, types)
- auth, users, tenants (multi-tenancy)
- ai, audit, notifications, mcp, payment-terminals
- billing-usage, branches, companies, core

Phase 1 - Modules to adapt (70-95%):
- partners (for shippers/consignees)
- inventory (for refacciones)
- financial (for transport costing)

Phase 2 - Pattern modules (50-70%):
- ordenes-transporte (from sales)
- gestion-flota (from products)
- viajes (from projects)

Phase 3 - New transport-specific modules:
- tracking (GPS, events, alerts)
- tarifas-transporte (pricing, surcharges)
- combustible-gastos (fuel, tolls, expenses)
- carta-porte (CFDI complement 3.1)

Estimated token savings: ~65% (~10,675 lines)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 10:10:19 -06:00

503 lines
18 KiB
TypeScript

import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { createMockRepository, createMockQueryBuilder } from '../../../__tests__/helpers.js';
// Mock factories
function createMockSubscriptionPlan(overrides: Record<string, any> = {}) {
return {
id: 'plan-uuid-1',
code: 'STARTER',
name: 'Starter Plan',
baseMonthlyPrice: 499,
baseAnnualPrice: 4990,
maxUsers: 5,
maxBranches: 1,
...overrides,
};
}
function createMockSubscription(overrides: Record<string, any> = {}) {
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);
});
});
});