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 })
|
||||
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
|
||||
@Column({ name: 'current_price', type: 'decimal', precision: 12, scale: 2 })
|
||||
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
|
||||
*/
|
||||
|
||||
export { SubscriptionPlansService } from './subscription-plans.service';
|
||||
export { SubscriptionsService } from './subscriptions.service';
|
||||
export { UsageTrackingService } from './usage-tracking.service';
|
||||
export { InvoicesService } from './invoices.service';
|
||||
export { SubscriptionPlansService } from './subscription-plans.service.js';
|
||||
export { SubscriptionsService } from './subscriptions.service.js';
|
||||
export { UsageTrackingService } from './usage-tracking.service.js';
|
||||
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