diff --git a/.gitignore b/.gitignore index 70399e1fe6..d9ac258354 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ yarn-error.log* # IDE - IntelliJ .idea/ +*.iml # OS .DS_Store @@ -48,4 +49,5 @@ dist hardhat-dependency-compiler # cache -cache \ No newline at end of file +cache + diff --git a/packages/apps/job-launcher/server/.env.example b/packages/apps/job-launcher/server/.env.example index 73585bf6e8..d892a45d20 100644 --- a/packages/apps/job-launcher/server/.env.example +++ b/packages/apps/job-launcher/server/.env.example @@ -83,10 +83,11 @@ 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 +PAYMENT_PROVIDER_API_VERSION=2022-11-15 # 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/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.interface.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.interface.ts index a9b4d8650f..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,6 +1,60 @@ import { PaymentEntity } from './payment.entity'; +import { PaymentStatus, 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; + postalCode?: string; +} + +export interface CustomerData { + email: string; + name?: string; + address?: BillingAddress; + defaultPaymentMethod?: string; +} + +export interface TaxId { + id: string; + type: VatType; + value: string; +} + +export interface Invoice { + id: string; + paymentId: string | null; + status?: string; + amountDue: number; + currency: string; +} + +export interface CardSetup { + customerId: string; + paymentMethod: string; +} + +export interface PaymentData { + customer: string; + id: string; + clientSecret: string | null; + status: PaymentStatus | null; + amount: number; + amountReceived: number; + currency: string; + latestCharge: 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 6667226e1f..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,6 +15,8 @@ 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 { StripeService } from './providers/stripe/stripe.service'; +import { PaymentProvider } from './providers/payment-provider.abstract'; @Module({ imports: [ @@ -39,7 +41,16 @@ import { RateModule } from '../rate/rate.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.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 2a7a83146a..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 @@ -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, @@ -22,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, @@ -30,14 +28,12 @@ import { ErrorSignature, } from '../../common/constants/errors'; import { SortDirection } from '../../common/enums/collection'; -import { Country } from '../../common/enums/job'; import { PaymentCurrency, PaymentSortField, PaymentSource, PaymentStatus, PaymentType, - StripePaymentStatus, VatType, } from '../../common/enums/payment'; import { @@ -55,13 +51,15 @@ import { GetPaymentsDto, UserBalanceDto } from './payment.dto'; import { PaymentEntity } from './payment.entity'; import { PaymentRepository } from './payment.repository'; import { PaymentService } from './payment.service'; +import { PaymentProvider } from './providers/payment-provider.abstract'; +import { Invoice, PaymentData } from './payment.interface'; describe('PaymentService', () => { - let stripe: Stripe; let paymentService: PaymentService; - let paymentRepository: PaymentRepository; - let userRepository: UserRepository; - let rateService: RateService; + let paymentProvider: jest.Mocked; + let paymentRepository: jest.Mocked; + let userRepository: jest.Mocked; + let rateService: jest.Mocked; const signerMock = { address: MOCK_ADDRESS, @@ -71,6 +69,7 @@ describe('PaymentService', () => { beforeEach(async () => { const moduleRef = await Test.createTestingModule({ providers: [ + PaymentService, { provide: ConfigService, useValue: { @@ -83,8 +82,6 @@ describe('PaymentService', () => { }), }, }, - PaymentService, - StripeConfigService, { provide: PaymentRepository, useValue: createMock(), @@ -111,82 +108,31 @@ 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); - - 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; }); 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,56 +144,49 @@ describe('PaymentService', () => { const user = { id: 1, - stripeCustomerId: 'cus_123', + paymentProviderId: 'cus_123', }; const paymentIntent = { id: 'pi_123', - client_secret: 'clientSecret123', - }; + clientSecret: 'clientSecret123', + } as PaymentData; const invoice = { id: 'id', - payment_intent: paymentIntent.id, - }; + paymentId: paymentIntent.id, + } as Invoice; + + paymentProvider.createInvoice.mockResolvedValue(invoice as any); + paymentProvider.assignPaymentMethod.mockResolvedValue( + paymentIntent as any, + ); - createInvoiceMock.mockResolvedValue(invoice as any); - finalizeInvoiceMock.mockResolvedValue(invoice as any); - retrievePaymentIntentMock.mockResolvedValue(paymentIntent as any); - jest - .spyOn(stripe.paymentIntents, 'retrieve') - .mockResolvedValue(paymentIntent as any); jest .spyOn(paymentRepository, 'findOneByTransaction') .mockResolvedValue(null); + jest .spyOn(paymentRepository, 'createUnique') .mockResolvedValue(undefined as any); 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(result).toEqual(paymentIntent.clientSecret); + expect(paymentProvider.createInvoice).toHaveBeenCalledWith( + 'cus_123', + 10000, + PaymentCurrency.USD, + 'Top up', + ); + expect(paymentProvider.assignPaymentMethod).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 +195,7 @@ describe('PaymentService', () => { const user = { id: 1, - stripeCustomerId: 'cus_123', + paymentProviderId: 'cus_123', }; const paymentIntent = { @@ -269,12 +208,10 @@ 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); + paymentProvider.createInvoice.mockResolvedValue(invoice as any); + paymentProvider.assignPaymentMethod.mockResolvedValue( + paymentIntent as any, + ); findOneMock.mockResolvedValue({ transaction: paymentIntent.client_secret, @@ -286,45 +223,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(() => { @@ -338,13 +243,15 @@ describe('PaymentService', () => { }; const paymentData = { - status: StripePaymentStatus.SUCCEEDED, + status: PaymentStatus.SUCCEEDED, amount: 100, - amount_received: 100, + amountReceived: 100, currency: PaymentCurrency.USD, }; - retrievePaymentIntentMock.mockResolvedValue(paymentData); + paymentProvider.retrievePaymentIntent.mockResolvedValue( + paymentData as any, + ); const paymentEntity: Partial = { userId: userId, @@ -357,50 +264,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, - }; - - retrievePaymentIntentMock.mockResolvedValue(paymentData); - - 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.FAILED, amount: 100, - amount_received: 0, + amountReceived: 0, currency: PaymentCurrency.USD, }; - retrievePaymentIntentMock.mockResolvedValue(paymentData); + paymentProvider.retrievePaymentIntent.mockResolvedValue( + paymentData as any, + ); const paymentEntity: Partial = { userId: userId, @@ -424,11 +327,13 @@ describe('PaymentService', () => { const paymentData = { status: 'unknown_status', amount: 100, - amount_received: 0, + amountReceived: 0, currency: PaymentCurrency.USD, }; - retrievePaymentIntentMock.mockResolvedValue(paymentData); + paymentProvider.retrievePaymentIntent.mockResolvedValue( + paymentData as any, + ); const paymentEntity: Partial = { userId: userId, @@ -449,7 +354,9 @@ describe('PaymentService', () => { paymentId: MOCK_PAYMENT_ID, }; - retrievePaymentIntentMock.mockResolvedValue(null); + paymentProvider.retrievePaymentIntent.mockResolvedValue( + null as unknown as PaymentData, + ); await expect( paymentService.confirmFiatPayment(userId, dto), @@ -847,41 +754,33 @@ describe('PaymentService', () => { const user = { id: 1, email: 'test@hmt.ai', - stripeCustomerId: null, + paymentProviderId: null, }; - const paymentIntent = { - client_secret: 'clientSecret123', - }; + const client_secret = 'clientSecret123'; + const customerId = 'cus_123'; - jest - .spyOn(stripe.customers, 'create') - .mockResolvedValue({ id: 'cus_123' } as any); - jest - .spyOn(stripe.setupIntents, 'create') - .mockResolvedValue(paymentIntent as any); + paymentProvider.createCustomer.mockResolvedValue(customerId); + paymentProvider.setupCard.mockResolvedValue(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(result).toEqual(client_secret); + expect(paymentProvider.createCustomer).toHaveBeenCalledWith(user.email); }); 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()); + + paymentProvider.createCustomer.mockRejectedValue( + new ServerError(ErrorPayment.CustomerNotCreated), + ); await expect( paymentService.createCustomerAndAssignCard(user as any), @@ -894,15 +793,16 @@ describe('PaymentService', () => { email: 'test@hmt.ai', }; - jest - .spyOn(stripe.customers, 'create') - .mockResolvedValue({ id: 1 } as any); - - jest.spyOn(stripe.setupIntents, 'create').mockRejectedValue(new Error()); + paymentProvider.createCustomer.mockResolvedValue('cus_123'); + paymentProvider.setupCard.mockRejectedValue( + new ServerError(ErrorPayment.CardNotAssigned), + ); await expect( paymentService.createCustomerAndAssignCard(user as any), - ).rejects.toThrow(ErrorPayment.CardNotAssigned); + ).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 () => { @@ -911,36 +811,32 @@ 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); + paymentProvider.createCustomer.mockResolvedValue('cus_123'); + paymentProvider.setupCard.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 = { - customer: 'cus_123', - payment_method: 'pm_123', + customerId: 'cus_123', + paymentMethod: 'pm_123', }; - jest - .spyOn(stripe.setupIntents, 'retrieve') - .mockResolvedValue(setupMock as any); - jest.spyOn(stripe.customers, 'update').mockResolvedValue(null as any); + paymentProvider.retrieveCardSetup.mockResolvedValue(setupMock as any); + paymentProvider.updateCustomer.mockResolvedValue(null as any); jest .spyOn(userRepository, 'updateOne') .mockResolvedValue(undefined as any); @@ -953,14 +849,14 @@ 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', { - invoice_settings: { - default_payment_method: 'pm_123', - }, + expect(paymentProvider.retrieveCardSetup).toHaveBeenCalledWith( + 'setup_123', + ); + expect(paymentProvider.updateCustomer).toHaveBeenCalledWith('cus_123', { + defaultPaymentMethod: 'pm_123', }); }); @@ -970,16 +866,14 @@ describe('PaymentService', () => { email: 'test@hmt.ai', }; - jest - .spyOn(stripe.setupIntents, 'retrieve') - .mockResolvedValue(undefined as any); + paymentProvider.retrieveCardSetup.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 +881,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 +898,34 @@ 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({ - invoice_settings: { default_payment_method: paymentMethodId }, + + paymentProvider.createInvoice.mockResolvedValueOnce({ + id: invoiceId, + paymentId: paymentIntent, } as any); + paymentProvider.assignPaymentMethod.mockResolvedValueOnce( + paymentIntent as any, + ); + paymentProvider.getDefaultPaymentMethod.mockResolvedValueOnce( + paymentMethodId, + ); 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(paymentProvider.createInvoice).toHaveBeenCalledWith( + user.paymentProviderId, + expect.any(Number), + PaymentCurrency.USD, + 'Slash Job Id ' + jobEntity.id, + ); + expect(paymentProvider.assignPaymentMethod).toHaveBeenCalledWith( + paymentIntent, + paymentMethodId, + true, ); - expect(paymentRepository.createUnique).toHaveBeenCalledTimes(2); }); it('should fail if user does not have payment info', async () => { @@ -1065,25 +940,23 @@ 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({ - invoice_settings: { default_payment_method: paymentMethodId }, + + paymentProvider.createInvoice.mockResolvedValueOnce({ + id: invoiceId, } as any); - jest - .spyOn(stripe.paymentIntents, 'confirm') - .mockRejectedValue(new Error()); + paymentProvider.getDefaultPaymentMethod.mockResolvedValueOnce( + paymentMethodId, + ); + + paymentProvider.assignPaymentMethod.mockRejectedValue( + new ServerError(ErrorPayment.PaymentMethodAssociationFailed), + ); await expect( paymentService.createSlash(jobEntity as any), - ).rejects.toThrow(ErrorPayment.PaymentMethodAssociationFailed); + ).rejects.toThrow( + new ServerError(ErrorPayment.PaymentMethodAssociationFailed), + ); }); }); @@ -1091,22 +964,22 @@ 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', + brand: 'visa', + last4: '4242', + }, + { id: 'pm_456', brand: 'mastercard', last4: '5555' }, + ]; - jest - .spyOn(stripe.customers, 'listPaymentMethods') - .mockResolvedValueOnce(paymentMethods as any); - jest - .spyOn(paymentService as any, 'getDefaultPaymentMethod') - .mockResolvedValueOnce('pm_123'); + paymentProvider.listPaymentMethods.mockResolvedValue( + paymentMethods as any, + ); + paymentProvider.getDefaultPaymentMethod.mockResolvedValue('pm_123'); const result = await paymentService.listUserPaymentMethods(user as any); @@ -1130,37 +1003,36 @@ 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'); + paymentProvider.retrievePaymentMethod.mockResolvedValue({ + id: 'pm_123', + } as any); + paymentProvider.getDefaultPaymentMethod.mockResolvedValue('pm_456'); jest .spyOn(paymentService as any, 'isPaymentMethodInUse') .mockResolvedValue(false); - jest.spyOn(stripe.paymentMethods, 'detach').mockResolvedValue({} as any); + paymentProvider.detachPaymentMethod.mockResolvedValue({} as any); await paymentService.deletePaymentMethod(user as any, 'pm_123'); - expect(stripe.paymentMethods.detach).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 () => { 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'); + paymentProvider.retrievePaymentMethod.mockResolvedValue({ + id: 'pm_123', + default: true, + } as any); + paymentProvider.getDefaultPaymentMethod.mockResolvedValue('pm_123'); jest .spyOn(paymentService as any, 'isPaymentMethodInUse') .mockResolvedValue(true); @@ -1175,40 +1047,29 @@ describe('PaymentService', () => { it('should get user billing info successfully', async () => { const user = { id: 1, - stripeCustomerId: 'cus_123', - }; - - const taxIds = { - data: [{ type: VatType.EU_VAT, value: 'DE123456789' }], + paymentProviderId: 'cus_123', }; const customer = { name: 'John Doe', email: 'john@example.com', address: { - country: Country.DE, - postal_code: '12345', + country: 'de', + postalCode: '12345', city: 'Berlin', - line1: 'Street 1', + line: 'Street 1', }, }; - jest - .spyOn(stripe.customers, 'listTaxIds') - .mockResolvedValue(taxIds as any); - jest - .spyOn(stripe.customers, 'retrieve') - .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: Country.DE, + country: 'de', postalCode: '12345', city: 'Berlin', line: 'Street 1', @@ -1221,7 +1082,7 @@ describe('PaymentService', () => { it('should update user billing info successfully', async () => { const user = { id: 1, - stripeCustomerId: 'cus_123', + paymentProviderId: 'cus_123', }; const updateBillingInfoDto = { @@ -1237,31 +1098,28 @@ 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); + paymentProvider.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', { - name: 'John Doe', - email: 'john@example.com', - address: { - country: 'DE', - postal_code: '12345', - city: 'Berlin', - line1: 'Street 1', + expect(paymentProvider.updateBillingInfo).toHaveBeenCalledWith( + 'cus_123', + { + name: 'John Doe', + email: 'john@example.com', + address: { + country: 'DE', + postalCode: '12345', + city: 'Berlin', + line: 'Street 1', + }, + vat: 'DE123456789', + vatType: VatType.EU_VAT, }, - }); + ); }); }); @@ -1269,15 +1127,15 @@ 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); + paymentProvider.updateCustomer.mockResolvedValue({} as any); await paymentService.changeDefaultPaymentMethod(user as any, 'pm_123'); - expect(stripe.customers.update).toHaveBeenCalledWith('cus_123', { - invoice_settings: { default_payment_method: 'pm_123' }, + expect(paymentProvider.updateCustomer).toHaveBeenCalledWith('cus_123', { + defaultPaymentMethod: 'pm_123', }); }); }); @@ -1422,58 +1280,28 @@ 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({ - customer: 'cus_123', - latest_charge: 'ch_123', - } as any); - - retrieveChargeMock.mockResolvedValue({ - receipt_url: 'https://receipt.url', - } as any); + paymentProvider.getReceiptUrl.mockResolvedValue('https://receipt.url'); const result = await paymentService.getReceipt(paymentId, user); - expect(result).toEqual('https://receipt.url'); - expect(retrievePaymentIntentMock).toHaveBeenCalledWith(paymentId); - expect(retrieveChargeMock).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; - - retrievePaymentIntentMock.mockResolvedValue(null); - await expect(paymentService.getReceipt(paymentId, user)).rejects.toThrow( - new NotFoundError(ErrorPayment.NotFound), + expect(result).toBe('https://receipt.url'); + expect(paymentProvider.getReceiptUrl).toHaveBeenCalledWith( + 'pi_123', + 'cus_123', ); }); - it('should throw a NOT_FOUND error if charge does not exist', async () => { + it('should throw a NOT_FOUND error if receipt URL is not found', async () => { const paymentId = 'pi_123'; - const user = { stripeCustomerId: 'cus_123' } as any; - - retrievePaymentIntentMock.mockResolvedValue({ - customer: 'cus_123', - latest_charge: 'ch_123', - } as any); + const user = { paymentProviderId: 'cus_123' } as any; - retrieveChargeMock.mockResolvedValue(null); + paymentProvider.getReceiptUrl.mockRejectedValue( + new NotFoundError(ErrorPayment.NotFound), + ); 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 0809be590b..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 @@ -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'; @@ -18,14 +16,11 @@ import { PaymentSource, PaymentStatus, PaymentType, - StripePaymentStatus, - 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, @@ -50,11 +45,11 @@ 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 './providers/payment-provider.abstract'; @Injectable() export class PaymentService { private readonly logger = new Logger(PaymentService.name); - private stripe: Stripe; constructor( private readonly networkConfigService: NetworkConfigService, @@ -62,70 +57,26 @@ 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 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 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.paymentProviderId; - // 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.paymentProvider.createCustomer(user.email); } - return setupIntent.client_secret; + return await this.paymentProvider.setupCard(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.stripe.setupIntents.retrieve(data.setupId); + const setup = await this.paymentProvider.retrieveCardSetup(data.setupId); if (!setup) { this.logger.log(ErrorPayment.SetupNotFound, PaymentService.name); @@ -133,23 +84,18 @@ export class PaymentService { } let defaultPaymentMethod: string | null = null; - if (!user.stripeCustomerId) { - // Assign the Stripe customer ID to the user if it does not exist yet. - user.stripeCustomerId = setup.customer as string; + if (!user.paymentProviderId) { + user.paymentProviderId = setup.customerId as string; await this.userRepository.updateOne(user); } else { - // Check if the user already has a default payment method. defaultPaymentMethod = await this.getDefaultPaymentMethod( - user.stripeCustomerId, + user.paymentProviderId, ); } if (data.defaultCard || !defaultPaymentMethod) { - // Update Stripe customer settings to use this payment method by default. - await this.stripe.customers.update(user.stripeCustomerId, { - invoice_settings: { - default_payment_method: setup.payment_method, - }, + await this.paymentProvider.updateCustomer(user.paymentProviderId, { + defaultPaymentMethod: setup.paymentMethod as string, }); } @@ -163,19 +109,19 @@ 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.createInvoice( - user.stripeCustomerId, + const invoice = await this.paymentProvider.createInvoice( + user.paymentProviderId, amountInCents, currency, 'Top up', ); - const paymentIntent = await this.handleStripePaymentIntent( - invoice.payment_intent as string, + const paymentIntent = await this.paymentProvider.assignPaymentMethod( + invoice.paymentId as string, paymentMethodId, false, // on-session payment ); @@ -200,17 +146,17 @@ export class PaymentService { transaction: paymentIntent.id, status: PaymentStatus.PENDING, }); + await this.paymentRepository.createUnique(newPaymentEntity); - return paymentIntent.client_secret!; + return paymentIntent.clientSecret!; } public async confirmFiatPayment( 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.stripe.paymentIntents.retrieve( + const paymentData = await this.paymentProvider.retrievePaymentIntent( data.paymentId, ); @@ -227,25 +173,23 @@ 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); } - if ( - paymentData?.status === StripePaymentStatus.CANCELED || - paymentData?.status === StripePaymentStatus.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 !== StripePaymentStatus.SUCCEEDED) { + } else if (paymentData.status !== PaymentStatus.SUCCEEDED) { return false; // TODO: Handling other cases } // Update the payment entity to reflect successful payment. paymentEntity.status = PaymentStatus.SUCCEEDED; + await this.paymentRepository.updateOne(paymentEntity); return true; @@ -257,9 +201,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 +284,7 @@ export class PaymentService { transaction: dto.transactionHash, status: PaymentStatus.SUCCEEDED, }); + await this.paymentRepository.createUnique(newPaymentEntity); return true; @@ -352,12 +299,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) { @@ -388,97 +333,33 @@ 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; 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.createInvoice( - user.stripeCustomerId, + const invoice = await this.paymentProvider.createInvoice( + user.paymentProviderId, amountInCents, currency, 'Slash Job Id ' + job.id, ); const defaultPaymentMethod = await this.getDefaultPaymentMethod( - user.stripeCustomerId, + user.paymentProviderId, ); if (!defaultPaymentMethod) { throw new ServerError(ErrorPayment.NotDefaultPaymentMethod); } - const paymentIntent = await this.handleStripePaymentIntent( - invoice.payment_intent as string, + const paymentIntent = await this.paymentProvider.assignPaymentMethod( + invoice.paymentId as string, defaultPaymentMethod, true, // off-session payment ); @@ -494,6 +375,7 @@ export class PaymentService { transaction: paymentIntent.id, status: PaymentStatus.SUCCEEDED, }); + await this.paymentRepository.createUnique(newPaymentEntity); Object.assign(newPaymentEntity, { @@ -507,6 +389,7 @@ export class PaymentService { status: PaymentStatus.SUCCEEDED, jobId: job.id, }); + await this.paymentRepository.createUnique(newPaymentEntity); } @@ -536,31 +419,26 @@ 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.stripe.customers.listPaymentMethods( - user.stripeCustomerId, - { - type: 'card', - limit: 100, - }, + // 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.stripeCustomerId, + user.paymentProviderId, ); - 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; - 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); } @@ -570,103 +448,48 @@ export class PaymentService { async deletePaymentMethod(user: UserEntity, paymentMethodId: string) { // Retrieve the payment method to be detached const paymentMethod = - await this.stripe.paymentMethods.retrieve(paymentMethodId); + await this.paymentProvider.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)) && + user.paymentProviderId && + paymentMethod.default && (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.paymentProvider.detachPaymentMethod(paymentMethodId); } async getUserBillingInfo(user: UserEntity): Promise { - if (!user.stripeCustomerId) { - return null; - } - - // Retrieve the customer's tax IDs and customer information - const taxIds = await this.stripe.customers.listTaxIds( - user.stripeCustomerId, + return await this.paymentProvider.retrieveBillingInfo( + user.paymentProviderId, ); - - const customer = (await this.stripe.customers.retrieve( - user.stripeCustomerId, - )) as Stripe.Customer; - - 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.data[0]?.value; - userBillingInfo.vatType = taxIds.data[0]?.type as VatType; - return userBillingInfo; } async updateUserBillingInfo( 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.stripe.customers.listTaxIds( - 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); - } - - // Create the new VAT tax ID - if (updateBillingInfoDto.vat && updateBillingInfoDto.vatType) { - await this.stripe.customers.createTaxId(user.stripeCustomerId, { - type: updateBillingInfoDto.vatType, - value: updateBillingInfoDto.vat, - }); - } - - // If there are changes to the address, name, or email, update them - if ( - updateBillingInfoDto.address || - updateBillingInfoDto.name || - updateBillingInfoDto.email - ) { - return this.stripe.customers.update(user.stripeCustomerId, { - address: { - line1: updateBillingInfoDto.address?.line, - city: updateBillingInfoDto.address?.city, - country: updateBillingInfoDto.address?.country, - postal_code: updateBillingInfoDto.address?.postalCode, - }, - name: updateBillingInfoDto.name, - email: updateBillingInfoDto.email, - }); - } + return await this.paymentProvider.updateBillingInfo( + user.paymentProviderId, + updateBillingInfoDto, + ); } 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.stripe.customers.update(user.stripeCustomerId, { - invoice_settings: { default_payment_method: cardId }, + + return this.paymentProvider.updateCustomer(user.paymentProviderId, { + defaultPaymentMethod: cardId, }); } @@ -675,10 +498,7 @@ export class PaymentService { throw new NotFoundError(ErrorPayment.CustomerNotFound); } - // 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.paymentProvider.getDefaultPaymentMethod(customerId); } private async isPaymentMethodInUse(userId: number): Promise { @@ -721,22 +541,10 @@ 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); - - 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( - 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 new file mode 100644 index 0000000000..6adca84442 --- /dev/null +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/payment-provider.abstract.ts @@ -0,0 +1,115 @@ +import { + CardSetup, + CustomerData, + Invoice, + PaymentData, + 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); + + /** + * Create a new customer in the payment provider system + * @param email Customer's email address + * @returns Customer ID + */ + 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 + * @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; + + /** + * 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 assignPaymentMethod( + paymentIntentId: string, + paymentMethodId: string, + offSession: boolean, + ): 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; + + abstract retrieveCardSetup(setupId: string): Promise; + + /** + * 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; + + abstract getReceiptUrl( + paymentId: string, + customerId: string | null, + ): Promise; + + abstract retrieveBillingInfo( + customerId: string | null, + ): Promise; + + abstract updateBillingInfo( + customerId: string, + data: BillingInfoDto, + ): Promise; + + 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 new file mode 100644 index 0000000000..b6c817c722 --- /dev/null +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/fixtures.ts @@ -0,0 +1,101 @@ +import { faker } from '@faker-js/faker'; +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), + 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, +}); + +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 new file mode 100644 index 0000000000..fed082f457 --- /dev/null +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.spec.ts @@ -0,0 +1,918 @@ +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'; +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, + VatType, +} from '../../../../common/enums/payment'; +import { + createMockBillingInfoDto, + createMockCharge, + createMockCustomer, + createMockInvoice, + createMockPaymentIntent, + createMockPaymentMethod, + createMockSetupIntent, + createMockTaxId, +} from './fixtures'; + +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', + }; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + StripeService, + { + provide: PaymentProviderConfigService, + useValue: mockStripeConfigService, + }, + ], + }).compile(); + + service = module.get(StripeService); + + // 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'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createCustomer', () => { + it('should create a customer successfully', async () => { + const mockCustomer = { id: faker.string.uuid() }; + stripeMock.customers.create = jest + .fn() + .mockResolvedValueOnce(mockCustomer); + + const email = faker.internet.email(); + const result = await service.createCustomer(email); + + expect(result).toBe(mockCustomer.id); + expect(stripeMock.customers.create).toHaveBeenCalledWith({ + email, + }); + }); + + it('should handle errors when creating customer', async () => { + stripeMock.customers.create = jest + .fn() + .mockRejectedValue(new Error('Stripe error')); + + const email = faker.internet.email(); + + await expect(service.createCustomer(email)).rejects.toThrow( + new ServerError(ErrorPayment.CustomerNotCreated), + ); + expect(loggerSpy).toHaveBeenCalled(); + }); + }); + + describe('setupCard', () => { + const mockSetupIntent = createMockSetupIntent(); + + it('should create setup intent successfully', async () => { + stripeMock.setupIntents.create = jest + .fn() + .mockResolvedValueOnce(mockSetupIntent); + + 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: customerId, + }); + }); + + it('should handle null customerId', async () => { + stripeMock.setupIntents.create = jest + .fn() + .mockResolvedValueOnce(mockSetupIntent); + + await service.setupCard(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().mockResolvedValueOnce({}); + + const customerId = faker.string.uuid(); + + await expect(service.setupCard(customerId)).rejects.toThrow( + new ServerError(ErrorPayment.ClientSecretDoesNotExist), + ); + }); + }); + + describe('assignPaymentMethod', () => { + const mockPaymentIntent = createMockPaymentIntent(); + + it('should assign off-session payment method', async () => { + stripeMock.paymentIntents.confirm = jest + .fn() + .mockResolvedValueOnce(mockPaymentIntent); + stripeMock.paymentIntents.retrieve = jest + .fn() + .mockResolvedValueOnce(mockPaymentIntent); + + const paymentIntentId = faker.string.uuid(); + const paymentMethodId = faker.string.uuid(); + + const result = await service.assignPaymentMethod( + paymentIntentId, + paymentMethodId, + true, + ); + + expect(stripeMock.paymentIntents.confirm).toHaveBeenCalledWith( + paymentIntentId, + { + payment_method: paymentMethodId, + off_session: true, + }, + ); + expect(result).toEqual({ + id: mockPaymentIntent.id, + clientSecret: mockPaymentIntent.client_secret, + status: PaymentStatus.FAILED, + amount: mockPaymentIntent.amount, + amountReceived: mockPaymentIntent.amount_received, + currency: mockPaymentIntent.currency, + customer: mockPaymentIntent.customer, + latestCharge: mockPaymentIntent.latest_charge, + } as PaymentData); + }); + + it('should assign on-session payment method', async () => { + stripeMock.paymentIntents.update = jest + .fn() + .mockResolvedValueOnce(mockPaymentIntent); + stripeMock.paymentIntents.retrieve = jest + .fn() + .mockResolvedValueOnce(mockPaymentIntent); + + const paymentIntentId = faker.string.uuid(); + const paymentMethodId = faker.string.uuid(); + + const result = await service.assignPaymentMethod( + paymentIntentId, + paymentMethodId, + false, + ); + + expect(stripeMock.paymentIntents.update).toHaveBeenCalledWith( + paymentIntentId, + { + payment_method: paymentMethodId, + }, + ); + expect(result).toEqual({ + id: mockPaymentIntent.id, + clientSecret: mockPaymentIntent.client_secret, + status: PaymentStatus.FAILED, + amount: mockPaymentIntent.amount, + amountReceived: mockPaymentIntent.amount_received, + currency: mockPaymentIntent.currency, + customer: mockPaymentIntent.customer, + latestCharge: mockPaymentIntent.latest_charge, + } as PaymentData); + }); + }); + + describe('createInvoice', () => { + const mockInvoice = createMockInvoice(); + + it('should create invoice successfully', async () => { + const customerId = faker.string.uuid(); + + stripeMock.invoices.create = jest + .fn() + .mockResolvedValueOnce({ id: customerId }); + stripeMock.invoiceItems.create = jest.fn().mockResolvedValueOnce({}); + stripeMock.invoices.finalizeInvoice = jest + .fn() + .mockResolvedValueOnce(mockInvoice); + + const result = await service.createInvoice( + customerId, + 1000, + PaymentCurrency.USD, + 'Test invoice', + ); + + expect(stripeMock.invoices.create).toHaveBeenCalled(); + expect(stripeMock.invoiceItems.create).toHaveBeenCalled(); + expect(stripeMock.invoices.finalizeInvoice).toHaveBeenCalled(); + + const { id, payment_intent, status, currency, amount_due } = mockInvoice; + + expect(result).toEqual({ + id, + paymentId: payment_intent, + status, + currency, + amountDue: amount_due, + }); + }); + + 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() + .mockResolvedValueOnce({ id: invoiceId }); + stripeMock.invoiceItems.create = jest.fn().mockResolvedValueOnce({}); + stripeMock.invoices.finalizeInvoice = jest + .fn() + .mockResolvedValueOnce({ id: invoiceId }); + + await expect( + service.createInvoice( + customerId, + 1000, + PaymentCurrency.USD, + 'Test invoice', + ), + ).rejects.toThrow(new ServerError(ErrorPayment.IntentNotCreated)); + }); + }); + + describe('retrievePaymentIntent', () => { + it('should retrieve payment intent successfully', async () => { + const mockPaymentIntent = createMockPaymentIntent({ + status: StripePaymentStatus.SUCCEEDED, + amount_received: 1000, + }); + + stripeMock.paymentIntents.retrieve = jest + .fn() + .mockResolvedValueOnce(mockPaymentIntent); + + const result = await service.retrievePaymentIntent(mockPaymentIntent.id); + + 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( + mockPaymentIntent.id, + ); + }); + + it('should handle different payment statuses', async () => { + const statuses = [ + { + stripe: StripePaymentStatus.REQUIRES_PAYMENT_METHOD, + expected: PaymentStatus.FAILED, + }, + { + 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() + .mockResolvedValueOnce(mockPaymentIntent); + + const result = await service.retrievePaymentIntent( + mockPaymentIntent.id, + ); + + expect(result.status).toBe(expected); + } + }); + + it('should handle missing client secret', async () => { + const mockPaymentIntent = createMockPaymentIntent({ + client_secret: null, + }); + stripeMock.paymentIntents.retrieve = jest + .fn() + .mockResolvedValueOnce(mockPaymentIntent); + + const result = await service.retrievePaymentIntent(mockPaymentIntent.id); + + expect(result.clientSecret).toBeNull(); + }); + }); + + 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: defaultPaymentMethod, + }; + + stripeMock.customers.retrieve = jest + .fn() + .mockResolvedValueOnce(mockCustomer); + + const result = await service.getDefaultPaymentMethod(mockCustomer.id); + + expect(result).toBe(defaultPaymentMethod); + expect(stripeMock.customers.retrieve).toHaveBeenCalledWith( + mockCustomer.id, + ); + }); + + 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() + .mockResolvedValueOnce(mockCustomer); + + const result = await service.getDefaultPaymentMethod(mockCustomer.id); + + 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() + .mockResolvedValueOnce(mockCustomer); + + const result = await service.getDefaultPaymentMethod(mockCustomer.id); + + 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() + .mockResolvedValueOnce({ data: mockPaymentMethods }); + stripeMock.customers.retrieve = jest + .fn() + .mockResolvedValueOnce(mockCustomer); + + const result = await service.listPaymentMethods(mockCustomer.id); + + 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( + mockCustomer.id, + { type: 'card', limit: 100 }, + ); + }); + + it('should return empty array when no payment methods', async () => { + const mockCustomer = createMockCustomer(); + + stripeMock.customers.listPaymentMethods = jest + .fn() + .mockResolvedValueOnce({ data: [] }); + + stripeMock.customers.retrieve = jest + .fn() + .mockResolvedValueOnce(mockCustomer); + + const result = await service.listPaymentMethods(mockCustomer.id); + + 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() + .mockResolvedValueOnce({ data: mockPaymentMethods }); + stripeMock.customers.retrieve = jest + .fn() + .mockResolvedValueOnce(mockCustomer); + + const result = await service.listPaymentMethods(mockCustomer.id); + + 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() + .mockResolvedValueOnce(mockSetupIntent); + + const result = await service.retrieveCardSetup(mockSetupIntent.id); + + expect(result).toEqual({ + customerId: mockSetupIntent.customer, + paymentMethod: mockSetupIntent.payment_method, + }); + expect(stripeMock.setupIntents.retrieve).toHaveBeenCalledWith( + mockSetupIntent.id, + ); + }); + + it('should handle setup intent without customer', async () => { + const mockSetupIntent = createMockSetupIntent(); + (mockSetupIntent as any).customer = null; + + stripeMock.setupIntents.retrieve = jest + .fn() + .mockResolvedValueOnce(mockSetupIntent); + + const result = await service.retrieveCardSetup(mockSetupIntent.id); + + 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() + .mockResolvedValueOnce(mockSetupIntent); + + const result = await service.retrieveCardSetup(mockSetupIntent.id); + + expect(result).toEqual({ + customerId: mockSetupIntent.customer, + paymentMethod: null, + }); + }); + }); + + describe('updateCustomer', () => { + it('should update customer successfully', async () => { + const mockCustomer = createMockCustomer(); + stripeMock.customers.update = jest + .fn() + .mockResolvedValueOnce(mockCustomer); + + const updateData = { + name: 'Updated Name', + address: { + line1: '123 Street', + city: 'City', + }, + }; + + const result = await service.updateCustomer(mockCustomer.id, updateData); + + const { line1, city, country, postal_code } = mockCustomer.address; + + expect(result).toEqual({ + email: mockCustomer.email, + name: mockCustomer.name, + address: { + line1, + city, + country, + postalCode: postal_code, + }, + defaultPaymentMethod: + mockCustomer.invoice_settings.default_payment_method, + }); + + expect(stripeMock.customers.update).toHaveBeenCalledWith( + mockCustomer.id, + updateData, + ); + }); + }); + + 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() + .mockResolvedValueOnce(mockPaymentMethod); + + const result = await service.retrievePaymentMethod(mockPaymentMethod.id); + + expect(result).toEqual({ + 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( + mockPaymentMethod.id, + ); + }); + }); + + describe('detachPaymentMethod', () => { + it('should detach payment method successfully', async () => { + const mockPaymentMethod = createMockPaymentMethod(); + + stripeMock.paymentMethods.detach = jest + .fn() + .mockResolvedValueOnce(mockPaymentMethod); + + const result = await service.detachPaymentMethod(mockPaymentMethod.id); + + expect(result).toEqual({ + 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( + mockPaymentMethod.id, + ); + }); + }); + + describe('getReceiptUrl', () => { + it('should return receipt URL for valid payment', async () => { + const customerId = faker.string.uuid(); + + const mockPaymentIntent = createMockPaymentIntent({ + customer: customerId, + latest_charge: 'ch_123', + }); + const mockCharge = { + receipt_url: faker.internet.email(), + }; + + stripeMock.paymentIntents.retrieve = jest + .fn() + .mockResolvedValueOnce(mockPaymentIntent); + stripeMock.charges.retrieve = jest.fn().mockResolvedValueOnce(mockCharge); + + const result = await service.getReceiptUrl( + mockPaymentIntent.id, + customerId, + ); + + 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() + .mockResolvedValueOnce(null); + + 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({ + latest_charge: faker.string.uuid(), + }); + + const mockCharge = { + receipt_url: null, + }; + + stripeMock.charges.retrieve = jest.fn().mockResolvedValueOnce(mockCharge); + + stripeMock.paymentIntents.retrieve = jest + .fn() + .mockResolvedValueOnce(mockPaymentIntent); + + 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: customerId, + latest_charge: faker.string.uuid(), + }); + const mockCharge = createMockCharge({ + receipt_url: null, + }); + + stripeMock.paymentIntents.retrieve = jest + .fn() + .mockResolvedValueOnce(mockPaymentIntent); + stripeMock.charges.retrieve = jest.fn().mockResolvedValueOnce(mockCharge); + + await expect( + service.getReceiptUrl(mockPaymentIntent.id, customerId), + ).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 = createMockCustomer(); + const mockTaxIds = [ + { + id: 'txi_123', + type: VatType.EU_VAT, + value: 'DE123456789', + }, + ]; + + stripeMock.customers.retrieve = jest + .fn() + .mockResolvedValueOnce(mockCustomer); + stripeMock.customers.listTaxIds = jest + .fn() + .mockResolvedValueOnce({ data: mockTaxIds }); + + const result = await service.retrieveBillingInfo(mockCustomer.id); + + expect(result).toEqual({ + name: mockCustomer.name, + email: mockCustomer.email, + address: { + line: mockCustomer.address.line1, + city: mockCustomer.address.city, + country: mockCustomer.address.country.toLowerCase(), + postalCode: mockCustomer.address.postal_code, + }, + vat: 'DE123456789', + vatType: VatType.EU_VAT, + }); + }); + + it('should return partial billing info when some data is missing', async () => { + const mockCustomer = createMockCustomer({ + address: undefined, + }); + + stripeMock.customers.retrieve = jest + .fn() + .mockResolvedValueOnce(mockCustomer); + stripeMock.customers.listTaxIds = jest + .fn() + .mockResolvedValueOnce({ data: [] }); + + const result = await service.retrieveBillingInfo(mockCustomer.id); + + expect(result).toEqual({ + name: mockCustomer.name, + email: mockCustomer.email, + address: undefined, + vat: undefined, + vatType: undefined, + }); + }); + }); + + describe('updateBillingInfo', () => { + it('should update all billing information', async () => { + const mockTaxId = createMockTaxId(); + + const mockExistingTaxIds = [mockTaxId]; + + const mockUpdatedCustomer = createMockCustomer(); + + stripeMock.customers.listTaxIds = jest + .fn() + .mockResolvedValueOnce({ data: mockExistingTaxIds }); + stripeMock.customers.deleteTaxId = jest.fn().mockResolvedValueOnce({}); + stripeMock.customers.createTaxId = jest + .fn() + .mockResolvedValueOnce(mockTaxId); + stripeMock.customers.update = jest + .fn() + .mockResolvedValueOnce(mockUpdatedCustomer); + + const mockUpdateBillingInfo = createMockBillingInfoDto(); + const { name, email, address } = mockUpdateBillingInfo; + const { city, country, postalCode, line } = address ?? {}; + + await service.updateBillingInfo( + mockUpdatedCustomer.id, + mockUpdateBillingInfo, + ); + + expect(stripeMock.customers.deleteTaxId).toHaveBeenCalledWith( + mockUpdatedCustomer.id, + mockExistingTaxIds[0].id, + ); + 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 mockTaxId = createMockTaxId(); + + const mockExistingTaxIds = [mockTaxId]; + + const mockUpdatedCustomer = createMockCustomer(); + + stripeMock.customers.listTaxIds = jest + .fn() + .mockResolvedValueOnce({ data: mockExistingTaxIds }); + stripeMock.customers.deleteTaxId = jest.fn().mockResolvedValueOnce({}); + stripeMock.customers.update = jest + .fn() + .mockResolvedValueOnce(mockUpdatedCustomer); + + const mockUpdateBillingInfo = createMockBillingInfoDto({ + vat: undefined, + vatType: undefined, + }); + + const { name, email, address } = mockUpdateBillingInfo; + const { city, country, postalCode, line } = address ?? {}; + + await service.updateBillingInfo( + mockUpdatedCustomer.id, + mockUpdateBillingInfo, + ); + + expect(stripeMock.customers.deleteTaxId).toHaveBeenCalledWith( + mockUpdatedCustomer.id, + mockTaxId.id, + ); + expect(stripeMock.customers.createTaxId).not.toHaveBeenCalled(); + 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 new file mode 100644 index 0000000000..b40a39d655 --- /dev/null +++ b/packages/apps/job-launcher/server/src/modules/payment/providers/stripe/stripe.service.ts @@ -0,0 +1,427 @@ +import { Injectable } from '@nestjs/common'; +import Stripe from 'stripe'; +import { PaymentProviderConfigService } from '../../../../common/config/payment-provider-config.service'; +import { NotFoundError, ServerError } from '../../../../common/errors'; +import { ErrorPayment } from '../../../../common/constants/errors'; +import { PaymentStatus, VatType } from '../../../../common/enums/payment'; +import { + CardSetup, + CustomerData, + Invoice, + PaymentData, + PaymentMethod, + TaxId, +} from '../../payment.interface'; +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: PaymentProviderConfigService) { + super(); + + 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 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, + 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 { + id: invoice.id, + paymentId: invoice.payment_intent as string, + status: invoice.status?.toString(), + amountDue: invoice.amount_due, + currency: invoice.currency, + }; + } + + async assignPaymentMethod( + 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.retrievePaymentIntent(paymentIntentId); + + if (!paymentIntent?.clientSecret) { + throw new ServerError(ErrorPayment.ClientSecretDoesNotExist); + } + + return paymentIntent; + } + + 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.latestCharge 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.postalCode 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); + + 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, + postalCode: data.address?.postalCode, + }, + name: data.name, + email: data.email, + }); + } + + return this.retrieveCustomer(customerId); + } + + async retrievePaymentIntent(paymentIntentId: string): Promise { + const paymentIntent = + await this.stripe.paymentIntents.retrieve(paymentIntentId); + + if (!paymentIntent) { + 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, + clientSecret: paymentIntent.client_secret, + status, + amount: paymentIntent.amount, + amountReceived: paymentIntent.amount_received, + currency: paymentIntent.currency, + latestCharge: paymentIntent.latest_charge as string, + }; + } + + async getDefaultPaymentMethod(customerId: string): Promise { + const customer = await this.retrieveCustomer(customerId); + return customer.defaultPaymentMethod ?? null; + } + + async listPaymentMethods(customerId: string): Promise { + const paymentMethods = await this.stripe.customers.listPaymentMethods( + customerId, + { type: 'card', limit: 100 }, + ); + + 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 { + 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 { + 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: defaultPaymentMethod === paymentMethod.id, + }; + } + + async updateCustomer( + customerId: string, + data: Partial, + ): Promise { + const { email, name, address, defaultPaymentMethod } = data; + const { line1, city, country, postalCode } = address ?? {}; + + const updatePayload = defaultPaymentMethod + ? { + invoice_settings: { + default_payment_method: data.defaultPaymentMethod, + }, + } + : { + email, + name, + address: { + line1, + city, + country, + postal_code: postalCode, + }, + }; + + const customer = await this.stripe.customers.update( + customerId, + updatePayload, + ); + + 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, + postalCode: customer.address.postal_code ?? undefined, + } + : undefined, + defaultPaymentMethod: customer.invoice_settings + ? (customer.invoice_settings.default_payment_method as string) + : undefined, + }; + } + + 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, + postalCode: customer.address.postal_code ?? undefined, + } + : undefined, + defaultPaymentMethod: customer.invoice_settings + ? (customer.invoice_settings.default_payment_method as string) + : undefined, + }; + } + + private 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, + })); + } + + private 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, + }; + } + + private async deleteTaxId( + customerId: string, + taxIdId: string, + ): Promise { + await this.stripe.customers.deleteTaxId(customerId, taxIdId); + } + + async retrieveCardSetup(setupIntentId: string): Promise { + const setupIntent = await this.stripe.setupIntents.retrieve(setupIntentId); + + return { + customerId: setupIntent.customer as string, + paymentMethod: setupIntent.payment_method as 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); + } + return { receipt_url: charge.receipt_url }; + } +} 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[]; 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,