From 03254451fac86b974eb45534f1316cc43f08c22f Mon Sep 17 00:00:00 2001 From: Xavier Abad <77491413+xabg2@users.noreply.github.com> Date: Sun, 11 Jan 2026 14:47:17 +0100 Subject: [PATCH] feat: improve invoice payment failed handler event --- src/server.ts | 2 +- src/services/objectStorage.service.ts | 31 +++++----- .../events/ObjectStorageWebhookHandler.ts | 15 ++--- .../invoices/InvoiceCompletedHandler.ts | 8 +-- .../events/invoices/InvoiceFailedHandler.ts | 47 +++++++++++++++ src/webhooks/index.ts | 19 +++--- src/webhooks/providers/bit2me/index.ts | 4 -- tests/src/helpers/services-factory.ts | 9 --- .../ObjectStorageWebhookHandler.test.ts | 20 +++---- .../invoices/InvoiceCompletedHandler.test.ts | 2 +- .../invoices/InvoiceFailedHandler.test.ts | 49 +++++++++++++++ .../src/webhooks/handleFundsCaptured.test.ts | 3 +- .../handleInvoicePaymentFailed.test.ts | 59 +++++++++++-------- .../handleSubscriptionCanceled.test.ts | 4 +- tests/src/webhooks/webhook.test.ts | 15 ++--- 15 files changed, 178 insertions(+), 109 deletions(-) create mode 100644 src/webhooks/events/invoices/InvoiceFailedHandler.ts create mode 100644 tests/src/webhooks/events/invoices/InvoiceFailedHandler.test.ts diff --git a/src/server.ts b/src/server.ts index 28719859..6b78e3e6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -77,7 +77,7 @@ const start = async (mongoTestClient?: MongoClient): Promise => usersService, licenseCodesRepository, }); - const objectStorageService = new ObjectStorageService(paymentService, envVariablesConfig, axios); + const objectStorageService = new ObjectStorageService(); const userFeaturesOverridesService = new UserFeaturesOverridesService(usersService, userFeatureOverridesRepository); const productsService = new ProductsService(tiersService, usersService, userFeaturesOverridesService); diff --git a/src/services/objectStorage.service.ts b/src/services/objectStorage.service.ts index f1517cde..50fd039b 100644 --- a/src/services/objectStorage.service.ts +++ b/src/services/objectStorage.service.ts @@ -1,7 +1,6 @@ -import { PaymentService } from './payment.service'; import { sign } from 'jsonwebtoken'; -import { Axios, AxiosRequestConfig } from 'axios'; -import { type AppConfig } from '../config'; +import axios, { AxiosRequestConfig } from 'axios'; +import config from '../config'; function signToken(duration: string, secret: string) { return sign({}, Buffer.from(secret, 'base64').toString('utf8'), { @@ -11,11 +10,7 @@ function signToken(duration: string, secret: string) { } export class ObjectStorageService { - constructor( - private readonly paymentService: PaymentService, - private readonly config: AppConfig, - private readonly axios: Axios, - ) {} + constructor() {} async initObjectStorageUser(payload: { email: string; customerId: string }) { const { email, customerId } = payload; @@ -24,7 +19,7 @@ export class ObjectStorageService { } async reactivateAccount(payload: { customerId: string }): Promise { - const jwt = signToken('5m', this.config.OBJECT_STORAGE_GATEWAY_SECRET); + const jwt = signToken('5m', config.OBJECT_STORAGE_GATEWAY_SECRET); const params: AxiosRequestConfig = { headers: { 'Content-Type': 'application/json', @@ -32,11 +27,11 @@ export class ObjectStorageService { }, }; - await this.axios.put(`${this.config.OBJECT_STORAGE_URL}/users/${payload.customerId}/reactivate`, {}, params); + await axios.put(`${config.OBJECT_STORAGE_URL}/users/${payload.customerId}/reactivate`, {}, params); } async suspendAccount(payload: { customerId: string }): Promise { - const jwt = signToken('5m', this.config.OBJECT_STORAGE_GATEWAY_SECRET); + const jwt = signToken('5m', config.OBJECT_STORAGE_GATEWAY_SECRET); const params: AxiosRequestConfig = { headers: { 'Content-Type': 'application/json', @@ -44,11 +39,11 @@ export class ObjectStorageService { }, }; - await this.axios.put(`${this.config.OBJECT_STORAGE_URL}/users/${payload.customerId}/deactivate`, {}, params); + await axios.put(`${config.OBJECT_STORAGE_URL}/users/${payload.customerId}/deactivate`, {}, params); } async deleteAccount(payload: { customerId: string }): Promise { - const jwt = signToken('5m', this.config.OBJECT_STORAGE_GATEWAY_SECRET); + const jwt = signToken('5m', config.OBJECT_STORAGE_GATEWAY_SECRET); const params: AxiosRequestConfig = { headers: { 'Content-Type': 'application/json', @@ -56,11 +51,11 @@ export class ObjectStorageService { }, }; - await this.axios.delete(`${this.config.OBJECT_STORAGE_URL}/users/${payload.customerId}`, params); + await axios.delete(`${config.OBJECT_STORAGE_URL}/users/${payload.customerId}`, params); } private async createUser(email: string, customerId: string): Promise { - const jwt = signToken('5m', this.config.OBJECT_STORAGE_GATEWAY_SECRET); + const jwt = signToken('5m', config.OBJECT_STORAGE_GATEWAY_SECRET); const params: AxiosRequestConfig = { headers: { 'Content-Type': 'application/json', @@ -68,8 +63,8 @@ export class ObjectStorageService { }, }; - await this.axios.post( - `${this.config.OBJECT_STORAGE_URL}/users`, + await axios.post( + `${config.OBJECT_STORAGE_URL}/users`, { email, customerId, @@ -78,3 +73,5 @@ export class ObjectStorageService { ); } } + +export const objectStorageService = new ObjectStorageService(); diff --git a/src/webhooks/events/ObjectStorageWebhookHandler.ts b/src/webhooks/events/ObjectStorageWebhookHandler.ts index 45e533d9..c6b4eda6 100644 --- a/src/webhooks/events/ObjectStorageWebhookHandler.ts +++ b/src/webhooks/events/ObjectStorageWebhookHandler.ts @@ -1,15 +1,10 @@ import Stripe from 'stripe'; -import { ObjectStorageService } from '../../services/objectStorage.service'; -import { PaymentService } from '../../services/payment.service'; +import { objectStorageService } from '../../services/objectStorage.service'; import { AxiosError, isAxiosError } from 'axios'; import Logger from '../../Logger'; +import { paymentAdapter } from '../../infrastructure/payment.adapter'; export class ObjectStorageWebhookHandler { - constructor( - private readonly objectStorageService: ObjectStorageService, - private readonly paymentService: PaymentService, - ) {} - /** * Determines if the given product is an object storage product * @param product The product object to check @@ -48,7 +43,7 @@ export class ObjectStorageWebhookHandler { return; } - const product = await this.paymentService.getProduct(price.product as string); + const product = await paymentAdapter.getProduct(price.product as string); if (!this.isObjectStorageProduct(product)) { Logger.info(`Invoice ${invoice.id} for product ${price.product as string} is not an object-storage product`); @@ -56,7 +51,7 @@ export class ObjectStorageWebhookHandler { } try { - await this.objectStorageService.reactivateAccount({ customerId: customer.id }); + await objectStorageService.reactivateAccount({ customerId: customer.id }); } catch (error) { if (isAxiosError(error)) { const axiosError = error as AxiosError; @@ -78,3 +73,5 @@ export class ObjectStorageWebhookHandler { ); } } + +export const objectStorageWebhookHandler = new ObjectStorageWebhookHandler(); diff --git a/src/webhooks/events/invoices/InvoiceCompletedHandler.ts b/src/webhooks/events/invoices/InvoiceCompletedHandler.ts index b689eff0..940063a5 100644 --- a/src/webhooks/events/invoices/InvoiceCompletedHandler.ts +++ b/src/webhooks/events/invoices/InvoiceCompletedHandler.ts @@ -4,7 +4,6 @@ import { FastifyBaseLogger } from 'fastify'; import { PaymentService } from '../../../services/payment.service'; import { PriceMetadata } from '../../../types/stripe'; import { User, UserType } from '../../../core/users/User'; -import { ObjectStorageWebhookHandler } from '../ObjectStorageWebhookHandler'; import { TierNotFoundError, TiersService } from '../../../services/tiers.service'; import { UserNotFoundError, CouponNotBeingTrackedError, UsersService } from '../../../services/users.service'; import { StorageService } from '../../../services/storage.service'; @@ -12,6 +11,7 @@ import { NotFoundError } from '../../../errors/Errors'; import CacheService from '../../../services/cache.service'; import { Service, Tier } from '../../../core/users/Tier'; import Logger from '../../../Logger'; +import { objectStorageWebhookHandler } from '../ObjectStorageWebhookHandler'; export interface InvoiceCompletedHandlerPayload { customer: Stripe.Customer; @@ -22,7 +22,6 @@ export interface InvoiceCompletedHandlerPayload { export class InvoiceCompletedHandler { private readonly logger: FastifyBaseLogger; private readonly determineLifetimeConditions: DetermineLifetimeConditions; - private readonly objectStorageWebhookHandler: ObjectStorageWebhookHandler; private readonly paymentService: PaymentService; private readonly storageService: StorageService; private readonly tiersService: TiersService; @@ -32,7 +31,6 @@ export class InvoiceCompletedHandler { constructor({ logger, determineLifetimeConditions, - objectStorageWebhookHandler, paymentService, storageService, tiersService, @@ -41,7 +39,6 @@ export class InvoiceCompletedHandler { }: { logger: FastifyBaseLogger; determineLifetimeConditions: DetermineLifetimeConditions; - objectStorageWebhookHandler: ObjectStorageWebhookHandler; paymentService: PaymentService; storageService: StorageService; tiersService: TiersService; @@ -50,7 +47,6 @@ export class InvoiceCompletedHandler { }) { this.logger = logger; this.determineLifetimeConditions = determineLifetimeConditions; - this.objectStorageWebhookHandler = objectStorageWebhookHandler; this.paymentService = paymentService; this.storageService = storageService; this.tiersService = tiersService; @@ -94,7 +90,7 @@ export class InvoiceCompletedHandler { if (isObjStoragePlan) { Logger.info(`Invoice ${invoiceId} is for object storage, reactivating account if needed...`); - return this.objectStorageWebhookHandler.reactivateObjectStorageAccount(customer, invoice); + return objectStorageWebhookHandler.reactivateObjectStorageAccount(customer, invoice); } const tierBillingType = isLifetimePlan ? 'lifetime' : 'subscription'; diff --git a/src/webhooks/events/invoices/InvoiceFailedHandler.ts b/src/webhooks/events/invoices/InvoiceFailedHandler.ts new file mode 100644 index 00000000..34003fd7 --- /dev/null +++ b/src/webhooks/events/invoices/InvoiceFailedHandler.ts @@ -0,0 +1,47 @@ +import Stripe from 'stripe'; +import { paymentAdapter } from '../../../infrastructure/payment.adapter'; +import { objectStorageService } from '../../../services/objectStorage.service'; +import { UsersService } from '../../../services/users.service'; +import Logger from '../../../Logger'; + +export class InvoiceFailedHandler { + constructor(private readonly usersService: UsersService) {} + async run(invoice: Stripe.Invoice) { + const customerId = invoice.customer as string; + const objectStorageLineItem = await this.findObjectStorageProduct(invoice); + + if (objectStorageLineItem) { + Logger.info( + `Invoice ${invoice.id} for product ${objectStorageLineItem.price?.product as string} is an object-storage product`, + ); + return objectStorageService.suspendAccount({ customerId }); + } + + const user = await this.usersService.findUserByCustomerID(customerId).catch(() => undefined); + if (user) { + Logger.info(`Drive payment failure notification sent for customer ${customerId} (user UUID: ${user.uuid})`); + return this.usersService.notifyFailedPayment(user.uuid); + } + } + + async findObjectStorageProduct(invoice: Stripe.Invoice): Promise { + for (const line of invoice.lines.data) { + const price = line.price; + if (!price?.product) continue; + const productId = typeof price.product === 'string' ? price.product : price.product.id; + + const product = await paymentAdapter.getProduct(productId); + if (this.isObjectStorageProduct(product)) return line; + } + + return undefined; + } + + isObjectStorageProduct(product: Stripe.Product | Stripe.DeletedProduct): product is Stripe.Product { + return ( + (product as Stripe.Product).metadata && + !!(product as Stripe.Product).metadata.type && + (product as Stripe.Product).metadata.type === 'object-storage' + ); + } +} diff --git a/src/webhooks/index.ts b/src/webhooks/index.ts index 4a832e2c..c9508ebd 100644 --- a/src/webhooks/index.ts +++ b/src/webhooks/index.ts @@ -9,16 +9,15 @@ import { PaymentService } from '../services/payment.service'; import CacheService from '../services/cache.service'; import handleLifetimeRefunded from './handleLifetimeRefunded'; import { ObjectStorageService } from '../services/objectStorage.service'; -import handleInvoicePaymentFailed from './handleInvoicePaymentFailed'; import { handleDisputeResult } from './handleDisputeResult'; import handleSetupIntentSucceeded from './handleSetupIntentSucceded'; import { TiersService } from '../services/tiers.service'; import handleFundsCaptured from './handleFundsCaptured'; import { DetermineLifetimeConditions } from '../core/users/DetermineLifetimeConditions'; -import { ObjectStorageWebhookHandler } from './events/ObjectStorageWebhookHandler'; import { InvoiceCompletedHandler } from './events/invoices/InvoiceCompletedHandler'; import { BadRequestError } from '../errors/Errors'; import Logger from '../Logger'; +import { InvoiceFailedHandler } from './events/invoices/InvoiceFailedHandler'; export default function ( stripe: Stripe, @@ -53,15 +52,13 @@ export default function ( Logger.info(`Stripe event received: ${event.type}, id: ${event.id}`); switch (event.type) { - case 'invoice.payment_failed': - await handleInvoicePaymentFailed( - event.data.object, - objectStorageService, - paymentService, - usersService, - fastify.log, - ); + case 'invoice.payment_failed': { + const invoice = event.data.object; + + const invoiceFailedHandler = new InvoiceFailedHandler(usersService); + await invoiceFailedHandler.run(invoice); break; + } case 'payment_intent.amount_capturable_updated': await handleFundsCaptured(event.data.object, paymentService, objectStorageService, stripe, fastify.log); @@ -123,11 +120,9 @@ export default function ( } const determineLifetimeConditions = new DetermineLifetimeConditions(paymentService, tiersService); - const objectStorageWebhookHandler = new ObjectStorageWebhookHandler(objectStorageService, paymentService); const handler = new InvoiceCompletedHandler({ logger: fastify.log, determineLifetimeConditions, - objectStorageWebhookHandler, paymentService, storageService, tiersService, diff --git a/src/webhooks/providers/bit2me/index.ts b/src/webhooks/providers/bit2me/index.ts index aabba0b5..57831499 100644 --- a/src/webhooks/providers/bit2me/index.ts +++ b/src/webhooks/providers/bit2me/index.ts @@ -11,7 +11,6 @@ import { TiersService } from '../../../services/tiers.service'; import { BadRequestError } from '../../../errors/Errors'; import { InvoiceCompletedHandler } from '../../events/invoices/InvoiceCompletedHandler'; import { DetermineLifetimeConditions } from '../../../core/users/DetermineLifetimeConditions'; -import { ObjectStorageWebhookHandler } from '../../events/ObjectStorageWebhookHandler'; import Logger from '../../../Logger'; export interface CryptoWebhookDependencies { @@ -106,12 +105,9 @@ export default function ({ const determineLifetimeConditions = new DetermineLifetimeConditions(paymentService, tiersService); - const objectStorageWebhookHandler = new ObjectStorageWebhookHandler(objectStorageService, paymentService); - const handler = new InvoiceCompletedHandler({ logger: fastify.log, determineLifetimeConditions, - objectStorageWebhookHandler, paymentService, cacheService, tiersService, diff --git a/tests/src/helpers/services-factory.ts b/tests/src/helpers/services-factory.ts index f8193191..ad22952a 100644 --- a/tests/src/helpers/services-factory.ts +++ b/tests/src/helpers/services-factory.ts @@ -5,7 +5,6 @@ import { PaymentService } from '../../../src/services/payment.service'; import { UsersService } from '../../../src/services/users.service'; import { StorageService } from '../../../src/services/storage.service'; import { Bit2MeService } from '../../../src/services/bit2me.service'; -import { ObjectStorageService } from '../../../src/services/objectStorage.service'; import { ProductsService } from '../../../src/services/products.service'; import { LicenseCodesService } from '../../../src/services/licenseCodes.service'; import CacheService from '../../../src/services/cache.service'; @@ -20,7 +19,6 @@ import { UsersCouponsRepository } from '../../../src/core/coupons/UsersCouponsRe import testFactory from '../utils/factory'; import config from '../../../src/config'; import { DetermineLifetimeConditions } from '../../../src/core/users/DetermineLifetimeConditions'; -import { ObjectStorageWebhookHandler } from '../../../src/webhooks/events/ObjectStorageWebhookHandler'; import { InvoiceCompletedHandler } from '../../../src/webhooks/events/invoices/InvoiceCompletedHandler'; import { getLogger } from '../fixtures'; import { UserFeatureOverridesRepository } from '../../../src/core/users/MongoDBUserFeatureOverridesRepository'; @@ -33,12 +31,10 @@ export interface TestServices { usersService: UsersService; storageService: StorageService; bit2MeService: Bit2MeService; - objectStorageService: ObjectStorageService; productsService: ProductsService; licenseCodesService: LicenseCodesService; cacheService: CacheService; determineLifetimeConditions: DetermineLifetimeConditions; - objectStorageWebhookHandler: ObjectStorageWebhookHandler; invoiceCompletedHandler: InvoiceCompletedHandler; userFeaturesOverridesService: UserFeaturesOverridesService; } @@ -101,13 +97,10 @@ export const createTestServices = (overrides: TestServiceOverrides = {}): TestSe usersService, licenseCodesRepository: repositories.licenseCodesRepository, }); - const objectStorageService = new ObjectStorageService(paymentService, config, axios); const determineLifetimeConditions = new DetermineLifetimeConditions(paymentService, tiersService); - const objectStorageWebhookHandler = new ObjectStorageWebhookHandler(objectStorageService, paymentService); const invoiceCompletedHandler = new InvoiceCompletedHandler({ logger: getLogger(), determineLifetimeConditions, - objectStorageWebhookHandler, paymentService, storageService, tiersService, @@ -127,12 +120,10 @@ export const createTestServices = (overrides: TestServiceOverrides = {}): TestSe usersService, storageService, bit2MeService, - objectStorageService, productsService, licenseCodesService, cacheService, determineLifetimeConditions, - objectStorageWebhookHandler, invoiceCompletedHandler, userFeaturesOverridesService, ...repositories, diff --git a/tests/src/webhooks/events/ObjectStorageWebhookHandler.test.ts b/tests/src/webhooks/events/ObjectStorageWebhookHandler.test.ts index d6e43b47..b088eb99 100644 --- a/tests/src/webhooks/events/ObjectStorageWebhookHandler.test.ts +++ b/tests/src/webhooks/events/ObjectStorageWebhookHandler.test.ts @@ -2,9 +2,9 @@ import Stripe from 'stripe'; import axios from 'axios'; import { getCustomer, getInvoice, getProduct } from '../../fixtures'; import Logger from '../../../../src/Logger'; -import { createTestServices } from '../../helpers/services-factory'; - -const { objectStorageWebhookHandler, paymentService, objectStorageService } = createTestServices(); +import { objectStorageWebhookHandler } from '../../../../src/webhooks/events/ObjectStorageWebhookHandler'; +import { objectStorageService } from '../../../../src/services/objectStorage.service'; +import { paymentAdapter } from '../../../../src/infrastructure/payment.adapter'; beforeEach(() => { jest.clearAllMocks(); @@ -70,7 +70,7 @@ describe('Object Storage Webhook Handler', () => { ], }, }); - jest.spyOn(paymentService, 'getProduct').mockResolvedValue(mockedProduct as Stripe.Response); + jest.spyOn(paymentAdapter, 'getProduct').mockResolvedValue(mockedProduct as Stripe.Response); const objectStorageServiceSpy = jest.spyOn(objectStorageService, 'reactivateAccount').mockResolvedValue(); await objectStorageWebhookHandler.reactivateObjectStorageAccount(mockedCustomer, mockedInvoice); @@ -103,7 +103,7 @@ describe('Object Storage Webhook Handler', () => { ], }, }); - jest.spyOn(paymentService, 'getProduct').mockResolvedValue(mockedProduct as Stripe.Response); + jest.spyOn(paymentAdapter, 'getProduct').mockResolvedValue(mockedProduct as Stripe.Response); const objectStorageServiceSpy = jest.spyOn(objectStorageService, 'reactivateAccount').mockResolvedValue(); const loggerSpy = jest.spyOn(Logger, 'info'); @@ -137,7 +137,7 @@ describe('Object Storage Webhook Handler', () => { ], }, }); - jest.spyOn(paymentService, 'getProduct').mockResolvedValue(mockedProduct as Stripe.Response); + jest.spyOn(paymentAdapter, 'getProduct').mockResolvedValue(mockedProduct as Stripe.Response); const objectStorageServiceSpy = jest.spyOn(objectStorageService, 'reactivateAccount').mockResolvedValue(); const loggerSpy = jest.spyOn(Logger, 'info'); @@ -170,7 +170,7 @@ describe('Object Storage Webhook Handler', () => { ], }, }); - jest.spyOn(paymentService, 'getProduct').mockResolvedValue(mockedProduct as Stripe.Response); + jest.spyOn(paymentAdapter, 'getProduct').mockResolvedValue(mockedProduct as Stripe.Response); const objectStorageServiceSpy = jest.spyOn(objectStorageService, 'reactivateAccount').mockResolvedValue(); const loggerSpy = jest.spyOn(Logger, 'info'); @@ -206,7 +206,7 @@ describe('Object Storage Webhook Handler', () => { }, }); - jest.spyOn(paymentService, 'getProduct').mockResolvedValue(mockedProduct as Stripe.Response); + jest.spyOn(paymentAdapter, 'getProduct').mockResolvedValue(mockedProduct as Stripe.Response); jest.spyOn(objectStorageService, 'reactivateAccount').mockRejectedValue(new Error('Reactivation failed')); await expect( @@ -235,7 +235,7 @@ describe('Object Storage Webhook Handler', () => { }, }); - jest.spyOn(paymentService, 'getProduct').mockResolvedValue(mockedProduct as Stripe.Response); + jest.spyOn(paymentAdapter, 'getProduct').mockResolvedValue(mockedProduct as Stripe.Response); const axiosError = new Error('Not Found') as any; axiosError.response = { status: 404 }; @@ -276,7 +276,7 @@ describe('Object Storage Webhook Handler', () => { }, }); - jest.spyOn(paymentService, 'getProduct').mockResolvedValue(mockedProduct as Stripe.Response); + jest.spyOn(paymentAdapter, 'getProduct').mockResolvedValue(mockedProduct as Stripe.Response); const axiosError = new Error('Internal Server Error') as any; axiosError.response = { status: 500 }; diff --git a/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts b/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts index 7d60cc75..3c8761fe 100644 --- a/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts +++ b/tests/src/webhooks/events/invoices/InvoiceCompletedHandler.test.ts @@ -8,6 +8,7 @@ import { NotFoundError } from '../../../../../src/errors/Errors'; import Logger from '../../../../../src/Logger'; import { Service } from '../../../../../src/core/users/Tier'; import { createTestServices } from '../../../helpers/services-factory'; +import { objectStorageWebhookHandler } from '../../../../../src/webhooks/events/ObjectStorageWebhookHandler'; const { invoiceCompletedHandler, @@ -15,7 +16,6 @@ const { tiersService, usersService, determineLifetimeConditions, - objectStorageWebhookHandler, cacheService, storageService, } = createTestServices(); diff --git a/tests/src/webhooks/events/invoices/InvoiceFailedHandler.test.ts b/tests/src/webhooks/events/invoices/InvoiceFailedHandler.test.ts new file mode 100644 index 00000000..b4b05b5c --- /dev/null +++ b/tests/src/webhooks/events/invoices/InvoiceFailedHandler.test.ts @@ -0,0 +1,49 @@ +import { objectStorageService } from '../../../../../src/services/objectStorage.service'; +import { InvoiceFailedHandler } from '../../../../../src/webhooks/events/invoices/InvoiceFailedHandler'; +import { getInvoice, getUser } from '../../../fixtures'; +import { createTestServices } from '../../../helpers/services-factory'; + +const { usersService } = createTestServices(); + +describe('Invoice Payment Failed Handler', () => { + let invoiceFailedHandler: InvoiceFailedHandler; + + beforeEach(() => { + invoiceFailedHandler = new InvoiceFailedHandler(usersService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('When the failed invoice is an object storage invoice, then the object storage account is suspended', async () => { + const mockedInvoice = getInvoice({ status: 'paid' }); + + const findObjectStorageProductSpy = jest + .spyOn(invoiceFailedHandler, 'findObjectStorageProduct') + .mockResolvedValue(mockedInvoice.lines.data[0]); + const suspendAccountSpy = jest.spyOn(objectStorageService, 'suspendAccount').mockResolvedValue(); + + await invoiceFailedHandler.run(mockedInvoice); + + expect(findObjectStorageProductSpy).toHaveBeenCalledWith(mockedInvoice); + expect(suspendAccountSpy).toHaveBeenCalledWith({ customerId: mockedInvoice.customer as string }); + }); + + test('When the failed invoice is not an object storage invoice, then the user is notified of the payment failure', async () => { + const mockedInvoice = getInvoice({ status: 'paid' }); + const mockedUser = getUser(); + + const findObjectStorageProductSpy = jest + .spyOn(invoiceFailedHandler, 'findObjectStorageProduct') + .mockResolvedValue(undefined); + const getUserByCustomerIDSpy = jest.spyOn(usersService, 'findUserByCustomerID').mockResolvedValue(mockedUser); + const notifyFailedPaymentSpy = jest.spyOn(usersService, 'notifyFailedPayment').mockResolvedValue(); + + await invoiceFailedHandler.run(mockedInvoice); + + expect(findObjectStorageProductSpy).toHaveBeenCalledWith(mockedInvoice); + expect(getUserByCustomerIDSpy).toHaveBeenCalledWith(mockedInvoice.customer); + expect(notifyFailedPaymentSpy).toHaveBeenCalledWith(mockedUser.uuid); + }); +}); diff --git a/tests/src/webhooks/handleFundsCaptured.test.ts b/tests/src/webhooks/handleFundsCaptured.test.ts index e5963bf8..3ecb5fe5 100644 --- a/tests/src/webhooks/handleFundsCaptured.test.ts +++ b/tests/src/webhooks/handleFundsCaptured.test.ts @@ -5,6 +5,7 @@ import handleFundsCaptured from '../../../src/webhooks/handleFundsCaptured'; import { BadRequestError, ConflictError, GoneError, InternalServerError } from '../../../src/errors/Errors'; import { UserSubscription, UserType } from '../../../src/core/users/User'; import { createTestServices } from '../helpers/services-factory'; +import { objectStorageService } from '../../../src/services/objectStorage.service'; const logger: jest.Mocked = getLogger(); @@ -13,7 +14,7 @@ const stripeMock = { cancel: jest.fn(), }, }; -const { paymentService, objectStorageService, stripe } = createTestServices({ +const { paymentService, stripe } = createTestServices({ stripe: stripeMock, }); diff --git a/tests/src/webhooks/handleInvoicePaymentFailed.test.ts b/tests/src/webhooks/handleInvoicePaymentFailed.test.ts index 550bd2ce..11131ea6 100644 --- a/tests/src/webhooks/handleInvoicePaymentFailed.test.ts +++ b/tests/src/webhooks/handleInvoicePaymentFailed.test.ts @@ -2,10 +2,11 @@ import { FastifyBaseLogger } from 'fastify'; import { getCustomer, getInvoice, getLogger, getProduct } from '../fixtures'; import handleInvoicePaymentFailed from '../../../src/webhooks/handleInvoicePaymentFailed'; import { createTestServices } from '../helpers/services-factory'; +import { objectStorageService } from '../../../src/services/objectStorage.service'; const logger: jest.Mocked = getLogger(); -const { paymentService, usersService, objectStorageService } = createTestServices(); +const { paymentService, usersService } = createTestServices(); beforeEach(() => { jest.clearAllMocks(); @@ -15,23 +16,29 @@ beforeEach(() => { describe('Handle Invoice Payment Failed', () => { describe('When processing valid payment failure', () => { it('When payment fails for object storage invoice, then should only suspend account without Drive notification', async () => { - const customerId = 'cus_test123'; - const mockedCustomer = getCustomer({ id: customerId, email: 'test@internxt.com' }); - const mockedInvoice = getInvoice({ customer: customerId }); - const mockedProduct = getProduct({ params: { metadata: { type: 'object-storage' } } }); - - const getCustomerSpy = jest.spyOn(paymentService, 'getCustomer').mockResolvedValue(mockedCustomer as any); - const getProductSpy = jest.spyOn(paymentService, 'getProduct').mockResolvedValue(mockedProduct as any); - const findUserByCustomerIDSpy = jest.spyOn(usersService, 'findUserByCustomerID'); - const notifyFailedPaymentSpy = jest.spyOn(usersService, 'notifyFailedPayment'); - const suspendAccountSpy = jest.spyOn(objectStorageService, 'suspendAccount').mockResolvedValue(); - - await handleInvoicePaymentFailed(mockedInvoice as any, objectStorageService, paymentService, usersService, logger); - - expect(getCustomerSpy).toHaveBeenCalledWith(customerId); - expect(findUserByCustomerIDSpy).not.toHaveBeenCalled(); - expect(notifyFailedPaymentSpy).not.toHaveBeenCalled(); - expect(suspendAccountSpy).toHaveBeenCalledWith({ customerId }); + const customerId = 'cus_test123'; + const mockedCustomer = getCustomer({ id: customerId, email: 'test@internxt.com' }); + const mockedInvoice = getInvoice({ customer: customerId }); + const mockedProduct = getProduct({ params: { metadata: { type: 'object-storage' } } }); + + const getCustomerSpy = jest.spyOn(paymentService, 'getCustomer').mockResolvedValue(mockedCustomer as any); + jest.spyOn(paymentService, 'getProduct').mockResolvedValue(mockedProduct as any); + const findUserByCustomerIDSpy = jest.spyOn(usersService, 'findUserByCustomerID'); + const notifyFailedPaymentSpy = jest.spyOn(usersService, 'notifyFailedPayment'); + const suspendAccountSpy = jest.spyOn(objectStorageService, 'suspendAccount').mockResolvedValue(); + + await handleInvoicePaymentFailed( + mockedInvoice as any, + objectStorageService, + paymentService, + usersService, + logger, + ); + + expect(getCustomerSpy).toHaveBeenCalledWith(customerId); + expect(findUserByCustomerIDSpy).not.toHaveBeenCalled(); + expect(notifyFailedPaymentSpy).not.toHaveBeenCalled(); + expect(suspendAccountSpy).toHaveBeenCalledWith({ customerId }); }); }); @@ -48,7 +55,7 @@ describe('Handle Invoice Payment Failed', () => { jest.spyOn(usersService, 'notifyFailedPayment').mockRejectedValue(new Error('Gateway error')); await expect( - handleInvoicePaymentFailed(mockedInvoice as any, objectStorageService, paymentService, usersService, logger) + handleInvoicePaymentFailed(mockedInvoice as any, objectStorageService, paymentService, usersService, logger), ).resolves.toBeUndefined(); }); @@ -69,7 +76,7 @@ describe('Handle Invoice Payment Failed', () => { await handleInvoicePaymentFailed(mockedInvoice as any, objectStorageService, paymentService, usersService, logger); expect(loggerErrorSpy).toHaveBeenCalledWith( - `Failed to send payment notification for customer ${customerId}. Error: ${errorMessage}` + `Failed to send payment notification for customer ${customerId}. Error: ${errorMessage}`, ); }); @@ -90,7 +97,7 @@ describe('Handle Invoice Payment Failed', () => { await handleInvoicePaymentFailed(mockedInvoice as any, objectStorageService, paymentService, usersService, logger); expect(loggerErrorSpy).toHaveBeenCalledWith( - `Failed to send payment notification for customer ${customerId}. Error: ${String(nonErrorObject)}` + `Failed to send payment notification for customer ${customerId}. Error: ${String(nonErrorObject)}`, ); }); @@ -109,7 +116,7 @@ describe('Handle Invoice Payment Failed', () => { await handleInvoicePaymentFailed(mockedInvoice as any, objectStorageService, paymentService, usersService, logger); expect(loggerErrorSpy).toHaveBeenCalledWith( - `Failed to send payment notification for customer ${customerId}. Error: ${errorMessage}` + `Failed to send payment notification for customer ${customerId}. Error: ${errorMessage}`, ); }); @@ -117,7 +124,7 @@ describe('Handle Invoice Payment Failed', () => { const mockedInvoice = getInvoice({ customer: null }); await expect( - handleInvoicePaymentFailed(mockedInvoice as any, objectStorageService, paymentService, usersService, logger) + handleInvoicePaymentFailed(mockedInvoice as any, objectStorageService, paymentService, usersService, logger), ).rejects.toThrow('No customer found for this payment'); }); @@ -156,7 +163,7 @@ describe('Handle Invoice Payment Failed', () => { await handleInvoicePaymentFailed(mockedInvoice as any, objectStorageService, paymentService, usersService, logger); expect(loggerInfoSpy).toHaveBeenCalledWith( - `Drive payment failure notification sent for customer ${customerId} (user UUID: ${mockedUser.uuid})` + `Drive payment failure notification sent for customer ${customerId} (user UUID: ${mockedUser.uuid})`, ); }); @@ -174,7 +181,7 @@ describe('Handle Invoice Payment Failed', () => { await handleInvoicePaymentFailed(mockedInvoice as any, objectStorageService, paymentService, usersService, logger); expect(loggerWarnSpy).toHaveBeenCalledWith( - `User not found for customer ${customerId}. Skipping failed payment notification.` + `User not found for customer ${customerId}. Skipping failed payment notification.`, ); }); @@ -196,4 +203,4 @@ describe('Handle Invoice Payment Failed', () => { expect(notifyFailedPaymentSpy).toHaveBeenCalledWith('test-uuid-123'); expect(suspendAccountSpy).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); diff --git a/tests/src/webhooks/handleSubscriptionCanceled.test.ts b/tests/src/webhooks/handleSubscriptionCanceled.test.ts index f9c187e2..de98f858 100644 --- a/tests/src/webhooks/handleSubscriptionCanceled.test.ts +++ b/tests/src/webhooks/handleSubscriptionCanceled.test.ts @@ -6,13 +6,13 @@ import handleSubscriptionCanceled from '../../../src/webhooks/handleSubscription import { handleCancelPlan } from '../../../src/webhooks/utils/handleCancelPlan'; import { FREE_PLAN_BYTES_SPACE } from '../../../src/constants'; import { createTestServices } from '../helpers/services-factory'; +import { objectStorageService } from '../../../src/services/objectStorage.service'; jest.mock('../../../src/webhooks/utils/handleCancelPlan'); const logger: jest.Mocked = getLogger(); -const { paymentService, usersService, storageService, cacheService, objectStorageService, tiersService } = - createTestServices(); +const { paymentService, usersService, storageService, cacheService, tiersService } = createTestServices(); beforeEach(() => { jest.clearAllMocks(); diff --git a/tests/src/webhooks/webhook.test.ts b/tests/src/webhooks/webhook.test.ts index 77a9d936..2cbb4cd2 100644 --- a/tests/src/webhooks/webhook.test.ts +++ b/tests/src/webhooks/webhook.test.ts @@ -5,14 +5,12 @@ import { getCustomer, getInvoice, getPaymentIntent } from '../fixtures'; import handleFundsCaptured from '../../../src/webhooks/handleFundsCaptured'; import { PaymentService } from '../../../src/services/payment.service'; import { ObjectStorageService } from '../../../src/services/objectStorage.service'; -import { UsersService } from '../../../src/services/users.service'; -import handleInvoicePaymentFailed from '../../../src/webhooks/handleInvoicePaymentFailed'; import { InvoiceCompletedHandler } from '../../../src/webhooks/events/invoices/InvoiceCompletedHandler'; +import { InvoiceFailedHandler } from '../../../src/webhooks/events/invoices/InvoiceFailedHandler'; let app: FastifyInstance; jest.mock('../../../src/webhooks/handleFundsCaptured'); -jest.mock('../../../src/webhooks/handleInvoicePaymentFailed'); const secret = 'whsec_lorim_ipsum_etc_etc'; @@ -76,6 +74,8 @@ describe('Webhook events', () => { secret, }); + const invoiceFailedHandlerSpy = jest.spyOn(InvoiceFailedHandler.prototype, 'run').mockResolvedValue(); + const response = await app.inject({ method: 'POST', path: 'webhook', @@ -87,14 +87,7 @@ describe('Webhook events', () => { }); expect(response.statusCode).toBe(204); - expect(handleInvoicePaymentFailed).toHaveBeenCalled(); - expect(handleInvoicePaymentFailed).toHaveBeenCalledWith( - event.data.object, - expect.any(ObjectStorageService), - expect.any(PaymentService), - expect.any(UsersService), - app.log, - ); + expect(invoiceFailedHandlerSpy).toHaveBeenCalledWith(event.data.object); }); describe('Invoice Payment completed', () => {