template-saas/apps/backend/dist/modules/billing/__tests__/stripe.service.spec.js
rckrdmrd 50a821a415
Some checks failed
CI / Backend CI (push) Has been cancelled
CI / Frontend CI (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / CI Summary (push) Has been cancelled
[SIMCO-V38] feat: Actualizar a SIMCO v3.8.0
- HERENCIA-SIMCO.md actualizado con directivas v3.7 y v3.8
- Actualizaciones de configuracion

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

1091 lines
50 KiB
JavaScript

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const testing_1 = require("@nestjs/testing");
const typeorm_1 = require("@nestjs/typeorm");
const config_1 = require("@nestjs/config");
const common_1 = require("@nestjs/common");
const stripe_service_1 = require("../services/stripe.service");
const subscription_entity_1 = require("../entities/subscription.entity");
const invoice_entity_1 = require("../entities/invoice.entity");
const payment_method_entity_1 = require("../entities/payment-method.entity");
const stripe_webhook_dto_1 = require("../dto/stripe-webhook.dto");
jest.mock('stripe', () => {
const mockStripe = {
customers: {
create: jest.fn(),
retrieve: jest.fn(),
update: jest.fn(),
search: jest.fn(),
},
subscriptions: {
create: jest.fn(),
retrieve: jest.fn(),
update: jest.fn(),
cancel: jest.fn(),
},
checkout: {
sessions: {
create: jest.fn(),
},
},
billingPortal: {
sessions: {
create: jest.fn(),
},
},
paymentMethods: {
attach: jest.fn(),
detach: jest.fn(),
list: jest.fn(),
},
prices: {
list: jest.fn(),
retrieve: jest.fn(),
},
setupIntents: {
create: jest.fn(),
},
webhooks: {
constructEvent: jest.fn(),
},
};
return jest.fn(() => mockStripe);
});
describe('StripeService', () => {
let service;
let configService;
let subscriptionRepo;
let invoiceRepo;
let paymentMethodRepo;
let mockStripeInstance;
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
const mockCustomerId = 'cus_test123';
const mockSubscriptionId = 'sub_test123';
const mockPriceId = 'price_test123';
beforeEach(async () => {
const mockConfigService = {
get: jest.fn((key) => {
if (key === 'STRIPE_SECRET_KEY')
return 'sk_test_123';
if (key === 'STRIPE_WEBHOOK_SECRET')
return 'whsec_test123';
return null;
}),
};
const mockSubscriptionRepo = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
};
const mockInvoiceRepo = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
};
const mockPaymentMethodRepo = {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
};
const module = await testing_1.Test.createTestingModule({
providers: [
stripe_service_1.StripeService,
{ provide: config_1.ConfigService, useValue: mockConfigService },
{ provide: (0, typeorm_1.getRepositoryToken)(subscription_entity_1.Subscription), useValue: mockSubscriptionRepo },
{ provide: (0, typeorm_1.getRepositoryToken)(invoice_entity_1.Invoice), useValue: mockInvoiceRepo },
{ provide: (0, typeorm_1.getRepositoryToken)(payment_method_entity_1.PaymentMethod), useValue: mockPaymentMethodRepo },
],
}).compile();
service = module.get(stripe_service_1.StripeService);
configService = module.get(config_1.ConfigService);
subscriptionRepo = module.get((0, typeorm_1.getRepositoryToken)(subscription_entity_1.Subscription));
invoiceRepo = module.get((0, typeorm_1.getRepositoryToken)(invoice_entity_1.Invoice));
paymentMethodRepo = module.get((0, typeorm_1.getRepositoryToken)(payment_method_entity_1.PaymentMethod));
service.onModuleInit();
mockStripeInstance = service.stripe;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('onModuleInit', () => {
it('should initialize Stripe client when API key is configured', () => {
expect(mockStripeInstance).toBeDefined();
});
it('should warn when Stripe API key is not configured', () => {
const warnSpy = jest.spyOn(service['logger'], 'warn');
configService.get.mockReturnValue(undefined);
service.onModuleInit();
expect(warnSpy).toHaveBeenCalledWith('STRIPE_SECRET_KEY not configured - Stripe integration disabled');
});
});
describe('ensureStripeConfigured', () => {
it('should throw BadRequestException when Stripe is not configured', () => {
service.stripe = null;
expect(() => service.ensureStripeConfigured()).toThrow(common_1.BadRequestException);
});
});
describe('createCustomer', () => {
it('should create a Stripe customer successfully', async () => {
const mockCustomer = {
id: mockCustomerId,
email: 'test@example.com',
name: 'Test User',
metadata: { tenant_id: mockTenantId },
};
mockStripeInstance.customers.create.mockResolvedValue(mockCustomer);
const result = await service.createCustomer({
tenant_id: mockTenantId,
email: 'test@example.com',
name: 'Test User',
});
expect(result).toEqual(mockCustomer);
expect(mockStripeInstance.customers.create).toHaveBeenCalledWith({
email: 'test@example.com',
name: 'Test User',
metadata: {
tenant_id: mockTenantId,
},
});
});
it('should create customer with additional metadata', async () => {
const mockCustomer = {
id: mockCustomerId,
email: 'test@example.com',
metadata: { tenant_id: mockTenantId, company: 'Acme Inc' },
};
mockStripeInstance.customers.create.mockResolvedValue(mockCustomer);
await service.createCustomer({
tenant_id: mockTenantId,
email: 'test@example.com',
metadata: { company: 'Acme Inc' },
});
expect(mockStripeInstance.customers.create).toHaveBeenCalledWith({
email: 'test@example.com',
name: undefined,
metadata: {
tenant_id: mockTenantId,
company: 'Acme Inc',
},
});
});
});
describe('getCustomer', () => {
it('should retrieve a customer by ID', async () => {
const mockCustomer = { id: mockCustomerId, email: 'test@example.com' };
mockStripeInstance.customers.retrieve.mockResolvedValue(mockCustomer);
const result = await service.getCustomer(mockCustomerId);
expect(result).toEqual(mockCustomer);
});
it('should return null when customer not found', async () => {
mockStripeInstance.customers.retrieve.mockRejectedValue({
code: 'resource_missing',
});
const result = await service.getCustomer('invalid_id');
expect(result).toBeNull();
});
it('should throw error for non-resource-missing errors', async () => {
mockStripeInstance.customers.retrieve.mockRejectedValue(new Error('API Error'));
await expect(service.getCustomer('cus_123')).rejects.toThrow('API Error');
});
});
describe('findCustomerByTenantId', () => {
it('should find customer by tenant ID metadata', async () => {
const mockCustomer = {
id: mockCustomerId,
metadata: { tenant_id: mockTenantId },
};
mockStripeInstance.customers.search.mockResolvedValue({
data: [mockCustomer],
});
const result = await service.findCustomerByTenantId(mockTenantId);
expect(result).toEqual(mockCustomer);
expect(mockStripeInstance.customers.search).toHaveBeenCalledWith({
query: `metadata['tenant_id']:'${mockTenantId}'`,
});
});
it('should return null when no customer found', async () => {
mockStripeInstance.customers.search.mockResolvedValue({ data: [] });
const result = await service.findCustomerByTenantId('unknown_tenant');
expect(result).toBeNull();
});
});
describe('createSubscription', () => {
it('should create subscription without trial', async () => {
const mockSubscription = {
id: mockSubscriptionId,
customer: mockCustomerId,
items: { data: [{ price: mockPriceId }] },
status: 'active',
};
mockStripeInstance.subscriptions.create.mockResolvedValue(mockSubscription);
const result = await service.createSubscription({
customer_id: mockCustomerId,
price_id: mockPriceId,
});
expect(result).toEqual(mockSubscription);
expect(mockStripeInstance.subscriptions.create).toHaveBeenCalledWith({
customer: mockCustomerId,
items: [{ price: mockPriceId }],
payment_behavior: 'default_incomplete',
payment_settings: {
save_default_payment_method: 'on_subscription',
},
expand: ['latest_invoice.payment_intent'],
metadata: undefined,
});
});
it('should create subscription with trial period', async () => {
const mockSubscription = {
id: mockSubscriptionId,
customer: mockCustomerId,
status: 'trialing',
trial_end: Math.floor(Date.now() / 1000) + 14 * 24 * 60 * 60,
};
mockStripeInstance.subscriptions.create.mockResolvedValue(mockSubscription);
const result = await service.createSubscription({
customer_id: mockCustomerId,
price_id: mockPriceId,
trial_period_days: 14,
});
expect(result.status).toBe('trialing');
expect(mockStripeInstance.subscriptions.create).toHaveBeenCalledWith(expect.objectContaining({
trial_period_days: 14,
}));
});
it('should create subscription with custom metadata', async () => {
const mockSubscription = {
id: mockSubscriptionId,
metadata: { tenant_id: mockTenantId, plan: 'pro' },
};
mockStripeInstance.subscriptions.create.mockResolvedValue(mockSubscription);
await service.createSubscription({
customer_id: mockCustomerId,
price_id: mockPriceId,
metadata: { tenant_id: mockTenantId, plan: 'pro' },
});
expect(mockStripeInstance.subscriptions.create).toHaveBeenCalledWith(expect.objectContaining({
metadata: { tenant_id: mockTenantId, plan: 'pro' },
}));
});
});
describe('getStripeSubscription', () => {
it('should retrieve subscription by ID', async () => {
const mockSubscription = { id: mockSubscriptionId, status: 'active' };
mockStripeInstance.subscriptions.retrieve.mockResolvedValue(mockSubscription);
const result = await service.getStripeSubscription(mockSubscriptionId);
expect(result).toEqual(mockSubscription);
});
it('should return null when subscription not found', async () => {
mockStripeInstance.subscriptions.retrieve.mockRejectedValue({
code: 'resource_missing',
});
const result = await service.getStripeSubscription('invalid_sub');
expect(result).toBeNull();
});
});
describe('cancelStripeSubscription', () => {
it('should cancel subscription immediately', async () => {
const mockCancelled = {
id: mockSubscriptionId,
status: 'canceled',
canceled_at: Date.now() / 1000,
};
mockStripeInstance.subscriptions.cancel.mockResolvedValue(mockCancelled);
const result = await service.cancelStripeSubscription(mockSubscriptionId, {
immediately: true,
});
expect(result.status).toBe('canceled');
expect(mockStripeInstance.subscriptions.cancel).toHaveBeenCalledWith(mockSubscriptionId);
});
it('should schedule cancellation at period end', async () => {
const mockScheduled = {
id: mockSubscriptionId,
status: 'active',
cancel_at_period_end: true,
};
mockStripeInstance.subscriptions.update.mockResolvedValue(mockScheduled);
const result = await service.cancelStripeSubscription(mockSubscriptionId, {
immediately: false,
});
expect(result.cancel_at_period_end).toBe(true);
expect(mockStripeInstance.subscriptions.update).toHaveBeenCalledWith(mockSubscriptionId, { cancel_at_period_end: true });
});
it('should default to end-of-period cancellation', async () => {
mockStripeInstance.subscriptions.update.mockResolvedValue({
id: mockSubscriptionId,
cancel_at_period_end: true,
});
await service.cancelStripeSubscription(mockSubscriptionId);
expect(mockStripeInstance.subscriptions.update).toHaveBeenCalledWith(mockSubscriptionId, { cancel_at_period_end: true });
});
});
describe('updateStripeSubscription (upgrade/downgrade)', () => {
it('should upgrade subscription with proration', async () => {
const currentSubscription = {
id: mockSubscriptionId,
items: { data: [{ id: 'si_123', price: { id: 'price_basic' } }] },
};
const upgradedSubscription = {
id: mockSubscriptionId,
items: { data: [{ id: 'si_123', price: { id: 'price_pro' } }] },
};
mockStripeInstance.subscriptions.retrieve.mockResolvedValue(currentSubscription);
mockStripeInstance.subscriptions.update.mockResolvedValue(upgradedSubscription);
const result = await service.updateStripeSubscription(mockSubscriptionId, 'price_pro');
expect(mockStripeInstance.subscriptions.update).toHaveBeenCalledWith(mockSubscriptionId, {
items: [{ id: 'si_123', price: 'price_pro' }],
proration_behavior: 'create_prorations',
});
expect(result.items.data[0].price.id).toBe('price_pro');
});
it('should downgrade subscription with proration', async () => {
const currentSubscription = {
id: mockSubscriptionId,
items: { data: [{ id: 'si_123', price: { id: 'price_pro' } }] },
};
mockStripeInstance.subscriptions.retrieve.mockResolvedValue(currentSubscription);
mockStripeInstance.subscriptions.update.mockResolvedValue({
id: mockSubscriptionId,
items: { data: [{ id: 'si_123', price: { id: 'price_basic' } }] },
});
const result = await service.updateStripeSubscription(mockSubscriptionId, 'price_basic');
expect(mockStripeInstance.subscriptions.update).toHaveBeenCalledWith(mockSubscriptionId, expect.objectContaining({
proration_behavior: 'create_prorations',
}));
});
});
describe('createCheckoutSession', () => {
it('should create checkout session successfully', async () => {
const mockSession = {
id: 'cs_test123',
url: 'https://checkout.stripe.com/...',
};
mockStripeInstance.customers.search.mockResolvedValue({
data: [{ id: mockCustomerId }],
});
mockStripeInstance.checkout.sessions.create.mockResolvedValue(mockSession);
const result = await service.createCheckoutSession({
tenant_id: mockTenantId,
price_id: mockPriceId,
success_url: 'https://app.example.com/success',
cancel_url: 'https://app.example.com/cancel',
});
expect(result).toEqual(mockSession);
expect(mockStripeInstance.checkout.sessions.create).toHaveBeenCalledWith({
customer: mockCustomerId,
mode: 'subscription',
line_items: [{ price: mockPriceId, quantity: 1 }],
success_url: 'https://app.example.com/success',
cancel_url: 'https://app.example.com/cancel',
subscription_data: {
metadata: { tenant_id: mockTenantId },
},
});
});
it('should create checkout session with trial period', async () => {
mockStripeInstance.customers.search.mockResolvedValue({
data: [{ id: mockCustomerId }],
});
mockStripeInstance.checkout.sessions.create.mockResolvedValue({
id: 'cs_test123',
});
await service.createCheckoutSession({
tenant_id: mockTenantId,
price_id: mockPriceId,
success_url: 'https://app.example.com/success',
cancel_url: 'https://app.example.com/cancel',
trial_period_days: 14,
});
expect(mockStripeInstance.checkout.sessions.create).toHaveBeenCalledWith(expect.objectContaining({
subscription_data: expect.objectContaining({
trial_period_days: 14,
}),
}));
});
it('should throw NotFoundException when customer not found', async () => {
mockStripeInstance.customers.search.mockResolvedValue({ data: [] });
await expect(service.createCheckoutSession({
tenant_id: 'unknown_tenant',
price_id: mockPriceId,
success_url: 'https://app.example.com/success',
cancel_url: 'https://app.example.com/cancel',
})).rejects.toThrow(common_1.NotFoundException);
});
});
describe('createBillingPortalSession', () => {
it('should create billing portal session successfully', async () => {
const mockSession = {
id: 'bps_test123',
url: 'https://billing.stripe.com/...',
};
mockStripeInstance.customers.search.mockResolvedValue({
data: [{ id: mockCustomerId }],
});
mockStripeInstance.billingPortal.sessions.create.mockResolvedValue(mockSession);
const result = await service.createBillingPortalSession({
tenant_id: mockTenantId,
return_url: 'https://app.example.com/billing',
});
expect(result).toEqual(mockSession);
expect(mockStripeInstance.billingPortal.sessions.create).toHaveBeenCalledWith({
customer: mockCustomerId,
return_url: 'https://app.example.com/billing',
});
});
it('should throw NotFoundException when customer not found', async () => {
mockStripeInstance.customers.search.mockResolvedValue({ data: [] });
await expect(service.createBillingPortalSession({
tenant_id: 'unknown_tenant',
return_url: 'https://app.example.com/billing',
})).rejects.toThrow(common_1.NotFoundException);
});
});
describe('attachPaymentMethod', () => {
it('should attach payment method to customer', async () => {
const mockPaymentMethod = {
id: 'pm_test123',
customer: mockCustomerId,
type: 'card',
};
mockStripeInstance.paymentMethods.attach.mockResolvedValue(mockPaymentMethod);
const result = await service.attachPaymentMethod('pm_test123', mockCustomerId);
expect(result).toEqual(mockPaymentMethod);
expect(mockStripeInstance.paymentMethods.attach).toHaveBeenCalledWith('pm_test123', { customer: mockCustomerId });
});
});
describe('detachPaymentMethod', () => {
it('should detach payment method', async () => {
const mockPaymentMethod = {
id: 'pm_test123',
customer: null,
};
mockStripeInstance.paymentMethods.detach.mockResolvedValue(mockPaymentMethod);
const result = await service.detachPaymentMethod('pm_test123');
expect(result.customer).toBeNull();
});
});
describe('listPaymentMethods', () => {
it('should list customer payment methods', async () => {
const mockPaymentMethods = {
data: [
{ id: 'pm_1', type: 'card', card: { last4: '4242' } },
{ id: 'pm_2', type: 'card', card: { last4: '1234' } },
],
};
mockStripeInstance.paymentMethods.list.mockResolvedValue(mockPaymentMethods);
const result = await service.listPaymentMethods(mockCustomerId);
expect(result).toHaveLength(2);
expect(mockStripeInstance.paymentMethods.list).toHaveBeenCalledWith({
customer: mockCustomerId,
type: 'card',
});
});
});
describe('setDefaultPaymentMethod', () => {
it('should set default payment method for customer', async () => {
const mockCustomer = {
id: mockCustomerId,
invoice_settings: { default_payment_method: 'pm_test123' },
};
mockStripeInstance.customers.update.mockResolvedValue(mockCustomer);
const result = await service.setDefaultPaymentMethod(mockCustomerId, 'pm_test123');
expect(result.invoice_settings.default_payment_method).toBe('pm_test123');
expect(mockStripeInstance.customers.update).toHaveBeenCalledWith(mockCustomerId, {
invoice_settings: { default_payment_method: 'pm_test123' },
});
});
});
describe('constructWebhookEvent', () => {
it('should construct webhook event with valid signature', () => {
const mockEvent = {
id: 'evt_test123',
type: 'customer.subscription.updated',
data: { object: {} },
};
mockStripeInstance.webhooks.constructEvent.mockReturnValue(mockEvent);
const payload = Buffer.from(JSON.stringify(mockEvent));
const signature = 'test_signature';
const result = service.constructWebhookEvent(payload, signature);
expect(result).toEqual(mockEvent);
expect(mockStripeInstance.webhooks.constructEvent).toHaveBeenCalledWith(payload, signature, 'whsec_test123');
});
it('should throw BadRequestException when webhook secret not configured', () => {
configService.get.mockImplementation((key) => {
if (key === 'STRIPE_SECRET_KEY')
return 'sk_test_123';
if (key === 'STRIPE_WEBHOOK_SECRET')
return undefined;
return null;
});
const payload = Buffer.from('{}');
expect(() => service.constructWebhookEvent(payload, 'sig')).toThrow(common_1.BadRequestException);
});
it('should throw error for invalid signature', () => {
mockStripeInstance.webhooks.constructEvent.mockImplementation(() => {
throw new Error('Invalid signature');
});
const payload = Buffer.from('{}');
expect(() => service.constructWebhookEvent(payload, 'invalid_sig')).toThrow('Invalid signature');
});
});
describe('handleWebhookEvent', () => {
describe('customer.subscription.updated', () => {
it('should sync subscription on update event', async () => {
const stripeSubscription = {
id: mockSubscriptionId,
status: 'active',
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
metadata: { tenant_id: mockTenantId },
items: { data: [{ price: { id: mockPriceId, product: 'prod_123' } }] },
customer: mockCustomerId,
};
const event = {
id: 'evt_test123',
type: stripe_webhook_dto_1.StripeWebhookEventType.SUBSCRIPTION_UPDATED,
data: { object: stripeSubscription },
};
subscriptionRepo.findOne.mockResolvedValue(null);
subscriptionRepo.create.mockReturnValue({});
subscriptionRepo.save.mockResolvedValue({});
await service.handleWebhookEvent(event);
expect(subscriptionRepo.save).toHaveBeenCalled();
});
it('should update existing subscription', async () => {
const stripeSubscription = {
id: mockSubscriptionId,
status: 'active',
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
metadata: { tenant_id: mockTenantId },
items: { data: [{ price: { id: mockPriceId, product: 'prod_123' } }] },
customer: mockCustomerId,
};
const existingSubscription = {
id: 'local-sub-123',
tenant_id: mockTenantId,
external_subscription_id: mockSubscriptionId,
status: subscription_entity_1.SubscriptionStatus.TRIAL,
};
const event = {
id: 'evt_test123',
type: stripe_webhook_dto_1.StripeWebhookEventType.SUBSCRIPTION_UPDATED,
data: { object: stripeSubscription },
};
subscriptionRepo.findOne.mockResolvedValue(existingSubscription);
subscriptionRepo.save.mockResolvedValue({
...existingSubscription,
status: subscription_entity_1.SubscriptionStatus.ACTIVE,
});
await service.handleWebhookEvent(event);
expect(subscriptionRepo.save).toHaveBeenCalledWith(expect.objectContaining({
status: subscription_entity_1.SubscriptionStatus.ACTIVE,
}));
});
it('should handle subscription with trial_end', async () => {
const trialEnd = Math.floor(Date.now() / 1000) + 14 * 24 * 60 * 60;
const stripeSubscription = {
id: mockSubscriptionId,
status: 'trialing',
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
trial_end: trialEnd,
metadata: { tenant_id: mockTenantId },
items: { data: [{ price: { id: mockPriceId, product: 'prod_123' } }] },
customer: mockCustomerId,
};
const event = {
id: 'evt_test123',
type: stripe_webhook_dto_1.StripeWebhookEventType.SUBSCRIPTION_UPDATED,
data: { object: stripeSubscription },
};
subscriptionRepo.findOne.mockResolvedValue(null);
subscriptionRepo.create.mockReturnValue({});
subscriptionRepo.save.mockResolvedValue({});
await service.handleWebhookEvent(event);
expect(subscriptionRepo.save).toHaveBeenCalledWith(expect.objectContaining({
trial_end: new Date(trialEnd * 1000),
}));
});
it('should skip subscription without tenant_id metadata', async () => {
const stripeSubscription = {
id: mockSubscriptionId,
status: 'active',
metadata: {},
};
const event = {
id: 'evt_test123',
type: stripe_webhook_dto_1.StripeWebhookEventType.SUBSCRIPTION_UPDATED,
data: { object: stripeSubscription },
};
await service.handleWebhookEvent(event);
expect(subscriptionRepo.save).not.toHaveBeenCalled();
});
});
describe('customer.subscription.deleted', () => {
it('should mark subscription as cancelled', async () => {
const stripeSubscription = {
id: mockSubscriptionId,
status: 'canceled',
};
const existingSubscription = {
id: 'local-sub-123',
external_subscription_id: mockSubscriptionId,
status: subscription_entity_1.SubscriptionStatus.ACTIVE,
};
const event = {
id: 'evt_test123',
type: stripe_webhook_dto_1.StripeWebhookEventType.SUBSCRIPTION_DELETED,
data: { object: stripeSubscription },
};
subscriptionRepo.findOne.mockResolvedValue(existingSubscription);
subscriptionRepo.save.mockResolvedValue({});
await service.handleWebhookEvent(event);
expect(subscriptionRepo.save).toHaveBeenCalledWith(expect.objectContaining({
status: subscription_entity_1.SubscriptionStatus.CANCELLED,
cancelled_at: expect.any(Date),
}));
});
it('should handle deletion when subscription not found locally', async () => {
const stripeSubscription = {
id: 'unknown_sub_id',
status: 'canceled',
};
const event = {
id: 'evt_test123',
type: stripe_webhook_dto_1.StripeWebhookEventType.SUBSCRIPTION_DELETED,
data: { object: stripeSubscription },
};
subscriptionRepo.findOne.mockResolvedValue(null);
await service.handleWebhookEvent(event);
expect(subscriptionRepo.save).not.toHaveBeenCalled();
});
});
describe('invoice.paid', () => {
it('should create and mark invoice as paid', async () => {
const stripeInvoice = {
id: 'in_test123',
number: 'INV-001',
subtotal: 10000,
tax: 1600,
total: 11600,
due_date: Math.floor(Date.now() / 1000) + 15 * 24 * 60 * 60,
subscription: mockSubscriptionId,
subscription_details: { metadata: { tenant_id: mockTenantId } },
invoice_pdf: 'https://stripe.com/invoice.pdf',
};
const event = {
id: 'evt_test123',
type: stripe_webhook_dto_1.StripeWebhookEventType.INVOICE_PAID,
data: { object: stripeInvoice },
};
invoiceRepo.findOne.mockResolvedValue(null);
invoiceRepo.create.mockReturnValue({});
invoiceRepo.save.mockResolvedValue({});
await service.handleWebhookEvent(event);
expect(invoiceRepo.save).toHaveBeenCalledWith(expect.objectContaining({
status: invoice_entity_1.InvoiceStatus.PAID,
paid_at: expect.any(Date),
external_invoice_id: 'in_test123',
pdf_url: 'https://stripe.com/invoice.pdf',
}));
});
it('should update existing invoice when paid', async () => {
const stripeInvoice = {
id: 'in_test123',
number: 'INV-001',
subtotal: 10000,
total: 11600,
subscription_details: { metadata: { tenant_id: mockTenantId } },
invoice_pdf: 'https://stripe.com/invoice.pdf',
};
const existingInvoice = {
id: 'local-inv-123',
invoice_number: 'INV-001',
status: invoice_entity_1.InvoiceStatus.OPEN,
};
const event = {
id: 'evt_test123',
type: stripe_webhook_dto_1.StripeWebhookEventType.INVOICE_PAID,
data: { object: stripeInvoice },
};
invoiceRepo.findOne.mockResolvedValue(existingInvoice);
invoiceRepo.save.mockResolvedValue({});
await service.handleWebhookEvent(event);
expect(invoiceRepo.save).toHaveBeenCalledWith(expect.objectContaining({
status: invoice_entity_1.InvoiceStatus.PAID,
}));
});
it('should skip invoice without tenant_id', async () => {
const stripeInvoice = {
id: 'in_test123',
subscription_details: { metadata: {} },
};
const event = {
id: 'evt_test123',
type: stripe_webhook_dto_1.StripeWebhookEventType.INVOICE_PAID,
data: { object: stripeInvoice },
};
await service.handleWebhookEvent(event);
expect(invoiceRepo.save).not.toHaveBeenCalled();
});
});
describe('invoice.payment_failed', () => {
it('should mark subscription as past_due on payment failure', async () => {
const stripeInvoice = {
id: 'in_test123',
subscription_details: { metadata: { tenant_id: mockTenantId } },
};
const existingSubscription = {
id: 'local-sub-123',
tenant_id: mockTenantId,
status: subscription_entity_1.SubscriptionStatus.ACTIVE,
metadata: {},
};
const event = {
id: 'evt_test123',
type: stripe_webhook_dto_1.StripeWebhookEventType.INVOICE_PAYMENT_FAILED,
data: { object: stripeInvoice },
};
subscriptionRepo.findOne.mockResolvedValue(existingSubscription);
subscriptionRepo.save.mockResolvedValue({});
await service.handleWebhookEvent(event);
expect(subscriptionRepo.save).toHaveBeenCalledWith(expect.objectContaining({
status: subscription_entity_1.SubscriptionStatus.PAST_DUE,
metadata: expect.objectContaining({
payment_failed_at: expect.any(String),
failed_invoice_id: 'in_test123',
}),
}));
});
it('should skip when no tenant_id in invoice', async () => {
const stripeInvoice = {
id: 'in_test123',
subscription_details: {},
};
const event = {
id: 'evt_test123',
type: stripe_webhook_dto_1.StripeWebhookEventType.INVOICE_PAYMENT_FAILED,
data: { object: stripeInvoice },
};
await service.handleWebhookEvent(event);
expect(subscriptionRepo.save).not.toHaveBeenCalled();
});
it('should handle payment failure when subscription not found', async () => {
const stripeInvoice = {
id: 'in_test123',
subscription_details: { metadata: { tenant_id: mockTenantId } },
};
const event = {
id: 'evt_test123',
type: stripe_webhook_dto_1.StripeWebhookEventType.INVOICE_PAYMENT_FAILED,
data: { object: stripeInvoice },
};
subscriptionRepo.findOne.mockResolvedValue(null);
await service.handleWebhookEvent(event);
expect(subscriptionRepo.save).not.toHaveBeenCalled();
});
});
describe('payment_method.attached', () => {
it('should sync payment method when attached', async () => {
const stripePaymentMethod = {
id: 'pm_test123',
customer: mockCustomerId,
type: 'card',
card: {
brand: 'visa',
last4: '4242',
exp_month: 12,
exp_year: 2025,
},
};
const mockCustomer = {
id: mockCustomerId,
metadata: { tenant_id: mockTenantId },
};
const event = {
id: 'evt_test123',
type: stripe_webhook_dto_1.StripeWebhookEventType.PAYMENT_METHOD_ATTACHED,
data: { object: stripePaymentMethod },
};
const mockPaymentMethodObject = {
tenant_id: mockTenantId,
external_payment_method_id: 'pm_test123',
};
mockStripeInstance.customers.retrieve.mockResolvedValue(mockCustomer);
paymentMethodRepo.findOne.mockResolvedValue(null);
paymentMethodRepo.create.mockReturnValue(mockPaymentMethodObject);
paymentMethodRepo.save.mockResolvedValue({});
await service.handleWebhookEvent(event);
expect(paymentMethodRepo.create).toHaveBeenCalledWith({
tenant_id: mockTenantId,
external_payment_method_id: 'pm_test123',
});
expect(paymentMethodRepo.save).toHaveBeenCalled();
const savedObject = paymentMethodRepo.save.mock.calls[0][0];
expect(savedObject.card_brand).toBe('visa');
expect(savedObject.card_last_four).toBe('4242');
expect(savedObject.card_exp_month).toBe(12);
expect(savedObject.card_exp_year).toBe(2025);
expect(savedObject.payment_provider).toBe('stripe');
expect(savedObject.is_active).toBe(true);
});
it('should update existing payment method', async () => {
const stripePaymentMethod = {
id: 'pm_test123',
customer: mockCustomerId,
type: 'card',
card: {
brand: 'mastercard',
last4: '5555',
exp_month: 6,
exp_year: 2026,
},
};
const existingPaymentMethod = {
id: 'local-pm-123',
external_payment_method_id: 'pm_test123',
card_brand: 'visa',
};
const mockCustomer = {
id: mockCustomerId,
metadata: { tenant_id: mockTenantId },
};
const event = {
id: 'evt_test123',
type: stripe_webhook_dto_1.StripeWebhookEventType.PAYMENT_METHOD_ATTACHED,
data: { object: stripePaymentMethod },
};
mockStripeInstance.customers.retrieve.mockResolvedValue(mockCustomer);
paymentMethodRepo.findOne.mockResolvedValue(existingPaymentMethod);
paymentMethodRepo.save.mockResolvedValue({});
await service.handleWebhookEvent(event);
expect(paymentMethodRepo.save).toHaveBeenCalledWith(expect.objectContaining({
card_brand: 'mastercard',
card_last_four: '5555',
}));
});
it('should skip when no customer on payment method', async () => {
const stripePaymentMethod = {
id: 'pm_test123',
customer: null,
type: 'card',
};
const event = {
id: 'evt_test123',
type: stripe_webhook_dto_1.StripeWebhookEventType.PAYMENT_METHOD_ATTACHED,
data: { object: stripePaymentMethod },
};
await service.handleWebhookEvent(event);
expect(paymentMethodRepo.save).not.toHaveBeenCalled();
});
});
describe('payment_method.detached', () => {
it('should deactivate payment method when detached', async () => {
const stripePaymentMethod = {
id: 'pm_test123',
};
const existingPaymentMethod = {
id: 'local-pm-123',
external_payment_method_id: 'pm_test123',
is_active: true,
};
const event = {
id: 'evt_test123',
type: stripe_webhook_dto_1.StripeWebhookEventType.PAYMENT_METHOD_DETACHED,
data: { object: stripePaymentMethod },
};
paymentMethodRepo.findOne.mockResolvedValue(existingPaymentMethod);
paymentMethodRepo.save.mockResolvedValue({});
await service.handleWebhookEvent(event);
expect(paymentMethodRepo.save).toHaveBeenCalledWith(expect.objectContaining({
is_active: false,
}));
});
it('should handle detachment when payment method not found locally', async () => {
const stripePaymentMethod = {
id: 'unknown_pm_id',
};
const event = {
id: 'evt_test123',
type: stripe_webhook_dto_1.StripeWebhookEventType.PAYMENT_METHOD_DETACHED,
data: { object: stripePaymentMethod },
};
paymentMethodRepo.findOne.mockResolvedValue(null);
await service.handleWebhookEvent(event);
expect(paymentMethodRepo.save).not.toHaveBeenCalled();
});
});
describe('checkout.session.completed', () => {
it('should sync subscription on checkout completion', async () => {
const checkoutSession = {
id: 'cs_test123',
subscription: mockSubscriptionId,
metadata: { tenant_id: mockTenantId },
};
const stripeSubscription = {
id: mockSubscriptionId,
status: 'active',
current_period_start: Math.floor(Date.now() / 1000),
current_period_end: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
metadata: { tenant_id: mockTenantId },
items: { data: [{ price: { id: mockPriceId, product: 'prod_123' } }] },
customer: mockCustomerId,
};
const event = {
id: 'evt_test123',
type: stripe_webhook_dto_1.StripeWebhookEventType.CHECKOUT_SESSION_COMPLETED,
data: { object: checkoutSession },
};
mockStripeInstance.subscriptions.retrieve.mockResolvedValue(stripeSubscription);
subscriptionRepo.findOne.mockResolvedValue(null);
subscriptionRepo.create.mockReturnValue({});
subscriptionRepo.save.mockResolvedValue({});
await service.handleWebhookEvent(event);
expect(mockStripeInstance.subscriptions.retrieve).toHaveBeenCalledWith(mockSubscriptionId);
expect(subscriptionRepo.save).toHaveBeenCalled();
});
it('should skip when no subscription in checkout session', async () => {
const checkoutSession = {
id: 'cs_test123',
subscription: null,
metadata: { tenant_id: mockTenantId },
};
const event = {
id: 'evt_test123',
type: stripe_webhook_dto_1.StripeWebhookEventType.CHECKOUT_SESSION_COMPLETED,
data: { object: checkoutSession },
};
await service.handleWebhookEvent(event);
expect(subscriptionRepo.save).not.toHaveBeenCalled();
});
it('should skip when no tenant_id in checkout session', async () => {
const checkoutSession = {
id: 'cs_test123',
subscription: mockSubscriptionId,
metadata: {},
};
const event = {
id: 'evt_test123',
type: stripe_webhook_dto_1.StripeWebhookEventType.CHECKOUT_SESSION_COMPLETED,
data: { object: checkoutSession },
};
await service.handleWebhookEvent(event);
expect(subscriptionRepo.save).not.toHaveBeenCalled();
});
});
describe('unhandled events', () => {
it('should log unhandled event types', async () => {
const logSpy = jest.spyOn(service['logger'], 'log');
const event = {
id: 'evt_test123',
type: 'some.unknown.event',
data: { object: {} },
};
await service.handleWebhookEvent(event);
expect(logSpy).toHaveBeenCalledWith('Unhandled event type: some.unknown.event');
});
});
});
describe('listPrices', () => {
it('should list all active prices', async () => {
const mockPrices = {
data: [
{ id: 'price_basic', unit_amount: 999 },
{ id: 'price_pro', unit_amount: 2999 },
],
};
mockStripeInstance.prices.list.mockResolvedValue(mockPrices);
const result = await service.listPrices();
expect(result).toHaveLength(2);
expect(mockStripeInstance.prices.list).toHaveBeenCalledWith({
active: true,
expand: ['data.product'],
});
});
it('should filter prices by product ID', async () => {
mockStripeInstance.prices.list.mockResolvedValue({ data: [] });
await service.listPrices('prod_123');
expect(mockStripeInstance.prices.list).toHaveBeenCalledWith({
active: true,
expand: ['data.product'],
product: 'prod_123',
});
});
});
describe('getPrice', () => {
it('should retrieve price by ID', async () => {
const mockPrice = { id: mockPriceId, unit_amount: 2999 };
mockStripeInstance.prices.retrieve.mockResolvedValue(mockPrice);
const result = await service.getPrice(mockPriceId);
expect(result).toEqual(mockPrice);
expect(mockStripeInstance.prices.retrieve).toHaveBeenCalledWith(mockPriceId, {
expand: ['product'],
});
});
it('should return null when price not found', async () => {
mockStripeInstance.prices.retrieve.mockRejectedValue({
code: 'resource_missing',
});
const result = await service.getPrice('invalid_price');
expect(result).toBeNull();
});
});
describe('createSetupIntent', () => {
it('should create setup intent for customer', async () => {
const mockSetupIntent = {
id: 'seti_test123',
client_secret: 'seti_test123_secret',
};
mockStripeInstance.setupIntents.create.mockResolvedValue(mockSetupIntent);
const result = await service.createSetupIntent(mockCustomerId);
expect(result).toEqual(mockSetupIntent);
expect(mockStripeInstance.setupIntents.create).toHaveBeenCalledWith({
customer: mockCustomerId,
payment_method_types: ['card'],
});
});
});
describe('mapStripeStatus', () => {
it('should map trialing to TRIAL', () => {
const result = service.mapStripeStatus('trialing');
expect(result).toBe(subscription_entity_1.SubscriptionStatus.TRIAL);
});
it('should map active to ACTIVE', () => {
const result = service.mapStripeStatus('active');
expect(result).toBe(subscription_entity_1.SubscriptionStatus.ACTIVE);
});
it('should map past_due to PAST_DUE', () => {
const result = service.mapStripeStatus('past_due');
expect(result).toBe(subscription_entity_1.SubscriptionStatus.PAST_DUE);
});
it('should map canceled to CANCELLED', () => {
const result = service.mapStripeStatus('canceled');
expect(result).toBe(subscription_entity_1.SubscriptionStatus.CANCELLED);
});
it('should map unpaid to PAST_DUE', () => {
const result = service.mapStripeStatus('unpaid');
expect(result).toBe(subscription_entity_1.SubscriptionStatus.PAST_DUE);
});
it('should map incomplete to TRIAL', () => {
const result = service.mapStripeStatus('incomplete');
expect(result).toBe(subscription_entity_1.SubscriptionStatus.TRIAL);
});
it('should map incomplete_expired to EXPIRED', () => {
const result = service.mapStripeStatus('incomplete_expired');
expect(result).toBe(subscription_entity_1.SubscriptionStatus.EXPIRED);
});
it('should map paused to CANCELLED', () => {
const result = service.mapStripeStatus('paused');
expect(result).toBe(subscription_entity_1.SubscriptionStatus.CANCELLED);
});
it('should default to ACTIVE for unknown status', () => {
const result = service.mapStripeStatus('unknown_status');
expect(result).toBe(subscription_entity_1.SubscriptionStatus.ACTIVE);
});
});
});
//# sourceMappingURL=stripe.service.spec.js.map