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:
rckrdmrd 2026-01-18 08:03:57 -06:00
parent 0bdb2eed65
commit b194f59599
9 changed files with 3004 additions and 4 deletions

View 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);
});
});
});

View 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);
});
});
});

View File

@ -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 }
);
});
});
});

View File

@ -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;

View File

@ -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);
},
};

View 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,
};
}
}

View File

@ -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';

View 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;
}
}

View 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();
}
}