From f249590e52a1d906330326f2d63e0ee138530646 Mon Sep 17 00:00:00 2001 From: Nikolai Muhhin Date: Mon, 9 Jun 2025 23:46:15 +0300 Subject: [PATCH 01/16] Extract Stripe service as a separate module --- .../src/modules/payment/payment.module.ts | 2 + .../src/modules/payment/payment.service.ts | 216 +++++------------- .../src/modules/stripe/stripe.module.ts | 9 + .../src/modules/stripe/stripe.service.ts | 181 +++++++++++++++ 4 files changed, 243 insertions(+), 165 deletions(-) create mode 100644 packages/apps/job-launcher/server/src/modules/stripe/stripe.module.ts create mode 100644 packages/apps/job-launcher/server/src/modules/stripe/stripe.service.ts diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.module.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.module.ts index 6667226e1f..41e66ec4d8 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.module.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.module.ts @@ -15,6 +15,7 @@ import { UserEntity } from '../user/user.entity'; import { JobRepository } from '../job/job.repository'; import { UserRepository } from '../user/user.repository'; import { RateModule } from '../rate/rate.module'; +import { StripeModule } from '../stripe/stripe.module'; @Module({ imports: [ @@ -24,6 +25,7 @@ import { RateModule } from '../rate/rate.module'; Web3Module, WhitelistModule, RateModule, + StripeModule, MinioModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts index 0809be590b..564248ba63 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts @@ -5,10 +5,8 @@ import { } from '@human-protocol/core/typechain-types'; import { Injectable, Logger } from '@nestjs/common'; import { ethers, formatUnits } from 'ethers'; -import Stripe from 'stripe'; import { NetworkConfigService } from '../../common/config/network-config.service'; import { ServerConfigService } from '../../common/config/server-config.service'; -import { StripeConfigService } from '../../common/config/stripe-config.service'; import { TX_CONFIRMATION_TRESHOLD } from '../../common/constants'; import { ErrorPayment } from '../../common/constants/errors'; import { CoingeckoTokenId } from '../../common/constants/payment'; @@ -50,11 +48,12 @@ import { JobRepository } from '../job/job.repository'; import { RateService } from '../rate/rate.service'; import { UserEntity } from '../user/user.entity'; import { UserRepository } from '../user/user.repository'; +import { StripeService } from '../stripe/stripe.service'; @Injectable() export class PaymentService { + private readonly logger = new Logger(PaymentService.name); - private stripe: Stripe; constructor( private readonly networkConfigService: NetworkConfigService, @@ -62,70 +61,27 @@ export class PaymentService { private readonly paymentRepository: PaymentRepository, private readonly userRepository: UserRepository, private readonly jobRepository: JobRepository, - private stripeConfigService: StripeConfigService, - private serverConfigService: ServerConfigService, - private rateService: RateService, - ) { - this.stripe = new Stripe(this.stripeConfigService.secretKey, { - apiVersion: this.stripeConfigService.apiVersion as any, - appInfo: { - name: this.stripeConfigService.appName, - version: this.stripeConfigService.appVersion, - url: this.stripeConfigService.appInfoURL, - }, - }); - } + private readonly serverConfigService: ServerConfigService, + private readonly rateService: RateService, + private readonly stripeService: StripeService, + ) { } public async createCustomerAndAssignCard(user: UserEntity): Promise { // Creates a new Stripe customer if the user does not already have one. // It then initiates a SetupIntent to link a payment method (card) to the customer. - let setupIntent: Stripe.Response; - let customerId = user.stripeCustomerId; - if (!user.stripeCustomerId) { - try { - // Create a new customer in Stripe and assign the ID to the user. - customerId = ( - await this.stripe.customers.create({ - email: user.email, - }) - ).id; - } catch (error) { - this.logger.log(error.message, PaymentService.name); - throw new ServerError(ErrorPayment.CustomerNotCreated); - } - } - try { - // Create a SetupIntent to manage and confirm card setup. - setupIntent = await this.stripe.setupIntents.create({ - automatic_payment_methods: { - enabled: true, - }, - customer: customerId ?? undefined, - }); - } catch (error) { - this.logger.log(error.message, PaymentService.name); - throw new ServerError(ErrorPayment.CardNotAssigned); - } + let customerId = user.stripeCustomerId; - // Ensure the SetupIntent contains a client secret for completing the card setup process. - if (!setupIntent?.client_secret) { - this.logger.log( - ErrorPayment.ClientSecretDoesNotExist, - PaymentService.name, - ); - throw new ServerError(ErrorPayment.ClientSecretDoesNotExist); + if (!customerId) { + customerId = await this.stripeService.createCustomer(user.email); } - return setupIntent.client_secret; + return await this.stripeService.createSetupIntentAndReturnSecret(customerId); } - public async confirmCard( - user: UserEntity, - data: CardConfirmDto, - ): Promise { + public async confirmCard(user: UserEntity, data: CardConfirmDto): Promise { // Confirms the card setup using the Stripe SetupIntent and sets it as the default payment method if requested. - const setup = await this.stripe.setupIntents.retrieve(data.setupId); + const setup = await this.stripeService.retrieveSetupIntent(data.setupId); if (!setup) { this.logger.log(ErrorPayment.SetupNotFound, PaymentService.name); @@ -139,16 +95,14 @@ export class PaymentService { await this.userRepository.updateOne(user); } else { // Check if the user already has a default payment method. - defaultPaymentMethod = await this.getDefaultPaymentMethod( - user.stripeCustomerId, - ); + defaultPaymentMethod = await this.getDefaultPaymentMethod(user.stripeCustomerId); } if (data.defaultCard || !defaultPaymentMethod) { // Update Stripe customer settings to use this payment method by default. - await this.stripe.customers.update(user.stripeCustomerId, { + await this.stripeService.updateCustomer(user.stripeCustomerId, { invoice_settings: { - default_payment_method: setup.payment_method, + default_payment_method: setup.payment_method as string, }, }); } @@ -167,14 +121,14 @@ export class PaymentService { throw new NotFoundError(ErrorPayment.CustomerNotFound); } - const invoice = await this.createInvoice( + const invoice = await this.stripeService.createInvoice( user.stripeCustomerId, amountInCents, currency, 'Top up', ); - const paymentIntent = await this.handleStripePaymentIntent( + const paymentIntent = await this.stripeService.handlePaymentIntent( invoice.payment_intent as string, paymentMethodId, false, // on-session payment @@ -200,6 +154,7 @@ export class PaymentService { transaction: paymentIntent.id, status: PaymentStatus.PENDING, }); + await this.paymentRepository.createUnique(newPaymentEntity); return paymentIntent.client_secret!; @@ -210,7 +165,7 @@ export class PaymentService { data: PaymentFiatConfirmDto, ): Promise { // Confirms a fiat payment based on the PaymentIntent ID and updates its status in the system. - const paymentData = await this.stripe.paymentIntents.retrieve( + const paymentData = await this.stripeService.retrievePaymentIntent( data.paymentId, ); @@ -246,6 +201,7 @@ export class PaymentService { // Update the payment entity to reflect successful payment. paymentEntity.status = PaymentStatus.SUCCEEDED; + await this.paymentRepository.updateOne(paymentEntity); return true; @@ -257,9 +213,11 @@ export class PaymentService { signature: string, ): Promise { this.web3Service.validateChainId(dto.chainId); + const network = this.networkConfigService.networks.find( (item) => item.chainId === dto.chainId, ); + const provider = new ethers.JsonRpcProvider(network?.rpcUrl); const transaction = await provider.getTransactionReceipt( @@ -338,6 +296,7 @@ export class PaymentService { transaction: dto.transactionHash, status: PaymentStatus.SUCCEEDED, }); + await this.paymentRepository.createUnique(newPaymentEntity); return true; @@ -388,70 +347,6 @@ export class PaymentService { return mul(amount, rate); } - private async createInvoice( - customerId: string, - amountInCents: number, - currency: string, - description: string, - ): Promise { - let invoice = await this.stripe.invoices.create({ - customer: customerId, - currency: currency, - auto_advance: false, - payment_settings: { - payment_method_types: ['card'], - }, - }); - - await this.stripe.invoiceItems.create({ - customer: customerId, - amount: amountInCents, - invoice: invoice.id, - description: description, - }); - - // Finalize the invoice to prepare it for payment. - invoice = await this.stripe.invoices.finalizeInvoice(invoice.id); - - if (!invoice.payment_intent) { - throw new ServerError(ErrorPayment.IntentNotCreated); - } - - return invoice; - } - - private async handleStripePaymentIntent( - paymentIntentId: string, - paymentMethodId: string, - offSession: boolean, - ): Promise { - try { - if (offSession) { - // Use confirm for off-session payments - await this.stripe.paymentIntents.confirm(paymentIntentId, { - payment_method: paymentMethodId, - off_session: true, - }); - } else { - // Use update for on-session payments - await this.stripe.paymentIntents.update(paymentIntentId, { - payment_method: paymentMethodId, - }); - } - } catch { - throw new ServerError(ErrorPayment.PaymentMethodAssociationFailed); - } - - const paymentIntent = - await this.stripe.paymentIntents.retrieve(paymentIntentId); - - if (!paymentIntent?.client_secret) { - throw new ServerError(ErrorPayment.ClientSecretDoesNotExist); - } - - return paymentIntent; - } - public async createSlash(job: JobEntity): Promise { const amount = this.serverConfigService.abuseAmount; const currency = PaymentCurrency.USD; @@ -462,7 +357,7 @@ export class PaymentService { } const amountInCents = Math.ceil(mul(amount, 100)); - const invoice = await this.createInvoice( + const invoice = await this.stripeService.createInvoice( user.stripeCustomerId, amountInCents, currency, @@ -477,7 +372,7 @@ export class PaymentService { throw new ServerError(ErrorPayment.NotDefaultPaymentMethod); } - const paymentIntent = await this.handleStripePaymentIntent( + const paymentIntent = await this.stripeService.handlePaymentIntent( invoice.payment_intent as string, defaultPaymentMethod, true, // off-session payment @@ -494,6 +389,7 @@ export class PaymentService { transaction: paymentIntent.id, status: PaymentStatus.SUCCEEDED, }); + await this.paymentRepository.createUnique(newPaymentEntity); Object.assign(newPaymentEntity, { @@ -507,6 +403,7 @@ export class PaymentService { status: PaymentStatus.SUCCEEDED, jobId: job.id, }); + await this.paymentRepository.createUnique(newPaymentEntity); } @@ -541,20 +438,12 @@ export class PaymentService { } // List all the payment methods (cards) associated with the user's Stripe account - const paymentMethods = await this.stripe.customers.listPaymentMethods( - user.stripeCustomerId, - { - type: 'card', - limit: 100, - }, - ); + const paymentMethods = await this.stripeService.listPaymentMethods(user.stripeCustomerId); // Get the default payment method for the user - const defaultPaymentMethod = await this.getDefaultPaymentMethod( - user.stripeCustomerId, - ); + const defaultPaymentMethod = await this.getDefaultPaymentMethod(user.stripeCustomerId); - for (const paymentMethod of paymentMethods.data) { + for (const paymentMethod of paymentMethods) { const card = new CardDto(); card.id = paymentMethod.id; card.brand = paymentMethod.card?.brand as string; @@ -569,21 +458,20 @@ export class PaymentService { async deletePaymentMethod(user: UserEntity, paymentMethodId: string) { // Retrieve the payment method to be detached - const paymentMethod = - await this.stripe.paymentMethods.retrieve(paymentMethodId); + const paymentMethod = await this.stripeService.retrievePaymentMethod(paymentMethodId); // Check if the payment method is the default one and in use for the user if ( user.stripeCustomerId && paymentMethod.id === - (await this.getDefaultPaymentMethod(user.stripeCustomerId)) && + (await this.getDefaultPaymentMethod(user.stripeCustomerId)) && (await this.isPaymentMethodInUse(user.id)) ) { throw new ConflictError(ErrorPayment.PaymentMethodInUse); } // Detach the payment method from the user's account - return this.stripe.paymentMethods.detach(paymentMethodId); + return this.stripeService.detachPaymentMethod(paymentMethodId); } async getUserBillingInfo(user: UserEntity): Promise { @@ -592,13 +480,11 @@ export class PaymentService { } // Retrieve the customer's tax IDs and customer information - const taxIds = await this.stripe.customers.listTaxIds( + const taxIds = await this.stripeService.listCustomerTaxIds( user.stripeCustomerId, ); - const customer = (await this.stripe.customers.retrieve( - user.stripeCustomerId, - )) as Stripe.Customer; + const customer = await this.stripeService.retrieveCustomer(user.stripeCustomerId); const userBillingInfo = new BillingInfoDto(); if (customer.address) { @@ -611,8 +497,8 @@ export class PaymentService { } userBillingInfo.name = customer.name as string; userBillingInfo.email = customer.email as string; - userBillingInfo.vat = taxIds.data[0]?.value; - userBillingInfo.vatType = taxIds.data[0]?.type as VatType; + userBillingInfo.vat = taxIds[0]?.value; + userBillingInfo.vatType = taxIds[0]?.type as VatType; return userBillingInfo; } @@ -624,21 +510,21 @@ export class PaymentService { throw new NotFoundError(ErrorPayment.CustomerNotFound); } // If the VAT or VAT type has changed, update it in Stripe - const existingTaxIds = await this.stripe.customers.listTaxIds( + const existingTaxIds = await this.stripeService.listCustomerTaxIds( user.stripeCustomerId, ); // Delete any existing tax IDs before adding the new one - for (const taxId of existingTaxIds.data) { - await this.stripe.customers.deleteTaxId(user.stripeCustomerId, taxId.id); + for (const taxId of existingTaxIds) { + await this.stripeService.deleteTaxId(user.stripeCustomerId, taxId.id); } // Create the new VAT tax ID if (updateBillingInfoDto.vat && updateBillingInfoDto.vatType) { - await this.stripe.customers.createTaxId(user.stripeCustomerId, { - type: updateBillingInfoDto.vatType, - value: updateBillingInfoDto.vat, - }); + await this.stripeService.createTaxId(user.stripeCustomerId, + updateBillingInfoDto.vatType, + updateBillingInfoDto.vat, + ); } // If there are changes to the address, name, or email, update them @@ -647,7 +533,7 @@ export class PaymentService { updateBillingInfoDto.name || updateBillingInfoDto.email ) { - return this.stripe.customers.update(user.stripeCustomerId, { + return this.stripeService.updateCustomer(user.stripeCustomerId, { address: { line1: updateBillingInfoDto.address?.line, city: updateBillingInfoDto.address?.city, @@ -664,8 +550,9 @@ export class PaymentService { if (!user.stripeCustomerId) { throw new NotFoundError(ErrorPayment.CustomerNotFound); } + // Update the user's default payment method in Stripe - return this.stripe.customers.update(user.stripeCustomerId, { + return this.stripeService.updateCustomer(user.stripeCustomerId, { invoice_settings: { default_payment_method: cardId }, }); } @@ -676,9 +563,7 @@ export class PaymentService { } // Retrieve the customer from Stripe and return the default payment method - const customer = await this.stripe.customers.retrieve(customerId); - return (customer as Stripe.Customer).invoice_settings - .default_payment_method as string; + return await this.stripeService.getDefaultPaymentMethod(customerId); } private async isPaymentMethodInUse(userId: number): Promise { @@ -722,16 +607,17 @@ export class PaymentService { async getReceipt(paymentId: string, user: UserEntity): Promise { // Retrieve the payment intent using the provided payment ID - const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentId); + const paymentIntent = await this.stripeService.retrievePaymentIntent(paymentId); if (!paymentIntent || paymentIntent.customer !== user.stripeCustomerId) { throw new NotFoundError(ErrorPayment.NotFound); } // Retrieve the charge for the payment intent and ensure it has a receipt URL - const charge = await this.stripe.charges.retrieve( + const charge = await this.stripeService.retrieveCharge( paymentIntent.latest_charge as string, ); + if (!charge || !charge.receipt_url) { throw new NotFoundError(ErrorPayment.NotFound); } diff --git a/packages/apps/job-launcher/server/src/modules/stripe/stripe.module.ts b/packages/apps/job-launcher/server/src/modules/stripe/stripe.module.ts new file mode 100644 index 0000000000..73b218670f --- /dev/null +++ b/packages/apps/job-launcher/server/src/modules/stripe/stripe.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { StripeService } from './stripe.service'; +import { StripeConfigService } from '../../common/config/stripe-config.service'; + +@Module({ + providers: [StripeService, StripeConfigService], + exports: [StripeService], +}) +export class StripeModule {} \ No newline at end of file diff --git a/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.ts b/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.ts new file mode 100644 index 0000000000..c6c8918705 --- /dev/null +++ b/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.ts @@ -0,0 +1,181 @@ +import { Injectable, Logger } from '@nestjs/common'; +import Stripe from 'stripe'; +import { StripeConfigService } from '../../common/config/stripe-config.service'; +import { ServerError } from '../../common/errors'; +import { ErrorPayment } from '../../common/constants/errors'; +import { VatType } from '../../common/enums/payment'; + +@Injectable() +export class StripeService { + + private readonly logger = new Logger(StripeService.name); + + private stripe: Stripe; + + constructor(private stripeConfigService: StripeConfigService) { + this.stripe = new Stripe(this.stripeConfigService.secretKey, { + apiVersion: this.stripeConfigService.apiVersion as any, + appInfo: { + name: this.stripeConfigService.appName, + version: this.stripeConfigService.appVersion, + url: this.stripeConfigService.appInfoURL, + }, + }); + } + + async createCustomer(email: string): Promise { + try { + const customer = await this.stripe.customers.create({ email }); + return customer.id; + } catch (error) { + this.logger.log(error.message, StripeService.name); + throw new ServerError(ErrorPayment.CustomerNotCreated); + } + } + + async createSetupIntentAndReturnSecret(customerId: string): Promise { + let setupIntent: Stripe.Response; + + try { + setupIntent = await this.stripe.setupIntents.create({ + automatic_payment_methods: { enabled: true }, + customer: customerId ?? undefined, + }); + } catch (error) { + this.logger.log(error.message, StripeService.name); + throw new ServerError(ErrorPayment.CardNotAssigned); + } + + if (!setupIntent?.client_secret) { + this.logger.log(ErrorPayment.ClientSecretDoesNotExist, StripeService.name); + throw new ServerError(ErrorPayment.ClientSecretDoesNotExist); + } + + return setupIntent.client_secret; + } + + async createInvoice(customerId: string, amountInCents: number, currency: string, description: string): Promise { + let invoice = await this.stripe.invoices.create({ + customer: customerId, + currency: currency, + auto_advance: false, + payment_settings: { + payment_method_types: ['card'], + }, + }); + + await this.stripe.invoiceItems.create({ + customer: customerId, + amount: amountInCents, + invoice: invoice.id, + description: description, + }); + + invoice = await this.stripe.invoices.finalizeInvoice(invoice.id); + + if (!invoice.payment_intent) { + throw new ServerError(ErrorPayment.IntentNotCreated); + } + + return invoice; + } + + async handlePaymentIntent(paymentIntentId: string, paymentMethodId: string, offSession: boolean): Promise { + try { + if (offSession) { + await this.stripe.paymentIntents.confirm(paymentIntentId, { + payment_method: paymentMethodId, + off_session: true, + }); + } else { + await this.stripe.paymentIntents.update(paymentIntentId, { + payment_method: paymentMethodId, + }); + } + } catch { + throw new ServerError(ErrorPayment.PaymentMethodAssociationFailed); + } + + const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentIntentId); + + if (!paymentIntent?.client_secret) { + throw new ServerError(ErrorPayment.ClientSecretDoesNotExist); + } + + return paymentIntent; + } + + async retrievePaymentIntent(paymentIntentId: string): Promise { + return this.stripe.paymentIntents.retrieve(paymentIntentId); + } + + async retrieveCustomer(customerId: string): Promise { + return (await this.stripe.customers.retrieve(customerId)) as Stripe.Customer; + } + + async getDefaultPaymentMethod(customerId: string): Promise { + const customer = await this.retrieveCustomer(customerId); + + return customer.invoice_settings.default_payment_method as string; + } + + async listPaymentMethods(customerId: string): Promise { + const paymentMethods = await this.stripe.customers.listPaymentMethods( + customerId, + { type: 'card', limit: 100 }, + ); + + return paymentMethods.data; + } + + async detachPaymentMethod(paymentMethodId: string): Promise { + return this.stripe.paymentMethods.detach(paymentMethodId); + } + + async retrievePaymentMethod(paymentMethodId: string): Promise { + return this.stripe.paymentMethods.retrieve(paymentMethodId); + } + + async updateCustomer( + customerId: string, + data: Partial<{ + address: { + line1?: string; + city?: string; + country?: string; + postal_code?: string; + }; + name?: string; + email?: string; + invoice_settings?: Partial<{ + default_payment_method?: string; + }>; + }>, + ): Promise { + return this.stripe.customers.update(customerId, data); + } + + async listCustomerTaxIds(customerId: string): Promise { + const taxIds = await this.stripe.customers.listTaxIds(customerId); + return taxIds.data; + } + + async deleteTaxId(customerId: string, taxId: string): Promise { + await this.stripe.customers.deleteTaxId(customerId, taxId); + } + + async createTaxId(customerId: string, type: VatType, value: string): Promise { + return this.stripe.customers.createTaxId(customerId, { + type, + value, + }); + } + + async retrieveSetupIntent(setupIntentId: string): Promise { + return this.stripe.setupIntents.retrieve(setupIntentId); + } + + async retrieveCharge(chargeId: string): Promise { + return this.stripe.charges.retrieve(chargeId); + } +} \ No newline at end of file From 6c11458ef24bead0c769283f852f1307f54600f1 Mon Sep 17 00:00:00 2001 From: Nikolai Muhhin Date: Mon, 9 Jun 2025 23:51:50 +0300 Subject: [PATCH 02/16] Cover with basic tests --- .../src/modules/stripe/stripe.service.spec.ts | 221 ++++++++++++++++++ .../src/modules/stripe/stripe.service.ts | 2 +- 2 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 packages/apps/job-launcher/server/src/modules/stripe/stripe.service.spec.ts diff --git a/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.spec.ts b/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.spec.ts new file mode 100644 index 0000000000..286864844c --- /dev/null +++ b/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.spec.ts @@ -0,0 +1,221 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Logger } from '@nestjs/common'; +import { StripeService } from './stripe.service'; +import { StripeConfigService } from '../../common/config/stripe-config.service'; +import Stripe from 'stripe'; +import { ServerError } from '../../common/errors'; +import { ErrorPayment } from '../../common/constants/errors'; +import { VatType } from '../../common/enums/payment'; + +jest.mock('stripe'); + +describe('StripeService', () => { + let service: StripeService; + let stripeMock: jest.Mocked; + let loggerSpy: jest.SpyInstance; + + const mockStripeConfigService = { + secretKey: 'test_key', + apiVersion: '2023-10-16', + appName: 'test-app', + appVersion: '1.0.0', + appInfoURL: 'https://test.com', + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StripeService, + { + provide: StripeConfigService, + useValue: mockStripeConfigService, + }, + ], + }).compile(); + + service = module.get(StripeService); + stripeMock = new Stripe('dummy_key') as jest.Mocked; + (service as any).stripe = stripeMock; + loggerSpy = jest.spyOn(Logger.prototype, 'log'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createCustomer', () => { + it('should create a customer successfully', async () => { + const mockCustomer = { id: 'cus_123' }; + stripeMock.customers.create = jest.fn().mockResolvedValue(mockCustomer); + + const result = await service.createCustomer('test@example.com'); + + expect(result).toBe('cus_123'); + expect(stripeMock.customers.create).toHaveBeenCalledWith({ email: 'test@example.com' }); + }); + + it('should handle errors when creating customer', async () => { + stripeMock.customers.create = jest.fn().mockRejectedValue(new Error('Stripe error')); + + await expect(service.createCustomer('test@example.com')).rejects.toThrow( + new ServerError(ErrorPayment.CustomerNotCreated), + ); + expect(loggerSpy).toHaveBeenCalled(); + }); + }); + + describe('createSetupIntentAndReturnSecret', () => { + const mockSetupIntent = { + client_secret: 'seti_secret_123', + }; + + it('should create setup intent successfully', async () => { + stripeMock.setupIntents.create = jest.fn().mockResolvedValue(mockSetupIntent); + + const result = await service.createSetupIntentAndReturnSecret('cus_123'); + + expect(result).toBe('seti_secret_123'); + expect(stripeMock.setupIntents.create).toHaveBeenCalledWith({ + automatic_payment_methods: { enabled: true }, + customer: 'cus_123', + }); + }); + + it('should handle null customerId', async () => { + stripeMock.setupIntents.create = jest.fn().mockResolvedValue(mockSetupIntent); + + await service.createSetupIntentAndReturnSecret(null); + + expect(stripeMock.setupIntents.create).toHaveBeenCalledWith({ + automatic_payment_methods: { enabled: true }, + customer: undefined, + }); + }); + + it('should handle missing client secret', async () => { + stripeMock.setupIntents.create = jest.fn().mockResolvedValue({}); + + await expect(service.createSetupIntentAndReturnSecret('cus_123')).rejects.toThrow( + new ServerError(ErrorPayment.ClientSecretDoesNotExist), + ); + }); + }); + + describe('handlePaymentIntent', () => { + const mockPaymentIntent = { + id: 'pi_123', + client_secret: 'pi_secret_123', + }; + + it('should handle off-session payment intent', async () => { + stripeMock.paymentIntents.confirm = jest.fn().mockResolvedValue({}); + stripeMock.paymentIntents.retrieve = jest.fn().mockResolvedValue(mockPaymentIntent); + + const result = await service.handlePaymentIntent('pi_123', 'pm_123', true); + + expect(stripeMock.paymentIntents.confirm).toHaveBeenCalledWith('pi_123', { + payment_method: 'pm_123', + off_session: true, + }); + expect(result).toEqual(mockPaymentIntent); + }); + + it('should handle on-session payment intent', async () => { + stripeMock.paymentIntents.update = jest.fn().mockResolvedValue({}); + stripeMock.paymentIntents.retrieve = jest.fn().mockResolvedValue(mockPaymentIntent); + + const result = await service.handlePaymentIntent('pi_123', 'pm_123', false); + + expect(stripeMock.paymentIntents.update).toHaveBeenCalledWith('pi_123', { + payment_method: 'pm_123', + }); + expect(result).toEqual(mockPaymentIntent); + }); + }); + + describe('createInvoice', () => { + const mockInvoice = { + id: 'inv_123', + payment_intent: 'pi_123', + }; + + it('should create invoice successfully', async () => { + stripeMock.invoices.create = jest.fn().mockResolvedValue({ id: 'inv_123' }); + stripeMock.invoiceItems.create = jest.fn().mockResolvedValue({}); + stripeMock.invoices.finalizeInvoice = jest.fn().mockResolvedValue(mockInvoice); + + const result = await service.createInvoice('cus_123', 1000, 'usd', 'Test invoice'); + + expect(stripeMock.invoices.create).toHaveBeenCalled(); + expect(stripeMock.invoiceItems.create).toHaveBeenCalled(); + expect(stripeMock.invoices.finalizeInvoice).toHaveBeenCalled(); + expect(result).toEqual(mockInvoice); + }); + + it('should throw error when payment intent is missing', async () => { + stripeMock.invoices.create = jest.fn().mockResolvedValue({ id: 'inv_123' }); + stripeMock.invoiceItems.create = jest.fn().mockResolvedValue({}); + stripeMock.invoices.finalizeInvoice = jest.fn().mockResolvedValue({ id: 'inv_123' }); + + await expect( + service.createInvoice('cus_123', 1000, 'usd', 'Test invoice'), + ).rejects.toThrow(new ServerError(ErrorPayment.IntentNotCreated)); + }); + }); + + describe('updateCustomer', () => { + it('should update customer successfully', async () => { + const mockCustomer = { + id: 'cus_123', + name: 'Updated Name', + }; + stripeMock.customers.update = jest.fn().mockResolvedValue(mockCustomer); + + const updateData = { + name: 'Updated Name', + address: { + line1: '123 Street', + city: 'City', + }, + }; + + const result = await service.updateCustomer('cus_123', updateData); + + expect(stripeMock.customers.update).toHaveBeenCalledWith('cus_123', updateData); + expect(result).toEqual(mockCustomer); + }); + }); + + describe('tax ID operations', () => { + it('should create tax ID successfully', async () => { + const mockTaxId = { id: 'txi_123', type: VatType.EU_VAT, value: 'DE123456789' }; + stripeMock.customers.createTaxId = jest.fn().mockResolvedValue(mockTaxId); + + const result = await service.createTaxId('cus_123', VatType.EU_VAT, 'DE123456789'); + + expect(stripeMock.customers.createTaxId).toHaveBeenCalledWith('cus_123', { + type: VatType.EU_VAT, + value: 'DE123456789', + }); + expect(result).toEqual(mockTaxId); + }); + + it('should list tax IDs successfully', async () => { + const mockTaxIds = { data: [{ id: 'txi_123' }] }; + stripeMock.customers.listTaxIds = jest.fn().mockResolvedValue(mockTaxIds); + + const result = await service.listCustomerTaxIds('cus_123'); + + expect(stripeMock.customers.listTaxIds).toHaveBeenCalledWith('cus_123'); + expect(result).toEqual(mockTaxIds.data); + }); + + it('should delete tax ID successfully', async () => { + stripeMock.customers.deleteTaxId = jest.fn().mockResolvedValue({}); + + await service.deleteTaxId('cus_123', 'txi_123'); + + expect(stripeMock.customers.deleteTaxId).toHaveBeenCalledWith('cus_123', 'txi_123'); + }); + }); +}); \ No newline at end of file diff --git a/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.ts b/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.ts index c6c8918705..75b2c5419f 100644 --- a/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.ts +++ b/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.ts @@ -33,7 +33,7 @@ export class StripeService { } } - async createSetupIntentAndReturnSecret(customerId: string): Promise { + async createSetupIntentAndReturnSecret(customerId: string | null): Promise { let setupIntent: Stripe.Response; try { From 12649f7a9731e3677c9ce2787408ffce6a8984ed Mon Sep 17 00:00:00 2001 From: Nikolai Muhhin Date: Tue, 10 Jun 2025 12:39:32 +0300 Subject: [PATCH 03/16] Rename column "hmt"."users":"stripe_customer_id" --- ...9498615107-RenameStripeCustomerIdColumn.ts | 28 ++++++++++ .../src/modules/job/job.service.spec.ts | 2 +- .../server/src/modules/job/job.service.ts | 4 +- .../src/modules/payment/payment.service.ts | 52 +++++++++---------- .../server/src/modules/user/fixtures.ts | 2 +- .../server/src/modules/user/user.entity.ts | 2 +- 6 files changed, 59 insertions(+), 31 deletions(-) create mode 100644 packages/apps/job-launcher/server/src/database/migrations/1749498615107-RenameStripeCustomerIdColumn.ts diff --git a/packages/apps/job-launcher/server/src/database/migrations/1749498615107-RenameStripeCustomerIdColumn.ts b/packages/apps/job-launcher/server/src/database/migrations/1749498615107-RenameStripeCustomerIdColumn.ts new file mode 100644 index 0000000000..ff6bb88c1b --- /dev/null +++ b/packages/apps/job-launcher/server/src/database/migrations/1749498615107-RenameStripeCustomerIdColumn.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RenameStripeCustomerIdColumn1749498615107 implements MigrationInterface { + + name = 'RenameStripeCustomerIdColumn1749498615107'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "hmt"."users" + RENAME COLUMN "stripe_customer_id" TO "payment_provider_id" + `); + await queryRunner.query(` + ALTER TABLE "hmt"."users" + RENAME CONSTRAINT "UQ_5ffbe395603641c29e8ce9b4c97" TO "UQ_721ffe5f6051eb5c6ac35321213" + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "hmt"."users" + RENAME CONSTRAINT "UQ_721ffe5f6051eb5c6ac35321213" TO "UQ_5ffbe395603641c29e8ce9b4c97" + `); + await queryRunner.query(` + ALTER TABLE "hmt"."users" + RENAME COLUMN "payment_provider_id" TO "stripe_customer_id" + `); + } +} diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts index ea98af8fcb..66072ac3ff 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.spec.ts @@ -469,7 +469,7 @@ describe('JobService', () => { const fortuneJobDto: JobFortuneDto = createFortuneJobDto(); await expect( jobService.createJob( - createUser({ stripeCustomerId: null }), + createUser({ paymentProviderId: null }), FortuneJobType.FORTUNE, fortuneJobDto, ), diff --git a/packages/apps/job-launcher/server/src/modules/job/job.service.ts b/packages/apps/job-launcher/server/src/modules/job/job.service.ts index 93d0e9f284..e18688b745 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.service.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.service.ts @@ -146,9 +146,9 @@ export class JobService { const whitelisted = await this.whitelistService.isUserWhitelisted(user.id); if (!whitelisted) { if ( - !user.stripeCustomerId || + !user.paymentProviderId || !(await this.paymentService.getDefaultPaymentMethod( - user.stripeCustomerId, + user.paymentProviderId, )) ) throw new ValidationError(ErrorJob.NotActiveCard); diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts index 564248ba63..4c92a104f5 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts @@ -70,7 +70,7 @@ export class PaymentService { // Creates a new Stripe customer if the user does not already have one. // It then initiates a SetupIntent to link a payment method (card) to the customer. - let customerId = user.stripeCustomerId; + let customerId = user.paymentProviderId; if (!customerId) { customerId = await this.stripeService.createCustomer(user.email); @@ -89,18 +89,18 @@ export class PaymentService { } let defaultPaymentMethod: string | null = null; - if (!user.stripeCustomerId) { + if (!user.paymentProviderId) { // Assign the Stripe customer ID to the user if it does not exist yet. - user.stripeCustomerId = setup.customer as string; + user.paymentProviderId = setup.customer as string; await this.userRepository.updateOne(user); } else { // Check if the user already has a default payment method. - defaultPaymentMethod = await this.getDefaultPaymentMethod(user.stripeCustomerId); + defaultPaymentMethod = await this.getDefaultPaymentMethod(user.paymentProviderId); } if (data.defaultCard || !defaultPaymentMethod) { // Update Stripe customer settings to use this payment method by default. - await this.stripeService.updateCustomer(user.stripeCustomerId, { + await this.stripeService.updateCustomer(user.paymentProviderId, { invoice_settings: { default_payment_method: setup.payment_method as string, }, @@ -117,12 +117,12 @@ export class PaymentService { const { amount, currency, paymentMethodId } = dto; const amountInCents = Math.ceil(mul(amount, 100)); - if (!user.stripeCustomerId) { + if (!user.paymentProviderId) { throw new NotFoundError(ErrorPayment.CustomerNotFound); } const invoice = await this.stripeService.createInvoice( - user.stripeCustomerId, + user.paymentProviderId, amountInCents, currency, 'Top up', @@ -352,20 +352,20 @@ export class PaymentService { const currency = PaymentCurrency.USD; const user = await this.userRepository.findById(job.userId); - if (!user || !user.stripeCustomerId) { + if (!user || !user.paymentProviderId) { throw new NotFoundError(ErrorPayment.CustomerNotFound); } const amountInCents = Math.ceil(mul(amount, 100)); const invoice = await this.stripeService.createInvoice( - user.stripeCustomerId, + user.paymentProviderId, amountInCents, currency, 'Slash Job Id ' + job.id, ); const defaultPaymentMethod = await this.getDefaultPaymentMethod( - user.stripeCustomerId, + user.paymentProviderId, ); if (!defaultPaymentMethod) { @@ -433,15 +433,15 @@ export class PaymentService { async listUserPaymentMethods(user: UserEntity): Promise { const cards: CardDto[] = []; - if (!user.stripeCustomerId) { + if (!user.paymentProviderId) { return cards; } // List all the payment methods (cards) associated with the user's Stripe account - const paymentMethods = await this.stripeService.listPaymentMethods(user.stripeCustomerId); + const paymentMethods = await this.stripeService.listPaymentMethods(user.paymentProviderId); // Get the default payment method for the user - const defaultPaymentMethod = await this.getDefaultPaymentMethod(user.stripeCustomerId); + const defaultPaymentMethod = await this.getDefaultPaymentMethod(user.paymentProviderId); for (const paymentMethod of paymentMethods) { const card = new CardDto(); @@ -462,9 +462,9 @@ export class PaymentService { // Check if the payment method is the default one and in use for the user if ( - user.stripeCustomerId && + user.paymentProviderId && paymentMethod.id === - (await this.getDefaultPaymentMethod(user.stripeCustomerId)) && + (await this.getDefaultPaymentMethod(user.paymentProviderId)) && (await this.isPaymentMethodInUse(user.id)) ) { throw new ConflictError(ErrorPayment.PaymentMethodInUse); @@ -475,16 +475,16 @@ export class PaymentService { } async getUserBillingInfo(user: UserEntity): Promise { - if (!user.stripeCustomerId) { + if (!user.paymentProviderId) { return null; } // Retrieve the customer's tax IDs and customer information const taxIds = await this.stripeService.listCustomerTaxIds( - user.stripeCustomerId, + user.paymentProviderId, ); - const customer = await this.stripeService.retrieveCustomer(user.stripeCustomerId); + const customer = await this.stripeService.retrieveCustomer(user.paymentProviderId); const userBillingInfo = new BillingInfoDto(); if (customer.address) { @@ -506,22 +506,22 @@ export class PaymentService { user: UserEntity, updateBillingInfoDto: BillingInfoDto, ) { - if (!user.stripeCustomerId) { + if (!user.paymentProviderId) { throw new NotFoundError(ErrorPayment.CustomerNotFound); } // If the VAT or VAT type has changed, update it in Stripe const existingTaxIds = await this.stripeService.listCustomerTaxIds( - user.stripeCustomerId, + user.paymentProviderId, ); // Delete any existing tax IDs before adding the new one for (const taxId of existingTaxIds) { - await this.stripeService.deleteTaxId(user.stripeCustomerId, taxId.id); + await this.stripeService.deleteTaxId(user.paymentProviderId, taxId.id); } // Create the new VAT tax ID if (updateBillingInfoDto.vat && updateBillingInfoDto.vatType) { - await this.stripeService.createTaxId(user.stripeCustomerId, + await this.stripeService.createTaxId(user.paymentProviderId, updateBillingInfoDto.vatType, updateBillingInfoDto.vat, ); @@ -533,7 +533,7 @@ export class PaymentService { updateBillingInfoDto.name || updateBillingInfoDto.email ) { - return this.stripeService.updateCustomer(user.stripeCustomerId, { + return this.stripeService.updateCustomer(user.paymentProviderId, { address: { line1: updateBillingInfoDto.address?.line, city: updateBillingInfoDto.address?.city, @@ -547,12 +547,12 @@ export class PaymentService { } async changeDefaultPaymentMethod(user: UserEntity, cardId: string) { - if (!user.stripeCustomerId) { + if (!user.paymentProviderId) { throw new NotFoundError(ErrorPayment.CustomerNotFound); } // Update the user's default payment method in Stripe - return this.stripeService.updateCustomer(user.stripeCustomerId, { + return this.stripeService.updateCustomer(user.paymentProviderId, { invoice_settings: { default_payment_method: cardId }, }); } @@ -609,7 +609,7 @@ export class PaymentService { // Retrieve the payment intent using the provided payment ID const paymentIntent = await this.stripeService.retrievePaymentIntent(paymentId); - if (!paymentIntent || paymentIntent.customer !== user.stripeCustomerId) { + if (!paymentIntent || paymentIntent.customer !== user.paymentProviderId) { throw new NotFoundError(ErrorPayment.NotFound); } diff --git a/packages/apps/job-launcher/server/src/modules/user/fixtures.ts b/packages/apps/job-launcher/server/src/modules/user/fixtures.ts index 06b69d59f2..53af331af7 100644 --- a/packages/apps/job-launcher/server/src/modules/user/fixtures.ts +++ b/packages/apps/job-launcher/server/src/modules/user/fixtures.ts @@ -9,7 +9,7 @@ export const createUser = (overrides: Partial = {}): UserEntity => { user.password = faker.internet.password(); user.type = faker.helpers.arrayElement(Object.values(UserType)); user.status = faker.helpers.arrayElement(Object.values(UserStatus)); - user.stripeCustomerId = faker.string.uuid(); + user.paymentProviderId = faker.string.uuid(); user.jobs = []; user.payments = []; user.apiKey = null; diff --git a/packages/apps/job-launcher/server/src/modules/user/user.entity.ts b/packages/apps/job-launcher/server/src/modules/user/user.entity.ts index c63f108411..59b5a9117a 100644 --- a/packages/apps/job-launcher/server/src/modules/user/user.entity.ts +++ b/packages/apps/job-launcher/server/src/modules/user/user.entity.ts @@ -29,7 +29,7 @@ export class UserEntity extends BaseEntity implements IUser { public status: UserStatus; @Column({ type: 'varchar', nullable: true, unique: true }) - public stripeCustomerId: string | null; + public paymentProviderId: string | null; @OneToMany(() => JobEntity, (job) => job.user) public jobs: JobEntity[]; From 86bfcc569916714c0a6c3e09b1c881f5e62e8c78 Mon Sep 17 00:00:00 2001 From: Nikolai Muhhin Date: Wed, 11 Jun 2025 23:18:52 +0300 Subject: [PATCH 04/16] Adjust tests --- .../modules/payment/payment.service.spec.ts | 467 ++++++------------ .../src/modules/stripe/stripe.service.spec.ts | 38 +- 2 files changed, 194 insertions(+), 311 deletions(-) diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts index 2a7a83146a..6801c184b6 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts @@ -12,7 +12,6 @@ import { ConflictException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Test } from '@nestjs/testing'; import { ethers } from 'ethers'; -import Stripe from 'stripe'; import { MOCK_ADDRESS, MOCK_PAYMENT_ID, @@ -55,13 +54,14 @@ import { GetPaymentsDto, UserBalanceDto } from './payment.dto'; import { PaymentEntity } from './payment.entity'; import { PaymentRepository } from './payment.repository'; import { PaymentService } from './payment.service'; +import { StripeService } from '../stripe/stripe.service'; describe('PaymentService', () => { - let stripe: Stripe; let paymentService: PaymentService; - let paymentRepository: PaymentRepository; - let userRepository: UserRepository; - let rateService: RateService; + let stripeService: jest.Mocked; + let paymentRepository: jest.Mocked; + let userRepository: jest.Mocked; + let rateService: jest.Mocked; const signerMock = { address: MOCK_ADDRESS, @@ -71,6 +71,7 @@ describe('PaymentService', () => { beforeEach(async () => { const moduleRef = await Test.createTestingModule({ providers: [ + PaymentService, { provide: ConfigService, useValue: { @@ -83,8 +84,26 @@ describe('PaymentService', () => { }), }, }, - PaymentService, - StripeConfigService, + { + provide: StripeService, + useValue: { + createCustomer: jest.fn(), + createSetupIntentAndReturnSecret: jest.fn(), + retrieveSetupIntent: jest.fn(), + createInvoice: jest.fn(), + handlePaymentIntent: jest.fn(), + retrievePaymentIntent: jest.fn(), + retrieveCustomer: jest.fn(), + getDefaultPaymentMethod: jest.fn(), + updateCustomer: jest.fn(), + listPaymentMethods: jest.fn(), + retrievePaymentMethod: jest.fn(), + detachPaymentMethod: jest.fn(), + listCustomerTaxIds: jest.fn(), + retrieveCharge: jest.fn(), + createTaxId: jest.fn(), + }, + }, { provide: PaymentRepository, useValue: createMock(), @@ -120,73 +139,18 @@ describe('PaymentService', () => { paymentRepository = moduleRef.get(PaymentRepository); userRepository = moduleRef.get(UserRepository); rateService = moduleRef.get(RateService); - - stripe = { - customers: { - create: jest.fn(), - update: jest.fn(), - listPaymentMethods: jest.fn(), - listTaxIds: jest.fn(), - createTaxId: jest.fn(), - retrieve: jest.fn(), - }, - paymentIntents: { - create: jest.fn(), - retrieve: jest.fn(), - update: jest.fn(), - confirm: jest.fn(), - }, - setupIntents: { - create: jest.fn(), - retrieve: jest.fn(), - }, - paymentMethods: { - retrieve: jest.fn(), - detach: jest.fn(), - }, - charges: { - retrieve: jest.fn(), - }, - invoices: { - create: jest.fn(), - finalizeInvoice: jest.fn(), - }, - invoiceItems: { - create: jest.fn(), - }, - } as any; - - paymentService['stripe'] = stripe; + stripeService = moduleRef.get(StripeService); }); describe('createFiatPayment', () => { - let createInvoiceMock: any, - createInvoiceItemMock: any, - finalizeInvoiceMock: any, - retrievePaymentIntentMock: any, - updatePaymentIntentMock: any, - findOneMock: any; + let findOneMock: any; beforeEach(() => { findOneMock = jest.spyOn(paymentRepository, 'findOneByTransaction'); - createInvoiceMock = jest.spyOn(stripe.invoices, 'create'); - createInvoiceItemMock = jest.spyOn(stripe.invoiceItems, 'create'); - finalizeInvoiceMock = jest.spyOn(stripe.invoices, 'finalizeInvoice'); - retrievePaymentIntentMock = jest.spyOn(stripe.paymentIntents, 'retrieve'); - updatePaymentIntentMock = jest.spyOn(stripe.paymentIntents, 'update'); }); afterEach(() => { - expect(createInvoiceMock).toHaveBeenCalledTimes(1); - expect(createInvoiceItemMock).toHaveBeenCalledTimes(1); - expect(finalizeInvoiceMock).toHaveBeenCalledTimes(1); - expect(retrievePaymentIntentMock).toHaveBeenCalledTimes(1); - expect(updatePaymentIntentMock).toHaveBeenCalledTimes(1); - createInvoiceMock.mockRestore(); - createInvoiceItemMock.mockRestore(); - finalizeInvoiceMock.mockRestore(); - retrievePaymentIntentMock.mockRestore(); - updatePaymentIntentMock.mockRestore(); + jest.restoreAllMocks(); }); it('should create a fiat payment successfully', async () => { @@ -198,7 +162,7 @@ describe('PaymentService', () => { const user = { id: 1, - stripeCustomerId: 'cus_123', + paymentProviderId: 'cus_123', }; const paymentIntent = { @@ -211,15 +175,14 @@ describe('PaymentService', () => { payment_intent: paymentIntent.id, }; - createInvoiceMock.mockResolvedValue(invoice as any); - finalizeInvoiceMock.mockResolvedValue(invoice as any); - retrievePaymentIntentMock.mockResolvedValue(paymentIntent as any); - jest - .spyOn(stripe.paymentIntents, 'retrieve') - .mockResolvedValue(paymentIntent as any); + stripeService.createInvoice.mockResolvedValue(invoice as any); + stripeService.retrievePaymentIntent.mockResolvedValue(paymentIntent as any); + stripeService.handlePaymentIntent.mockResolvedValue(paymentIntent as any); + jest .spyOn(paymentRepository, 'findOneByTransaction') .mockResolvedValue(null); + jest .spyOn(paymentRepository, 'createUnique') .mockResolvedValue(undefined as any); @@ -227,27 +190,20 @@ describe('PaymentService', () => { const result = await paymentService.createFiatPayment(user as any, dto); expect(result).toEqual(paymentIntent.client_secret); - expect(stripe.invoices.create).toHaveBeenCalledWith({ - currency: PaymentCurrency.USD, - customer: 'cus_123', - auto_advance: false, - payment_settings: { - payment_method_types: ['card'], - }, - }); - expect(stripe.invoiceItems.create).toHaveBeenCalledWith({ - customer: 'cus_123', - amount: 10000, - invoice: invoice.id, - description: 'Top up', - }); - expect(stripe.paymentIntents.update).toHaveBeenCalledWith('pi_123', { - payment_method: 'pm_123', - }); + expect(stripeService.createInvoice).toHaveBeenCalledWith( + 'cus_123', + 10000, + PaymentCurrency.USD, + 'Top up' + ); + expect(stripeService.handlePaymentIntent).toHaveBeenCalledWith( + 'pi_123', + 'pm_123', + false + ); }); it('should throw a bad request exception if transaction already exist', async () => { - 0; const dto = { amount: 100, currency: PaymentCurrency.USD, @@ -256,7 +212,7 @@ describe('PaymentService', () => { const user = { id: 1, - stripeCustomerId: 'cus_123', + paymentProviderId: 'cus_123', }; const paymentIntent = { @@ -269,12 +225,9 @@ describe('PaymentService', () => { payment_intent: paymentIntent.id, }; - createInvoiceMock.mockResolvedValue(invoice as any); - finalizeInvoiceMock.mockResolvedValue(invoice as any); - retrievePaymentIntentMock.mockResolvedValue(paymentIntent as any); - jest - .spyOn(stripe.paymentIntents, 'retrieve') - .mockResolvedValue(paymentIntent as any); + stripeService.createInvoice.mockResolvedValue(invoice as any); + stripeService.retrievePaymentIntent.mockResolvedValue(paymentIntent as any); + stripeService.handlePaymentIntent.mockResolvedValue(paymentIntent as any); findOneMock.mockResolvedValue({ transaction: paymentIntent.client_secret, @@ -286,45 +239,13 @@ describe('PaymentService', () => { new ConflictError(ErrorPayment.TransactionAlreadyExists), ); }); - - it('should throw a bad request exception if the invoice creation fails', async () => { - 0; - const dto = { - amount: 100, - currency: PaymentCurrency.USD, - paymentMethodId: 'pm_123', - }; - - const user = { - id: 1, - stripeCustomerId: 'cus_123', - }; - - const paymentIntent = { - id: 'pi_123', - }; - - const invoice = { - id: 'id', - payment_intent: paymentIntent.id, - }; - - createInvoiceMock.mockResolvedValue(invoice as any); - finalizeInvoiceMock.mockResolvedValue(invoice as any); - retrievePaymentIntentMock.mockResolvedValue(paymentIntent as any); - - await expect( - paymentService.createFiatPayment(user as any, dto), - ).rejects.toThrow(new ServerError(ErrorPayment.ClientSecretDoesNotExist)); - }); }); describe('confirmFiatPayment', () => { - let retrievePaymentIntentMock: any, findOneMock: any; + let findOneMock: any; beforeEach(() => { findOneMock = jest.spyOn(paymentRepository, 'findOneByTransaction'); - retrievePaymentIntentMock = jest.spyOn(stripe.paymentIntents, 'retrieve'); }); afterEach(() => { @@ -344,7 +265,7 @@ describe('PaymentService', () => { currency: PaymentCurrency.USD, }; - retrievePaymentIntentMock.mockResolvedValue(paymentData); + stripeService.retrievePaymentIntent.mockResolvedValue(paymentData as any); const paymentEntity: Partial = { userId: userId, @@ -372,7 +293,7 @@ describe('PaymentService', () => { currency: PaymentCurrency.USD, }; - retrievePaymentIntentMock.mockResolvedValue(paymentData); + stripeService.retrievePaymentIntent.mockResolvedValue(paymentData as any); const paymentEntity: Partial = { userId: userId, @@ -400,7 +321,7 @@ describe('PaymentService', () => { currency: PaymentCurrency.USD, }; - retrievePaymentIntentMock.mockResolvedValue(paymentData); + stripeService.retrievePaymentIntent.mockResolvedValue(paymentData as any); const paymentEntity: Partial = { userId: userId, @@ -428,7 +349,7 @@ describe('PaymentService', () => { currency: PaymentCurrency.USD, }; - retrievePaymentIntentMock.mockResolvedValue(paymentData); + stripeService.retrievePaymentIntent.mockResolvedValue(paymentData as any); const paymentEntity: Partial = { userId: userId, @@ -449,7 +370,7 @@ describe('PaymentService', () => { paymentId: MOCK_PAYMENT_ID, }; - retrievePaymentIntentMock.mockResolvedValue(null); + stripeService.retrievePaymentIntent.mockResolvedValue({} as any); await expect( paymentService.confirmFiatPayment(userId, dto), @@ -847,41 +768,32 @@ describe('PaymentService', () => { const user = { id: 1, email: 'test@hmt.ai', - stripeCustomerId: null, + paymentProviderId: null, }; const paymentIntent = { client_secret: 'clientSecret123', }; - jest - .spyOn(stripe.customers, 'create') - .mockResolvedValue({ id: 'cus_123' } as any); - jest - .spyOn(stripe.setupIntents, 'create') - .mockResolvedValue(paymentIntent as any); + stripeService.createCustomer.mockResolvedValue('cus_123'); + stripeService.createSetupIntentAndReturnSecret.mockResolvedValue(paymentIntent.client_secret); const result = await paymentService.createCustomerAndAssignCard( user as any, ); expect(result).toEqual(paymentIntent.client_secret); - expect(stripe.customers.create).toHaveBeenCalledWith({ - email: user.email, - }); - expect(stripe.setupIntents.create).toHaveBeenCalledWith({ - automatic_payment_methods: { enabled: true }, - customer: 'cus_123', - }); + expect(stripeService.createCustomer).toHaveBeenCalledWith(user.email); + expect(stripeService.createSetupIntentAndReturnSecret).toHaveBeenCalledWith('cus_123'); }); it('should throw a bad request exception if the customer creation fails', async () => { const user = { id: 1, email: 'test@hmt.ai', - stripeCustomerId: undefined, + paymentProviderId: undefined, }; - jest.spyOn(stripe.customers, 'create').mockRejectedValue(new Error()); + stripeService.createCustomer.mockRejectedValue(new ServerError(ErrorPayment.CustomerNotCreated)); await expect( paymentService.createCustomerAndAssignCard(user as any), @@ -894,15 +806,12 @@ describe('PaymentService', () => { email: 'test@hmt.ai', }; - jest - .spyOn(stripe.customers, 'create') - .mockResolvedValue({ id: 1 } as any); - - jest.spyOn(stripe.setupIntents, 'create').mockRejectedValue(new Error()); + stripeService.createCustomer.mockResolvedValue({ id: 1 } as any); + stripeService.createSetupIntentAndReturnSecret.mockRejectedValue(new ServerError(ErrorPayment.IntentNotCreated)); await expect( paymentService.createCustomerAndAssignCard(user as any), - ).rejects.toThrow(ErrorPayment.CardNotAssigned); + ).rejects.toThrow(new ServerError(ErrorPayment.IntentNotCreated)); }); it('should throw a bad request exception if the client secret does not exists', async () => { @@ -911,25 +820,21 @@ describe('PaymentService', () => { email: 'test@hmt.ai', }; - jest - .spyOn(stripe.customers, 'create') - .mockResolvedValue({ id: 1 } as any); - jest - .spyOn(stripe.setupIntents, 'create') - .mockResolvedValue(undefined as any); + stripeService.createCustomer.mockResolvedValue(user.id.toString()); + stripeService.createSetupIntentAndReturnSecret.mockRejectedValue(new ServerError(ErrorPayment.ClientSecretDoesNotExist)); await expect( paymentService.createCustomerAndAssignCard(user as any), - ).rejects.toThrow(ErrorPayment.ClientSecretDoesNotExist); + ).rejects.toThrow(new ServerError(ErrorPayment.ClientSecretDoesNotExist)); }); }); describe('confirmCard', () => { - it('should confirm a card and update user stripeCustomerId successfully', async () => { + it('should confirm a card and update user paymentProviderId successfully', async () => { const user = { id: 1, email: 'test@hmt.ai', - stripeCustomerId: null, + paymentProviderId: null, }; const setupMock = { @@ -937,10 +842,8 @@ describe('PaymentService', () => { payment_method: 'pm_123', }; - jest - .spyOn(stripe.setupIntents, 'retrieve') - .mockResolvedValue(setupMock as any); - jest.spyOn(stripe.customers, 'update').mockResolvedValue(null as any); + stripeService.retrieveSetupIntent.mockResolvedValue(setupMock as any); + stripeService.updateCustomer.mockResolvedValue(null as any); jest .spyOn(userRepository, 'updateOne') .mockResolvedValue(undefined as any); @@ -953,11 +856,11 @@ describe('PaymentService', () => { expect(result).toBeTruthy(); expect(userRepository.updateOne).toHaveBeenCalledWith( expect.objectContaining({ - stripeCustomerId: 'cus_123', + paymentProviderId: 'cus_123', }), ); - expect(stripe.setupIntents.retrieve).toHaveBeenCalledWith('setup_123'); - expect(stripe.customers.update).toHaveBeenCalledWith('cus_123', { + expect(stripeService.retrieveSetupIntent).toHaveBeenCalledWith('setup_123'); + expect(stripeService.updateCustomer).toHaveBeenCalledWith('cus_123', { invoice_settings: { default_payment_method: 'pm_123', }, @@ -970,16 +873,14 @@ describe('PaymentService', () => { email: 'test@hmt.ai', }; - jest - .spyOn(stripe.setupIntents, 'retrieve') - .mockResolvedValue(undefined as any); + stripeService.retrieveSetupIntent.mockResolvedValue(undefined as any); await expect( paymentService.confirmCard(user as any, { setupId: '1', defaultCard: false, }), - ).rejects.toThrow(ErrorPayment.SetupNotFound); + ).rejects.toThrow(new ServerError(ErrorPayment.SetupNotFound)); }); }); @@ -987,7 +888,7 @@ describe('PaymentService', () => { const user = { id: faker.number.int(), email: faker.internet.email(), - stripeCustomerId: faker.word.sample(), + paymentProviderId: faker.word.sample(), }; const jobEntity = { @@ -1004,53 +905,31 @@ describe('PaymentService', () => { const invoiceId = faker.word.sample(); const paymentMethodId = faker.word.sample(); - it('should charge user credit card and create slash payments successfully', async () => { + it('should create slash successfully', async () => { jest.spyOn(userRepository, 'findById').mockResolvedValueOnce(user as any); - jest - .spyOn(stripe.paymentIntents, 'retrieve') - .mockResolvedValueOnce(paymentIntent as any); - jest - .spyOn(stripe.paymentIntents, 'confirm') - .mockResolvedValueOnce(paymentIntent as any); - jest - .spyOn(stripe.invoices, 'create') - .mockResolvedValueOnce({ id: invoiceId } as any); - jest - .spyOn(stripe.invoiceItems, 'create') - .mockResolvedValueOnce({} as any); - jest - .spyOn(stripe.invoices, 'finalizeInvoice') - .mockResolvedValueOnce({ payment_intent: paymentIntent.id } as any); - jest.spyOn(stripe.customers, 'retrieve').mockResolvedValueOnce({ + + stripeService.createInvoice.mockResolvedValueOnce({ id: invoiceId, payment_intent: paymentIntent } as any); + stripeService.retrievePaymentIntent.mockResolvedValueOnce(paymentIntent as any); + stripeService.handlePaymentIntent.mockResolvedValueOnce(paymentIntent as any); + stripeService.getDefaultPaymentMethod.mockResolvedValueOnce(paymentMethodId); + stripeService.retrieveCustomer.mockResolvedValueOnce({ invoice_settings: { default_payment_method: paymentMethodId }, } as any); const result = await paymentService.createSlash(jobEntity as any); expect(result).toBe(undefined); - expect(stripe.invoices.create).toHaveBeenCalledWith({ - customer: user.stripeCustomerId, - currency: PaymentCurrency.USD, - auto_advance: false, - payment_settings: { - payment_method_types: ['card'], - }, - }); - expect(stripe.invoiceItems.create).toHaveBeenCalledWith({ - customer: user.stripeCustomerId, - amount: expect.any(Number), - invoice: invoiceId, - description: 'Slash Job Id ' + jobEntity.id, - }); - expect(stripe.invoices.finalizeInvoice).toHaveBeenCalledWith(invoiceId); - expect(stripe.paymentIntents.confirm).toHaveBeenCalledWith( - paymentIntent.id, - { - payment_method: paymentMethodId, - off_session: true, - }, + expect(stripeService.createInvoice).toHaveBeenCalledWith( + user.paymentProviderId, + expect.any(Number), + PaymentCurrency.USD, + 'Slash Job Id ' + jobEntity.id + ); + expect(stripeService.handlePaymentIntent).toHaveBeenCalledWith( + paymentIntent, + paymentMethodId, + true ); - expect(paymentRepository.createUnique).toHaveBeenCalledTimes(2); }); it('should fail if user does not have payment info', async () => { @@ -1065,25 +944,18 @@ describe('PaymentService', () => { it('should fail if stripe create payment intent fails', async () => { jest.spyOn(userRepository, 'findById').mockResolvedValueOnce(user as any); - jest - .spyOn(stripe.invoices, 'create') - .mockResolvedValueOnce({ id: invoiceId } as any); - jest - .spyOn(stripe.invoiceItems, 'create') - .mockResolvedValueOnce({} as any); - jest - .spyOn(stripe.invoices, 'finalizeInvoice') - .mockResolvedValueOnce({ payment_intent: paymentIntent.id } as any); - jest.spyOn(stripe.customers, 'retrieve').mockResolvedValueOnce({ + + stripeService.createInvoice.mockResolvedValueOnce({ id: invoiceId } as any); + stripeService.getDefaultPaymentMethod.mockResolvedValueOnce(paymentMethodId); + stripeService.retrieveCustomer.mockResolvedValueOnce({ invoice_settings: { default_payment_method: paymentMethodId }, } as any); - jest - .spyOn(stripe.paymentIntents, 'confirm') - .mockRejectedValue(new Error()); + + stripeService.handlePaymentIntent.mockRejectedValue(new ServerError(ErrorPayment.PaymentMethodAssociationFailed)); await expect( paymentService.createSlash(jobEntity as any), - ).rejects.toThrow(ErrorPayment.PaymentMethodAssociationFailed); + ).rejects.toThrow(new ServerError(ErrorPayment.PaymentMethodAssociationFailed)); }); }); @@ -1091,22 +963,16 @@ describe('PaymentService', () => { it('should list user payment methods successfully', async () => { const user = { id: 1, - stripeCustomerId: 'cus_123', + paymentProviderId: 'cus_123', }; - const paymentMethods = { - data: [ - { id: 'pm_123', card: { brand: 'visa', last4: '4242' } }, - { id: 'pm_456', card: { brand: 'mastercard', last4: '5555' } }, - ], - }; + const paymentMethods = [ + { id: 'pm_123', card: { brand: 'visa', last4: '4242' } }, + { id: 'pm_456', card: { brand: 'mastercard', last4: '5555' } }, + ]; - jest - .spyOn(stripe.customers, 'listPaymentMethods') - .mockResolvedValueOnce(paymentMethods as any); - jest - .spyOn(paymentService as any, 'getDefaultPaymentMethod') - .mockResolvedValueOnce('pm_123'); + stripeService.listPaymentMethods.mockResolvedValue(paymentMethods as any); + stripeService.getDefaultPaymentMethod.mockResolvedValue('pm_123'); const result = await paymentService.listUserPaymentMethods(user as any); @@ -1130,37 +996,29 @@ describe('PaymentService', () => { it('should delete a payment method successfully', async () => { const user = { id: 1, - stripeCustomerId: 'cus_123', + paymentProviderId: 'cus_123', }; - jest - .spyOn(stripe.paymentMethods, 'retrieve') - .mockResolvedValue({ id: 'pm_123' } as any); - jest - .spyOn(paymentService as any, 'getDefaultPaymentMethod') - .mockResolvedValue('pm_456'); + stripeService.retrievePaymentMethod.mockResolvedValue({ id: 'pm_123' } as any); + stripeService.getDefaultPaymentMethod.mockResolvedValue('pm_456'); jest .spyOn(paymentService as any, 'isPaymentMethodInUse') .mockResolvedValue(false); - jest.spyOn(stripe.paymentMethods, 'detach').mockResolvedValue({} as any); + stripeService.detachPaymentMethod.mockResolvedValue({} as any); await paymentService.deletePaymentMethod(user as any, 'pm_123'); - expect(stripe.paymentMethods.detach).toHaveBeenCalledWith('pm_123'); + expect(stripeService.detachPaymentMethod).toHaveBeenCalledWith('pm_123'); }); it('should throw an error when trying to delete the default payment method in use', async () => { const user = { id: 1, - stripeCustomerId: 'cus_123', + paymentProviderId: 'cus_123', }; - jest - .spyOn(stripe.paymentMethods, 'retrieve') - .mockResolvedValue({ id: 'pm_123' } as any); - jest - .spyOn(paymentService as any, 'getDefaultPaymentMethod') - .mockResolvedValue('pm_123'); + stripeService.retrievePaymentMethod.mockResolvedValue({ id: 'pm_123' } as any); + stripeService.getDefaultPaymentMethod.mockResolvedValue('pm_123'); jest .spyOn(paymentService as any, 'isPaymentMethodInUse') .mockResolvedValue(true); @@ -1175,30 +1033,31 @@ describe('PaymentService', () => { it('should get user billing info successfully', async () => { const user = { id: 1, - stripeCustomerId: 'cus_123', + paymentProviderId: 'cus_123', }; const taxIds = { - data: [{ type: VatType.EU_VAT, value: 'DE123456789' }], + data: [ + { + type: VatType.EU_VAT, + value: 'DE123456789', + }, + ], }; const customer = { name: 'John Doe', email: 'john@example.com', address: { - country: Country.DE, + country: 'DE', postal_code: '12345', city: 'Berlin', line1: 'Street 1', }, }; - jest - .spyOn(stripe.customers, 'listTaxIds') - .mockResolvedValue(taxIds as any); - jest - .spyOn(stripe.customers, 'retrieve') - .mockResolvedValue(customer as any); + stripeService.listCustomerTaxIds.mockResolvedValue(taxIds.data as any); + stripeService.retrieveCustomer.mockResolvedValue(customer as any); const result = await paymentService.getUserBillingInfo(user as any); @@ -1208,7 +1067,7 @@ describe('PaymentService', () => { vat: 'DE123456789', vatType: VatType.EU_VAT, address: { - country: Country.DE, + country: 'de', postalCode: '12345', city: 'Berlin', line: 'Street 1', @@ -1221,7 +1080,7 @@ describe('PaymentService', () => { it('should update user billing info successfully', async () => { const user = { id: 1, - stripeCustomerId: 'cus_123', + paymentProviderId: 'cus_123', }; const updateBillingInfoDto = { @@ -1237,22 +1096,18 @@ describe('PaymentService', () => { }, }; - jest - .spyOn(stripe.customers, 'listTaxIds') - .mockResolvedValue({ data: [] } as any); - jest.spyOn(stripe.customers, 'createTaxId').mockResolvedValue({} as any); - jest.spyOn(stripe.customers, 'update').mockResolvedValue({} as any); + stripeService.listCustomerTaxIds.mockResolvedValue([] as any); + stripeService.createTaxId.mockResolvedValue({} as any); + stripeService.updateCustomer.mockResolvedValue({} as any); await paymentService.updateUserBillingInfo( user as any, updateBillingInfoDto, ); - expect(stripe.customers.createTaxId).toHaveBeenCalledWith('cus_123', { - type: VatType.EU_VAT, - value: 'DE123456789', - }); - expect(stripe.customers.update).toHaveBeenCalledWith('cus_123', { + expect(stripeService.createTaxId).toHaveBeenCalledWith('cus_123', VatType.EU_VAT, 'DE123456789'); + + expect(stripeService.updateCustomer).toHaveBeenCalledWith('cus_123', { name: 'John Doe', email: 'john@example.com', address: { @@ -1269,14 +1124,14 @@ describe('PaymentService', () => { it('should change the default payment method successfully', async () => { const user = { id: 1, - stripeCustomerId: 'cus_123', + paymentProviderId: 'cus_123', }; - jest.spyOn(stripe.customers, 'update').mockResolvedValue({} as any); + stripeService.updateCustomer.mockResolvedValue({} as any); await paymentService.changeDefaultPaymentMethod(user as any, 'pm_123'); - expect(stripe.customers.update).toHaveBeenCalledWith('cus_123', { + expect(stripeService.updateCustomer).toHaveBeenCalledWith('cus_123', { invoice_settings: { default_payment_method: 'pm_123' }, }); }); @@ -1422,42 +1277,34 @@ describe('PaymentService', () => { }); describe('getReceipt', () => { - let retrievePaymentIntentMock: jest.SpyInstance; - let retrieveChargeMock: jest.SpyInstance; - - beforeEach(() => { - retrievePaymentIntentMock = jest.spyOn(stripe.paymentIntents, 'retrieve'); - retrieveChargeMock = jest.spyOn(stripe.charges, 'retrieve'); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('should return the receipt URL if payment intent and charge exist', async () => { + it('should get receipt successfully', async () => { const paymentId = 'pi_123'; - const user = { stripeCustomerId: 'cus_123' } as any; + const user = { paymentProviderId: 'cus_123' } as any; - retrievePaymentIntentMock.mockResolvedValue({ + const paymentIntent = { customer: 'cus_123', latest_charge: 'ch_123', - } as any); + }; - retrieveChargeMock.mockResolvedValue({ + const charge = { receipt_url: 'https://receipt.url', - } as any); + }; + + stripeService.retrievePaymentIntent.mockResolvedValue(paymentIntent as any); + stripeService.retrieveCharge.mockResolvedValue(charge as any); const result = await paymentService.getReceipt(paymentId, user); - expect(result).toEqual('https://receipt.url'); - expect(retrievePaymentIntentMock).toHaveBeenCalledWith(paymentId); - expect(retrieveChargeMock).toHaveBeenCalledWith('ch_123'); + + expect(result).toBe('https://receipt.url'); + expect(stripeService.retrievePaymentIntent).toHaveBeenCalledWith('pi_123'); + expect(stripeService.retrieveCharge).toHaveBeenCalledWith('ch_123'); }); it('should throw a NOT_FOUND error if payment intent does not exist', async () => { const paymentId = 'pi_123'; - const user = { stripeCustomerId: 'cus_123' } as any; + const user = { paymentProviderId: 'cus_123' } as any; - retrievePaymentIntentMock.mockResolvedValue(null); + stripeService.retrievePaymentIntent.mockResolvedValue({} as any); await expect(paymentService.getReceipt(paymentId, user)).rejects.toThrow( new NotFoundError(ErrorPayment.NotFound), @@ -1466,14 +1313,14 @@ describe('PaymentService', () => { it('should throw a NOT_FOUND error if charge does not exist', async () => { const paymentId = 'pi_123'; - const user = { stripeCustomerId: 'cus_123' } as any; + const user = { paymentProviderId: 'cus_123' } as any; - retrievePaymentIntentMock.mockResolvedValue({ + stripeService.retrievePaymentIntent.mockResolvedValue({ customer: 'cus_123', latest_charge: 'ch_123', } as any); - retrieveChargeMock.mockResolvedValue(null); + stripeService.retrieveCharge.mockResolvedValue({} as any); await expect(paymentService.getReceipt(paymentId, user)).rejects.toThrow( new NotFoundError(ErrorPayment.NotFound), diff --git a/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.spec.ts b/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.spec.ts index 286864844c..fa27c24afb 100644 --- a/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.spec.ts @@ -34,7 +34,43 @@ describe('StripeService', () => { }).compile(); service = module.get(StripeService); - stripeMock = new Stripe('dummy_key') as jest.Mocked; + + // Create a properly structured mock for Stripe + stripeMock = { + customers: { + create: jest.fn(), + update: jest.fn(), + retrieve: jest.fn(), + listPaymentMethods: jest.fn(), + listTaxIds: jest.fn(), + deleteTaxId: jest.fn(), + createTaxId: jest.fn(), + }, + setupIntents: { + create: jest.fn(), + retrieve: jest.fn(), + }, + paymentIntents: { + confirm: jest.fn(), + update: jest.fn(), + retrieve: jest.fn(), + }, + invoices: { + create: jest.fn(), + finalizeInvoice: jest.fn(), + }, + invoiceItems: { + create: jest.fn(), + }, + paymentMethods: { + detach: jest.fn(), + retrieve: jest.fn(), + }, + charges: { + retrieve: jest.fn(), + }, + } as unknown as jest.Mocked; + (service as any).stripe = stripeMock; loggerSpy = jest.spyOn(Logger.prototype, 'log'); }); From 036c874514f76fe5c924b9b49d7cae58ef2a4f1b Mon Sep 17 00:00:00 2001 From: Nikolai Muhhin Date: Thu, 12 Jun 2025 08:54:23 +0300 Subject: [PATCH 05/16] Code cleanup --- .../server/src/modules/payment/payment.service.spec.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts index 6801c184b6..631fbb7c17 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts @@ -21,7 +21,6 @@ import { } from '../../../test/constants'; import { NetworkConfigService } from '../../common/config/network-config.service'; import { ServerConfigService } from '../../common/config/server-config.service'; -import { StripeConfigService } from '../../common/config/stripe-config.service'; import { TX_CONFIRMATION_TRESHOLD } from '../../common/constants'; import { ErrorPayment, @@ -29,7 +28,6 @@ import { ErrorSignature, } from '../../common/constants/errors'; import { SortDirection } from '../../common/enums/collection'; -import { Country } from '../../common/enums/job'; import { PaymentCurrency, PaymentSortField, From 8acb30b12f969dceda4cdd3d9a3f219519a367ea Mon Sep 17 00:00:00 2001 From: Nikolai Muhhin Date: Sun, 15 Jun 2025 01:36:05 +0300 Subject: [PATCH 06/16] Ignore Idea config --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 70399e1fe6..5983f9ddf9 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,5 @@ dist hardhat-dependency-compiler # cache -cache \ No newline at end of file +cache +*.iml From 96df96b5cfc68351d240878278fcb0742d9113af Mon Sep 17 00:00:00 2001 From: Nikolai Muhhin Date: Sun, 15 Jun 2025 01:36:41 +0300 Subject: [PATCH 07/16] Extract payment provider with stripe implementation --- .../server/src/common/enums/payment.ts | 4 - .../payment/payment-provider.abstract.ts | 195 +++++++++++++ .../src/modules/payment/payment.module.ts | 13 +- .../modules/payment/payment.service.spec.ts | 265 ++++++++++-------- .../src/modules/payment/payment.service.ts | 116 ++++---- .../src/modules/stripe/stripe.module.ts | 2 +- .../src/modules/stripe/stripe.service.spec.ts | 214 ++++++++++++-- .../src/modules/stripe/stripe.service.ts | 247 ++++++++++++---- 8 files changed, 787 insertions(+), 269 deletions(-) create mode 100644 packages/apps/job-launcher/server/src/modules/payment/payment-provider.abstract.ts diff --git a/packages/apps/job-launcher/server/src/common/enums/payment.ts b/packages/apps/job-launcher/server/src/common/enums/payment.ts index 2db8a1325e..c1c42b7e87 100644 --- a/packages/apps/job-launcher/server/src/common/enums/payment.ts +++ b/packages/apps/job-launcher/server/src/common/enums/payment.ts @@ -34,12 +34,8 @@ export enum PaymentStatus { PENDING = 'pending', FAILED = 'failed', SUCCEEDED = 'succeeded', -} - -export enum StripePaymentStatus { CANCELED = 'canceled', REQUIRES_PAYMENT_METHOD = 'requires_payment_method', - SUCCEEDED = 'succeeded', } export enum PaymentSortField { diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment-provider.abstract.ts b/packages/apps/job-launcher/server/src/modules/payment/payment-provider.abstract.ts new file mode 100644 index 0000000000..f07f23ad30 --- /dev/null +++ b/packages/apps/job-launcher/server/src/modules/payment/payment-provider.abstract.ts @@ -0,0 +1,195 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { VatType } from '../../common/enums/payment'; + +export interface PaymentMethod { + id: string; + brand: string; + last4: string; + expMonth: number; + expYear: number; + default: boolean; +} + +export interface BillingAddress { + line1?: string; + city?: string; + country?: string; + postal_code?: string; +} + +export interface CustomerData { + email: string; + name?: string; + address?: BillingAddress; + default_payment_method?: string; +} + +export interface TaxId { + id: string; + type: VatType; + value: string; +} + +export interface Invoice { + id: string; + payment_intent: string | null; + status?: string; + amount_due: number; + currency: string; +} + +export interface SetupIntent { + customer: string; + payment_method: string; +} + +export interface PaymentIntent { + customer: string; + id: string; + client_secret: string; + status: string; + amount: number; + amount_received: number; + currency: string; + latest_charge: string; +} + +@Injectable() +export abstract class PaymentProvider { + protected readonly logger: Logger = new Logger(this.constructor.name); + + /** + * Create a new customer in the payment provider system + * @param email Customer's email address + * @returns Customer ID + */ + abstract createCustomer(email: string): Promise; + + /** + * Create a setup intent for adding a new payment method + * @param customerId Customer ID + * @returns Setup intent client secret + */ + abstract createSetupIntent(customerId: string): Promise; + + /** + * Create an invoice for a customer + * @param customerId Customer ID + * @param amountInCents Amount in cents + * @param currency Currency code + * @param description Invoice description + * @returns Created invoice + */ + abstract createInvoice( + customerId: string, + amountInCents: number, + currency: string, + description: string, + ): Promise; + + /** + * Handle a payment intent (confirm, update, etc.) + * @param paymentIntentId Payment intent ID + * @param paymentMethodId Payment method ID + * @param offSession Whether the payment is off-session + * @returns Updated payment intent + */ + abstract handlePaymentIntent( + paymentIntentId: string, + paymentMethodId: string, + offSession: boolean, + ): Promise; + + /** + * Retrieve a customer's information + * @param customerId Customer ID + * @returns Customer data + */ + abstract retrieveCustomer(customerId: string): Promise; + + /** + * Get the default payment method for a customer + * @param customerId Customer ID + * @returns Payment method ID or null + */ + abstract getDefaultPaymentMethod(customerId: string): Promise; + + /** + * List all payment methods for a customer + * @param customerId Customer ID + * @returns Array of payment methods + */ + abstract listPaymentMethods(customerId: string): Promise; + + /** + * Update customer information + * @param customerId Customer ID + * @param data Customer data to update + * @returns Updated customer data + */ + abstract updateCustomer( + customerId: string, + data: Partial, + ): Promise; + + /** + * List tax IDs for a customer + * @param customerId Customer ID + * @returns Array of tax IDs + */ + abstract listCustomerTaxIds(customerId: string): Promise; + + /** + * Create a tax ID for a customer + * @param customerId Customer ID + * @param type Tax ID type + * @param value Tax ID value + * @returns Created tax ID + */ + abstract createTaxId( + customerId: string, + type: VatType, + value: string, + ): Promise; + + /** + * Delete a tax ID + * @param customerId Customer ID + * @param taxIdId Tax ID to delete + */ + abstract deleteTaxId(customerId: string, taxIdId: string): Promise; + + abstract retrieveSetupIntent(setupId: string): Promise; + + /** + * Retrieve a payment intent + * @param paymentIntentId Payment intent ID + * @returns Payment intent data + */ + abstract retrievePaymentIntent( + paymentIntentId: string | null, + ): Promise; + + /** + * Retrieve a charge + * @param chargeId Charge ID + * @returns Charge data with receipt URL + */ + abstract retrieveCharge(chargeId: string): Promise<{ receipt_url: string }>; + + /** + * Retrieve a payment method + * @param paymentMethodId Payment method ID + * @returns Payment method data + */ + abstract retrievePaymentMethod( + paymentMethodId: string, + ): Promise; + + /** + * Detach a payment method from a customer + * @param paymentMethodId Payment method ID + * @returns Detached payment method + */ + abstract detachPaymentMethod(paymentMethodId: string): Promise; +} diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.module.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.module.ts index 41e66ec4d8..be2824a01b 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.module.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.module.ts @@ -16,6 +16,8 @@ import { JobRepository } from '../job/job.repository'; import { UserRepository } from '../user/user.repository'; import { RateModule } from '../rate/rate.module'; import { StripeModule } from '../stripe/stripe.module'; +import { StripeService } from '../stripe/stripe.service'; +import { PaymentProvider } from './payment-provider.abstract'; @Module({ imports: [ @@ -41,7 +43,16 @@ import { StripeModule } from '../stripe/stripe.module'; }), ], controllers: [PaymentController], - providers: [PaymentService, PaymentRepository, JobRepository, UserRepository], + providers: [ + PaymentService, + PaymentRepository, + JobRepository, + UserRepository, + { + provide: PaymentProvider, + useClass: StripeService, + }, + ], exports: [PaymentService, PaymentRepository], }) export class PaymentModule {} diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts index 631fbb7c17..8b3f73747c 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts @@ -34,7 +34,6 @@ import { PaymentSource, PaymentStatus, PaymentType, - StripePaymentStatus, VatType, } from '../../common/enums/payment'; import { @@ -52,11 +51,11 @@ import { GetPaymentsDto, UserBalanceDto } from './payment.dto'; import { PaymentEntity } from './payment.entity'; import { PaymentRepository } from './payment.repository'; import { PaymentService } from './payment.service'; -import { StripeService } from '../stripe/stripe.service'; +import { PaymentIntent, PaymentProvider } from './payment-provider.abstract'; describe('PaymentService', () => { let paymentService: PaymentService; - let stripeService: jest.Mocked; + let paymentProvider: jest.Mocked; let paymentRepository: jest.Mocked; let userRepository: jest.Mocked; let rateService: jest.Mocked; @@ -82,26 +81,6 @@ describe('PaymentService', () => { }), }, }, - { - provide: StripeService, - useValue: { - createCustomer: jest.fn(), - createSetupIntentAndReturnSecret: jest.fn(), - retrieveSetupIntent: jest.fn(), - createInvoice: jest.fn(), - handlePaymentIntent: jest.fn(), - retrievePaymentIntent: jest.fn(), - retrieveCustomer: jest.fn(), - getDefaultPaymentMethod: jest.fn(), - updateCustomer: jest.fn(), - listPaymentMethods: jest.fn(), - retrievePaymentMethod: jest.fn(), - detachPaymentMethod: jest.fn(), - listCustomerTaxIds: jest.fn(), - retrieveCharge: jest.fn(), - createTaxId: jest.fn(), - }, - }, { provide: PaymentRepository, useValue: createMock(), @@ -128,16 +107,20 @@ describe('PaymentService', () => { getRate: jest.fn().mockResolvedValue(1), }, }, + { + provide: PaymentProvider, + useValue: createMock(), + }, NetworkConfigService, ServerConfigService, ], }).compile(); paymentService = moduleRef.get(PaymentService); + paymentProvider = moduleRef.get(PaymentProvider); paymentRepository = moduleRef.get(PaymentRepository); userRepository = moduleRef.get(UserRepository); rateService = moduleRef.get(RateService); - stripeService = moduleRef.get(StripeService); }); describe('createFiatPayment', () => { @@ -173,9 +156,10 @@ describe('PaymentService', () => { payment_intent: paymentIntent.id, }; - stripeService.createInvoice.mockResolvedValue(invoice as any); - stripeService.retrievePaymentIntent.mockResolvedValue(paymentIntent as any); - stripeService.handlePaymentIntent.mockResolvedValue(paymentIntent as any); + paymentProvider.createInvoice.mockResolvedValue(invoice as any); + paymentProvider.handlePaymentIntent.mockResolvedValue( + paymentIntent as any, + ); jest .spyOn(paymentRepository, 'findOneByTransaction') @@ -188,16 +172,16 @@ describe('PaymentService', () => { const result = await paymentService.createFiatPayment(user as any, dto); expect(result).toEqual(paymentIntent.client_secret); - expect(stripeService.createInvoice).toHaveBeenCalledWith( + expect(paymentProvider.createInvoice).toHaveBeenCalledWith( 'cus_123', 10000, PaymentCurrency.USD, - 'Top up' + 'Top up', ); - expect(stripeService.handlePaymentIntent).toHaveBeenCalledWith( + expect(paymentProvider.handlePaymentIntent).toHaveBeenCalledWith( 'pi_123', 'pm_123', - false + false, ); }); @@ -223,9 +207,10 @@ describe('PaymentService', () => { payment_intent: paymentIntent.id, }; - stripeService.createInvoice.mockResolvedValue(invoice as any); - stripeService.retrievePaymentIntent.mockResolvedValue(paymentIntent as any); - stripeService.handlePaymentIntent.mockResolvedValue(paymentIntent as any); + paymentProvider.createInvoice.mockResolvedValue(invoice as any); + paymentProvider.handlePaymentIntent.mockResolvedValue( + paymentIntent as any, + ); findOneMock.mockResolvedValue({ transaction: paymentIntent.client_secret, @@ -257,13 +242,15 @@ describe('PaymentService', () => { }; const paymentData = { - status: StripePaymentStatus.SUCCEEDED, + status: PaymentStatus.SUCCEEDED, amount: 100, amount_received: 100, currency: PaymentCurrency.USD, }; - stripeService.retrievePaymentIntent.mockResolvedValue(paymentData as any); + paymentProvider.retrievePaymentIntent.mockResolvedValue( + paymentData as any, + ); const paymentEntity: Partial = { userId: userId, @@ -276,50 +263,46 @@ describe('PaymentService', () => { const result = await paymentService.confirmFiatPayment(userId, dto); expect(result).toBe(true); + expect(paymentProvider.retrievePaymentIntent).toHaveBeenCalledWith( + MOCK_PAYMENT_ID, + ); + expect(paymentRepository.updateOne).toHaveBeenCalledWith({ + userId, + amount: 1, + currency: PaymentCurrency.USD, + status: PaymentStatus.SUCCEEDED, + }); }); - it('should handle payment cancellation', async () => { + it('should throw a not found exception if payment not found', async () => { const userId = 1; const dto = { paymentId: MOCK_PAYMENT_ID, }; - const paymentData = { - status: StripePaymentStatus.CANCELED, - amount: 100, - amount_received: 0, - currency: PaymentCurrency.USD, - }; - - stripeService.retrievePaymentIntent.mockResolvedValue(paymentData as any); - - const paymentEntity: Partial = { - userId: userId, - status: PaymentStatus.PENDING, - amount: 0, - currency: PaymentCurrency.USD, - }; - findOneMock.mockResolvedValue(paymentEntity); + findOneMock.mockResolvedValue(null); await expect( paymentService.confirmFiatPayment(userId, dto), - ).rejects.toThrow(new ConflictError(ErrorPayment.NotSuccess)); + ).rejects.toThrow(new NotFoundError(ErrorPayment.NotFound)); }); - it('should handle payment requiring a payment method', async () => { + it('should throw a conflict exception if payment status is not pending', async () => { const userId = 1; const dto = { paymentId: MOCK_PAYMENT_ID, }; const paymentData = { - status: StripePaymentStatus.REQUIRES_PAYMENT_METHOD, + status: PaymentStatus.REQUIRES_PAYMENT_METHOD, amount: 100, amount_received: 0, currency: PaymentCurrency.USD, }; - stripeService.retrievePaymentIntent.mockResolvedValue(paymentData as any); + paymentProvider.retrievePaymentIntent.mockResolvedValue( + paymentData as any, + ); const paymentEntity: Partial = { userId: userId, @@ -347,7 +330,9 @@ describe('PaymentService', () => { currency: PaymentCurrency.USD, }; - stripeService.retrievePaymentIntent.mockResolvedValue(paymentData as any); + paymentProvider.retrievePaymentIntent.mockResolvedValue( + paymentData as any, + ); const paymentEntity: Partial = { userId: userId, @@ -368,7 +353,9 @@ describe('PaymentService', () => { paymentId: MOCK_PAYMENT_ID, }; - stripeService.retrievePaymentIntent.mockResolvedValue({} as any); + paymentProvider.retrievePaymentIntent.mockResolvedValue( + null as unknown as PaymentIntent, + ); await expect( paymentService.confirmFiatPayment(userId, dto), @@ -773,16 +760,18 @@ describe('PaymentService', () => { client_secret: 'clientSecret123', }; - stripeService.createCustomer.mockResolvedValue('cus_123'); - stripeService.createSetupIntentAndReturnSecret.mockResolvedValue(paymentIntent.client_secret); + paymentProvider.createCustomer.mockResolvedValue('cus_123'); + paymentProvider.createSetupIntent.mockResolvedValue( + paymentIntent.client_secret, + ); const result = await paymentService.createCustomerAndAssignCard( user as any, ); expect(result).toEqual(paymentIntent.client_secret); - expect(stripeService.createCustomer).toHaveBeenCalledWith(user.email); - expect(stripeService.createSetupIntentAndReturnSecret).toHaveBeenCalledWith('cus_123'); + expect(paymentProvider.createCustomer).toHaveBeenCalledWith(user.email); + expect(paymentProvider.createSetupIntent).toHaveBeenCalledWith('cus_123'); }); it('should throw a bad request exception if the customer creation fails', async () => { @@ -791,7 +780,9 @@ describe('PaymentService', () => { email: 'test@hmt.ai', paymentProviderId: undefined, }; - stripeService.createCustomer.mockRejectedValue(new ServerError(ErrorPayment.CustomerNotCreated)); + paymentProvider.createCustomer.mockRejectedValue( + new ServerError(ErrorPayment.CustomerNotCreated), + ); await expect( paymentService.createCustomerAndAssignCard(user as any), @@ -804,8 +795,10 @@ describe('PaymentService', () => { email: 'test@hmt.ai', }; - stripeService.createCustomer.mockResolvedValue({ id: 1 } as any); - stripeService.createSetupIntentAndReturnSecret.mockRejectedValue(new ServerError(ErrorPayment.IntentNotCreated)); + paymentProvider.createCustomer.mockResolvedValue({ id: 1 } as any); + paymentProvider.createSetupIntent.mockRejectedValue( + new ServerError(ErrorPayment.IntentNotCreated), + ); await expect( paymentService.createCustomerAndAssignCard(user as any), @@ -818,8 +811,10 @@ describe('PaymentService', () => { email: 'test@hmt.ai', }; - stripeService.createCustomer.mockResolvedValue(user.id.toString()); - stripeService.createSetupIntentAndReturnSecret.mockRejectedValue(new ServerError(ErrorPayment.ClientSecretDoesNotExist)); + paymentProvider.createCustomer.mockResolvedValue(user.id.toString()); + paymentProvider.createSetupIntent.mockRejectedValue( + new ServerError(ErrorPayment.ClientSecretDoesNotExist), + ); await expect( paymentService.createCustomerAndAssignCard(user as any), @@ -840,8 +835,8 @@ describe('PaymentService', () => { payment_method: 'pm_123', }; - stripeService.retrieveSetupIntent.mockResolvedValue(setupMock as any); - stripeService.updateCustomer.mockResolvedValue(null as any); + paymentProvider.retrieveSetupIntent.mockResolvedValue(setupMock as any); + paymentProvider.updateCustomer.mockResolvedValue(null as any); jest .spyOn(userRepository, 'updateOne') .mockResolvedValue(undefined as any); @@ -857,11 +852,11 @@ describe('PaymentService', () => { paymentProviderId: 'cus_123', }), ); - expect(stripeService.retrieveSetupIntent).toHaveBeenCalledWith('setup_123'); - expect(stripeService.updateCustomer).toHaveBeenCalledWith('cus_123', { - invoice_settings: { - default_payment_method: 'pm_123', - }, + expect(paymentProvider.retrieveSetupIntent).toHaveBeenCalledWith( + 'setup_123', + ); + expect(paymentProvider.updateCustomer).toHaveBeenCalledWith('cus_123', { + default_payment_method: 'pm_123', }); }); @@ -871,7 +866,7 @@ describe('PaymentService', () => { email: 'test@hmt.ai', }; - stripeService.retrieveSetupIntent.mockResolvedValue(undefined as any); + paymentProvider.retrieveSetupIntent.mockResolvedValue(undefined as any); await expect( paymentService.confirmCard(user as any, { @@ -905,28 +900,34 @@ describe('PaymentService', () => { it('should create slash successfully', async () => { jest.spyOn(userRepository, 'findById').mockResolvedValueOnce(user as any); - - stripeService.createInvoice.mockResolvedValueOnce({ id: invoiceId, payment_intent: paymentIntent } as any); - stripeService.retrievePaymentIntent.mockResolvedValueOnce(paymentIntent as any); - stripeService.handlePaymentIntent.mockResolvedValueOnce(paymentIntent as any); - stripeService.getDefaultPaymentMethod.mockResolvedValueOnce(paymentMethodId); - stripeService.retrieveCustomer.mockResolvedValueOnce({ + + paymentProvider.createInvoice.mockResolvedValueOnce({ + id: invoiceId, + payment_intent: paymentIntent, + } as any); + paymentProvider.handlePaymentIntent.mockResolvedValueOnce( + paymentIntent as any, + ); + paymentProvider.getDefaultPaymentMethod.mockResolvedValueOnce( + paymentMethodId, + ); + paymentProvider.retrieveCustomer.mockResolvedValueOnce({ invoice_settings: { default_payment_method: paymentMethodId }, } as any); const result = await paymentService.createSlash(jobEntity as any); expect(result).toBe(undefined); - expect(stripeService.createInvoice).toHaveBeenCalledWith( + expect(paymentProvider.createInvoice).toHaveBeenCalledWith( user.paymentProviderId, expect.any(Number), PaymentCurrency.USD, - 'Slash Job Id ' + jobEntity.id + 'Slash Job Id ' + jobEntity.id, ); - expect(stripeService.handlePaymentIntent).toHaveBeenCalledWith( + expect(paymentProvider.handlePaymentIntent).toHaveBeenCalledWith( paymentIntent, paymentMethodId, - true + true, ); }); @@ -943,17 +944,25 @@ describe('PaymentService', () => { it('should fail if stripe create payment intent fails', async () => { jest.spyOn(userRepository, 'findById').mockResolvedValueOnce(user as any); - stripeService.createInvoice.mockResolvedValueOnce({ id: invoiceId } as any); - stripeService.getDefaultPaymentMethod.mockResolvedValueOnce(paymentMethodId); - stripeService.retrieveCustomer.mockResolvedValueOnce({ + paymentProvider.createInvoice.mockResolvedValueOnce({ + id: invoiceId, + } as any); + paymentProvider.getDefaultPaymentMethod.mockResolvedValueOnce( + paymentMethodId, + ); + paymentProvider.retrieveCustomer.mockResolvedValueOnce({ invoice_settings: { default_payment_method: paymentMethodId }, } as any); - stripeService.handlePaymentIntent.mockRejectedValue(new ServerError(ErrorPayment.PaymentMethodAssociationFailed)); + paymentProvider.handlePaymentIntent.mockRejectedValue( + new ServerError(ErrorPayment.PaymentMethodAssociationFailed), + ); await expect( paymentService.createSlash(jobEntity as any), - ).rejects.toThrow(new ServerError(ErrorPayment.PaymentMethodAssociationFailed)); + ).rejects.toThrow( + new ServerError(ErrorPayment.PaymentMethodAssociationFailed), + ); }); }); @@ -965,12 +974,18 @@ describe('PaymentService', () => { }; const paymentMethods = [ - { id: 'pm_123', card: { brand: 'visa', last4: '4242' } }, - { id: 'pm_456', card: { brand: 'mastercard', last4: '5555' } }, + { + id: 'pm_123', + brand: 'visa', + last4: '4242', + }, + { id: 'pm_456', brand: 'mastercard', last4: '5555' }, ]; - stripeService.listPaymentMethods.mockResolvedValue(paymentMethods as any); - stripeService.getDefaultPaymentMethod.mockResolvedValue('pm_123'); + paymentProvider.listPaymentMethods.mockResolvedValue( + paymentMethods as any, + ); + paymentProvider.getDefaultPaymentMethod.mockResolvedValue('pm_123'); const result = await paymentService.listUserPaymentMethods(user as any); @@ -997,16 +1012,20 @@ describe('PaymentService', () => { paymentProviderId: 'cus_123', }; - stripeService.retrievePaymentMethod.mockResolvedValue({ id: 'pm_123' } as any); - stripeService.getDefaultPaymentMethod.mockResolvedValue('pm_456'); + paymentProvider.retrievePaymentMethod.mockResolvedValue({ + id: 'pm_123', + } as any); + paymentProvider.getDefaultPaymentMethod.mockResolvedValue('pm_456'); jest .spyOn(paymentService as any, 'isPaymentMethodInUse') .mockResolvedValue(false); - stripeService.detachPaymentMethod.mockResolvedValue({} as any); + paymentProvider.detachPaymentMethod.mockResolvedValue({} as any); await paymentService.deletePaymentMethod(user as any, 'pm_123'); - expect(stripeService.detachPaymentMethod).toHaveBeenCalledWith('pm_123'); + expect(paymentProvider.detachPaymentMethod).toHaveBeenCalledWith( + 'pm_123', + ); }); it('should throw an error when trying to delete the default payment method in use', async () => { @@ -1015,8 +1034,10 @@ describe('PaymentService', () => { paymentProviderId: 'cus_123', }; - stripeService.retrievePaymentMethod.mockResolvedValue({ id: 'pm_123' } as any); - stripeService.getDefaultPaymentMethod.mockResolvedValue('pm_123'); + paymentProvider.retrievePaymentMethod.mockResolvedValue({ + id: 'pm_123', + } as any); + paymentProvider.getDefaultPaymentMethod.mockResolvedValue('pm_123'); jest .spyOn(paymentService as any, 'isPaymentMethodInUse') .mockResolvedValue(true); @@ -1054,8 +1075,8 @@ describe('PaymentService', () => { }, }; - stripeService.listCustomerTaxIds.mockResolvedValue(taxIds.data as any); - stripeService.retrieveCustomer.mockResolvedValue(customer as any); + paymentProvider.listCustomerTaxIds.mockResolvedValue(taxIds.data as any); + paymentProvider.retrieveCustomer.mockResolvedValue(customer as any); const result = await paymentService.getUserBillingInfo(user as any); @@ -1094,18 +1115,22 @@ describe('PaymentService', () => { }, }; - stripeService.listCustomerTaxIds.mockResolvedValue([] as any); - stripeService.createTaxId.mockResolvedValue({} as any); - stripeService.updateCustomer.mockResolvedValue({} as any); + paymentProvider.listCustomerTaxIds.mockResolvedValue([] as any); + paymentProvider.createTaxId.mockResolvedValue({} as any); + paymentProvider.updateCustomer.mockResolvedValue({} as any); await paymentService.updateUserBillingInfo( user as any, updateBillingInfoDto, ); - expect(stripeService.createTaxId).toHaveBeenCalledWith('cus_123', VatType.EU_VAT, 'DE123456789'); + expect(paymentProvider.createTaxId).toHaveBeenCalledWith( + 'cus_123', + VatType.EU_VAT, + 'DE123456789', + ); - expect(stripeService.updateCustomer).toHaveBeenCalledWith('cus_123', { + expect(paymentProvider.updateCustomer).toHaveBeenCalledWith('cus_123', { name: 'John Doe', email: 'john@example.com', address: { @@ -1125,12 +1150,12 @@ describe('PaymentService', () => { paymentProviderId: 'cus_123', }; - stripeService.updateCustomer.mockResolvedValue({} as any); + paymentProvider.updateCustomer.mockResolvedValue({} as any); await paymentService.changeDefaultPaymentMethod(user as any, 'pm_123'); - expect(stripeService.updateCustomer).toHaveBeenCalledWith('cus_123', { - invoice_settings: { default_payment_method: 'pm_123' }, + expect(paymentProvider.updateCustomer).toHaveBeenCalledWith('cus_123', { + default_payment_method: 'pm_123', }); }); }); @@ -1288,21 +1313,25 @@ describe('PaymentService', () => { receipt_url: 'https://receipt.url', }; - stripeService.retrievePaymentIntent.mockResolvedValue(paymentIntent as any); - stripeService.retrieveCharge.mockResolvedValue(charge as any); + paymentProvider.retrievePaymentIntent.mockResolvedValue( + paymentIntent as any, + ); + paymentProvider.retrieveCharge.mockResolvedValue(charge as any); const result = await paymentService.getReceipt(paymentId, user); expect(result).toBe('https://receipt.url'); - expect(stripeService.retrievePaymentIntent).toHaveBeenCalledWith('pi_123'); - expect(stripeService.retrieveCharge).toHaveBeenCalledWith('ch_123'); + expect(paymentProvider.retrievePaymentIntent).toHaveBeenCalledWith( + 'pi_123', + ); + expect(paymentProvider.retrieveCharge).toHaveBeenCalledWith('ch_123'); }); it('should throw a NOT_FOUND error if payment intent does not exist', async () => { const paymentId = 'pi_123'; const user = { paymentProviderId: 'cus_123' } as any; - stripeService.retrievePaymentIntent.mockResolvedValue({} as any); + paymentProvider.retrievePaymentIntent.mockResolvedValue({} as any); await expect(paymentService.getReceipt(paymentId, user)).rejects.toThrow( new NotFoundError(ErrorPayment.NotFound), @@ -1313,12 +1342,12 @@ describe('PaymentService', () => { const paymentId = 'pi_123'; const user = { paymentProviderId: 'cus_123' } as any; - stripeService.retrievePaymentIntent.mockResolvedValue({ + paymentProvider.retrievePaymentIntent.mockResolvedValue({ customer: 'cus_123', latest_charge: 'ch_123', } as any); - stripeService.retrieveCharge.mockResolvedValue({} as any); + paymentProvider.retrieveCharge.mockResolvedValue({} as any); await expect(paymentService.getReceipt(paymentId, user)).rejects.toThrow( new NotFoundError(ErrorPayment.NotFound), diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts index 4c92a104f5..56c00f6332 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts @@ -16,7 +16,6 @@ import { PaymentSource, PaymentStatus, PaymentType, - StripePaymentStatus, VatType, } from '../../common/enums/payment'; import { add, div, eq, lt, mul } from '../../common/utils/decimal'; @@ -48,7 +47,7 @@ import { JobRepository } from '../job/job.repository'; import { RateService } from '../rate/rate.service'; import { UserEntity } from '../user/user.entity'; import { UserRepository } from '../user/user.repository'; -import { StripeService } from '../stripe/stripe.service'; +import { PaymentProvider } from './payment-provider.abstract'; @Injectable() export class PaymentService { @@ -63,25 +62,24 @@ export class PaymentService { private readonly jobRepository: JobRepository, private readonly serverConfigService: ServerConfigService, private readonly rateService: RateService, - private readonly stripeService: StripeService, - ) { } + private readonly paymentProvider: PaymentProvider, + ) {} public async createCustomerAndAssignCard(user: UserEntity): Promise { - // Creates a new Stripe customer if the user does not already have one. - // It then initiates a SetupIntent to link a payment method (card) to the customer. - let customerId = user.paymentProviderId; if (!customerId) { - customerId = await this.stripeService.createCustomer(user.email); + customerId = await this.paymentProvider.createCustomer(user.email); } - return await this.stripeService.createSetupIntentAndReturnSecret(customerId); + return await this.paymentProvider.createSetupIntent(customerId); } - public async confirmCard(user: UserEntity, data: CardConfirmDto): Promise { - // Confirms the card setup using the Stripe SetupIntent and sets it as the default payment method if requested. - const setup = await this.stripeService.retrieveSetupIntent(data.setupId); + public async confirmCard( + user: UserEntity, + data: CardConfirmDto, + ): Promise { + const setup = await this.paymentProvider.retrieveSetupIntent(data.setupId); if (!setup) { this.logger.log(ErrorPayment.SetupNotFound, PaymentService.name); @@ -90,20 +88,17 @@ export class PaymentService { let defaultPaymentMethod: string | null = null; if (!user.paymentProviderId) { - // Assign the Stripe customer ID to the user if it does not exist yet. user.paymentProviderId = setup.customer as string; await this.userRepository.updateOne(user); } else { - // Check if the user already has a default payment method. - defaultPaymentMethod = await this.getDefaultPaymentMethod(user.paymentProviderId); + defaultPaymentMethod = await this.getDefaultPaymentMethod( + user.paymentProviderId, + ); } if (data.defaultCard || !defaultPaymentMethod) { - // Update Stripe customer settings to use this payment method by default. - await this.stripeService.updateCustomer(user.paymentProviderId, { - invoice_settings: { - default_payment_method: setup.payment_method as string, - }, + await this.paymentProvider.updateCustomer(user.paymentProviderId, { + default_payment_method: setup.payment_method as string, }); } @@ -121,14 +116,14 @@ export class PaymentService { throw new NotFoundError(ErrorPayment.CustomerNotFound); } - const invoice = await this.stripeService.createInvoice( + const invoice = await this.paymentProvider.createInvoice( user.paymentProviderId, amountInCents, currency, 'Top up', ); - const paymentIntent = await this.stripeService.handlePaymentIntent( + const paymentIntent = await this.paymentProvider.handlePaymentIntent( invoice.payment_intent as string, paymentMethodId, false, // on-session payment @@ -164,8 +159,7 @@ export class PaymentService { userId: number, data: PaymentFiatConfirmDto, ): Promise { - // Confirms a fiat payment based on the PaymentIntent ID and updates its status in the system. - const paymentData = await this.stripeService.retrievePaymentIntent( + const paymentData = await this.paymentProvider.retrievePaymentIntent( data.paymentId, ); @@ -189,13 +183,14 @@ export class PaymentService { } if ( - paymentData?.status === StripePaymentStatus.CANCELED || - paymentData?.status === StripePaymentStatus.REQUIRES_PAYMENT_METHOD + paymentData.status === PaymentStatus.CANCELED || + paymentData.status === PaymentStatus.REQUIRES_PAYMENT_METHOD ) { paymentEntity.status = PaymentStatus.FAILED; await this.paymentRepository.updateOne(paymentEntity); + throw new ConflictError(ErrorPayment.NotSuccess); - } else if (paymentData?.status !== StripePaymentStatus.SUCCEEDED) { + } else if (paymentData.status !== PaymentStatus.SUCCEEDED) { return false; // TODO: Handling other cases } @@ -311,12 +306,10 @@ export class PaymentService { currency, ); - const balance = paymentEntities.reduce( + return paymentEntities.reduce( (sum, payment) => add(sum, Number(payment.amount)), 0, ); - - return balance; } public async createRefundPayment(dto: PaymentRefund) { @@ -357,7 +350,7 @@ export class PaymentService { } const amountInCents = Math.ceil(mul(amount, 100)); - const invoice = await this.stripeService.createInvoice( + const invoice = await this.paymentProvider.createInvoice( user.paymentProviderId, amountInCents, currency, @@ -372,7 +365,7 @@ export class PaymentService { throw new ServerError(ErrorPayment.NotDefaultPaymentMethod); } - const paymentIntent = await this.stripeService.handlePaymentIntent( + const paymentIntent = await this.paymentProvider.handlePaymentIntent( invoice.payment_intent as string, defaultPaymentMethod, true, // off-session payment @@ -437,19 +430,22 @@ export class PaymentService { return cards; } - // List all the payment methods (cards) associated with the user's Stripe account - const paymentMethods = await this.stripeService.listPaymentMethods(user.paymentProviderId); + // List all the payment methods (cards) associated with the user's account + const paymentMethods = await this.paymentProvider.listPaymentMethods( + user.paymentProviderId, + ); - // Get the default payment method for the user - const defaultPaymentMethod = await this.getDefaultPaymentMethod(user.paymentProviderId); + const defaultPaymentMethod = await this.getDefaultPaymentMethod( + user.paymentProviderId, + ); for (const paymentMethod of paymentMethods) { const card = new CardDto(); card.id = paymentMethod.id; - card.brand = paymentMethod.card?.brand as string; - card.last4 = paymentMethod.card?.last4 as string; - card.expMonth = paymentMethod.card?.exp_month as number; - card.expYear = paymentMethod.card?.exp_year as number; + card.brand = paymentMethod.brand; + card.last4 = paymentMethod.last4; + card.expMonth = paymentMethod.expMonth; + card.expYear = paymentMethod.expYear; card.default = defaultPaymentMethod === paymentMethod.id; cards.push(card); } @@ -458,20 +454,21 @@ export class PaymentService { async deletePaymentMethod(user: UserEntity, paymentMethodId: string) { // Retrieve the payment method to be detached - const paymentMethod = await this.stripeService.retrievePaymentMethod(paymentMethodId); + const paymentMethod = + await this.paymentProvider.retrievePaymentMethod(paymentMethodId); // Check if the payment method is the default one and in use for the user if ( user.paymentProviderId && paymentMethod.id === - (await this.getDefaultPaymentMethod(user.paymentProviderId)) && + (await this.getDefaultPaymentMethod(user.paymentProviderId)) && (await this.isPaymentMethodInUse(user.id)) ) { throw new ConflictError(ErrorPayment.PaymentMethodInUse); } // Detach the payment method from the user's account - return this.stripeService.detachPaymentMethod(paymentMethodId); + return this.paymentProvider.detachPaymentMethod(paymentMethodId); } async getUserBillingInfo(user: UserEntity): Promise { @@ -479,12 +476,13 @@ export class PaymentService { return null; } - // Retrieve the customer's tax IDs and customer information - const taxIds = await this.stripeService.listCustomerTaxIds( + const taxIds = await this.paymentProvider.listCustomerTaxIds( user.paymentProviderId, ); - const customer = await this.stripeService.retrieveCustomer(user.paymentProviderId); + const customer = await this.paymentProvider.retrieveCustomer( + user.paymentProviderId, + ); const userBillingInfo = new BillingInfoDto(); if (customer.address) { @@ -509,19 +507,20 @@ export class PaymentService { if (!user.paymentProviderId) { throw new NotFoundError(ErrorPayment.CustomerNotFound); } - // If the VAT or VAT type has changed, update it in Stripe - const existingTaxIds = await this.stripeService.listCustomerTaxIds( + + const existingTaxIds = await this.paymentProvider.listCustomerTaxIds( user.paymentProviderId, ); // Delete any existing tax IDs before adding the new one for (const taxId of existingTaxIds) { - await this.stripeService.deleteTaxId(user.paymentProviderId, taxId.id); + await this.paymentProvider.deleteTaxId(user.paymentProviderId, taxId.id); } // Create the new VAT tax ID if (updateBillingInfoDto.vat && updateBillingInfoDto.vatType) { - await this.stripeService.createTaxId(user.paymentProviderId, + await this.paymentProvider.createTaxId( + user.paymentProviderId, updateBillingInfoDto.vatType, updateBillingInfoDto.vat, ); @@ -533,7 +532,7 @@ export class PaymentService { updateBillingInfoDto.name || updateBillingInfoDto.email ) { - return this.stripeService.updateCustomer(user.paymentProviderId, { + return this.paymentProvider.updateCustomer(user.paymentProviderId, { address: { line1: updateBillingInfoDto.address?.line, city: updateBillingInfoDto.address?.city, @@ -551,9 +550,8 @@ export class PaymentService { throw new NotFoundError(ErrorPayment.CustomerNotFound); } - // Update the user's default payment method in Stripe - return this.stripeService.updateCustomer(user.paymentProviderId, { - invoice_settings: { default_payment_method: cardId }, + return this.paymentProvider.updateCustomer(user.paymentProviderId, { + default_payment_method: cardId, }); } @@ -562,8 +560,7 @@ export class PaymentService { throw new NotFoundError(ErrorPayment.CustomerNotFound); } - // Retrieve the customer from Stripe and return the default payment method - return await this.stripeService.getDefaultPaymentMethod(customerId); + return await this.paymentProvider.getDefaultPaymentMethod(customerId); } private async isPaymentMethodInUse(userId: number): Promise { @@ -606,15 +603,14 @@ export class PaymentService { } async getReceipt(paymentId: string, user: UserEntity): Promise { - // Retrieve the payment intent using the provided payment ID - const paymentIntent = await this.stripeService.retrievePaymentIntent(paymentId); + const paymentIntent = + await this.paymentProvider.retrievePaymentIntent(paymentId); if (!paymentIntent || paymentIntent.customer !== user.paymentProviderId) { throw new NotFoundError(ErrorPayment.NotFound); } - // Retrieve the charge for the payment intent and ensure it has a receipt URL - const charge = await this.stripeService.retrieveCharge( + const charge = await this.paymentProvider.retrieveCharge( paymentIntent.latest_charge as string, ); diff --git a/packages/apps/job-launcher/server/src/modules/stripe/stripe.module.ts b/packages/apps/job-launcher/server/src/modules/stripe/stripe.module.ts index 73b218670f..b130c17151 100644 --- a/packages/apps/job-launcher/server/src/modules/stripe/stripe.module.ts +++ b/packages/apps/job-launcher/server/src/modules/stripe/stripe.module.ts @@ -6,4 +6,4 @@ import { StripeConfigService } from '../../common/config/stripe-config.service'; providers: [StripeService, StripeConfigService], exports: [StripeService], }) -export class StripeModule {} \ No newline at end of file +export class StripeModule {} diff --git a/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.spec.ts b/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.spec.ts index fa27c24afb..21b29125d3 100644 --- a/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.spec.ts @@ -5,7 +5,12 @@ import { StripeConfigService } from '../../common/config/stripe-config.service'; import Stripe from 'stripe'; import { ServerError } from '../../common/errors'; import { ErrorPayment } from '../../common/constants/errors'; -import { VatType } from '../../common/enums/payment'; +import { + PaymentCurrency, + PaymentStatus, + VatType, +} from '../../common/enums/payment'; +import { PaymentProvider } from '../payment/payment-provider.abstract'; jest.mock('stripe'); @@ -34,7 +39,7 @@ describe('StripeService', () => { }).compile(); service = module.get(StripeService); - + // Create a properly structured mock for Stripe stripeMock = { customers: { @@ -79,6 +84,10 @@ describe('StripeService', () => { jest.clearAllMocks(); }); + it('should implement PaymentProvider interface', () => { + expect(service).toBeInstanceOf(PaymentProvider); + }); + describe('createCustomer', () => { it('should create a customer successfully', async () => { const mockCustomer = { id: 'cus_123' }; @@ -87,11 +96,15 @@ describe('StripeService', () => { const result = await service.createCustomer('test@example.com'); expect(result).toBe('cus_123'); - expect(stripeMock.customers.create).toHaveBeenCalledWith({ email: 'test@example.com' }); + expect(stripeMock.customers.create).toHaveBeenCalledWith({ + email: 'test@example.com', + }); }); it('should handle errors when creating customer', async () => { - stripeMock.customers.create = jest.fn().mockRejectedValue(new Error('Stripe error')); + stripeMock.customers.create = jest + .fn() + .mockRejectedValue(new Error('Stripe error')); await expect(service.createCustomer('test@example.com')).rejects.toThrow( new ServerError(ErrorPayment.CustomerNotCreated), @@ -100,15 +113,20 @@ describe('StripeService', () => { }); }); - describe('createSetupIntentAndReturnSecret', () => { + describe('createSetupIntent', () => { const mockSetupIntent = { + id: 'seti_123', client_secret: 'seti_secret_123', + customer: 'cus_123', + payment_method: 'pm_123', }; it('should create setup intent successfully', async () => { - stripeMock.setupIntents.create = jest.fn().mockResolvedValue(mockSetupIntent); + stripeMock.setupIntents.create = jest + .fn() + .mockResolvedValue(mockSetupIntent); - const result = await service.createSetupIntentAndReturnSecret('cus_123'); + const result = await service.createSetupIntent('cus_123'); expect(result).toBe('seti_secret_123'); expect(stripeMock.setupIntents.create).toHaveBeenCalledWith({ @@ -118,9 +136,11 @@ describe('StripeService', () => { }); it('should handle null customerId', async () => { - stripeMock.setupIntents.create = jest.fn().mockResolvedValue(mockSetupIntent); + stripeMock.setupIntents.create = jest + .fn() + .mockResolvedValue(mockSetupIntent); - await service.createSetupIntentAndReturnSecret(null); + await service.createSetupIntent(null); expect(stripeMock.setupIntents.create).toHaveBeenCalledWith({ automatic_payment_methods: { enabled: true }, @@ -131,7 +151,7 @@ describe('StripeService', () => { it('should handle missing client secret', async () => { stripeMock.setupIntents.create = jest.fn().mockResolvedValue({}); - await expect(service.createSetupIntentAndReturnSecret('cus_123')).rejects.toThrow( + await expect(service.createSetupIntent('cus_123')).rejects.toThrow( new ServerError(ErrorPayment.ClientSecretDoesNotExist), ); }); @@ -141,13 +161,26 @@ describe('StripeService', () => { const mockPaymentIntent = { id: 'pi_123', client_secret: 'pi_secret_123', + status: PaymentStatus.REQUIRES_PAYMENT_METHOD, + amount: 1000, + currency: PaymentCurrency.USD, + customer: 'cus_123', + latest_charge: 'ch_123', }; it('should handle off-session payment intent', async () => { - stripeMock.paymentIntents.confirm = jest.fn().mockResolvedValue({}); - stripeMock.paymentIntents.retrieve = jest.fn().mockResolvedValue(mockPaymentIntent); - - const result = await service.handlePaymentIntent('pi_123', 'pm_123', true); + stripeMock.paymentIntents.confirm = jest + .fn() + .mockResolvedValue(mockPaymentIntent); + stripeMock.paymentIntents.retrieve = jest + .fn() + .mockResolvedValue(mockPaymentIntent); + + const result = await service.handlePaymentIntent( + 'pi_123', + 'pm_123', + true, + ); expect(stripeMock.paymentIntents.confirm).toHaveBeenCalledWith('pi_123', { payment_method: 'pm_123', @@ -157,10 +190,18 @@ describe('StripeService', () => { }); it('should handle on-session payment intent', async () => { - stripeMock.paymentIntents.update = jest.fn().mockResolvedValue({}); - stripeMock.paymentIntents.retrieve = jest.fn().mockResolvedValue(mockPaymentIntent); - - const result = await service.handlePaymentIntent('pi_123', 'pm_123', false); + stripeMock.paymentIntents.update = jest + .fn() + .mockResolvedValue(mockPaymentIntent); + stripeMock.paymentIntents.retrieve = jest + .fn() + .mockResolvedValue(mockPaymentIntent); + + const result = await service.handlePaymentIntent( + 'pi_123', + 'pm_123', + false, + ); expect(stripeMock.paymentIntents.update).toHaveBeenCalledWith('pi_123', { payment_method: 'pm_123', @@ -173,14 +214,26 @@ describe('StripeService', () => { const mockInvoice = { id: 'inv_123', payment_intent: 'pi_123', + status: 'draft', + amount_due: 1000, + currency: PaymentCurrency.USD, }; it('should create invoice successfully', async () => { - stripeMock.invoices.create = jest.fn().mockResolvedValue({ id: 'inv_123' }); + stripeMock.invoices.create = jest + .fn() + .mockResolvedValue({ id: 'inv_123' }); stripeMock.invoiceItems.create = jest.fn().mockResolvedValue({}); - stripeMock.invoices.finalizeInvoice = jest.fn().mockResolvedValue(mockInvoice); - - const result = await service.createInvoice('cus_123', 1000, 'usd', 'Test invoice'); + stripeMock.invoices.finalizeInvoice = jest + .fn() + .mockResolvedValue(mockInvoice); + + const result = await service.createInvoice( + 'cus_123', + 1000, + PaymentCurrency.USD, + 'Test invoice', + ); expect(stripeMock.invoices.create).toHaveBeenCalled(); expect(stripeMock.invoiceItems.create).toHaveBeenCalled(); @@ -189,12 +242,21 @@ describe('StripeService', () => { }); it('should throw error when payment intent is missing', async () => { - stripeMock.invoices.create = jest.fn().mockResolvedValue({ id: 'inv_123' }); + stripeMock.invoices.create = jest + .fn() + .mockResolvedValue({ id: 'inv_123' }); stripeMock.invoiceItems.create = jest.fn().mockResolvedValue({}); - stripeMock.invoices.finalizeInvoice = jest.fn().mockResolvedValue({ id: 'inv_123' }); + stripeMock.invoices.finalizeInvoice = jest + .fn() + .mockResolvedValue({ id: 'inv_123' }); await expect( - service.createInvoice('cus_123', 1000, 'usd', 'Test invoice'), + service.createInvoice( + 'cus_123', + 1000, + PaymentCurrency.USD, + 'Test invoice', + ), ).rejects.toThrow(new ServerError(ErrorPayment.IntentNotCreated)); }); }); @@ -204,6 +266,16 @@ describe('StripeService', () => { const mockCustomer = { id: 'cus_123', name: 'Updated Name', + email: 'test@example.com', + address: { + line1: '123 Street', + city: 'City', + country: 'US', + postal_code: '12345', + }, + invoice_settings: { + default_payment_method: 'pm_123', + }, }; stripeMock.customers.update = jest.fn().mockResolvedValue(mockCustomer); @@ -217,17 +289,94 @@ describe('StripeService', () => { const result = await service.updateCustomer('cus_123', updateData); - expect(stripeMock.customers.update).toHaveBeenCalledWith('cus_123', updateData); - expect(result).toEqual(mockCustomer); + expect(result).toEqual({ + email: mockCustomer.email, + name: mockCustomer.name, + address: mockCustomer.address, + default_payment_method: + mockCustomer.invoice_settings.default_payment_method, + }); + expect(stripeMock.customers.update).toHaveBeenCalledWith( + 'cus_123', + updateData, + ); + }); + }); + + describe('retrievePaymentMethod', () => { + it('should retrieve payment method successfully', async () => { + const mockPaymentMethod = { + id: 'pm_123', + card: { + brand: 'visa', + last4: '4242', + exp_month: 12, + exp_year: 2024, + }, + }; + + stripeMock.paymentMethods.retrieve = jest + .fn() + .mockResolvedValue(mockPaymentMethod); + + const result = await service.retrievePaymentMethod('pm_123'); + + expect(result).toEqual({ + id: 'pm_123', + brand: 'visa', + last4: '4242', + expMonth: 12, + expYear: 2024, + default: false, + }); + expect(stripeMock.paymentMethods.retrieve).toHaveBeenCalledWith('pm_123'); + }); + }); + + describe('detachPaymentMethod', () => { + it('should detach payment method successfully', async () => { + const mockPaymentMethod = { + id: 'pm_123', + card: { + brand: 'visa', + last4: '4242', + exp_month: 12, + exp_year: 2024, + }, + }; + + stripeMock.paymentMethods.detach = jest + .fn() + .mockResolvedValue(mockPaymentMethod); + + const result = await service.detachPaymentMethod('pm_123'); + + expect(result).toEqual({ + id: 'pm_123', + brand: 'visa', + last4: '4242', + expMonth: 12, + expYear: 2024, + default: false, + }); + expect(stripeMock.paymentMethods.detach).toHaveBeenCalledWith('pm_123'); }); }); describe('tax ID operations', () => { it('should create tax ID successfully', async () => { - const mockTaxId = { id: 'txi_123', type: VatType.EU_VAT, value: 'DE123456789' }; + const mockTaxId = { + id: 'txi_123', + type: VatType.EU_VAT, + value: 'DE123456789', + }; stripeMock.customers.createTaxId = jest.fn().mockResolvedValue(mockTaxId); - const result = await service.createTaxId('cus_123', VatType.EU_VAT, 'DE123456789'); + const result = await service.createTaxId( + 'cus_123', + VatType.EU_VAT, + 'DE123456789', + ); expect(stripeMock.customers.createTaxId).toHaveBeenCalledWith('cus_123', { type: VatType.EU_VAT, @@ -251,7 +400,10 @@ describe('StripeService', () => { await service.deleteTaxId('cus_123', 'txi_123'); - expect(stripeMock.customers.deleteTaxId).toHaveBeenCalledWith('cus_123', 'txi_123'); + expect(stripeMock.customers.deleteTaxId).toHaveBeenCalledWith( + 'cus_123', + 'txi_123', + ); }); }); -}); \ No newline at end of file +}); diff --git a/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.ts b/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.ts index 75b2c5419f..e03d9f45e1 100644 --- a/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.ts +++ b/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.ts @@ -1,18 +1,26 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import Stripe from 'stripe'; import { StripeConfigService } from '../../common/config/stripe-config.service'; -import { ServerError } from '../../common/errors'; +import { NotFoundError, ServerError } from '../../common/errors'; import { ErrorPayment } from '../../common/constants/errors'; import { VatType } from '../../common/enums/payment'; +import { + PaymentProvider, + PaymentMethod, + CustomerData, + TaxId, + Invoice, + PaymentIntent, + SetupIntent, +} from '../payment/payment-provider.abstract'; @Injectable() -export class StripeService { - - private readonly logger = new Logger(StripeService.name); - +export class StripeService extends PaymentProvider { private stripe: Stripe; constructor(private stripeConfigService: StripeConfigService) { + super(); + this.stripe = new Stripe(this.stripeConfigService.secretKey, { apiVersion: this.stripeConfigService.apiVersion as any, appInfo: { @@ -33,7 +41,7 @@ export class StripeService { } } - async createSetupIntentAndReturnSecret(customerId: string | null): Promise { + async createSetupIntent(customerId: string | null): Promise { let setupIntent: Stripe.Response; try { @@ -47,14 +55,22 @@ export class StripeService { } if (!setupIntent?.client_secret) { - this.logger.log(ErrorPayment.ClientSecretDoesNotExist, StripeService.name); + this.logger.log( + ErrorPayment.ClientSecretDoesNotExist, + StripeService.name, + ); throw new ServerError(ErrorPayment.ClientSecretDoesNotExist); } return setupIntent.client_secret; } - async createInvoice(customerId: string, amountInCents: number, currency: string, description: string): Promise { + async createInvoice( + customerId: string, + amountInCents: number, + currency: string, + description: string, + ): Promise { let invoice = await this.stripe.invoices.create({ customer: customerId, currency: currency, @@ -77,10 +93,20 @@ export class StripeService { throw new ServerError(ErrorPayment.IntentNotCreated); } - return invoice; + return { + id: invoice.id, + payment_intent: invoice.payment_intent as string, + status: invoice.status?.toString(), + amount_due: invoice.amount_due, + currency: invoice.currency, + }; } - async handlePaymentIntent(paymentIntentId: string, paymentMethodId: string, offSession: boolean): Promise { + async handlePaymentIntent( + paymentIntentId: string, + paymentMethodId: string, + offSession: boolean, + ): Promise { try { if (offSession) { await this.stripe.paymentIntents.confirm(paymentIntentId, { @@ -96,86 +122,199 @@ export class StripeService { throw new ServerError(ErrorPayment.PaymentMethodAssociationFailed); } - const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentIntentId); + const paymentIntent = + await this.stripe.paymentIntents.retrieve(paymentIntentId); if (!paymentIntent?.client_secret) { throw new ServerError(ErrorPayment.ClientSecretDoesNotExist); } - return paymentIntent; + return { + id: paymentIntent.id, + customer: paymentIntent.customer as string, + client_secret: paymentIntent.client_secret, + status: paymentIntent.status, + amount: paymentIntent.amount, + amount_received: paymentIntent.amount_received, + currency: paymentIntent.currency, + latest_charge: paymentIntent.latest_charge as string, + }; } - async retrievePaymentIntent(paymentIntentId: string): Promise { - return this.stripe.paymentIntents.retrieve(paymentIntentId); + async retrievePaymentIntent(paymentIntentId: string): Promise { + const paymentIntent = + await this.stripe.paymentIntents.retrieve(paymentIntentId); + + if (!paymentIntent) { + throw new NotFoundError(ErrorPayment.NotFound); + } + + if (!paymentIntent.client_secret) { + throw new ServerError(ErrorPayment.ClientSecretDoesNotExist); + } + + return { + id: paymentIntent.id, + customer: paymentIntent.customer as string, + client_secret: paymentIntent.client_secret, + status: paymentIntent.status, + amount: paymentIntent.amount, + amount_received: paymentIntent.amount_received, + currency: paymentIntent.currency, + latest_charge: paymentIntent.latest_charge as string, + }; } - async retrieveCustomer(customerId: string): Promise { - return (await this.stripe.customers.retrieve(customerId)) as Stripe.Customer; + async retrieveCustomer(customerId: string): Promise { + const customer = (await this.stripe.customers.retrieve( + customerId, + )) as Stripe.Customer; + + return { + email: customer.email!, + name: customer.name ?? undefined, + address: customer.address + ? { + line1: customer.address.line1 ?? undefined, + city: customer.address.city ?? undefined, + country: customer.address.country ?? undefined, + postal_code: customer.address.postal_code ?? undefined, + } + : undefined, + default_payment_method: customer.invoice_settings + .default_payment_method as string, + }; } async getDefaultPaymentMethod(customerId: string): Promise { const customer = await this.retrieveCustomer(customerId); - - return customer.invoice_settings.default_payment_method as string; + return customer.default_payment_method ?? null; } - async listPaymentMethods(customerId: string): Promise { + async listPaymentMethods(customerId: string): Promise { const paymentMethods = await this.stripe.customers.listPaymentMethods( customerId, { type: 'card', limit: 100 }, ); - return paymentMethods.data; + const defaultPaymentMethod = await this.getDefaultPaymentMethod(customerId); + + return paymentMethods.data.map((method) => ({ + id: method.id, + brand: method.card?.brand as string, + last4: method.card?.last4 as string, + expMonth: method.card?.exp_month as number, + expYear: method.card?.exp_year as number, + default: defaultPaymentMethod === method.id, + })); } - async detachPaymentMethod(paymentMethodId: string): Promise { - return this.stripe.paymentMethods.detach(paymentMethodId); + async detachPaymentMethod(paymentMethodId: string): Promise { + const paymentMethod = + await this.stripe.paymentMethods.detach(paymentMethodId); + + return { + id: paymentMethod.id, + brand: paymentMethod.card?.brand as string, + last4: paymentMethod.card?.last4 as string, + expMonth: paymentMethod.card?.exp_month as number, + expYear: paymentMethod.card?.exp_year as number, + default: false, + }; } - async retrievePaymentMethod(paymentMethodId: string): Promise { - return this.stripe.paymentMethods.retrieve(paymentMethodId); + async retrievePaymentMethod(paymentMethodId: string): Promise { + const paymentMethod = + await this.stripe.paymentMethods.retrieve(paymentMethodId); + + return { + id: paymentMethod.id, + brand: paymentMethod.card?.brand as string, + last4: paymentMethod.card?.last4 as string, + expMonth: paymentMethod.card?.exp_month as number, + expYear: paymentMethod.card?.exp_year as number, + default: false, // We don't know if it's default without customer context + }; } async updateCustomer( customerId: string, - data: Partial<{ - address: { - line1?: string; - city?: string; - country?: string; - postal_code?: string; - }; - name?: string; - email?: string; - invoice_settings?: Partial<{ - default_payment_method?: string; - }>; - }>, - ): Promise { - return this.stripe.customers.update(customerId, data); - } - - async listCustomerTaxIds(customerId: string): Promise { - const taxIds = await this.stripe.customers.listTaxIds(customerId); - return taxIds.data; + data: Partial, + ): Promise { + const params = data.default_payment_method + ? { + ...data, + invoice_settings: { + default_payment_method: data.default_payment_method, + }, + } + : data; + + const customer = (await this.stripe.customers.update( + customerId, + params, + )) as Stripe.Customer; + + return { + email: customer.email!, + name: customer.name ?? undefined, + address: customer.address + ? { + line1: customer.address.line1 ?? undefined, + city: customer.address.city ?? undefined, + country: customer.address.country ?? undefined, + postal_code: customer.address.postal_code ?? undefined, + } + : undefined, + default_payment_method: customer.invoice_settings + .default_payment_method as string, + }; } - async deleteTaxId(customerId: string, taxId: string): Promise { - await this.stripe.customers.deleteTaxId(customerId, taxId); + async listCustomerTaxIds(customerId: string): Promise { + const taxIds = await this.stripe.customers.listTaxIds(customerId); + + return taxIds.data.map((taxId) => ({ + id: taxId.id, + type: taxId.type as VatType, + value: taxId.value, + })); } - async createTaxId(customerId: string, type: VatType, value: string): Promise { - return this.stripe.customers.createTaxId(customerId, { + async createTaxId( + customerId: string, + type: VatType, + value: string, + ): Promise { + const taxId = await this.stripe.customers.createTaxId(customerId, { type, value, }); + return { + id: taxId.id, + type: taxId.type as VatType, + value: taxId.value, + }; + } + + async deleteTaxId(customerId: string, taxIdId: string): Promise { + await this.stripe.customers.deleteTaxId(customerId, taxIdId); } - async retrieveSetupIntent(setupIntentId: string): Promise { - return this.stripe.setupIntents.retrieve(setupIntentId); + async retrieveSetupIntent(setupIntentId: string): Promise { + const setupIntent = await this.stripe.setupIntents.retrieve(setupIntentId); + + return { + customer: setupIntent.customer as string, + payment_method: setupIntent.payment_method as string, + }; } - async retrieveCharge(chargeId: string): Promise { - return this.stripe.charges.retrieve(chargeId); + async retrieveCharge(chargeId: string): Promise<{ receipt_url: string }> { + const charge = await this.stripe.charges.retrieve(chargeId); + if (!charge.receipt_url) { + throw new ServerError(ErrorPayment.NotFound); + } + return { receipt_url: charge.receipt_url }; } -} \ No newline at end of file +} From f948646229aa79805098d2f1389cbf65cf2fd2c8 Mon Sep 17 00:00:00 2001 From: Nikolai Muhhin Date: Tue, 17 Jun 2025 20:33:13 +0300 Subject: [PATCH 08/16] Move files to proper packages --- .../src/modules/payment/payment.interface.ts | 54 ++++++++++++++++ .../src/modules/payment/payment.module.ts | 6 +- .../src/modules/payment/payment.repository.ts | 2 +- .../modules/payment/payment.service.spec.ts | 3 +- .../src/modules/payment/payment.service.ts | 3 +- .../payment-provider.abstract.ts | 63 +++---------------- .../providers}/stripe/stripe.module.ts | 2 +- .../providers}/stripe/stripe.service.spec.ts | 10 +-- .../providers}/stripe/stripe.service.ts | 12 ++-- 9 files changed, 82 insertions(+), 73 deletions(-) rename packages/apps/job-launcher/server/src/modules/payment/{ => providers}/payment-provider.abstract.ts (80%) rename packages/apps/job-launcher/server/src/modules/{ => payment/providers}/stripe/stripe.module.ts (71%) rename packages/apps/job-launcher/server/src/modules/{ => payment/providers}/stripe/stripe.service.spec.ts (97%) rename packages/apps/job-launcher/server/src/modules/{ => payment/providers}/stripe/stripe.service.ts (96%) diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.interface.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.interface.ts index a9b4d8650f..be8a3a2af3 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.interface.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.interface.ts @@ -1,6 +1,60 @@ import { PaymentEntity } from './payment.entity'; +import { VatType } from '../../common/enums/payment'; export interface ListResult { entities: PaymentEntity[]; itemCount: number; } + +export interface PaymentMethod { + id: string; + brand: string; + last4: string; + expMonth: number; + expYear: number; + default: boolean; +} + +export interface BillingAddress { + line1?: string; + city?: string; + country?: string; + postal_code?: string; +} + +export interface CustomerData { + email: string; + name?: string; + address?: BillingAddress; + default_payment_method?: string; +} + +export interface TaxId { + id: string; + type: VatType; + value: string; +} + +export interface Invoice { + id: string; + payment_intent: string | null; + status?: string; + amount_due: number; + currency: string; +} + +export interface SetupIntent { + customer: string; + payment_method: string; +} + +export interface PaymentIntent { + customer: string; + id: string; + client_secret: string; + status: string; + amount: number; + amount_received: number; + currency: string; + latest_charge: string; +} diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.module.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.module.ts index be2824a01b..8b732a0112 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.module.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.module.ts @@ -15,9 +15,9 @@ import { UserEntity } from '../user/user.entity'; import { JobRepository } from '../job/job.repository'; import { UserRepository } from '../user/user.repository'; import { RateModule } from '../rate/rate.module'; -import { StripeModule } from '../stripe/stripe.module'; -import { StripeService } from '../stripe/stripe.service'; -import { PaymentProvider } from './payment-provider.abstract'; +import { StripeModule } from './providers/stripe/stripe.module'; +import { StripeService } from './providers/stripe/stripe.service'; +import { PaymentProvider } from './providers/payment-provider.abstract'; @Module({ imports: [ diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.repository.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.repository.ts index a7da7743fb..1753cd296f 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.repository.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.repository.ts @@ -4,7 +4,7 @@ import { DataSource, In, LessThan, MoreThan } from 'typeorm'; import { PaymentStatus } from '../../common/enums/payment'; import { BaseRepository } from '../../database/base.repository'; import { PaymentEntity } from './payment.entity'; -import { ListResult } from '../payment/payment.interface'; +import { ListResult } from './payment.interface'; import { GetPaymentsDto } from './payment.dto'; import { convertToDatabaseSortDirection } from '../../database/database.utils'; diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts index 8b3f73747c..5536cbbeb8 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts @@ -51,7 +51,8 @@ import { GetPaymentsDto, UserBalanceDto } from './payment.dto'; import { PaymentEntity } from './payment.entity'; import { PaymentRepository } from './payment.repository'; import { PaymentService } from './payment.service'; -import { PaymentIntent, PaymentProvider } from './payment-provider.abstract'; +import { PaymentProvider } from './providers/payment-provider.abstract'; +import { PaymentIntent } from './payment.interface'; describe('PaymentService', () => { let paymentService: PaymentService; diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts index 56c00f6332..e6069581e2 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts @@ -47,11 +47,10 @@ import { JobRepository } from '../job/job.repository'; import { RateService } from '../rate/rate.service'; import { UserEntity } from '../user/user.entity'; import { UserRepository } from '../user/user.repository'; -import { PaymentProvider } from './payment-provider.abstract'; +import { PaymentProvider } from './providers/payment-provider.abstract'; @Injectable() export class PaymentService { - private readonly logger = new Logger(PaymentService.name); constructor( diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment-provider.abstract.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts similarity index 80% rename from packages/apps/job-launcher/server/src/modules/payment/payment-provider.abstract.ts rename to packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts index f07f23ad30..c918d79855 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment-provider.abstract.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts @@ -1,58 +1,13 @@ import { Injectable, Logger } from '@nestjs/common'; -import { VatType } from '../../common/enums/payment'; - -export interface PaymentMethod { - id: string; - brand: string; - last4: string; - expMonth: number; - expYear: number; - default: boolean; -} - -export interface BillingAddress { - line1?: string; - city?: string; - country?: string; - postal_code?: string; -} - -export interface CustomerData { - email: string; - name?: string; - address?: BillingAddress; - default_payment_method?: string; -} - -export interface TaxId { - id: string; - type: VatType; - value: string; -} - -export interface Invoice { - id: string; - payment_intent: string | null; - status?: string; - amount_due: number; - currency: string; -} - -export interface SetupIntent { - customer: string; - payment_method: string; -} - -export interface PaymentIntent { - customer: string; - id: string; - client_secret: string; - status: string; - amount: number; - amount_received: number; - currency: string; - latest_charge: string; -} +import { VatType } from '../../../common/enums/payment'; +import { + Invoice, + PaymentIntent, + CustomerData, + PaymentMethod, + TaxId, + SetupIntent, +} from '../payment.interface'; @Injectable() export abstract class PaymentProvider { diff --git a/packages/apps/job-launcher/server/src/modules/stripe/stripe.module.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.module.ts similarity index 71% rename from packages/apps/job-launcher/server/src/modules/stripe/stripe.module.ts rename to packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.module.ts index b130c17151..5c41e4f78f 100644 --- a/packages/apps/job-launcher/server/src/modules/stripe/stripe.module.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { StripeService } from './stripe.service'; -import { StripeConfigService } from '../../common/config/stripe-config.service'; +import { StripeConfigService } from '../../../../common/config/stripe-config.service'; @Module({ providers: [StripeService, StripeConfigService], diff --git a/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts similarity index 97% rename from packages/apps/job-launcher/server/src/modules/stripe/stripe.service.spec.ts rename to packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts index 21b29125d3..aa2e17298d 100644 --- a/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts @@ -1,16 +1,16 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Logger } from '@nestjs/common'; import { StripeService } from './stripe.service'; -import { StripeConfigService } from '../../common/config/stripe-config.service'; +import { StripeConfigService } from '../../../../common/config/stripe-config.service'; import Stripe from 'stripe'; -import { ServerError } from '../../common/errors'; -import { ErrorPayment } from '../../common/constants/errors'; +import { ServerError } from '../../../../common/errors'; +import { ErrorPayment } from '../../../../common/constants/errors'; import { PaymentCurrency, PaymentStatus, VatType, -} from '../../common/enums/payment'; -import { PaymentProvider } from '../payment/payment-provider.abstract'; +} from '../../../../common/enums/payment'; +import { PaymentProvider } from '../payment-provider.abstract'; jest.mock('stripe'); diff --git a/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts similarity index 96% rename from packages/apps/job-launcher/server/src/modules/stripe/stripe.service.ts rename to packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts index e03d9f45e1..6c4d9b75df 100644 --- a/packages/apps/job-launcher/server/src/modules/stripe/stripe.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts @@ -1,18 +1,18 @@ import { Injectable } from '@nestjs/common'; import Stripe from 'stripe'; -import { StripeConfigService } from '../../common/config/stripe-config.service'; -import { NotFoundError, ServerError } from '../../common/errors'; -import { ErrorPayment } from '../../common/constants/errors'; -import { VatType } from '../../common/enums/payment'; +import { StripeConfigService } from '../../../../common/config/stripe-config.service'; +import { NotFoundError, ServerError } from '../../../../common/errors'; +import { ErrorPayment } from '../../../../common/constants/errors'; +import { VatType } from '../../../../common/enums/payment'; import { - PaymentProvider, PaymentMethod, CustomerData, TaxId, Invoice, PaymentIntent, SetupIntent, -} from '../payment/payment-provider.abstract'; +} from '../../payment.interface'; +import { PaymentProvider } from '../payment-provider.abstract'; @Injectable() export class StripeService extends PaymentProvider { From bb3dfd59467c56788d3c59849baa830eae9f885d Mon Sep 17 00:00:00 2001 From: Nikolai Muhhin Date: Tue, 17 Jun 2025 22:54:54 +0300 Subject: [PATCH 09/16] Reduce PaymentProvide abstract methods number --- .../src/modules/payment/payment.interface.ts | 10 +- .../modules/payment/payment.service.spec.ts | 30 +-- .../src/modules/payment/payment.service.ts | 104 ++------ .../providers/payment-provider.abstract.ts | 108 +++----- .../providers/stripe/stripe.service.spec.ts | 10 +- .../providers/stripe/stripe.service.ts | 240 ++++++++++++------ 6 files changed, 235 insertions(+), 267 deletions(-) diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.interface.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.interface.ts index be8a3a2af3..6e8c037ad8 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.interface.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.interface.ts @@ -37,21 +37,21 @@ export interface TaxId { export interface Invoice { id: string; - payment_intent: string | null; + payment_id: string | null; status?: string; amount_due: number; currency: string; } -export interface SetupIntent { - customer: string; +export interface CardSetup { + customer_id: string; payment_method: string; } -export interface PaymentIntent { +export interface PaymentData { customer: string; id: string; - client_secret: string; + client_secret: string | null; status: string; amount: number; amount_received: number; diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts index 5536cbbeb8..ac64461e68 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts @@ -52,7 +52,7 @@ import { PaymentEntity } from './payment.entity'; import { PaymentRepository } from './payment.repository'; import { PaymentService } from './payment.service'; import { PaymentProvider } from './providers/payment-provider.abstract'; -import { PaymentIntent } from './payment.interface'; +import { PaymentData } from './payment.interface'; describe('PaymentService', () => { let paymentService: PaymentService; @@ -158,7 +158,7 @@ describe('PaymentService', () => { }; paymentProvider.createInvoice.mockResolvedValue(invoice as any); - paymentProvider.handlePaymentIntent.mockResolvedValue( + paymentProvider.createPayment.mockResolvedValue( paymentIntent as any, ); @@ -179,7 +179,7 @@ describe('PaymentService', () => { PaymentCurrency.USD, 'Top up', ); - expect(paymentProvider.handlePaymentIntent).toHaveBeenCalledWith( + expect(paymentProvider.createPayment).toHaveBeenCalledWith( 'pi_123', 'pm_123', false, @@ -209,7 +209,7 @@ describe('PaymentService', () => { }; paymentProvider.createInvoice.mockResolvedValue(invoice as any); - paymentProvider.handlePaymentIntent.mockResolvedValue( + paymentProvider.createPayment.mockResolvedValue( paymentIntent as any, ); @@ -355,7 +355,7 @@ describe('PaymentService', () => { }; paymentProvider.retrievePaymentIntent.mockResolvedValue( - null as unknown as PaymentIntent, + null as unknown as PaymentData, ); await expect( @@ -762,7 +762,7 @@ describe('PaymentService', () => { }; paymentProvider.createCustomer.mockResolvedValue('cus_123'); - paymentProvider.createSetupIntent.mockResolvedValue( + paymentProvider.setupCard.mockResolvedValue( paymentIntent.client_secret, ); @@ -772,7 +772,7 @@ describe('PaymentService', () => { expect(result).toEqual(paymentIntent.client_secret); expect(paymentProvider.createCustomer).toHaveBeenCalledWith(user.email); - expect(paymentProvider.createSetupIntent).toHaveBeenCalledWith('cus_123'); + expect(paymentProvider.setupCard).toHaveBeenCalledWith('cus_123'); }); it('should throw a bad request exception if the customer creation fails', async () => { @@ -797,7 +797,7 @@ describe('PaymentService', () => { }; paymentProvider.createCustomer.mockResolvedValue({ id: 1 } as any); - paymentProvider.createSetupIntent.mockRejectedValue( + paymentProvider.setupCard.mockRejectedValue( new ServerError(ErrorPayment.IntentNotCreated), ); @@ -813,7 +813,7 @@ describe('PaymentService', () => { }; paymentProvider.createCustomer.mockResolvedValue(user.id.toString()); - paymentProvider.createSetupIntent.mockRejectedValue( + paymentProvider.setupCard.mockRejectedValue( new ServerError(ErrorPayment.ClientSecretDoesNotExist), ); @@ -836,7 +836,7 @@ describe('PaymentService', () => { payment_method: 'pm_123', }; - paymentProvider.retrieveSetupIntent.mockResolvedValue(setupMock as any); + paymentProvider.retrieveCardSetup.mockResolvedValue(setupMock as any); paymentProvider.updateCustomer.mockResolvedValue(null as any); jest .spyOn(userRepository, 'updateOne') @@ -853,7 +853,7 @@ describe('PaymentService', () => { paymentProviderId: 'cus_123', }), ); - expect(paymentProvider.retrieveSetupIntent).toHaveBeenCalledWith( + expect(paymentProvider.retrieveCardSetup).toHaveBeenCalledWith( 'setup_123', ); expect(paymentProvider.updateCustomer).toHaveBeenCalledWith('cus_123', { @@ -867,7 +867,7 @@ describe('PaymentService', () => { email: 'test@hmt.ai', }; - paymentProvider.retrieveSetupIntent.mockResolvedValue(undefined as any); + paymentProvider.retrieveCardSetup.mockResolvedValue(undefined as any); await expect( paymentService.confirmCard(user as any, { @@ -906,7 +906,7 @@ describe('PaymentService', () => { id: invoiceId, payment_intent: paymentIntent, } as any); - paymentProvider.handlePaymentIntent.mockResolvedValueOnce( + paymentProvider.createPayment.mockResolvedValueOnce( paymentIntent as any, ); paymentProvider.getDefaultPaymentMethod.mockResolvedValueOnce( @@ -925,7 +925,7 @@ describe('PaymentService', () => { PaymentCurrency.USD, 'Slash Job Id ' + jobEntity.id, ); - expect(paymentProvider.handlePaymentIntent).toHaveBeenCalledWith( + expect(paymentProvider.createPayment).toHaveBeenCalledWith( paymentIntent, paymentMethodId, true, @@ -955,7 +955,7 @@ describe('PaymentService', () => { invoice_settings: { default_payment_method: paymentMethodId }, } as any); - paymentProvider.handlePaymentIntent.mockRejectedValue( + paymentProvider.createPayment.mockRejectedValue( new ServerError(ErrorPayment.PaymentMethodAssociationFailed), ); diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts index e6069581e2..25981d7a18 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts @@ -16,13 +16,11 @@ import { PaymentSource, PaymentStatus, PaymentType, - VatType, } from '../../common/enums/payment'; import { add, div, eq, lt, mul } from '../../common/utils/decimal'; import { verifySignature } from '../../common/utils/signature'; import { Web3Service } from '../web3/web3.service'; import { - AddressDto, BillingInfoDto, CardConfirmDto, CardDto, @@ -65,20 +63,17 @@ export class PaymentService { ) {} public async createCustomerAndAssignCard(user: UserEntity): Promise { - let customerId = user.paymentProviderId; - - if (!customerId) { - customerId = await this.paymentProvider.createCustomer(user.email); - } - - return await this.paymentProvider.createSetupIntent(customerId); + return await this.paymentProvider.createCustomerWithCard( + user.paymentProviderId, + user.email, + ); } public async confirmCard( user: UserEntity, data: CardConfirmDto, ): Promise { - const setup = await this.paymentProvider.retrieveSetupIntent(data.setupId); + const setup = await this.paymentProvider.retrieveCardSetup(data.setupId); if (!setup) { this.logger.log(ErrorPayment.SetupNotFound, PaymentService.name); @@ -87,7 +82,7 @@ export class PaymentService { let defaultPaymentMethod: string | null = null; if (!user.paymentProviderId) { - user.paymentProviderId = setup.customer as string; + user.paymentProviderId = setup.customer_id as string; await this.userRepository.updateOne(user); } else { defaultPaymentMethod = await this.getDefaultPaymentMethod( @@ -122,8 +117,8 @@ export class PaymentService { 'Top up', ); - const paymentIntent = await this.paymentProvider.handlePaymentIntent( - invoice.payment_intent as string, + const paymentIntent = await this.paymentProvider.createPayment( + invoice.payment_id as string, paymentMethodId, false, // on-session payment ); @@ -186,6 +181,7 @@ export class PaymentService { paymentData.status === PaymentStatus.REQUIRES_PAYMENT_METHOD ) { paymentEntity.status = PaymentStatus.FAILED; + await this.paymentRepository.updateOne(paymentEntity); throw new ConflictError(ErrorPayment.NotSuccess); @@ -364,8 +360,8 @@ export class PaymentService { throw new ServerError(ErrorPayment.NotDefaultPaymentMethod); } - const paymentIntent = await this.paymentProvider.handlePaymentIntent( - invoice.payment_intent as string, + const paymentIntent = await this.paymentProvider.createPayment( + invoice.payment_id as string, defaultPaymentMethod, true, // off-session payment ); @@ -471,32 +467,9 @@ export class PaymentService { } async getUserBillingInfo(user: UserEntity): Promise { - if (!user.paymentProviderId) { - return null; - } - - const taxIds = await this.paymentProvider.listCustomerTaxIds( + return await this.paymentProvider.retrieveBillingInfo( user.paymentProviderId, ); - - const customer = await this.paymentProvider.retrieveCustomer( - user.paymentProviderId, - ); - - const userBillingInfo = new BillingInfoDto(); - if (customer.address) { - const address = new AddressDto(); - address.country = (customer.address.country as string).toLowerCase(); - address.postalCode = customer.address.postal_code as string; - address.city = customer.address.city as string; - address.line = customer.address.line1 as string; - userBillingInfo.address = address; - } - userBillingInfo.name = customer.name as string; - userBillingInfo.email = customer.email as string; - userBillingInfo.vat = taxIds[0]?.value; - userBillingInfo.vatType = taxIds[0]?.type as VatType; - return userBillingInfo; } async updateUserBillingInfo( @@ -507,41 +480,10 @@ export class PaymentService { throw new NotFoundError(ErrorPayment.CustomerNotFound); } - const existingTaxIds = await this.paymentProvider.listCustomerTaxIds( + return await this.paymentProvider.updateBillingInfo( user.paymentProviderId, + updateBillingInfoDto, ); - - // Delete any existing tax IDs before adding the new one - for (const taxId of existingTaxIds) { - await this.paymentProvider.deleteTaxId(user.paymentProviderId, taxId.id); - } - - // Create the new VAT tax ID - if (updateBillingInfoDto.vat && updateBillingInfoDto.vatType) { - await this.paymentProvider.createTaxId( - user.paymentProviderId, - updateBillingInfoDto.vatType, - updateBillingInfoDto.vat, - ); - } - - // If there are changes to the address, name, or email, update them - if ( - updateBillingInfoDto.address || - updateBillingInfoDto.name || - updateBillingInfoDto.email - ) { - return this.paymentProvider.updateCustomer(user.paymentProviderId, { - address: { - line1: updateBillingInfoDto.address?.line, - city: updateBillingInfoDto.address?.city, - country: updateBillingInfoDto.address?.country, - postal_code: updateBillingInfoDto.address?.postalCode, - }, - name: updateBillingInfoDto.name, - email: updateBillingInfoDto.email, - }); - } } async changeDefaultPaymentMethod(user: UserEntity, cardId: string) { @@ -602,22 +544,10 @@ export class PaymentService { } async getReceipt(paymentId: string, user: UserEntity): Promise { - const paymentIntent = - await this.paymentProvider.retrievePaymentIntent(paymentId); - - if (!paymentIntent || paymentIntent.customer !== user.paymentProviderId) { - throw new NotFoundError(ErrorPayment.NotFound); - } - - const charge = await this.paymentProvider.retrieveCharge( - paymentIntent.latest_charge as string, + return await this.paymentProvider.getReceiptUrl( + paymentId, + user.paymentProviderId, ); - - if (!charge || !charge.receipt_url) { - throw new NotFoundError(ErrorPayment.NotFound); - } - - return charge.receipt_url; } public async getUserBalance(userId: number): Promise { diff --git a/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts index c918d79855..0818695188 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts @@ -1,31 +1,23 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { VatType } from '../../../common/enums/payment'; import { - Invoice, - PaymentIntent, + CardSetup, CustomerData, + Invoice, + PaymentData, PaymentMethod, - TaxId, - SetupIntent, } from '../payment.interface'; +import { BillingInfoDto } from '../payment.dto'; -@Injectable() -export abstract class PaymentProvider { - protected readonly logger: Logger = new Logger(this.constructor.name); - +export interface PaymentProvider { /** * Create a new customer in the payment provider system + * @param customerId Customer ID * @param email Customer's email address * @returns Customer ID */ - abstract createCustomer(email: string): Promise; - - /** - * Create a setup intent for adding a new payment method - * @param customerId Customer ID - * @returns Setup intent client secret - */ - abstract createSetupIntent(customerId: string): Promise; + createCustomerWithCard( + customerId: string | null, + email: string, + ): Promise; /** * Create an invoice for a customer @@ -35,7 +27,7 @@ export abstract class PaymentProvider { * @param description Invoice description * @returns Created invoice */ - abstract createInvoice( + createInvoice( customerId: string, amountInCents: number, currency: string, @@ -49,32 +41,25 @@ export abstract class PaymentProvider { * @param offSession Whether the payment is off-session * @returns Updated payment intent */ - abstract handlePaymentIntent( + createPayment( paymentIntentId: string, paymentMethodId: string, offSession: boolean, - ): Promise; - - /** - * Retrieve a customer's information - * @param customerId Customer ID - * @returns Customer data - */ - abstract retrieveCustomer(customerId: string): Promise; + ): Promise; /** * Get the default payment method for a customer * @param customerId Customer ID * @returns Payment method ID or null */ - abstract getDefaultPaymentMethod(customerId: string): Promise; + getDefaultPaymentMethod(customerId: string): Promise; /** * List all payment methods for a customer * @param customerId Customer ID * @returns Array of payment methods */ - abstract listPaymentMethods(customerId: string): Promise; + listPaymentMethods(customerId: string): Promise; /** * Update customer information @@ -82,69 +67,34 @@ export abstract class PaymentProvider { * @param data Customer data to update * @returns Updated customer data */ - abstract updateCustomer( + updateCustomer( customerId: string, data: Partial, ): Promise; - /** - * List tax IDs for a customer - * @param customerId Customer ID - * @returns Array of tax IDs - */ - abstract listCustomerTaxIds(customerId: string): Promise; - - /** - * Create a tax ID for a customer - * @param customerId Customer ID - * @param type Tax ID type - * @param value Tax ID value - * @returns Created tax ID - */ - abstract createTaxId( - customerId: string, - type: VatType, - value: string, - ): Promise; - - /** - * Delete a tax ID - * @param customerId Customer ID - * @param taxIdId Tax ID to delete - */ - abstract deleteTaxId(customerId: string, taxIdId: string): Promise; - - abstract retrieveSetupIntent(setupId: string): Promise; - - /** - * Retrieve a payment intent - * @param paymentIntentId Payment intent ID - * @returns Payment intent data - */ - abstract retrievePaymentIntent( - paymentIntentId: string | null, - ): Promise; - - /** - * Retrieve a charge - * @param chargeId Charge ID - * @returns Charge data with receipt URL - */ - abstract retrieveCharge(chargeId: string): Promise<{ receipt_url: string }>; + retrieveCardSetup(setupId: string): Promise; /** * Retrieve a payment method * @param paymentMethodId Payment method ID * @returns Payment method data */ - abstract retrievePaymentMethod( - paymentMethodId: string, - ): Promise; + retrievePaymentMethod(paymentMethodId: string): Promise; /** * Detach a payment method from a customer * @param paymentMethodId Payment method ID * @returns Detached payment method */ - abstract detachPaymentMethod(paymentMethodId: string): Promise; + detachPaymentMethod(paymentMethodId: string): Promise; + + getReceiptUrl(paymentId: string, customerId: string | null): Promise; + + retrieveBillingInfo( + customerId: string | null, + ): Promise; + + updateBillingInfo(customerId: string, data: BillingInfoDto): Promise; + + retrievePaymentIntent(paymentId: string): any; } diff --git a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts index aa2e17298d..f09c3f7658 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts @@ -126,7 +126,7 @@ describe('StripeService', () => { .fn() .mockResolvedValue(mockSetupIntent); - const result = await service.createSetupIntent('cus_123'); + const result = await service.setupCard('cus_123'); expect(result).toBe('seti_secret_123'); expect(stripeMock.setupIntents.create).toHaveBeenCalledWith({ @@ -140,7 +140,7 @@ describe('StripeService', () => { .fn() .mockResolvedValue(mockSetupIntent); - await service.createSetupIntent(null); + await service.setupCard(null); expect(stripeMock.setupIntents.create).toHaveBeenCalledWith({ automatic_payment_methods: { enabled: true }, @@ -151,7 +151,7 @@ describe('StripeService', () => { it('should handle missing client secret', async () => { stripeMock.setupIntents.create = jest.fn().mockResolvedValue({}); - await expect(service.createSetupIntent('cus_123')).rejects.toThrow( + await expect(service.setupCard('cus_123')).rejects.toThrow( new ServerError(ErrorPayment.ClientSecretDoesNotExist), ); }); @@ -176,7 +176,7 @@ describe('StripeService', () => { .fn() .mockResolvedValue(mockPaymentIntent); - const result = await service.handlePaymentIntent( + const result = await service.createPayment( 'pi_123', 'pm_123', true, @@ -197,7 +197,7 @@ describe('StripeService', () => { .fn() .mockResolvedValue(mockPaymentIntent); - const result = await service.handlePaymentIntent( + const result = await service.createPayment( 'pi_123', 'pm_123', false, diff --git a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts index 6c4d9b75df..74f17f8c69 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts @@ -1,26 +1,27 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import Stripe from 'stripe'; import { StripeConfigService } from '../../../../common/config/stripe-config.service'; import { NotFoundError, ServerError } from '../../../../common/errors'; import { ErrorPayment } from '../../../../common/constants/errors'; import { VatType } from '../../../../common/enums/payment'; import { - PaymentMethod, + CardSetup, CustomerData, - TaxId, Invoice, - PaymentIntent, - SetupIntent, + PaymentData, + PaymentMethod, + TaxId, } from '../../payment.interface'; import { PaymentProvider } from '../payment-provider.abstract'; +import { AddressDto, BillingInfoDto } from '../../payment.dto'; @Injectable() -export class StripeService extends PaymentProvider { +export class StripeService implements PaymentProvider { + protected readonly logger: Logger = new Logger(StripeService.name); + private stripe: Stripe; constructor(private stripeConfigService: StripeConfigService) { - super(); - this.stripe = new Stripe(this.stripeConfigService.secretKey, { apiVersion: this.stripeConfigService.apiVersion as any, appInfo: { @@ -31,38 +32,12 @@ export class StripeService extends PaymentProvider { }); } - async createCustomer(email: string): Promise { - try { - const customer = await this.stripe.customers.create({ email }); - return customer.id; - } catch (error) { - this.logger.log(error.message, StripeService.name); - throw new ServerError(ErrorPayment.CustomerNotCreated); + async createCustomerWithCard(customerId: string | null, email: string) { + if (!customerId) { + customerId = await this.createCustomer(email); } - } - - async createSetupIntent(customerId: string | null): Promise { - let setupIntent: Stripe.Response; - try { - setupIntent = await this.stripe.setupIntents.create({ - automatic_payment_methods: { enabled: true }, - customer: customerId ?? undefined, - }); - } catch (error) { - this.logger.log(error.message, StripeService.name); - throw new ServerError(ErrorPayment.CardNotAssigned); - } - - if (!setupIntent?.client_secret) { - this.logger.log( - ErrorPayment.ClientSecretDoesNotExist, - StripeService.name, - ); - throw new ServerError(ErrorPayment.ClientSecretDoesNotExist); - } - - return setupIntent.client_secret; + return await this.setupCard(customerId); } async createInvoice( @@ -95,18 +70,18 @@ export class StripeService extends PaymentProvider { return { id: invoice.id, - payment_intent: invoice.payment_intent as string, + payment_id: invoice.payment_intent as string, status: invoice.status?.toString(), amount_due: invoice.amount_due, currency: invoice.currency, - }; + } as Invoice; } - async handlePaymentIntent( + async createPayment( paymentIntentId: string, paymentMethodId: string, offSession: boolean, - ): Promise { + ): Promise { try { if (offSession) { await this.stripe.paymentIntents.confirm(paymentIntentId, { @@ -122,8 +97,7 @@ export class StripeService extends PaymentProvider { throw new ServerError(ErrorPayment.PaymentMethodAssociationFailed); } - const paymentIntent = - await this.stripe.paymentIntents.retrieve(paymentIntentId); + const paymentIntent = await this.retrievePaymentIntent(paymentIntentId); if (!paymentIntent?.client_secret) { throw new ServerError(ErrorPayment.ClientSecretDoesNotExist); @@ -141,7 +115,86 @@ export class StripeService extends PaymentProvider { }; } - async retrievePaymentIntent(paymentIntentId: string): Promise { + async getReceiptUrl(paymentId: string, customerId: string): Promise { + const paymentIntent = await this.retrievePaymentIntent(paymentId); + + if (!paymentIntent || paymentIntent.customer !== customerId) { + throw new NotFoundError(ErrorPayment.NotFound); + } + + const charge = await this.retrieveCharge( + paymentIntent.latest_charge as string, + ); + + if (!charge || !charge.receipt_url) { + throw new NotFoundError(ErrorPayment.NotFound); + } + + return charge.receipt_url; + } + + async retrieveBillingInfo( + customerId: string | null, + ): Promise { + if (!customerId) { + return null; + } + + const taxIds = await this.listCustomerTaxIds(customerId); + + const customer = await this.retrieveCustomer(customerId); + + const userBillingInfo = new BillingInfoDto(); + + if (customer.address) { + const address = new AddressDto(); + address.country = (customer.address.country as string).toLowerCase(); + address.postalCode = customer.address.postal_code as string; + address.city = customer.address.city as string; + address.line = customer.address.line1 as string; + userBillingInfo.address = address; + } + + userBillingInfo.name = customer.name as string; + userBillingInfo.email = customer.email as string; + userBillingInfo.vat = taxIds[0]?.value; + userBillingInfo.vatType = taxIds[0]?.type as VatType; + + return userBillingInfo; + } + + async updateBillingInfo( + customerId: string, + data: BillingInfoDto, + ): Promise { + const existingTaxIds = await this.listCustomerTaxIds(customerId); + + // Delete any existing tax IDs before adding the new one + for (const taxId of existingTaxIds) { + await this.deleteTaxId(customerId, taxId.id); + } + + // Create the new VAT tax ID + if (data.vat && data.vatType) { + await this.createTaxId(customerId, data.vatType, data.vat); + } + + // If there are changes to the address, name, or email, update them + if (data.address || data.name || data.email) { + return this.updateCustomer(customerId, { + address: { + line1: data.address?.line, + city: data.address?.city, + country: data.address?.country, + postal_code: data.address?.postalCode, + }, + name: data.name, + email: data.email, + }); + } + } + + async retrievePaymentIntent(paymentIntentId: string): Promise { const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentIntentId); @@ -149,10 +202,6 @@ export class StripeService extends PaymentProvider { throw new NotFoundError(ErrorPayment.NotFound); } - if (!paymentIntent.client_secret) { - throw new ServerError(ErrorPayment.ClientSecretDoesNotExist); - } - return { id: paymentIntent.id, customer: paymentIntent.customer as string, @@ -165,27 +214,6 @@ export class StripeService extends PaymentProvider { }; } - async retrieveCustomer(customerId: string): Promise { - const customer = (await this.stripe.customers.retrieve( - customerId, - )) as Stripe.Customer; - - return { - email: customer.email!, - name: customer.name ?? undefined, - address: customer.address - ? { - line1: customer.address.line1 ?? undefined, - city: customer.address.city ?? undefined, - country: customer.address.country ?? undefined, - postal_code: customer.address.postal_code ?? undefined, - } - : undefined, - default_payment_method: customer.invoice_settings - .default_payment_method as string, - }; - } - async getDefaultPaymentMethod(customerId: string): Promise { const customer = await this.retrieveCustomer(customerId); return customer.default_payment_method ?? null; @@ -271,7 +299,62 @@ export class StripeService extends PaymentProvider { }; } - async listCustomerTaxIds(customerId: string): Promise { + private async createCustomer(email: string): Promise { + try { + const customer = await this.stripe.customers.create({ email }); + return customer.id; + } catch (error) { + this.logger.log(error.message, StripeService.name); + throw new ServerError(ErrorPayment.CustomerNotCreated); + } + } + + private async setupCard(customerId: string | null): Promise { + let setupIntent: Stripe.Response; + + try { + setupIntent = await this.stripe.setupIntents.create({ + automatic_payment_methods: { enabled: true }, + customer: customerId ?? undefined, + }); + } catch (error) { + this.logger.log(error.message, StripeService.name); + throw new ServerError(ErrorPayment.CardNotAssigned); + } + + if (!setupIntent?.client_secret) { + this.logger.log( + ErrorPayment.ClientSecretDoesNotExist, + StripeService.name, + ); + throw new ServerError(ErrorPayment.ClientSecretDoesNotExist); + } + + return setupIntent.client_secret; + } + + private async retrieveCustomer(customerId: string): Promise { + const customer = (await this.stripe.customers.retrieve( + customerId, + )) as Stripe.Customer; + + return { + email: customer.email!, + name: customer.name ?? undefined, + address: customer.address + ? { + line1: customer.address.line1 ?? undefined, + city: customer.address.city ?? undefined, + country: customer.address.country ?? undefined, + postal_code: customer.address.postal_code ?? undefined, + } + : undefined, + default_payment_method: customer.invoice_settings + .default_payment_method as string, + }; + } + + private async listCustomerTaxIds(customerId: string): Promise { const taxIds = await this.stripe.customers.listTaxIds(customerId); return taxIds.data.map((taxId) => ({ @@ -281,7 +364,7 @@ export class StripeService extends PaymentProvider { })); } - async createTaxId( + private async createTaxId( customerId: string, type: VatType, value: string, @@ -297,20 +380,25 @@ export class StripeService extends PaymentProvider { }; } - async deleteTaxId(customerId: string, taxIdId: string): Promise { + private async deleteTaxId( + customerId: string, + taxIdId: string, + ): Promise { await this.stripe.customers.deleteTaxId(customerId, taxIdId); } - async retrieveSetupIntent(setupIntentId: string): Promise { + async retrieveCardSetup(setupIntentId: string): Promise { const setupIntent = await this.stripe.setupIntents.retrieve(setupIntentId); return { - customer: setupIntent.customer as string, + customer_id: setupIntent.customer as string, payment_method: setupIntent.payment_method as string, }; } - async retrieveCharge(chargeId: string): Promise<{ receipt_url: string }> { + private async retrieveCharge( + chargeId: string, + ): Promise<{ receipt_url: string }> { const charge = await this.stripe.charges.retrieve(chargeId); if (!charge.receipt_url) { throw new ServerError(ErrorPayment.NotFound); From 0eed15e05d23cca6bacf956ccb63e7a82d1b8b42 Mon Sep 17 00:00:00 2001 From: Nikolai Muhhin Date: Tue, 17 Jun 2025 23:37:43 +0300 Subject: [PATCH 10/16] Fix tests --- .../modules/payment/payment.service.spec.ts | 126 ++----- .../providers/payment-provider.abstract.ts | 40 +- .../providers/stripe/stripe.service.spec.ts | 341 +++++++++++++++++- .../providers/stripe/stripe.service.ts | 28 +- 4 files changed, 404 insertions(+), 131 deletions(-) diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts index ac64461e68..d557a78331 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts @@ -154,13 +154,11 @@ describe('PaymentService', () => { const invoice = { id: 'id', - payment_intent: paymentIntent.id, + payment_id: paymentIntent.id, }; paymentProvider.createInvoice.mockResolvedValue(invoice as any); - paymentProvider.createPayment.mockResolvedValue( - paymentIntent as any, - ); + paymentProvider.createPayment.mockResolvedValue(paymentIntent as any); jest .spyOn(paymentRepository, 'findOneByTransaction') @@ -209,9 +207,7 @@ describe('PaymentService', () => { }; paymentProvider.createInvoice.mockResolvedValue(invoice as any); - paymentProvider.createPayment.mockResolvedValue( - paymentIntent as any, - ); + paymentProvider.createPayment.mockResolvedValue(paymentIntent as any); findOneMock.mockResolvedValue({ transaction: paymentIntent.client_secret, @@ -761,8 +757,7 @@ describe('PaymentService', () => { client_secret: 'clientSecret123', }; - paymentProvider.createCustomer.mockResolvedValue('cus_123'); - paymentProvider.setupCard.mockResolvedValue( + paymentProvider.createCustomerWithCard.mockResolvedValue( paymentIntent.client_secret, ); @@ -771,8 +766,10 @@ describe('PaymentService', () => { ); expect(result).toEqual(paymentIntent.client_secret); - expect(paymentProvider.createCustomer).toHaveBeenCalledWith(user.email); - expect(paymentProvider.setupCard).toHaveBeenCalledWith('cus_123'); + expect(paymentProvider.createCustomerWithCard).toHaveBeenCalledWith( + null, + user.email, + ); }); it('should throw a bad request exception if the customer creation fails', async () => { @@ -781,7 +778,7 @@ describe('PaymentService', () => { email: 'test@hmt.ai', paymentProviderId: undefined, }; - paymentProvider.createCustomer.mockRejectedValue( + paymentProvider.createCustomerWithCard.mockRejectedValue( new ServerError(ErrorPayment.CustomerNotCreated), ); @@ -796,8 +793,7 @@ describe('PaymentService', () => { email: 'test@hmt.ai', }; - paymentProvider.createCustomer.mockResolvedValue({ id: 1 } as any); - paymentProvider.setupCard.mockRejectedValue( + paymentProvider.createCustomerWithCard.mockRejectedValue( new ServerError(ErrorPayment.IntentNotCreated), ); @@ -812,8 +808,7 @@ describe('PaymentService', () => { email: 'test@hmt.ai', }; - paymentProvider.createCustomer.mockResolvedValue(user.id.toString()); - paymentProvider.setupCard.mockRejectedValue( + paymentProvider.createCustomerWithCard.mockRejectedValue( new ServerError(ErrorPayment.ClientSecretDoesNotExist), ); @@ -832,7 +827,7 @@ describe('PaymentService', () => { }; const setupMock = { - customer: 'cus_123', + customer_id: 'cus_123', payment_method: 'pm_123', }; @@ -904,17 +899,12 @@ describe('PaymentService', () => { paymentProvider.createInvoice.mockResolvedValueOnce({ id: invoiceId, - payment_intent: paymentIntent, + payment_id: paymentIntent, } as any); - paymentProvider.createPayment.mockResolvedValueOnce( - paymentIntent as any, - ); + paymentProvider.createPayment.mockResolvedValueOnce(paymentIntent as any); paymentProvider.getDefaultPaymentMethod.mockResolvedValueOnce( paymentMethodId, ); - paymentProvider.retrieveCustomer.mockResolvedValueOnce({ - invoice_settings: { default_payment_method: paymentMethodId }, - } as any); const result = await paymentService.createSlash(jobEntity as any); @@ -951,9 +941,6 @@ describe('PaymentService', () => { paymentProvider.getDefaultPaymentMethod.mockResolvedValueOnce( paymentMethodId, ); - paymentProvider.retrieveCustomer.mockResolvedValueOnce({ - invoice_settings: { default_payment_method: paymentMethodId }, - } as any); paymentProvider.createPayment.mockRejectedValue( new ServerError(ErrorPayment.PaymentMethodAssociationFailed), @@ -1056,36 +1043,24 @@ describe('PaymentService', () => { paymentProviderId: 'cus_123', }; - const taxIds = { - data: [ - { - type: VatType.EU_VAT, - value: 'DE123456789', - }, - ], - }; - const customer = { name: 'John Doe', email: 'john@example.com', address: { - country: 'DE', - postal_code: '12345', + country: 'de', + postalCode: '12345', city: 'Berlin', - line1: 'Street 1', + line: 'Street 1', }, }; - paymentProvider.listCustomerTaxIds.mockResolvedValue(taxIds.data as any); - paymentProvider.retrieveCustomer.mockResolvedValue(customer as any); + paymentProvider.retrieveBillingInfo.mockResolvedValue(customer as any); const result = await paymentService.getUserBillingInfo(user as any); expect(result).toEqual({ name: 'John Doe', email: 'john@example.com', - vat: 'DE123456789', - vatType: VatType.EU_VAT, address: { country: 'de', postalCode: '12345', @@ -1116,8 +1091,6 @@ describe('PaymentService', () => { }, }; - paymentProvider.listCustomerTaxIds.mockResolvedValue([] as any); - paymentProvider.createTaxId.mockResolvedValue({} as any); paymentProvider.updateCustomer.mockResolvedValue({} as any); await paymentService.updateUserBillingInfo( @@ -1125,22 +1098,21 @@ describe('PaymentService', () => { updateBillingInfoDto, ); - expect(paymentProvider.createTaxId).toHaveBeenCalledWith( + expect(paymentProvider.updateBillingInfo).toHaveBeenCalledWith( 'cus_123', - VatType.EU_VAT, - 'DE123456789', - ); - - expect(paymentProvider.updateCustomer).toHaveBeenCalledWith('cus_123', { - name: 'John Doe', - email: 'john@example.com', - address: { - country: 'DE', - postal_code: '12345', - city: 'Berlin', - line1: 'Street 1', + { + name: 'John Doe', + email: 'john@example.com', + address: { + country: 'DE', + postalCode: '12345', + city: 'Berlin', + line: 'Street 1', + }, + vat: 'DE123456789', + vatType: VatType.EU_VAT, }, - }); + ); }); }); @@ -1305,50 +1277,24 @@ describe('PaymentService', () => { const paymentId = 'pi_123'; const user = { paymentProviderId: 'cus_123' } as any; - const paymentIntent = { - customer: 'cus_123', - latest_charge: 'ch_123', - }; - - const charge = { - receipt_url: 'https://receipt.url', - }; - - paymentProvider.retrievePaymentIntent.mockResolvedValue( - paymentIntent as any, - ); - paymentProvider.retrieveCharge.mockResolvedValue(charge as any); + paymentProvider.getReceiptUrl.mockResolvedValue('https://receipt.url'); const result = await paymentService.getReceipt(paymentId, user); expect(result).toBe('https://receipt.url'); - expect(paymentProvider.retrievePaymentIntent).toHaveBeenCalledWith( + expect(paymentProvider.getReceiptUrl).toHaveBeenCalledWith( 'pi_123', + 'cus_123', ); - expect(paymentProvider.retrieveCharge).toHaveBeenCalledWith('ch_123'); }); - it('should throw a NOT_FOUND error if payment intent does not exist', async () => { + it('should throw a NOT_FOUND error if receipt URL is not found', async () => { const paymentId = 'pi_123'; const user = { paymentProviderId: 'cus_123' } as any; - paymentProvider.retrievePaymentIntent.mockResolvedValue({} as any); - - await expect(paymentService.getReceipt(paymentId, user)).rejects.toThrow( + paymentProvider.getReceiptUrl.mockRejectedValue( new NotFoundError(ErrorPayment.NotFound), ); - }); - - it('should throw a NOT_FOUND error if charge does not exist', async () => { - const paymentId = 'pi_123'; - const user = { paymentProviderId: 'cus_123' } as any; - - paymentProvider.retrievePaymentIntent.mockResolvedValue({ - customer: 'cus_123', - latest_charge: 'ch_123', - } as any); - - paymentProvider.retrieveCharge.mockResolvedValue({} as any); await expect(paymentService.getReceipt(paymentId, user)).rejects.toThrow( new NotFoundError(ErrorPayment.NotFound), diff --git a/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts index 0818695188..093af3c5f3 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts @@ -6,15 +6,19 @@ import { PaymentMethod, } from '../payment.interface'; import { BillingInfoDto } from '../payment.dto'; +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export abstract class PaymentProvider { + protected readonly logger: Logger = new Logger(this.constructor.name); -export interface PaymentProvider { /** * Create a new customer in the payment provider system * @param customerId Customer ID * @param email Customer's email address * @returns Customer ID */ - createCustomerWithCard( + abstract createCustomerWithCard( customerId: string | null, email: string, ): Promise; @@ -27,7 +31,7 @@ export interface PaymentProvider { * @param description Invoice description * @returns Created invoice */ - createInvoice( + abstract createInvoice( customerId: string, amountInCents: number, currency: string, @@ -41,7 +45,7 @@ export interface PaymentProvider { * @param offSession Whether the payment is off-session * @returns Updated payment intent */ - createPayment( + abstract createPayment( paymentIntentId: string, paymentMethodId: string, offSession: boolean, @@ -52,14 +56,14 @@ export interface PaymentProvider { * @param customerId Customer ID * @returns Payment method ID or null */ - getDefaultPaymentMethod(customerId: string): Promise; + abstract getDefaultPaymentMethod(customerId: string): Promise; /** * List all payment methods for a customer * @param customerId Customer ID * @returns Array of payment methods */ - listPaymentMethods(customerId: string): Promise; + abstract listPaymentMethods(customerId: string): Promise; /** * Update customer information @@ -67,34 +71,42 @@ export interface PaymentProvider { * @param data Customer data to update * @returns Updated customer data */ - updateCustomer( + abstract updateCustomer( customerId: string, data: Partial, ): Promise; - retrieveCardSetup(setupId: string): Promise; + abstract retrieveCardSetup(setupId: string): Promise; /** * Retrieve a payment method * @param paymentMethodId Payment method ID * @returns Payment method data */ - retrievePaymentMethod(paymentMethodId: string): Promise; + abstract retrievePaymentMethod( + paymentMethodId: string, + ): Promise; /** * Detach a payment method from a customer * @param paymentMethodId Payment method ID * @returns Detached payment method */ - detachPaymentMethod(paymentMethodId: string): Promise; + abstract detachPaymentMethod(paymentMethodId: string): Promise; - getReceiptUrl(paymentId: string, customerId: string | null): Promise; + abstract getReceiptUrl( + paymentId: string, + customerId: string | null, + ): Promise; - retrieveBillingInfo( + abstract retrieveBillingInfo( customerId: string | null, ): Promise; - updateBillingInfo(customerId: string, data: BillingInfoDto): Promise; + abstract updateBillingInfo( + customerId: string, + data: BillingInfoDto, + ): Promise; - retrievePaymentIntent(paymentId: string): any; + abstract retrievePaymentIntent(paymentId: string): any; } diff --git a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts index f09c3f7658..ed4a6e668b 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts @@ -3,7 +3,7 @@ import { Logger } from '@nestjs/common'; import { StripeService } from './stripe.service'; import { StripeConfigService } from '../../../../common/config/stripe-config.service'; import Stripe from 'stripe'; -import { ServerError } from '../../../../common/errors'; +import { NotFoundError, ServerError } from '../../../../common/errors'; import { ErrorPayment } from '../../../../common/constants/errors'; import { PaymentCurrency, @@ -176,11 +176,7 @@ describe('StripeService', () => { .fn() .mockResolvedValue(mockPaymentIntent); - const result = await service.createPayment( - 'pi_123', - 'pm_123', - true, - ); + const result = await service.createPayment('pi_123', 'pm_123', true); expect(stripeMock.paymentIntents.confirm).toHaveBeenCalledWith('pi_123', { payment_method: 'pm_123', @@ -197,11 +193,7 @@ describe('StripeService', () => { .fn() .mockResolvedValue(mockPaymentIntent); - const result = await service.createPayment( - 'pi_123', - 'pm_123', - false, - ); + const result = await service.createPayment('pi_123', 'pm_123', false); expect(stripeMock.paymentIntents.update).toHaveBeenCalledWith('pi_123', { payment_method: 'pm_123', @@ -238,7 +230,16 @@ describe('StripeService', () => { expect(stripeMock.invoices.create).toHaveBeenCalled(); expect(stripeMock.invoiceItems.create).toHaveBeenCalled(); expect(stripeMock.invoices.finalizeInvoice).toHaveBeenCalled(); - expect(result).toEqual(mockInvoice); + + const { id, payment_intent, status, currency, amount_due } = mockInvoice; + + expect(result).toEqual({ + id, + payment_id: payment_intent, + status, + currency, + amount_due, + }); }); it('should throw error when payment intent is missing', async () => { @@ -406,4 +407,320 @@ describe('StripeService', () => { ); }); }); + + describe('createCustomerWithCard', () => { + it('should create new customer and setup card when customerId is null', async () => { + const mockCustomer = { id: 'cus_123' }; + const mockSetupIntent = { + id: 'seti_123', + client_secret: 'seti_secret_123', + }; + + stripeMock.customers.create = jest.fn().mockResolvedValue(mockCustomer); + stripeMock.setupIntents.create = jest + .fn() + .mockResolvedValue(mockSetupIntent); + + const result = await service.createCustomerWithCard( + null, + 'test@example.com', + ); + + expect(result).toBe('seti_secret_123'); + expect(stripeMock.customers.create).toHaveBeenCalledWith({ + email: 'test@example.com', + }); + expect(stripeMock.setupIntents.create).toHaveBeenCalledWith({ + automatic_payment_methods: { enabled: true }, + customer: 'cus_123', + }); + }); + + it('should only setup card when customerId is provided', async () => { + const mockSetupIntent = { + id: 'seti_123', + client_secret: 'seti_secret_123', + }; + + stripeMock.setupIntents.create = jest + .fn() + .mockResolvedValue(mockSetupIntent); + + const result = await service.createCustomerWithCard( + 'cus_123', + 'test@example.com', + ); + + expect(result).toBe('seti_secret_123'); + expect(stripeMock.customers.create).not.toHaveBeenCalled(); + expect(stripeMock.setupIntents.create).toHaveBeenCalledWith({ + automatic_payment_methods: { enabled: true }, + customer: 'cus_123', + }); + }); + }); + + describe('getReceiptUrl', () => { + it('should return receipt URL for valid payment', async () => { + const mockPaymentIntent = { + id: 'pi_123', + customer: 'cus_123', + latest_charge: 'ch_123', + }; + const mockCharge = { + receipt_url: 'https://receipt.example.com', + }; + + stripeMock.paymentIntents.retrieve = jest + .fn() + .mockResolvedValue(mockPaymentIntent); + stripeMock.charges.retrieve = jest.fn().mockResolvedValue(mockCharge); + + const result = await service.getReceiptUrl('pi_123', 'cus_123'); + + expect(result).toBe('https://receipt.example.com'); + expect(stripeMock.paymentIntents.retrieve).toHaveBeenCalledWith('pi_123'); + expect(stripeMock.charges.retrieve).toHaveBeenCalledWith('ch_123'); + }); + + it('should throw NotFoundError when payment intent not found', async () => { + stripeMock.paymentIntents.retrieve = jest.fn().mockResolvedValue(null); + + await expect(service.getReceiptUrl('pi_123', 'cus_123')).rejects.toThrow( + new NotFoundError(ErrorPayment.NotFound), + ); + }); + + it('should throw NotFoundError when customer ID does not match', async () => { + const mockPaymentIntent = { + id: 'pi_123', + customer: 'cus_456', + latest_charge: 'ch_123', + }; + + stripeMock.paymentIntents.retrieve = jest + .fn() + .mockResolvedValue(mockPaymentIntent); + + await expect(service.getReceiptUrl('pi_123', 'cus_123')).rejects.toThrow( + new NotFoundError(ErrorPayment.NotFound), + ); + }); + + it('should throw NotFoundError when receipt URL is missing', async () => { + const mockPaymentIntent = { + id: 'pi_123', + customer: 'cus_123', + latest_charge: 'ch_123', + }; + const mockCharge = { + receipt_url: null, + }; + + stripeMock.paymentIntents.retrieve = jest + .fn() + .mockResolvedValue(mockPaymentIntent); + stripeMock.charges.retrieve = jest.fn().mockResolvedValue(mockCharge); + + await expect(service.getReceiptUrl('pi_123', 'cus_123')).rejects.toThrow( + new NotFoundError(ErrorPayment.NotFound), + ); + }); + }); + + describe('retrieveBillingInfo', () => { + it('should return null when customerId is null', async () => { + const result = await service.retrieveBillingInfo(null); + expect(result).toBeNull(); + }); + + it('should return complete billing info when all data is available', async () => { + const mockCustomer = { + id: 'cus_123', + name: 'John Doe', + email: 'john@example.com', + address: { + line1: '123 Main St', + city: 'New York', + country: 'US', + postal_code: '10001', + }, + }; + + const mockTaxIds = [ + { + id: 'txi_123', + type: VatType.EU_VAT, + value: 'DE123456789', + }, + ]; + + stripeMock.customers.retrieve = jest.fn().mockResolvedValue(mockCustomer); + stripeMock.customers.listTaxIds = jest + .fn() + .mockResolvedValue({ data: mockTaxIds }); + + const result = await service.retrieveBillingInfo('cus_123'); + + expect(result).toEqual({ + name: 'John Doe', + email: 'john@example.com', + address: { + line: '123 Main St', + city: 'New York', + country: 'us', + postalCode: '10001', + }, + vat: 'DE123456789', + vatType: VatType.EU_VAT, + }); + }); + + it('should return partial billing info when some data is missing', async () => { + const mockCustomer = { + id: 'cus_123', + name: 'John Doe', + email: 'john@example.com', + }; + + stripeMock.customers.retrieve = jest.fn().mockResolvedValue(mockCustomer); + stripeMock.customers.listTaxIds = jest + .fn() + .mockResolvedValue({ data: [] }); + + const result = await service.retrieveBillingInfo('cus_123'); + + expect(result).toEqual({ + name: 'John Doe', + email: 'john@example.com', + address: undefined, + vat: undefined, + vatType: undefined, + }); + }); + }); + + describe('updateBillingInfo', () => { + it('should update all billing information', async () => { + const mockExistingTaxIds = [ + { + id: 'txi_123', + type: VatType.EU_VAT, + value: 'DE123456789', + }, + ]; + + const mockUpdatedCustomer = { + id: 'cus_123', + name: 'John Doe', + email: 'john@example.com', + address: { + line1: '123 Main St', + city: 'New York', + country: 'US', + postal_code: '10001', + }, + }; + + stripeMock.customers.listTaxIds = jest + .fn() + .mockResolvedValue({ data: mockExistingTaxIds }); + stripeMock.customers.deleteTaxId = jest.fn().mockResolvedValue({}); + stripeMock.customers.createTaxId = jest.fn().mockResolvedValue({ + id: 'txi_456', + type: VatType.EU_VAT, + value: 'DE987654321', + }); + stripeMock.customers.update = jest + .fn() + .mockResolvedValue(mockUpdatedCustomer); + + const updateData = { + name: 'John Doe', + email: 'john@example.com', + address: { + line: '123 Main St', + city: 'New York', + country: 'us', + postalCode: '10001', + }, + vat: 'DE987654321', + vatType: VatType.EU_VAT, + }; + + await service.updateBillingInfo('cus_123', updateData); + + expect(stripeMock.customers.deleteTaxId).toHaveBeenCalledWith( + 'cus_123', + 'txi_123', + ); + expect(stripeMock.customers.createTaxId).toHaveBeenCalledWith('cus_123', { + type: VatType.EU_VAT, + value: 'DE987654321', + }); + expect(stripeMock.customers.update).toHaveBeenCalledWith('cus_123', { + name: 'John Doe', + email: 'john@example.com', + address: { + line1: '123 Main St', + city: 'New York', + country: 'us', + postal_code: '10001', + }, + }); + }); + + it('should handle update without VAT information', async () => { + const mockExistingTaxIds = [ + { + id: 'txi_123', + type: VatType.EU_VAT, + value: 'DE123456789', + }, + ]; + + const mockUpdatedCustomer = { + id: 'cus_123', + name: 'John Doe', + email: 'john@example.com', + }; + + stripeMock.customers.listTaxIds = jest + .fn() + .mockResolvedValue({ data: mockExistingTaxIds }); + stripeMock.customers.deleteTaxId = jest.fn().mockResolvedValue({}); + stripeMock.customers.update = jest + .fn() + .mockResolvedValue(mockUpdatedCustomer); + + const updateData = { + name: 'John Doe', + email: 'john@example.com', + address: { + line: '123 Main St', + city: 'New York', + country: 'us', + postalCode: '10001', + }, + }; + + await service.updateBillingInfo('cus_123', updateData); + + expect(stripeMock.customers.deleteTaxId).toHaveBeenCalledWith( + 'cus_123', + 'txi_123', + ); + expect(stripeMock.customers.createTaxId).not.toHaveBeenCalled(); + expect(stripeMock.customers.update).toHaveBeenCalledWith('cus_123', { + name: 'John Doe', + email: 'john@example.com', + address: { + line1: '123 Main St', + city: 'New York', + country: 'us', + postal_code: '10001', + }, + }); + }); + }); }); diff --git a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts index 74f17f8c69..ebc6f0c5b1 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import Stripe from 'stripe'; import { StripeConfigService } from '../../../../common/config/stripe-config.service'; import { NotFoundError, ServerError } from '../../../../common/errors'; @@ -16,12 +16,12 @@ import { PaymentProvider } from '../payment-provider.abstract'; import { AddressDto, BillingInfoDto } from '../../payment.dto'; @Injectable() -export class StripeService implements PaymentProvider { - protected readonly logger: Logger = new Logger(StripeService.name); - +export class StripeService extends PaymentProvider { private stripe: Stripe; constructor(private stripeConfigService: StripeConfigService) { + super(); + this.stripe = new Stripe(this.stripeConfigService.secretKey, { apiVersion: this.stripeConfigService.apiVersion as any, appInfo: { @@ -271,7 +271,6 @@ export class StripeService implements PaymentProvider { ): Promise { const params = data.default_payment_method ? { - ...data, invoice_settings: { default_payment_method: data.default_payment_method, }, @@ -295,11 +294,12 @@ export class StripeService implements PaymentProvider { } : undefined, default_payment_method: customer.invoice_settings - .default_payment_method as string, + ? (customer.invoice_settings.default_payment_method as string) + : undefined, }; } - private async createCustomer(email: string): Promise { + async createCustomer(email: string): Promise { try { const customer = await this.stripe.customers.create({ email }); return customer.id; @@ -309,7 +309,7 @@ export class StripeService implements PaymentProvider { } } - private async setupCard(customerId: string | null): Promise { + async setupCard(customerId: string | null): Promise { let setupIntent: Stripe.Response; try { @@ -350,11 +350,12 @@ export class StripeService implements PaymentProvider { } : undefined, default_payment_method: customer.invoice_settings - .default_payment_method as string, + ? (customer.invoice_settings.default_payment_method as string) + : undefined, }; } - private async listCustomerTaxIds(customerId: string): Promise { + async listCustomerTaxIds(customerId: string): Promise { const taxIds = await this.stripe.customers.listTaxIds(customerId); return taxIds.data.map((taxId) => ({ @@ -364,7 +365,7 @@ export class StripeService implements PaymentProvider { })); } - private async createTaxId( + async createTaxId( customerId: string, type: VatType, value: string, @@ -380,10 +381,7 @@ export class StripeService implements PaymentProvider { }; } - private async deleteTaxId( - customerId: string, - taxIdId: string, - ): Promise { + async deleteTaxId(customerId: string, taxIdId: string): Promise { await this.stripe.customers.deleteTaxId(customerId, taxIdId); } From 44a77c479fcb1c2e8f136761ac37ec90d52ab671 Mon Sep 17 00:00:00 2001 From: Nikolai Muhhin Date: Wed, 18 Jun 2025 23:00:44 +0300 Subject: [PATCH 11/16] Code cleanup --- .../src/modules/payment/payment.interface.ts | 12 ++-- .../src/modules/payment/payment.module.ts | 2 - .../modules/payment/payment.service.spec.ts | 44 ++++++------ .../src/modules/payment/payment.service.ts | 21 +++--- .../providers/payment-provider.abstract.ts | 13 ++-- .../payment/providers/stripe/stripe.module.ts | 9 --- .../providers/stripe/stripe.service.spec.ts | 71 +++---------------- .../providers/stripe/stripe.service.ts | 50 +++++++------ 8 files changed, 85 insertions(+), 137 deletions(-) delete mode 100644 packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.module.ts diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.interface.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.interface.ts index 6e8c037ad8..47a05f5451 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.interface.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.interface.ts @@ -19,14 +19,14 @@ export interface BillingAddress { line1?: string; city?: string; country?: string; - postal_code?: string; + postalCode?: string; } export interface CustomerData { email: string; name?: string; address?: BillingAddress; - default_payment_method?: string; + defaultPaymentMethod?: string; } export interface TaxId { @@ -37,15 +37,15 @@ export interface TaxId { export interface Invoice { id: string; - payment_id: string | null; + paymentId: string | null; status?: string; - amount_due: number; + amountDue: number; currency: string; } export interface CardSetup { - customer_id: string; - payment_method: string; + customerId: string; + paymentMethod: string; } export interface PaymentData { diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.module.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.module.ts index 8b732a0112..522df94b37 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.module.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.module.ts @@ -15,7 +15,6 @@ import { UserEntity } from '../user/user.entity'; import { JobRepository } from '../job/job.repository'; import { UserRepository } from '../user/user.repository'; import { RateModule } from '../rate/rate.module'; -import { StripeModule } from './providers/stripe/stripe.module'; import { StripeService } from './providers/stripe/stripe.service'; import { PaymentProvider } from './providers/payment-provider.abstract'; @@ -27,7 +26,6 @@ import { PaymentProvider } from './providers/payment-provider.abstract'; Web3Module, WhitelistModule, RateModule, - StripeModule, MinioModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts index d557a78331..95a7f2492e 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts @@ -154,7 +154,7 @@ describe('PaymentService', () => { const invoice = { id: 'id', - payment_id: paymentIntent.id, + paymentId: paymentIntent.id, }; paymentProvider.createInvoice.mockResolvedValue(invoice as any); @@ -753,23 +753,18 @@ describe('PaymentService', () => { paymentProviderId: null, }; - const paymentIntent = { - client_secret: 'clientSecret123', - }; + const client_secret = 'clientSecret123'; + const customerId = 'cus_123'; - paymentProvider.createCustomerWithCard.mockResolvedValue( - paymentIntent.client_secret, - ); + paymentProvider.createCustomer.mockResolvedValue(customerId); + paymentProvider.setupCard.mockResolvedValue(client_secret); const result = await paymentService.createCustomerAndAssignCard( user as any, ); - expect(result).toEqual(paymentIntent.client_secret); - expect(paymentProvider.createCustomerWithCard).toHaveBeenCalledWith( - null, - user.email, - ); + expect(result).toEqual(client_secret); + expect(paymentProvider.createCustomer).toHaveBeenCalledWith(user.email); }); it('should throw a bad request exception if the customer creation fails', async () => { @@ -778,7 +773,8 @@ describe('PaymentService', () => { email: 'test@hmt.ai', paymentProviderId: undefined, }; - paymentProvider.createCustomerWithCard.mockRejectedValue( + + paymentProvider.createCustomer.mockRejectedValue( new ServerError(ErrorPayment.CustomerNotCreated), ); @@ -793,13 +789,16 @@ describe('PaymentService', () => { email: 'test@hmt.ai', }; - paymentProvider.createCustomerWithCard.mockRejectedValue( - new ServerError(ErrorPayment.IntentNotCreated), + paymentProvider.createCustomer.mockResolvedValue('cus_123'); + paymentProvider.setupCard.mockRejectedValue( + new ServerError(ErrorPayment.CardNotAssigned), ); await expect( paymentService.createCustomerAndAssignCard(user as any), - ).rejects.toThrow(new ServerError(ErrorPayment.IntentNotCreated)); + ).rejects.toThrow(new ServerError(ErrorPayment.CardNotAssigned)); + + expect(paymentProvider.createCustomer).toHaveBeenCalledWith(user.email); }); it('should throw a bad request exception if the client secret does not exists', async () => { @@ -808,7 +807,8 @@ describe('PaymentService', () => { email: 'test@hmt.ai', }; - paymentProvider.createCustomerWithCard.mockRejectedValue( + paymentProvider.createCustomer.mockResolvedValue('cus_123'); + paymentProvider.setupCard.mockRejectedValue( new ServerError(ErrorPayment.ClientSecretDoesNotExist), ); @@ -827,8 +827,8 @@ describe('PaymentService', () => { }; const setupMock = { - customer_id: 'cus_123', - payment_method: 'pm_123', + customerId: 'cus_123', + paymentMethod: 'pm_123', }; paymentProvider.retrieveCardSetup.mockResolvedValue(setupMock as any); @@ -852,7 +852,7 @@ describe('PaymentService', () => { 'setup_123', ); expect(paymentProvider.updateCustomer).toHaveBeenCalledWith('cus_123', { - default_payment_method: 'pm_123', + defaultPaymentMethod: 'pm_123', }); }); @@ -899,7 +899,7 @@ describe('PaymentService', () => { paymentProvider.createInvoice.mockResolvedValueOnce({ id: invoiceId, - payment_id: paymentIntent, + paymentId: paymentIntent, } as any); paymentProvider.createPayment.mockResolvedValueOnce(paymentIntent as any); paymentProvider.getDefaultPaymentMethod.mockResolvedValueOnce( @@ -1128,7 +1128,7 @@ describe('PaymentService', () => { await paymentService.changeDefaultPaymentMethod(user as any, 'pm_123'); expect(paymentProvider.updateCustomer).toHaveBeenCalledWith('cus_123', { - default_payment_method: 'pm_123', + defaultPaymentMethod: 'pm_123', }); }); }); diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts index 25981d7a18..9054d5f384 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts @@ -63,10 +63,13 @@ export class PaymentService { ) {} public async createCustomerAndAssignCard(user: UserEntity): Promise { - return await this.paymentProvider.createCustomerWithCard( - user.paymentProviderId, - user.email, - ); + let customerId = user.paymentProviderId; + + if (!customerId) { + customerId = await this.paymentProvider.createCustomer(user.email); + } + + return await this.paymentProvider.setupCard(customerId); } public async confirmCard( @@ -82,7 +85,7 @@ export class PaymentService { let defaultPaymentMethod: string | null = null; if (!user.paymentProviderId) { - user.paymentProviderId = setup.customer_id as string; + user.paymentProviderId = setup.customerId as string; await this.userRepository.updateOne(user); } else { defaultPaymentMethod = await this.getDefaultPaymentMethod( @@ -92,7 +95,7 @@ export class PaymentService { if (data.defaultCard || !defaultPaymentMethod) { await this.paymentProvider.updateCustomer(user.paymentProviderId, { - default_payment_method: setup.payment_method as string, + defaultPaymentMethod: setup.paymentMethod as string, }); } @@ -118,7 +121,7 @@ export class PaymentService { ); const paymentIntent = await this.paymentProvider.createPayment( - invoice.payment_id as string, + invoice.paymentId as string, paymentMethodId, false, // on-session payment ); @@ -361,7 +364,7 @@ export class PaymentService { } const paymentIntent = await this.paymentProvider.createPayment( - invoice.payment_id as string, + invoice.paymentId as string, defaultPaymentMethod, true, // off-session payment ); @@ -492,7 +495,7 @@ export class PaymentService { } return this.paymentProvider.updateCustomer(user.paymentProviderId, { - default_payment_method: cardId, + defaultPaymentMethod: cardId, }); } diff --git a/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts index 093af3c5f3..01188c1434 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts @@ -14,14 +14,17 @@ export abstract class PaymentProvider { /** * Create a new customer in the payment provider system - * @param customerId Customer ID * @param email Customer's email address * @returns Customer ID */ - abstract createCustomerWithCard( - customerId: string | null, - email: string, - ): Promise; + abstract createCustomer(email: string): Promise; + + /** + * Setup payment card in the payment provider system + * @param customerId Customer ID + * @returns Customer ID + */ + abstract setupCard(customerId: string): Promise; /** * Create an invoice for a customer diff --git a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.module.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.module.ts deleted file mode 100644 index 5c41e4f78f..0000000000 --- a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { StripeService } from './stripe.service'; -import { StripeConfigService } from '../../../../common/config/stripe-config.service'; - -@Module({ - providers: [StripeService, StripeConfigService], - exports: [StripeService], -}) -export class StripeModule {} diff --git a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts index ed4a6e668b..7ac2fc2622 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts @@ -10,7 +10,6 @@ import { PaymentStatus, VatType, } from '../../../../common/enums/payment'; -import { PaymentProvider } from '../payment-provider.abstract'; jest.mock('stripe'); @@ -84,10 +83,6 @@ describe('StripeService', () => { jest.clearAllMocks(); }); - it('should implement PaymentProvider interface', () => { - expect(service).toBeInstanceOf(PaymentProvider); - }); - describe('createCustomer', () => { it('should create a customer successfully', async () => { const mockCustomer = { id: 'cus_123' }; @@ -235,10 +230,10 @@ describe('StripeService', () => { expect(result).toEqual({ id, - payment_id: payment_intent, + paymentId: payment_intent, status, currency, - amount_due, + amountDue: amount_due, }); }); @@ -293,10 +288,16 @@ describe('StripeService', () => { expect(result).toEqual({ email: mockCustomer.email, name: mockCustomer.name, - address: mockCustomer.address, - default_payment_method: + address: { + line1: '123 Street', + city: 'City', + country: 'US', + postalCode: '12345', + }, + defaultPaymentMethod: mockCustomer.invoice_settings.default_payment_method, }); + expect(stripeMock.customers.update).toHaveBeenCalledWith( 'cus_123', updateData, @@ -408,58 +409,6 @@ describe('StripeService', () => { }); }); - describe('createCustomerWithCard', () => { - it('should create new customer and setup card when customerId is null', async () => { - const mockCustomer = { id: 'cus_123' }; - const mockSetupIntent = { - id: 'seti_123', - client_secret: 'seti_secret_123', - }; - - stripeMock.customers.create = jest.fn().mockResolvedValue(mockCustomer); - stripeMock.setupIntents.create = jest - .fn() - .mockResolvedValue(mockSetupIntent); - - const result = await service.createCustomerWithCard( - null, - 'test@example.com', - ); - - expect(result).toBe('seti_secret_123'); - expect(stripeMock.customers.create).toHaveBeenCalledWith({ - email: 'test@example.com', - }); - expect(stripeMock.setupIntents.create).toHaveBeenCalledWith({ - automatic_payment_methods: { enabled: true }, - customer: 'cus_123', - }); - }); - - it('should only setup card when customerId is provided', async () => { - const mockSetupIntent = { - id: 'seti_123', - client_secret: 'seti_secret_123', - }; - - stripeMock.setupIntents.create = jest - .fn() - .mockResolvedValue(mockSetupIntent); - - const result = await service.createCustomerWithCard( - 'cus_123', - 'test@example.com', - ); - - expect(result).toBe('seti_secret_123'); - expect(stripeMock.customers.create).not.toHaveBeenCalled(); - expect(stripeMock.setupIntents.create).toHaveBeenCalledWith({ - automatic_payment_methods: { enabled: true }, - customer: 'cus_123', - }); - }); - }); - describe('getReceiptUrl', () => { it('should return receipt URL for valid payment', async () => { const mockPaymentIntent = { diff --git a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts index ebc6f0c5b1..bbc52a2553 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts @@ -32,14 +32,6 @@ export class StripeService extends PaymentProvider { }); } - async createCustomerWithCard(customerId: string | null, email: string) { - if (!customerId) { - customerId = await this.createCustomer(email); - } - - return await this.setupCard(customerId); - } - async createInvoice( customerId: string, amountInCents: number, @@ -70,9 +62,9 @@ export class StripeService extends PaymentProvider { return { id: invoice.id, - payment_id: invoice.payment_intent as string, + paymentId: invoice.payment_intent as string, status: invoice.status?.toString(), - amount_due: invoice.amount_due, + amountDue: invoice.amount_due, currency: invoice.currency, } as Invoice; } @@ -149,7 +141,7 @@ export class StripeService extends PaymentProvider { if (customer.address) { const address = new AddressDto(); address.country = (customer.address.country as string).toLowerCase(); - address.postalCode = customer.address.postal_code as string; + address.postalCode = customer.address.postalCode as string; address.city = customer.address.city as string; address.line = customer.address.line1 as string; userBillingInfo.address = address; @@ -186,7 +178,7 @@ export class StripeService extends PaymentProvider { line1: data.address?.line, city: data.address?.city, country: data.address?.country, - postal_code: data.address?.postalCode, + postalCode: data.address?.postalCode, }, name: data.name, email: data.email, @@ -216,7 +208,7 @@ export class StripeService extends PaymentProvider { async getDefaultPaymentMethod(customerId: string): Promise { const customer = await this.retrieveCustomer(customerId); - return customer.default_payment_method ?? null; + return customer.defaultPaymentMethod ?? null; } async listPaymentMethods(customerId: string): Promise { @@ -269,17 +261,29 @@ export class StripeService extends PaymentProvider { customerId: string, data: Partial, ): Promise { - const params = data.default_payment_method + const { email, name, address, defaultPaymentMethod } = data; + const { line1, city, country, postalCode } = address ?? {}; + + const updatePayload = defaultPaymentMethod ? { invoice_settings: { - default_payment_method: data.default_payment_method, + default_payment_method: data.defaultPaymentMethod, }, } - : data; + : { + email, + name, + address: { + line1, + city, + country, + postal_code: postalCode, + }, + }; const customer = (await this.stripe.customers.update( customerId, - params, + updatePayload, )) as Stripe.Customer; return { @@ -290,10 +294,10 @@ export class StripeService extends PaymentProvider { line1: customer.address.line1 ?? undefined, city: customer.address.city ?? undefined, country: customer.address.country ?? undefined, - postal_code: customer.address.postal_code ?? undefined, + postalCode: customer.address.postal_code ?? undefined, } : undefined, - default_payment_method: customer.invoice_settings + defaultPaymentMethod: customer.invoice_settings ? (customer.invoice_settings.default_payment_method as string) : undefined, }; @@ -346,10 +350,10 @@ export class StripeService extends PaymentProvider { line1: customer.address.line1 ?? undefined, city: customer.address.city ?? undefined, country: customer.address.country ?? undefined, - postal_code: customer.address.postal_code ?? undefined, + postalCode: customer.address.postal_code ?? undefined, } : undefined, - default_payment_method: customer.invoice_settings + defaultPaymentMethod: customer.invoice_settings ? (customer.invoice_settings.default_payment_method as string) : undefined, }; @@ -389,8 +393,8 @@ export class StripeService extends PaymentProvider { const setupIntent = await this.stripe.setupIntents.retrieve(setupIntentId); return { - customer_id: setupIntent.customer as string, - payment_method: setupIntent.payment_method as string, + customerId: setupIntent.customer as string, + paymentMethod: setupIntent.payment_method as string, }; } From d0dfbd3d01d7d96fc2531a4a632ea1f4f49bde2f Mon Sep 17 00:00:00 2001 From: Nikolai Muhhin Date: Thu, 19 Jun 2025 00:20:20 +0300 Subject: [PATCH 12/16] Restore StripePaymentStatus and adjust tests --- .../server/src/common/enums/payment.ts | 4 + .../src/modules/payment/payment.interface.ts | 10 +- .../modules/payment/payment.service.spec.ts | 30 +++-- .../src/modules/payment/payment.service.ts | 13 +- .../providers/payment-provider.abstract.ts | 4 +- .../providers/stripe/stripe.service.spec.ts | 97 ++++++-------- .../providers/stripe/stripe.service.ts | 120 ++++++++++-------- 7 files changed, 139 insertions(+), 139 deletions(-) diff --git a/packages/apps/job-launcher/server/src/common/enums/payment.ts b/packages/apps/job-launcher/server/src/common/enums/payment.ts index c1c42b7e87..2db8a1325e 100644 --- a/packages/apps/job-launcher/server/src/common/enums/payment.ts +++ b/packages/apps/job-launcher/server/src/common/enums/payment.ts @@ -34,8 +34,12 @@ export enum PaymentStatus { PENDING = 'pending', FAILED = 'failed', SUCCEEDED = 'succeeded', +} + +export enum StripePaymentStatus { CANCELED = 'canceled', REQUIRES_PAYMENT_METHOD = 'requires_payment_method', + SUCCEEDED = 'succeeded', } export enum PaymentSortField { diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.interface.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.interface.ts index 47a05f5451..b2648a08c2 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.interface.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.interface.ts @@ -1,5 +1,5 @@ import { PaymentEntity } from './payment.entity'; -import { VatType } from '../../common/enums/payment'; +import { PaymentStatus, VatType } from '../../common/enums/payment'; export interface ListResult { entities: PaymentEntity[]; @@ -51,10 +51,10 @@ export interface CardSetup { export interface PaymentData { customer: string; id: string; - client_secret: string | null; - status: string; + clientSecret: string | null; + status: PaymentStatus | null; amount: number; - amount_received: number; + amountReceived: number; currency: string; - latest_charge: string; + latestCharge: string; } diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts index 95a7f2492e..bcca66a4ac 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts @@ -52,7 +52,7 @@ import { PaymentEntity } from './payment.entity'; import { PaymentRepository } from './payment.repository'; import { PaymentService } from './payment.service'; import { PaymentProvider } from './providers/payment-provider.abstract'; -import { PaymentData } from './payment.interface'; +import { Invoice, PaymentData } from './payment.interface'; describe('PaymentService', () => { let paymentService: PaymentService; @@ -149,16 +149,18 @@ describe('PaymentService', () => { const paymentIntent = { id: 'pi_123', - client_secret: 'clientSecret123', - }; + clientSecret: 'clientSecret123', + } as PaymentData; const invoice = { id: 'id', paymentId: paymentIntent.id, - }; + } as Invoice; paymentProvider.createInvoice.mockResolvedValue(invoice as any); - paymentProvider.createPayment.mockResolvedValue(paymentIntent as any); + paymentProvider.assignPaymentMethod.mockResolvedValue( + paymentIntent as any, + ); jest .spyOn(paymentRepository, 'findOneByTransaction') @@ -170,14 +172,14 @@ describe('PaymentService', () => { const result = await paymentService.createFiatPayment(user as any, dto); - expect(result).toEqual(paymentIntent.client_secret); + expect(result).toEqual(paymentIntent.clientSecret); expect(paymentProvider.createInvoice).toHaveBeenCalledWith( 'cus_123', 10000, PaymentCurrency.USD, 'Top up', ); - expect(paymentProvider.createPayment).toHaveBeenCalledWith( + expect(paymentProvider.assignPaymentMethod).toHaveBeenCalledWith( 'pi_123', 'pm_123', false, @@ -207,7 +209,9 @@ describe('PaymentService', () => { }; paymentProvider.createInvoice.mockResolvedValue(invoice as any); - paymentProvider.createPayment.mockResolvedValue(paymentIntent as any); + paymentProvider.assignPaymentMethod.mockResolvedValue( + paymentIntent as any, + ); findOneMock.mockResolvedValue({ transaction: paymentIntent.client_secret, @@ -291,7 +295,7 @@ describe('PaymentService', () => { }; const paymentData = { - status: PaymentStatus.REQUIRES_PAYMENT_METHOD, + status: PaymentStatus.FAILED, amount: 100, amount_received: 0, currency: PaymentCurrency.USD, @@ -901,7 +905,9 @@ describe('PaymentService', () => { id: invoiceId, paymentId: paymentIntent, } as any); - paymentProvider.createPayment.mockResolvedValueOnce(paymentIntent as any); + paymentProvider.assignPaymentMethod.mockResolvedValueOnce( + paymentIntent as any, + ); paymentProvider.getDefaultPaymentMethod.mockResolvedValueOnce( paymentMethodId, ); @@ -915,7 +921,7 @@ describe('PaymentService', () => { PaymentCurrency.USD, 'Slash Job Id ' + jobEntity.id, ); - expect(paymentProvider.createPayment).toHaveBeenCalledWith( + expect(paymentProvider.assignPaymentMethod).toHaveBeenCalledWith( paymentIntent, paymentMethodId, true, @@ -942,7 +948,7 @@ describe('PaymentService', () => { paymentMethodId, ); - paymentProvider.createPayment.mockRejectedValue( + paymentProvider.assignPaymentMethod.mockRejectedValue( new ServerError(ErrorPayment.PaymentMethodAssociationFailed), ); diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts index 9054d5f384..20d6c00faa 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts @@ -120,7 +120,7 @@ export class PaymentService { 'Top up', ); - const paymentIntent = await this.paymentProvider.createPayment( + const paymentIntent = await this.paymentProvider.assignPaymentMethod( invoice.paymentId as string, paymentMethodId, false, // on-session payment @@ -149,7 +149,7 @@ export class PaymentService { await this.paymentRepository.createUnique(newPaymentEntity); - return paymentIntent.client_secret!; + return paymentIntent.clientSecret!; } public async confirmFiatPayment( @@ -179,14 +179,9 @@ export class PaymentService { throw new NotFoundError(ErrorPayment.NotFound); } - if ( - paymentData.status === PaymentStatus.CANCELED || - paymentData.status === PaymentStatus.REQUIRES_PAYMENT_METHOD - ) { + if (paymentData.status === PaymentStatus.FAILED) { paymentEntity.status = PaymentStatus.FAILED; - await this.paymentRepository.updateOne(paymentEntity); - throw new ConflictError(ErrorPayment.NotSuccess); } else if (paymentData.status !== PaymentStatus.SUCCEEDED) { return false; // TODO: Handling other cases @@ -363,7 +358,7 @@ export class PaymentService { throw new ServerError(ErrorPayment.NotDefaultPaymentMethod); } - const paymentIntent = await this.paymentProvider.createPayment( + const paymentIntent = await this.paymentProvider.assignPaymentMethod( invoice.paymentId as string, defaultPaymentMethod, true, // off-session payment diff --git a/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts index 01188c1434..dc7655a421 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts @@ -42,13 +42,13 @@ export abstract class PaymentProvider { ): Promise; /** - * Handle a payment intent (confirm, update, etc.) + * Assign a payment method and confirm the payment intent * @param paymentIntentId Payment intent ID * @param paymentMethodId Payment method ID * @param offSession Whether the payment is off-session * @returns Updated payment intent */ - abstract createPayment( + abstract assignPaymentMethod( paymentIntentId: string, paymentMethodId: string, offSession: boolean, diff --git a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts index 7ac2fc2622..f5061f94a2 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts @@ -1,3 +1,6 @@ +jest.mock('stripe'); + +import { PaymentData } from '../../payment.interface'; import { Test, TestingModule } from '@nestjs/testing'; import { Logger } from '@nestjs/common'; import { StripeService } from './stripe.service'; @@ -8,11 +11,10 @@ import { ErrorPayment } from '../../../../common/constants/errors'; import { PaymentCurrency, PaymentStatus, + StripePaymentStatus, VatType, } from '../../../../common/enums/payment'; -jest.mock('stripe'); - describe('StripeService', () => { let service: StripeService; let stripeMock: jest.Mocked; @@ -108,7 +110,7 @@ describe('StripeService', () => { }); }); - describe('createSetupIntent', () => { + describe('setupCard', () => { const mockSetupIntent = { id: 'seti_123', client_secret: 'seti_secret_123', @@ -152,18 +154,19 @@ describe('StripeService', () => { }); }); - describe('handlePaymentIntent', () => { + describe('assignPaymentMethod', () => { const mockPaymentIntent = { id: 'pi_123', client_secret: 'pi_secret_123', - status: PaymentStatus.REQUIRES_PAYMENT_METHOD, + status: StripePaymentStatus.REQUIRES_PAYMENT_METHOD, amount: 1000, + amount_received: 1000, currency: PaymentCurrency.USD, customer: 'cus_123', latest_charge: 'ch_123', - }; + } as Stripe.PaymentIntent; - it('should handle off-session payment intent', async () => { + it('should assign off-session payment method', async () => { stripeMock.paymentIntents.confirm = jest .fn() .mockResolvedValue(mockPaymentIntent); @@ -171,16 +174,29 @@ describe('StripeService', () => { .fn() .mockResolvedValue(mockPaymentIntent); - const result = await service.createPayment('pi_123', 'pm_123', true); + const result = await service.assignPaymentMethod( + 'pi_123', + 'pm_123', + true, + ); expect(stripeMock.paymentIntents.confirm).toHaveBeenCalledWith('pi_123', { payment_method: 'pm_123', off_session: true, }); - expect(result).toEqual(mockPaymentIntent); + expect(result).toEqual({ + id: 'pi_123', + clientSecret: 'pi_secret_123', + status: PaymentStatus.FAILED, + amount: 1000, + amountReceived: 1000, + currency: PaymentCurrency.USD, + customer: 'cus_123', + latestCharge: 'ch_123', + } as PaymentData); }); - it('should handle on-session payment intent', async () => { + it('should assign on-session payment method', async () => { stripeMock.paymentIntents.update = jest .fn() .mockResolvedValue(mockPaymentIntent); @@ -188,12 +204,25 @@ describe('StripeService', () => { .fn() .mockResolvedValue(mockPaymentIntent); - const result = await service.createPayment('pi_123', 'pm_123', false); + const result = await service.assignPaymentMethod( + 'pi_123', + 'pm_123', + false, + ); expect(stripeMock.paymentIntents.update).toHaveBeenCalledWith('pi_123', { payment_method: 'pm_123', }); - expect(result).toEqual(mockPaymentIntent); + expect(result).toEqual({ + id: 'pi_123', + clientSecret: 'pi_secret_123', + status: PaymentStatus.FAILED, + amount: 1000, + amountReceived: 1000, + currency: PaymentCurrency.USD, + customer: 'cus_123', + latestCharge: 'ch_123', + } as PaymentData); }); }); @@ -365,50 +394,6 @@ describe('StripeService', () => { }); }); - describe('tax ID operations', () => { - it('should create tax ID successfully', async () => { - const mockTaxId = { - id: 'txi_123', - type: VatType.EU_VAT, - value: 'DE123456789', - }; - stripeMock.customers.createTaxId = jest.fn().mockResolvedValue(mockTaxId); - - const result = await service.createTaxId( - 'cus_123', - VatType.EU_VAT, - 'DE123456789', - ); - - expect(stripeMock.customers.createTaxId).toHaveBeenCalledWith('cus_123', { - type: VatType.EU_VAT, - value: 'DE123456789', - }); - expect(result).toEqual(mockTaxId); - }); - - it('should list tax IDs successfully', async () => { - const mockTaxIds = { data: [{ id: 'txi_123' }] }; - stripeMock.customers.listTaxIds = jest.fn().mockResolvedValue(mockTaxIds); - - const result = await service.listCustomerTaxIds('cus_123'); - - expect(stripeMock.customers.listTaxIds).toHaveBeenCalledWith('cus_123'); - expect(result).toEqual(mockTaxIds.data); - }); - - it('should delete tax ID successfully', async () => { - stripeMock.customers.deleteTaxId = jest.fn().mockResolvedValue({}); - - await service.deleteTaxId('cus_123', 'txi_123'); - - expect(stripeMock.customers.deleteTaxId).toHaveBeenCalledWith( - 'cus_123', - 'txi_123', - ); - }); - }); - describe('getReceiptUrl', () => { it('should return receipt URL for valid payment', async () => { const mockPaymentIntent = { diff --git a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts index bbc52a2553..8e1e5f9177 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts @@ -3,7 +3,11 @@ import Stripe from 'stripe'; import { StripeConfigService } from '../../../../common/config/stripe-config.service'; import { NotFoundError, ServerError } from '../../../../common/errors'; import { ErrorPayment } from '../../../../common/constants/errors'; -import { VatType } from '../../../../common/enums/payment'; +import { + PaymentStatus, + StripePaymentStatus, + VatType, +} from '../../../../common/enums/payment'; import { CardSetup, CustomerData, @@ -32,6 +36,40 @@ export class StripeService extends PaymentProvider { }); } + async createCustomer(email: string): Promise { + try { + const customer = await this.stripe.customers.create({ email }); + return customer.id; + } catch (error) { + this.logger.log(error.message, StripeService.name); + throw new ServerError(ErrorPayment.CustomerNotCreated); + } + } + + async setupCard(customerId: string | null): Promise { + let setupIntent: Stripe.Response; + + try { + setupIntent = await this.stripe.setupIntents.create({ + automatic_payment_methods: { enabled: true }, + customer: customerId ?? undefined, + }); + } catch (error) { + this.logger.log(error.message, StripeService.name); + throw new ServerError(ErrorPayment.CardNotAssigned); + } + + if (!setupIntent?.client_secret) { + this.logger.log( + ErrorPayment.ClientSecretDoesNotExist, + StripeService.name, + ); + throw new ServerError(ErrorPayment.ClientSecretDoesNotExist); + } + + return setupIntent.client_secret; + } + async createInvoice( customerId: string, amountInCents: number, @@ -69,7 +107,7 @@ export class StripeService extends PaymentProvider { } as Invoice; } - async createPayment( + async assignPaymentMethod( paymentIntentId: string, paymentMethodId: string, offSession: boolean, @@ -91,20 +129,11 @@ export class StripeService extends PaymentProvider { const paymentIntent = await this.retrievePaymentIntent(paymentIntentId); - if (!paymentIntent?.client_secret) { + if (!paymentIntent?.clientSecret) { throw new ServerError(ErrorPayment.ClientSecretDoesNotExist); } - return { - id: paymentIntent.id, - customer: paymentIntent.customer as string, - client_secret: paymentIntent.client_secret, - status: paymentIntent.status, - amount: paymentIntent.amount, - amount_received: paymentIntent.amount_received, - currency: paymentIntent.currency, - latest_charge: paymentIntent.latest_charge as string, - }; + return paymentIntent; } async getReceiptUrl(paymentId: string, customerId: string): Promise { @@ -115,7 +144,7 @@ export class StripeService extends PaymentProvider { } const charge = await this.retrieveCharge( - paymentIntent.latest_charge as string, + paymentIntent.latestCharge as string, ); if (!charge || !charge.receipt_url) { @@ -194,15 +223,27 @@ export class StripeService extends PaymentProvider { throw new NotFoundError(ErrorPayment.NotFound); } + let status: PaymentStatus | null; + if ( + paymentIntent.status === StripePaymentStatus.CANCELED || + paymentIntent?.status === StripePaymentStatus.REQUIRES_PAYMENT_METHOD + ) { + status = PaymentStatus.FAILED; + } else if (paymentIntent?.status !== StripePaymentStatus.SUCCEEDED) { + status = null; // handle other statuses + } else { + status = PaymentStatus.SUCCEEDED; + } + return { id: paymentIntent.id, customer: paymentIntent.customer as string, - client_secret: paymentIntent.client_secret, - status: paymentIntent.status, + clientSecret: paymentIntent.client_secret, + status, amount: paymentIntent.amount, - amount_received: paymentIntent.amount_received, + amountReceived: paymentIntent.amount_received, currency: paymentIntent.currency, - latest_charge: paymentIntent.latest_charge as string, + latestCharge: paymentIntent.latest_charge as string, }; } @@ -303,40 +344,6 @@ export class StripeService extends PaymentProvider { }; } - async createCustomer(email: string): Promise { - try { - const customer = await this.stripe.customers.create({ email }); - return customer.id; - } catch (error) { - this.logger.log(error.message, StripeService.name); - throw new ServerError(ErrorPayment.CustomerNotCreated); - } - } - - async setupCard(customerId: string | null): Promise { - let setupIntent: Stripe.Response; - - try { - setupIntent = await this.stripe.setupIntents.create({ - automatic_payment_methods: { enabled: true }, - customer: customerId ?? undefined, - }); - } catch (error) { - this.logger.log(error.message, StripeService.name); - throw new ServerError(ErrorPayment.CardNotAssigned); - } - - if (!setupIntent?.client_secret) { - this.logger.log( - ErrorPayment.ClientSecretDoesNotExist, - StripeService.name, - ); - throw new ServerError(ErrorPayment.ClientSecretDoesNotExist); - } - - return setupIntent.client_secret; - } - private async retrieveCustomer(customerId: string): Promise { const customer = (await this.stripe.customers.retrieve( customerId, @@ -359,7 +366,7 @@ export class StripeService extends PaymentProvider { }; } - async listCustomerTaxIds(customerId: string): Promise { + private async listCustomerTaxIds(customerId: string): Promise { const taxIds = await this.stripe.customers.listTaxIds(customerId); return taxIds.data.map((taxId) => ({ @@ -369,7 +376,7 @@ export class StripeService extends PaymentProvider { })); } - async createTaxId( + private async createTaxId( customerId: string, type: VatType, value: string, @@ -385,7 +392,10 @@ export class StripeService extends PaymentProvider { }; } - async deleteTaxId(customerId: string, taxIdId: string): Promise { + private async deleteTaxId( + customerId: string, + taxIdId: string, + ): Promise { await this.stripe.customers.deleteTaxId(customerId, taxIdId); } From 6e2f1a8f8f78c8fc3e5261870337526c590774c7 Mon Sep 17 00:00:00 2001 From: Nikolai Muhhin Date: Thu, 19 Jun 2025 01:19:58 +0300 Subject: [PATCH 13/16] Use test fixtures --- .../server/src/modules/payment/fixtures.ts | 181 +++++++ .../providers/stripe/stripe.service.spec.ts | 447 ++++++++++++------ 2 files changed, 495 insertions(+), 133 deletions(-) create mode 100644 packages/apps/job-launcher/server/src/modules/payment/fixtures.ts diff --git a/packages/apps/job-launcher/server/src/modules/payment/fixtures.ts b/packages/apps/job-launcher/server/src/modules/payment/fixtures.ts new file mode 100644 index 0000000000..3d7fa1fc3a --- /dev/null +++ b/packages/apps/job-launcher/server/src/modules/payment/fixtures.ts @@ -0,0 +1,181 @@ +import { faker } from '@faker-js/faker'; +import { + PaymentCurrency, + PaymentStatus, + StripePaymentStatus, + VatType, +} from '../../common/enums/payment'; +import { + CardSetup, + CustomerData, + Invoice, + PaymentData, + PaymentMethod, + TaxId, +} from './payment.interface'; +import { AddressDto, BillingInfoDto } from './payment.dto'; + +export const createMockSetupIntent = () => ({ + id: faker.string.alphanumeric(24), + client_secret: faker.string.alphanumeric(32), + customer: faker.string.alphanumeric(24), + payment_method: faker.string.alphanumeric(24), + status: 'requires_payment_method', + created: faker.number.int(), +}); + +export const createMockPaymentIntent = (overrides: Partial = {}) => ({ + id: faker.string.alphanumeric(24), + client_secret: faker.string.alphanumeric(32), + status: StripePaymentStatus.REQUIRES_PAYMENT_METHOD, + amount: faker.number.int({ min: 1000, max: 100000 }), + amount_received: 0, + currency: PaymentCurrency.USD, + customer: faker.string.alphanumeric(24), + latest_charge: faker.string.alphanumeric(24), + created: faker.number.int(), + ...overrides, +}); + +export const createMockCustomer = (overrides: Partial = {}) => ({ + id: faker.string.alphanumeric(24), + name: faker.person.fullName(), + email: faker.internet.email(), + address: { + line1: faker.location.streetAddress(), + city: faker.location.city(), + country: faker.location.countryCode(), + postal_code: faker.location.zipCode(), + }, + invoice_settings: { + default_payment_method: faker.string.alphanumeric(24), + }, + created: faker.number.int(), + ...overrides, +}); + +export const createMockPaymentMethod = (overrides: Partial = {}) => ({ + id: faker.string.alphanumeric(24), + card: { + brand: faker.helpers.arrayElement(['visa', 'mastercard', 'amex']), + last4: faker.string.numeric(4), + exp_month: faker.number.int({ min: 1, max: 12 }), + exp_year: faker.number.int({ min: 2024, max: 2030 }), + }, + customer: faker.string.alphanumeric(24), + created: faker.number.int(), + ...overrides, +}); + +export const createMockInvoice = (overrides: Partial = {}) => ({ + id: faker.string.alphanumeric(24), + payment_intent: faker.string.alphanumeric(24), + status: 'draft', + amount_due: faker.number.int({ min: 1000, max: 100000 }), + currency: PaymentCurrency.USD, + customer: faker.string.alphanumeric(24), + created: faker.number.int(), + ...overrides, +}); + +export const createMockCharge = (overrides: Partial = {}) => ({ + id: faker.string.alphanumeric(24), + receipt_url: faker.internet.url(), + amount: faker.number.int({ min: 1000, max: 100000 }), + currency: PaymentCurrency.USD, + created: faker.number.int(), + ...overrides, +}); + +export const createMockTaxId = (overrides: Partial = {}) => ({ + id: faker.string.alphanumeric(24), + type: faker.helpers.arrayElement(Object.values(VatType)), + value: faker.string.alphanumeric(10), + created: faker.number.int(), + ...overrides, +}); + +// Fixtures for our internal interfaces +export const createMockPaymentData = ( + overrides: Partial = {}, +): PaymentData => ({ + id: faker.string.alphanumeric(24), + clientSecret: faker.string.alphanumeric(32), + status: PaymentStatus.FAILED, + amount: faker.number.int({ min: 1000, max: 100000 }), + amountReceived: 0, + currency: PaymentCurrency.USD, + customer: faker.string.alphanumeric(24), + latestCharge: faker.string.alphanumeric(24), + ...overrides, +}); + +export const createMockPaymentMethodData = ( + overrides: Partial = {}, +): PaymentMethod => ({ + id: faker.string.alphanumeric(24), + brand: faker.helpers.arrayElement(['visa', 'mastercard', 'amex']), + last4: faker.string.numeric(4), + expMonth: faker.number.int({ min: 1, max: 12 }), + expYear: faker.number.int({ min: 2024, max: 2030 }), + default: false, + ...overrides, +}); + +export const createMockCardSetup = ( + overrides: Partial = {}, +): CardSetup => ({ + customerId: faker.string.alphanumeric(24), + paymentMethod: faker.string.alphanumeric(24), + ...overrides, +}); + +export const createMockInvoiceData = ( + overrides: Partial = {}, +): Invoice => ({ + id: faker.string.alphanumeric(24), + paymentId: faker.string.alphanumeric(24), + status: 'draft', + amountDue: faker.number.int({ min: 1000, max: 100000 }), + currency: PaymentCurrency.USD, + ...overrides, +}); + +export const createMockCustomerData = ( + overrides: Partial = {}, +): CustomerData => ({ + email: faker.internet.email(), + name: faker.person.fullName(), + address: { + line1: faker.location.streetAddress(), + city: faker.location.city(), + country: faker.location.countryCode(), + postalCode: faker.location.zipCode(), + }, + defaultPaymentMethod: faker.string.alphanumeric(24), + ...overrides, +}); + +export const createMockTaxIdData = (overrides: Partial = {}): TaxId => ({ + id: faker.string.alphanumeric(24), + type: faker.helpers.arrayElement(Object.values(VatType)), + value: faker.string.alphanumeric(10), + ...overrides, +}); + +export const createMockBillingInfoDto = ( + overrides: Partial = {}, +): BillingInfoDto => { + const dto = new BillingInfoDto(); + dto.name = faker.person.fullName(); + dto.email = faker.internet.email(); + dto.address = new AddressDto(); + dto.address.line = faker.location.streetAddress(); + dto.address.city = faker.location.city(); + dto.address.country = faker.location.countryCode().toLowerCase(); + dto.address.postalCode = faker.location.zipCode(); + dto.vat = faker.string.alphanumeric(10); + dto.vatType = faker.helpers.arrayElement(Object.values(VatType)); + + return Object.assign(dto, overrides); +}; diff --git a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts index f5061f94a2..caa1e64087 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts @@ -14,6 +14,13 @@ import { StripePaymentStatus, VatType, } from '../../../../common/enums/payment'; +import { + createMockPaymentIntent, + createMockCustomer, + createMockPaymentMethod, + createMockSetupIntent, + createMockInvoice, +} from '../../fixtures'; describe('StripeService', () => { let service: StripeService; @@ -111,12 +118,7 @@ describe('StripeService', () => { }); describe('setupCard', () => { - const mockSetupIntent = { - id: 'seti_123', - client_secret: 'seti_secret_123', - customer: 'cus_123', - payment_method: 'pm_123', - }; + const mockSetupIntent = createMockSetupIntent(); it('should create setup intent successfully', async () => { stripeMock.setupIntents.create = jest @@ -125,7 +127,7 @@ describe('StripeService', () => { const result = await service.setupCard('cus_123'); - expect(result).toBe('seti_secret_123'); + expect(result).toBe(mockSetupIntent.client_secret); expect(stripeMock.setupIntents.create).toHaveBeenCalledWith({ automatic_payment_methods: { enabled: true }, customer: 'cus_123', @@ -155,16 +157,7 @@ describe('StripeService', () => { }); describe('assignPaymentMethod', () => { - const mockPaymentIntent = { - id: 'pi_123', - client_secret: 'pi_secret_123', - status: StripePaymentStatus.REQUIRES_PAYMENT_METHOD, - amount: 1000, - amount_received: 1000, - currency: PaymentCurrency.USD, - customer: 'cus_123', - latest_charge: 'ch_123', - } as Stripe.PaymentIntent; + const mockPaymentIntent = createMockPaymentIntent(); it('should assign off-session payment method', async () => { stripeMock.paymentIntents.confirm = jest @@ -185,14 +178,14 @@ describe('StripeService', () => { off_session: true, }); expect(result).toEqual({ - id: 'pi_123', - clientSecret: 'pi_secret_123', + id: mockPaymentIntent.id, + clientSecret: mockPaymentIntent.client_secret, status: PaymentStatus.FAILED, - amount: 1000, - amountReceived: 1000, - currency: PaymentCurrency.USD, - customer: 'cus_123', - latestCharge: 'ch_123', + amount: mockPaymentIntent.amount, + amountReceived: mockPaymentIntent.amount_received, + currency: mockPaymentIntent.currency, + customer: mockPaymentIntent.customer, + latestCharge: mockPaymentIntent.latest_charge, } as PaymentData); }); @@ -214,26 +207,20 @@ describe('StripeService', () => { payment_method: 'pm_123', }); expect(result).toEqual({ - id: 'pi_123', - clientSecret: 'pi_secret_123', + id: mockPaymentIntent.id, + clientSecret: mockPaymentIntent.client_secret, status: PaymentStatus.FAILED, - amount: 1000, - amountReceived: 1000, - currency: PaymentCurrency.USD, - customer: 'cus_123', - latestCharge: 'ch_123', + amount: mockPaymentIntent.amount, + amountReceived: mockPaymentIntent.amount_received, + currency: mockPaymentIntent.currency, + customer: mockPaymentIntent.customer, + latestCharge: mockPaymentIntent.latest_charge, } as PaymentData); }); }); describe('createInvoice', () => { - const mockInvoice = { - id: 'inv_123', - payment_intent: 'pi_123', - status: 'draft', - amount_due: 1000, - currency: PaymentCurrency.USD, - }; + const mockInvoice = createMockInvoice(); it('should create invoice successfully', async () => { stripeMock.invoices.create = jest @@ -286,22 +273,260 @@ describe('StripeService', () => { }); }); - describe('updateCustomer', () => { - it('should update customer successfully', async () => { - const mockCustomer = { - id: 'cus_123', - name: 'Updated Name', - email: 'test@example.com', - address: { - line1: '123 Street', - city: 'City', - country: 'US', - postal_code: '12345', + describe('retrievePaymentIntent', () => { + it('should retrieve payment intent successfully', async () => { + const mockPaymentIntent = createMockPaymentIntent({ + status: StripePaymentStatus.SUCCEEDED, + amount_received: 1000, + }); + + stripeMock.paymentIntents.retrieve = jest + .fn() + .mockResolvedValue(mockPaymentIntent); + + const result = await service.retrievePaymentIntent('pi_123'); + + expect(result).toEqual({ + id: mockPaymentIntent.id, + clientSecret: mockPaymentIntent.client_secret, + status: PaymentStatus.SUCCEEDED, + amount: mockPaymentIntent.amount, + amountReceived: mockPaymentIntent.amount_received, + currency: mockPaymentIntent.currency, + customer: mockPaymentIntent.customer, + latestCharge: mockPaymentIntent.latest_charge, + } as PaymentData); + expect(stripeMock.paymentIntents.retrieve).toHaveBeenCalledWith('pi_123'); + }); + + it('should handle different payment statuses', async () => { + const statuses = [ + { + stripe: StripePaymentStatus.REQUIRES_PAYMENT_METHOD, + expected: PaymentStatus.FAILED, }, - invoice_settings: { - default_payment_method: 'pm_123', + { + stripe: StripePaymentStatus.SUCCEEDED, + expected: PaymentStatus.SUCCEEDED, }, + { + stripe: StripePaymentStatus.CANCELED, + expected: PaymentStatus.FAILED, + }, + ]; + + for (const { stripe, expected } of statuses) { + const mockPaymentIntent = createMockPaymentIntent({ status: stripe }); + stripeMock.paymentIntents.retrieve = jest + .fn() + .mockResolvedValue(mockPaymentIntent); + + const result = await service.retrievePaymentIntent('pi_123'); + + expect(result.status).toBe(expected); + } + }); + + it('should handle missing client secret', async () => { + const mockPaymentIntent = createMockPaymentIntent({ + client_secret: null, + }); + stripeMock.paymentIntents.retrieve = jest + .fn() + .mockResolvedValue(mockPaymentIntent); + + const result = await service.retrievePaymentIntent('pi_123'); + + expect(result.clientSecret).toBeNull(); + }); + }); + + describe('getDefaultPaymentMethod', () => { + it('should return default payment method ID when available', async () => { + const mockCustomer = createMockCustomer(); + (mockCustomer as any).invoice_settings = { + default_payment_method: 'pm_default_123', + }; + + stripeMock.customers.retrieve = jest.fn().mockResolvedValue(mockCustomer); + + const result = await service.getDefaultPaymentMethod('cus_123'); + + expect(result).toBe('pm_default_123'); + expect(stripeMock.customers.retrieve).toHaveBeenCalledWith('cus_123'); + }); + + it('should return null when no default payment method', async () => { + const mockCustomer = createMockCustomer(); + (mockCustomer as any).invoice_settings = { + default_payment_method: null, }; + + stripeMock.customers.retrieve = jest.fn().mockResolvedValue(mockCustomer); + + const result = await service.getDefaultPaymentMethod('cus_123'); + + expect(result).toBeNull(); + }); + + it('should return null when customer has no invoice settings', async () => { + const mockCustomer = createMockCustomer(); + (mockCustomer as any).invoice_settings = undefined; + + stripeMock.customers.retrieve = jest.fn().mockResolvedValue(mockCustomer); + + const result = await service.getDefaultPaymentMethod('cus_123'); + + expect(result).toBeNull(); + }); + }); + + describe('listPaymentMethods', () => { + it('should return list of payment methods', async () => { + const mockCustomer = createMockCustomer(); + + const mockPaymentMethods = [ + createMockPaymentMethod(), + createMockPaymentMethod(), + ]; + + mockPaymentMethods[0].id = 'pm_1'; + mockPaymentMethods[0].card = { + brand: 'visa', + last4: '4242', + exp_month: 12, + exp_year: 2024, + }; + mockPaymentMethods[1].id = 'pm_2'; + mockPaymentMethods[1].card = { + brand: 'mastercard', + last4: '5555', + exp_month: 6, + exp_year: 2025, + }; + + stripeMock.customers.listPaymentMethods = jest + .fn() + .mockResolvedValue({ data: mockPaymentMethods }); + stripeMock.customers.retrieve = jest.fn().mockResolvedValue(mockCustomer); + + const result = await service.listPaymentMethods('cus_123'); + + expect(result).toEqual([ + { + id: 'pm_1', + brand: 'visa', + last4: '4242', + expMonth: 12, + expYear: 2024, + default: false, + }, + { + id: 'pm_2', + brand: 'mastercard', + last4: '5555', + expMonth: 6, + expYear: 2025, + default: false, + }, + ]); + expect(stripeMock.customers.listPaymentMethods).toHaveBeenCalledWith( + 'cus_123', + { type: 'card', limit: 100 }, + ); + }); + + it('should return empty array when no payment methods', async () => { + const mockCustomer = createMockCustomer(); + + stripeMock.customers.listPaymentMethods = jest + .fn() + .mockResolvedValue({ data: [] }); + + stripeMock.customers.retrieve = jest.fn().mockResolvedValue(mockCustomer); + + const result = await service.listPaymentMethods('cus_123'); + + expect(result).toEqual([]); + }); + + it('should handle payment methods without card details', async () => { + const mockCustomer = createMockCustomer(); + + const mockPaymentMethods = [createMockPaymentMethod()]; + (mockPaymentMethods[0] as any).card = null; + + stripeMock.customers.listPaymentMethods = jest + .fn() + .mockResolvedValue({ data: mockPaymentMethods }); + stripeMock.customers.retrieve = jest.fn().mockResolvedValue(mockCustomer); + + const result = await service.listPaymentMethods('cus_123'); + + expect(result[0]).toEqual({ + id: mockPaymentMethods[0].id, + brand: undefined, + last4: undefined, + expMonth: undefined, + expYear: undefined, + default: false, + }); + }); + }); + + describe('retrieveCardSetup', () => { + it('should retrieve card setup successfully', async () => { + const mockSetupIntent = createMockSetupIntent(); + + stripeMock.setupIntents.retrieve = jest + .fn() + .mockResolvedValue(mockSetupIntent); + + const result = await service.retrieveCardSetup('seti_123'); + + expect(result).toEqual({ + customerId: mockSetupIntent.customer, + paymentMethod: mockSetupIntent.payment_method, + }); + expect(stripeMock.setupIntents.retrieve).toHaveBeenCalledWith('seti_123'); + }); + + it('should handle setup intent without customer', async () => { + const mockSetupIntent = createMockSetupIntent(); + (mockSetupIntent as any).customer = null; + + stripeMock.setupIntents.retrieve = jest + .fn() + .mockResolvedValue(mockSetupIntent); + + const result = await service.retrieveCardSetup('seti_123'); + + expect(result).toEqual({ + customerId: null, + paymentMethod: mockSetupIntent.payment_method, + }); + }); + + it('should handle setup intent without payment method', async () => { + const mockSetupIntent = createMockSetupIntent(); + (mockSetupIntent as any).payment_method = null; + + stripeMock.setupIntents.retrieve = jest + .fn() + .mockResolvedValue(mockSetupIntent); + + const result = await service.retrieveCardSetup('seti_123'); + + expect(result).toEqual({ + customerId: mockSetupIntent.customer, + paymentMethod: null, + }); + }); + }); + + describe('updateCustomer', () => { + it('should update customer successfully', async () => { + const mockCustomer = createMockCustomer(); stripeMock.customers.update = jest.fn().mockResolvedValue(mockCustomer); const updateData = { @@ -314,14 +539,16 @@ describe('StripeService', () => { const result = await service.updateCustomer('cus_123', updateData); + const { line1, city, country, postal_code } = mockCustomer.address; + expect(result).toEqual({ email: mockCustomer.email, name: mockCustomer.name, address: { - line1: '123 Street', - city: 'City', - country: 'US', - postalCode: '12345', + line1, + city, + country, + postalCode: postal_code, }, defaultPaymentMethod: mockCustomer.invoice_settings.default_payment_method, @@ -336,15 +563,7 @@ describe('StripeService', () => { describe('retrievePaymentMethod', () => { it('should retrieve payment method successfully', async () => { - const mockPaymentMethod = { - id: 'pm_123', - card: { - brand: 'visa', - last4: '4242', - exp_month: 12, - exp_year: 2024, - }, - }; + const mockPaymentMethod = createMockPaymentMethod(); stripeMock.paymentMethods.retrieve = jest .fn() @@ -353,11 +572,11 @@ describe('StripeService', () => { const result = await service.retrievePaymentMethod('pm_123'); expect(result).toEqual({ - id: 'pm_123', - brand: 'visa', - last4: '4242', - expMonth: 12, - expYear: 2024, + id: mockPaymentMethod.id, + brand: mockPaymentMethod.card.brand, + last4: mockPaymentMethod.card.last4, + expMonth: mockPaymentMethod.card.exp_month, + expYear: mockPaymentMethod.card.exp_year, default: false, }); expect(stripeMock.paymentMethods.retrieve).toHaveBeenCalledWith('pm_123'); @@ -366,15 +585,7 @@ describe('StripeService', () => { describe('detachPaymentMethod', () => { it('should detach payment method successfully', async () => { - const mockPaymentMethod = { - id: 'pm_123', - card: { - brand: 'visa', - last4: '4242', - exp_month: 12, - exp_year: 2024, - }, - }; + const mockPaymentMethod = createMockPaymentMethod(); stripeMock.paymentMethods.detach = jest .fn() @@ -383,11 +594,11 @@ describe('StripeService', () => { const result = await service.detachPaymentMethod('pm_123'); expect(result).toEqual({ - id: 'pm_123', - brand: 'visa', - last4: '4242', - expMonth: 12, - expYear: 2024, + id: mockPaymentMethod.id, + brand: mockPaymentMethod.card.brand, + last4: mockPaymentMethod.card.last4, + expMonth: mockPaymentMethod.card.exp_month, + expYear: mockPaymentMethod.card.exp_year, default: false, }); expect(stripeMock.paymentMethods.detach).toHaveBeenCalledWith('pm_123'); @@ -396,11 +607,10 @@ describe('StripeService', () => { describe('getReceiptUrl', () => { it('should return receipt URL for valid payment', async () => { - const mockPaymentIntent = { - id: 'pi_123', + const mockPaymentIntent = createMockPaymentIntent({ customer: 'cus_123', latest_charge: 'ch_123', - }; + }); const mockCharge = { receipt_url: 'https://receipt.example.com', }; @@ -426,11 +636,10 @@ describe('StripeService', () => { }); it('should throw NotFoundError when customer ID does not match', async () => { - const mockPaymentIntent = { - id: 'pi_123', + const mockPaymentIntent = createMockPaymentIntent({ customer: 'cus_456', latest_charge: 'ch_123', - }; + }); stripeMock.paymentIntents.retrieve = jest .fn() @@ -442,11 +651,10 @@ describe('StripeService', () => { }); it('should throw NotFoundError when receipt URL is missing', async () => { - const mockPaymentIntent = { - id: 'pi_123', + const mockPaymentIntent = createMockPaymentIntent({ customer: 'cus_123', latest_charge: 'ch_123', - }; + }); const mockCharge = { receipt_url: null, }; @@ -469,18 +677,7 @@ describe('StripeService', () => { }); it('should return complete billing info when all data is available', async () => { - const mockCustomer = { - id: 'cus_123', - name: 'John Doe', - email: 'john@example.com', - address: { - line1: '123 Main St', - city: 'New York', - country: 'US', - postal_code: '10001', - }, - }; - + const mockCustomer = createMockCustomer(); const mockTaxIds = [ { id: 'txi_123', @@ -497,13 +694,13 @@ describe('StripeService', () => { const result = await service.retrieveBillingInfo('cus_123'); expect(result).toEqual({ - name: 'John Doe', - email: 'john@example.com', + name: mockCustomer.name, + email: mockCustomer.email, address: { - line: '123 Main St', - city: 'New York', - country: 'us', - postalCode: '10001', + line: mockCustomer.address.line1, + city: mockCustomer.address.city, + country: mockCustomer.address.country.toLowerCase(), + postalCode: mockCustomer.address.postal_code, }, vat: 'DE123456789', vatType: VatType.EU_VAT, @@ -511,11 +708,9 @@ describe('StripeService', () => { }); it('should return partial billing info when some data is missing', async () => { - const mockCustomer = { - id: 'cus_123', - name: 'John Doe', - email: 'john@example.com', - }; + const mockCustomer = createMockCustomer({ + address: undefined, + }); stripeMock.customers.retrieve = jest.fn().mockResolvedValue(mockCustomer); stripeMock.customers.listTaxIds = jest @@ -525,8 +720,8 @@ describe('StripeService', () => { const result = await service.retrieveBillingInfo('cus_123'); expect(result).toEqual({ - name: 'John Doe', - email: 'john@example.com', + name: mockCustomer.name, + email: mockCustomer.email, address: undefined, vat: undefined, vatType: undefined, @@ -544,17 +739,7 @@ describe('StripeService', () => { }, ]; - const mockUpdatedCustomer = { - id: 'cus_123', - name: 'John Doe', - email: 'john@example.com', - address: { - line1: '123 Main St', - city: 'New York', - country: 'US', - postal_code: '10001', - }, - }; + const mockUpdatedCustomer = createMockCustomer(); stripeMock.customers.listTaxIds = jest .fn() @@ -613,11 +798,7 @@ describe('StripeService', () => { }, ]; - const mockUpdatedCustomer = { - id: 'cus_123', - name: 'John Doe', - email: 'john@example.com', - }; + const mockUpdatedCustomer = createMockCustomer(); stripeMock.customers.listTaxIds = jest .fn() From 44d7635faee9f9aa87e0a316ebc5cf833d39b973 Mon Sep 17 00:00:00 2001 From: Nikolai Muhhin Date: Thu, 19 Jun 2025 23:22:13 +0300 Subject: [PATCH 14/16] Simplify fetching default payment method. Adjust tests --- .gitignore | 3 +- .../modules/payment/payment.service.spec.ts | 1 + .../src/modules/payment/payment.service.ts | 3 +- .../{ => providers/stripe}/fixtures.ts | 81 +--- .../providers/stripe/stripe.service.spec.ts | 456 ++++++++++-------- .../providers/stripe/stripe.service.ts | 8 +- 6 files changed, 279 insertions(+), 273 deletions(-) rename packages/apps/job-launcher/server/src/modules/payment/{ => providers/stripe}/fixtures.ts (59%) diff --git a/.gitignore b/.gitignore index 5983f9ddf9..d9ac258354 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ yarn-error.log* # IDE - IntelliJ .idea/ +*.iml # OS .DS_Store @@ -49,4 +50,4 @@ hardhat-dependency-compiler # cache cache -*.iml + diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts index bcca66a4ac..3d579be95f 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts @@ -1030,6 +1030,7 @@ describe('PaymentService', () => { paymentProvider.retrievePaymentMethod.mockResolvedValue({ id: 'pm_123', + default: true, } as any); paymentProvider.getDefaultPaymentMethod.mockResolvedValue('pm_123'); jest diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts index 20d6c00faa..0ceb213b48 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts @@ -453,8 +453,7 @@ export class PaymentService { // Check if the payment method is the default one and in use for the user if ( user.paymentProviderId && - paymentMethod.id === - (await this.getDefaultPaymentMethod(user.paymentProviderId)) && + paymentMethod.default && (await this.isPaymentMethodInUse(user.id)) ) { throw new ConflictError(ErrorPayment.PaymentMethodInUse); diff --git a/packages/apps/job-launcher/server/src/modules/payment/fixtures.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/fixtures.ts similarity index 59% rename from packages/apps/job-launcher/server/src/modules/payment/fixtures.ts rename to packages/apps/job-launcher/server/src/modules/payment/providers/stripe/fixtures.ts index 3d7fa1fc3a..d53a6e035e 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/fixtures.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/fixtures.ts @@ -1,19 +1,10 @@ import { faker } from '@faker-js/faker'; import { PaymentCurrency, - PaymentStatus, StripePaymentStatus, VatType, -} from '../../common/enums/payment'; -import { - CardSetup, - CustomerData, - Invoice, - PaymentData, - PaymentMethod, - TaxId, -} from './payment.interface'; -import { AddressDto, BillingInfoDto } from './payment.dto'; +} from '../../../../common/enums/payment'; +import { AddressDto, BillingInfoDto } from '../../payment.dto'; export const createMockSetupIntent = () => ({ id: faker.string.alphanumeric(24), @@ -95,74 +86,6 @@ export const createMockTaxId = (overrides: Partial = {}) => ({ ...overrides, }); -// Fixtures for our internal interfaces -export const createMockPaymentData = ( - overrides: Partial = {}, -): PaymentData => ({ - id: faker.string.alphanumeric(24), - clientSecret: faker.string.alphanumeric(32), - status: PaymentStatus.FAILED, - amount: faker.number.int({ min: 1000, max: 100000 }), - amountReceived: 0, - currency: PaymentCurrency.USD, - customer: faker.string.alphanumeric(24), - latestCharge: faker.string.alphanumeric(24), - ...overrides, -}); - -export const createMockPaymentMethodData = ( - overrides: Partial = {}, -): PaymentMethod => ({ - id: faker.string.alphanumeric(24), - brand: faker.helpers.arrayElement(['visa', 'mastercard', 'amex']), - last4: faker.string.numeric(4), - expMonth: faker.number.int({ min: 1, max: 12 }), - expYear: faker.number.int({ min: 2024, max: 2030 }), - default: false, - ...overrides, -}); - -export const createMockCardSetup = ( - overrides: Partial = {}, -): CardSetup => ({ - customerId: faker.string.alphanumeric(24), - paymentMethod: faker.string.alphanumeric(24), - ...overrides, -}); - -export const createMockInvoiceData = ( - overrides: Partial = {}, -): Invoice => ({ - id: faker.string.alphanumeric(24), - paymentId: faker.string.alphanumeric(24), - status: 'draft', - amountDue: faker.number.int({ min: 1000, max: 100000 }), - currency: PaymentCurrency.USD, - ...overrides, -}); - -export const createMockCustomerData = ( - overrides: Partial = {}, -): CustomerData => ({ - email: faker.internet.email(), - name: faker.person.fullName(), - address: { - line1: faker.location.streetAddress(), - city: faker.location.city(), - country: faker.location.countryCode(), - postalCode: faker.location.zipCode(), - }, - defaultPaymentMethod: faker.string.alphanumeric(24), - ...overrides, -}); - -export const createMockTaxIdData = (overrides: Partial = {}): TaxId => ({ - id: faker.string.alphanumeric(24), - type: faker.helpers.arrayElement(Object.values(VatType)), - value: faker.string.alphanumeric(10), - ...overrides, -}); - export const createMockBillingInfoDto = ( overrides: Partial = {}, ): BillingInfoDto => { diff --git a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts index caa1e64087..bb85c6b307 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts @@ -1,5 +1,6 @@ jest.mock('stripe'); +import { faker } from '@faker-js/faker'; import { PaymentData } from '../../payment.interface'; import { Test, TestingModule } from '@nestjs/testing'; import { Logger } from '@nestjs/common'; @@ -15,12 +16,15 @@ import { VatType, } from '../../../../common/enums/payment'; import { - createMockPaymentIntent, + createMockBillingInfoDto, + createMockCharge, createMockCustomer, + createMockInvoice, + createMockPaymentIntent, createMockPaymentMethod, createMockSetupIntent, - createMockInvoice, -} from '../../fixtures'; + createMockTaxId, +} from './fixtures'; describe('StripeService', () => { let service: StripeService; @@ -35,7 +39,7 @@ describe('StripeService', () => { appInfoURL: 'https://test.com', }; - beforeEach(async () => { + beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ StripeService, @@ -94,14 +98,17 @@ describe('StripeService', () => { describe('createCustomer', () => { it('should create a customer successfully', async () => { - const mockCustomer = { id: 'cus_123' }; - stripeMock.customers.create = jest.fn().mockResolvedValue(mockCustomer); + const mockCustomer = { id: faker.string.uuid() }; + stripeMock.customers.create = jest + .fn() + .mockResolvedValueOnce(mockCustomer); - const result = await service.createCustomer('test@example.com'); + const email = faker.internet.email(); + const result = await service.createCustomer(email); - expect(result).toBe('cus_123'); + expect(result).toBe(mockCustomer.id); expect(stripeMock.customers.create).toHaveBeenCalledWith({ - email: 'test@example.com', + email, }); }); @@ -110,7 +117,9 @@ describe('StripeService', () => { .fn() .mockRejectedValue(new Error('Stripe error')); - await expect(service.createCustomer('test@example.com')).rejects.toThrow( + const email = faker.internet.email(); + + await expect(service.createCustomer(email)).rejects.toThrow( new ServerError(ErrorPayment.CustomerNotCreated), ); expect(loggerSpy).toHaveBeenCalled(); @@ -123,21 +132,22 @@ describe('StripeService', () => { it('should create setup intent successfully', async () => { stripeMock.setupIntents.create = jest .fn() - .mockResolvedValue(mockSetupIntent); + .mockResolvedValueOnce(mockSetupIntent); - const result = await service.setupCard('cus_123'); + const customerId = faker.string.uuid(); + const result = await service.setupCard(customerId); expect(result).toBe(mockSetupIntent.client_secret); expect(stripeMock.setupIntents.create).toHaveBeenCalledWith({ automatic_payment_methods: { enabled: true }, - customer: 'cus_123', + customer: customerId, }); }); it('should handle null customerId', async () => { stripeMock.setupIntents.create = jest .fn() - .mockResolvedValue(mockSetupIntent); + .mockResolvedValueOnce(mockSetupIntent); await service.setupCard(null); @@ -148,9 +158,11 @@ describe('StripeService', () => { }); it('should handle missing client secret', async () => { - stripeMock.setupIntents.create = jest.fn().mockResolvedValue({}); + stripeMock.setupIntents.create = jest.fn().mockResolvedValueOnce({}); - await expect(service.setupCard('cus_123')).rejects.toThrow( + const customerId = faker.string.uuid(); + + await expect(service.setupCard(customerId)).rejects.toThrow( new ServerError(ErrorPayment.ClientSecretDoesNotExist), ); }); @@ -162,21 +174,27 @@ describe('StripeService', () => { it('should assign off-session payment method', async () => { stripeMock.paymentIntents.confirm = jest .fn() - .mockResolvedValue(mockPaymentIntent); + .mockResolvedValueOnce(mockPaymentIntent); stripeMock.paymentIntents.retrieve = jest .fn() - .mockResolvedValue(mockPaymentIntent); + .mockResolvedValueOnce(mockPaymentIntent); + + const paymentIntentId = faker.string.uuid(); + const paymentMethodId = faker.string.uuid(); const result = await service.assignPaymentMethod( - 'pi_123', - 'pm_123', + paymentIntentId, + paymentMethodId, true, ); - expect(stripeMock.paymentIntents.confirm).toHaveBeenCalledWith('pi_123', { - payment_method: 'pm_123', - off_session: true, - }); + expect(stripeMock.paymentIntents.confirm).toHaveBeenCalledWith( + paymentIntentId, + { + payment_method: paymentMethodId, + off_session: true, + }, + ); expect(result).toEqual({ id: mockPaymentIntent.id, clientSecret: mockPaymentIntent.client_secret, @@ -192,20 +210,26 @@ describe('StripeService', () => { it('should assign on-session payment method', async () => { stripeMock.paymentIntents.update = jest .fn() - .mockResolvedValue(mockPaymentIntent); + .mockResolvedValueOnce(mockPaymentIntent); stripeMock.paymentIntents.retrieve = jest .fn() - .mockResolvedValue(mockPaymentIntent); + .mockResolvedValueOnce(mockPaymentIntent); + + const paymentIntentId = faker.string.uuid(); + const paymentMethodId = faker.string.uuid(); const result = await service.assignPaymentMethod( - 'pi_123', - 'pm_123', + paymentIntentId, + paymentMethodId, false, ); - expect(stripeMock.paymentIntents.update).toHaveBeenCalledWith('pi_123', { - payment_method: 'pm_123', - }); + expect(stripeMock.paymentIntents.update).toHaveBeenCalledWith( + paymentIntentId, + { + payment_method: paymentMethodId, + }, + ); expect(result).toEqual({ id: mockPaymentIntent.id, clientSecret: mockPaymentIntent.client_secret, @@ -223,16 +247,18 @@ describe('StripeService', () => { const mockInvoice = createMockInvoice(); it('should create invoice successfully', async () => { + const customerId = faker.string.uuid(); + stripeMock.invoices.create = jest .fn() - .mockResolvedValue({ id: 'inv_123' }); - stripeMock.invoiceItems.create = jest.fn().mockResolvedValue({}); + .mockResolvedValueOnce({ id: customerId }); + stripeMock.invoiceItems.create = jest.fn().mockResolvedValueOnce({}); stripeMock.invoices.finalizeInvoice = jest .fn() - .mockResolvedValue(mockInvoice); + .mockResolvedValueOnce(mockInvoice); const result = await service.createInvoice( - 'cus_123', + customerId, 1000, PaymentCurrency.USD, 'Test invoice', @@ -254,17 +280,20 @@ describe('StripeService', () => { }); it('should throw error when payment intent is missing', async () => { + const customerId = faker.string.uuid(); + const invoiceId = faker.string.uuid(); + stripeMock.invoices.create = jest .fn() - .mockResolvedValue({ id: 'inv_123' }); - stripeMock.invoiceItems.create = jest.fn().mockResolvedValue({}); + .mockResolvedValueOnce({ id: invoiceId }); + stripeMock.invoiceItems.create = jest.fn().mockResolvedValueOnce({}); stripeMock.invoices.finalizeInvoice = jest .fn() - .mockResolvedValue({ id: 'inv_123' }); + .mockResolvedValueOnce({ id: invoiceId }); await expect( service.createInvoice( - 'cus_123', + customerId, 1000, PaymentCurrency.USD, 'Test invoice', @@ -282,9 +311,9 @@ describe('StripeService', () => { stripeMock.paymentIntents.retrieve = jest .fn() - .mockResolvedValue(mockPaymentIntent); + .mockResolvedValueOnce(mockPaymentIntent); - const result = await service.retrievePaymentIntent('pi_123'); + const result = await service.retrievePaymentIntent(mockPaymentIntent.id); expect(result).toEqual({ id: mockPaymentIntent.id, @@ -296,7 +325,9 @@ describe('StripeService', () => { customer: mockPaymentIntent.customer, latestCharge: mockPaymentIntent.latest_charge, } as PaymentData); - expect(stripeMock.paymentIntents.retrieve).toHaveBeenCalledWith('pi_123'); + expect(stripeMock.paymentIntents.retrieve).toHaveBeenCalledWith( + mockPaymentIntent.id, + ); }); it('should handle different payment statuses', async () => { @@ -319,9 +350,11 @@ describe('StripeService', () => { const mockPaymentIntent = createMockPaymentIntent({ status: stripe }); stripeMock.paymentIntents.retrieve = jest .fn() - .mockResolvedValue(mockPaymentIntent); + .mockResolvedValueOnce(mockPaymentIntent); - const result = await service.retrievePaymentIntent('pi_123'); + const result = await service.retrievePaymentIntent( + mockPaymentIntent.id, + ); expect(result.status).toBe(expected); } @@ -333,9 +366,9 @@ describe('StripeService', () => { }); stripeMock.paymentIntents.retrieve = jest .fn() - .mockResolvedValue(mockPaymentIntent); + .mockResolvedValueOnce(mockPaymentIntent); - const result = await service.retrievePaymentIntent('pi_123'); + const result = await service.retrievePaymentIntent(mockPaymentIntent.id); expect(result.clientSecret).toBeNull(); }); @@ -344,16 +377,22 @@ describe('StripeService', () => { describe('getDefaultPaymentMethod', () => { it('should return default payment method ID when available', async () => { const mockCustomer = createMockCustomer(); + const defaultPaymentMethod = faker.string.alphanumeric(); + (mockCustomer as any).invoice_settings = { - default_payment_method: 'pm_default_123', + default_payment_method: defaultPaymentMethod, }; - stripeMock.customers.retrieve = jest.fn().mockResolvedValue(mockCustomer); + stripeMock.customers.retrieve = jest + .fn() + .mockResolvedValueOnce(mockCustomer); - const result = await service.getDefaultPaymentMethod('cus_123'); + const result = await service.getDefaultPaymentMethod(mockCustomer.id); - expect(result).toBe('pm_default_123'); - expect(stripeMock.customers.retrieve).toHaveBeenCalledWith('cus_123'); + expect(result).toBe(defaultPaymentMethod); + expect(stripeMock.customers.retrieve).toHaveBeenCalledWith( + mockCustomer.id, + ); }); it('should return null when no default payment method', async () => { @@ -362,9 +401,11 @@ describe('StripeService', () => { default_payment_method: null, }; - stripeMock.customers.retrieve = jest.fn().mockResolvedValue(mockCustomer); + stripeMock.customers.retrieve = jest + .fn() + .mockResolvedValueOnce(mockCustomer); - const result = await service.getDefaultPaymentMethod('cus_123'); + const result = await service.getDefaultPaymentMethod(mockCustomer.id); expect(result).toBeNull(); }); @@ -373,9 +414,11 @@ describe('StripeService', () => { const mockCustomer = createMockCustomer(); (mockCustomer as any).invoice_settings = undefined; - stripeMock.customers.retrieve = jest.fn().mockResolvedValue(mockCustomer); + stripeMock.customers.retrieve = jest + .fn() + .mockResolvedValueOnce(mockCustomer); - const result = await service.getDefaultPaymentMethod('cus_123'); + const result = await service.getDefaultPaymentMethod(mockCustomer.id); expect(result).toBeNull(); }); @@ -407,10 +450,12 @@ describe('StripeService', () => { stripeMock.customers.listPaymentMethods = jest .fn() - .mockResolvedValue({ data: mockPaymentMethods }); - stripeMock.customers.retrieve = jest.fn().mockResolvedValue(mockCustomer); + .mockResolvedValueOnce({ data: mockPaymentMethods }); + stripeMock.customers.retrieve = jest + .fn() + .mockResolvedValueOnce(mockCustomer); - const result = await service.listPaymentMethods('cus_123'); + const result = await service.listPaymentMethods(mockCustomer.id); expect(result).toEqual([ { @@ -431,7 +476,7 @@ describe('StripeService', () => { }, ]); expect(stripeMock.customers.listPaymentMethods).toHaveBeenCalledWith( - 'cus_123', + mockCustomer.id, { type: 'card', limit: 100 }, ); }); @@ -441,11 +486,13 @@ describe('StripeService', () => { stripeMock.customers.listPaymentMethods = jest .fn() - .mockResolvedValue({ data: [] }); + .mockResolvedValueOnce({ data: [] }); - stripeMock.customers.retrieve = jest.fn().mockResolvedValue(mockCustomer); + stripeMock.customers.retrieve = jest + .fn() + .mockResolvedValueOnce(mockCustomer); - const result = await service.listPaymentMethods('cus_123'); + const result = await service.listPaymentMethods(mockCustomer.id); expect(result).toEqual([]); }); @@ -458,10 +505,12 @@ describe('StripeService', () => { stripeMock.customers.listPaymentMethods = jest .fn() - .mockResolvedValue({ data: mockPaymentMethods }); - stripeMock.customers.retrieve = jest.fn().mockResolvedValue(mockCustomer); + .mockResolvedValueOnce({ data: mockPaymentMethods }); + stripeMock.customers.retrieve = jest + .fn() + .mockResolvedValueOnce(mockCustomer); - const result = await service.listPaymentMethods('cus_123'); + const result = await service.listPaymentMethods(mockCustomer.id); expect(result[0]).toEqual({ id: mockPaymentMethods[0].id, @@ -480,15 +529,17 @@ describe('StripeService', () => { stripeMock.setupIntents.retrieve = jest .fn() - .mockResolvedValue(mockSetupIntent); + .mockResolvedValueOnce(mockSetupIntent); - const result = await service.retrieveCardSetup('seti_123'); + const result = await service.retrieveCardSetup(mockSetupIntent.id); expect(result).toEqual({ customerId: mockSetupIntent.customer, paymentMethod: mockSetupIntent.payment_method, }); - expect(stripeMock.setupIntents.retrieve).toHaveBeenCalledWith('seti_123'); + expect(stripeMock.setupIntents.retrieve).toHaveBeenCalledWith( + mockSetupIntent.id, + ); }); it('should handle setup intent without customer', async () => { @@ -497,9 +548,9 @@ describe('StripeService', () => { stripeMock.setupIntents.retrieve = jest .fn() - .mockResolvedValue(mockSetupIntent); + .mockResolvedValueOnce(mockSetupIntent); - const result = await service.retrieveCardSetup('seti_123'); + const result = await service.retrieveCardSetup(mockSetupIntent.id); expect(result).toEqual({ customerId: null, @@ -513,9 +564,9 @@ describe('StripeService', () => { stripeMock.setupIntents.retrieve = jest .fn() - .mockResolvedValue(mockSetupIntent); + .mockResolvedValueOnce(mockSetupIntent); - const result = await service.retrieveCardSetup('seti_123'); + const result = await service.retrieveCardSetup(mockSetupIntent.id); expect(result).toEqual({ customerId: mockSetupIntent.customer, @@ -527,7 +578,9 @@ describe('StripeService', () => { describe('updateCustomer', () => { it('should update customer successfully', async () => { const mockCustomer = createMockCustomer(); - stripeMock.customers.update = jest.fn().mockResolvedValue(mockCustomer); + stripeMock.customers.update = jest + .fn() + .mockResolvedValueOnce(mockCustomer); const updateData = { name: 'Updated Name', @@ -537,7 +590,7 @@ describe('StripeService', () => { }, }; - const result = await service.updateCustomer('cus_123', updateData); + const result = await service.updateCustomer(mockCustomer.id, updateData); const { line1, city, country, postal_code } = mockCustomer.address; @@ -555,7 +608,7 @@ describe('StripeService', () => { }); expect(stripeMock.customers.update).toHaveBeenCalledWith( - 'cus_123', + mockCustomer.id, updateData, ); }); @@ -563,13 +616,17 @@ describe('StripeService', () => { describe('retrievePaymentMethod', () => { it('should retrieve payment method successfully', async () => { + const mockCustomer = createMockCustomer(); const mockPaymentMethod = createMockPaymentMethod(); + stripeMock.customers.retrieve = jest + .fn() + .mockResolvedValueOnce(mockCustomer); stripeMock.paymentMethods.retrieve = jest .fn() - .mockResolvedValue(mockPaymentMethod); + .mockResolvedValueOnce(mockPaymentMethod); - const result = await service.retrievePaymentMethod('pm_123'); + const result = await service.retrievePaymentMethod(mockPaymentMethod.id); expect(result).toEqual({ id: mockPaymentMethod.id, @@ -579,7 +636,9 @@ describe('StripeService', () => { expYear: mockPaymentMethod.card.exp_year, default: false, }); - expect(stripeMock.paymentMethods.retrieve).toHaveBeenCalledWith('pm_123'); + expect(stripeMock.paymentMethods.retrieve).toHaveBeenCalledWith( + mockPaymentMethod.id, + ); }); }); @@ -589,9 +648,9 @@ describe('StripeService', () => { stripeMock.paymentMethods.detach = jest .fn() - .mockResolvedValue(mockPaymentMethod); + .mockResolvedValueOnce(mockPaymentMethod); - const result = await service.detachPaymentMethod('pm_123'); + const result = await service.detachPaymentMethod(mockPaymentMethod.id); expect(result).toEqual({ id: mockPaymentMethod.id, @@ -601,72 +660,94 @@ describe('StripeService', () => { expYear: mockPaymentMethod.card.exp_year, default: false, }); - expect(stripeMock.paymentMethods.detach).toHaveBeenCalledWith('pm_123'); + expect(stripeMock.paymentMethods.detach).toHaveBeenCalledWith( + mockPaymentMethod.id, + ); }); }); describe('getReceiptUrl', () => { it('should return receipt URL for valid payment', async () => { + const customerId = faker.string.uuid(); + const mockPaymentIntent = createMockPaymentIntent({ - customer: 'cus_123', + customer: customerId, latest_charge: 'ch_123', }); const mockCharge = { - receipt_url: 'https://receipt.example.com', + receipt_url: faker.internet.email(), }; stripeMock.paymentIntents.retrieve = jest .fn() - .mockResolvedValue(mockPaymentIntent); - stripeMock.charges.retrieve = jest.fn().mockResolvedValue(mockCharge); + .mockResolvedValueOnce(mockPaymentIntent); + stripeMock.charges.retrieve = jest.fn().mockResolvedValueOnce(mockCharge); - const result = await service.getReceiptUrl('pi_123', 'cus_123'); + const result = await service.getReceiptUrl( + mockPaymentIntent.id, + customerId, + ); - expect(result).toBe('https://receipt.example.com'); - expect(stripeMock.paymentIntents.retrieve).toHaveBeenCalledWith('pi_123'); - expect(stripeMock.charges.retrieve).toHaveBeenCalledWith('ch_123'); + expect(result).toBe(mockCharge.receipt_url); + expect(stripeMock.paymentIntents.retrieve).toHaveBeenCalledWith( + mockPaymentIntent.id, + ); + expect(stripeMock.charges.retrieve).toHaveBeenCalledWith( + mockPaymentIntent.latest_charge, + ); }); it('should throw NotFoundError when payment intent not found', async () => { - stripeMock.paymentIntents.retrieve = jest.fn().mockResolvedValue(null); + stripeMock.paymentIntents.retrieve = jest + .fn() + .mockResolvedValueOnce(null); - await expect(service.getReceiptUrl('pi_123', 'cus_123')).rejects.toThrow( - new NotFoundError(ErrorPayment.NotFound), - ); + await expect( + service.getReceiptUrl(faker.string.uuid(), faker.string.uuid()), + ).rejects.toThrow(new NotFoundError(ErrorPayment.NotFound)); }); it('should throw NotFoundError when customer ID does not match', async () => { + const customerId = faker.string.uuid(); + const mockPaymentIntent = createMockPaymentIntent({ - customer: 'cus_456', - latest_charge: 'ch_123', + latest_charge: faker.string.uuid(), }); + const mockCharge = { + receipt_url: null, + }; + + stripeMock.charges.retrieve = jest.fn().mockResolvedValueOnce(mockCharge); + stripeMock.paymentIntents.retrieve = jest .fn() - .mockResolvedValue(mockPaymentIntent); + .mockResolvedValueOnce(mockPaymentIntent); - await expect(service.getReceiptUrl('pi_123', 'cus_123')).rejects.toThrow( - new NotFoundError(ErrorPayment.NotFound), - ); + await expect( + service.getReceiptUrl(mockPaymentIntent.id, customerId), + ).rejects.toThrow(new NotFoundError(ErrorPayment.NotFound)); }); it('should throw NotFoundError when receipt URL is missing', async () => { + const customerId = faker.string.uuid(); + const mockPaymentIntent = createMockPaymentIntent({ - customer: 'cus_123', - latest_charge: 'ch_123', + customer: customerId, + latest_charge: faker.string.uuid(), }); - const mockCharge = { + const mockCharge = createMockCharge({ receipt_url: null, - }; + }); stripeMock.paymentIntents.retrieve = jest .fn() - .mockResolvedValue(mockPaymentIntent); - stripeMock.charges.retrieve = jest.fn().mockResolvedValue(mockCharge); + .mockResolvedValueOnce(mockPaymentIntent); + stripeMock.charges.retrieve = jest.fn().mockResolvedValueOnce(mockCharge); - await expect(service.getReceiptUrl('pi_123', 'cus_123')).rejects.toThrow( - new NotFoundError(ErrorPayment.NotFound), - ); + await expect( + service.getReceiptUrl(mockPaymentIntent.id, customerId), + ).rejects.toThrow(new NotFoundError(ErrorPayment.NotFound)); }); }); @@ -686,12 +767,14 @@ describe('StripeService', () => { }, ]; - stripeMock.customers.retrieve = jest.fn().mockResolvedValue(mockCustomer); + stripeMock.customers.retrieve = jest + .fn() + .mockResolvedValueOnce(mockCustomer); stripeMock.customers.listTaxIds = jest .fn() - .mockResolvedValue({ data: mockTaxIds }); + .mockResolvedValueOnce({ data: mockTaxIds }); - const result = await service.retrieveBillingInfo('cus_123'); + const result = await service.retrieveBillingInfo(mockCustomer.id); expect(result).toEqual({ name: mockCustomer.name, @@ -712,12 +795,14 @@ describe('StripeService', () => { address: undefined, }); - stripeMock.customers.retrieve = jest.fn().mockResolvedValue(mockCustomer); + stripeMock.customers.retrieve = jest + .fn() + .mockResolvedValueOnce(mockCustomer); stripeMock.customers.listTaxIds = jest .fn() - .mockResolvedValue({ data: [] }); + .mockResolvedValueOnce({ data: [] }); - const result = await service.retrieveBillingInfo('cus_123'); + const result = await service.retrieveBillingInfo(mockCustomer.id); expect(result).toEqual({ name: mockCustomer.name, @@ -731,111 +816,104 @@ describe('StripeService', () => { describe('updateBillingInfo', () => { it('should update all billing information', async () => { - const mockExistingTaxIds = [ - { - id: 'txi_123', - type: VatType.EU_VAT, - value: 'DE123456789', - }, - ]; + const mockTaxId = createMockTaxId(); + + const mockExistingTaxIds = [mockTaxId]; const mockUpdatedCustomer = createMockCustomer(); stripeMock.customers.listTaxIds = jest .fn() - .mockResolvedValue({ data: mockExistingTaxIds }); - stripeMock.customers.deleteTaxId = jest.fn().mockResolvedValue({}); - stripeMock.customers.createTaxId = jest.fn().mockResolvedValue({ - id: 'txi_456', - type: VatType.EU_VAT, - value: 'DE987654321', - }); + .mockResolvedValueOnce({ data: mockExistingTaxIds }); + stripeMock.customers.deleteTaxId = jest.fn().mockResolvedValueOnce({}); + stripeMock.customers.createTaxId = jest + .fn() + .mockResolvedValueOnce(mockTaxId); stripeMock.customers.update = jest .fn() - .mockResolvedValue(mockUpdatedCustomer); + .mockResolvedValueOnce(mockUpdatedCustomer); - const updateData = { - name: 'John Doe', - email: 'john@example.com', - address: { - line: '123 Main St', - city: 'New York', - country: 'us', - postalCode: '10001', - }, - vat: 'DE987654321', - vatType: VatType.EU_VAT, - }; + const mockUpdateBillingInfo = createMockBillingInfoDto(); + const { name, email, address } = mockUpdateBillingInfo; + const { city, country, postalCode, line } = address ?? {}; - await service.updateBillingInfo('cus_123', updateData); + await service.updateBillingInfo( + mockUpdatedCustomer.id, + mockUpdateBillingInfo, + ); expect(stripeMock.customers.deleteTaxId).toHaveBeenCalledWith( - 'cus_123', - 'txi_123', + mockUpdatedCustomer.id, + mockExistingTaxIds[0].id, ); - expect(stripeMock.customers.createTaxId).toHaveBeenCalledWith('cus_123', { - type: VatType.EU_VAT, - value: 'DE987654321', - }); - expect(stripeMock.customers.update).toHaveBeenCalledWith('cus_123', { - name: 'John Doe', - email: 'john@example.com', - address: { - line1: '123 Main St', - city: 'New York', - country: 'us', - postal_code: '10001', + expect(stripeMock.customers.createTaxId).toHaveBeenCalledWith( + mockUpdatedCustomer.id, + { + type: mockUpdateBillingInfo.vatType, + value: mockUpdateBillingInfo.vat, }, - }); + ); + expect(stripeMock.customers.update).toHaveBeenCalledWith( + mockUpdatedCustomer.id, + { + name, + email, + address: { + city, + country, + line1: line, + postal_code: postalCode, + }, + }, + ); }); it('should handle update without VAT information', async () => { - const mockExistingTaxIds = [ - { - id: 'txi_123', - type: VatType.EU_VAT, - value: 'DE123456789', - }, - ]; + const mockTaxId = createMockTaxId(); + + const mockExistingTaxIds = [mockTaxId]; const mockUpdatedCustomer = createMockCustomer(); stripeMock.customers.listTaxIds = jest .fn() - .mockResolvedValue({ data: mockExistingTaxIds }); - stripeMock.customers.deleteTaxId = jest.fn().mockResolvedValue({}); + .mockResolvedValueOnce({ data: mockExistingTaxIds }); + stripeMock.customers.deleteTaxId = jest.fn().mockResolvedValueOnce({}); stripeMock.customers.update = jest .fn() - .mockResolvedValue(mockUpdatedCustomer); + .mockResolvedValueOnce(mockUpdatedCustomer); - const updateData = { - name: 'John Doe', - email: 'john@example.com', - address: { - line: '123 Main St', - city: 'New York', - country: 'us', - postalCode: '10001', - }, - }; + const mockUpdateBillingInfo = createMockBillingInfoDto({ + vat: undefined, + vatType: undefined, + }); - await service.updateBillingInfo('cus_123', updateData); + const { name, email, address } = mockUpdateBillingInfo; + const { city, country, postalCode, line } = address ?? {}; + + await service.updateBillingInfo( + mockUpdatedCustomer.id, + mockUpdateBillingInfo, + ); expect(stripeMock.customers.deleteTaxId).toHaveBeenCalledWith( - 'cus_123', - 'txi_123', + mockUpdatedCustomer.id, + mockTaxId.id, ); expect(stripeMock.customers.createTaxId).not.toHaveBeenCalled(); - expect(stripeMock.customers.update).toHaveBeenCalledWith('cus_123', { - name: 'John Doe', - email: 'john@example.com', - address: { - line1: '123 Main St', - city: 'New York', - country: 'us', - postal_code: '10001', + expect(stripeMock.customers.update).toHaveBeenCalledWith( + mockUpdatedCustomer.id, + { + name, + email, + address: { + city, + country, + line1: line, + postal_code: postalCode, + }, }, - }); + ); }); }); }); diff --git a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts index 8e1e5f9177..5bb3efe93d 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts @@ -104,7 +104,7 @@ export class StripeService extends PaymentProvider { status: invoice.status?.toString(), amountDue: invoice.amount_due, currency: invoice.currency, - } as Invoice; + }; } async assignPaymentMethod( @@ -288,13 +288,17 @@ export class StripeService extends PaymentProvider { const paymentMethod = await this.stripe.paymentMethods.retrieve(paymentMethodId); + const defaultPaymentMethod = await this.getDefaultPaymentMethod( + paymentMethod.customer as string, + ); + return { id: paymentMethod.id, brand: paymentMethod.card?.brand as string, last4: paymentMethod.card?.last4 as string, expMonth: paymentMethod.card?.exp_month as number, expYear: paymentMethod.card?.exp_year as number, - default: false, // We don't know if it's default without customer context + default: defaultPaymentMethod === paymentMethod.id, }; } From aa471785ed7bd42b0fee7bf559f207a5eede7ff2 Mon Sep 17 00:00:00 2001 From: Nikolai Muhhin Date: Mon, 23 Jun 2025 12:45:44 +0300 Subject: [PATCH 15/16] Rename stripe configuration service to generic conf service --- .../apps/job-launcher/server/.env.example | 8 +-- .../server/src/common/config/config.module.ts | 6 +- .../server/src/common/config/env-schema.ts | 10 ++-- .../config/payment-provider-config.service.ts | 59 +++++++++++++++++++ .../common/config/stripe-config.service.ts | 50 ---------------- .../server/src/common/enums/payment.ts | 6 -- .../modules/payment/payment.service.spec.ts | 6 +- .../src/modules/payment/payment.service.ts | 2 +- .../providers/payment-provider.abstract.ts | 4 +- .../payment/providers/stripe/fixtures.ts | 7 +-- .../providers/stripe/stripe.service.spec.ts | 7 +-- .../providers/stripe/stripe.service.ts | 25 ++++---- .../webhook/webhook.controller.spec.ts | 12 ++-- .../job-launcher/server/test/constants.ts | 16 ++--- 14 files changed, 110 insertions(+), 108 deletions(-) create mode 100644 packages/apps/job-launcher/server/src/common/config/payment-provider-config.service.ts delete mode 100644 packages/apps/job-launcher/server/src/common/config/stripe-config.service.ts diff --git a/packages/apps/job-launcher/server/.env.example b/packages/apps/job-launcher/server/.env.example index 73585bf6e8..ba500dd4ec 100644 --- a/packages/apps/job-launcher/server/.env.example +++ b/packages/apps/job-launcher/server/.env.example @@ -83,10 +83,10 @@ HCAPTCHA_SITE_KEY=10000000-ffff-ffff-ffff-000000000001 HCAPTCHA_SECRET=0x0000000000000000000000000000000000000000 # Stripe -STRIPE_SECRET_KEY=disabled -STRIPE_APP_NAME=Launcher Server Local -STRIPE_APP_VERSION=1.0.0 -STRIPE_APP_INFO_URL=http://local.app +PAYMENT_PROVIDER_SECRET_KEY=disabled +PAYMENT_PROVIDER_APP_NAME=Launcher Server Local +PAYMENT_PROVIDER_APP_VERSION=1.0.0 +PAYMENT_PROVIDER_APP_INFO_URL=http://local.app # Sendgrid SENDGRID_API_KEY=sendgrid-disabled diff --git a/packages/apps/job-launcher/server/src/common/config/config.module.ts b/packages/apps/job-launcher/server/src/common/config/config.module.ts index 1f39191864..82692b8851 100644 --- a/packages/apps/job-launcher/server/src/common/config/config.module.ts +++ b/packages/apps/job-launcher/server/src/common/config/config.module.ts @@ -9,7 +9,7 @@ import { NetworkConfigService } from './network-config.service'; import { PGPConfigService } from './pgp-config.service'; import { S3ConfigService } from './s3-config.service'; import { SendgridConfigService } from './sendgrid-config.service'; -import { StripeConfigService } from './stripe-config.service'; +import { PaymentProviderConfigService } from './payment-provider-config.service'; import { Web3ConfigService } from './web3-config.service'; import { SlackConfigService } from './slack-config.service'; import { VisionConfigService } from './vision-config.service'; @@ -23,7 +23,7 @@ import { VisionConfigService } from './vision-config.service'; DatabaseConfigService, Web3ConfigService, S3ConfigService, - StripeConfigService, + PaymentProviderConfigService, SendgridConfigService, CvatConfigService, PGPConfigService, @@ -38,7 +38,7 @@ import { VisionConfigService } from './vision-config.service'; DatabaseConfigService, Web3ConfigService, S3ConfigService, - StripeConfigService, + PaymentProviderConfigService, SendgridConfigService, CvatConfigService, PGPConfigService, diff --git a/packages/apps/job-launcher/server/src/common/config/env-schema.ts b/packages/apps/job-launcher/server/src/common/config/env-schema.ts index 6cb4fa35e0..50e853d2dc 100644 --- a/packages/apps/job-launcher/server/src/common/config/env-schema.ts +++ b/packages/apps/job-launcher/server/src/common/config/env-schema.ts @@ -59,11 +59,11 @@ export const envValidator = Joi.object({ S3_BUCKET: Joi.string(), S3_USE_SSL: Joi.string(), // Stripe - STRIPE_SECRET_KEY: Joi.string().required(), - STRIPE_API_VERSION: Joi.string(), - STRIPE_APP_NAME: Joi.string(), - STRIPE_APP_VERSION: Joi.string(), - STRIPE_APP_INFO_URL: Joi.string(), + PAYMENT_PROVIDER_SECRET_KEY: Joi.string().required(), + PAYMENT_PROVIDER_API_VERSION: Joi.string(), + PAYMENT_PROVIDER_APP_NAME: Joi.string(), + PAYMENT_PROVIDER_APP_VERSION: Joi.string(), + PAYMENT_PROVIDER_APP_INFO_URL: Joi.string(), // SendGrid SENDGRID_API_KEY: Joi.string().required(), SENDGRID_FROM_EMAIL: Joi.string(), diff --git a/packages/apps/job-launcher/server/src/common/config/payment-provider-config.service.ts b/packages/apps/job-launcher/server/src/common/config/payment-provider-config.service.ts new file mode 100644 index 0000000000..c28a642d2e --- /dev/null +++ b/packages/apps/job-launcher/server/src/common/config/payment-provider-config.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class PaymentProviderConfigService { + constructor(private configService: ConfigService) {} + + /** + * The secret key used for authenticating requests to the payment providers API. + * Required + */ + get secretKey(): string { + return this.configService.getOrThrow('PAYMENT_PROVIDER_SECRET_KEY'); + } + + /** + * The version of the payment providers to use for requests. + * Default: '2022-11-15' + */ + get apiVersion(): string { + return this.configService.get( + 'PAYMENT_PROVIDER_API_VERSION', + '2022-11-15', + ); + } + + /** + * The name of the application interacting with the payment providers API. + * Default: 'Fortune' + */ + get appName(): string { + return this.configService.get( + 'PAYMENT_PROVIDER_APP_NAME', + 'Fortune', + ); + } + + /** + * The version of the application interacting with the payment providers API. + * Default: '0.0.1' + */ + get appVersion(): string { + return this.configService.get( + 'PAYMENT_PROVIDER_APP_VERSION', + '0.0.1', + ); + } + + /** + * The URL of the application's information page. + * Default: 'https://hmt.ai' + */ + get appInfoURL(): string { + return this.configService.get( + 'PAYMENT_PROVIDER_APP_INFO_URL', + 'https://hmt.ai', + ); + } +} diff --git a/packages/apps/job-launcher/server/src/common/config/stripe-config.service.ts b/packages/apps/job-launcher/server/src/common/config/stripe-config.service.ts deleted file mode 100644 index 513d64c64d..0000000000 --- a/packages/apps/job-launcher/server/src/common/config/stripe-config.service.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -@Injectable() -export class StripeConfigService { - constructor(private configService: ConfigService) {} - - /** - * The secret key used for authenticating requests to the Stripe API. - * Required - */ - get secretKey(): string { - return this.configService.getOrThrow('STRIPE_SECRET_KEY'); - } - - /** - * The version of the Stripe API to use for requests. - * Default: '2022-11-15' - */ - get apiVersion(): string { - return this.configService.get('STRIPE_API_VERSION', '2022-11-15'); - } - - /** - * The name of the application interacting with the Stripe API. - * Default: 'Fortune' - */ - get appName(): string { - return this.configService.get('STRIPE_APP_NAME', 'Fortune'); - } - - /** - * The version of the application interacting with the Stripe API. - * Default: '0.0.1' - */ - get appVersion(): string { - return this.configService.get('STRIPE_APP_VERSION', '0.0.1'); - } - - /** - * The URL of the application's information page. - * Default: 'https://hmt.ai' - */ - get appInfoURL(): string { - return this.configService.get( - 'STRIPE_APP_INFO_URL', - 'https://hmt.ai', - ); - } -} diff --git a/packages/apps/job-launcher/server/src/common/enums/payment.ts b/packages/apps/job-launcher/server/src/common/enums/payment.ts index 2db8a1325e..9277a96b7f 100644 --- a/packages/apps/job-launcher/server/src/common/enums/payment.ts +++ b/packages/apps/job-launcher/server/src/common/enums/payment.ts @@ -36,12 +36,6 @@ export enum PaymentStatus { SUCCEEDED = 'succeeded', } -export enum StripePaymentStatus { - CANCELED = 'canceled', - REQUIRES_PAYMENT_METHOD = 'requires_payment_method', - SUCCEEDED = 'succeeded', -} - export enum PaymentSortField { CREATED_AT = 'created_at', AMOUNT = 'amount', diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts index 3d579be95f..7677b18080 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts @@ -245,7 +245,7 @@ describe('PaymentService', () => { const paymentData = { status: PaymentStatus.SUCCEEDED, amount: 100, - amount_received: 100, + amountReceived: 100, currency: PaymentCurrency.USD, }; @@ -297,7 +297,7 @@ describe('PaymentService', () => { const paymentData = { status: PaymentStatus.FAILED, amount: 100, - amount_received: 0, + amountReceived: 0, currency: PaymentCurrency.USD, }; @@ -327,7 +327,7 @@ describe('PaymentService', () => { const paymentData = { status: 'unknown_status', amount: 100, - amount_received: 0, + amountReceived: 0, currency: PaymentCurrency.USD, }; diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts index 0ceb213b48..6454eef906 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.service.ts @@ -173,7 +173,7 @@ export class PaymentService { !paymentEntity || paymentEntity.userId !== userId || paymentEntity.status !== PaymentStatus.PENDING || - !eq(paymentEntity.amount, div(paymentData.amount_received, 100)) || + !eq(paymentEntity.amount, div(paymentData.amountReceived, 100)) || paymentEntity.currency !== paymentData.currency ) { throw new NotFoundError(ErrorPayment.NotFound); diff --git a/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts index dc7655a421..6adca84442 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts @@ -109,7 +109,7 @@ export abstract class PaymentProvider { abstract updateBillingInfo( customerId: string, data: BillingInfoDto, - ): Promise; + ): Promise; - abstract retrievePaymentIntent(paymentId: string): any; + abstract retrievePaymentIntent(paymentId: string): Promise; } diff --git a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/fixtures.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/fixtures.ts index d53a6e035e..b6c817c722 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/fixtures.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/fixtures.ts @@ -1,10 +1,7 @@ import { faker } from '@faker-js/faker'; -import { - PaymentCurrency, - StripePaymentStatus, - VatType, -} from '../../../../common/enums/payment'; +import { PaymentCurrency, VatType } from '../../../../common/enums/payment'; import { AddressDto, BillingInfoDto } from '../../payment.dto'; +import { StripePaymentStatus } from './stripe.service'; export const createMockSetupIntent = () => ({ id: faker.string.alphanumeric(24), diff --git a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts index bb85c6b307..fed082f457 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts @@ -4,15 +4,14 @@ import { faker } from '@faker-js/faker'; import { PaymentData } from '../../payment.interface'; import { Test, TestingModule } from '@nestjs/testing'; import { Logger } from '@nestjs/common'; -import { StripeService } from './stripe.service'; -import { StripeConfigService } from '../../../../common/config/stripe-config.service'; +import { StripePaymentStatus, StripeService } from './stripe.service'; +import { PaymentProviderConfigService } from '../../../../common/config/payment-provider-config.service'; import Stripe from 'stripe'; import { NotFoundError, ServerError } from '../../../../common/errors'; import { ErrorPayment } from '../../../../common/constants/errors'; import { PaymentCurrency, PaymentStatus, - StripePaymentStatus, VatType, } from '../../../../common/enums/payment'; import { @@ -44,7 +43,7 @@ describe('StripeService', () => { providers: [ StripeService, { - provide: StripeConfigService, + provide: PaymentProviderConfigService, useValue: mockStripeConfigService, }, ], diff --git a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts index 5bb3efe93d..b40a39d655 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts @@ -1,13 +1,9 @@ import { Injectable } from '@nestjs/common'; import Stripe from 'stripe'; -import { StripeConfigService } from '../../../../common/config/stripe-config.service'; +import { PaymentProviderConfigService } from '../../../../common/config/payment-provider-config.service'; import { NotFoundError, ServerError } from '../../../../common/errors'; import { ErrorPayment } from '../../../../common/constants/errors'; -import { - PaymentStatus, - StripePaymentStatus, - VatType, -} from '../../../../common/enums/payment'; +import { PaymentStatus, VatType } from '../../../../common/enums/payment'; import { CardSetup, CustomerData, @@ -19,11 +15,17 @@ import { import { PaymentProvider } from '../payment-provider.abstract'; import { AddressDto, BillingInfoDto } from '../../payment.dto'; +export enum StripePaymentStatus { + CANCELED = 'canceled', + REQUIRES_PAYMENT_METHOD = 'requires_payment_method', + SUCCEEDED = 'succeeded', +} + @Injectable() export class StripeService extends PaymentProvider { private stripe: Stripe; - constructor(private stripeConfigService: StripeConfigService) { + constructor(private stripeConfigService: PaymentProviderConfigService) { super(); this.stripe = new Stripe(this.stripeConfigService.secretKey, { @@ -187,10 +189,9 @@ export class StripeService extends PaymentProvider { async updateBillingInfo( customerId: string, data: BillingInfoDto, - ): Promise { + ): Promise { const existingTaxIds = await this.listCustomerTaxIds(customerId); - // Delete any existing tax IDs before adding the new one for (const taxId of existingTaxIds) { await this.deleteTaxId(customerId, taxId.id); } @@ -213,6 +214,8 @@ export class StripeService extends PaymentProvider { email: data.email, }); } + + return this.retrieveCustomer(customerId); } async retrievePaymentIntent(paymentIntentId: string): Promise { @@ -326,10 +329,10 @@ export class StripeService extends PaymentProvider { }, }; - const customer = (await this.stripe.customers.update( + const customer = await this.stripe.customers.update( customerId, updatePayload, - )) as Stripe.Customer; + ); return { email: customer.email!, diff --git a/packages/apps/job-launcher/server/src/modules/webhook/webhook.controller.spec.ts b/packages/apps/job-launcher/server/src/modules/webhook/webhook.controller.spec.ts index 35c82ff14d..22305582c9 100644 --- a/packages/apps/job-launcher/server/src/modules/webhook/webhook.controller.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/webhook/webhook.controller.spec.ts @@ -27,9 +27,9 @@ import { MOCK_S3_SECRET_KEY, MOCK_S3_USE_SSL, MOCK_SECRET, - MOCK_STRIPE_API_VERSION, - MOCK_STRIPE_APP_INFO_URL, - MOCK_STRIPE_SECRET_KEY, + MOCK_PAYMENT_PROVIDER_API_VERSION, + MOCK_PAYMENT_PROVIDER_APP_INFO_URL, + MOCK_PAYMENT_PROVIDER_SECRET_KEY, } from '../../../test/constants'; import { ServerConfigService } from '../../common/config/server-config.service'; import { Web3ConfigService } from '../../common/config/web3-config.service'; @@ -62,9 +62,9 @@ describe('WebhookController', () => { FORTUNE_EXCHANGE_ORACLE_ADDRESS: MOCK_ADDRESS, FORTUNE_RECORDING_ORACLE_ADDRESS: MOCK_ADDRESS, WEB3_PRIVATE_KEY: MOCK_PRIVATE_KEY, - STRIPE_SECRET_KEY: MOCK_STRIPE_SECRET_KEY, - STRIPE_API_VERSION: MOCK_STRIPE_API_VERSION, - STRIPE_APP_INFO_URL: MOCK_STRIPE_APP_INFO_URL, + PAYMENT_PROVIDER_SECRET_KEY: MOCK_PAYMENT_PROVIDER_SECRET_KEY, + PAYMENT_PROVIDER_API_VERSION: MOCK_PAYMENT_PROVIDER_API_VERSION, + PAYMENT_PROVIDER_APP_INFO_URL: MOCK_PAYMENT_PROVIDER_APP_INFO_URL, HCAPTCHA_SITE_KEY: MOCK_HCAPTCHA_SITE_KEY, HCAPTCHA_RECORDING_ORACLE_URI: MOCK_RECORDING_ORACLE_URL, HCAPTCHA_REPUTATION_ORACLE_URI: MOCK_REPUTATION_ORACLE_URL, diff --git a/packages/apps/job-launcher/server/test/constants.ts b/packages/apps/job-launcher/server/test/constants.ts index 809f24265b..d54e1a00c9 100644 --- a/packages/apps/job-launcher/server/test/constants.ts +++ b/packages/apps/job-launcher/server/test/constants.ts @@ -66,11 +66,11 @@ export const MOCK_JOB_ID = 1; export const MOCK_SENDGRID_API_KEY = 'SG.xxxxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'; -export const MOCK_STRIPE_SECRET_KEY = 'xxxxxxxxxxxxxxxxxxxxxx'; +export const MOCK_PAYMENT_PROVIDER_SECRET_KEY = 'xxxxxxxxxxxxxxxxxxxxxx'; export const MOCK_COINGECKO_API_KEY = 'xxxxxxxxxxxxxxxxxxxxxx'; -export const MOCK_STRIPE_API_VERSION = '2022-11-15'; -export const MOCK_STRIPE_APP_NAME = 'Name'; -export const MOCK_STRIPE_APP_INFO_URL = 'https://test-app-url.com'; +export const MOCK_PAYMENT_PROVIDER_API_VERSION = '2022-11-15'; +export const MOCK_PAYMENT_PROVIDER_APP_NAME = 'Name'; +export const MOCK_PAYMENT_PROVIDER_APP_INFO_URL = 'https://test-app-url.com'; export const MOCK_SENDGRID_FROM_EMAIL = 'info@hmt.ai'; export const MOCK_SENDGRID_FROM_NAME = 'John Doe'; export const MOCK_S3_ENDPOINT = 'localhost'; @@ -248,10 +248,10 @@ export const mockConfig: any = { PGP_PASSPHRASE: MOCK_PGP_PASSPHRASE, REPUTATION_ORACLE_ADDRESS: MOCK_ADDRESS, WEB3_PRIVATE_KEY: MOCK_PRIVATE_KEY, - STRIPE_SECRET_KEY: MOCK_STRIPE_SECRET_KEY, - STRIPE_API_VERSION: MOCK_STRIPE_API_VERSION, - STRIPE_APP_NAME: MOCK_STRIPE_APP_NAME, - STRIPE_APP_INFO_URL: MOCK_STRIPE_APP_INFO_URL, + PAYMENT_PROVIDER_SECRET_KEY: MOCK_PAYMENT_PROVIDER_SECRET_KEY, + PAYMENT_PROVIDER_API_VERSION: MOCK_PAYMENT_PROVIDER_API_VERSION, + PAYMENT_PROVIDER_APP_NAME: MOCK_PAYMENT_PROVIDER_APP_NAME, + PAYMENT_PROVIDER_APP_INFO_URL: MOCK_PAYMENT_PROVIDER_APP_INFO_URL, CVAT_EXCHANGE_ORACLE_ADDRESS: MOCK_ADDRESS, CVAT_RECORDING_ORACLE_ADDRESS: MOCK_ADDRESS, HCAPTCHA_SITE_KEY: MOCK_HCAPTCHA_SITE_KEY, From fee1d97f878530d89bb550efd5ca7e5e40fcacc5 Mon Sep 17 00:00:00 2001 From: portuu3 <61605646+portuu3@users.noreply.github.com> Date: Thu, 26 Jun 2025 08:53:52 +0200 Subject: [PATCH 16/16] Add missing env --- packages/apps/job-launcher/server/.env.example | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/apps/job-launcher/server/.env.example b/packages/apps/job-launcher/server/.env.example index ba500dd4ec..d892a45d20 100644 --- a/packages/apps/job-launcher/server/.env.example +++ b/packages/apps/job-launcher/server/.env.example @@ -87,6 +87,7 @@ PAYMENT_PROVIDER_SECRET_KEY=disabled PAYMENT_PROVIDER_APP_NAME=Launcher Server Local PAYMENT_PROVIDER_APP_VERSION=1.0.0 PAYMENT_PROVIDER_APP_INFO_URL=http://local.app +PAYMENT_PROVIDER_API_VERSION=2022-11-15 # Sendgrid SENDGRID_API_KEY=sendgrid-disabled