feat(billing-usage): Add services, middleware and tests for FASE 4
- Add CouponsService for discount coupon management - Add PlanLimitsService for plan limit validation - Add StripeWebhookService for Stripe event processing - Add plan-enforcement middleware (requireLimit, requireFeature, planBasedRateLimit) - Add 68 unit tests for all billing-usage services - Update TenantSubscription entity with Stripe integration fields - Export new services from module index Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0bdb2eed65
commit
b194f59599
409
src/modules/billing-usage/__tests__/coupons.service.test.ts
Normal file
409
src/modules/billing-usage/__tests__/coupons.service.test.ts
Normal file
@ -0,0 +1,409 @@
|
|||||||
|
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
|
||||||
|
import { createMockRepository, createMockQueryBuilder } from '../../../__tests__/helpers.js';
|
||||||
|
|
||||||
|
// Mock factories for billing entities
|
||||||
|
function createMockCoupon(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'coupon-uuid-1',
|
||||||
|
code: 'SAVE20',
|
||||||
|
name: '20% Discount',
|
||||||
|
description: 'Get 20% off your subscription',
|
||||||
|
discountType: 'percentage',
|
||||||
|
discountValue: 20,
|
||||||
|
currency: 'MXN',
|
||||||
|
applicablePlans: [],
|
||||||
|
minAmount: 0,
|
||||||
|
durationPeriod: 'once',
|
||||||
|
durationMonths: null,
|
||||||
|
maxRedemptions: 100,
|
||||||
|
currentRedemptions: 10,
|
||||||
|
validFrom: new Date('2024-01-01'),
|
||||||
|
validUntil: new Date('2030-12-31'),
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockCouponRedemption(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'redemption-uuid-1',
|
||||||
|
couponId: 'coupon-uuid-1',
|
||||||
|
tenantId: 'tenant-uuid-1',
|
||||||
|
subscriptionId: 'subscription-uuid-1',
|
||||||
|
discountAmount: 200,
|
||||||
|
expiresAt: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock repositories
|
||||||
|
const mockCouponRepository = createMockRepository();
|
||||||
|
const mockRedemptionRepository = createMockRepository();
|
||||||
|
const mockSubscriptionRepository = createMockRepository();
|
||||||
|
const mockQueryBuilder = createMockQueryBuilder();
|
||||||
|
|
||||||
|
// Mock transaction manager
|
||||||
|
const mockManager = {
|
||||||
|
save: jest.fn().mockResolvedValue({}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock DataSource with transaction
|
||||||
|
const mockDataSource = {
|
||||||
|
getRepository: jest.fn((entity: any) => {
|
||||||
|
const entityName = entity.name || entity;
|
||||||
|
if (entityName === 'Coupon') return mockCouponRepository;
|
||||||
|
if (entityName === 'CouponRedemption') return mockRedemptionRepository;
|
||||||
|
if (entityName === 'TenantSubscription') return mockSubscriptionRepository;
|
||||||
|
return mockCouponRepository;
|
||||||
|
}),
|
||||||
|
transaction: jest.fn((callback: (manager: any) => Promise<void>) => callback(mockManager)),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('../../../shared/utils/logger.js', () => ({
|
||||||
|
logger: {
|
||||||
|
info: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
debug: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocking
|
||||||
|
import { CouponsService } from '../services/coupons.service.js';
|
||||||
|
|
||||||
|
describe('CouponsService', () => {
|
||||||
|
let service: CouponsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
service = new CouponsService(mockDataSource as any);
|
||||||
|
mockCouponRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create a new coupon successfully', async () => {
|
||||||
|
const dto = {
|
||||||
|
code: 'NEWCODE',
|
||||||
|
name: 'New Discount',
|
||||||
|
discountType: 'percentage' as const,
|
||||||
|
discountValue: 15,
|
||||||
|
validFrom: new Date(),
|
||||||
|
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCoupon = createMockCoupon({ ...dto, id: 'new-coupon-uuid', code: 'NEWCODE' });
|
||||||
|
mockCouponRepository.findOne.mockResolvedValue(null);
|
||||||
|
mockCouponRepository.create.mockReturnValue(mockCoupon);
|
||||||
|
mockCouponRepository.save.mockResolvedValue(mockCoupon);
|
||||||
|
|
||||||
|
const result = await service.create(dto);
|
||||||
|
|
||||||
|
expect(result.code).toBe('NEWCODE');
|
||||||
|
expect(mockCouponRepository.create).toHaveBeenCalled();
|
||||||
|
expect(mockCouponRepository.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if coupon code already exists', async () => {
|
||||||
|
const dto = {
|
||||||
|
code: 'EXISTING',
|
||||||
|
name: 'Existing Discount',
|
||||||
|
discountType: 'percentage' as const,
|
||||||
|
discountValue: 10,
|
||||||
|
validFrom: new Date(),
|
||||||
|
validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockCouponRepository.findOne.mockResolvedValue(createMockCoupon({ code: 'EXISTING' }));
|
||||||
|
|
||||||
|
await expect(service.create(dto)).rejects.toThrow('Coupon with code EXISTING already exists');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByCode', () => {
|
||||||
|
it('should find a coupon by code', async () => {
|
||||||
|
const mockCoupon = createMockCoupon({ code: 'TESTCODE' });
|
||||||
|
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
|
||||||
|
|
||||||
|
const result = await service.findByCode('TESTCODE');
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result?.code).toBe('TESTCODE');
|
||||||
|
expect(mockCouponRepository.findOne).toHaveBeenCalledWith({
|
||||||
|
where: { code: 'TESTCODE' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null if coupon not found', async () => {
|
||||||
|
mockCouponRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await service.findByCode('NOTFOUND');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateCoupon', () => {
|
||||||
|
it('should validate an active coupon successfully', async () => {
|
||||||
|
const mockCoupon = createMockCoupon({
|
||||||
|
code: 'VALID',
|
||||||
|
isActive: true,
|
||||||
|
validFrom: new Date('2023-01-01'),
|
||||||
|
validUntil: new Date('2030-12-31'),
|
||||||
|
maxRedemptions: 100,
|
||||||
|
currentRedemptions: 10,
|
||||||
|
applicablePlans: [],
|
||||||
|
minAmount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
|
||||||
|
mockRedemptionRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await service.validateCoupon('VALID', 'tenant-uuid-1', 'plan-uuid-1', 1000);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toBe('Cupón válido');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject inactive coupon', async () => {
|
||||||
|
const mockCoupon = createMockCoupon({ isActive: false });
|
||||||
|
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
|
||||||
|
|
||||||
|
const result = await service.validateCoupon('INACTIVE', 'tenant-uuid-1', 'plan-uuid-1', 1000);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toBe('Cupón inactivo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject coupon not yet valid', async () => {
|
||||||
|
const mockCoupon = createMockCoupon({
|
||||||
|
isActive: true,
|
||||||
|
validFrom: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // Future date
|
||||||
|
});
|
||||||
|
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
|
||||||
|
|
||||||
|
const result = await service.validateCoupon('FUTURE', 'tenant-uuid-1', 'plan-uuid-1', 1000);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toBe('Cupón aún no válido');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject expired coupon', async () => {
|
||||||
|
const mockCoupon = createMockCoupon({
|
||||||
|
isActive: true,
|
||||||
|
validFrom: new Date('2020-01-01'),
|
||||||
|
validUntil: new Date('2020-12-31'), // Past date
|
||||||
|
});
|
||||||
|
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
|
||||||
|
|
||||||
|
const result = await service.validateCoupon('EXPIRED', 'tenant-uuid-1', 'plan-uuid-1', 1000);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toBe('Cupón expirado');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject coupon exceeding max redemptions', async () => {
|
||||||
|
const mockCoupon = createMockCoupon({
|
||||||
|
isActive: true,
|
||||||
|
validFrom: new Date('2023-01-01'),
|
||||||
|
validUntil: new Date('2030-12-31'),
|
||||||
|
maxRedemptions: 10,
|
||||||
|
currentRedemptions: 10,
|
||||||
|
});
|
||||||
|
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
|
||||||
|
|
||||||
|
const result = await service.validateCoupon('MAXED', 'tenant-uuid-1', 'plan-uuid-1', 1000);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toBe('Cupón agotado');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject if tenant already redeemed', async () => {
|
||||||
|
const mockCoupon = createMockCoupon({
|
||||||
|
isActive: true,
|
||||||
|
validFrom: new Date('2023-01-01'),
|
||||||
|
validUntil: new Date('2030-12-31'),
|
||||||
|
maxRedemptions: 100,
|
||||||
|
currentRedemptions: 10,
|
||||||
|
});
|
||||||
|
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
|
||||||
|
mockRedemptionRepository.findOne.mockResolvedValue(createMockCouponRedemption());
|
||||||
|
|
||||||
|
const result = await service.validateCoupon('ONCEONLY', 'tenant-uuid-1', 'plan-uuid-1', 1000);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toBe('Cupón ya utilizado');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject if coupon not found', async () => {
|
||||||
|
mockCouponRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await service.validateCoupon('NOTFOUND', 'tenant-uuid-1', 'plan-uuid-1', 1000);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toBe('Cupón no encontrado');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyCoupon', () => {
|
||||||
|
it('should apply percentage discount correctly', async () => {
|
||||||
|
const mockCoupon = createMockCoupon({
|
||||||
|
id: 'coupon-uuid-1',
|
||||||
|
discountType: 'percentage',
|
||||||
|
discountValue: 20,
|
||||||
|
isActive: true,
|
||||||
|
validFrom: new Date('2023-01-01'),
|
||||||
|
validUntil: new Date('2030-12-31'),
|
||||||
|
maxRedemptions: 100,
|
||||||
|
currentRedemptions: 10,
|
||||||
|
applicablePlans: [],
|
||||||
|
minAmount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
|
||||||
|
mockRedemptionRepository.findOne.mockResolvedValue(null); // No existing redemption
|
||||||
|
mockRedemptionRepository.create.mockReturnValue(createMockCouponRedemption({ discountAmount: 200 }));
|
||||||
|
|
||||||
|
const result = await service.applyCoupon('SAVE20', 'tenant-uuid-1', 'subscription-uuid-1', 1000);
|
||||||
|
|
||||||
|
expect(result.discountAmount).toBe(200); // 20% of 1000
|
||||||
|
expect(mockManager.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply fixed discount correctly', async () => {
|
||||||
|
const mockCoupon = createMockCoupon({
|
||||||
|
id: 'coupon-uuid-1',
|
||||||
|
discountType: 'fixed',
|
||||||
|
discountValue: 150,
|
||||||
|
isActive: true,
|
||||||
|
validFrom: new Date('2023-01-01'),
|
||||||
|
validUntil: new Date('2030-12-31'),
|
||||||
|
maxRedemptions: 100,
|
||||||
|
currentRedemptions: 10,
|
||||||
|
applicablePlans: [],
|
||||||
|
minAmount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
|
||||||
|
mockRedemptionRepository.findOne.mockResolvedValue(null);
|
||||||
|
mockRedemptionRepository.create.mockReturnValue(createMockCouponRedemption({ discountAmount: 150 }));
|
||||||
|
|
||||||
|
const result = await service.applyCoupon('FIXED150', 'tenant-uuid-1', 'subscription-uuid-1', 1000);
|
||||||
|
|
||||||
|
expect(result.discountAmount).toBe(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if coupon is invalid', async () => {
|
||||||
|
mockCouponRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.applyCoupon('INVALID', 'tenant-uuid-1', 'subscription-uuid-1', 1000)
|
||||||
|
).rejects.toThrow('Cupón no encontrado');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findAll', () => {
|
||||||
|
it('should return all coupons', async () => {
|
||||||
|
const mockCoupons = [
|
||||||
|
createMockCoupon({ code: 'CODE1' }),
|
||||||
|
createMockCoupon({ code: 'CODE2' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
mockQueryBuilder.getMany.mockResolvedValue(mockCoupons);
|
||||||
|
|
||||||
|
const result = await service.findAll();
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by active status', async () => {
|
||||||
|
const mockCoupons = [createMockCoupon({ isActive: true })];
|
||||||
|
mockQueryBuilder.getMany.mockResolvedValue(mockCoupons);
|
||||||
|
|
||||||
|
await service.findAll({ isActive: true });
|
||||||
|
|
||||||
|
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('coupon.isActive = :isActive', { isActive: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStats', () => {
|
||||||
|
it('should return coupon statistics', async () => {
|
||||||
|
const mockCoupon = createMockCoupon({
|
||||||
|
maxRedemptions: 100,
|
||||||
|
currentRedemptions: 25,
|
||||||
|
});
|
||||||
|
const mockRedemptions = [
|
||||||
|
createMockCouponRedemption({ discountAmount: 200 }),
|
||||||
|
createMockCouponRedemption({ discountAmount: 300 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
|
||||||
|
mockRedemptionRepository.find.mockResolvedValue(mockRedemptions);
|
||||||
|
|
||||||
|
const result = await service.getStats('coupon-uuid-1');
|
||||||
|
|
||||||
|
expect(result.totalRedemptions).toBe(2);
|
||||||
|
expect(result.totalDiscountGiven).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if coupon not found', async () => {
|
||||||
|
mockCouponRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.getStats('nonexistent')).rejects.toThrow('Coupon not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deactivate', () => {
|
||||||
|
it('should deactivate a coupon', async () => {
|
||||||
|
const mockCoupon = createMockCoupon({ isActive: true });
|
||||||
|
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
|
||||||
|
mockCouponRepository.save.mockResolvedValue({ ...mockCoupon, isActive: false });
|
||||||
|
|
||||||
|
const result = await service.deactivate('coupon-uuid-1');
|
||||||
|
|
||||||
|
expect(result.isActive).toBe(false);
|
||||||
|
expect(mockCouponRepository.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if coupon not found', async () => {
|
||||||
|
mockCouponRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.deactivate('nonexistent')).rejects.toThrow('Coupon not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('should update coupon properties', async () => {
|
||||||
|
const mockCoupon = createMockCoupon({ name: 'Old Name' });
|
||||||
|
mockCouponRepository.findOne.mockResolvedValue(mockCoupon);
|
||||||
|
mockCouponRepository.save.mockResolvedValue({ ...mockCoupon, name: 'New Name' });
|
||||||
|
|
||||||
|
const result = await service.update('coupon-uuid-1', { name: 'New Name' });
|
||||||
|
|
||||||
|
expect(result.name).toBe('New Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if coupon not found', async () => {
|
||||||
|
mockCouponRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.update('nonexistent', { name: 'New' })).rejects.toThrow('Coupon not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getActiveRedemptions', () => {
|
||||||
|
it('should return active redemptions for tenant', async () => {
|
||||||
|
const mockRedemptions = [
|
||||||
|
createMockCouponRedemption({ expiresAt: null }),
|
||||||
|
createMockCouponRedemption({ expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) }),
|
||||||
|
];
|
||||||
|
|
||||||
|
mockRedemptionRepository.find.mockResolvedValue(mockRedemptions);
|
||||||
|
|
||||||
|
const result = await service.getActiveRedemptions('tenant-uuid-1');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
466
src/modules/billing-usage/__tests__/plan-limits.service.test.ts
Normal file
466
src/modules/billing-usage/__tests__/plan-limits.service.test.ts
Normal file
@ -0,0 +1,466 @@
|
|||||||
|
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
|
||||||
|
import { createMockRepository, createMockQueryBuilder } from '../../../__tests__/helpers.js';
|
||||||
|
|
||||||
|
// Mock factories for billing entities
|
||||||
|
function createMockPlanLimit(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'limit-uuid-1',
|
||||||
|
planId: 'plan-uuid-1',
|
||||||
|
limitKey: 'users',
|
||||||
|
limitName: 'Active Users',
|
||||||
|
limitValue: 10,
|
||||||
|
limitType: 'monthly',
|
||||||
|
allowOverage: false,
|
||||||
|
overageUnitPrice: 0,
|
||||||
|
overageCurrency: 'MXN',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockSubscriptionPlan(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'plan-uuid-1',
|
||||||
|
code: 'PRO',
|
||||||
|
name: 'Professional Plan',
|
||||||
|
description: 'Professional subscription plan',
|
||||||
|
monthlyPrice: 499,
|
||||||
|
annualPrice: 4990,
|
||||||
|
currency: 'MXN',
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 2,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockSubscription(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'subscription-uuid-1',
|
||||||
|
tenantId: 'tenant-uuid-1',
|
||||||
|
planId: 'plan-uuid-1',
|
||||||
|
status: 'active',
|
||||||
|
currentPrice: 499,
|
||||||
|
billingCycle: 'monthly',
|
||||||
|
currentPeriodStart: new Date(),
|
||||||
|
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockUsageTracking(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'usage-uuid-1',
|
||||||
|
tenantId: 'tenant-uuid-1',
|
||||||
|
periodStart: new Date(new Date().getFullYear(), new Date().getMonth(), 1),
|
||||||
|
periodEnd: new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0),
|
||||||
|
activeUsers: 5,
|
||||||
|
storageUsedGb: 2.5,
|
||||||
|
apiCalls: 1000,
|
||||||
|
activeBranches: 2,
|
||||||
|
documentsCount: 150,
|
||||||
|
invoicesGenerated: 50,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock repositories with extended methods
|
||||||
|
const mockLimitRepository = {
|
||||||
|
...createMockRepository(),
|
||||||
|
remove: jest.fn(),
|
||||||
|
};
|
||||||
|
const mockPlanRepository = createMockRepository();
|
||||||
|
const mockSubscriptionRepository = createMockRepository();
|
||||||
|
const mockUsageRepository = createMockRepository();
|
||||||
|
|
||||||
|
// Mock DataSource
|
||||||
|
const mockDataSource = {
|
||||||
|
getRepository: jest.fn((entity: any) => {
|
||||||
|
const entityName = entity.name || entity;
|
||||||
|
if (entityName === 'PlanLimit') return mockLimitRepository;
|
||||||
|
if (entityName === 'SubscriptionPlan') return mockPlanRepository;
|
||||||
|
if (entityName === 'TenantSubscription') return mockSubscriptionRepository;
|
||||||
|
if (entityName === 'UsageTracking') return mockUsageRepository;
|
||||||
|
return mockLimitRepository;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('../../../shared/utils/logger.js', () => ({
|
||||||
|
logger: {
|
||||||
|
info: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
debug: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocking
|
||||||
|
import { PlanLimitsService } from '../services/plan-limits.service.js';
|
||||||
|
|
||||||
|
describe('PlanLimitsService', () => {
|
||||||
|
let service: PlanLimitsService;
|
||||||
|
const tenantId = 'tenant-uuid-1';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
service = new PlanLimitsService(mockDataSource as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('should create a new plan limit successfully', async () => {
|
||||||
|
const dto = {
|
||||||
|
planId: 'plan-uuid-1',
|
||||||
|
limitKey: 'storage_gb',
|
||||||
|
limitName: 'Storage (GB)',
|
||||||
|
limitValue: 50,
|
||||||
|
limitType: 'fixed' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPlan = createMockSubscriptionPlan();
|
||||||
|
const mockLimit = createMockPlanLimit({ ...dto, id: 'new-limit-uuid' });
|
||||||
|
|
||||||
|
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
|
||||||
|
mockLimitRepository.findOne.mockResolvedValue(null);
|
||||||
|
mockLimitRepository.create.mockReturnValue(mockLimit);
|
||||||
|
mockLimitRepository.save.mockResolvedValue(mockLimit);
|
||||||
|
|
||||||
|
const result = await service.create(dto);
|
||||||
|
|
||||||
|
expect(result.limitKey).toBe('storage_gb');
|
||||||
|
expect(result.limitValue).toBe(50);
|
||||||
|
expect(mockLimitRepository.create).toHaveBeenCalled();
|
||||||
|
expect(mockLimitRepository.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if plan not found', async () => {
|
||||||
|
mockPlanRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const dto = {
|
||||||
|
planId: 'nonexistent-plan',
|
||||||
|
limitKey: 'users',
|
||||||
|
limitName: 'Users',
|
||||||
|
limitValue: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(service.create(dto)).rejects.toThrow('Plan not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if limit key already exists for plan', async () => {
|
||||||
|
const mockPlan = createMockSubscriptionPlan();
|
||||||
|
const existingLimit = createMockPlanLimit({ limitKey: 'users' });
|
||||||
|
|
||||||
|
mockPlanRepository.findOne.mockResolvedValue(mockPlan);
|
||||||
|
mockLimitRepository.findOne.mockResolvedValue(existingLimit);
|
||||||
|
|
||||||
|
const dto = {
|
||||||
|
planId: 'plan-uuid-1',
|
||||||
|
limitKey: 'users',
|
||||||
|
limitName: 'Users',
|
||||||
|
limitValue: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(service.create(dto)).rejects.toThrow('Limit users already exists for this plan');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByPlan', () => {
|
||||||
|
it('should return all limits for a plan', async () => {
|
||||||
|
const mockLimits = [
|
||||||
|
createMockPlanLimit({ limitKey: 'users', limitValue: 10 }),
|
||||||
|
createMockPlanLimit({ limitKey: 'storage_gb', limitValue: 50 }),
|
||||||
|
createMockPlanLimit({ limitKey: 'api_calls', limitValue: 10000 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
mockLimitRepository.find.mockResolvedValue(mockLimits);
|
||||||
|
|
||||||
|
const result = await service.findByPlan('plan-uuid-1');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3);
|
||||||
|
expect(mockLimitRepository.find).toHaveBeenCalledWith({
|
||||||
|
where: { planId: 'plan-uuid-1' },
|
||||||
|
order: { limitKey: 'ASC' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByKey', () => {
|
||||||
|
it('should find a specific limit by key', async () => {
|
||||||
|
const mockLimit = createMockPlanLimit({ limitKey: 'users' });
|
||||||
|
mockLimitRepository.findOne.mockResolvedValue(mockLimit);
|
||||||
|
|
||||||
|
const result = await service.findByKey('plan-uuid-1', 'users');
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result?.limitKey).toBe('users');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null if limit not found', async () => {
|
||||||
|
mockLimitRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await service.findByKey('plan-uuid-1', 'nonexistent');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('should update a plan limit', async () => {
|
||||||
|
const mockLimit = createMockPlanLimit({ limitValue: 10 });
|
||||||
|
mockLimitRepository.findOne.mockResolvedValue(mockLimit);
|
||||||
|
mockLimitRepository.save.mockResolvedValue({ ...mockLimit, limitValue: 20 });
|
||||||
|
|
||||||
|
const result = await service.update('limit-uuid-1', { limitValue: 20 });
|
||||||
|
|
||||||
|
expect(result.limitValue).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if limit not found', async () => {
|
||||||
|
mockLimitRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.update('nonexistent', { limitValue: 20 })).rejects.toThrow('Limit not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete', () => {
|
||||||
|
it('should delete a plan limit', async () => {
|
||||||
|
const mockLimit = createMockPlanLimit();
|
||||||
|
mockLimitRepository.findOne.mockResolvedValue(mockLimit);
|
||||||
|
mockLimitRepository.remove.mockResolvedValue(mockLimit);
|
||||||
|
|
||||||
|
await expect(service.delete('limit-uuid-1')).resolves.not.toThrow();
|
||||||
|
expect(mockLimitRepository.remove).toHaveBeenCalledWith(mockLimit);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if limit not found', async () => {
|
||||||
|
mockLimitRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.delete('nonexistent')).rejects.toThrow('Limit not found');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTenantLimits', () => {
|
||||||
|
it('should return limits for tenant with active subscription', async () => {
|
||||||
|
const mockSubscription = createMockSubscription({ planId: 'pro-plan' });
|
||||||
|
const mockLimits = [
|
||||||
|
createMockPlanLimit({ limitKey: 'users', limitValue: 25 }),
|
||||||
|
createMockPlanLimit({ limitKey: 'storage_gb', limitValue: 100 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
||||||
|
mockLimitRepository.find.mockResolvedValue(mockLimits);
|
||||||
|
|
||||||
|
const result = await service.getTenantLimits(tenantId);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(mockSubscriptionRepository.findOne).toHaveBeenCalledWith({
|
||||||
|
where: { tenantId, status: 'active' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return free plan limits if no active subscription', async () => {
|
||||||
|
const mockFreePlan = createMockSubscriptionPlan({ id: 'free-plan', code: 'FREE' });
|
||||||
|
const mockLimits = [createMockPlanLimit({ limitKey: 'users', limitValue: 3 })];
|
||||||
|
|
||||||
|
mockSubscriptionRepository.findOne.mockResolvedValue(null);
|
||||||
|
mockPlanRepository.findOne.mockResolvedValue(mockFreePlan);
|
||||||
|
mockLimitRepository.find.mockResolvedValue(mockLimits);
|
||||||
|
|
||||||
|
const result = await service.getTenantLimits(tenantId);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].limitValue).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array if no subscription and no free plan', async () => {
|
||||||
|
mockSubscriptionRepository.findOne.mockResolvedValue(null);
|
||||||
|
mockPlanRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await service.getTenantLimits(tenantId);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getTenantLimit', () => {
|
||||||
|
it('should return specific limit value for tenant', async () => {
|
||||||
|
const mockSubscription = createMockSubscription();
|
||||||
|
const mockLimits = [createMockPlanLimit({ limitKey: 'users', limitValue: 10 })];
|
||||||
|
|
||||||
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
||||||
|
mockLimitRepository.find.mockResolvedValue(mockLimits);
|
||||||
|
|
||||||
|
const result = await service.getTenantLimit(tenantId, 'users');
|
||||||
|
|
||||||
|
expect(result).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 if limit not found', async () => {
|
||||||
|
mockSubscriptionRepository.findOne.mockResolvedValue(null);
|
||||||
|
mockPlanRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await service.getTenantLimit(tenantId, 'nonexistent');
|
||||||
|
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checkUsage', () => {
|
||||||
|
it('should allow usage within limits', async () => {
|
||||||
|
const mockSubscription = createMockSubscription();
|
||||||
|
const mockLimits = [createMockPlanLimit({ limitKey: 'users', limitValue: 10, allowOverage: false })];
|
||||||
|
|
||||||
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
||||||
|
mockLimitRepository.find.mockResolvedValue(mockLimits);
|
||||||
|
|
||||||
|
const result = await service.checkUsage(tenantId, 'users', 5, 1);
|
||||||
|
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.remaining).toBe(4);
|
||||||
|
expect(result.message).toBe('Dentro del límite');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject usage exceeding limits when overage not allowed', async () => {
|
||||||
|
const mockSubscription = createMockSubscription();
|
||||||
|
const mockLimits = [createMockPlanLimit({ limitKey: 'users', limitValue: 10, allowOverage: false })];
|
||||||
|
|
||||||
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
||||||
|
mockLimitRepository.find.mockResolvedValue(mockLimits);
|
||||||
|
|
||||||
|
const result = await service.checkUsage(tenantId, 'users', 10, 1);
|
||||||
|
|
||||||
|
expect(result.allowed).toBe(false);
|
||||||
|
expect(result.remaining).toBe(0);
|
||||||
|
expect(result.message).toContain('Límite alcanzado');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow overage when configured', async () => {
|
||||||
|
const mockSubscription = createMockSubscription();
|
||||||
|
const mockLimits = [
|
||||||
|
createMockPlanLimit({
|
||||||
|
limitKey: 'users',
|
||||||
|
limitValue: 10,
|
||||||
|
allowOverage: true,
|
||||||
|
overageUnitPrice: 50,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
||||||
|
mockLimitRepository.find.mockResolvedValue(mockLimits);
|
||||||
|
|
||||||
|
const result = await service.checkUsage(tenantId, 'users', 10, 2);
|
||||||
|
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.overageAllowed).toBe(true);
|
||||||
|
expect(result.overageUnits).toBe(2);
|
||||||
|
expect(result.overageCost).toBe(100); // 2 * 50
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow unlimited when no limit defined', async () => {
|
||||||
|
mockSubscriptionRepository.findOne.mockResolvedValue(null);
|
||||||
|
mockPlanRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await service.checkUsage(tenantId, 'nonexistent', 1000, 100);
|
||||||
|
|
||||||
|
expect(result.allowed).toBe(true);
|
||||||
|
expect(result.limit).toBe(-1);
|
||||||
|
expect(result.remaining).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCurrentUsage', () => {
|
||||||
|
it('should return current usage for a limit key', async () => {
|
||||||
|
const mockUsage = createMockUsageTracking({ activeUsers: 7 });
|
||||||
|
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
|
||||||
|
|
||||||
|
const result = await service.getCurrentUsage(tenantId, 'users');
|
||||||
|
|
||||||
|
expect(result).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 if no usage record found', async () => {
|
||||||
|
mockUsageRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await service.getCurrentUsage(tenantId, 'users');
|
||||||
|
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct value for different limit keys', async () => {
|
||||||
|
const mockUsage = createMockUsageTracking({
|
||||||
|
activeUsers: 5,
|
||||||
|
storageUsedGb: 10,
|
||||||
|
apiCalls: 5000,
|
||||||
|
});
|
||||||
|
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
|
||||||
|
|
||||||
|
expect(await service.getCurrentUsage(tenantId, 'users')).toBe(5);
|
||||||
|
expect(await service.getCurrentUsage(tenantId, 'storage_gb')).toBe(10);
|
||||||
|
expect(await service.getCurrentUsage(tenantId, 'api_calls')).toBe(5000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateAllLimits', () => {
|
||||||
|
it('should return valid when all limits OK', async () => {
|
||||||
|
const mockSubscription = createMockSubscription();
|
||||||
|
const mockLimits = [
|
||||||
|
createMockPlanLimit({ limitKey: 'users', limitValue: 10 }),
|
||||||
|
createMockPlanLimit({ limitKey: 'storage_gb', limitValue: 50 }),
|
||||||
|
];
|
||||||
|
const mockUsage = createMockUsageTracking({ activeUsers: 5, storageUsedGb: 20 });
|
||||||
|
|
||||||
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
||||||
|
mockLimitRepository.find.mockResolvedValue(mockLimits);
|
||||||
|
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
|
||||||
|
|
||||||
|
const result = await service.validateAllLimits(tenantId);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
expect(result.violations).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return violations when limits exceeded', async () => {
|
||||||
|
const mockSubscription = createMockSubscription();
|
||||||
|
const mockLimits = [
|
||||||
|
createMockPlanLimit({ limitKey: 'users', limitValue: 5, allowOverage: false }),
|
||||||
|
];
|
||||||
|
const mockUsage = createMockUsageTracking({ activeUsers: 10 });
|
||||||
|
|
||||||
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
||||||
|
mockLimitRepository.find.mockResolvedValue(mockLimits);
|
||||||
|
mockUsageRepository.findOne.mockResolvedValue(mockUsage);
|
||||||
|
|
||||||
|
const result = await service.validateAllLimits(tenantId);
|
||||||
|
|
||||||
|
expect(result.valid).toBe(false);
|
||||||
|
expect(result.violations).toHaveLength(1);
|
||||||
|
expect(result.violations[0].limitKey).toBe('users');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('copyLimitsFromPlan', () => {
|
||||||
|
it('should copy all limits from source to target plan', async () => {
|
||||||
|
const sourceLimits = [
|
||||||
|
createMockPlanLimit({ id: 'limit-1', limitKey: 'users', limitValue: 10 }),
|
||||||
|
createMockPlanLimit({ id: 'limit-2', limitKey: 'storage_gb', limitValue: 50 }),
|
||||||
|
];
|
||||||
|
const targetPlan = createMockSubscriptionPlan({ id: 'target-plan' });
|
||||||
|
|
||||||
|
mockLimitRepository.find.mockResolvedValue(sourceLimits);
|
||||||
|
mockPlanRepository.findOne.mockResolvedValue(targetPlan);
|
||||||
|
mockLimitRepository.findOne.mockResolvedValue(null); // No existing limits
|
||||||
|
mockLimitRepository.create.mockImplementation((data) => data as any);
|
||||||
|
mockLimitRepository.save.mockImplementation((data) => Promise.resolve({ ...data, id: 'new-limit' }));
|
||||||
|
|
||||||
|
const result = await service.copyLimitsFromPlan('source-plan', 'target-plan');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(mockLimitRepository.create).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,597 @@
|
|||||||
|
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
|
||||||
|
import { createMockRepository } from '../../../__tests__/helpers.js';
|
||||||
|
|
||||||
|
// Mock factories for Stripe entities
|
||||||
|
function createMockStripeEvent(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'event-uuid-1',
|
||||||
|
stripeEventId: 'evt_1234567890',
|
||||||
|
eventType: 'customer.subscription.created',
|
||||||
|
apiVersion: '2023-10-16',
|
||||||
|
data: {
|
||||||
|
object: {
|
||||||
|
id: 'sub_123',
|
||||||
|
customer: 'cus_123',
|
||||||
|
status: 'active',
|
||||||
|
current_period_start: Math.floor(Date.now() / 1000),
|
||||||
|
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
processed: false,
|
||||||
|
processedAt: null,
|
||||||
|
retryCount: 0,
|
||||||
|
errorMessage: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMockSubscription(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'subscription-uuid-1',
|
||||||
|
tenantId: 'tenant-uuid-1',
|
||||||
|
planId: 'plan-uuid-1',
|
||||||
|
status: 'active',
|
||||||
|
stripeCustomerId: 'cus_123',
|
||||||
|
stripeSubscriptionId: 'sub_123',
|
||||||
|
currentPeriodStart: new Date(),
|
||||||
|
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||||
|
billingCycle: 'monthly',
|
||||||
|
currentPrice: 499,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock repositories
|
||||||
|
const mockEventRepository = createMockRepository();
|
||||||
|
const mockSubscriptionRepository = createMockRepository();
|
||||||
|
|
||||||
|
// Mock DataSource
|
||||||
|
const mockDataSource = {
|
||||||
|
getRepository: jest.fn((entity: any) => {
|
||||||
|
const entityName = entity.name || entity;
|
||||||
|
if (entityName === 'StripeEvent') return mockEventRepository;
|
||||||
|
if (entityName === 'TenantSubscription') return mockSubscriptionRepository;
|
||||||
|
return mockEventRepository;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('../../../shared/utils/logger.js', () => ({
|
||||||
|
logger: {
|
||||||
|
info: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
debug: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import after mocking
|
||||||
|
import { StripeWebhookService, StripeWebhookPayload } from '../services/stripe-webhook.service.js';
|
||||||
|
|
||||||
|
describe('StripeWebhookService', () => {
|
||||||
|
let service: StripeWebhookService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
service = new StripeWebhookService(mockDataSource as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('processWebhook', () => {
|
||||||
|
it('should process a new webhook event successfully', async () => {
|
||||||
|
const payload: StripeWebhookPayload = {
|
||||||
|
id: 'evt_new_event',
|
||||||
|
type: 'customer.subscription.created',
|
||||||
|
api_version: '2023-10-16',
|
||||||
|
data: {
|
||||||
|
object: {
|
||||||
|
id: 'sub_new',
|
||||||
|
customer: 'cus_123',
|
||||||
|
status: 'active',
|
||||||
|
current_period_start: Math.floor(Date.now() / 1000),
|
||||||
|
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
livemode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEvent = createMockStripeEvent({ stripeEventId: 'evt_new_event' });
|
||||||
|
const mockSubscription = createMockSubscription();
|
||||||
|
|
||||||
|
mockEventRepository.findOne.mockResolvedValue(null); // No existing event
|
||||||
|
mockEventRepository.create.mockReturnValue(mockEvent);
|
||||||
|
mockEventRepository.save.mockResolvedValue(mockEvent);
|
||||||
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
||||||
|
mockSubscriptionRepository.save.mockResolvedValue(mockSubscription);
|
||||||
|
|
||||||
|
const result = await service.processWebhook(payload);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toBe('Event processed successfully');
|
||||||
|
expect(mockEventRepository.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return success for already processed event', async () => {
|
||||||
|
const payload: StripeWebhookPayload = {
|
||||||
|
id: 'evt_already_processed',
|
||||||
|
type: 'customer.subscription.created',
|
||||||
|
data: { object: {} },
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
livemode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const existingEvent = createMockStripeEvent({
|
||||||
|
stripeEventId: 'evt_already_processed',
|
||||||
|
processed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockEventRepository.findOne.mockResolvedValue(existingEvent);
|
||||||
|
|
||||||
|
const result = await service.processWebhook(payload);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toBe('Event already processed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should retry processing for failed event', async () => {
|
||||||
|
const payload: StripeWebhookPayload = {
|
||||||
|
id: 'evt_failed',
|
||||||
|
type: 'customer.subscription.created',
|
||||||
|
data: {
|
||||||
|
object: {
|
||||||
|
id: 'sub_retry',
|
||||||
|
customer: 'cus_123',
|
||||||
|
status: 'active',
|
||||||
|
current_period_start: Math.floor(Date.now() / 1000),
|
||||||
|
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
livemode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const failedEvent = createMockStripeEvent({
|
||||||
|
stripeEventId: 'evt_failed',
|
||||||
|
processed: false,
|
||||||
|
retryCount: 1,
|
||||||
|
data: payload.data,
|
||||||
|
});
|
||||||
|
const mockSubscription = createMockSubscription();
|
||||||
|
|
||||||
|
mockEventRepository.findOne.mockResolvedValue(failedEvent);
|
||||||
|
mockEventRepository.save.mockResolvedValue(failedEvent);
|
||||||
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
||||||
|
mockSubscriptionRepository.save.mockResolvedValue(mockSubscription);
|
||||||
|
|
||||||
|
const result = await service.processWebhook(payload);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toBe('Event processed on retry');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle processing errors gracefully', async () => {
|
||||||
|
const payload: StripeWebhookPayload = {
|
||||||
|
id: 'evt_error',
|
||||||
|
type: 'customer.subscription.created',
|
||||||
|
data: {
|
||||||
|
object: {
|
||||||
|
id: 'sub_error',
|
||||||
|
customer: 'cus_123',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
livemode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEvent = createMockStripeEvent({ stripeEventId: 'evt_error' });
|
||||||
|
|
||||||
|
mockEventRepository.findOne.mockResolvedValue(null);
|
||||||
|
mockEventRepository.create.mockReturnValue(mockEvent);
|
||||||
|
mockEventRepository.save.mockResolvedValue(mockEvent);
|
||||||
|
mockSubscriptionRepository.findOne.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
const result = await service.processWebhook(payload);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('Database error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleSubscriptionCreated', () => {
|
||||||
|
it('should create/link subscription for existing customer', async () => {
|
||||||
|
const payload: StripeWebhookPayload = {
|
||||||
|
id: 'evt_sub_created',
|
||||||
|
type: 'customer.subscription.created',
|
||||||
|
data: {
|
||||||
|
object: {
|
||||||
|
id: 'sub_new_123',
|
||||||
|
customer: 'cus_existing',
|
||||||
|
status: 'active',
|
||||||
|
current_period_start: Math.floor(Date.now() / 1000),
|
||||||
|
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
||||||
|
trial_end: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
livemode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEvent = createMockStripeEvent();
|
||||||
|
const mockSubscription = createMockSubscription({ stripeCustomerId: 'cus_existing' });
|
||||||
|
|
||||||
|
mockEventRepository.findOne.mockResolvedValue(null);
|
||||||
|
mockEventRepository.create.mockReturnValue(mockEvent);
|
||||||
|
mockEventRepository.save.mockResolvedValue(mockEvent);
|
||||||
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
||||||
|
mockSubscriptionRepository.save.mockResolvedValue(mockSubscription);
|
||||||
|
|
||||||
|
const result = await service.processWebhook(payload);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(mockSubscriptionRepository.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleSubscriptionUpdated', () => {
|
||||||
|
it('should update subscription status', async () => {
|
||||||
|
const payload: StripeWebhookPayload = {
|
||||||
|
id: 'evt_sub_updated',
|
||||||
|
type: 'customer.subscription.updated',
|
||||||
|
data: {
|
||||||
|
object: {
|
||||||
|
id: 'sub_123',
|
||||||
|
customer: 'cus_123',
|
||||||
|
status: 'past_due',
|
||||||
|
current_period_start: Math.floor(Date.now() / 1000),
|
||||||
|
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
||||||
|
cancel_at_period_end: false,
|
||||||
|
canceled_at: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
livemode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEvent = createMockStripeEvent();
|
||||||
|
const mockSubscription = createMockSubscription({ stripeSubscriptionId: 'sub_123' });
|
||||||
|
|
||||||
|
mockEventRepository.findOne.mockResolvedValue(null);
|
||||||
|
mockEventRepository.create.mockReturnValue(mockEvent);
|
||||||
|
mockEventRepository.save.mockResolvedValue(mockEvent);
|
||||||
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
||||||
|
mockSubscriptionRepository.save.mockResolvedValue({ ...mockSubscription, status: 'past_due' });
|
||||||
|
|
||||||
|
const result = await service.processWebhook(payload);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle cancel_at_period_end flag', async () => {
|
||||||
|
const payload: StripeWebhookPayload = {
|
||||||
|
id: 'evt_sub_cancel_scheduled',
|
||||||
|
type: 'customer.subscription.updated',
|
||||||
|
data: {
|
||||||
|
object: {
|
||||||
|
id: 'sub_cancel',
|
||||||
|
customer: 'cus_123',
|
||||||
|
status: 'active',
|
||||||
|
current_period_start: Math.floor(Date.now() / 1000),
|
||||||
|
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
||||||
|
cancel_at_period_end: true,
|
||||||
|
canceled_at: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
livemode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEvent = createMockStripeEvent({ eventType: 'customer.subscription.updated' });
|
||||||
|
const mockSubscription = createMockSubscription({ stripeSubscriptionId: 'sub_cancel' });
|
||||||
|
|
||||||
|
mockEventRepository.findOne.mockResolvedValue(null);
|
||||||
|
mockEventRepository.create.mockReturnValue(mockEvent);
|
||||||
|
mockEventRepository.save.mockResolvedValue(mockEvent);
|
||||||
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
||||||
|
mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub));
|
||||||
|
|
||||||
|
await service.processWebhook(payload);
|
||||||
|
|
||||||
|
expect(mockSubscriptionRepository.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ cancelAtPeriodEnd: true })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleSubscriptionDeleted', () => {
|
||||||
|
it('should mark subscription as cancelled', async () => {
|
||||||
|
const payload: StripeWebhookPayload = {
|
||||||
|
id: 'evt_sub_deleted',
|
||||||
|
type: 'customer.subscription.deleted',
|
||||||
|
data: {
|
||||||
|
object: {
|
||||||
|
id: 'sub_deleted',
|
||||||
|
customer: 'cus_123',
|
||||||
|
status: 'canceled',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
livemode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEvent = createMockStripeEvent();
|
||||||
|
const mockSubscription = createMockSubscription({ stripeSubscriptionId: 'sub_deleted' });
|
||||||
|
|
||||||
|
mockEventRepository.findOne.mockResolvedValue(null);
|
||||||
|
mockEventRepository.create.mockReturnValue(mockEvent);
|
||||||
|
mockEventRepository.save.mockResolvedValue(mockEvent);
|
||||||
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
||||||
|
mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub));
|
||||||
|
|
||||||
|
await service.processWebhook(payload);
|
||||||
|
|
||||||
|
expect(mockSubscriptionRepository.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ status: 'cancelled' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handlePaymentSucceeded', () => {
|
||||||
|
it('should update subscription with payment info', async () => {
|
||||||
|
const payload: StripeWebhookPayload = {
|
||||||
|
id: 'evt_payment_success',
|
||||||
|
type: 'invoice.payment_succeeded',
|
||||||
|
data: {
|
||||||
|
object: {
|
||||||
|
id: 'inv_123',
|
||||||
|
customer: 'cus_123',
|
||||||
|
amount_paid: 49900, // $499.00 in cents
|
||||||
|
subscription: 'sub_123',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
livemode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEvent = createMockStripeEvent({ eventType: 'invoice.payment_succeeded' });
|
||||||
|
const mockSubscription = createMockSubscription({ stripeCustomerId: 'cus_123' });
|
||||||
|
|
||||||
|
mockEventRepository.findOne.mockResolvedValue(null);
|
||||||
|
mockEventRepository.create.mockReturnValue(mockEvent);
|
||||||
|
mockEventRepository.save.mockResolvedValue(mockEvent);
|
||||||
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
||||||
|
mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub));
|
||||||
|
|
||||||
|
await service.processWebhook(payload);
|
||||||
|
|
||||||
|
expect(mockSubscriptionRepository.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
status: 'active',
|
||||||
|
lastPaymentAmount: 499, // Converted from cents
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handlePaymentFailed', () => {
|
||||||
|
it('should mark subscription as past_due', async () => {
|
||||||
|
const payload: StripeWebhookPayload = {
|
||||||
|
id: 'evt_payment_failed',
|
||||||
|
type: 'invoice.payment_failed',
|
||||||
|
data: {
|
||||||
|
object: {
|
||||||
|
id: 'inv_failed',
|
||||||
|
customer: 'cus_123',
|
||||||
|
attempt_count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
livemode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEvent = createMockStripeEvent({ eventType: 'invoice.payment_failed' });
|
||||||
|
const mockSubscription = createMockSubscription({ stripeCustomerId: 'cus_123', status: 'active' });
|
||||||
|
|
||||||
|
mockEventRepository.findOne.mockResolvedValue(null);
|
||||||
|
mockEventRepository.create.mockReturnValue(mockEvent);
|
||||||
|
mockEventRepository.save.mockResolvedValue(mockEvent);
|
||||||
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
||||||
|
mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub));
|
||||||
|
|
||||||
|
await service.processWebhook(payload);
|
||||||
|
|
||||||
|
expect(mockSubscriptionRepository.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ status: 'past_due' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleCheckoutCompleted', () => {
|
||||||
|
it('should link Stripe customer to tenant', async () => {
|
||||||
|
const payload: StripeWebhookPayload = {
|
||||||
|
id: 'evt_checkout_completed',
|
||||||
|
type: 'checkout.session.completed',
|
||||||
|
data: {
|
||||||
|
object: {
|
||||||
|
id: 'cs_123',
|
||||||
|
customer: 'cus_new',
|
||||||
|
subscription: 'sub_new',
|
||||||
|
metadata: {
|
||||||
|
tenant_id: 'tenant-uuid-1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
livemode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEvent = createMockStripeEvent({ eventType: 'checkout.session.completed' });
|
||||||
|
const mockSubscription = createMockSubscription({ tenantId: 'tenant-uuid-1' });
|
||||||
|
|
||||||
|
mockEventRepository.findOne.mockResolvedValue(null);
|
||||||
|
mockEventRepository.create.mockReturnValue(mockEvent);
|
||||||
|
mockEventRepository.save.mockResolvedValue(mockEvent);
|
||||||
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
||||||
|
mockSubscriptionRepository.save.mockImplementation((sub) => Promise.resolve(sub));
|
||||||
|
|
||||||
|
await service.processWebhook(payload);
|
||||||
|
|
||||||
|
expect(mockSubscriptionRepository.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
stripeCustomerId: 'cus_new',
|
||||||
|
stripeSubscriptionId: 'sub_new',
|
||||||
|
status: 'active',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('retryProcessing', () => {
|
||||||
|
it('should retry and succeed', async () => {
|
||||||
|
const failedEvent = createMockStripeEvent({
|
||||||
|
processed: false,
|
||||||
|
retryCount: 2,
|
||||||
|
data: {
|
||||||
|
object: {
|
||||||
|
id: 'sub_retry',
|
||||||
|
customer: 'cus_123',
|
||||||
|
status: 'active',
|
||||||
|
current_period_start: Math.floor(Date.now() / 1000),
|
||||||
|
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const mockSubscription = createMockSubscription();
|
||||||
|
|
||||||
|
mockSubscriptionRepository.findOne.mockResolvedValue(mockSubscription);
|
||||||
|
mockSubscriptionRepository.save.mockResolvedValue(mockSubscription);
|
||||||
|
mockEventRepository.save.mockResolvedValue(failedEvent);
|
||||||
|
|
||||||
|
const result = await service.retryProcessing(failedEvent as any);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toBe('Event processed on retry');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fail if max retries exceeded', async () => {
|
||||||
|
const maxRetriedEvent = createMockStripeEvent({
|
||||||
|
processed: false,
|
||||||
|
retryCount: 5,
|
||||||
|
errorMessage: 'Previous error',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.retryProcessing(maxRetriedEvent as any);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toBe('Max retries exceeded');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getFailedEvents', () => {
|
||||||
|
it('should return unprocessed events', async () => {
|
||||||
|
const failedEvents = [
|
||||||
|
createMockStripeEvent({ processed: false }),
|
||||||
|
createMockStripeEvent({ processed: false, stripeEventId: 'evt_2' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
mockEventRepository.find.mockResolvedValue(failedEvents);
|
||||||
|
|
||||||
|
const result = await service.getFailedEvents();
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(mockEventRepository.find).toHaveBeenCalledWith({
|
||||||
|
where: { processed: false },
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect limit parameter', async () => {
|
||||||
|
mockEventRepository.find.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await service.getFailedEvents(50);
|
||||||
|
|
||||||
|
expect(mockEventRepository.find).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ take: 50 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findByStripeEventId', () => {
|
||||||
|
it('should find event by Stripe ID', async () => {
|
||||||
|
const mockEvent = createMockStripeEvent({ stripeEventId: 'evt_find' });
|
||||||
|
mockEventRepository.findOne.mockResolvedValue(mockEvent);
|
||||||
|
|
||||||
|
const result = await service.findByStripeEventId('evt_find');
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result?.stripeEventId).toBe('evt_find');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null if not found', async () => {
|
||||||
|
mockEventRepository.findOne.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await service.findByStripeEventId('evt_notfound');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRecentEvents', () => {
|
||||||
|
it('should return recent events with default options', async () => {
|
||||||
|
const mockEvents = [createMockStripeEvent()];
|
||||||
|
const mockQueryBuilder = {
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
take: jest.fn().mockReturnThis(),
|
||||||
|
getMany: jest.fn().mockResolvedValue(mockEvents),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockEventRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||||
|
|
||||||
|
const result = await service.getRecentEvents();
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(mockQueryBuilder.take).toHaveBeenCalledWith(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by event type', async () => {
|
||||||
|
const mockQueryBuilder = {
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
take: jest.fn().mockReturnThis(),
|
||||||
|
getMany: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockEventRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||||
|
|
||||||
|
await service.getRecentEvents({ eventType: 'invoice.payment_succeeded' });
|
||||||
|
|
||||||
|
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||||
|
'event.eventType = :eventType',
|
||||||
|
{ eventType: 'invoice.payment_succeeded' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by processed status', async () => {
|
||||||
|
const mockQueryBuilder = {
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
take: jest.fn().mockReturnThis(),
|
||||||
|
getMany: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockEventRepository.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||||
|
|
||||||
|
await service.getRecentEvents({ processed: false });
|
||||||
|
|
||||||
|
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||||
|
'event.processed = :processed',
|
||||||
|
{ processed: false }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -70,6 +70,21 @@ export class TenantSubscription {
|
|||||||
@Column({ name: 'payment_provider', type: 'varchar', length: 30, nullable: true })
|
@Column({ name: 'payment_provider', type: 'varchar', length: 30, nullable: true })
|
||||||
paymentProvider: string; // stripe, mercadopago, bank_transfer
|
paymentProvider: string; // stripe, mercadopago, bank_transfer
|
||||||
|
|
||||||
|
// Stripe integration
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'stripe_customer_id', type: 'varchar', length: 255, nullable: true })
|
||||||
|
stripeCustomerId?: string;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@Column({ name: 'stripe_subscription_id', type: 'varchar', length: 255, nullable: true })
|
||||||
|
stripeSubscriptionId?: string;
|
||||||
|
|
||||||
|
@Column({ name: 'last_payment_at', type: 'timestamptz', nullable: true })
|
||||||
|
lastPaymentAt?: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'last_payment_amount', type: 'decimal', precision: 12, scale: 2, nullable: true })
|
||||||
|
lastPaymentAmount?: number;
|
||||||
|
|
||||||
// Precios actuales
|
// Precios actuales
|
||||||
@Column({ name: 'current_price', type: 'decimal', precision: 12, scale: 2 })
|
@Column({ name: 'current_price', type: 'decimal', precision: 12, scale: 2 })
|
||||||
currentPrice: number;
|
currentPrice: number;
|
||||||
|
|||||||
@ -0,0 +1,366 @@
|
|||||||
|
/**
|
||||||
|
* Plan Enforcement Middleware
|
||||||
|
*
|
||||||
|
* Middleware for validating plan limits and features before allowing operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { PlanLimitsService } from '../services/plan-limits.service.js';
|
||||||
|
import { TenantSubscription, PlanFeature } from '../entities/index.js';
|
||||||
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// Extend Express Request to include user info
|
||||||
|
interface AuthenticatedRequest extends Request {
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration for limit checks
|
||||||
|
export interface LimitCheckConfig {
|
||||||
|
limitKey: string;
|
||||||
|
getCurrentUsage?: (req: AuthenticatedRequest, tenantId: string) => Promise<number>;
|
||||||
|
requestedUnits?: number;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration for feature checks
|
||||||
|
export interface FeatureCheckConfig {
|
||||||
|
featureKey: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a middleware that checks plan limits
|
||||||
|
*/
|
||||||
|
export function requireLimit(
|
||||||
|
dataSource: DataSource,
|
||||||
|
config: LimitCheckConfig
|
||||||
|
) {
|
||||||
|
const planLimitsService = new PlanLimitsService(dataSource);
|
||||||
|
|
||||||
|
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.user?.tenantId;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'No autenticado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current usage
|
||||||
|
let currentUsage = 0;
|
||||||
|
if (config.getCurrentUsage) {
|
||||||
|
currentUsage = await config.getCurrentUsage(req, tenantId);
|
||||||
|
} else {
|
||||||
|
currentUsage = await planLimitsService.getCurrentUsage(tenantId, config.limitKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if within limits
|
||||||
|
const check = await planLimitsService.checkUsage(
|
||||||
|
tenantId,
|
||||||
|
config.limitKey,
|
||||||
|
currentUsage,
|
||||||
|
config.requestedUnits || 1
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!check.allowed) {
|
||||||
|
logger.warn('Plan limit exceeded', {
|
||||||
|
tenantId,
|
||||||
|
limitKey: config.limitKey,
|
||||||
|
currentUsage,
|
||||||
|
limit: check.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: config.errorMessage || check.message,
|
||||||
|
details: {
|
||||||
|
limitKey: config.limitKey,
|
||||||
|
currentUsage: check.currentUsage,
|
||||||
|
limit: check.limit,
|
||||||
|
remaining: check.remaining,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add limit info to request for downstream use
|
||||||
|
(req as any).limitCheck = check;
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Plan limit check failed', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
limitKey: config.limitKey,
|
||||||
|
});
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a middleware that checks if tenant has a specific feature
|
||||||
|
*/
|
||||||
|
export function requireFeature(
|
||||||
|
dataSource: DataSource,
|
||||||
|
config: FeatureCheckConfig
|
||||||
|
) {
|
||||||
|
const featureRepository = dataSource.getRepository(PlanFeature);
|
||||||
|
const subscriptionRepository = dataSource.getRepository(TenantSubscription);
|
||||||
|
|
||||||
|
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.user?.tenantId;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'No autenticado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tenant's subscription
|
||||||
|
const subscription = await subscriptionRepository.findOne({
|
||||||
|
where: { tenantId, status: 'active' },
|
||||||
|
});
|
||||||
|
|
||||||
|
let planId: string | null = null;
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
planId = subscription.planId;
|
||||||
|
} else {
|
||||||
|
// Check for free plan
|
||||||
|
const freePlanFeature = await featureRepository.findOne({
|
||||||
|
where: { featureKey: config.featureKey },
|
||||||
|
relations: ['plan'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (freePlanFeature?.plan?.code === 'FREE') {
|
||||||
|
planId = freePlanFeature.plan.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!planId) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: config.errorMessage || 'Suscripción requerida para esta función',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if plan has the feature
|
||||||
|
const feature = await featureRepository.findOne({
|
||||||
|
where: { planId, featureKey: config.featureKey },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!feature || !feature.enabled) {
|
||||||
|
logger.warn('Feature not available', {
|
||||||
|
tenantId,
|
||||||
|
featureKey: config.featureKey,
|
||||||
|
planId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: config.errorMessage || `Función no disponible: ${config.featureKey}`,
|
||||||
|
details: {
|
||||||
|
featureKey: config.featureKey,
|
||||||
|
upgrade: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add feature info to request
|
||||||
|
(req as any).feature = feature;
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Feature check failed', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
featureKey: config.featureKey,
|
||||||
|
});
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a middleware that checks subscription status
|
||||||
|
*/
|
||||||
|
export function requireActiveSubscription(dataSource: DataSource) {
|
||||||
|
const subscriptionRepository = dataSource.getRepository(TenantSubscription);
|
||||||
|
|
||||||
|
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.user?.tenantId;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
error: 'No autenticado',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscription = await subscriptionRepository.findOne({
|
||||||
|
where: { tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
// Allow free tier access
|
||||||
|
(req as any).subscription = null;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.status === 'cancelled') {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Suscripción cancelada',
|
||||||
|
details: {
|
||||||
|
status: subscription.status,
|
||||||
|
cancelledAt: subscription.cancelledAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.status === 'past_due') {
|
||||||
|
// Allow limited access for past_due, but warn
|
||||||
|
logger.warn('Tenant accessing with past_due subscription', { tenantId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add subscription to request
|
||||||
|
(req as any).subscription = subscription;
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Subscription check failed', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a rate limiting middleware based on plan
|
||||||
|
*/
|
||||||
|
export function planBasedRateLimit(
|
||||||
|
dataSource: DataSource,
|
||||||
|
options: {
|
||||||
|
windowMs?: number;
|
||||||
|
defaultLimit?: number;
|
||||||
|
limitKey?: string;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const planLimitsService = new PlanLimitsService(dataSource);
|
||||||
|
const windowMs = options.windowMs || 60 * 1000; // 1 minute default
|
||||||
|
const defaultLimit = options.defaultLimit || 100;
|
||||||
|
const limitKey = options.limitKey || 'api_calls_per_minute';
|
||||||
|
|
||||||
|
// In-memory rate limit store (use Redis in production)
|
||||||
|
const rateLimitStore = new Map<string, { count: number; resetAt: number }>();
|
||||||
|
|
||||||
|
return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.user?.tenantId;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
return next(); // Skip for unauthenticated requests
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const key = `${tenantId}:${limitKey}`;
|
||||||
|
|
||||||
|
// Get or create rate limit entry
|
||||||
|
let entry = rateLimitStore.get(key);
|
||||||
|
if (!entry || entry.resetAt < now) {
|
||||||
|
entry = { count: 0, resetAt: now + windowMs };
|
||||||
|
rateLimitStore.set(key, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get plan limit
|
||||||
|
const planLimit = await planLimitsService.getTenantLimit(tenantId, limitKey);
|
||||||
|
const limit = planLimit > 0 ? planLimit : defaultLimit;
|
||||||
|
|
||||||
|
// Check if exceeded
|
||||||
|
if (entry.count >= limit) {
|
||||||
|
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||||
|
|
||||||
|
return res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Límite de peticiones excedido',
|
||||||
|
details: {
|
||||||
|
limit,
|
||||||
|
remaining: 0,
|
||||||
|
resetAt: new Date(entry.resetAt).toISOString(),
|
||||||
|
retryAfter,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment counter
|
||||||
|
entry.count++;
|
||||||
|
|
||||||
|
// Set rate limit headers
|
||||||
|
res.set({
|
||||||
|
'X-RateLimit-Limit': String(limit),
|
||||||
|
'X-RateLimit-Remaining': String(limit - entry.count),
|
||||||
|
'X-RateLimit-Reset': String(Math.ceil(entry.resetAt / 1000)),
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Rate limit check failed', {
|
||||||
|
error: (error as Error).message,
|
||||||
|
});
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility: Get common usage getters
|
||||||
|
*/
|
||||||
|
export const usageGetters = {
|
||||||
|
/**
|
||||||
|
* Get user count for tenant
|
||||||
|
*/
|
||||||
|
async getUserCount(dataSource: DataSource, tenantId: string): Promise<number> {
|
||||||
|
const result = await dataSource.query(
|
||||||
|
`SELECT COUNT(*) as count FROM auth.users WHERE tenant_id = $1 AND is_active = true`,
|
||||||
|
[tenantId]
|
||||||
|
);
|
||||||
|
return parseInt(result[0]?.count || '0', 10);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get storage usage for tenant (in GB)
|
||||||
|
*/
|
||||||
|
async getStorageUsage(dataSource: DataSource, tenantId: string): Promise<number> {
|
||||||
|
// This would need to integrate with file storage system
|
||||||
|
// Placeholder implementation
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get API calls count for current month
|
||||||
|
*/
|
||||||
|
async getApiCallsCount(dataSource: DataSource, tenantId: string): Promise<number> {
|
||||||
|
const startOfMonth = new Date();
|
||||||
|
startOfMonth.setDate(1);
|
||||||
|
startOfMonth.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const result = await dataSource.query(
|
||||||
|
`SELECT COALESCE(SUM(api_calls_count), 0) as count
|
||||||
|
FROM billing.usage_tracking
|
||||||
|
WHERE tenant_id = $1 AND period_start >= $2`,
|
||||||
|
[tenantId, startOfMonth]
|
||||||
|
);
|
||||||
|
return parseInt(result[0]?.count || '0', 10);
|
||||||
|
},
|
||||||
|
};
|
||||||
348
src/modules/billing-usage/services/coupons.service.ts
Normal file
348
src/modules/billing-usage/services/coupons.service.ts
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
/**
|
||||||
|
* Coupons Service
|
||||||
|
*
|
||||||
|
* Service for managing discount coupons and redemptions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Repository, DataSource, LessThanOrEqual, MoreThanOrEqual, IsNull, Or } from 'typeorm';
|
||||||
|
import { Coupon, CouponRedemption, TenantSubscription, DiscountType } from '../entities/index.js';
|
||||||
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
export interface CreateCouponDto {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
discountType: DiscountType;
|
||||||
|
discountValue: number;
|
||||||
|
currency?: string;
|
||||||
|
applicablePlans?: string[];
|
||||||
|
minAmount?: number;
|
||||||
|
durationPeriod?: 'once' | 'forever' | 'months';
|
||||||
|
durationMonths?: number;
|
||||||
|
maxRedemptions?: number;
|
||||||
|
validFrom?: Date;
|
||||||
|
validUntil?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateCouponDto {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
maxRedemptions?: number;
|
||||||
|
validUntil?: Date;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyCouponResult {
|
||||||
|
success: boolean;
|
||||||
|
discountAmount: number;
|
||||||
|
message: string;
|
||||||
|
coupon?: Coupon;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CouponsService {
|
||||||
|
private couponRepository: Repository<Coupon>;
|
||||||
|
private redemptionRepository: Repository<CouponRedemption>;
|
||||||
|
private subscriptionRepository: Repository<TenantSubscription>;
|
||||||
|
|
||||||
|
constructor(private dataSource: DataSource) {
|
||||||
|
this.couponRepository = dataSource.getRepository(Coupon);
|
||||||
|
this.redemptionRepository = dataSource.getRepository(CouponRedemption);
|
||||||
|
this.subscriptionRepository = dataSource.getRepository(TenantSubscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new coupon
|
||||||
|
*/
|
||||||
|
async create(dto: CreateCouponDto): Promise<Coupon> {
|
||||||
|
// Check if code already exists
|
||||||
|
const existing = await this.couponRepository.findOne({
|
||||||
|
where: { code: dto.code.toUpperCase() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`Coupon with code ${dto.code} already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const coupon = this.couponRepository.create({
|
||||||
|
code: dto.code.toUpperCase(),
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description,
|
||||||
|
discountType: dto.discountType,
|
||||||
|
discountValue: dto.discountValue,
|
||||||
|
currency: dto.currency || 'MXN',
|
||||||
|
applicablePlans: dto.applicablePlans || [],
|
||||||
|
minAmount: dto.minAmount || 0,
|
||||||
|
durationPeriod: dto.durationPeriod || 'once',
|
||||||
|
durationMonths: dto.durationMonths,
|
||||||
|
maxRedemptions: dto.maxRedemptions,
|
||||||
|
validFrom: dto.validFrom,
|
||||||
|
validUntil: dto.validUntil,
|
||||||
|
isActive: true,
|
||||||
|
currentRedemptions: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.couponRepository.save(coupon);
|
||||||
|
|
||||||
|
logger.info('Coupon created', { couponId: saved.id, code: saved.code });
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all coupons
|
||||||
|
*/
|
||||||
|
async findAll(options?: { isActive?: boolean }): Promise<Coupon[]> {
|
||||||
|
const query = this.couponRepository.createQueryBuilder('coupon');
|
||||||
|
|
||||||
|
if (options?.isActive !== undefined) {
|
||||||
|
query.andWhere('coupon.isActive = :isActive', { isActive: options.isActive });
|
||||||
|
}
|
||||||
|
|
||||||
|
return query.orderBy('coupon.createdAt', 'DESC').getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find coupon by code
|
||||||
|
*/
|
||||||
|
async findByCode(code: string): Promise<Coupon | null> {
|
||||||
|
return this.couponRepository.findOne({
|
||||||
|
where: { code: code.toUpperCase() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find coupon by ID
|
||||||
|
*/
|
||||||
|
async findById(id: string): Promise<Coupon | null> {
|
||||||
|
return this.couponRepository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: ['redemptions'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a coupon
|
||||||
|
*/
|
||||||
|
async update(id: string, dto: UpdateCouponDto): Promise<Coupon> {
|
||||||
|
const coupon = await this.findById(id);
|
||||||
|
if (!coupon) {
|
||||||
|
throw new Error('Coupon not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.name !== undefined) coupon.name = dto.name;
|
||||||
|
if (dto.description !== undefined) coupon.description = dto.description;
|
||||||
|
if (dto.maxRedemptions !== undefined) coupon.maxRedemptions = dto.maxRedemptions;
|
||||||
|
if (dto.validUntil !== undefined) coupon.validUntil = dto.validUntil;
|
||||||
|
if (dto.isActive !== undefined) coupon.isActive = dto.isActive;
|
||||||
|
|
||||||
|
return this.couponRepository.save(coupon);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate if a coupon can be applied
|
||||||
|
*/
|
||||||
|
async validateCoupon(
|
||||||
|
code: string,
|
||||||
|
tenantId: string,
|
||||||
|
planId?: string,
|
||||||
|
amount?: number
|
||||||
|
): Promise<ApplyCouponResult> {
|
||||||
|
const coupon = await this.findByCode(code);
|
||||||
|
|
||||||
|
if (!coupon) {
|
||||||
|
return { success: false, discountAmount: 0, message: 'Cupón no encontrado' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!coupon.isActive) {
|
||||||
|
return { success: false, discountAmount: 0, message: 'Cupón inactivo' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check validity dates
|
||||||
|
const now = new Date();
|
||||||
|
if (coupon.validFrom && now < coupon.validFrom) {
|
||||||
|
return { success: false, discountAmount: 0, message: 'Cupón aún no válido' };
|
||||||
|
}
|
||||||
|
if (coupon.validUntil && now > coupon.validUntil) {
|
||||||
|
return { success: false, discountAmount: 0, message: 'Cupón expirado' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check max redemptions
|
||||||
|
if (coupon.maxRedemptions && coupon.currentRedemptions >= coupon.maxRedemptions) {
|
||||||
|
return { success: false, discountAmount: 0, message: 'Cupón agotado' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already redeemed by this tenant
|
||||||
|
const existingRedemption = await this.redemptionRepository.findOne({
|
||||||
|
where: { couponId: coupon.id, tenantId },
|
||||||
|
});
|
||||||
|
if (existingRedemption) {
|
||||||
|
return { success: false, discountAmount: 0, message: 'Cupón ya utilizado' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check applicable plans
|
||||||
|
if (planId && coupon.applicablePlans.length > 0) {
|
||||||
|
if (!coupon.applicablePlans.includes(planId)) {
|
||||||
|
return { success: false, discountAmount: 0, message: 'Cupón no aplicable a este plan' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check minimum amount
|
||||||
|
if (amount && coupon.minAmount > 0 && amount < coupon.minAmount) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
discountAmount: 0,
|
||||||
|
message: `Monto mínimo requerido: ${coupon.minAmount} ${coupon.currency}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate discount
|
||||||
|
let discountAmount = 0;
|
||||||
|
if (amount) {
|
||||||
|
if (coupon.discountType === 'percentage') {
|
||||||
|
discountAmount = (amount * coupon.discountValue) / 100;
|
||||||
|
} else {
|
||||||
|
discountAmount = Math.min(coupon.discountValue, amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
discountAmount,
|
||||||
|
message: 'Cupón válido',
|
||||||
|
coupon,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a coupon to a subscription
|
||||||
|
*/
|
||||||
|
async applyCoupon(
|
||||||
|
code: string,
|
||||||
|
tenantId: string,
|
||||||
|
subscriptionId: string,
|
||||||
|
amount: number
|
||||||
|
): Promise<CouponRedemption> {
|
||||||
|
const validation = await this.validateCoupon(code, tenantId, undefined, amount);
|
||||||
|
|
||||||
|
if (!validation.success || !validation.coupon) {
|
||||||
|
throw new Error(validation.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const coupon = validation.coupon;
|
||||||
|
|
||||||
|
// Create redemption record
|
||||||
|
const redemption = this.redemptionRepository.create({
|
||||||
|
couponId: coupon.id,
|
||||||
|
tenantId,
|
||||||
|
subscriptionId,
|
||||||
|
discountAmount: validation.discountAmount,
|
||||||
|
expiresAt: this.calculateRedemptionExpiry(coupon),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update coupon redemption count
|
||||||
|
coupon.currentRedemptions += 1;
|
||||||
|
|
||||||
|
// Save in transaction
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.save(redemption);
|
||||||
|
await manager.save(coupon);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Coupon applied', {
|
||||||
|
couponId: coupon.id,
|
||||||
|
code: coupon.code,
|
||||||
|
tenantId,
|
||||||
|
subscriptionId,
|
||||||
|
discountAmount: validation.discountAmount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return redemption;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate when a redemption expires based on coupon duration
|
||||||
|
*/
|
||||||
|
private calculateRedemptionExpiry(coupon: Coupon): Date | undefined {
|
||||||
|
if (coupon.durationPeriod === 'forever') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coupon.durationPeriod === 'once') {
|
||||||
|
// Expires at end of current billing period (30 days)
|
||||||
|
const expiry = new Date();
|
||||||
|
expiry.setDate(expiry.getDate() + 30);
|
||||||
|
return expiry;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coupon.durationPeriod === 'months' && coupon.durationMonths) {
|
||||||
|
const expiry = new Date();
|
||||||
|
expiry.setMonth(expiry.getMonth() + coupon.durationMonths);
|
||||||
|
return expiry;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active redemptions for a tenant
|
||||||
|
*/
|
||||||
|
async getActiveRedemptions(tenantId: string): Promise<CouponRedemption[]> {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
return this.redemptionRepository.find({
|
||||||
|
where: [
|
||||||
|
{ tenantId, expiresAt: IsNull() },
|
||||||
|
{ tenantId, expiresAt: MoreThanOrEqual(now) },
|
||||||
|
],
|
||||||
|
relations: ['coupon'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivate a coupon
|
||||||
|
*/
|
||||||
|
async deactivate(id: string): Promise<Coupon> {
|
||||||
|
const coupon = await this.findById(id);
|
||||||
|
if (!coupon) {
|
||||||
|
throw new Error('Coupon not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
coupon.isActive = false;
|
||||||
|
return this.couponRepository.save(coupon);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get coupon statistics
|
||||||
|
*/
|
||||||
|
async getStats(id: string): Promise<{
|
||||||
|
totalRedemptions: number;
|
||||||
|
totalDiscountGiven: number;
|
||||||
|
activeRedemptions: number;
|
||||||
|
}> {
|
||||||
|
const coupon = await this.findById(id);
|
||||||
|
if (!coupon) {
|
||||||
|
throw new Error('Coupon not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const redemptions = await this.redemptionRepository.find({
|
||||||
|
where: { couponId: id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalDiscountGiven = redemptions.reduce(
|
||||||
|
(sum, r) => sum + Number(r.discountAmount),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeRedemptions = redemptions.filter(
|
||||||
|
(r) => !r.expiresAt || r.expiresAt > now
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalRedemptions: redemptions.length,
|
||||||
|
totalDiscountGiven,
|
||||||
|
activeRedemptions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,7 +2,10 @@
|
|||||||
* Billing Usage Services Index
|
* Billing Usage Services Index
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { SubscriptionPlansService } from './subscription-plans.service';
|
export { SubscriptionPlansService } from './subscription-plans.service.js';
|
||||||
export { SubscriptionsService } from './subscriptions.service';
|
export { SubscriptionsService } from './subscriptions.service.js';
|
||||||
export { UsageTrackingService } from './usage-tracking.service';
|
export { UsageTrackingService } from './usage-tracking.service.js';
|
||||||
export { InvoicesService } from './invoices.service';
|
export { InvoicesService } from './invoices.service.js';
|
||||||
|
export { CouponsService, CreateCouponDto, UpdateCouponDto, ApplyCouponResult } from './coupons.service.js';
|
||||||
|
export { PlanLimitsService, CreatePlanLimitDto, UpdatePlanLimitDto, UsageCheckResult } from './plan-limits.service.js';
|
||||||
|
export { StripeWebhookService, StripeEventType, StripeWebhookPayload, ProcessResult } from './stripe-webhook.service.js';
|
||||||
|
|||||||
334
src/modules/billing-usage/services/plan-limits.service.ts
Normal file
334
src/modules/billing-usage/services/plan-limits.service.ts
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
/**
|
||||||
|
* Plan Limits Service
|
||||||
|
*
|
||||||
|
* Service for managing plan limits and usage validation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Repository, DataSource } from 'typeorm';
|
||||||
|
import { PlanLimit, LimitType, SubscriptionPlan, TenantSubscription, UsageTracking } from '../entities/index.js';
|
||||||
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
export interface CreatePlanLimitDto {
|
||||||
|
planId: string;
|
||||||
|
limitKey: string;
|
||||||
|
limitName: string;
|
||||||
|
limitValue: number;
|
||||||
|
limitType?: LimitType;
|
||||||
|
allowOverage?: boolean;
|
||||||
|
overageUnitPrice?: number;
|
||||||
|
overageCurrency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePlanLimitDto {
|
||||||
|
limitName?: string;
|
||||||
|
limitValue?: number;
|
||||||
|
allowOverage?: boolean;
|
||||||
|
overageUnitPrice?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsageCheckResult {
|
||||||
|
allowed: boolean;
|
||||||
|
currentUsage: number;
|
||||||
|
limit: number;
|
||||||
|
remaining: number;
|
||||||
|
overageAllowed: boolean;
|
||||||
|
overageUnits?: number;
|
||||||
|
overageCost?: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlanLimitsService {
|
||||||
|
private limitRepository: Repository<PlanLimit>;
|
||||||
|
private planRepository: Repository<SubscriptionPlan>;
|
||||||
|
private subscriptionRepository: Repository<TenantSubscription>;
|
||||||
|
private usageRepository: Repository<UsageTracking>;
|
||||||
|
|
||||||
|
constructor(private dataSource: DataSource) {
|
||||||
|
this.limitRepository = dataSource.getRepository(PlanLimit);
|
||||||
|
this.planRepository = dataSource.getRepository(SubscriptionPlan);
|
||||||
|
this.subscriptionRepository = dataSource.getRepository(TenantSubscription);
|
||||||
|
this.usageRepository = dataSource.getRepository(UsageTracking);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new plan limit
|
||||||
|
*/
|
||||||
|
async create(dto: CreatePlanLimitDto): Promise<PlanLimit> {
|
||||||
|
// Verify plan exists
|
||||||
|
const plan = await this.planRepository.findOne({ where: { id: dto.planId } });
|
||||||
|
if (!plan) {
|
||||||
|
throw new Error('Plan not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate limit key
|
||||||
|
const existing = await this.limitRepository.findOne({
|
||||||
|
where: { planId: dto.planId, limitKey: dto.limitKey },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new Error(`Limit ${dto.limitKey} already exists for this plan`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = this.limitRepository.create({
|
||||||
|
planId: dto.planId,
|
||||||
|
limitKey: dto.limitKey,
|
||||||
|
limitName: dto.limitName,
|
||||||
|
limitValue: dto.limitValue,
|
||||||
|
limitType: dto.limitType || 'monthly',
|
||||||
|
allowOverage: dto.allowOverage || false,
|
||||||
|
overageUnitPrice: dto.overageUnitPrice || 0,
|
||||||
|
overageCurrency: dto.overageCurrency || 'MXN',
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = await this.limitRepository.save(limit);
|
||||||
|
|
||||||
|
logger.info('Plan limit created', {
|
||||||
|
limitId: saved.id,
|
||||||
|
planId: dto.planId,
|
||||||
|
limitKey: dto.limitKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all limits for a plan
|
||||||
|
*/
|
||||||
|
async findByPlan(planId: string): Promise<PlanLimit[]> {
|
||||||
|
return this.limitRepository.find({
|
||||||
|
where: { planId },
|
||||||
|
order: { limitKey: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a specific limit by key
|
||||||
|
*/
|
||||||
|
async findByKey(planId: string, limitKey: string): Promise<PlanLimit | null> {
|
||||||
|
return this.limitRepository.findOne({
|
||||||
|
where: { planId, limitKey },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a plan limit
|
||||||
|
*/
|
||||||
|
async update(id: string, dto: UpdatePlanLimitDto): Promise<PlanLimit> {
|
||||||
|
const limit = await this.limitRepository.findOne({ where: { id } });
|
||||||
|
if (!limit) {
|
||||||
|
throw new Error('Limit not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.limitName !== undefined) limit.limitName = dto.limitName;
|
||||||
|
if (dto.limitValue !== undefined) limit.limitValue = dto.limitValue;
|
||||||
|
if (dto.allowOverage !== undefined) limit.allowOverage = dto.allowOverage;
|
||||||
|
if (dto.overageUnitPrice !== undefined) limit.overageUnitPrice = dto.overageUnitPrice;
|
||||||
|
|
||||||
|
return this.limitRepository.save(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a plan limit
|
||||||
|
*/
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
const limit = await this.limitRepository.findOne({ where: { id } });
|
||||||
|
if (!limit) {
|
||||||
|
throw new Error('Limit not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.limitRepository.remove(limit);
|
||||||
|
|
||||||
|
logger.info('Plan limit deleted', { limitId: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tenant's current plan limits
|
||||||
|
*/
|
||||||
|
async getTenantLimits(tenantId: string): Promise<PlanLimit[]> {
|
||||||
|
const subscription = await this.subscriptionRepository.findOne({
|
||||||
|
where: { tenantId, status: 'active' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
// Return free plan limits by default
|
||||||
|
const freePlan = await this.planRepository.findOne({
|
||||||
|
where: { code: 'FREE' },
|
||||||
|
});
|
||||||
|
if (freePlan) {
|
||||||
|
return this.findByPlan(freePlan.id);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findByPlan(subscription.planId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific limit for a tenant
|
||||||
|
*/
|
||||||
|
async getTenantLimit(tenantId: string, limitKey: string): Promise<number> {
|
||||||
|
const limits = await this.getTenantLimits(tenantId);
|
||||||
|
const limit = limits.find((l) => l.limitKey === limitKey);
|
||||||
|
return limit?.limitValue || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if tenant can use a resource (within limits)
|
||||||
|
*/
|
||||||
|
async checkUsage(
|
||||||
|
tenantId: string,
|
||||||
|
limitKey: string,
|
||||||
|
currentUsage: number,
|
||||||
|
requestedUnits: number = 1
|
||||||
|
): Promise<UsageCheckResult> {
|
||||||
|
const limits = await this.getTenantLimits(tenantId);
|
||||||
|
const limit = limits.find((l) => l.limitKey === limitKey);
|
||||||
|
|
||||||
|
if (!limit) {
|
||||||
|
// No limit defined = unlimited
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
currentUsage,
|
||||||
|
limit: -1,
|
||||||
|
remaining: -1,
|
||||||
|
overageAllowed: false,
|
||||||
|
message: 'Sin límite definido',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = limit.limitValue - currentUsage;
|
||||||
|
const wouldExceed = currentUsage + requestedUnits > limit.limitValue;
|
||||||
|
|
||||||
|
if (!wouldExceed) {
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
currentUsage,
|
||||||
|
limit: limit.limitValue,
|
||||||
|
remaining: remaining - requestedUnits,
|
||||||
|
overageAllowed: limit.allowOverage,
|
||||||
|
message: 'Dentro del límite',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Would exceed limit
|
||||||
|
if (limit.allowOverage) {
|
||||||
|
const overageUnits = currentUsage + requestedUnits - limit.limitValue;
|
||||||
|
const overageCost = overageUnits * Number(limit.overageUnitPrice);
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
currentUsage,
|
||||||
|
limit: limit.limitValue,
|
||||||
|
remaining: 0,
|
||||||
|
overageAllowed: true,
|
||||||
|
overageUnits,
|
||||||
|
overageCost,
|
||||||
|
message: `Se aplicará cargo por excedente: ${overageUnits} unidades`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
currentUsage,
|
||||||
|
limit: limit.limitValue,
|
||||||
|
remaining: Math.max(0, remaining),
|
||||||
|
overageAllowed: false,
|
||||||
|
message: `Límite alcanzado: ${limit.limitName}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current usage for a tenant and limit key
|
||||||
|
*/
|
||||||
|
async getCurrentUsage(tenantId: string, limitKey: string): Promise<number> {
|
||||||
|
// Get current period
|
||||||
|
const now = new Date();
|
||||||
|
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
|
||||||
|
const usage = await this.usageRepository.findOne({
|
||||||
|
where: {
|
||||||
|
tenantId,
|
||||||
|
periodStart,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!usage) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map limit key to usage field
|
||||||
|
const usageMap: Record<string, keyof UsageTracking> = {
|
||||||
|
users: 'activeUsers',
|
||||||
|
storage_gb: 'storageUsedGb',
|
||||||
|
api_calls: 'apiCalls',
|
||||||
|
branches: 'activeBranches',
|
||||||
|
documents: 'documentsCount',
|
||||||
|
invoices: 'invoicesGenerated',
|
||||||
|
// Add more mappings as needed
|
||||||
|
};
|
||||||
|
|
||||||
|
const field = usageMap[limitKey];
|
||||||
|
if (field && usage[field] !== undefined) {
|
||||||
|
return Number(usage[field]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate all limits for a tenant
|
||||||
|
*/
|
||||||
|
async validateAllLimits(tenantId: string): Promise<{
|
||||||
|
valid: boolean;
|
||||||
|
violations: Array<{ limitKey: string; message: string }>;
|
||||||
|
}> {
|
||||||
|
const limits = await this.getTenantLimits(tenantId);
|
||||||
|
const violations: Array<{ limitKey: string; message: string }> = [];
|
||||||
|
|
||||||
|
for (const limit of limits) {
|
||||||
|
const currentUsage = await this.getCurrentUsage(tenantId, limit.limitKey);
|
||||||
|
const check = await this.checkUsage(tenantId, limit.limitKey, currentUsage);
|
||||||
|
|
||||||
|
if (!check.allowed) {
|
||||||
|
violations.push({
|
||||||
|
limitKey: limit.limitKey,
|
||||||
|
message: check.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: violations.length === 0,
|
||||||
|
violations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy limits from one plan to another
|
||||||
|
*/
|
||||||
|
async copyLimitsFromPlan(sourcePlanId: string, targetPlanId: string): Promise<PlanLimit[]> {
|
||||||
|
const sourceLimits = await this.findByPlan(sourcePlanId);
|
||||||
|
const createdLimits: PlanLimit[] = [];
|
||||||
|
|
||||||
|
for (const sourceLimit of sourceLimits) {
|
||||||
|
const limit = await this.create({
|
||||||
|
planId: targetPlanId,
|
||||||
|
limitKey: sourceLimit.limitKey,
|
||||||
|
limitName: sourceLimit.limitName,
|
||||||
|
limitValue: sourceLimit.limitValue,
|
||||||
|
limitType: sourceLimit.limitType,
|
||||||
|
allowOverage: sourceLimit.allowOverage,
|
||||||
|
overageUnitPrice: Number(sourceLimit.overageUnitPrice),
|
||||||
|
overageCurrency: sourceLimit.overageCurrency,
|
||||||
|
});
|
||||||
|
createdLimits.push(limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Plan limits copied', {
|
||||||
|
sourcePlanId,
|
||||||
|
targetPlanId,
|
||||||
|
count: createdLimits.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return createdLimits;
|
||||||
|
}
|
||||||
|
}
|
||||||
462
src/modules/billing-usage/services/stripe-webhook.service.ts
Normal file
462
src/modules/billing-usage/services/stripe-webhook.service.ts
Normal file
@ -0,0 +1,462 @@
|
|||||||
|
/**
|
||||||
|
* Stripe Webhook Service
|
||||||
|
*
|
||||||
|
* Service for processing Stripe webhook events
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Repository, DataSource } from 'typeorm';
|
||||||
|
import { StripeEvent, TenantSubscription } from '../entities/index.js';
|
||||||
|
import { logger } from '../../../shared/utils/logger.js';
|
||||||
|
|
||||||
|
// Stripe event types we handle
|
||||||
|
export type StripeEventType =
|
||||||
|
| 'customer.subscription.created'
|
||||||
|
| 'customer.subscription.updated'
|
||||||
|
| 'customer.subscription.deleted'
|
||||||
|
| 'customer.subscription.trial_will_end'
|
||||||
|
| 'invoice.payment_succeeded'
|
||||||
|
| 'invoice.payment_failed'
|
||||||
|
| 'invoice.upcoming'
|
||||||
|
| 'payment_intent.succeeded'
|
||||||
|
| 'payment_intent.payment_failed'
|
||||||
|
| 'checkout.session.completed';
|
||||||
|
|
||||||
|
export interface StripeWebhookPayload {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
api_version?: string;
|
||||||
|
data: {
|
||||||
|
object: Record<string, any>;
|
||||||
|
previous_attributes?: Record<string, any>;
|
||||||
|
};
|
||||||
|
created: number;
|
||||||
|
livemode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessResult {
|
||||||
|
success: boolean;
|
||||||
|
eventId: string;
|
||||||
|
message: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StripeWebhookService {
|
||||||
|
private eventRepository: Repository<StripeEvent>;
|
||||||
|
private subscriptionRepository: Repository<TenantSubscription>;
|
||||||
|
|
||||||
|
constructor(private dataSource: DataSource) {
|
||||||
|
this.eventRepository = dataSource.getRepository(StripeEvent);
|
||||||
|
this.subscriptionRepository = dataSource.getRepository(TenantSubscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process an incoming Stripe webhook event
|
||||||
|
*/
|
||||||
|
async processWebhook(payload: StripeWebhookPayload): Promise<ProcessResult> {
|
||||||
|
const { id: stripeEventId, type: eventType, api_version, data } = payload;
|
||||||
|
|
||||||
|
// Check for duplicate event
|
||||||
|
const existing = await this.eventRepository.findOne({
|
||||||
|
where: { stripeEventId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.processed) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
eventId: existing.id,
|
||||||
|
message: 'Event already processed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Retry processing
|
||||||
|
return this.retryProcessing(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the event
|
||||||
|
const event = this.eventRepository.create({
|
||||||
|
stripeEventId,
|
||||||
|
eventType,
|
||||||
|
apiVersion: api_version,
|
||||||
|
data,
|
||||||
|
processed: false,
|
||||||
|
retryCount: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.eventRepository.save(event);
|
||||||
|
|
||||||
|
logger.info('Stripe webhook received', { stripeEventId, eventType });
|
||||||
|
|
||||||
|
// Process the event
|
||||||
|
try {
|
||||||
|
await this.handleEvent(event, data.object);
|
||||||
|
|
||||||
|
// Mark as processed
|
||||||
|
event.processed = true;
|
||||||
|
event.processedAt = new Date();
|
||||||
|
await this.eventRepository.save(event);
|
||||||
|
|
||||||
|
logger.info('Stripe webhook processed', { stripeEventId, eventType });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
eventId: event.id,
|
||||||
|
message: 'Event processed successfully',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = (error as Error).message;
|
||||||
|
|
||||||
|
event.errorMessage = errorMessage;
|
||||||
|
event.retryCount += 1;
|
||||||
|
await this.eventRepository.save(event);
|
||||||
|
|
||||||
|
logger.error('Stripe webhook processing failed', {
|
||||||
|
stripeEventId,
|
||||||
|
eventType,
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
eventId: event.id,
|
||||||
|
message: 'Event processing failed',
|
||||||
|
error: errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle specific event types
|
||||||
|
*/
|
||||||
|
private async handleEvent(event: StripeEvent, object: Record<string, any>): Promise<void> {
|
||||||
|
const eventType = event.eventType as StripeEventType;
|
||||||
|
|
||||||
|
switch (eventType) {
|
||||||
|
case 'customer.subscription.created':
|
||||||
|
await this.handleSubscriptionCreated(object);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'customer.subscription.updated':
|
||||||
|
await this.handleSubscriptionUpdated(object);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'customer.subscription.deleted':
|
||||||
|
await this.handleSubscriptionDeleted(object);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'customer.subscription.trial_will_end':
|
||||||
|
await this.handleTrialWillEnd(object);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'invoice.payment_succeeded':
|
||||||
|
await this.handlePaymentSucceeded(object);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'invoice.payment_failed':
|
||||||
|
await this.handlePaymentFailed(object);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'checkout.session.completed':
|
||||||
|
await this.handleCheckoutCompleted(object);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.warn('Unhandled Stripe event type', { eventType });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle subscription created
|
||||||
|
*/
|
||||||
|
private async handleSubscriptionCreated(subscription: Record<string, any>): Promise<void> {
|
||||||
|
const customerId = subscription.customer;
|
||||||
|
const stripeSubscriptionId = subscription.id;
|
||||||
|
const status = this.mapStripeStatus(subscription.status);
|
||||||
|
|
||||||
|
// Find tenant by Stripe customer ID
|
||||||
|
const existing = await this.subscriptionRepository.findOne({
|
||||||
|
where: { stripeCustomerId: customerId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.stripeSubscriptionId = stripeSubscriptionId;
|
||||||
|
existing.status = status;
|
||||||
|
existing.currentPeriodStart = new Date(subscription.current_period_start * 1000);
|
||||||
|
existing.currentPeriodEnd = new Date(subscription.current_period_end * 1000);
|
||||||
|
|
||||||
|
if (subscription.trial_end) {
|
||||||
|
existing.trialEnd = new Date(subscription.trial_end * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.subscriptionRepository.save(existing);
|
||||||
|
|
||||||
|
logger.info('Subscription created/linked', {
|
||||||
|
tenantId: existing.tenantId,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle subscription updated
|
||||||
|
*/
|
||||||
|
private async handleSubscriptionUpdated(subscription: Record<string, any>): Promise<void> {
|
||||||
|
const stripeSubscriptionId = subscription.id;
|
||||||
|
const status = this.mapStripeStatus(subscription.status);
|
||||||
|
|
||||||
|
const existing = await this.subscriptionRepository.findOne({
|
||||||
|
where: { stripeSubscriptionId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.status = status;
|
||||||
|
existing.currentPeriodStart = new Date(subscription.current_period_start * 1000);
|
||||||
|
existing.currentPeriodEnd = new Date(subscription.current_period_end * 1000);
|
||||||
|
|
||||||
|
if (subscription.cancel_at_period_end) {
|
||||||
|
existing.cancelAtPeriodEnd = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.canceled_at) {
|
||||||
|
existing.cancelledAt = new Date(subscription.canceled_at * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.subscriptionRepository.save(existing);
|
||||||
|
|
||||||
|
logger.info('Subscription updated', {
|
||||||
|
tenantId: existing.tenantId,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle subscription deleted (cancelled)
|
||||||
|
*/
|
||||||
|
private async handleSubscriptionDeleted(subscription: Record<string, any>): Promise<void> {
|
||||||
|
const stripeSubscriptionId = subscription.id;
|
||||||
|
|
||||||
|
const existing = await this.subscriptionRepository.findOne({
|
||||||
|
where: { stripeSubscriptionId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.status = 'cancelled';
|
||||||
|
existing.cancelledAt = new Date();
|
||||||
|
|
||||||
|
await this.subscriptionRepository.save(existing);
|
||||||
|
|
||||||
|
logger.info('Subscription cancelled', {
|
||||||
|
tenantId: existing.tenantId,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle trial will end (send notification)
|
||||||
|
*/
|
||||||
|
private async handleTrialWillEnd(subscription: Record<string, any>): Promise<void> {
|
||||||
|
const stripeSubscriptionId = subscription.id;
|
||||||
|
const trialEnd = new Date(subscription.trial_end * 1000);
|
||||||
|
|
||||||
|
const existing = await this.subscriptionRepository.findOne({
|
||||||
|
where: { stripeSubscriptionId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// TODO: Send notification to tenant
|
||||||
|
logger.info('Trial ending soon', {
|
||||||
|
tenantId: existing.tenantId,
|
||||||
|
trialEnd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle payment succeeded
|
||||||
|
*/
|
||||||
|
private async handlePaymentSucceeded(invoice: Record<string, any>): Promise<void> {
|
||||||
|
const customerId = invoice.customer;
|
||||||
|
const amountPaid = invoice.amount_paid;
|
||||||
|
const invoiceId = invoice.id;
|
||||||
|
|
||||||
|
const subscription = await this.subscriptionRepository.findOne({
|
||||||
|
where: { stripeCustomerId: customerId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
// Update last payment info
|
||||||
|
subscription.lastPaymentAt = new Date();
|
||||||
|
subscription.lastPaymentAmount = amountPaid / 100; // Stripe amounts are in cents
|
||||||
|
subscription.status = 'active';
|
||||||
|
|
||||||
|
await this.subscriptionRepository.save(subscription);
|
||||||
|
|
||||||
|
logger.info('Payment succeeded', {
|
||||||
|
tenantId: subscription.tenantId,
|
||||||
|
invoiceId,
|
||||||
|
amount: amountPaid / 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle payment failed
|
||||||
|
*/
|
||||||
|
private async handlePaymentFailed(invoice: Record<string, any>): Promise<void> {
|
||||||
|
const customerId = invoice.customer;
|
||||||
|
const invoiceId = invoice.id;
|
||||||
|
const attemptCount = invoice.attempt_count;
|
||||||
|
|
||||||
|
const subscription = await this.subscriptionRepository.findOne({
|
||||||
|
where: { stripeCustomerId: customerId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
subscription.status = 'past_due';
|
||||||
|
await this.subscriptionRepository.save(subscription);
|
||||||
|
|
||||||
|
// TODO: Send payment failed notification
|
||||||
|
logger.warn('Payment failed', {
|
||||||
|
tenantId: subscription.tenantId,
|
||||||
|
invoiceId,
|
||||||
|
attemptCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle checkout session completed
|
||||||
|
*/
|
||||||
|
private async handleCheckoutCompleted(session: Record<string, any>): Promise<void> {
|
||||||
|
const customerId = session.customer;
|
||||||
|
const subscriptionId = session.subscription;
|
||||||
|
const metadata = session.metadata || {};
|
||||||
|
const tenantId = metadata.tenant_id;
|
||||||
|
|
||||||
|
if (tenantId) {
|
||||||
|
// Link Stripe customer to tenant
|
||||||
|
const subscription = await this.subscriptionRepository.findOne({
|
||||||
|
where: { tenantId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subscription) {
|
||||||
|
subscription.stripeCustomerId = customerId;
|
||||||
|
subscription.stripeSubscriptionId = subscriptionId;
|
||||||
|
subscription.status = 'active';
|
||||||
|
|
||||||
|
await this.subscriptionRepository.save(subscription);
|
||||||
|
|
||||||
|
logger.info('Checkout completed', {
|
||||||
|
tenantId,
|
||||||
|
customerId,
|
||||||
|
subscriptionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map Stripe subscription status to our status
|
||||||
|
*/
|
||||||
|
private mapStripeStatus(stripeStatus: string): 'active' | 'trial' | 'past_due' | 'cancelled' | 'suspended' {
|
||||||
|
const statusMap: Record<string, 'active' | 'trial' | 'past_due' | 'cancelled' | 'suspended'> = {
|
||||||
|
active: 'active',
|
||||||
|
trialing: 'trial',
|
||||||
|
past_due: 'past_due',
|
||||||
|
canceled: 'cancelled',
|
||||||
|
unpaid: 'past_due',
|
||||||
|
incomplete: 'suspended',
|
||||||
|
incomplete_expired: 'cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
return statusMap[stripeStatus] || 'suspended';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry processing a failed event
|
||||||
|
*/
|
||||||
|
async retryProcessing(event: StripeEvent): Promise<ProcessResult> {
|
||||||
|
if (event.retryCount >= 5) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
eventId: event.id,
|
||||||
|
message: 'Max retries exceeded',
|
||||||
|
error: event.errorMessage || 'Unknown error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.handleEvent(event, event.data.object);
|
||||||
|
|
||||||
|
event.processed = true;
|
||||||
|
event.processedAt = new Date();
|
||||||
|
event.errorMessage = undefined;
|
||||||
|
await this.eventRepository.save(event);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
eventId: event.id,
|
||||||
|
message: 'Event processed on retry',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
event.errorMessage = (error as Error).message;
|
||||||
|
event.retryCount += 1;
|
||||||
|
await this.eventRepository.save(event);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
eventId: event.id,
|
||||||
|
message: 'Retry failed',
|
||||||
|
error: event.errorMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get failed events for retry
|
||||||
|
*/
|
||||||
|
async getFailedEvents(limit: number = 100): Promise<StripeEvent[]> {
|
||||||
|
return this.eventRepository.find({
|
||||||
|
where: {
|
||||||
|
processed: false,
|
||||||
|
},
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get event by Stripe event ID
|
||||||
|
*/
|
||||||
|
async findByStripeEventId(stripeEventId: string): Promise<StripeEvent | null> {
|
||||||
|
return this.eventRepository.findOne({
|
||||||
|
where: { stripeEventId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent events
|
||||||
|
*/
|
||||||
|
async getRecentEvents(options?: {
|
||||||
|
limit?: number;
|
||||||
|
eventType?: string;
|
||||||
|
processed?: boolean;
|
||||||
|
}): Promise<StripeEvent[]> {
|
||||||
|
const query = this.eventRepository.createQueryBuilder('event');
|
||||||
|
|
||||||
|
if (options?.eventType) {
|
||||||
|
query.andWhere('event.eventType = :eventType', { eventType: options.eventType });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.processed !== undefined) {
|
||||||
|
query.andWhere('event.processed = :processed', { processed: options.processed });
|
||||||
|
}
|
||||||
|
|
||||||
|
return query
|
||||||
|
.orderBy('event.createdAt', 'DESC')
|
||||||
|
.take(options?.limit || 50)
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user