diff --git a/src/modules/billing-usage/__tests__/coupons.service.test.ts b/src/modules/billing-usage/__tests__/coupons.service.test.ts new file mode 100644 index 0000000..7631280 --- /dev/null +++ b/src/modules/billing-usage/__tests__/coupons.service.test.ts @@ -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 = {}) { + 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 = {}) { + 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) => 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); + }); + }); +}); diff --git a/src/modules/billing-usage/__tests__/plan-limits.service.test.ts b/src/modules/billing-usage/__tests__/plan-limits.service.test.ts new file mode 100644 index 0000000..4088dd6 --- /dev/null +++ b/src/modules/billing-usage/__tests__/plan-limits.service.test.ts @@ -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 = {}) { + 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 = {}) { + 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 = {}) { + 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 = {}) { + 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); + }); + }); +}); diff --git a/src/modules/billing-usage/__tests__/stripe-webhook.service.test.ts b/src/modules/billing-usage/__tests__/stripe-webhook.service.test.ts new file mode 100644 index 0000000..bd6b085 --- /dev/null +++ b/src/modules/billing-usage/__tests__/stripe-webhook.service.test.ts @@ -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 = {}) { + 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 = {}) { + 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 } + ); + }); + }); +}); diff --git a/src/modules/billing-usage/entities/tenant-subscription.entity.ts b/src/modules/billing-usage/entities/tenant-subscription.entity.ts index 5cdc50e..1973259 100644 --- a/src/modules/billing-usage/entities/tenant-subscription.entity.ts +++ b/src/modules/billing-usage/entities/tenant-subscription.entity.ts @@ -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; diff --git a/src/modules/billing-usage/middleware/plan-enforcement.middleware.ts b/src/modules/billing-usage/middleware/plan-enforcement.middleware.ts new file mode 100644 index 0000000..e7726e0 --- /dev/null +++ b/src/modules/billing-usage/middleware/plan-enforcement.middleware.ts @@ -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; + 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(); + + 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 { + 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 { + // 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 { + 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); + }, +}; diff --git a/src/modules/billing-usage/services/coupons.service.ts b/src/modules/billing-usage/services/coupons.service.ts new file mode 100644 index 0000000..027b1de --- /dev/null +++ b/src/modules/billing-usage/services/coupons.service.ts @@ -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; + private redemptionRepository: Repository; + private subscriptionRepository: Repository; + + 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 { + // 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 { + 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 { + return this.couponRepository.findOne({ + where: { code: code.toUpperCase() }, + }); + } + + /** + * Find coupon by ID + */ + async findById(id: string): Promise { + return this.couponRepository.findOne({ + where: { id }, + relations: ['redemptions'], + }); + } + + /** + * Update a coupon + */ + async update(id: string, dto: UpdateCouponDto): Promise { + 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 { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/src/modules/billing-usage/services/index.ts b/src/modules/billing-usage/services/index.ts index c0d4392..452a9dd 100644 --- a/src/modules/billing-usage/services/index.ts +++ b/src/modules/billing-usage/services/index.ts @@ -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'; diff --git a/src/modules/billing-usage/services/plan-limits.service.ts b/src/modules/billing-usage/services/plan-limits.service.ts new file mode 100644 index 0000000..c0e0a28 --- /dev/null +++ b/src/modules/billing-usage/services/plan-limits.service.ts @@ -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; + private planRepository: Repository; + private subscriptionRepository: Repository; + private usageRepository: Repository; + + 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 { + // 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 { + return this.limitRepository.find({ + where: { planId }, + order: { limitKey: 'ASC' }, + }); + } + + /** + * Find a specific limit by key + */ + async findByKey(planId: string, limitKey: string): Promise { + return this.limitRepository.findOne({ + where: { planId, limitKey }, + }); + } + + /** + * Update a plan limit + */ + async update(id: string, dto: UpdatePlanLimitDto): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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 = { + 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 { + 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; + } +} diff --git a/src/modules/billing-usage/services/stripe-webhook.service.ts b/src/modules/billing-usage/services/stripe-webhook.service.ts new file mode 100644 index 0000000..c8f24da --- /dev/null +++ b/src/modules/billing-usage/services/stripe-webhook.service.ts @@ -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; + previous_attributes?: Record; + }; + created: number; + livemode: boolean; +} + +export interface ProcessResult { + success: boolean; + eventId: string; + message: string; + error?: string; +} + +export class StripeWebhookService { + private eventRepository: Repository; + private subscriptionRepository: Repository; + + 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 { + 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): Promise { + 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): Promise { + 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): Promise { + 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): Promise { + 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): Promise { + 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): Promise { + 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): Promise { + 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): Promise { + 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 = { + 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 { + 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 { + return this.eventRepository.find({ + where: { + processed: false, + }, + order: { createdAt: 'ASC' }, + take: limit, + }); + } + + /** + * Get event by Stripe event ID + */ + async findByStripeEventId(stripeEventId: string): Promise { + return this.eventRepository.findOne({ + where: { stripeEventId }, + }); + } + + /** + * Get recent events + */ + async getRecentEvents(options?: { + limit?: number; + eventType?: string; + processed?: boolean; + }): Promise { + 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(); + } +}