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>
503 lines
18 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|