erp-transportistas-backend-v2/src/modules/billing-usage/__tests__/stripe-webhook.service.test.ts
Adrian Flores Cortes 95c6b58449 feat: Add base modules from erp-core following SIMCO-REUSE directive
Phase 0 - Base modules (100% copy):
- shared/ (errors, middleware, services, utils, types)
- auth, users, tenants (multi-tenancy)
- ai, audit, notifications, mcp, payment-terminals
- billing-usage, branches, companies, core

Phase 1 - Modules to adapt (70-95%):
- partners (for shippers/consignees)
- inventory (for refacciones)
- financial (for transport costing)

Phase 2 - Pattern modules (50-70%):
- ordenes-transporte (from sales)
- gestion-flota (from products)
- viajes (from projects)

Phase 3 - New transport-specific modules:
- tracking (GPS, events, alerts)
- tarifas-transporte (pricing, surcharges)
- combustible-gastos (fuel, tolls, expenses)
- carta-porte (CFDI complement 3.1)

Estimated token savings: ~65% (~10,675 lines)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 10:10:19 -06:00

598 lines
20 KiB
TypeScript

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