"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