From 2f5e9a4d084dfb3a1c84694143f8f4b56b11676d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Wed, 4 Sep 2024 13:21:52 +0200 Subject: [PATCH 01/11] Abuse system integration in Job Launcher server --- .../server/src/common/config/env-schema.ts | 1 + .../common/config/server-config.service.ts | 3 + .../server/src/common/constants/errors.ts | 6 +- .../server/src/common/enums/cron-job.ts | 1 + .../server/src/common/enums/payment.ts | 1 + .../server/src/common/enums/webhook.ts | 1 + .../server/src/database/database.module.ts | 2 + .../modules/cron-job/cron-job.service.spec.ts | 147 ++++++++- .../src/modules/cron-job/cron-job.service.ts | 71 +++- .../modules/payment/payment-info.entity.ts | 26 ++ .../payment/payment-info.repository.ts | 19 ++ .../src/modules/payment/payment.controller.ts | 54 +++ .../server/src/modules/payment/payment.dto.ts | 6 + .../src/modules/payment/payment.module.ts | 18 +- .../modules/payment/payment.service.spec.ts | 311 ++++++++++++++++-- .../src/modules/payment/payment.service.ts | 153 ++++++++- .../server/src/modules/user/user.entity.ts | 4 + .../webhook/webhook.controller.spec.ts | 5 + .../src/modules/webhook/webhook.module.ts | 6 +- .../src/modules/webhook/webhook.repository.ts | 9 +- .../modules/webhook/webhook.service.spec.ts | 66 ++++ .../src/modules/webhook/webhook.service.ts | 29 ++ 22 files changed, 903 insertions(+), 36 deletions(-) create mode 100644 packages/apps/job-launcher/server/src/modules/payment/payment-info.entity.ts create mode 100644 packages/apps/job-launcher/server/src/modules/payment/payment-info.repository.ts 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 5e3681d8ba..eb9efb4ce9 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 @@ -8,6 +8,7 @@ export const envValidator = Joi.object({ FE_URL: Joi.string(), MAX_RETRY_COUNT: Joi.number(), MINIMUM_FEE_USD: Joi.number(), + ABUSE_AMOUNT: Joi.number(), // Auth JWT_PRIVATE_KEY: Joi.string().required(), JWT_PUBLIC_KEY: Joi.string().required(), diff --git a/packages/apps/job-launcher/server/src/common/config/server-config.service.ts b/packages/apps/job-launcher/server/src/common/config/server-config.service.ts index 660f6ab2ca..a540c50610 100644 --- a/packages/apps/job-launcher/server/src/common/config/server-config.service.ts +++ b/packages/apps/job-launcher/server/src/common/config/server-config.service.ts @@ -31,4 +31,7 @@ export class ServerConfigService { get coingeckoApiKey(): string { return this.configService.get('COINGECKO_API_KEY', ''); } + get abuseAmount(): number { + return +this.configService.get('ABUSE_AMOUNT', 10000); + } } diff --git a/packages/apps/job-launcher/server/src/common/constants/errors.ts b/packages/apps/job-launcher/server/src/common/constants/errors.ts index 48daeba626..1d2753d745 100644 --- a/packages/apps/job-launcher/server/src/common/constants/errors.ts +++ b/packages/apps/job-launcher/server/src/common/constants/errors.ts @@ -32,6 +32,7 @@ export enum ErrorWebhook { NotFound = 'Webhook not found', UrlNotFound = 'Webhook URL not found', NotCreated = 'Webhook has not been created', + InvalidEscrow = 'Invalid escrow data provided', } /** @@ -89,7 +90,10 @@ export enum ErrorPayment { NotFound = 'Payment not found', NotSuccess = 'Unsuccessful payment', IntentNotCreated = 'Payment intent not created', - ClientSecretDoesNotExist = 'Payment intent was not created', + CardNotAssigned = 'Card not assigned', + CardAssigned = 'User already has a card assigned', + SetupNotFound = 'Setup not found', + ClientSecretDoesNotExist = 'Client secret does not exist', CustomerNotFound = 'Customer not found', CustomerNotCreated = 'Customer not created', IncorrectAmount = 'Incorrect amount', diff --git a/packages/apps/job-launcher/server/src/common/enums/cron-job.ts b/packages/apps/job-launcher/server/src/common/enums/cron-job.ts index 38d17f9cf7..83cdca59d7 100644 --- a/packages/apps/job-launcher/server/src/common/enums/cron-job.ts +++ b/packages/apps/job-launcher/server/src/common/enums/cron-job.ts @@ -5,4 +5,5 @@ export enum CronJobType { CancelEscrow = 'cancel-escrow', ProcessPendingWebhook = 'process-pending-webhook', SyncJobStatuses = 'sync-job-statuses', + Abuse = 'abuse', } 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 dbaf0d9dc6..96f50a50fd 100644 --- a/packages/apps/job-launcher/server/src/common/enums/payment.ts +++ b/packages/apps/job-launcher/server/src/common/enums/payment.ts @@ -62,6 +62,7 @@ export enum PaymentType { DEPOSIT = 'DEPOSIT', REFUND = 'REFUND', WITHDRAWAL = 'WITHDRAWAL', + SLASH = 'SLASH', } export enum PaymentStatus { diff --git a/packages/apps/job-launcher/server/src/common/enums/webhook.ts b/packages/apps/job-launcher/server/src/common/enums/webhook.ts index dd9e38da7e..3c77ddd3fc 100644 --- a/packages/apps/job-launcher/server/src/common/enums/webhook.ts +++ b/packages/apps/job-launcher/server/src/common/enums/webhook.ts @@ -4,6 +4,7 @@ export enum EventType { ESCROW_COMPLETED = 'escrow_completed', TASK_CREATION_FAILED = 'task_creation_failed', ESCROW_FAILED = 'escrow_failed', + ABUSE = 'abuse', } export enum OracleType { diff --git a/packages/apps/job-launcher/server/src/database/database.module.ts b/packages/apps/job-launcher/server/src/database/database.module.ts index 776c456a8c..91865b165c 100644 --- a/packages/apps/job-launcher/server/src/database/database.module.ts +++ b/packages/apps/job-launcher/server/src/database/database.module.ts @@ -15,6 +15,7 @@ import { ApiKeyEntity } from '../modules/auth/apikey.entity'; import { WebhookEntity } from '../modules/webhook/webhook.entity'; import { LoggerOptions } from 'typeorm'; import { CronJobEntity } from '../modules/cron-job/cron-job.entity'; +import { PaymentInfoEntity } from '../modules/payment/payment-info.entity'; @Module({ imports: [ @@ -47,6 +48,7 @@ import { CronJobEntity } from '../modules/cron-job/cron-job.entity'; PaymentEntity, WebhookEntity, CronJobEntity, + PaymentInfoEntity, ], // We are using migrations, synchronize should be set to false. synchronize: false, diff --git a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.spec.ts b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.spec.ts index c95d222c95..f796991977 100644 --- a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.spec.ts @@ -78,6 +78,7 @@ describe('CronJobService', () => { webhookRepository: WebhookRepository, storageService: StorageService, jobService: JobService, + paymentService: PaymentService, jobRepository: JobRepository; const signerMock = { @@ -150,6 +151,7 @@ describe('CronJobService', () => { }).compile(); service = module.get(CronJobService); + paymentService = module.get(PaymentService); jobService = module.get(JobService); jobRepository = module.get(JobRepository); repository = module.get(CronJobRepository); @@ -853,7 +855,7 @@ describe('CronJobService', () => { }; jest - .spyOn(webhookRepository, 'findByStatus') + .spyOn(webhookRepository, 'findByStatusAndType') .mockResolvedValue([webhookEntity1 as any, webhookEntity2 as any]); sendWebhookMock = jest.spyOn(webhookService as any, 'sendWebhook'); @@ -1046,6 +1048,149 @@ describe('CronJobService', () => { await service.syncJobStuses(); + expect(service.completeCronJob).toHaveBeenCalledWith( + cronJobEntityMock as any, + ); + }); + }); + describe('processAbuseCronJob', () => { + let sendWebhookMock: any; + let cronJobEntityMock: Partial; + let webhookEntity: Partial, jobEntity: Partial; + + beforeEach(() => { + cronJobEntityMock = { + cronJobType: CronJobType.Abuse, + startedAt: new Date(), + }; + + webhookEntity = { + id: 1, + chainId: ChainId.LOCALHOST, + escrowAddress: MOCK_ADDRESS, + status: WebhookStatus.PENDING, + waitUntil: new Date(), + retriesCount: 0, + }; + + jobEntity = { + id: 1, + chainId: ChainId.LOCALHOST, + escrowAddress: MOCK_ADDRESS, + status: JobStatus.PENDING, + }; + + jest + .spyOn(webhookRepository, 'findByStatusAndType') + .mockResolvedValue([webhookEntity as any]); + + sendWebhookMock = jest.spyOn(webhookService as any, 'sendWebhook'); + sendWebhookMock.mockResolvedValue(true); + + jest.spyOn(service, 'isCronJobRunning').mockResolvedValue(false); + + jest.spyOn(repository, 'findOneByType').mockResolvedValue(null); + jest + .spyOn(repository, 'createUnique') + .mockResolvedValue(cronJobEntityMock as any); + jest + .spyOn(jobRepository, 'findOneByChainIdAndEscrowAddress') + .mockResolvedValue(jobEntity as any); + jest + .spyOn(jobService, 'processEscrowCancellation') + .mockResolvedValue(null as any); + jest.spyOn(paymentService, 'createSlash').mockResolvedValue(null as any); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should not run if cron job is already running', async () => { + jest.spyOn(service, 'isCronJobRunning').mockResolvedValueOnce(true); + + const startCronJobMock = jest.spyOn(service, 'startCronJob'); + + await service.processAbuse(); + + expect(startCronJobMock).not.toHaveBeenCalled(); + }); + + it('should create cron job entity to lock the process', async () => { + jest + .spyOn(service, 'startCronJob') + .mockResolvedValueOnce(cronJobEntityMock as any); + + await service.processAbuse(); + + expect(service.startCronJob).toHaveBeenCalledWith(CronJobType.Abuse); + }); + + it('should slash for all of the pending webhooks', async () => { + await service.processAbuse(); + + expect(jobRepository.updateOne).toHaveBeenCalled(); + expect(jobEntity.status).toBe(JobStatus.CANCELED); + expect(webhookRepository.updateOne).toHaveBeenCalled(); + expect(webhookEntity.status).toBe(WebhookStatus.COMPLETED); + }); + + it('should increase retriesCount by 1 if no job is found', async () => { + jest + .spyOn(jobRepository, 'findOneByChainIdAndEscrowAddress') + .mockResolvedValue(null); + await service.processAbuse(); + + expect(webhookRepository.updateOne).toHaveBeenCalled(); + expect(webhookEntity.status).toBe(WebhookStatus.PENDING); + expect(webhookEntity.retriesCount).toBe(1); + expect(webhookEntity.waitUntil).toBeInstanceOf(Date); + }); + + it('should increase retriesCount by 1 if processEscrowCancellation fails', async () => { + jest + .spyOn(jobService, 'processEscrowCancellation') + .mockRejectedValueOnce(new Error()); + await service.processAbuse(); + + expect(webhookRepository.updateOne).toHaveBeenCalled(); + expect(webhookEntity.status).toBe(WebhookStatus.PENDING); + expect(webhookEntity.retriesCount).toBe(1); + expect(webhookEntity.waitUntil).toBeInstanceOf(Date); + }); + + it('should increase retriesCount by 1 if createSlash fails', async () => { + jest + .spyOn(paymentService, 'createSlash') + .mockRejectedValueOnce(new Error()); + await service.processAbuse(); + + expect(webhookRepository.updateOne).toHaveBeenCalled(); + expect(webhookEntity.status).toBe(WebhookStatus.PENDING); + expect(webhookEntity.retriesCount).toBe(1); + expect(webhookEntity.waitUntil).toBeInstanceOf(Date); + }); + + it('should mark webhook as failed if retriesCount exceeds threshold', async () => { + jest + .spyOn(jobService, 'processEscrowCancellation') + .mockRejectedValueOnce(new Error()); + + webhookEntity.retriesCount = MOCK_MAX_RETRY_COUNT; + + await service.processAbuse(); + + expect(webhookRepository.updateOne).toHaveBeenCalled(); + expect(webhookEntity.status).toBe(WebhookStatus.FAILED); + }); + + it('should complete the cron job entity to unlock', async () => { + jest + .spyOn(service, 'completeCronJob') + .mockResolvedValueOnce(cronJobEntityMock as any); + + await service.processAbuse(); + expect(service.completeCronJob).toHaveBeenCalledWith( cronJobEntityMock as any, ); diff --git a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts index cff79ccd85..0e8ac33a31 100644 --- a/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts +++ b/packages/apps/job-launcher/server/src/modules/cron-job/cron-job.service.ts @@ -1,7 +1,11 @@ import { HttpStatus, Injectable, Logger } from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; import { CronJobType } from '../../common/enums/cron-job'; -import { ErrorCronJob, ErrorEscrow } from '../../common/constants/errors'; +import { + ErrorCronJob, + ErrorEscrow, + ErrorJob, +} from '../../common/constants/errors'; import { CronJobEntity } from './cron-job.entity'; import { CronJobRepository } from './cron-job.repository'; @@ -290,8 +294,9 @@ export class CronJobService { const cronJob = await this.startCronJob(CronJobType.ProcessPendingWebhook); try { - const webhookEntities = await this.webhookRepository.findByStatus( + const webhookEntities = await this.webhookRepository.findByStatusAndType( WebhookStatus.PENDING, + EventType.ESCROW_CREATED, ); for (const webhookEntity of webhookEntities) { @@ -313,6 +318,68 @@ export class CronJobService { await this.completeCronJob(cronJob); } + @Cron('*/5 * * * *') + /** + * Process an abuse webhook. + * @returns {Promise} - Returns a promise that resolves when the operation is complete. + */ + public async processAbuse(): Promise { + const isCronJobRunning = await this.isCronJobRunning(CronJobType.Abuse); + + if (isCronJobRunning) { + return; + } + + this.logger.log('Abuse START'); + const cronJob = await this.startCronJob(CronJobType.Abuse); + + try { + const webhookEntities = await this.webhookRepository.findByStatusAndType( + WebhookStatus.PENDING, + EventType.ABUSE, + ); + + for (const webhookEntity of webhookEntities) { + try { + const jobEntity = + await this.jobRepository.findOneByChainIdAndEscrowAddress( + webhookEntity.chainId, + webhookEntity.escrowAddress, + ); + if (!jobEntity) { + this.logger.log(ErrorJob.NotFound, JobService.name); + throw new ControlledError( + ErrorJob.NotFound, + HttpStatus.BAD_REQUEST, + ); + } + if ( + jobEntity.escrowAddress && + jobEntity.status !== JobStatus.CANCELED + ) { + await this.jobService.processEscrowCancellation(jobEntity); + jobEntity.status = JobStatus.CANCELED; + await this.jobRepository.updateOne(jobEntity); + } + await this.paymentService.createSlash(jobEntity); + } catch (err) { + this.logger.error( + `Error slashing escrow (address: ${webhookEntity.escrowAddress}, chainId: ${webhookEntity.chainId}: ${err.message}`, + ); + await this.webhookService.handleWebhookError(webhookEntity); + continue; + } + webhookEntity.status = WebhookStatus.COMPLETED; + await this.webhookRepository.updateOne(webhookEntity); + } + } catch (e) { + this.logger.error(e); + } + + this.logger.log('Abuse STOP'); + await this.completeCronJob(cronJob); + } + @Cron('30 */2 * * * *') /** * Process a job that syncs job statuses. diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment-info.entity.ts b/packages/apps/job-launcher/server/src/modules/payment/payment-info.entity.ts new file mode 100644 index 0000000000..638ff3ceeb --- /dev/null +++ b/packages/apps/job-launcher/server/src/modules/payment/payment-info.entity.ts @@ -0,0 +1,26 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; +import { NS } from '../../common/constants'; +import { BaseEntity } from '../../database/base.entity'; +import { UserEntity } from '../user/user.entity'; + +@Entity({ schema: NS, name: 'payments-info' }) +@Index(['userId'], { + unique: true, +}) +@Index(['customerId'], { + unique: true, +}) +export class PaymentInfoEntity extends BaseEntity { + @Column({ type: 'varchar' }) + public customerId: string; + + @Column({ type: 'varchar' }) + public paymentMethodId: string; + + @JoinColumn() + @ManyToOne(() => UserEntity, (user) => user.payments) + public user: UserEntity; + + @Column({ type: 'int' }) + public userId: number; +} diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment-info.repository.ts b/packages/apps/job-launcher/server/src/modules/payment/payment-info.repository.ts new file mode 100644 index 0000000000..81f1df6406 --- /dev/null +++ b/packages/apps/job-launcher/server/src/modules/payment/payment-info.repository.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { BaseRepository } from '../../database/base.repository'; +import { PaymentInfoEntity } from './payment-info.entity'; + +@Injectable() +export class PaymentInfoRepository extends BaseRepository { + constructor(private dataSource: DataSource) { + super(PaymentInfoEntity, dataSource); + } + + public findOneByUser(userId: number): Promise { + return this.findOne({ + where: { + userId, + }, + }); + } +} diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts index 5e7bb18af8..163d1d58c2 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts @@ -20,6 +20,7 @@ import { JwtAuthGuard } from '../../common/guards'; import { RequestWithUser } from '../../common/types'; import { + CardConfirmDto, GetRateDto, PaymentCryptoCreateDto, PaymentFiatConfirmDto, @@ -42,6 +43,59 @@ export class PaymentController { private readonly rateService: RateService, ) {} + @ApiOperation({ + summary: 'Assign a card to a user', + description: 'Endpoint to assign a card to an user.', + }) + @ApiResponse({ + status: 200, + description: 'Payment created successfully', + type: String, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized. Missing or invalid credentials.', + }) + @ApiResponse({ + status: 404, + description: 'Not Found. Could not find the requested content.', + }) + @Post('/fiat/setup-card') + public async assignCard(@Request() req: RequestWithUser): Promise { + return this.paymentService.createCustomerAndAssignCard(req.user); + } + + @ApiOperation({ + summary: 'Confirm a card', + description: + 'Endpoint to confirm that a card was successfully assigned to an user.', + }) + @ApiBody({ type: PaymentFiatConfirmDto }) + @ApiResponse({ + status: 200, + description: 'Card confirmed successfully', + type: Boolean, + }) + @ApiResponse({ + status: 400, + description: 'Bad Request. Invalid input parameters.', + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized. Missing or invalid credentials.', + }) + @ApiResponse({ + status: 404, + description: 'Not Found. Could not find the requested content.', + }) + @Post('/fiat/confirm-card') + public async confirmSetupCard( + @Request() req: RequestWithUser, + @Body() data: CardConfirmDto, + ): Promise { + return this.paymentService.confirmCard(req.user, data); + } + @ApiOperation({ summary: 'Create a fiat payment', description: 'Endpoint to create a new fiat payment.', diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts index aa796c98ef..68a9fc421f 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts @@ -35,6 +35,12 @@ export class PaymentCryptoCreateDto { public transactionHash: string; } +export class CardConfirmDto { + @ApiProperty({ name: 'payment_id' }) + @IsString() + public setupId: string; +} + export class GetRateDto { @ApiProperty() @IsString() 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 1951a389ea..8b81423472 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 @@ -10,11 +10,13 @@ import { PaymentRepository } from './payment.repository'; import { HttpModule } from '@nestjs/axios'; import { Web3Module } from '../web3/web3.module'; import { RateService } from './rate.service'; +import { PaymentInfoEntity } from './payment-info.entity'; +import { PaymentInfoRepository } from './payment-info.repository'; @Module({ imports: [ HttpModule, - TypeOrmModule.forFeature([PaymentEntity]), + TypeOrmModule.forFeature([PaymentEntity, PaymentInfoEntity]), ConfigModule, Web3Module, MinioModule.registerAsync({ @@ -32,7 +34,17 @@ import { RateService } from './rate.service'; }), ], controllers: [PaymentController], - providers: [PaymentService, PaymentRepository, RateService], - exports: [PaymentService, PaymentRepository, RateService], + providers: [ + PaymentService, + PaymentRepository, + RateService, + PaymentInfoRepository, + ], + exports: [ + PaymentService, + PaymentRepository, + RateService, + PaymentInfoRepository, + ], }) export class PaymentModule {} diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts index f9480ac1ab..428a0882d8 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 @@ -26,6 +26,8 @@ import { MOCK_SIGNATURE, MOCK_TRANSACTION_HASH, } from '../../../test/constants'; +import { PaymentInfoRepository } from './payment-info.repository'; +import { ServerConfigService } from '../../common/config/server-config.service'; import { Web3Service } from '../web3/web3.service'; import { HMToken__factory } from '@human-protocol/core/typechain-types'; import { ChainId, NETWORKS } from '@human-protocol/sdk'; @@ -52,6 +54,7 @@ describe('PaymentService', () => { let stripe: Stripe; let paymentService: PaymentService; let paymentRepository: PaymentRepository; + let paymentInfoRepository: PaymentInfoRepository; const signerMock = { address: MOCK_ADDRESS, @@ -88,6 +91,10 @@ describe('PaymentService', () => { provide: PaymentRepository, useValue: createMock(), }, + { + provide: PaymentInfoRepository, + useValue: createMock(), + }, { provide: Web3Service, useValue: { @@ -99,37 +106,31 @@ describe('PaymentService', () => { { provide: HttpService, useValue: createMock() }, { provide: RateService, useValue: createMock() }, NetworkConfigService, + ServerConfigService, ], }).compile(); paymentService = moduleRef.get(PaymentService); paymentRepository = moduleRef.get(PaymentRepository); - const stripeCustomersCreateMock = jest.fn(); - const stripePaymentIntentsCreateMock = jest.fn(); - const stripePaymentIntentsRetrieveMock = jest.fn(); + paymentInfoRepository = moduleRef.get(PaymentInfoRepository); stripe = { customers: { - create: stripeCustomersCreateMock, + create: jest.fn(), + update: jest.fn(), }, paymentIntents: { - create: stripePaymentIntentsCreateMock, - retrieve: stripePaymentIntentsRetrieveMock, + create: jest.fn(), + retrieve: jest.fn(), + }, + setupIntents: { + create: jest.fn(), + retrieve: jest.fn(), }, } as any; paymentService['stripe'] = stripe; - - jest - .spyOn(stripe.customers, 'create') - .mockImplementation(stripeCustomersCreateMock); - jest - .spyOn(stripe.paymentIntents, 'create') - .mockImplementation(stripePaymentIntentsCreateMock); - jest - .spyOn(stripe.paymentIntents, 'retrieve') - .mockImplementation(stripePaymentIntentsRetrieveMock); }); describe('createFiatPayment', () => { @@ -151,7 +152,13 @@ describe('PaymentService', () => { currency: Currency.USD, }; - const userId = 1; + const user = { + id: 1, + paymentInfo: { + customerId: 'test', + paymentMethodId: 'test', + }, + }; const paymentIntent = { client_secret: 'clientSecret123', @@ -160,11 +167,14 @@ describe('PaymentService', () => { createPaymentIntentMock.mockResolvedValue(paymentIntent); findOneMock.mockResolvedValue(null); - const result = await paymentService.createFiatPayment(userId, dto); + const result = await paymentService.createFiatPayment(user as any, dto); expect(createPaymentIntentMock).toHaveBeenCalledWith({ amount: dto.amount * 100, currency: dto.currency, + customer: 'test', + off_session: false, + payment_method: 'test', }); expect(result).toEqual(paymentIntent.client_secret); }); @@ -176,7 +186,13 @@ describe('PaymentService', () => { currency: Currency.USD, }; - const userId = 1; + const user = { + id: 1, + paymentInfo: { + customerId: 'test', + paymentMethodId: 'test', + }, + }; const paymentIntent = { client_secret: 'clientSecret123', @@ -188,7 +204,7 @@ describe('PaymentService', () => { } as PaymentEntity); await expect( - paymentService.createFiatPayment(userId, dto), + paymentService.createFiatPayment(user as any, dto), ).rejects.toThrow( new ControlledError( ErrorPayment.TransactionAlreadyExists, @@ -204,12 +220,18 @@ describe('PaymentService', () => { currency: Currency.USD, }; - const userId = 1; + const user = { + id: 1, + paymentInfo: { + customerId: 'test', + paymentMethodId: 'test', + }, + }; createPaymentIntentMock.mockResolvedValue(); await expect( - paymentService.createFiatPayment(userId, dto), + paymentService.createFiatPayment(user as any, dto), ).rejects.toThrow( new ControlledError( ErrorPayment.ClientSecretDoesNotExist, @@ -792,4 +814,249 @@ describe('PaymentService', () => { ).rejects.toThrow(new DatabaseError('', '')); }); }); + describe('createCustomerAndAssignCard', () => { + it('should create a customer and assign a card successfully', async () => { + const user = { + id: 1, + email: 'test@hmt.ai', + }; + + const paymentIntent = { + client_secret: 'clientSecret123', + }; + + jest + .spyOn(stripe.customers, 'create') + .mockResolvedValue({ id: 1 } as any); + jest + .spyOn(stripe.setupIntents, 'create') + .mockResolvedValue(paymentIntent as any); + + 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: 1, + }); + }); + + it('should throw a bad request exception if user payment info already exist', async () => { + const user = { + id: 1, + email: 'test@hmt.ai', + paymentInfo: { + customerId: 'test', + paymentMethodId: 'test', + }, + }; + + await expect( + paymentService.createCustomerAndAssignCard(user as any), + ).rejects.toThrow(ErrorPayment.CardAssigned); + }); + + it('should throw a bad request exception if the customer creation fails', async () => { + const user = { + id: 1, + email: 'test@hmt.ai', + }; + + jest.spyOn(stripe.customers, 'create').mockRejectedValue(new Error()); + + await expect( + paymentService.createCustomerAndAssignCard(user as any), + ).rejects.toThrow(ErrorPayment.CardNotAssigned); + }); + + it('should throw a bad request exception if the setup intent creation fails', async () => { + const user = { + id: 1, + email: 'test@hmt.ai', + }; + + jest + .spyOn(stripe.customers, 'create') + .mockResolvedValue({ id: 1 } as any); + + jest.spyOn(stripe.setupIntents, 'create').mockRejectedValue(new Error()); + + await expect( + paymentService.createCustomerAndAssignCard(user as any), + ).rejects.toThrow(ErrorPayment.CardNotAssigned); + }); + + it('should throw a bad request exception if the client secret does not exists', async () => { + const user = { + id: 1, + email: 'test@hmt.ai', + }; + + jest + .spyOn(stripe.customers, 'create') + .mockResolvedValue({ id: 1 } as any); + jest + .spyOn(stripe.setupIntents, 'create') + .mockResolvedValue(undefined as any); + + await expect( + paymentService.createCustomerAndAssignCard(user as any), + ).rejects.toThrow(ErrorPayment.ClientSecretDoesNotExist); + }); + }); + + describe('confirmCard', () => { + it('should confirm a card and create payment info successfully', async () => { + const user = { + id: 1, + email: 'test@hmt.ai', + }; + + const setupMock = { + customer: 1, + payment_method: 1, + }; + + jest + .spyOn(stripe.setupIntents, 'retrieve') + .mockResolvedValue(setupMock as any); + jest.spyOn(stripe.customers, 'update').mockResolvedValue(null as any); + + const result = await paymentService.confirmCard(user as any, { + setupId: '1', + }); + + expect(result).toBeTruthy(); + expect(paymentInfoRepository.createUnique).toHaveBeenCalledWith({ + user: user, + customerId: setupMock.customer, + paymentMethodId: setupMock.payment_method, + }); + expect(stripe.setupIntents.retrieve).toHaveBeenCalledWith('1'); + expect(stripe.customers.update).toHaveBeenCalledWith(setupMock.customer, { + invoice_settings: { + default_payment_method: setupMock.payment_method, + }, + }); + }); + + it('should fail if setupId is not in Stripe', async () => { + const user = { + id: 1, + email: 'test@hmt.ai', + }; + + jest + .spyOn(stripe.setupIntents, 'retrieve') + .mockResolvedValue(undefined as any); + + await expect( + paymentService.confirmCard(user as any, { + setupId: '1', + }), + ).rejects.toThrow(ErrorPayment.SetupNotFound); + }); + }); + + describe('createSlash', () => { + it('should charge user credit card and create slash payments successfully', async () => { + const user = { + id: 1, + email: 'test@hmt.ai', + }; + + const jobEntity = { + id: 1, + user: user, + userId: user.id, + }; + + const paymentInfo = { + customerId: 'test', + paymentMethodId: 'test', + }; + + const paymentIntent = { + id: 1, + client_secret: 'clientSecret123', + }; + + jest + .spyOn(paymentInfoRepository, 'findOneByUser') + .mockResolvedValue(paymentInfo as any); + + jest + .spyOn(stripe.paymentIntents, 'create') + .mockResolvedValue(paymentIntent as any); + + const result = await paymentService.createSlash(jobEntity as any); + + expect(result).toBe(undefined); + expect(stripe.paymentIntents.create).toHaveBeenCalledWith({ + amount: expect.any(Number), + currency: Currency.USD, + customer: paymentInfo.customerId, + payment_method: paymentInfo.paymentMethodId, + off_session: true, + confirm: true, + }); + expect(paymentRepository.createUnique).toHaveBeenCalledTimes(2); + }); + + it('should fail if user does not have payment info', async () => { + const user = { + id: 1, + email: 'test@hmt.ai', + }; + + const jobEntity = { + id: 1, + user: user, + userId: user.id, + }; + + jest + .spyOn(paymentInfoRepository, 'findOneByUser') + .mockResolvedValue(null); + + await expect( + paymentService.createSlash(jobEntity as any), + ).rejects.toThrow(ErrorPayment.CustomerNotFound); + }); + + it('should fail if stripe create payment intent fails', async () => { + const user = { + id: 1, + email: 'test@hmt.ai', + }; + + const jobEntity = { + id: 1, + user: user, + userId: user.id, + }; + + const paymentInfo = { + customerId: 'test', + paymentMethodId: 'test', + }; + + jest + .spyOn(paymentInfoRepository, 'findOneByUser') + .mockResolvedValue(paymentInfo as any); + + jest.spyOn(stripe.paymentIntents, 'create').mockResolvedValue({} as any); + + await expect( + paymentService.createSlash(jobEntity as any), + ).rejects.toThrow(ErrorPayment.ClientSecretDoesNotExist); + }); + }); }); 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 d514202ee4..158ea20d8b 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 @@ -4,6 +4,7 @@ import { ethers } from 'ethers'; import { ErrorPayment } from '../../common/constants/errors'; import { PaymentRepository } from './payment.repository'; import { + CardConfirmDto, PaymentCryptoCreateDto, PaymentFiatConfirmDto, PaymentFiatCreateDto, @@ -31,6 +32,11 @@ import { verifySignature } from '../../common/utils/signature'; import { PaymentEntity } from './payment.entity'; import { ControlledError } from '../../common/errors/controlled'; import { RateService } from './rate.service'; +import { UserEntity } from '../user/user.entity'; +import { PaymentInfoRepository } from './payment-info.repository'; +import { PaymentInfoEntity } from './payment-info.entity'; +import { JobEntity } from '../job/job.entity'; +import { ServerConfigService } from '../../common/config/server-config.service'; @Injectable() export class PaymentService { @@ -41,8 +47,10 @@ export class PaymentService { private readonly networkConfigService: NetworkConfigService, private readonly web3Service: Web3Service, private readonly paymentRepository: PaymentRepository, + private readonly paymentInfoRepository: PaymentInfoRepository, private stripeConfigService: StripeConfigService, private rateService: RateService, + private serverConfigService: ServerConfigService, ) { this.stripe = new Stripe(this.stripeConfigService.secretKey, { apiVersion: this.stripeConfigService.apiVersion as any, @@ -54,8 +62,81 @@ export class PaymentService { }); } + public async createCustomerAndAssignCard(user: UserEntity): Promise { + let setupIntent: Stripe.Response; + + if (!!user.paymentInfo) { + this.logger.log(ErrorPayment.CardAssigned, PaymentService.name); + throw new ControlledError( + ErrorPayment.CardAssigned, + HttpStatus.NOT_FOUND, + ); + } + + try { + const customer = await this.stripe.customers.create({ + email: user.email, + }); + + setupIntent = await this.stripe.setupIntents.create({ + automatic_payment_methods: { + enabled: true, + }, + customer: customer.id, + }); + } catch (error) { + this.logger.log(error.message, PaymentService.name); + throw new ControlledError( + ErrorPayment.CardNotAssigned, + HttpStatus.NOT_FOUND, + ); + } + + if (!setupIntent?.client_secret) { + this.logger.log( + ErrorPayment.ClientSecretDoesNotExist, + PaymentService.name, + ); + throw new ControlledError( + ErrorPayment.ClientSecretDoesNotExist, + HttpStatus.NOT_FOUND, + ); + } + + return setupIntent.client_secret; + } + + public async confirmCard( + user: UserEntity, + data: CardConfirmDto, + ): Promise { + const setup = await this.stripe.setupIntents.retrieve(data.setupId); + + if (!setup) { + this.logger.log(ErrorPayment.SetupNotFound, PaymentService.name); + throw new ControlledError( + ErrorPayment.SetupNotFound, + HttpStatus.NOT_FOUND, + ); + } + + await this.stripe.customers.update(setup.customer, { + invoice_settings: { + default_payment_method: setup.payment_method, + }, + }); + + const paymentInfo = new PaymentInfoEntity(); + paymentInfo.user = user; + paymentInfo.customerId = setup.customer as string; + paymentInfo.paymentMethodId = setup.payment_method as string; + await this.paymentInfoRepository.createUnique(paymentInfo); + + return true; + } + public async createFiatPayment( - userId: number, + user: UserEntity, dto: PaymentFiatCreateDto, ): Promise { const { amount, currency } = dto; @@ -64,6 +145,9 @@ export class PaymentService { const params: Stripe.PaymentIntentCreateParams = { amount: amountInCents, currency: currency, + customer: user.paymentInfo.customerId, + payment_method: user.paymentInfo.paymentMethodId, + off_session: false, }; const paymentIntent = await this.stripe.paymentIntents.create(params); @@ -90,7 +174,7 @@ export class PaymentService { const newPaymentEntity = new PaymentEntity(); Object.assign(newPaymentEntity, { - userId, + userId: user.id, source: PaymentSource.FIAT, type: PaymentType.DEPOSIT, amount: div(amountInCents, 100), @@ -290,4 +374,69 @@ export class PaymentService { }); await this.paymentRepository.createUnique(paymentEntity); } + public async createSlash(job: JobEntity): Promise { + const amount = this.serverConfigService.abuseAmount, + currency = Currency.USD; + + const paymentInfo = await this.paymentInfoRepository.findOneByUser( + job.userId, + ); + if (!paymentInfo) { + this.logger.log(ErrorPayment.CustomerNotFound, PaymentService.name); + throw new ControlledError( + ErrorPayment.CustomerNotFound, + HttpStatus.BAD_REQUEST, + ); + } + + const amountInCents = Math.ceil(mul(amount, 100)); + const params: Stripe.PaymentIntentCreateParams = { + amount: amountInCents, + currency: currency, + customer: paymentInfo.customerId, + payment_method: paymentInfo.paymentMethodId, + off_session: true, + confirm: true, + }; + + const paymentIntent = await this.stripe.paymentIntents.create(params); + + if (!paymentIntent?.client_secret) { + this.logger.log( + ErrorPayment.ClientSecretDoesNotExist, + PaymentService.name, + ); + throw new ControlledError( + ErrorPayment.ClientSecretDoesNotExist, + HttpStatus.BAD_REQUEST, + ); + } + + const newPaymentEntity = new PaymentEntity(); + Object.assign(newPaymentEntity, { + userId: job.user.id, + source: PaymentSource.FIAT, + type: PaymentType.DEPOSIT, + amount: div(amountInCents, 100), + currency, + rate: 1, + transaction: paymentIntent.id, + status: PaymentStatus.SUCCEEDED, + }); + await this.paymentRepository.createUnique(newPaymentEntity); + + Object.assign(newPaymentEntity, { + userId: job.user.id, + source: PaymentSource.FIAT, + type: PaymentType.SLASH, + amount: div(-amountInCents, 100), + currency, + rate: 1, + transaction: null, + status: PaymentStatus.SUCCEEDED, + }); + await this.paymentRepository.createUnique(newPaymentEntity); + + return; + } } 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 6b10a7133f..0b784913c7 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 @@ -8,6 +8,7 @@ import { UserStatus, UserType } from '../../common/enums/user'; import { PaymentEntity } from '../payment/payment.entity'; import { JobEntity } from '../job/job.entity'; import { ApiKeyEntity } from '../auth/apikey.entity'; +import { PaymentInfoEntity } from '../payment/payment-info.entity'; @Entity({ schema: NS, name: 'users' }) export class UserEntity extends BaseEntity implements IUser { @@ -33,6 +34,9 @@ export class UserEntity extends BaseEntity implements IUser { @OneToMany(() => PaymentEntity, (payment) => payment.user) public payments: PaymentEntity[]; + @OneToOne(() => PaymentInfoEntity, (payment) => payment.user) + public paymentInfo: PaymentInfoEntity; + @OneToOne(() => ApiKeyEntity, (apiKey) => apiKey.user, { nullable: true, }) 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 102774e10c..3c00cd55ee 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 @@ -19,6 +19,7 @@ import { import { ServerConfigService } from '../../common/config/server-config.service'; import { Web3ConfigService } from '../../common/config/web3-config.service'; import { ControlledError } from '../../common/errors/controlled'; +import { JobRepository } from '../job/job.repository'; jest.mock('@human-protocol/sdk'); @@ -48,6 +49,10 @@ describe('WebhookController', () => { provide: WebhookRepository, useValue: createMock(), }, + { + provide: JobRepository, + useValue: createMock(), + }, { provide: ConfigService, useValue: { diff --git a/packages/apps/job-launcher/server/src/modules/webhook/webhook.module.ts b/packages/apps/job-launcher/server/src/modules/webhook/webhook.module.ts index d3221f22d3..dab2beb310 100644 --- a/packages/apps/job-launcher/server/src/modules/webhook/webhook.module.ts +++ b/packages/apps/job-launcher/server/src/modules/webhook/webhook.module.ts @@ -9,17 +9,19 @@ import { Web3Module } from '../web3/web3.module'; import { HttpModule } from '@nestjs/axios'; import { JobModule } from '../job/job.module'; import { WebhookController } from './webhook.controller'; +import { JobEntity } from '../job/job.entity'; +import { JobRepository } from '../job/job.repository'; @Module({ imports: [ - TypeOrmModule.forFeature([WebhookEntity]), + TypeOrmModule.forFeature([WebhookEntity, JobEntity]), ConfigModule, JobModule, Web3Module, HttpModule, ], controllers: [WebhookController], - providers: [Logger, WebhookService, WebhookRepository], + providers: [Logger, WebhookService, WebhookRepository, JobRepository], exports: [WebhookService], }) export class WebhookModule {} diff --git a/packages/apps/job-launcher/server/src/modules/webhook/webhook.repository.ts b/packages/apps/job-launcher/server/src/modules/webhook/webhook.repository.ts index 35d7bc8ef6..bf5dfd1537 100644 --- a/packages/apps/job-launcher/server/src/modules/webhook/webhook.repository.ts +++ b/packages/apps/job-launcher/server/src/modules/webhook/webhook.repository.ts @@ -3,7 +3,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { BaseRepository } from '../../database/base.repository'; import { DataSource, LessThanOrEqual } from 'typeorm'; import { ServerConfigService } from '../../common/config/server-config.service'; -import { WebhookStatus } from '../../common/enums/webhook'; +import { EventType, WebhookStatus } from '../../common/enums/webhook'; import { WebhookEntity } from './webhook.entity'; @Injectable() @@ -15,11 +15,14 @@ export class WebhookRepository extends BaseRepository { ) { super(WebhookEntity, dataSource); } - - public findByStatus(status: WebhookStatus): Promise { + public findByStatusAndType( + status: WebhookStatus, + type: EventType, + ): Promise { return this.find({ where: { status: status, + eventType: type, retriesCount: LessThanOrEqual(this.serverConfigService.maxRetryCount), waitUntil: LessThanOrEqual(new Date()), }, diff --git a/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.spec.ts b/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.spec.ts index 58d5e1bfad..46c5260fb9 100644 --- a/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.spec.ts @@ -28,6 +28,8 @@ import { HttpStatus } from '@nestjs/common'; import { ServerConfigService } from '../../common/config/server-config.service'; import { Web3ConfigService } from '../../common/config/web3-config.service'; import { ControlledError } from '../../common/errors/controlled'; +import { JobRepository } from '../job/job.repository'; +import { JobRequestType } from '../../common/enums/job'; jest.mock('@human-protocol/sdk', () => ({ ...jest.requireActual('@human-protocol/sdk'), @@ -41,6 +43,7 @@ describe('WebhookService', () => { webhookRepository: WebhookRepository, web3Service: Web3Service, jobService: JobService, + jobRepository: JobRepository, httpService: HttpService; const signerMock = { @@ -83,6 +86,10 @@ describe('WebhookService', () => { provide: JobService, useValue: createMock(), }, + { + provide: JobRepository, + useValue: createMock(), + }, { provide: ConfigService, useValue: mockConfigService }, { provide: HttpService, useValue: createMock() }, ], @@ -93,6 +100,7 @@ describe('WebhookService', () => { web3Service = moduleRef.get(Web3Service); httpService = moduleRef.get(HttpService); jobService = moduleRef.get(JobService); + jobRepository = moduleRef.get(JobRepository); }); afterEach(() => { @@ -338,6 +346,22 @@ describe('WebhookService', () => { expect(jobService.escrowFailedWebhook).toHaveBeenCalledWith(webhook); }); + it('should handle an incoming abused escrow webhook', async () => { + const webhook: WebhookDataDto = { + chainId, + escrowAddress, + eventType: EventType.ABUSE, + }; + + jest.spyOn(webhookService, 'createIncomingWebhook'); + + expect(await webhookService.handleWebhook(webhook)).toBe(undefined); + + expect(webhookService.createIncomingWebhook).toHaveBeenCalledWith( + webhook, + ); + }); + it('should return an error when the event type is invalid', async () => { const webhook: WebhookDataDto = { chainId, @@ -353,4 +377,46 @@ describe('WebhookService', () => { ); }); }); + describe('createIncomingWebhook', () => { + it('should create a new incoming webhook', async () => { + const dto = { + chainId: ChainId.LOCALHOST, + escrowAddress: '', + }; + + jest + .spyOn(jobRepository, 'findOneByChainIdAndEscrowAddress') + .mockResolvedValue({ requestType: JobRequestType.FORTUNE } as any); + jest + .spyOn(jobService, 'getOracleType') + .mockReturnValue(OracleType.FORTUNE); + const result = await webhookService.createIncomingWebhook(dto as any); + + expect(result).toBe(undefined); + expect(webhookRepository.createUnique).toHaveBeenCalledWith({ + chainId: ChainId.LOCALHOST, + escrowAddress: '', + hasSignature: false, + oracleType: OracleType.FORTUNE, + retriesCount: 0, + status: WebhookStatus.PENDING, + waitUntil: expect.any(Date), + }); + }); + + it('should create a new incoming webhook', async () => { + const dto = { + chainId: ChainId.LOCALHOST, + escrowAddress: '', + }; + + jest + .spyOn(jobRepository, 'findOneByChainIdAndEscrowAddress') + .mockResolvedValue(undefined as any); + + await expect( + webhookService.createIncomingWebhook(dto as any), + ).rejects.toThrow(ErrorWebhook.InvalidEscrow); + }); + }); }); diff --git a/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.ts b/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.ts index f0e37be14a..bdd830f53c 100644 --- a/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.ts +++ b/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.ts @@ -22,6 +22,7 @@ import { CaseConverter } from '../../common/utils/case-converter'; import { EventType } from '../../common/enums/webhook'; import { JobService } from '../job/job.service'; import { ControlledError } from '../../common/errors/controlled'; +import { JobRepository } from '../job/job.repository'; @Injectable() export class WebhookService { constructor( @@ -29,6 +30,7 @@ export class WebhookService { private readonly web3Service: Web3Service, private readonly webhookRepository: WebhookRepository, private readonly jobService: JobService, + private readonly jobRepository: JobRepository, private readonly commonConfigSerice: ServerConfigService, private readonly web3ConfigService: Web3ConfigService, private readonly httpService: HttpService, @@ -141,6 +143,10 @@ export class WebhookService { await this.jobService.escrowFailedWebhook(webhook); break; + case EventType.ABUSE: + await this.createIncomingWebhook(webhook); + break; + default: throw new ControlledError( `Invalid webhook event type: ${webhook.eventType}`, @@ -148,4 +154,27 @@ export class WebhookService { ); } } + + public async createIncomingWebhook(webhook: WebhookDataDto): Promise { + const jobEntity = await this.jobRepository.findOneByChainIdAndEscrowAddress( + webhook.chainId, + webhook.escrowAddress, + ); + + if (!jobEntity) { + throw new ControlledError( + ErrorWebhook.InvalidEscrow, + HttpStatus.BAD_REQUEST, + ); + } + + const webhookEntity = new WebhookEntity(); + Object.assign(webhookEntity, webhook); + webhookEntity.oracleType = this.jobService.getOracleType( + jobEntity.requestType, + ); + webhookEntity.hasSignature = false; + + this.webhookRepository.createUnique(webhookEntity); + } } From c6e8f64371c66f18618b41f9521c95ce58b2cb52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Wed, 4 Sep 2024 13:39:28 +0200 Subject: [PATCH 02/11] Add job launcher server migrations --- .../migrations/1725449786396-paymentIntent.ts | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 packages/apps/job-launcher/server/src/database/migrations/1725449786396-paymentIntent.ts diff --git a/packages/apps/job-launcher/server/src/database/migrations/1725449786396-paymentIntent.ts b/packages/apps/job-launcher/server/src/database/migrations/1725449786396-paymentIntent.ts new file mode 100644 index 0000000000..fe982a8ba1 --- /dev/null +++ b/packages/apps/job-launcher/server/src/database/migrations/1725449786396-paymentIntent.ts @@ -0,0 +1,168 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PaymentIntent1725449786396 implements MigrationInterface { + name = 'PaymentIntent1725449786396'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "hmt"."payments-info" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, + "customer_id" character varying NOT NULL, + "payment_method_id" character varying NOT NULL, + "user_id" integer NOT NULL, + CONSTRAINT "PK_b4970c6db0e80ea900a06ebc171" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_35c9ce414705e7b718a58aa6f0" ON "hmt"."payments-info" ("customer_id") + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_63719fa3540ac47f61cc4a7ba1" ON "hmt"."payments-info" ("user_id") + `); + await queryRunner.query(` + ALTER TYPE "hmt"."payments_type_enum" + RENAME TO "payments_type_enum_old" + `); + await queryRunner.query(` + CREATE TYPE "hmt"."payments_type_enum" AS ENUM('DEPOSIT', 'REFUND', 'WITHDRAWAL', 'SLASH') + `); + await queryRunner.query(` + ALTER TABLE "hmt"."payments" + ALTER COLUMN "type" TYPE "hmt"."payments_type_enum" USING "type"::"text"::"hmt"."payments_type_enum" + `); + await queryRunner.query(` + DROP TYPE "hmt"."payments_type_enum_old" + `); + await queryRunner.query(` + DROP INDEX "hmt"."IDX_012a8481fc9980fcc49f3f0dc2" + `); + await queryRunner.query(` + ALTER TYPE "hmt"."webhook_event_type_enum" + RENAME TO "webhook_event_type_enum_old" + `); + await queryRunner.query(` + CREATE TYPE "hmt"."webhook_event_type_enum" AS ENUM( + 'escrow_created', + 'escrow_canceled', + 'escrow_completed', + 'task_creation_failed', + 'escrow_failed', + 'abuse' + ) + `); + await queryRunner.query(` + ALTER TABLE "hmt"."webhook" + ALTER COLUMN "event_type" TYPE "hmt"."webhook_event_type_enum" USING "event_type"::"text"::"hmt"."webhook_event_type_enum" + `); + await queryRunner.query(` + DROP TYPE "hmt"."webhook_event_type_enum_old" + `); + await queryRunner.query(` + ALTER TYPE "hmt"."cron-jobs_cron_job_type_enum" + RENAME TO "cron-jobs_cron_job_type_enum_old" + `); + await queryRunner.query(` + CREATE TYPE "hmt"."cron-jobs_cron_job_type_enum" AS ENUM( + 'create-escrow', + 'setup-escrow', + 'fund-escrow', + 'cancel-escrow', + 'process-pending-webhook', + 'sync-job-statuses', + 'abuse' + ) + `); + await queryRunner.query(` + ALTER TABLE "hmt"."cron-jobs" + ALTER COLUMN "cron_job_type" TYPE "hmt"."cron-jobs_cron_job_type_enum" USING "cron_job_type"::"text"::"hmt"."cron-jobs_cron_job_type_enum" + `); + await queryRunner.query(` + DROP TYPE "hmt"."cron-jobs_cron_job_type_enum_old" + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_012a8481fc9980fcc49f3f0dc2" ON "hmt"."webhook" ("chain_id", "escrow_address", "event_type") + `); + await queryRunner.query(` + ALTER TABLE "hmt"."payments-info" + ADD CONSTRAINT "FK_63719fa3540ac47f61cc4a7ba11" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "hmt"."payments-info" DROP CONSTRAINT "FK_63719fa3540ac47f61cc4a7ba11" + `); + await queryRunner.query(` + DROP INDEX "hmt"."IDX_012a8481fc9980fcc49f3f0dc2" + `); + await queryRunner.query(` + CREATE TYPE "hmt"."cron-jobs_cron_job_type_enum_old" AS ENUM( + 'create-escrow', + 'setup-escrow', + 'fund-escrow', + 'cancel-escrow', + 'process-pending-webhook', + 'sync-job-statuses' + ) + `); + await queryRunner.query(` + ALTER TABLE "hmt"."cron-jobs" + ALTER COLUMN "cron_job_type" TYPE "hmt"."cron-jobs_cron_job_type_enum_old" USING "cron_job_type"::"text"::"hmt"."cron-jobs_cron_job_type_enum_old" + `); + await queryRunner.query(` + DROP TYPE "hmt"."cron-jobs_cron_job_type_enum" + `); + await queryRunner.query(` + ALTER TYPE "hmt"."cron-jobs_cron_job_type_enum_old" + RENAME TO "cron-jobs_cron_job_type_enum" + `); + await queryRunner.query(` + CREATE TYPE "hmt"."webhook_event_type_enum_old" AS ENUM( + 'escrow_created', + 'escrow_canceled', + 'escrow_completed', + 'task_creation_failed', + 'escrow_failed' + ) + `); + await queryRunner.query(` + ALTER TABLE "hmt"."webhook" + ALTER COLUMN "event_type" TYPE "hmt"."webhook_event_type_enum_old" USING "event_type"::"text"::"hmt"."webhook_event_type_enum_old" + `); + await queryRunner.query(` + DROP TYPE "hmt"."webhook_event_type_enum" + `); + await queryRunner.query(` + ALTER TYPE "hmt"."webhook_event_type_enum_old" + RENAME TO "webhook_event_type_enum" + `); + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_012a8481fc9980fcc49f3f0dc2" ON "hmt"."webhook" ("chain_id", "escrow_address", "event_type") + `); + await queryRunner.query(` + CREATE TYPE "hmt"."payments_type_enum_old" AS ENUM('DEPOSIT', 'REFUND', 'WITHDRAWAL') + `); + await queryRunner.query(` + ALTER TABLE "hmt"."payments" + ALTER COLUMN "type" TYPE "hmt"."payments_type_enum_old" USING "type"::"text"::"hmt"."payments_type_enum_old" + `); + await queryRunner.query(` + DROP TYPE "hmt"."payments_type_enum" + `); + await queryRunner.query(` + ALTER TYPE "hmt"."payments_type_enum_old" + RENAME TO "payments_type_enum" + `); + await queryRunner.query(` + DROP INDEX "hmt"."IDX_63719fa3540ac47f61cc4a7ba1" + `); + await queryRunner.query(` + DROP INDEX "hmt"."IDX_35c9ce414705e7b718a58aa6f0" + `); + await queryRunner.query(` + DROP TABLE "hmt"."payments-info" + `); + } +} From 5284fd43132a157667552628c6b912aa250d08e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Mon, 9 Sep 2024 13:05:27 +0200 Subject: [PATCH 03/11] Create new component to setup card details --- .../components/CardSetup/CardSetupForm.tsx | 110 +++++++++++++ .../components/Jobs/Create/FiatPayForm.tsx | 148 ++---------------- .../src/components/Jobs/Create/PayJob.tsx | 64 +++++++- .../components/TopUpAccount/FiatTopUpForm.tsx | 93 ++--------- .../apps/job-launcher/client/src/main.tsx | 5 +- .../src/pages/Profile/TopUpAccount/index.tsx | 62 +++++++- .../client/src/services/payment.ts | 19 +++ .../src/modules/payment/payment.controller.ts | 23 ++- .../src/modules/user/user.repository.ts | 2 +- 9 files changed, 301 insertions(+), 225 deletions(-) create mode 100644 packages/apps/job-launcher/client/src/components/CardSetup/CardSetupForm.tsx diff --git a/packages/apps/job-launcher/client/src/components/CardSetup/CardSetupForm.tsx b/packages/apps/job-launcher/client/src/components/CardSetup/CardSetupForm.tsx new file mode 100644 index 0000000000..eb43b4f5b9 --- /dev/null +++ b/packages/apps/job-launcher/client/src/components/CardSetup/CardSetupForm.tsx @@ -0,0 +1,110 @@ +import { LoadingButton } from '@mui/lab'; +import { Box, Grid, Link, Typography } from '@mui/material'; +import { + PaymentElement, + useElements, + useStripe, +} from '@stripe/react-stripe-js'; +import { useState } from 'react'; +import { useSnackbar } from '../../providers/SnackProvider'; +import * as paymentService from '../../services/payment'; +import { useAppDispatch } from '../../state'; +import { fetchUserBalanceAsync } from '../../state/auth/reducer'; + +interface CardSetupFormProps { + onCardSetup: () => void; // Prop para notificar cuando la tarjeta está lista +} + +export const CardSetupForm: React.FC = ({ + onCardSetup, +}) => { + const stripe = useStripe(); + const elements = useElements(); + const [isLoading, setIsLoading] = useState(false); + const dispatch = useAppDispatch(); + const { showError } = useSnackbar(); + + const handleCardSetup = async () => { + if (!stripe || !elements) { + showError('Stripe.js has not yet loaded.'); + return; + } + + // Trigger form validation and card details collection + const { error: submitError } = await elements.submit(); + if (submitError) { + showError(submitError); + return; + } + + setIsLoading(true); + try { + const clientSecret = await paymentService.createSetupIntent(); + + if (!clientSecret) { + throw new Error('Failed to create SetupIntent.'); + } + + const { error: stripeError, setupIntent } = await stripe.confirmSetup({ + elements, + clientSecret, + confirmParams: { + return_url: window.location.href, + }, + redirect: 'if_required', + }); + + if (stripeError) { + throw stripeError; + } + + const success = await paymentService.confirmSetupIntent( + setupIntent?.id ?? '', + ); + + if (!success) { + throw new Error('Card setup confirmation failed.'); + } + + dispatch(fetchUserBalanceAsync()); + onCardSetup(); + } catch (err: any) { + showError(err.message || 'An error occurred while setting up the card.'); + } + setIsLoading(false); + }; + + return ( + + + + + + + + Save card details + + + + + + Terms & conditions + + + + + + ); +}; diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/FiatPayForm.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/FiatPayForm.tsx index 498e9cddcb..4111c82c82 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/FiatPayForm.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/FiatPayForm.tsx @@ -3,7 +3,6 @@ import { KVStoreKeys, NETWORKS } from '@human-protocol/sdk'; import { LoadingButton } from '@mui/lab'; import { Box, - BoxProps, Button, Checkbox, FormControl, @@ -14,18 +13,12 @@ import { TextField, Typography, } from '@mui/material'; -import { styled } from '@mui/material/styles'; -import { - CardCvcElement, - CardExpiryElement, - CardNumberElement, - useElements, - useStripe, -} from '@stripe/react-stripe-js'; +import { useElements, useStripe } from '@stripe/react-stripe-js'; import { useEffect, useMemo, useState } from 'react'; import { Address } from 'viem'; import { useReadContract } from 'wagmi'; import { CURRENCY } from '../../../constants/payment'; + import { useCreateJobPageUI } from '../../../providers/CreateJobPageUIProvider'; import * as jobService from '../../../services/job'; import * as paymentService from '../../../services/payment'; @@ -33,20 +26,6 @@ import { useAppDispatch, useAppSelector } from '../../../state'; import { fetchUserBalanceAsync } from '../../../state/auth/reducer'; import { JobType } from '../../../types'; -const StripeElement = styled(Box)( - (props) => ({ - border: '1px solid rgba(50,10,141,0.5)', - borderRadius: '4px', - height: '56px', - padding: '18px 16px', - pointerEvents: props.disabled ? 'none' : 'auto', - opacity: props.disabled ? 0.2 : 1, - '&:focus-within': { - borderColor: '#32108D', - }, - }), -); - export const FiatPayForm = ({ onStart, onFinish, @@ -64,10 +43,7 @@ export const FiatPayForm = ({ const [payWithAccountBalance, setPayWithAccountBalance] = useState(false); const [isLoading, setIsLoading] = useState(false); - const [paymentData, setPaymentData] = useState({ - amount: '', - name: '', - }); + const [amount, setAmount] = useState(''); const [jobLauncherAddress, setJobLauncherAddress] = useState(); const [minFee, setMinFee] = useState(0.01); @@ -94,7 +70,7 @@ export const FiatPayForm = ({ }, }); - const fundAmount = paymentData.amount ? Number(paymentData.amount) : 0; + const fundAmount = amount ? Number(amount) : 0; const feeAmount = Math.max( minFee, fundAmount * (Number(jobLauncherFee) / 100), @@ -114,78 +90,27 @@ export const FiatPayForm = ({ return totalAmount - accountAmount; }, [payWithAccountBalance, totalAmount, accountAmount]); - const cardElementsDisabled = useMemo(() => { - if (paymentData.amount) { - return creditCardPayAmount <= 0; - } else { - if (payWithAccountBalance) return true; - return false; - } - }, [paymentData, payWithAccountBalance, creditCardPayAmount]); - - const handlePaymentDataFormFieldChange = ( - fieldName: string, - fieldValue: any, - ) => { - setPaymentData({ ...paymentData, [fieldName]: fieldValue }); - }; - const handlePay = async () => { if (!stripe || !elements) { - // Stripe.js has not yet loaded. - // Make sure to disable form submission until Stripe.js has loaded. - // eslint-disable-next-line no-console - console.error('Stripe.js has not yet loaded.'); + onError('Stripe.js has not yet loaded.'); return; } setIsLoading(true); - try { if (creditCardPayAmount > 0) { - if (!paymentData.name) { - throw new Error('Please enter name on card.'); - } - - // Stripe elements validation - const cardNumber = elements.getElement(CardNumberElement) as any; - const cardExpiry = elements.getElement(CardExpiryElement) as any; - const cardCvc = elements.getElement(CardCvcElement) as any; - - if (!cardNumber || !cardExpiry || !cardCvc) { - throw new Error('Card elements are not initialized'); - } - if (cardNumber._invalid || cardNumber._empty) { - throw new Error('Your card number is incomplete.'); - } - if (cardExpiry._invalid || cardExpiry._empty) { - throw new Error("Your card's expiration date is incomplete."); - } - if (cardCvc._invalid || cardCvc._empty) { - throw new Error("Your card's security code is incomplete."); - } - - // send payment if creditCardPayment > 0 const clientSecret = await paymentService.createFiatPayment({ amount: creditCardPayAmount, - currency: CURRENCY.usd, + currency: 'usd', }); - // stripe payment const { error: stripeError, paymentIntent } = - await stripe.confirmCardPayment(clientSecret, { - payment_method: { - card: cardNumber, - billing_details: { - name: paymentData.name, - }, - }, - }); + await stripe.confirmCardPayment(clientSecret); + if (stripeError) { throw stripeError; } - // confirm payment const success = await paymentService.confirmFiatPayment( paymentIntent.id, ); @@ -254,44 +179,15 @@ export const FiatPayForm = ({ /> - - - - - - - - - - - - - - - - - - handlePaymentDataFormFieldChange('name', e.target.value) - } - disabled={cardElementsDisabled} - /> - - handlePaymentDataFormFieldChange('amount', e.target.value) - } + onChange={(e) => setAmount(e.target.value)} /> @@ -324,18 +220,6 @@ export const FiatPayForm = ({ )} - {/* - Amount due - {totalAmount} USD - */} - {/* - Fees - - ({JOB_LAUNCHER_FEE}%) {feeAmount} USD - - */} Pay now diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/PayJob.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/PayJob.tsx index 06e1674778..668e0f2917 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/PayJob.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/PayJob.tsx @@ -1,9 +1,11 @@ import { Box } from '@mui/material'; -import React, { useState } from 'react'; -import { StyledTabs, StyledTab } from '../../../components/Tabs'; +import { useEffect, useState } from 'react'; +import { CardSetupForm } from '../../../components/CardSetup/CardSetupForm'; +import { StyledTab, StyledTabs } from '../../../components/Tabs'; import { IS_TESTNET } from '../../../constants/chains'; import { useCreateJobPageUI } from '../../../providers/CreateJobPageUIProvider'; import { useSnackbar } from '../../../providers/SnackProvider'; +import { checkUserCard } from '../../../services/payment'; import { PayMethod } from '../../../types'; import { CryptoPayForm } from './CryptoPayForm'; import { FiatPayForm } from './FiatPayForm'; @@ -12,8 +14,18 @@ import { LaunchJobProgress } from './LaunchJobProgress'; export const PayJob = () => { const { payMethod, changePayMethod, goToNextStep } = useCreateJobPageUI(); const [isPaying, setIsPaying] = useState(false); + const [hasCard, setHasCard] = useState(null); const { showError } = useSnackbar(); + useEffect(() => { + const fetchCardStatus = async () => { + const result = await checkUserCard(); + setHasCard(result); + }; + + fetchCardStatus(); + }, []); + const handleStart = () => { setIsPaying(true); }; @@ -33,6 +45,54 @@ export const PayJob = () => { } }; + if (hasCard === null) { + return ( + +
Loading...
+
+ ); + } + + if (!hasCard) { + return ( + + setHasCard(true)} /> + + ); + } + return !isPaying ? ( { const [isLoading, setIsLoading] = useState(false); const [isSuccess, setIsSuccess] = useState(false); const [amount, setAmount] = useState(); - const [name, setName] = useState(); const dispatch = useAppDispatch(); const { showError } = useSnackbar(); const handleTopUpAccount = async () => { if (!stripe || !elements) { - // Stripe.js has not yet loaded. - // Make sure to disable form submission until Stripe.js has loaded. - // eslint-disable-next-line no-console - console.error('Stripe.js has not yet loaded.'); + showError('Stripe.js has not yet loaded.'); + return; + } + // Trigger form validation and wallet collection + const { error: submitError } = await elements.submit(); + if (submitError) { + showError(submitError); return; } setIsLoading(true); try { - const cardNumber = elements.getElement(CardNumberElement) as any; - const cardExpiry = elements.getElement(CardExpiryElement) as any; - const cardCvc = elements.getElement(CardCvcElement) as any; - if (!cardNumber || !cardExpiry || !cardCvc) { - throw new Error('Card elements are not initialized'); - } - if (cardNumber._invalid || cardNumber._empty) { - throw new Error('Your card number is incomplete.'); - } - if (cardExpiry._invalid || cardExpiry._empty) { - throw new Error("Your card's expiration date is incomplete."); - } - if (cardCvc._invalid || cardCvc._empty) { - throw new Error("Your card's security code is incomplete."); - } - // get client secret const clientSecret = await paymentService.createFiatPayment({ amount: Number(amount), @@ -66,12 +46,7 @@ export const FiatTopUpForm = () => { // stripe payment const { error: stripeError, paymentIntent } = - await stripe.confirmCardPayment(clientSecret, { - payment_method: { - card: cardNumber, - billing_details: { name }, - }, - }); + await stripe.confirmCardPayment(clientSecret); if (stripeError) { throw stripeError; @@ -88,7 +63,7 @@ export const FiatTopUpForm = () => { setIsSuccess(true); } catch (err: any) { - showError(err); + showError(err.message || 'An error occurred while setting up the card.'); setIsSuccess(false); } setIsLoading(false); @@ -99,51 +74,6 @@ export const FiatTopUpForm = () => { ) : ( - - - - - - - - - - - - - - - - - setName(e.target.value)} - /> - { size="large" onClick={handleTopUpAccount} loading={isLoading} - disabled={!amount || !name} > Top up account diff --git a/packages/apps/job-launcher/client/src/main.tsx b/packages/apps/job-launcher/client/src/main.tsx index 61ee3993ec..0148d40d7b 100644 --- a/packages/apps/job-launcher/client/src/main.tsx +++ b/packages/apps/job-launcher/client/src/main.tsx @@ -48,7 +48,10 @@ loadStripe(publishableKey).then((stripePromise) => { - + diff --git a/packages/apps/job-launcher/client/src/pages/Profile/TopUpAccount/index.tsx b/packages/apps/job-launcher/client/src/pages/Profile/TopUpAccount/index.tsx index 0ef2a3efbb..b615f6c5ef 100644 --- a/packages/apps/job-launcher/client/src/pages/Profile/TopUpAccount/index.tsx +++ b/packages/apps/job-launcher/client/src/pages/Profile/TopUpAccount/index.tsx @@ -1,19 +1,79 @@ import { Box, Typography } from '@mui/material'; -import React, { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { CardSetupForm } from '../../../components/CardSetup/CardSetupForm'; import { StyledTab, StyledTabs } from '../../../components/Tabs'; import { CryptoTopUpForm } from '../../../components/TopUpAccount/CryptoTopUpForm'; import { FiatTopUpForm } from '../../../components/TopUpAccount/FiatTopUpForm'; import { TopUpMethod } from '../../../components/TopUpAccount/TopUpMethod'; import { IS_TESTNET } from '../../../constants/chains'; +import { checkUserCard } from '../../../services/payment'; import { PayMethod } from '../../../types'; export default function TopUpAccount() { const [payMethod, setPayMethod] = useState(); + const [hasCard, setHasCard] = useState(null); + + useEffect(() => { + const fetchCardStatus = async () => { + const result = await checkUserCard(); + setHasCard(result); + }; + + fetchCardStatus(); + }, []); const handleSelectMethod = (method: PayMethod) => { setPayMethod(method); }; + if (hasCard === null) { + return ( + +
Loading...
+
+ ); + } + + if (!hasCard) { + return ( + + setHasCard(true)} /> + + ); + } + return ( diff --git a/packages/apps/job-launcher/client/src/services/payment.ts b/packages/apps/job-launcher/client/src/services/payment.ts index 629510570d..1784e73a12 100644 --- a/packages/apps/job-launcher/client/src/services/payment.ts +++ b/packages/apps/job-launcher/client/src/services/payment.ts @@ -4,6 +4,19 @@ import { PAYMENT_SIGNATURE_KEY } from '../constants/payment'; import { CryptoPaymentRequest, FiatPaymentRequest } from '../types'; import api from '../utils/api'; +export const createSetupIntent = async () => { + const { data } = await api.post('/payment/fiat/setup-card'); + return data; +}; + +export const confirmSetupIntent = async (setupIntentId: string) => { + const { data } = await api.post('/payment/fiat/confirm-card', { + setupId: setupIntentId, + }); + + return data; +}; + export const createCryptoPayment = async ( signer: WalletClient, body: CryptoPaymentRequest, @@ -54,3 +67,9 @@ export const getOperatorAddress = async () => { return data; }; + +export const checkUserCard = async () => { + const { data } = await api.get('/payment/check-card'); + + return data; +}; diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts index 163d1d58c2..4e3fe54c06 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts @@ -123,7 +123,7 @@ export class PaymentController { @Request() req: RequestWithUser, @Body() data: PaymentFiatCreateDto, ): Promise { - return this.paymentService.createFiatPayment(req.user.id, data); + return this.paymentService.createFiatPayment(req.user, data); } @ApiOperation({ @@ -246,4 +246,25 @@ export class PaymentController { public async getMinFee(): Promise { return this.serverConfigService.minimunFeeUsd; } + + @ApiOperation({ + summary: 'Check if a card has already been assigned to the user', + description: + 'Endpoint to check if a card has already been assigned to the user.', + }) + @ApiResponse({ + status: 200, + description: 'Card assigned succesfully', + type: Boolean, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized. Missing or invalid credentials.', + }) + @Get('/check-card') + public async checkUserCard( + @Request() req: RequestWithUser, + ): Promise { + return !!req.user?.paymentInfo?.paymentMethodId; + } } diff --git a/packages/apps/job-launcher/server/src/modules/user/user.repository.ts b/packages/apps/job-launcher/server/src/modules/user/user.repository.ts index c76fcee326..6717fba1e1 100644 --- a/packages/apps/job-launcher/server/src/modules/user/user.repository.ts +++ b/packages/apps/job-launcher/server/src/modules/user/user.repository.ts @@ -10,7 +10,7 @@ export class UserRepository extends BaseRepository { } async findById(id: number): Promise { - return this.findOne({ where: { id } }); + return this.findOne({ where: { id }, relations: ['paymentInfo'] }); } async findByEmail(email: string): Promise { From 83b28b5344c8740228034ad68f5512bcda45a82e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Mon, 11 Nov 2024 10:31:57 +0100 Subject: [PATCH 04/11] Merge branch 'develop' into feat/job-launcher-server/abuse --- .../server/src/modules/webhook/webhook.service.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.spec.ts b/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.spec.ts index 39d5082b1b..b28ffd8e44 100644 --- a/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.spec.ts +++ b/packages/apps/job-launcher/server/src/modules/webhook/webhook.service.spec.ts @@ -87,7 +87,6 @@ describe('WebhookService', () => { provide: JobRepository, useValue: createMock(), }, - { provide: ConfigService, useValue: mockConfigService }, { provide: HttpService, useValue: createMock() }, ], }).compile(); From ec66e10270e7173c28d9c52a3c9ed38448ec1bb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Thu, 14 Nov 2024 13:10:10 +0100 Subject: [PATCH 05/11] Create endpoints for the new billing flow: - Remove Old PaymentInfo Table and Store CustomerId in User Table - Update Endpoint to Create Customer and Add Payment Method - Create Endpoint to List Payment Methods - Create Endpoint to Remove a Payment Method - Create Endpoint to Retrieve User Billing Details - Create Endpoint to Edit Billing Details - Create Endpoint to Change Default Payment Method --- .../client/src/services/payment.ts | 2 +- .../server/src/common/constants/errors.ts | 1 + .../server/src/common/enums/job.ts | 2 +- .../server/src/common/enums/payment.ts | 83 +++++ .../server/src/common/guards/jwt.auth.ts | 1 + .../server/src/database/database.module.ts | 2 - ...aymentIntent.ts => 1731504887456-abuse.ts} | 39 +- .../server/src/modules/job/job.dto.ts | 8 +- .../server/src/modules/job/job.repository.ts | 18 +- .../src/modules/job/job.service.spec.ts | 4 +- .../modules/payment/payment-info.entity.ts | 26 -- .../payment/payment-info.repository.ts | 19 - .../src/modules/payment/payment.controller.ts | 237 +++++++++---- .../server/src/modules/payment/payment.dto.ts | 98 +++++- .../src/modules/payment/payment.module.ts | 18 +- .../modules/payment/payment.service.spec.ts | 333 ++++++++++++++---- .../src/modules/payment/payment.service.ts | 167 ++++++++- .../src/modules/payment/rate.service.ts | 2 - .../server/src/modules/user/user.entity.ts | 7 +- .../src/modules/user/user.repository.ts | 2 +- 20 files changed, 816 insertions(+), 253 deletions(-) rename packages/apps/job-launcher/server/src/database/migrations/{1725449786396-paymentIntent.ts => 1731504887456-abuse.ts} (76%) delete mode 100644 packages/apps/job-launcher/server/src/modules/payment/payment-info.entity.ts delete mode 100644 packages/apps/job-launcher/server/src/modules/payment/payment-info.repository.ts diff --git a/packages/apps/job-launcher/client/src/services/payment.ts b/packages/apps/job-launcher/client/src/services/payment.ts index 1784e73a12..f895fe4746 100644 --- a/packages/apps/job-launcher/client/src/services/payment.ts +++ b/packages/apps/job-launcher/client/src/services/payment.ts @@ -69,7 +69,7 @@ export const getOperatorAddress = async () => { }; export const checkUserCard = async () => { - const { data } = await api.get('/payment/check-card'); + const { data } = await api.get('/payment/fiat/check-card'); return data; }; diff --git a/packages/apps/job-launcher/server/src/common/constants/errors.ts b/packages/apps/job-launcher/server/src/common/constants/errors.ts index 3ae18cdff2..0e4533c517 100644 --- a/packages/apps/job-launcher/server/src/common/constants/errors.ts +++ b/packages/apps/job-launcher/server/src/common/constants/errors.ts @@ -96,6 +96,7 @@ export enum ErrorPayment { ClientSecretDoesNotExist = 'Client secret does not exist', CustomerNotFound = 'Customer not found', CustomerNotCreated = 'Customer not created', + PaymentMethodInUse = 'Cannot delete the default payment method in use', IncorrectAmount = 'Incorrect amount', TransactionAlreadyExists = 'Transaction already exists', TransactionNotFoundByHash = 'Transaction not found by hash', diff --git a/packages/apps/job-launcher/server/src/common/enums/job.ts b/packages/apps/job-launcher/server/src/common/enums/job.ts index 13313209bf..935d1657e4 100644 --- a/packages/apps/job-launcher/server/src/common/enums/job.ts +++ b/packages/apps/job-launcher/server/src/common/enums/job.ts @@ -165,7 +165,7 @@ export enum WorkerLanguage { ZU = 'zu', } -export enum WorkerLocation { +export enum Country { AF = 'af', AL = 'al', DZ = 'dz', 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 428bfe78b4..811365c779 100644 --- a/packages/apps/job-launcher/server/src/common/enums/payment.ts +++ b/packages/apps/job-launcher/server/src/common/enums/payment.ts @@ -76,3 +76,86 @@ export enum StripePaymentStatus { REQUIRES_PAYMENT_METHOD = 'requires_payment_method', SUCCEEDED = 'succeeded', } + +export enum VatType { + AD_NRT = 'ad_nrt', + AE_TRN = 'ae_trn', + AR_CUIT = 'ar_cuit', + AU_ABN = 'au_abn', + AU_ARN = 'au_arn', + BG_UIC = 'bg_uic', + BH_VAT = 'bh_vat', + BO_TIN = 'bo_tin', + BR_CNPJ = 'br_cnpj', + BR_CPF = 'br_cpf', + BY_TIN = 'by_tin', + CA_BN = 'ca_bn', + CA_GST_HST = 'ca_gst_hst', + CA_PST_BC = 'ca_pst_bc', + CA_PST_MB = 'ca_pst_mb', + CA_PST_SK = 'ca_pst_sk', + CA_QST = 'ca_qst', + CH_UID = 'ch_uid', + CH_VAT = 'ch_vat', + CL_TIN = 'cl_tin', + CN_TIN = 'cn_tin', + CO_NIT = 'co_nit', + CR_TIN = 'cr_tin', + DE_STN = 'de_stn', + DO_RCN = 'do_rcn', + EC_RUC = 'ec_ruc', + EG_TIN = 'eg_tin', + ES_CIF = 'es_cif', + EU_OSS_VAT = 'eu_oss_vat', + EU_VAT = 'eu_vat', + GB_VAT = 'gb_vat', + GE_VAT = 'ge_vat', + HK_BR = 'hk_br', + HR_OIB = 'hr_oib', + HU_TIN = 'hu_tin', + ID_NPWP = 'id_npwp', + IL_VAT = 'il_vat', + IN_GST = 'in_gst', + IS_VAT = 'is_vat', + JP_CN = 'jp_cn', + JP_RN = 'jp_rn', + JP_TRN = 'jp_trn', + KE_PIN = 'ke_pin', + KR_BRN = 'kr_brn', + KZ_BIN = 'kz_bin', + LI_UID = 'li_uid', + MA_VAT = 'ma_vat', + MD_VAT = 'md_vat', + MX_RFC = 'mx_rfc', + MY_FRP = 'my_frp', + MY_ITN = 'my_itn', + MY_SST = 'my_sst', + NG_TIN = 'ng_tin', + NO_VAT = 'no_vat', + NO_VOEC = 'no_voec', + NZ_GST = 'nz_gst', + OM_VAT = 'om_vat', + PE_RUC = 'pe_ruc', + PH_TIN = 'ph_tin', + RO_TIN = 'ro_tin', + RS_PIB = 'rs_pib', + RU_INN = 'ru_inn', + RU_KPP = 'ru_kpp', + SA_VAT = 'sa_vat', + SG_GST = 'sg_gst', + SG_UEN = 'sg_uen', + SI_TIN = 'si_tin', + SV_NIT = 'sv_nit', + TH_VAT = 'th_vat', + TR_TIN = 'tr_tin', + TW_VAT = 'tw_vat', + TZ_VAT = 'tz_vat', + UA_VAT = 'ua_vat', + US_EIN = 'us_ein', + UY_RUC = 'uy_ruc', + UZ_TIN = 'uz_tin', + UZ_VAT = 'uz_vat', + VE_RIF = 've_rif', + VN_TIN = 'vn_tin', + ZA_VAT = 'za_vat', +} diff --git a/packages/apps/job-launcher/server/src/common/guards/jwt.auth.ts b/packages/apps/job-launcher/server/src/common/guards/jwt.auth.ts index 00176a1008..f427218a91 100644 --- a/packages/apps/job-launcher/server/src/common/guards/jwt.auth.ts +++ b/packages/apps/job-launcher/server/src/common/guards/jwt.auth.ts @@ -53,6 +53,7 @@ export class JwtAuthGuard extends AuthGuard('jwt-http') implements CanActivate { // see https://github.com/nestjs/passport/blob/master/lib/auth.guard.ts return (await super.canActivate(context)) as boolean; } catch (jwtError) { + console.log(jwtError); switch (jwtError?.response?.statusCode) { case HttpStatus.UNAUTHORIZED: return this.handleApiKeyAuthentication(context); diff --git a/packages/apps/job-launcher/server/src/database/database.module.ts b/packages/apps/job-launcher/server/src/database/database.module.ts index fa83385d39..e6f30ebc60 100644 --- a/packages/apps/job-launcher/server/src/database/database.module.ts +++ b/packages/apps/job-launcher/server/src/database/database.module.ts @@ -15,7 +15,6 @@ import { ApiKeyEntity } from '../modules/auth/apikey.entity'; import { WebhookEntity } from '../modules/webhook/webhook.entity'; import { LoggerOptions } from 'typeorm'; import { CronJobEntity } from '../modules/cron-job/cron-job.entity'; -import { PaymentInfoEntity } from '../modules/payment/payment-info.entity'; import { WhitelistEntity } from 'src/modules/whitelist/whitelist.entity'; @Module({ @@ -49,7 +48,6 @@ import { WhitelistEntity } from 'src/modules/whitelist/whitelist.entity'; PaymentEntity, WebhookEntity, CronJobEntity, - PaymentInfoEntity, WhitelistEntity, ], // We are using migrations, synchronize should be set to false. diff --git a/packages/apps/job-launcher/server/src/database/migrations/1725449786396-paymentIntent.ts b/packages/apps/job-launcher/server/src/database/migrations/1731504887456-abuse.ts similarity index 76% rename from packages/apps/job-launcher/server/src/database/migrations/1725449786396-paymentIntent.ts rename to packages/apps/job-launcher/server/src/database/migrations/1731504887456-abuse.ts index fe982a8ba1..0b4526920b 100644 --- a/packages/apps/job-launcher/server/src/database/migrations/1725449786396-paymentIntent.ts +++ b/packages/apps/job-launcher/server/src/database/migrations/1731504887456-abuse.ts @@ -1,32 +1,23 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -export class PaymentIntent1725449786396 implements MigrationInterface { - name = 'PaymentIntent1725449786396'; +export class Abuse1731504887456 implements MigrationInterface { + name = 'Abuse1731504887456'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` - CREATE TABLE "hmt"."payments-info" ( - "id" SERIAL NOT NULL, - "created_at" TIMESTAMP WITH TIME ZONE NOT NULL, - "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL, - "customer_id" character varying NOT NULL, - "payment_method_id" character varying NOT NULL, - "user_id" integer NOT NULL, - CONSTRAINT "PK_b4970c6db0e80ea900a06ebc171" PRIMARY KEY ("id") - ) - `); - await queryRunner.query(` - CREATE UNIQUE INDEX "IDX_35c9ce414705e7b718a58aa6f0" ON "hmt"."payments-info" ("customer_id") + ALTER TABLE "hmt"."users" + ADD "stripe_customer_id" character varying `); await queryRunner.query(` - CREATE UNIQUE INDEX "IDX_63719fa3540ac47f61cc4a7ba1" ON "hmt"."payments-info" ("user_id") + ALTER TABLE "hmt"."users" + ADD CONSTRAINT "UQ_5ffbe395603641c29e8ce9b4c97" UNIQUE ("stripe_customer_id") `); await queryRunner.query(` ALTER TYPE "hmt"."payments_type_enum" RENAME TO "payments_type_enum_old" `); await queryRunner.query(` - CREATE TYPE "hmt"."payments_type_enum" AS ENUM('DEPOSIT', 'REFUND', 'WITHDRAWAL', 'SLASH') + CREATE TYPE "hmt"."payments_type_enum" AS ENUM('deposit', 'refund', 'withdrawal', 'slash') `); await queryRunner.query(` ALTER TABLE "hmt"."payments" @@ -84,16 +75,9 @@ export class PaymentIntent1725449786396 implements MigrationInterface { await queryRunner.query(` CREATE UNIQUE INDEX "IDX_012a8481fc9980fcc49f3f0dc2" ON "hmt"."webhook" ("chain_id", "escrow_address", "event_type") `); - await queryRunner.query(` - ALTER TABLE "hmt"."payments-info" - ADD CONSTRAINT "FK_63719fa3540ac47f61cc4a7ba11" FOREIGN KEY ("user_id") REFERENCES "hmt"."users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION - `); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(` - ALTER TABLE "hmt"."payments-info" DROP CONSTRAINT "FK_63719fa3540ac47f61cc4a7ba11" - `); await queryRunner.query(` DROP INDEX "hmt"."IDX_012a8481fc9980fcc49f3f0dc2" `); @@ -142,7 +126,7 @@ export class PaymentIntent1725449786396 implements MigrationInterface { CREATE UNIQUE INDEX "IDX_012a8481fc9980fcc49f3f0dc2" ON "hmt"."webhook" ("chain_id", "escrow_address", "event_type") `); await queryRunner.query(` - CREATE TYPE "hmt"."payments_type_enum_old" AS ENUM('DEPOSIT', 'REFUND', 'WITHDRAWAL') + CREATE TYPE "hmt"."payments_type_enum_old" AS ENUM('deposit', 'refund', 'withdrawal') `); await queryRunner.query(` ALTER TABLE "hmt"."payments" @@ -156,13 +140,10 @@ export class PaymentIntent1725449786396 implements MigrationInterface { RENAME TO "payments_type_enum" `); await queryRunner.query(` - DROP INDEX "hmt"."IDX_63719fa3540ac47f61cc4a7ba1" - `); - await queryRunner.query(` - DROP INDEX "hmt"."IDX_35c9ce414705e7b718a58aa6f0" + ALTER TABLE "hmt"."users" DROP CONSTRAINT "UQ_5ffbe395603641c29e8ce9b4c97" `); await queryRunner.query(` - DROP TABLE "hmt"."payments-info" + ALTER TABLE "hmt"."users" DROP COLUMN "stripe_customer_id" `); } } diff --git a/packages/apps/job-launcher/server/src/modules/job/job.dto.ts b/packages/apps/job-launcher/server/src/modules/job/job.dto.ts index 29587eff4b..b12d22759a 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.dto.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.dto.ts @@ -31,7 +31,7 @@ import { JobStatusFilter, WorkerBrowser, WorkerLanguage, - WorkerLocation, + Country, } from '../../common/enums/job'; import { Transform } from 'class-transformer'; import { AWSRegions, StorageProviders } from '../../common/enums/storage'; @@ -560,12 +560,12 @@ export class JobCaptchaAdvancedDto { workerLanguage?: WorkerLanguage; @ApiProperty({ - enum: WorkerLocation, + enum: Country, name: 'workerocation', }) - @IsEnumCaseInsensitive(WorkerLocation) + @IsEnumCaseInsensitive(Country) @IsOptional() - workerLocation?: WorkerLocation; + workerLocation?: Country; @ApiProperty({ enum: WorkerBrowser, diff --git a/packages/apps/job-launcher/server/src/modules/job/job.repository.ts b/packages/apps/job-launcher/server/src/modules/job/job.repository.ts index a95cd73671..cd2c13d088 100644 --- a/packages/apps/job-launcher/server/src/modules/job/job.repository.ts +++ b/packages/apps/job-launcher/server/src/modules/job/job.repository.ts @@ -2,7 +2,7 @@ import { ChainId } from '@human-protocol/sdk'; import { Injectable } from '@nestjs/common'; import { ServerConfigService } from '../../common/config/server-config.service'; import { SortDirection } from '../../common/enums/collection'; -import { DataSource, In, LessThanOrEqual } from 'typeorm'; +import { DataSource, In, LessThanOrEqual, Not } from 'typeorm'; import { JobSortField, JobStatus, @@ -18,6 +18,7 @@ import { JobCountDto, } from '../statistic/statistic.dto'; import { convertToDatabaseSortDirection } from '../../database/database.utils'; +import { PaymentSource } from 'src/common/enums/payment'; @Injectable() export class JobRepository extends BaseRepository { @@ -82,6 +83,21 @@ export class JobRepository extends BaseRepository { }); } + public async findActiveByUserAndPaymentSource( + userId: number, + paymentSource: PaymentSource, + ): Promise { + return this.find({ + where: { + userId, + status: Not(In([JobStatus.COMPLETED, JobStatus.CANCELED])), + payment: { + source: paymentSource, + }, + }, + }); + } + public async fetchFiltered( data: GetJobsDto, userId: number, 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 4de044dd1f..2f4b8de816 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 @@ -36,7 +36,7 @@ import { JobStatusFilter, WorkerBrowser, WorkerLanguage, - WorkerLocation, + Country, } from '../../common/enums/job'; import { MOCK_ADDRESS, @@ -1658,7 +1658,7 @@ describe('JobService', () => { maxRequests: 4, advanced: { workerLanguage: WorkerLanguage.EN, - workerLocation: WorkerLocation.FR, + workerLocation: Country.FR, targetBrowser: WorkerBrowser.DESKTOP, }, annotations: { diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment-info.entity.ts b/packages/apps/job-launcher/server/src/modules/payment/payment-info.entity.ts deleted file mode 100644 index 638ff3ceeb..0000000000 --- a/packages/apps/job-launcher/server/src/modules/payment/payment-info.entity.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; -import { NS } from '../../common/constants'; -import { BaseEntity } from '../../database/base.entity'; -import { UserEntity } from '../user/user.entity'; - -@Entity({ schema: NS, name: 'payments-info' }) -@Index(['userId'], { - unique: true, -}) -@Index(['customerId'], { - unique: true, -}) -export class PaymentInfoEntity extends BaseEntity { - @Column({ type: 'varchar' }) - public customerId: string; - - @Column({ type: 'varchar' }) - public paymentMethodId: string; - - @JoinColumn() - @ManyToOne(() => UserEntity, (user) => user.payments) - public user: UserEntity; - - @Column({ type: 'int' }) - public userId: number; -} diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment-info.repository.ts b/packages/apps/job-launcher/server/src/modules/payment/payment-info.repository.ts deleted file mode 100644 index 81f1df6406..0000000000 --- a/packages/apps/job-launcher/server/src/modules/payment/payment-info.repository.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { DataSource } from 'typeorm'; -import { BaseRepository } from '../../database/base.repository'; -import { PaymentInfoEntity } from './payment-info.entity'; - -@Injectable() -export class PaymentInfoRepository extends BaseRepository { - constructor(private dataSource: DataSource) { - super(PaymentInfoEntity, dataSource); - } - - public findOneByUser(userId: number): Promise { - return this.findOne({ - where: { - userId, - }, - }); - } -} diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts index 1d12ee7c75..f53b384b19 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts @@ -8,6 +8,8 @@ import { UseGuards, Headers, HttpStatus, + Patch, + Delete, } from '@nestjs/common'; import { ApiBearerAuth, @@ -20,11 +22,14 @@ import { JwtAuthGuard } from '../../common/guards'; import { RequestWithUser } from '../../common/types'; import { + BillingInfoDto, + BillingInfoUpdateDto, CardConfirmDto, GetRateDto, PaymentCryptoCreateDto, PaymentFiatConfirmDto, PaymentFiatCreateDto, + PaymentMethodIdDto, } from './payment.dto'; import { PaymentService } from './payment.service'; import { HEADER_SIGNATURE_KEY } from '../../common/constants'; @@ -44,6 +49,99 @@ export class PaymentController { private readonly rateService: RateService, ) {} + @ApiOperation({ + summary: 'Create a crypto payment', + description: 'Endpoint to create a new crypto payment.', + }) + @ApiBody({ type: PaymentCryptoCreateDto }) + @ApiResponse({ + status: 200, + description: 'Crypto payment created successfully', + type: Boolean, + }) + @ApiResponse({ + status: 400, + description: 'Bad Request. Invalid input parameters.', + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized. Missing or invalid credentials.', + }) + @ApiResponse({ + status: 404, + description: 'Not Found. Could not find the requested content.', + }) + @ApiResponse({ + status: 409, + description: 'Conflict. Conflict with the current state of the server.', + }) + // Disabled until billing system is active + // @UseGuards(WhitelistAuthGuard) + @Post('/crypto') + public async createCryptoPayment( + @Headers(HEADER_SIGNATURE_KEY) signature: string, + @Body() data: PaymentCryptoCreateDto, + @Request() req: RequestWithUser, + ): Promise { + return this.paymentService.createCryptoPayment( + req.user.id, + data, + signature, + ); + } + + @ApiOperation({ + summary: 'Get exchange rates', + description: 'Endpoint to get exchange rates.', + }) + @ApiResponse({ + status: 200, + description: 'Exchange rates retrieved successfully', + type: Number, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized. Missing or invalid credentials.', + }) + @ApiResponse({ + status: 404, + description: 'Not Found. Could not find the requested content.', + }) + @Get('/rates') + public async getRate(@Query() data: GetRateDto): Promise { + try { + return this.rateService.getRate(data.from, data.to); + } catch (e) { + throw new ControlledError( + 'Error getting rates', + HttpStatus.CONFLICT, + e.stack, + ); + } + } + + @ApiOperation({ + summary: 'Get Job Launcher minimum fee', + description: 'Endpoint to get Job Launcher minimum fee in USD.', + }) + @ApiResponse({ + status: 200, + description: 'Minimum fee retrieved successfully', + type: Number, + }) + @ApiResponse({ + status: 401, + description: 'Unauthorized. Missing or invalid credentials.', + }) + @ApiResponse({ + status: 404, + description: 'Not Found. Could not find the requested content.', + }) + @Get('/min-fee') + public async getMinFee(): Promise { + return this.serverConfigService.minimunFeeUsd; + } + @ApiOperation({ summary: 'Assign a card to a user', description: 'Endpoint to assign a card to an user.', @@ -158,116 +256,119 @@ export class PaymentController { } @ApiOperation({ - summary: 'Create a crypto payment', - description: 'Endpoint to create a new crypto payment.', + summary: 'Check if a card has already been assigned to the user', + description: + 'Endpoint to check if a card has already been assigned to the user.', }) - @ApiBody({ type: PaymentCryptoCreateDto }) @ApiResponse({ status: 200, - description: 'Crypto payment created successfully', + description: 'Card assigned succesfully', type: Boolean, }) - @ApiResponse({ - status: 400, - description: 'Bad Request. Invalid input parameters.', - }) @ApiResponse({ status: 401, description: 'Unauthorized. Missing or invalid credentials.', }) - @ApiResponse({ - status: 404, - description: 'Not Found. Could not find the requested content.', - }) - @ApiResponse({ - status: 409, - description: 'Conflict. Conflict with the current state of the server.', - }) - // Disabled until billing system is active - // @UseGuards(WhitelistAuthGuard) - @Post('/crypto') - public async createCryptoPayment( - @Headers(HEADER_SIGNATURE_KEY) signature: string, - @Body() data: PaymentCryptoCreateDto, + @Get('/fiat/check-card') + public async checkUserCard( @Request() req: RequestWithUser, ): Promise { - return this.paymentService.createCryptoPayment( - req.user.id, - data, - signature, - ); + return !!req.user?.stripeCustomerId; } @ApiOperation({ - summary: 'Get exchange rates', - description: 'Endpoint to get exchange rates.', + summary: 'List user cards', + description: 'Fetches all cards associated with the user.', }) @ApiResponse({ status: 200, - description: 'Exchange rates retrieved successfully', - type: Number, + description: 'Cards retrieved successfully', + type: Array, + }) + @Get('/fiat/cards') + public async listPaymentMethods(@Request() req: RequestWithUser) { + return this.paymentService.listUserPaymentMethods(req.user); + } + + @ApiOperation({ + summary: 'Delete a card', + description: + 'Deletes a specific card. If the card is the default payment method and is in use, returns an error.', }) @ApiResponse({ - status: 401, - description: 'Unauthorized. Missing or invalid credentials.', + status: 200, + description: 'Card deleted successfully', }) @ApiResponse({ - status: 404, - description: 'Not Found. Could not find the requested content.', + status: 400, + description: 'Cannot delete default card that is in use by a job.', }) - @Get('/rates') - public async getRate(@Query() data: GetRateDto): Promise { - try { - return this.rateService.getRate(data.from, data.to); - } catch (e) { - throw new ControlledError( - 'Error getting rates', - HttpStatus.CONFLICT, - e.stack, - ); - } + @Delete('/fiat/card') + public async deleteCard( + @Request() req: RequestWithUser, + @Query() data: PaymentMethodIdDto, + ): Promise { + await this.paymentService.deletePaymentMethod( + req.user, + data.paymentMethodId, + ); } @ApiOperation({ - summary: 'Get Job Launcher minimum fee', - description: 'Endpoint to get Job Launcher minimum fee in USD.', + summary: 'Get user billing information', + description: 'Fetches the billing information associated with the user.', }) @ApiResponse({ status: 200, - description: 'Minimum fee retrieved successfully', - type: Number, + description: 'Billing information retrieved successfully', + type: BillingInfoDto, }) - @ApiResponse({ - status: 401, - description: 'Unauthorized. Missing or invalid credentials.', + @Get('/fiat/billing-info') + public async getBillingInfo( + @Request() req: RequestWithUser, + ): Promise { + return this.paymentService.getUserBillingInfo(req.user); + } + + @ApiOperation({ + summary: 'Edit user billing information', + description: 'Updates the billing information associated with the user.', }) + @ApiBody({ type: BillingInfoUpdateDto }) @ApiResponse({ - status: 404, - description: 'Not Found. Could not find the requested content.', + status: 200, + description: 'Billing information updated successfully', }) - @Get('/min-fee') - public async getMinFee(): Promise { - return this.serverConfigService.minimunFeeUsd; + @Patch('/fiat/billing-info') + public async editBillingInfo( + @Request() req: RequestWithUser, + @Body() data: BillingInfoUpdateDto, + ): Promise { + await this.paymentService.updateUserBillingInfo(req.user, data); } @ApiOperation({ - summary: 'Check if a card has already been assigned to the user', + summary: 'Change default payment method', description: - 'Endpoint to check if a card has already been assigned to the user.', + 'Sets a specific card as the default payment method for the user.', }) @ApiResponse({ status: 200, - description: 'Card assigned succesfully', - type: Boolean, + description: 'Default payment method updated successfully', }) @ApiResponse({ - status: 401, - description: 'Unauthorized. Missing or invalid credentials.', + status: 400, + description: 'Cannot set the specified card as default.', }) - @Get('/check-card') - public async checkUserCard( + @ApiBody({ type: PaymentMethodIdDto }) + @Patch('/fiat/default-card') + public async changeDefaultCard( @Request() req: RequestWithUser, - ): Promise { - return !!req.user?.paymentInfo?.paymentMethodId; + @Body() data: PaymentMethodIdDto, + ): Promise { + await this.paymentService.changeDefaultPaymentMethod( + req.user, + data.paymentMethodId, + ); } } diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts index 519a920c46..355b802587 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts @@ -1,8 +1,17 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNumber, IsString, Min } from 'class-validator'; -import { Currency } from '../../common/enums/payment'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsBoolean, + IsEnum, + IsNumber, + IsObject, + IsOptional, + IsString, + Min, +} from 'class-validator'; +import { Currency, VatType } from '../../common/enums/payment'; import { ChainId } from '@human-protocol/sdk'; import { IsEnumCaseInsensitive } from '../../common/decorators'; +import { Country } from '../../common/enums/job'; export class PaymentFiatConfirmDto { @ApiProperty({ name: 'payment_id' }) @@ -21,6 +30,12 @@ export class PaymentFiatCreateDto { }) @IsEnumCaseInsensitive(Currency) public currency: Currency; + + @ApiProperty({ + name: 'payment_method_id', + }) + @IsString() + public paymentMethodId: string; } export class PaymentCryptoCreateDto { @@ -53,12 +68,89 @@ export class GetRateDto { } export class PaymentRefundCreateDto { + @ApiPropertyOptional({ name: 'refund_amount' }) @IsNumber() public refundAmount: number; + @ApiPropertyOptional({ name: 'user_id' }) @IsNumber() public userId: number; + @ApiPropertyOptional({ name: 'job_id' }) @IsNumber() public jobId: number; } + +export class AddressDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + public city?: string; + + @ApiPropertyOptional({ enum: Country }) + @IsOptional() + @IsEnum(Country) + public country?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + public line?: string; + + @ApiPropertyOptional({ name: 'postal_code' }) + @IsOptional() + @IsString() + public postalCode?: string; +} + +export class BillingInfoUpdateDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + public name?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsObject() + public address?: AddressDto; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + public vat?: string; + + @ApiPropertyOptional({ name: 'vat_type', enum: VatType }) + @IsOptional() + @IsEnum(VatType) + public vatType?: VatType; +} + +export class BillingInfoDto extends BillingInfoUpdateDto { + @ApiProperty() + @IsString() + public email?: string; +} + +export class PaymentMethodIdDto { + @ApiProperty({ name: 'payment_method_id' }) + @IsString() + public paymentMethodId: string; +} + +export class CardDto { + @ApiProperty() + @IsString() + public id: string; + + @ApiProperty({ name: 'last_4' }) + @IsString() + public last4: string; + + @ApiProperty() + @IsString() + public brand: string; + + @ApiProperty() + @IsBoolean() + public default: boolean; +} 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 74c1ed8b3b..bbf2d841cc 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 @@ -10,14 +10,16 @@ import { PaymentRepository } from './payment.repository'; import { HttpModule } from '@nestjs/axios'; import { Web3Module } from '../web3/web3.module'; import { RateService } from './rate.service'; -import { PaymentInfoEntity } from './payment-info.entity'; -import { PaymentInfoRepository } from './payment-info.repository'; import { WhitelistModule } from '../whitelist/whitelist.module'; +import { JobEntity } from '../job/job.entity'; +import { UserEntity } from '../user/user.entity'; +import { JobRepository } from '../job/job.repository'; +import { UserRepository } from '../user/user.repository'; @Module({ imports: [ HttpModule, - TypeOrmModule.forFeature([PaymentEntity, PaymentInfoEntity]), + TypeOrmModule.forFeature([PaymentEntity, JobEntity, UserEntity]), ConfigModule, Web3Module, WhitelistModule, @@ -40,13 +42,9 @@ import { WhitelistModule } from '../whitelist/whitelist.module'; PaymentService, PaymentRepository, RateService, - PaymentInfoRepository, - ], - exports: [ - PaymentService, - PaymentRepository, - RateService, - PaymentInfoRepository, + JobRepository, + UserRepository, ], + exports: [PaymentService, PaymentRepository, RateService], }) export class PaymentModule {} diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.service.spec.ts index f979604488..67f8c7270c 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 @@ -18,6 +18,7 @@ import { PaymentType, StripePaymentStatus, TokenId, + VatType, } from '../../common/enums/payment'; import { TX_CONFIRMATION_TRESHOLD } from '../../common/constants'; import { @@ -27,7 +28,6 @@ import { MOCK_TRANSACTION_HASH, mockConfig, } from '../../../test/constants'; -import { PaymentInfoRepository } from './payment-info.repository'; import { ServerConfigService } from '../../common/config/server-config.service'; import { Web3Service } from '../web3/web3.service'; import { HMToken__factory } from '@human-protocol/core/typechain-types'; @@ -40,6 +40,8 @@ import { StripeConfigService } from '../../common/config/stripe-config.service'; import { NetworkConfigService } from '../../common/config/network-config.service'; import { ControlledError } from '../../common/errors/controlled'; import { RateService } from './rate.service'; +import { UserRepository } from '../user/user.repository'; +import { JobRepository } from '../job/job.repository'; jest.mock('@human-protocol/sdk'); @@ -51,7 +53,7 @@ describe('PaymentService', () => { let stripe: Stripe; let paymentService: PaymentService; let paymentRepository: PaymentRepository; - let paymentInfoRepository: PaymentInfoRepository; + let userRepository: UserRepository; const signerMock = { address: MOCK_ADDRESS, @@ -80,8 +82,12 @@ describe('PaymentService', () => { useValue: createMock(), }, { - provide: PaymentInfoRepository, - useValue: createMock(), + provide: UserRepository, + useValue: createMock(), + }, + { + provide: JobRepository, + useValue: createMock(), }, { provide: Web3Service, @@ -105,13 +111,16 @@ describe('PaymentService', () => { paymentService = moduleRef.get(PaymentService); paymentRepository = moduleRef.get(PaymentRepository); - - paymentInfoRepository = moduleRef.get(PaymentInfoRepository); + userRepository = moduleRef.get(UserRepository); stripe = { customers: { create: jest.fn(), update: jest.fn(), + listPaymentMethods: jest.fn(), + listTaxIds: jest.fn(), + createTaxId: jest.fn(), + retrieve: jest.fn(), }, paymentIntents: { create: jest.fn(), @@ -121,6 +130,10 @@ describe('PaymentService', () => { create: jest.fn(), retrieve: jest.fn(), }, + paymentMethods: { + retrieve: jest.fn(), + detach: jest.fn(), + }, } as any; paymentService['stripe'] = stripe; @@ -143,33 +156,39 @@ describe('PaymentService', () => { const dto = { amount: 100, currency: Currency.USD, + paymentMethodId: 'pm_123', }; const user = { id: 1, - paymentInfo: { - customerId: 'test', - paymentMethodId: 'test', - }, + stripeCustomerId: 'cus_123', }; const paymentIntent = { + id: 'pi_123', client_secret: 'clientSecret123', }; - createPaymentIntentMock.mockResolvedValue(paymentIntent); - findOneMock.mockResolvedValue(null); + jest + .spyOn(stripe.paymentIntents, 'create') + .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(createPaymentIntentMock).toHaveBeenCalledWith({ - amount: dto.amount * 100, - currency: dto.currency, - customer: 'test', + expect(result).toEqual(paymentIntent.client_secret); + expect(stripe.paymentIntents.create).toHaveBeenCalledWith({ + amount: 10000, + currency: Currency.USD, + customer: 'cus_123', + payment_method: 'pm_123', off_session: false, - payment_method: 'test', }); - expect(result).toEqual(paymentIntent.client_secret); }); it('should throw a bad request exception if transaction already exist', async () => { @@ -177,6 +196,7 @@ describe('PaymentService', () => { const dto = { amount: 100, currency: Currency.USD, + paymentMethodId: 'pm_123', }; const user = { @@ -211,6 +231,7 @@ describe('PaymentService', () => { const dto = { amount: 100, currency: Currency.USD, + paymentMethodId: 'pm_123', }; const user = { @@ -807,11 +828,13 @@ describe('PaymentService', () => { ).rejects.toThrow(new DatabaseError('', '')); }); }); + describe('createCustomerAndAssignCard', () => { it('should create a customer and assign a card successfully', async () => { const user = { id: 1, email: 'test@hmt.ai', + stripeCustomerId: null, }; const paymentIntent = { @@ -820,7 +843,7 @@ describe('PaymentService', () => { jest .spyOn(stripe.customers, 'create') - .mockResolvedValue({ id: 1 } as any); + .mockResolvedValue({ id: 'cus_123' } as any); jest .spyOn(stripe.setupIntents, 'create') .mockResolvedValue(paymentIntent as any); @@ -834,26 +857,23 @@ describe('PaymentService', () => { email: user.email, }); expect(stripe.setupIntents.create).toHaveBeenCalledWith({ - automatic_payment_methods: { - enabled: true, - }, - customer: 1, + automatic_payment_methods: { enabled: true }, + customer: 'cus_123', }); }); - it('should throw a bad request exception if user payment info already exist', async () => { + it('should throw a bad request exception if user already has a stripeCustomerId', async () => { const user = { id: 1, email: 'test@hmt.ai', - paymentInfo: { - customerId: 'test', - paymentMethodId: 'test', - }, + stripeCustomerId: 'cus_123', }; await expect( paymentService.createCustomerAndAssignCard(user as any), - ).rejects.toThrow(ErrorPayment.CardAssigned); + ).rejects.toThrow( + new ControlledError(ErrorPayment.CardAssigned, HttpStatus.NOT_FOUND), + ); }); it('should throw a bad request exception if the customer creation fails', async () => { @@ -906,36 +926,40 @@ describe('PaymentService', () => { }); describe('confirmCard', () => { - it('should confirm a card and create payment info successfully', async () => { + it('should confirm a card and update user stripeCustomerId successfully', async () => { const user = { id: 1, email: 'test@hmt.ai', + stripeCustomerId: null, }; const setupMock = { - customer: 1, - payment_method: 1, + customer: 'cus_123', + payment_method: 'pm_123', }; jest .spyOn(stripe.setupIntents, 'retrieve') .mockResolvedValue(setupMock as any); jest.spyOn(stripe.customers, 'update').mockResolvedValue(null as any); + jest + .spyOn(userRepository, 'updateOne') + .mockResolvedValue(undefined as any); const result = await paymentService.confirmCard(user as any, { - setupId: '1', + setupId: 'setup_123', }); expect(result).toBeTruthy(); - expect(paymentInfoRepository.createUnique).toHaveBeenCalledWith({ - user: user, - customerId: setupMock.customer, - paymentMethodId: setupMock.payment_method, - }); - expect(stripe.setupIntents.retrieve).toHaveBeenCalledWith('1'); - expect(stripe.customers.update).toHaveBeenCalledWith(setupMock.customer, { + expect(userRepository.updateOne).toHaveBeenCalledWith( + expect.objectContaining({ + stripeCustomerId: 'cus_123', + }), + ); + expect(stripe.setupIntents.retrieve).toHaveBeenCalledWith('setup_123'); + expect(stripe.customers.update).toHaveBeenCalledWith('cus_123', { invoice_settings: { - default_payment_method: setupMock.payment_method, + default_payment_method: 'pm_123', }, }); }); @@ -963,6 +987,7 @@ describe('PaymentService', () => { const user = { id: 1, email: 'test@hmt.ai', + stripeCustomerId: 'cus_123', }; const jobEntity = { @@ -971,20 +996,12 @@ describe('PaymentService', () => { userId: user.id, }; - const paymentInfo = { - customerId: 'test', - paymentMethodId: 'test', - }; - const paymentIntent = { - id: 1, + id: 'pi_123', client_secret: 'clientSecret123', }; - jest - .spyOn(paymentInfoRepository, 'findOneByUser') - .mockResolvedValue(paymentInfo as any); - + jest.spyOn(userRepository, 'findById').mockResolvedValue(user as any); jest .spyOn(stripe.paymentIntents, 'create') .mockResolvedValue(paymentIntent as any); @@ -995,8 +1012,7 @@ describe('PaymentService', () => { expect(stripe.paymentIntents.create).toHaveBeenCalledWith({ amount: expect.any(Number), currency: Currency.USD, - customer: paymentInfo.customerId, - payment_method: paymentInfo.paymentMethodId, + customer: 'cus_123', off_session: true, confirm: true, }); @@ -1016,8 +1032,8 @@ describe('PaymentService', () => { }; jest - .spyOn(paymentInfoRepository, 'findOneByUser') - .mockResolvedValue(null); + .spyOn(userRepository, 'findById') + .mockResolvedValue(undefined as any); await expect( paymentService.createSlash(jobEntity as any), @@ -1036,20 +1052,211 @@ describe('PaymentService', () => { userId: user.id, }; - const paymentInfo = { - customerId: 'test', - paymentMethodId: 'test', + jest.spyOn(userRepository, 'findById').mockResolvedValue(user as any); + jest.spyOn(stripe.paymentIntents, 'create').mockResolvedValue({} as any); + + await expect( + paymentService.createSlash(jobEntity as any), + ).rejects.toThrow(ErrorPayment.ClientSecretDoesNotExist); + }); + }); + describe('listUserPaymentMethods', () => { + it('should list user payment methods successfully', async () => { + const user = { + id: 1, + stripeCustomerId: 'cus_123', + }; + + const paymentMethods = { + data: [ + { id: 'pm_123', card: { brand: 'visa', last4: '4242' } }, + { id: 'pm_456', card: { brand: 'mastercard', last4: '5555' } }, + ], }; jest - .spyOn(paymentInfoRepository, 'findOneByUser') - .mockResolvedValue(paymentInfo as any); + .spyOn(stripe.customers, 'listPaymentMethods') + .mockResolvedValue(paymentMethods as any); + jest + .spyOn(paymentService as any, 'getDefaultPaymentMethod') + .mockResolvedValue('pm_123'); - jest.spyOn(stripe.paymentIntents, 'create').mockResolvedValue({} as any); + const result = await paymentService.listUserPaymentMethods(user as any); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + id: 'pm_123', + brand: 'visa', + last4: '4242', + default: true, + }); + expect(result[1]).toEqual({ + id: 'pm_456', + brand: 'mastercard', + last4: '5555', + default: false, + }); + }); + }); + + describe('deletePaymentMethod', () => { + it('should delete a payment method successfully', async () => { + const user = { + id: 1, + stripeCustomerId: 'cus_123', + }; + + jest + .spyOn(stripe.paymentMethods, 'retrieve') + .mockResolvedValue({ id: 'pm_123' } as any); + jest + .spyOn(paymentService as any, 'getDefaultPaymentMethod') + .mockResolvedValue('pm_456'); + jest + .spyOn(paymentService as any, 'isPaymentMethodInUse') + .mockResolvedValue(false); + jest.spyOn(stripe.paymentMethods, 'detach').mockResolvedValue({} as any); + + await paymentService.deletePaymentMethod(user as any, 'pm_123'); + + expect(stripe.paymentMethods.detach).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', + }; + + jest + .spyOn(stripe.paymentMethods, 'retrieve') + .mockResolvedValue({ id: 'pm_123' } as any); + jest + .spyOn(paymentService as any, 'getDefaultPaymentMethod') + .mockResolvedValue('pm_123'); + jest + .spyOn(paymentService as any, 'isPaymentMethodInUse') + .mockResolvedValue(true); await expect( - paymentService.createSlash(jobEntity as any), - ).rejects.toThrow(ErrorPayment.ClientSecretDoesNotExist); + paymentService.deletePaymentMethod(user as any, 'pm_123'), + ).rejects.toThrow( + new ControlledError( + ErrorPayment.PaymentMethodInUse, + HttpStatus.BAD_REQUEST, + ), + ); + }); + }); + + describe('getUserBillingInfo', () => { + it('should get user billing info successfully', async () => { + const user = { + id: 1, + stripeCustomerId: 'cus_123', + }; + + const taxIds = { + data: [{ type: VatType.EU_VAT, value: 'DE123456789' }], + }; + + const customer = { + name: 'John Doe', + email: 'john@example.com', + address: { + country: 'DE', + postal_code: '12345', + city: 'Berlin', + line1: 'Street 1', + }, + }; + + jest + .spyOn(stripe.customers, 'listTaxIds') + .mockResolvedValue(taxIds as any); + jest + .spyOn(stripe.customers, 'retrieve') + .mockResolvedValue(customer as any); + + const result = await paymentService.getUserBillingInfo(user as any); + + expect(result).toEqual({ + name: 'John Doe', + email: 'john@example.com', + vat: 'DE123456789', + vatType: VatType.EU_VAT, + address: { + country: 'DE', + postalCode: '12345', + city: 'Berlin', + line: 'Street 1', + }, + }); + }); + }); + + describe('updateUserBillingInfo', () => { + it('should update user billing info successfully', async () => { + const user = { + id: 1, + stripeCustomerId: 'cus_123', + }; + + const updateBillingInfoDto = { + name: 'John Doe', + email: 'john@example.com', + vat: 'DE123456789', + vatType: VatType.EU_VAT, + address: { + country: 'DE', + postalCode: '12345', + city: 'Berlin', + line: 'Street 1', + }, + }; + + 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); + + 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', + }, + }); + }); + }); + + describe('changeDefaultPaymentMethod', () => { + it('should change the default payment method successfully', async () => { + const user = { + id: 1, + stripeCustomerId: 'cus_123', + }; + + jest.spyOn(stripe.customers, 'update').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' }, + }); }); }); }); 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 158ea20d8b..2a9ed6ee6d 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 @@ -4,7 +4,10 @@ import { ethers } from 'ethers'; import { ErrorPayment } from '../../common/constants/errors'; import { PaymentRepository } from './payment.repository'; import { + AddressDto, + BillingInfoDto, CardConfirmDto, + CardDto, PaymentCryptoCreateDto, PaymentFiatConfirmDto, PaymentFiatCreateDto, @@ -17,6 +20,7 @@ import { PaymentType, StripePaymentStatus, TokenId, + VatType, } from '../../common/enums/payment'; import { TX_CONFIRMATION_TRESHOLD } from '../../common/constants'; import { NetworkConfigService } from '../../common/config/network-config.service'; @@ -33,10 +37,10 @@ import { PaymentEntity } from './payment.entity'; import { ControlledError } from '../../common/errors/controlled'; import { RateService } from './rate.service'; import { UserEntity } from '../user/user.entity'; -import { PaymentInfoRepository } from './payment-info.repository'; -import { PaymentInfoEntity } from './payment-info.entity'; import { JobEntity } from '../job/job.entity'; import { ServerConfigService } from '../../common/config/server-config.service'; +import { UserRepository } from '../user/user.repository'; +import { JobRepository } from '../job/job.repository'; @Injectable() export class PaymentService { @@ -47,7 +51,8 @@ export class PaymentService { private readonly networkConfigService: NetworkConfigService, private readonly web3Service: Web3Service, private readonly paymentRepository: PaymentRepository, - private readonly paymentInfoRepository: PaymentInfoRepository, + private readonly userRepository: UserRepository, + private readonly jobRepository: JobRepository, private stripeConfigService: StripeConfigService, private rateService: RateService, private serverConfigService: ServerConfigService, @@ -65,7 +70,7 @@ export class PaymentService { public async createCustomerAndAssignCard(user: UserEntity): Promise { let setupIntent: Stripe.Response; - if (!!user.paymentInfo) { + if (user.stripeCustomerId) { this.logger.log(ErrorPayment.CardAssigned, PaymentService.name); throw new ControlledError( ErrorPayment.CardAssigned, @@ -126,11 +131,8 @@ export class PaymentService { }, }); - const paymentInfo = new PaymentInfoEntity(); - paymentInfo.user = user; - paymentInfo.customerId = setup.customer as string; - paymentInfo.paymentMethodId = setup.payment_method as string; - await this.paymentInfoRepository.createUnique(paymentInfo); + user.stripeCustomerId = setup.customer as string; + await this.userRepository.updateOne(user); return true; } @@ -145,8 +147,8 @@ export class PaymentService { const params: Stripe.PaymentIntentCreateParams = { amount: amountInCents, currency: currency, - customer: user.paymentInfo.customerId, - payment_method: user.paymentInfo.paymentMethodId, + customer: user.stripeCustomerId, + payment_method: dto.paymentMethodId, off_session: false, }; @@ -374,14 +376,13 @@ export class PaymentService { }); await this.paymentRepository.createUnique(paymentEntity); } + public async createSlash(job: JobEntity): Promise { const amount = this.serverConfigService.abuseAmount, currency = Currency.USD; - const paymentInfo = await this.paymentInfoRepository.findOneByUser( - job.userId, - ); - if (!paymentInfo) { + const user = await this.userRepository.findById(job.userId); + if (!user) { this.logger.log(ErrorPayment.CustomerNotFound, PaymentService.name); throw new ControlledError( ErrorPayment.CustomerNotFound, @@ -393,8 +394,7 @@ export class PaymentService { const params: Stripe.PaymentIntentCreateParams = { amount: amountInCents, currency: currency, - customer: paymentInfo.customerId, - payment_method: paymentInfo.paymentMethodId, + customer: user.stripeCustomerId, off_session: true, confirm: true, }; @@ -439,4 +439,137 @@ export class PaymentService { return; } + + async listUserPaymentMethods(user: UserEntity) { + const paymentMethods = await this.stripe.customers.listPaymentMethods( + user.stripeCustomerId, + { + type: 'card', + limit: 100, + }, + ); + + const cards: CardDto[] = []; + const defaultPaymentMethod = await this.getDefaultPaymentMethod( + user.stripeCustomerId, + ); + + for (const paymentMethod of paymentMethods.data) { + const card = new CardDto(); + card.id = paymentMethod.id; + card.brand = paymentMethod.card?.brand as string; + card.last4 = paymentMethod.card?.last4 as string; + card.default = defaultPaymentMethod === paymentMethod.id; + cards.push(card); + } + return cards; + } + + async deletePaymentMethod(user: UserEntity, paymentMethodId: string) { + const paymentMethod = + await this.stripe.paymentMethods.retrieve(paymentMethodId); + if ( + paymentMethod.id === + (await this.getDefaultPaymentMethod(user.stripeCustomerId)) && + (await this.isPaymentMethodInUse(user.id)) + ) { + throw new ControlledError( + ErrorPayment.PaymentMethodInUse, + HttpStatus.BAD_REQUEST, + ); + } + + return this.stripe.paymentMethods.detach(paymentMethodId); + } + + async getUserBillingInfo(user: UserEntity) { + const taxIds = await this.stripe.customers.listTaxIds( + user.stripeCustomerId, + ); + + const customer = await this.stripe.customers.retrieve( + user.stripeCustomerId, + ); + + const userBillingInfo = new BillingInfoDto(); + if ((customer as Stripe.Customer).address) { + const address = new AddressDto(); + address.country = (customer as Stripe.Customer).address + ?.country as string; + address.postalCode = (customer as Stripe.Customer).address + ?.postal_code as string; + address.city = (customer as Stripe.Customer).address?.city as string; + address.line = (customer as Stripe.Customer).address?.line1 as string; + userBillingInfo.address = address; + } + userBillingInfo.name = (customer as Stripe.Customer).name as string; + userBillingInfo.email = (customer as Stripe.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 (updateBillingInfoDto.vat && updateBillingInfoDto.vatType) { + const existingTaxIds = await this.stripe.customers.listTaxIds( + user.stripeCustomerId, + ); + + for (const taxId of existingTaxIds.data) { + await this.stripe.customers.deleteTaxId( + user.stripeCustomerId, + taxId.id, + ); + } + await this.stripe.customers.createTaxId(user.stripeCustomerId, { + type: updateBillingInfoDto.vatType, + value: updateBillingInfoDto.vat, + }); + } + + 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, + }); + } + } + + async changeDefaultPaymentMethod(user: UserEntity, cardId: string) { + return this.stripe.customers.update(user.stripeCustomerId, { + invoice_settings: { default_payment_method: cardId }, + }); + } + + private async getDefaultPaymentMethod( + customerId: string, + ): Promise { + const customer = await this.stripe.customers.retrieve(customerId); + return (customer as Stripe.Customer).invoice_settings + .default_payment_method as string; + } + + private async isPaymentMethodInUse(userId: number): Promise { + return ( + ( + await this.jobRepository.findActiveByUserAndPaymentSource( + userId, + PaymentSource.FIAT, + ) + ).length == 0 + ); + } } diff --git a/packages/apps/job-launcher/server/src/modules/payment/rate.service.ts b/packages/apps/job-launcher/server/src/modules/payment/rate.service.ts index 95fb35fbe7..a381fa30ab 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/rate.service.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/rate.service.ts @@ -81,8 +81,6 @@ export class RateService { return finalRate; } catch (error) { - console.log(error); - // try { // const coinMarketCapFrom = CoinMarketCupTokenId[from]; // const coinMarketCapTo = to; 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 89b740775f..f67be393be 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 @@ -8,7 +8,6 @@ import { UserStatus, UserType } from '../../common/enums/user'; import { PaymentEntity } from '../payment/payment.entity'; import { JobEntity } from '../job/job.entity'; import { ApiKeyEntity } from '../auth/apikey.entity'; -import { PaymentInfoEntity } from '../payment/payment-info.entity'; import { WhitelistEntity } from '../whitelist/whitelist.entity'; @Entity({ schema: NS, name: 'users' }) @@ -29,15 +28,15 @@ export class UserEntity extends BaseEntity implements IUser { }) public status: UserStatus; + @Column({ type: 'varchar', nullable: true, unique: true }) + public stripeCustomerId: string; + @OneToMany(() => JobEntity, (job) => job.user) public jobs: JobEntity[]; @OneToMany(() => PaymentEntity, (payment) => payment.user) public payments: PaymentEntity[]; - @OneToOne(() => PaymentInfoEntity, (payment) => payment.user) - public paymentInfo: PaymentInfoEntity; - @OneToOne(() => ApiKeyEntity, (apiKey) => apiKey.user, { nullable: true, }) diff --git a/packages/apps/job-launcher/server/src/modules/user/user.repository.ts b/packages/apps/job-launcher/server/src/modules/user/user.repository.ts index 6717fba1e1..c76fcee326 100644 --- a/packages/apps/job-launcher/server/src/modules/user/user.repository.ts +++ b/packages/apps/job-launcher/server/src/modules/user/user.repository.ts @@ -10,7 +10,7 @@ export class UserRepository extends BaseRepository { } async findById(id: number): Promise { - return this.findOne({ where: { id }, relations: ['paymentInfo'] }); + return this.findOne({ where: { id } }); } async findByEmail(email: string): Promise { From 1b2b32e7b92a26cf535b92b251cda42e3118f632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Mon, 18 Nov 2024 17:26:19 +0100 Subject: [PATCH 06/11] Billing system implementation: - Modal to add card - Modal to delete card - Set as default button - Modal to select card - Edited fiat forms to use new billing system - Modal for billing details --- packages/apps/job-launcher/client/src/App.tsx | 12 +- .../BillingDetails/BillingDetailsModal.tsx | 238 +++++++ .../components/CreditCard/AddCardModal.tsx | 72 ++ .../src/components/CreditCard/CardList.tsx | 151 ++++ .../CardSetupForm.tsx | 41 +- .../components/CreditCard/DeleteCardModal.tsx | 214 ++++++ .../components/CreditCard/SelectCardModal.tsx | 111 +++ .../components/Jobs/Create/FiatPayForm.tsx | 150 +++- .../src/components/Jobs/Create/PayJob.tsx | 62 +- .../src/components/SuccessModal/index.tsx | 91 +++ .../components/TopUpAccount/FiatTopUpForm.tsx | 99 ++- .../client/src/constants/payment.ts | 672 ++++++++++++++++++ .../src/pages/Profile/Settings/index.tsx | 203 ++++++ .../src/pages/Profile/TopUpAccount/index.tsx | 2 +- .../client/src/services/payment.ts | 34 +- .../job-launcher/client/src/types/index.ts | 25 + .../server/src/common/constants/errors.ts | 1 - .../src/modules/payment/payment.controller.ts | 21 - .../server/src/modules/payment/payment.dto.ts | 12 + .../modules/payment/payment.service.spec.ts | 23 +- .../src/modules/payment/payment.service.ts | 67 +- 21 files changed, 2121 insertions(+), 180 deletions(-) create mode 100644 packages/apps/job-launcher/client/src/components/BillingDetails/BillingDetailsModal.tsx create mode 100644 packages/apps/job-launcher/client/src/components/CreditCard/AddCardModal.tsx create mode 100644 packages/apps/job-launcher/client/src/components/CreditCard/CardList.tsx rename packages/apps/job-launcher/client/src/components/{CardSetup => CreditCard}/CardSetupForm.tsx (75%) create mode 100644 packages/apps/job-launcher/client/src/components/CreditCard/DeleteCardModal.tsx create mode 100644 packages/apps/job-launcher/client/src/components/CreditCard/SelectCardModal.tsx create mode 100644 packages/apps/job-launcher/client/src/components/SuccessModal/index.tsx create mode 100644 packages/apps/job-launcher/client/src/pages/Profile/Settings/index.tsx diff --git a/packages/apps/job-launcher/client/src/App.tsx b/packages/apps/job-launcher/client/src/App.tsx index 1d8f44743b..5f640fe725 100644 --- a/packages/apps/job-launcher/client/src/App.tsx +++ b/packages/apps/job-launcher/client/src/App.tsx @@ -1,5 +1,4 @@ -import React from 'react'; -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { ProtectedRoute } from './components/ProtectedRoute'; import Layout from './layouts'; import Dashboard from './pages/Dashboard'; @@ -8,6 +7,7 @@ import Home from './pages/Home'; import CreateJob from './pages/Job/CreateJob'; import JobDetail from './pages/Job/JobDetail'; import JobList from './pages/Job/JobList'; +import Settings from './pages/Profile/Settings'; import TopUpAccount from './pages/Profile/TopUpAccount'; import ResetPassword from './pages/ResetPassword'; import ValidateEmail from './pages/ValidateEmail'; @@ -65,6 +65,14 @@ export default function App() { } /> + + + + } + /> } /> diff --git a/packages/apps/job-launcher/client/src/components/BillingDetails/BillingDetailsModal.tsx b/packages/apps/job-launcher/client/src/components/BillingDetails/BillingDetailsModal.tsx new file mode 100644 index 0000000000..86013ff0f2 --- /dev/null +++ b/packages/apps/job-launcher/client/src/components/BillingDetails/BillingDetailsModal.tsx @@ -0,0 +1,238 @@ +import CloseIcon from '@mui/icons-material/Close'; +import { LoadingButton } from '@mui/lab'; +import { + Box, + Dialog, + IconButton, + MenuItem, + TextField, + Typography, + useTheme, +} from '@mui/material'; +import { useEffect, useState } from 'react'; +import { useSnackbar } from '../..//providers/SnackProvider'; +import { countryOptions, vatTypeOptions } from '../../constants/payment'; +import { editUserBillingInfo } from '../../services/payment'; +import { BillingInfo } from '../../types'; + +const BillingDetailsModal = ({ + open, + onClose, + billingInfo, + setBillingInfo, +}: { + open: boolean; + onClose: () => void; + billingInfo: BillingInfo; + setBillingInfo: (value: BillingInfo) => void; +}) => { + const theme = useTheme(); + const [isLoading, setIsLoading] = useState(false); + const [formData, setFormData] = useState(billingInfo); + const [errors, setErrors] = useState<{ [key: string]: string }>({}); + const { showError } = useSnackbar(); + + useEffect(() => { + if (billingInfo) { + setFormData(billingInfo); + } + }, [billingInfo]); + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + + if (['city', 'country', 'line', 'postalCode'].includes(name)) { + setFormData((prevFormData) => ({ + ...prevFormData, + address: { + ...prevFormData.address, + [name]: value, + }, + })); + } else { + setFormData({ + ...formData, + [name]: value, + }); + } + }; + + const validateForm = () => { + let newErrors: { [key: string]: string } = {}; + + if (!formData.name) { + newErrors.name = 'Name is required'; + } + + const addressFields = ['line', 'postalCode', 'city', 'country']; + const hasAddressFields = addressFields.some( + (field) => formData.address[field as keyof typeof formData.address], + ); + const allAddressFieldsFilled = addressFields.every( + (field) => formData.address[field as keyof typeof formData.address], + ); + + if (hasAddressFields && !allAddressFieldsFilled) { + newErrors.address = 'All address fields must be filled or none.'; + } + + if (formData.vat && !formData.vatType) { + newErrors.vatType = 'VAT type is required if VAT number is provided'; + } + + setErrors(newErrors); + + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async () => { + if (validateForm()) { + setIsLoading(true); + try { + delete formData.email; + await editUserBillingInfo(formData); + setBillingInfo(formData); + } catch (err: any) { + showError( + err.message || 'An error occurred while setting up the card.', + ); + } + setIsLoading(false); + onClose(); + } + }; + + return ( + + + + + {billingInfo ? 'Edit Billing Details' : 'Add Billing Details'} + + + + + + + + + Details + + + + + + {Object.entries(countryOptions).map(([key, label]) => ( + + {label} + + ))} + + + {/* VAT Section */} + + + {Object.entries(vatTypeOptions).map(([key, label]) => ( + + {label} + + ))} + + + + + + {billingInfo ? 'Save Changes' : 'Add Billing Details'} + + + + + + ); +}; + +export default BillingDetailsModal; diff --git a/packages/apps/job-launcher/client/src/components/CreditCard/AddCardModal.tsx b/packages/apps/job-launcher/client/src/components/CreditCard/AddCardModal.tsx new file mode 100644 index 0000000000..ffe366b875 --- /dev/null +++ b/packages/apps/job-launcher/client/src/components/CreditCard/AddCardModal.tsx @@ -0,0 +1,72 @@ +import CloseIcon from '@mui/icons-material/Close'; +import { Box, Dialog, IconButton, Typography, useTheme } from '@mui/material'; +import { CardSetupForm } from './CardSetupForm'; + +const AddCardModal = ({ + open, + onClose, + onComplete, +}: { + open: boolean; + onClose: () => void; + onComplete: () => void; +}) => { + const theme = useTheme(); + + const handleCardAdded = () => { + onComplete(); + onClose(); + }; + + return ( + + + + + Add Credit Card Details + + + We need you to add a credit card in order to comply with HUMAN’s + Abuse Mechanism. Learn more about it here. +
+
+ This card will be used for funding the jobs requested. +
+
+ + + + + + + + +
+
+ ); +}; + +export default AddCardModal; diff --git a/packages/apps/job-launcher/client/src/components/CreditCard/CardList.tsx b/packages/apps/job-launcher/client/src/components/CreditCard/CardList.tsx new file mode 100644 index 0000000000..7efe915379 --- /dev/null +++ b/packages/apps/job-launcher/client/src/components/CreditCard/CardList.tsx @@ -0,0 +1,151 @@ +import DeleteIcon from '@mui/icons-material/Delete'; +import { Box, Button, IconButton, Typography, useTheme } from '@mui/material'; +import React, { useState } from 'react'; +import { useSnackbar } from '../../providers/SnackProvider'; +import { setUserDefaultCard } from '../../services/payment'; +import { CardData } from '../../types'; +import { CardIcon } from '../Icons/CardIcon'; +import DeleteCardModal from './DeleteCardModal'; + +interface CardListProps { + cards: CardData[]; + fetchCards: () => void; + successMessage: (message: string) => void; + openAddCreditCardModal: (open: boolean) => void; +} + +const CardList: React.FC = ({ + cards, + fetchCards, + successMessage, + openAddCreditCardModal, +}) => { + const theme = useTheme(); + const { showError } = useSnackbar(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedCard, setSelectedCard] = useState( + undefined, + ); + + const isCardExpired = (cardMonth: number, cardYear: number) => { + const currentDate = new Date(); + const currentMonth = currentDate.getMonth() + 1; + const currentYear = currentDate.getFullYear(); + return ( + cardYear < currentYear || + (cardYear === currentYear && cardMonth < currentMonth) + ); + }; + + const handleSetDefaultCard = async (cardId: string) => { + try { + await setUserDefaultCard(cardId); + fetchCards(); + successMessage('Your card has been successfully updated.'); + } catch (error) { + showError('Error setting default card'); + } + }; + + const handleOpenDeleteModal = (card: CardData) => { + setSelectedCard(card); + setIsDeleteModalOpen(true); + }; + + const handleDeleteSuccess = () => { + if (selectedCard) { + fetchCards(); + successMessage('Your card has been successfully deleted.'); + setIsDeleteModalOpen(false); + } + }; + + return ( + + {cards.map((card) => ( + + + + + + **** **** **** {card.last4} + + + Exp. date {card.expMonth}/{card.expYear} + + + + + + {card.default ? ( + + ) : isCardExpired(card.expMonth, card.expYear) ? ( + + ) : ( + + )} + handleOpenDeleteModal(card)}> + + + + + ))} + {cards.length === 0 && ( + No cards to show + )} + + {selectedCard && ( + setIsDeleteModalOpen(false)} + cardId={selectedCard.id} + isDefault={selectedCard.default} + hasMultipleCards={cards.length > 1} + onSuccess={handleDeleteSuccess} + openAddCreditCardModal={openAddCreditCardModal} + /> + )} + + ); +}; + +export default CardList; diff --git a/packages/apps/job-launcher/client/src/components/CardSetup/CardSetupForm.tsx b/packages/apps/job-launcher/client/src/components/CreditCard/CardSetupForm.tsx similarity index 75% rename from packages/apps/job-launcher/client/src/components/CardSetup/CardSetupForm.tsx rename to packages/apps/job-launcher/client/src/components/CreditCard/CardSetupForm.tsx index eb43b4f5b9..fdde3590a1 100644 --- a/packages/apps/job-launcher/client/src/components/CardSetup/CardSetupForm.tsx +++ b/packages/apps/job-launcher/client/src/components/CreditCard/CardSetupForm.tsx @@ -1,5 +1,5 @@ import { LoadingButton } from '@mui/lab'; -import { Box, Grid, Link, Typography } from '@mui/material'; +import { Box, Checkbox, FormControlLabel, Grid } from '@mui/material'; import { PaymentElement, useElements, @@ -12,15 +12,15 @@ import { useAppDispatch } from '../../state'; import { fetchUserBalanceAsync } from '../../state/auth/reducer'; interface CardSetupFormProps { - onCardSetup: () => void; // Prop para notificar cuando la tarjeta está lista + onComplete: () => void; } -export const CardSetupForm: React.FC = ({ - onCardSetup, -}) => { +export const CardSetupForm: React.FC = ({ onComplete }) => { const stripe = useStripe(); const elements = useElements(); const [isLoading, setIsLoading] = useState(false); + + const [defaultCard, setDefaultCard] = useState(false); const dispatch = useAppDispatch(); const { showError } = useSnackbar(); @@ -60,6 +60,7 @@ export const CardSetupForm: React.FC = ({ const success = await paymentService.confirmSetupIntent( setupIntent?.id ?? '', + defaultCard, ); if (!success) { @@ -67,7 +68,7 @@ export const CardSetupForm: React.FC = ({ } dispatch(fetchUserBalanceAsync()); - onCardSetup(); + onComplete(); } catch (err: any) { showError(err.message || 'An error occurred while setting up the card.'); } @@ -79,7 +80,21 @@ export const CardSetupForm: React.FC = ({ + + setDefaultCard(e.target.checked)} + /> + } + label="Set this card as default payment method" + sx={{ mt: 2 }} /> @@ -91,19 +106,9 @@ export const CardSetupForm: React.FC = ({ onClick={handleCardSetup} loading={isLoading} > - Save card details + Add Credit Card - - - - Terms & conditions - - -
); diff --git a/packages/apps/job-launcher/client/src/components/CreditCard/DeleteCardModal.tsx b/packages/apps/job-launcher/client/src/components/CreditCard/DeleteCardModal.tsx new file mode 100644 index 0000000000..6bade1bc10 --- /dev/null +++ b/packages/apps/job-launcher/client/src/components/CreditCard/DeleteCardModal.tsx @@ -0,0 +1,214 @@ +import CloseIcon from '@mui/icons-material/Close'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import { + Box, + Button, + Dialog, + IconButton, + Typography, + useTheme, + Alert, +} from '@mui/material'; +import { useState } from 'react'; +import { useSnackbar } from '../../providers/SnackProvider'; +import { deleteUserCard } from '../../services/payment'; + +const DeleteCardModal = ({ + open, + onClose, + cardId, + isDefault = false, + hasMultipleCards = false, + onSuccess, + openAddCreditCardModal, +}: { + open: boolean; + onClose: () => void; + cardId: string; + isDefault?: boolean; + hasMultipleCards?: boolean; + onSuccess: () => void; + openAddCreditCardModal: (open: boolean) => void; +}) => { + const theme = useTheme(); + const { showError } = useSnackbar(); + + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); // Para manejar el error del backend + + const handleDelete = async () => { + try { + setIsLoading(true); + await deleteUserCard(cardId); + onSuccess(); + } catch (error: any) { + if ( + error.response?.status === 400 && + error.response?.data?.message === + 'Cannot delete the default payment method in use' + ) { + setError( + 'The credit card you’re trying to remove is currently in use to prevent potential misuse in your most recently launched job.', + ); + } else { + showError('Error deleting card'); + } + } finally { + setIsLoading(false); + } + }; + + return ( + + + + + Delete Credit Card? + + + + + + + + + {error ? ( + + {error} + + To proceed with the removal, please: + +
    +
  • + Wait 24 hours while we verify that your dataset does not + contain abusive content. +
  • +
  • + Alternatively, set a different card as your default payment + method. +
  • +
+ + +   + + +
+ ) : ( + <> + {isDefault && hasMultipleCards ? ( + + }> + + This credit card is set as default! + + + + This card can only be deleted if you choose another card as + default. + + + +   + + + + ) : ( + + + Are you sure you want to delete this credit card from your + account? + + + + + + + )} + + )} +
+
+
+ ); +}; + +export default DeleteCardModal; diff --git a/packages/apps/job-launcher/client/src/components/CreditCard/SelectCardModal.tsx b/packages/apps/job-launcher/client/src/components/CreditCard/SelectCardModal.tsx new file mode 100644 index 0000000000..d4a60dc1d6 --- /dev/null +++ b/packages/apps/job-launcher/client/src/components/CreditCard/SelectCardModal.tsx @@ -0,0 +1,111 @@ +import CloseIcon from '@mui/icons-material/Close'; +import { + Box, + Button, + Dialog, + IconButton, + Typography, + useTheme, +} from '@mui/material'; +import { useState } from 'react'; +import { CardData } from '../../types'; +import { CardIcon } from '../Icons/CardIcon'; + +const SelectCardModal = ({ + open, + onClose, + cards, + onSelect, +}: { + open: boolean; + onClose: () => void; + cards: CardData[]; + onSelect: (card: CardData) => void; +}) => { + const theme = useTheme(); + const [selectedCardId, setSelectedCardId] = useState(null); + + return ( + + + + + Select Credit Card + + + + + + + + + + {cards.map((card) => ( + setSelectedCardId(card.id)} + > + + **** **** **** {card.last4} + + Exp. date {card.expMonth}/{card.expYear} + + + ))} + + + + + + + ); +}; + +export default SelectCardModal; diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/FiatPayForm.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/FiatPayForm.tsx index 4111c82c82..da886df7f9 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/FiatPayForm.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/FiatPayForm.tsx @@ -8,6 +8,7 @@ import { FormControl, FormControlLabel, Grid, + InputAdornment, Link, Stack, TextField, @@ -15,16 +16,30 @@ import { } from '@mui/material'; import { useElements, useStripe } from '@stripe/react-stripe-js'; import { useEffect, useMemo, useState } from 'react'; + import { Address } from 'viem'; import { useReadContract } from 'wagmi'; +import AddCardModal from '../../../components/CreditCard/AddCardModal'; +import SelectCardModal from '../../../components/CreditCard/SelectCardModal'; +import { CardIcon } from '../../../components/Icons/CardIcon'; +import SuccessModal from '../../../components/SuccessModal'; import { CURRENCY } from '../../../constants/payment'; - import { useCreateJobPageUI } from '../../../providers/CreateJobPageUIProvider'; -import * as jobService from '../../../services/job'; -import * as paymentService from '../../../services/payment'; +import { + createCvatJob, + createFortuneJob, + createHCaptchaJob, +} from '../../../services/job'; +import { + confirmFiatPayment, + createFiatPayment, + getFee, + getOperatorAddress, + getUserCards, +} from '../../../services/payment'; import { useAppDispatch, useAppSelector } from '../../../state'; import { fetchUserBalanceAsync } from '../../../state/auth/reducer'; -import { JobType } from '../../../types'; +import { CardData, JobType } from '../../../types'; export const FiatPayForm = ({ onStart, @@ -37,27 +52,43 @@ export const FiatPayForm = ({ }) => { const stripe = useStripe(); const elements = useElements(); - const { jobRequest, goToPrevStep } = useCreateJobPageUI(); const { user } = useAppSelector((state) => state.auth); const dispatch = useAppDispatch(); - - const [payWithAccountBalance, setPayWithAccountBalance] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [amount, setAmount] = useState(''); const [jobLauncherAddress, setJobLauncherAddress] = useState(); const [minFee, setMinFee] = useState(0.01); + const [cards, setCards] = useState([]); + const [selectedCard, setSelectedCard] = useState(null); + const [isSelectCardModalOpen, setIsSelectCardModalOpen] = useState(false); + const [amount, setAmount] = useState(''); + const [payWithAccountBalance, setPayWithAccountBalance] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const { jobRequest, goToPrevStep } = useCreateJobPageUI(); + const [isAddCardOpen, setIsAddCardOpen] = useState(false); + const [isSuccessOpen, setIsSuccessOpen] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); useEffect(() => { const fetchJobLauncherData = async () => { - const address = await paymentService.getOperatorAddress(); - const fee = await paymentService.getFee(); + const address = await getOperatorAddress(); + const fee = await getFee(); setJobLauncherAddress(address); setMinFee(fee); }; fetchJobLauncherData(); + fetchCards(); }, []); + const fetchCards = async () => { + const data = await getUserCards(); + setCards(data); + + const defaultCard = data.find((card: CardData) => card.default); + if (defaultCard) { + setSelectedCard(defaultCard); + } + }; + const { data: jobLauncherFee } = useReadContract({ address: NETWORKS[jobRequest.chainId!]?.kvstoreAddress as Address, abi: KVStoreABI, @@ -90,18 +121,26 @@ export const FiatPayForm = ({ return totalAmount - accountAmount; }, [payWithAccountBalance, totalAmount, accountAmount]); + const handleSuccessAction = (message: string) => { + setSuccessMessage(message); + setIsSuccessOpen(true); + }; + const handlePay = async () => { if (!stripe || !elements) { onError('Stripe.js has not yet loaded.'); return; } + onStart(); setIsLoading(true); + try { - if (creditCardPayAmount > 0) { - const clientSecret = await paymentService.createFiatPayment({ + if (creditCardPayAmount > 0 && selectedCard) { + const clientSecret = await createFiatPayment({ amount: creditCardPayAmount, currency: 'usd', + paymentMethodId: selectedCard.id, }); const { error: stripeError, paymentIntent } = @@ -111,9 +150,7 @@ export const FiatPayForm = ({ throw stripeError; } - const success = await paymentService.confirmFiatPayment( - paymentIntent.id, - ); + const success = await confirmFiatPayment(paymentIntent.id); if (!success) { throw new Error('Payment confirmation failed.'); @@ -126,30 +163,26 @@ export const FiatPayForm = ({ if (!chainId) return; if (jobType === JobType.Fortune && fortuneRequest) { - await jobService.createFortuneJob( + await createFortuneJob( chainId, fortuneRequest, fundAmount, CURRENCY.usd, ); } else if (jobType === JobType.CVAT && cvatRequest) { - await jobService.createCvatJob( - chainId, - cvatRequest, - fundAmount, - CURRENCY.usd, - ); + await createCvatJob(chainId, cvatRequest, fundAmount, CURRENCY.usd); } else if (jobType === JobType.HCAPTCHA && hCaptchaRequest) { - await jobService.createHCaptchaJob(chainId, hCaptchaRequest); + await createHCaptchaJob(chainId, hCaptchaRequest); } + // Update balance and finish payment dispatch(fetchUserBalanceAsync()); onFinish(); } catch (err) { onError(err); + } finally { + setIsLoading(false); } - - setIsLoading(false); }; return ( @@ -188,7 +221,45 @@ export const FiatPayForm = ({ value={amount} type="number" onChange={(e) => setAmount(e.target.value)} + sx={{ mb: 2 }} /> + {selectedCard ? ( + + + + ), + endAdornment: ( + + + + ), + }} + sx={{ mb: 2 }} + /> + ) : ( + + )}
@@ -286,7 +357,7 @@ export const FiatPayForm = ({ size="large" onClick={handlePay} loading={isLoading} - disabled={!amount} + disabled={!amount || (!payWithAccountBalance && !selectedCard)} > Pay now @@ -308,7 +379,32 @@ export const FiatPayForm = ({ Terms & conditions + + setIsSelectCardModalOpen(false)} + cards={cards} + onSelect={(card) => { + setSelectedCard(card); + setIsSelectCardModalOpen(false); + }} + /> + setIsAddCardOpen(false)} + onComplete={() => { + handleSuccessAction('Your card has been successfully added.'); + fetchCards(); + }} + /> + setIsSuccessOpen(false)} + message={successMessage} + />
); }; + +export default FiatPayForm; diff --git a/packages/apps/job-launcher/client/src/components/Jobs/Create/PayJob.tsx b/packages/apps/job-launcher/client/src/components/Jobs/Create/PayJob.tsx index 668e0f2917..c9598bc1a8 100644 --- a/packages/apps/job-launcher/client/src/components/Jobs/Create/PayJob.tsx +++ b/packages/apps/job-launcher/client/src/components/Jobs/Create/PayJob.tsx @@ -1,11 +1,9 @@ import { Box } from '@mui/material'; -import { useEffect, useState } from 'react'; -import { CardSetupForm } from '../../../components/CardSetup/CardSetupForm'; +import { useState } from 'react'; import { StyledTab, StyledTabs } from '../../../components/Tabs'; import { IS_TESTNET } from '../../../constants/chains'; import { useCreateJobPageUI } from '../../../providers/CreateJobPageUIProvider'; import { useSnackbar } from '../../../providers/SnackProvider'; -import { checkUserCard } from '../../../services/payment'; import { PayMethod } from '../../../types'; import { CryptoPayForm } from './CryptoPayForm'; import { FiatPayForm } from './FiatPayForm'; @@ -14,18 +12,8 @@ import { LaunchJobProgress } from './LaunchJobProgress'; export const PayJob = () => { const { payMethod, changePayMethod, goToNextStep } = useCreateJobPageUI(); const [isPaying, setIsPaying] = useState(false); - const [hasCard, setHasCard] = useState(null); const { showError } = useSnackbar(); - useEffect(() => { - const fetchCardStatus = async () => { - const result = await checkUserCard(); - setHasCard(result); - }; - - fetchCardStatus(); - }, []); - const handleStart = () => { setIsPaying(true); }; @@ -45,54 +33,6 @@ export const PayJob = () => { } }; - if (hasCard === null) { - return ( - -
Loading...
-
- ); - } - - if (!hasCard) { - return ( - - setHasCard(true)} /> - - ); - } - return !isPaying ? ( void; + message: string; +}) => { + const theme = useTheme(); + + return ( + + + + + Success! + + + + + + + + + + + + + {message} + + + + + + ); +}; + +export default SuccessModal; diff --git a/packages/apps/job-launcher/client/src/components/TopUpAccount/FiatTopUpForm.tsx b/packages/apps/job-launcher/client/src/components/TopUpAccount/FiatTopUpForm.tsx index eac9a24587..ccd7659709 100644 --- a/packages/apps/job-launcher/client/src/components/TopUpAccount/FiatTopUpForm.tsx +++ b/packages/apps/job-launcher/client/src/components/TopUpAccount/FiatTopUpForm.tsx @@ -1,18 +1,25 @@ import { LoadingButton } from '@mui/lab'; import { Box, + Button, FormControl, Grid, + InputAdornment, Link, TextField, Typography, } from '@mui/material'; import { useElements, useStripe } from '@stripe/react-stripe-js'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useSnackbar } from '../../providers/SnackProvider'; import * as paymentService from '../../services/payment'; import { useAppDispatch } from '../../state'; import { fetchUserBalanceAsync } from '../../state/auth/reducer'; +import { CardData } from '../../types'; +import AddCardModal from '../CreditCard/AddCardModal'; +import SelectCardModal from '../CreditCard/SelectCardModal'; +import { CardIcon } from '../Icons/CardIcon'; +import SuccessModal from '../SuccessModal'; import { TopUpSuccess } from './TopUpSuccess'; export const FiatTopUpForm = () => { @@ -23,9 +30,34 @@ export const FiatTopUpForm = () => { const [amount, setAmount] = useState(); const dispatch = useAppDispatch(); const { showError } = useSnackbar(); + const [cards, setCards] = useState([]); + const [selectedCard, setSelectedCard] = useState(null); + const [isSelectCardModalOpen, setIsSelectCardModalOpen] = useState(false); + const [isAddCardOpen, setIsAddCardOpen] = useState(false); + const [isSuccessOpen, setIsSuccessOpen] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + + useEffect(() => { + fetchCards(); + }, []); + + const fetchCards = async () => { + const data = await paymentService.getUserCards(); + setCards(data); + + const defaultCard = data.find((card: CardData) => card.default); + if (defaultCard) { + setSelectedCard(defaultCard); + } + }; + + const handleSuccessAction = (message: string) => { + setSuccessMessage(message); + setIsSuccessOpen(true); + }; const handleTopUpAccount = async () => { - if (!stripe || !elements) { + if (!stripe || !elements || !selectedCard) { showError('Stripe.js has not yet loaded.'); return; } @@ -42,6 +74,7 @@ export const FiatTopUpForm = () => { const clientSecret = await paymentService.createFiatPayment({ amount: Number(amount), currency: 'usd', + paymentMethodId: selectedCard.id, }); // stripe payment @@ -83,7 +116,45 @@ export const FiatTopUpForm = () => { value={amount} type="number" onChange={(e) => setAmount(e.target.value)} + sx={{ mb: 2 }} /> + {selectedCard ? ( + + + + ), + endAdornment: ( + + + + ), + }} + sx={{ mb: 2 }} + /> + ) : ( + + )} @@ -94,6 +165,7 @@ export const FiatTopUpForm = () => { size="large" onClick={handleTopUpAccount} loading={isLoading} + disabled={!amount || !selectedCard} > Top up account @@ -109,6 +181,29 @@ export const FiatTopUpForm = () => { + + setIsSelectCardModalOpen(false)} + cards={cards} + onSelect={(card) => { + setSelectedCard(card); + setIsSelectCardModalOpen(false); + }} + /> + setIsAddCardOpen(false)} + onComplete={() => { + handleSuccessAction('Your card has been successfully added.'); + fetchCards(); + }} + /> + setIsSuccessOpen(false)} + message={successMessage} + /> ); }; diff --git a/packages/apps/job-launcher/client/src/constants/payment.ts b/packages/apps/job-launcher/client/src/constants/payment.ts index 8c7f69fe2c..c542c501f1 100644 --- a/packages/apps/job-launcher/client/src/constants/payment.ts +++ b/packages/apps/job-launcher/client/src/constants/payment.ts @@ -4,3 +4,675 @@ export const CURRENCY = { hmt: 'hmt', usd: 'usd', }; + +export enum Country { + AF = 'af', + AL = 'al', + DZ = 'dz', + AS = 'as', + AD = 'ad', + AO = 'ao', + AI = 'ai', + AQ = 'aq', + AG = 'ag', + AR = 'ar', + AM = 'am', + AW = 'aw', + AC = 'ac', + AU = 'au', + AT = 'at', + AZ = 'az', + BS = 'bs', + BH = 'bh', + BD = 'bd', + BB = 'bb', + BY = 'by', + BE = 'be', + BZ = 'bz', + BJ = 'bj', + BM = 'bm', + BT = 'bt', + BO = 'bo', + BQ = 'bq', + BA = 'ba', + BW = 'bw', + BV = 'bv', + BR = 'br', + IO = 'io', + BN = 'bn', + BG = 'bg', + BF = 'bf', + BI = 'bi', + KH = 'kh', + CM = 'cm', + CA = 'ca', + IC = 'ic', + CV = 'cv', + KY = 'ky', + CF = 'cf', + EA = 'ea', + TD = 'td', + CL = 'cl', + CN = 'cn', + CX = 'cx', + CP = 'cp', + CC = 'cc', + CO = 'co', + KM = 'km', + CK = 'ck', + CR = 'cr', + CI = 'ci', + HR = 'hr', + CU = 'cu', + CW = 'cw', + CY = 'cy', + CZ = 'cz', + CD = 'cd', + DK = 'dk', + DG = 'dg', + DJ = 'dj', + DM = 'dm', + DO = 'do', + TL = 'tl', + EC = 'ec', + EG = 'eg', + SV = 'sv', + GQ = 'gq', + ER = 'er', + EE = 'ee', + ET = 'et', + EU = 'eu', + FK = 'fk', + FO = 'fo', + FJ = 'fj', + FI = 'fi', + FR = 'fr', + FX = 'fx', + GF = 'gf', + PF = 'pf', + TF = 'tf', + GA = 'ga', + GM = 'gm', + GE = 'ge', + DE = 'de', + GH = 'gh', + GI = 'gi', + GR = 'gr', + GL = 'gl', + GD = 'gd', + GP = 'gp', + GU = 'gu', + GT = 'gt', + GG = 'gg', + GN = 'gn', + GW = 'gw', + GY = 'gy', + HT = 'ht', + HM = 'hm', + HN = 'hn', + HK = 'hk', + HU = 'hu', + IS = 'is', + IN = 'in', + ID = 'id', + IR = 'ir', + IQ = 'iq', + IE = 'ie', + IM = 'im', + IL = 'il', + IT = 'it', + JM = 'jm', + JP = 'jp', + JE = 'je', + JO = 'jo', + KZ = 'kz', + KE = 'ke', + KI = 'ki', + KP = 'kp', + KR = 'kr', + KW = 'kw', + KG = 'kg', + LA = 'la', + LV = 'lv', + LB = 'lb', + LS = 'ls', + LR = 'lr', + LY = 'ly', + LI = 'li', + LT = 'lt', + LU = 'lu', + MO = 'mo', + MK = 'mk', + MG = 'mg', + MW = 'mw', + MY = 'my', + ML = 'ml', + MT = 'mt', + MH = 'mh', + MQ = 'mq', + MR = 'mr', + MU = 'mu', + YT = 'yt', + MX = 'mx', + FM = 'fm', + MD = 'md', + MC = 'mc', + MN = 'mn', + ME = 'me', + MS = 'ms', + MA = 'ma', + MZ = 'mz', + MM = 'mm', + NA = 'na', + NR = 'nr', + NP = 'np', + NL = 'nl', + NC = 'nc', + NZ = 'nz', + NI = 'ni', + NE = 'ne', + NG = 'ng', + NU = 'nu', + NF = 'nf', + MP = 'mp', + NO = 'no', + OM = 'om', + PK = 'pk', + PW = 'pw', + PS = 'ps', + PA = 'pa', + PG = 'pg', + PY = 'py', + PE = 'pe', + PH = 'ph', + PN = 'pn', + PL = 'pl', + PT = 'pt', + PR = 'pr', + QA = 'qa', + RE = 're', + RO = 'ro', + RU = 'ru', + RW = 'rw', + BL = 'bl', + SH = 'sh', + KN = 'kn', + LC = 'lc', + MF = 'mf', + PM = 'pm', + VC = 'vc', + WS = 'ws', + SM = 'sm', + ST = 'st', + SA = 'sa', + SN = 'sn', + RS = 'rs', + SC = 'sc', + SL = 'sl', + SG = 'sg', + SX = 'sx', + SK = 'sk', + SI = 'si', + SB = 'sb', + SO = 'so', + ZA = 'za', + GS = 'gs', + SS = 'ss', + ES = 'es', + LK = 'lk', + SD = 'sd', + SR = 'sr', + SJ = 'sj', + SZ = 'sz', + SE = 'se', + CH = 'ch', + SY = 'sy', + TW = 'tw', + TJ = 'tj', + TZ = 'tz', + TH = 'th', + TG = 'tg', + TK = 'tk', + TO = 'to', + TT = 'tt', + TN = 'tn', + TR = 'tr', + TM = 'tm', + TC = 'tc', + TV = 'tv', + UG = 'ug', + UA = 'ua', + AE = 'ae', + GB = 'gb', + US = 'us', + UM = 'um', + UY = 'uy', + UZ = 'uz', + VU = 'vu', + VA = 'va', + VE = 've', + VN = 'vn', + WF = 'wf', + EH = 'eh', + YE = 'ye', + ZM = 'zm', + ZW = 'zw', +} + +export const countryOptions: { [key: string]: string } = { + [Country.AF]: 'Afghanistan', + [Country.AL]: 'Albania', + [Country.DZ]: 'Algeria', + [Country.AS]: 'American Samoa', + [Country.AD]: 'Andorra', + [Country.AO]: 'Angola', + [Country.AI]: 'Anguilla', + [Country.AQ]: 'Antarctica', + [Country.AG]: 'Antigua and Barbuda', + [Country.AR]: 'Argentina', + [Country.AM]: 'Armenia', + [Country.AW]: 'Aruba', + [Country.AC]: 'Ascension Island', + [Country.AU]: 'Australia', + [Country.AT]: 'Austria', + [Country.AZ]: 'Azerbaijan', + [Country.BS]: 'Bahamas', + [Country.BH]: 'Bahrain', + [Country.BD]: 'Bangladesh', + [Country.BB]: 'Barbados', + [Country.BY]: 'Belarus', + [Country.BE]: 'Belgium', + [Country.BZ]: 'Belize', + [Country.BJ]: 'Benin', + [Country.BM]: 'Bermuda', + [Country.BT]: 'Bhutan', + [Country.BO]: 'Bolivia', + [Country.BQ]: 'Bonaire, Sint Eustatius and Saba', + [Country.BA]: 'Bosnia and Herzegovina', + [Country.BW]: 'Botswana', + [Country.BV]: 'Bouvet Island', + [Country.BR]: 'Brazil', + [Country.IO]: 'British Indian Ocean Territory', + [Country.BN]: 'Brunei Darussalam', + [Country.BG]: 'Bulgaria', + [Country.BF]: 'Burkina Faso', + [Country.BI]: 'Burundi', + [Country.KH]: 'Cambodia', + [Country.CM]: 'Cameroon', + [Country.CA]: 'Canada', + [Country.IC]: 'Canary Islands', + [Country.CV]: 'Cape Verde', + [Country.KY]: 'Cayman Islands', + [Country.CF]: 'Central African Republic', + [Country.EA]: 'Ceuta and Melilla', + [Country.TD]: 'Chad', + [Country.CL]: 'Chile', + [Country.CN]: 'China', + [Country.CX]: 'Christmas Island', + [Country.CP]: 'Clipperton Island', + [Country.CC]: 'Cocos (Keeling) Islands', + [Country.CO]: 'Colombia', + [Country.KM]: 'Comoros', + [Country.CK]: 'Cook Islands', + [Country.CR]: 'Costa Rica', + [Country.CI]: "Côte d'Ivoire", + [Country.HR]: 'Croatia', + [Country.CU]: 'Cuba', + [Country.CW]: 'Curaçao', + [Country.CY]: 'Cyprus', + [Country.CZ]: 'Czechia', + [Country.CD]: 'Democratic Republic of the Congo', + [Country.DK]: 'Denmark', + [Country.DG]: 'Diego Garcia', + [Country.DJ]: 'Djibouti', + [Country.DM]: 'Dominica', + [Country.DO]: 'Dominican Republic', + [Country.TL]: 'Timor-Leste', + [Country.EC]: 'Ecuador', + [Country.EG]: 'Egypt', + [Country.SV]: 'El Salvador', + [Country.GQ]: 'Equatorial Guinea', + [Country.ER]: 'Eritrea', + [Country.EE]: 'Estonia', + [Country.ET]: 'Ethiopia', + [Country.EU]: 'European Union', + [Country.FK]: 'Falkland Islands', + [Country.FO]: 'Faroe Islands', + [Country.FJ]: 'Fiji', + [Country.FI]: 'Finland', + [Country.FR]: 'France', + [Country.FX]: 'Metropolitan France', + [Country.GF]: 'French Guiana', + [Country.PF]: 'French Polynesia', + [Country.TF]: 'French Southern Territories', + [Country.GA]: 'Gabon', + [Country.GM]: 'Gambia', + [Country.GE]: 'Georgia', + [Country.DE]: 'Germany', + [Country.GH]: 'Ghana', + [Country.GI]: 'Gibraltar', + [Country.GR]: 'Greece', + [Country.GL]: 'Greenland', + [Country.GD]: 'Grenada', + [Country.GP]: 'Guadeloupe', + [Country.GU]: 'Guam', + [Country.GT]: 'Guatemala', + [Country.GG]: 'Guernsey', + [Country.GN]: 'Guinea', + [Country.GW]: 'Guinea-Bissau', + [Country.GY]: 'Guyana', + [Country.HT]: 'Haiti', + [Country.HM]: 'Heard Island and McDonald Islands', + [Country.HN]: 'Honduras', + [Country.HK]: 'Hong Kong', + [Country.HU]: 'Hungary', + [Country.IS]: 'Iceland', + [Country.IN]: 'India', + [Country.ID]: 'Indonesia', + [Country.IR]: 'Iran', + [Country.IQ]: 'Iraq', + [Country.IE]: 'Ireland', + [Country.IM]: 'Isle of Man', + [Country.IL]: 'Israel', + [Country.IT]: 'Italy', + [Country.JM]: 'Jamaica', + [Country.JP]: 'Japan', + [Country.JE]: 'Jersey', + [Country.JO]: 'Jordan', + [Country.KZ]: 'Kazakhstan', + [Country.KE]: 'Kenya', + [Country.KI]: 'Kiribati', + [Country.KP]: 'North Korea', + [Country.KR]: 'South Korea', + [Country.KW]: 'Kuwait', + [Country.KG]: 'Kyrgyzstan', + [Country.LA]: 'Laos', + [Country.LV]: 'Latvia', + [Country.LB]: 'Lebanon', + [Country.LS]: 'Lesotho', + [Country.LR]: 'Liberia', + [Country.LY]: 'Libya', + [Country.LI]: 'Liechtenstein', + [Country.LT]: 'Lithuania', + [Country.LU]: 'Luxembourg', + [Country.MO]: 'Macao', + [Country.MK]: 'North Macedonia', + [Country.MG]: 'Madagascar', + [Country.MW]: 'Malawi', + [Country.MY]: 'Malaysia', + [Country.ML]: 'Mali', + [Country.MT]: 'Malta', + [Country.MH]: 'Marshall Islands', + [Country.MQ]: 'Martinique', + [Country.MR]: 'Mauritania', + [Country.MU]: 'Mauritius', + [Country.YT]: 'Mayotte', + [Country.MX]: 'Mexico', + [Country.FM]: 'Micronesia', + [Country.MD]: 'Moldova', + [Country.MC]: 'Monaco', + [Country.MN]: 'Mongolia', + [Country.ME]: 'Montenegro', + [Country.MS]: 'Montserrat', + [Country.MA]: 'Morocco', + [Country.MZ]: 'Mozambique', + [Country.MM]: 'Myanmar', + [Country.NA]: 'Namibia', + [Country.NR]: 'Nauru', + [Country.NP]: 'Nepal', + [Country.NL]: 'Netherlands', + [Country.NC]: 'New Caledonia', + [Country.NZ]: 'New Zealand', + [Country.NI]: 'Nicaragua', + [Country.NE]: 'Niger', + [Country.NG]: 'Nigeria', + [Country.NU]: 'Niue', + [Country.NF]: 'Norfolk Island', + [Country.MP]: 'Northern Mariana Islands', + [Country.NO]: 'Norway', + [Country.OM]: 'Oman', + [Country.PK]: 'Pakistan', + [Country.PW]: 'Palau', + [Country.PS]: 'Palestine', + [Country.PA]: 'Panama', + [Country.PG]: 'Papua New Guinea', + [Country.PY]: 'Paraguay', + [Country.PE]: 'Peru', + [Country.PH]: 'Philippines', + [Country.PN]: 'Pitcairn Islands', + [Country.PL]: 'Poland', + [Country.PT]: 'Portugal', + [Country.PR]: 'Puerto Rico', + [Country.QA]: 'Qatar', + [Country.RE]: 'Réunion', + [Country.RO]: 'Romania', + [Country.RU]: 'Russia', + [Country.RW]: 'Rwanda', + [Country.BL]: 'Saint Barthélemy', + [Country.SH]: 'Saint Helena, Ascension and Tristan da Cunha', + [Country.KN]: 'Saint Kitts and Nevis', + [Country.LC]: 'Saint Lucia', + [Country.MF]: 'Saint Martin', + [Country.PM]: 'Saint Pierre and Miquelon', + [Country.VC]: 'Saint Vincent and the Grenadines', + [Country.WS]: 'Samoa', + [Country.SM]: 'San Marino', + [Country.ST]: 'Sao Tome and Principe', + [Country.SA]: 'Saudi Arabia', + [Country.SN]: 'Senegal', + [Country.RS]: 'Serbia', + [Country.SC]: 'Seychelles', + [Country.SL]: 'Sierra Leone', + [Country.SG]: 'Singapore', + [Country.SK]: 'Slovakia', + [Country.SI]: 'Slovenia', + [Country.SB]: 'Solomon Islands', + [Country.SO]: 'Somalia', + [Country.ZA]: 'South Africa', + [Country.GS]: 'South Georgia and the South Sandwich Islands', + [Country.SS]: 'South Sudan', + [Country.ES]: 'Spain', + [Country.LK]: 'Sri Lanka', + [Country.SD]: 'Sudan', + [Country.SR]: 'Suriname', + [Country.SJ]: 'Svalbard and Jan Mayen', + [Country.SE]: 'Sweden', + [Country.CH]: 'Switzerland', + [Country.SY]: 'Syria', + [Country.TW]: 'Taiwan', + [Country.TJ]: 'Tajikistan', + [Country.TZ]: 'Tanzania', + [Country.TH]: 'Thailand', + [Country.TG]: 'Togo', + [Country.TK]: 'Tokelau', + [Country.TO]: 'Tonga', + [Country.TT]: 'Trinidad and Tobago', + [Country.TN]: 'Tunisia', + [Country.TR]: 'Turkey', + [Country.TM]: 'Turkmenistan', + [Country.TC]: 'Turks and Caicos Islands', + [Country.TV]: 'Tuvalu', + [Country.UG]: 'Uganda', + [Country.UA]: 'Ukraine', + [Country.AE]: 'United Arab Emirates', + [Country.GB]: 'United Kingdom', + [Country.US]: 'United States', + [Country.UM]: 'United States Minor Outlying Islands', + [Country.UY]: 'Uruguay', + [Country.UZ]: 'Uzbekistan', + [Country.VU]: 'Vanuatu', + [Country.VA]: 'Vatican City', + [Country.VE]: 'Venezuela', + [Country.VN]: 'Vietnam', + [Country.WF]: 'Wallis and Futuna', + [Country.EH]: 'Western Sahara', + [Country.YE]: 'Yemen', + [Country.ZM]: 'Zambia', + [Country.ZW]: 'Zimbabwe', +}; + +export enum VatType { + AD_NRT = 'ad_nrt', + AE_TRN = 'ae_trn', + AR_CUIT = 'ar_cuit', + AU_ABN = 'au_abn', + AU_ARN = 'au_arn', + BG_UIC = 'bg_uic', + BH_VAT = 'bh_vat', + BO_TIN = 'bo_tin', + BR_CNPJ = 'br_cnpj', + BR_CPF = 'br_cpf', + BY_TIN = 'by_tin', + CA_BN = 'ca_bn', + CA_GST_HST = 'ca_gst_hst', + CA_PST_BC = 'ca_pst_bc', + CA_PST_MB = 'ca_pst_mb', + CA_PST_SK = 'ca_pst_sk', + CA_QST = 'ca_qst', + CH_UID = 'ch_uid', + CH_VAT = 'ch_vat', + CL_TIN = 'cl_tin', + CN_TIN = 'cn_tin', + CO_NIT = 'co_nit', + CR_TIN = 'cr_tin', + DE_STN = 'de_stn', + DO_RCN = 'do_rcn', + EC_RUC = 'ec_ruc', + EG_TIN = 'eg_tin', + ES_CIF = 'es_cif', + EU_OSS_VAT = 'eu_oss_vat', + EU_VAT = 'eu_vat', + GB_VAT = 'gb_vat', + GE_VAT = 'ge_vat', + HK_BR = 'hk_br', + HR_OIB = 'hr_oib', + HU_TIN = 'hu_tin', + ID_NPWP = 'id_npwp', + IL_VAT = 'il_vat', + IN_GST = 'in_gst', + IS_VAT = 'is_vat', + JP_CN = 'jp_cn', + JP_RN = 'jp_rn', + JP_TRN = 'jp_trn', + KE_PIN = 'ke_pin', + KR_BRN = 'kr_brn', + KZ_BIN = 'kz_bin', + LI_UID = 'li_uid', + MA_VAT = 'ma_vat', + MD_VAT = 'md_vat', + MX_RFC = 'mx_rfc', + MY_FRP = 'my_frp', + MY_ITN = 'my_itn', + MY_SST = 'my_sst', + NG_TIN = 'ng_tin', + NO_VAT = 'no_vat', + NO_VOEC = 'no_voec', + NZ_GST = 'nz_gst', + OM_VAT = 'om_vat', + PE_RUC = 'pe_ruc', + PH_TIN = 'ph_tin', + RO_TIN = 'ro_tin', + RS_PIB = 'rs_pib', + RU_INN = 'ru_inn', + RU_KPP = 'ru_kpp', + SA_VAT = 'sa_vat', + SG_GST = 'sg_gst', + SG_UEN = 'sg_uen', + SI_TIN = 'si_tin', + SV_NIT = 'sv_nit', + TH_VAT = 'th_vat', + TR_TIN = 'tr_tin', + TW_VAT = 'tw_vat', + TZ_VAT = 'tz_vat', + UA_VAT = 'ua_vat', + US_EIN = 'us_ein', + UY_RUC = 'uy_ruc', + UZ_TIN = 'uz_tin', + UZ_VAT = 'uz_vat', + VE_RIF = 've_rif', + VN_TIN = 'vn_tin', + ZA_VAT = 'za_vat', +} + +export const vatTypeOptions: { [key: string]: string } = { + [VatType.AD_NRT]: 'Andorran NRT', + [VatType.AE_TRN]: 'UAE TRN', + [VatType.AR_CUIT]: 'Argentinian CUIT', + [VatType.AU_ABN]: 'Australian ABN', + [VatType.AU_ARN]: 'Australian ARN', + [VatType.BG_UIC]: 'Bulgarian UIC', + [VatType.BH_VAT]: 'Bahraini VAT', + [VatType.BO_TIN]: 'Bolivian TIN', + [VatType.BR_CNPJ]: 'Brazilian CNPJ', + [VatType.BR_CPF]: 'Brazilian CPF', + [VatType.BY_TIN]: 'Belarusian TIN', + [VatType.CA_BN]: 'Canadian BN', + [VatType.CA_GST_HST]: 'Canadian GST/HST', + [VatType.CA_PST_BC]: 'Canadian PST BC', + [VatType.CA_PST_MB]: 'Canadian PST MB', + [VatType.CA_PST_SK]: 'Canadian PST SK', + [VatType.CA_QST]: 'Canadian QST', + [VatType.CH_UID]: 'Swiss UID', + [VatType.CH_VAT]: 'Swiss VAT', + [VatType.CL_TIN]: 'Chilean TIN', + [VatType.CN_TIN]: 'Chinese TIN', + [VatType.CO_NIT]: 'Colombian NIT', + [VatType.CR_TIN]: 'Costa Rican TIN', + [VatType.DE_STN]: 'German STN', + [VatType.DO_RCN]: 'Dominican RCN', + [VatType.EC_RUC]: 'Ecuadorian RUC', + [VatType.EG_TIN]: 'Egyptian TIN', + [VatType.ES_CIF]: 'Spanish CIF', + [VatType.EU_OSS_VAT]: 'EU OSS VAT', + [VatType.EU_VAT]: 'EU VAT', + [VatType.GB_VAT]: 'British VAT', + [VatType.GE_VAT]: 'Georgian VAT', + [VatType.HK_BR]: 'Hong Kong BR', + [VatType.HR_OIB]: 'Croatian OIB', + [VatType.HU_TIN]: 'Hungarian TIN', + [VatType.ID_NPWP]: 'Indonesian NPWP', + [VatType.IL_VAT]: 'Israeli VAT', + [VatType.IN_GST]: 'Indian GST', + [VatType.IS_VAT]: 'Icelandic VAT', + [VatType.JP_CN]: 'Japanese CN', + [VatType.JP_RN]: 'Japanese RN', + [VatType.JP_TRN]: 'Japanese TRN', + [VatType.KE_PIN]: 'Kenyan PIN', + [VatType.KR_BRN]: 'South Korean BRN', + [VatType.KZ_BIN]: 'Kazakh BIN', + [VatType.LI_UID]: 'Liechtenstein UID', + [VatType.MA_VAT]: 'Moroccan VAT', + [VatType.MD_VAT]: 'Moldovan VAT', + [VatType.MX_RFC]: 'Mexican RFC', + [VatType.MY_FRP]: 'Malaysian FRP', + [VatType.MY_ITN]: 'Malaysian ITN', + [VatType.MY_SST]: 'Malaysian SST', + [VatType.NG_TIN]: 'Nigerian TIN', + [VatType.NO_VAT]: 'Norwegian VAT', + [VatType.NO_VOEC]: 'Norwegian VOEC', + [VatType.NZ_GST]: 'New Zealand GST', + [VatType.OM_VAT]: 'Omani VAT', + [VatType.PE_RUC]: 'Peruvian RUC', + [VatType.PH_TIN]: 'Philippine TIN', + [VatType.RO_TIN]: 'Romanian TIN', + [VatType.RS_PIB]: 'Serbian PIB', + [VatType.RU_INN]: 'Russian INN', + [VatType.RU_KPP]: 'Russian KPP', + [VatType.SA_VAT]: 'Saudi VAT', + [VatType.SG_GST]: 'Singaporean GST', + [VatType.SG_UEN]: 'Singaporean UEN', + [VatType.SI_TIN]: 'Slovenian TIN', + [VatType.SV_NIT]: 'Salvadoran NIT', + [VatType.TH_VAT]: 'Thai VAT', + [VatType.TR_TIN]: 'Turkish TIN', + [VatType.TW_VAT]: 'Taiwanese VAT', + [VatType.TZ_VAT]: 'Tanzanian VAT', + [VatType.UA_VAT]: 'Ukrainian VAT', + [VatType.US_EIN]: 'American EIN', + [VatType.UY_RUC]: 'Uruguayan RUC', + [VatType.UZ_TIN]: 'Uzbek TIN', + [VatType.UZ_VAT]: 'Uzbek VAT', + [VatType.VE_RIF]: 'Venezuelan RIF', + [VatType.VN_TIN]: 'Vietnamese TIN', + [VatType.ZA_VAT]: 'South African VAT', +}; diff --git a/packages/apps/job-launcher/client/src/pages/Profile/Settings/index.tsx b/packages/apps/job-launcher/client/src/pages/Profile/Settings/index.tsx new file mode 100644 index 0000000000..2e40a48523 --- /dev/null +++ b/packages/apps/job-launcher/client/src/pages/Profile/Settings/index.tsx @@ -0,0 +1,203 @@ +import { + Box, + Button, + Card, + CardContent, + Divider, + Grid, + Typography, +} from '@mui/material'; +import { useEffect, useState } from 'react'; +import BillingDetailsModal from '../../../components/BillingDetails/BillingDetailsModal'; +import AddCardModal from '../../../components/CreditCard/AddCardModal'; +import CardList from '../../../components/CreditCard/CardList'; +import SuccessModal from '../../../components/SuccessModal'; +import { countryOptions, vatTypeOptions } from '../../../constants/payment'; +import { useSnackbar } from '../../../providers/SnackProvider'; +import { getUserBillingInfo, getUserCards } from '../../../services/payment'; +import { BillingInfo, CardData } from '../../../types'; + +const Settings = () => { + const { showError } = useSnackbar(); + const [isAddCardOpen, setIsAddCardOpen] = useState(false); + const [isEditBillingOpen, setIsEditBillingOpen] = useState(false); + const [isSuccessOpen, setIsSuccessOpen] = useState(false); + const [successMessage, setSuccessMessage] = useState(''); + const [cards, setCards] = useState([]); + const [billingInfo, setBillingInfo] = useState({ + name: '', + email: '', + address: { + city: '', + country: '', + postalCode: '', + line: '', + }, + vat: '', + vatType: '', + }); + + const fetchCards = async () => { + try { + const data = await getUserCards(); + setCards(data); + } catch (error) { + showError('Error fetching cards'); + } + }; + + const fetchBillingInfo = async () => { + try { + const data = await getUserBillingInfo(); + setBillingInfo(data); + } catch (error) { + showError('Error fetching billing info'); + } + }; + + useEffect(() => { + fetchCards(); + fetchBillingInfo(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleSuccessAction = (message: string) => { + setSuccessMessage(message); + setIsSuccessOpen(true); + }; + + return ( + + + + Settings + + + + + {/* Payment Details card */} + + + + + + + + Payment Details + + + Manage your credit cards and payment options + + + + + + + + handleSuccessAction(message)} + openAddCreditCardModal={setIsAddCardOpen} + /> + + + + + + + + {/* Billing Info card */} + + + + + + + + Billing Details + + + Add/edit your billing details. + + + + + + + Details + + Full Name / Company Name: {billingInfo?.name} + + Email: {billingInfo?.email} + Address: {billingInfo?.address.line} + + Postal code: {billingInfo?.address.postalCode} + + City: {billingInfo?.address.city} + + Country: {countryOptions[billingInfo?.address.country]} + + + VAT Type: {vatTypeOptions[billingInfo?.vatType]} + + VAT Number: {billingInfo?.vat} + + + + + + + + setIsAddCardOpen(false)} + onComplete={() => { + handleSuccessAction('Your card has been successfully added.'); + fetchCards(); + }} + /> + + setIsEditBillingOpen(false)} + billingInfo={billingInfo} + setBillingInfo={(info) => { + setBillingInfo(info); + handleSuccessAction( + 'Your billing details have been successfully updated.', + ); + }} + /> + + setIsSuccessOpen(false)} + message={successMessage} + /> + + ); +}; + +export default Settings; diff --git a/packages/apps/job-launcher/client/src/pages/Profile/TopUpAccount/index.tsx b/packages/apps/job-launcher/client/src/pages/Profile/TopUpAccount/index.tsx index b615f6c5ef..e5daaaeebb 100644 --- a/packages/apps/job-launcher/client/src/pages/Profile/TopUpAccount/index.tsx +++ b/packages/apps/job-launcher/client/src/pages/Profile/TopUpAccount/index.tsx @@ -1,6 +1,6 @@ import { Box, Typography } from '@mui/material'; import { useEffect, useState } from 'react'; -import { CardSetupForm } from '../../../components/CardSetup/CardSetupForm'; +import { CardSetupForm } from '../../../components/CreditCard/CardSetupForm'; import { StyledTab, StyledTabs } from '../../../components/Tabs'; import { CryptoTopUpForm } from '../../../components/TopUpAccount/CryptoTopUpForm'; import { FiatTopUpForm } from '../../../components/TopUpAccount/FiatTopUpForm'; diff --git a/packages/apps/job-launcher/client/src/services/payment.ts b/packages/apps/job-launcher/client/src/services/payment.ts index f895fe4746..95924cbd82 100644 --- a/packages/apps/job-launcher/client/src/services/payment.ts +++ b/packages/apps/job-launcher/client/src/services/payment.ts @@ -1,7 +1,11 @@ import { WalletClient } from 'viem'; import { PAYMENT_SIGNATURE_KEY } from '../constants/payment'; -import { CryptoPaymentRequest, FiatPaymentRequest } from '../types'; +import { + BillingInfo, + CryptoPaymentRequest, + FiatPaymentRequest, +} from '../types'; import api from '../utils/api'; export const createSetupIntent = async () => { @@ -9,9 +13,13 @@ export const createSetupIntent = async () => { return data; }; -export const confirmSetupIntent = async (setupIntentId: string) => { +export const confirmSetupIntent = async ( + setupIntentId: string, + defaultCard: boolean, +) => { const { data } = await api.post('/payment/fiat/confirm-card', { setupId: setupIntentId, + defaultCard, }); return data; @@ -73,3 +81,25 @@ export const checkUserCard = async () => { return data; }; + +export const getUserCards = async () => { + const { data } = await api.get('/payment/fiat/cards'); + return data; +}; + +export const deleteUserCard = async (cardId: string) => { + await api.delete(`/payment/fiat/card?paymentMethodId=${cardId}`); +}; + +export const setUserDefaultCard = async (cardId: string) => { + await api.patch('/payment/fiat/default-card', { paymentMethodId: cardId }); +}; + +export const getUserBillingInfo = async () => { + const { data } = await api.get('/payment/fiat/billing-info'); + return data; +}; + +export const editUserBillingInfo = async (body: BillingInfo) => { + await api.patch('/payment/fiat/billing-info', body); +}; diff --git a/packages/apps/job-launcher/client/src/types/index.ts b/packages/apps/job-launcher/client/src/types/index.ts index 8acc312722..18de16a28a 100644 --- a/packages/apps/job-launcher/client/src/types/index.ts +++ b/packages/apps/job-launcher/client/src/types/index.ts @@ -31,6 +31,7 @@ export type CryptoPaymentRequest = { export type FiatPaymentRequest = { amount: number; currency: string; + paymentMethodId: string; }; export type CreateFortuneJobRequest = { @@ -298,3 +299,27 @@ export type Qualification = { description: string; expires_at: string; }; + +export type CardData = { + id: string; + last4: string; + brand: string; + expMonth: number; + expYear: number; + default: boolean; +}; + +export type BillingInfo = { + name: string; + email?: string; + address: Address; + vat: string; + vatType: string; +}; + +type Address = { + city: string; + country: string; + line: string; + postalCode: string; +}; diff --git a/packages/apps/job-launcher/server/src/common/constants/errors.ts b/packages/apps/job-launcher/server/src/common/constants/errors.ts index 0e4533c517..19b731939c 100644 --- a/packages/apps/job-launcher/server/src/common/constants/errors.ts +++ b/packages/apps/job-launcher/server/src/common/constants/errors.ts @@ -91,7 +91,6 @@ export enum ErrorPayment { NotSuccess = 'Unsuccessful payment', IntentNotCreated = 'Payment intent not created', CardNotAssigned = 'Card not assigned', - CardAssigned = 'User already has a card assigned', SetupNotFound = 'Setup not found', ClientSecretDoesNotExist = 'Client secret does not exist', CustomerNotFound = 'Customer not found', diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts index f53b384b19..56a9bece7f 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.controller.ts @@ -255,27 +255,6 @@ export class PaymentController { return this.paymentService.confirmFiatPayment(req.user.id, data); } - @ApiOperation({ - summary: 'Check if a card has already been assigned to the user', - description: - 'Endpoint to check if a card has already been assigned to the user.', - }) - @ApiResponse({ - status: 200, - description: 'Card assigned succesfully', - type: Boolean, - }) - @ApiResponse({ - status: 401, - description: 'Unauthorized. Missing or invalid credentials.', - }) - @Get('/fiat/check-card') - public async checkUserCard( - @Request() req: RequestWithUser, - ): Promise { - return !!req.user?.stripeCustomerId; - } - @ApiOperation({ summary: 'List user cards', description: 'Fetches all cards associated with the user.', diff --git a/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts b/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts index 355b802587..f6104084f8 100644 --- a/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts +++ b/packages/apps/job-launcher/server/src/modules/payment/payment.dto.ts @@ -55,6 +55,10 @@ export class CardConfirmDto { @ApiProperty({ name: 'payment_id' }) @IsString() public setupId: string; + + @ApiPropertyOptional({ name: 'default_card', default: false }) + @IsBoolean() + public defaultCard = false; } export class GetRateDto { @@ -150,6 +154,14 @@ export class CardDto { @IsString() public brand: string; + @ApiProperty({ name: 'exp_month' }) + @IsNumber() + public expMonth: number; + + @ApiProperty({ name: 'exp_year' }) + @IsNumber() + public expYear: number; + @ApiProperty() @IsBoolean() public default: boolean; 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 67f8c7270c..7c54f47772 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 @@ -862,31 +862,22 @@ describe('PaymentService', () => { }); }); - it('should throw a bad request exception if user already has a stripeCustomerId', async () => { - const user = { - id: 1, - email: 'test@hmt.ai', - stripeCustomerId: 'cus_123', - }; - - await expect( - paymentService.createCustomerAndAssignCard(user as any), - ).rejects.toThrow( - new ControlledError(ErrorPayment.CardAssigned, HttpStatus.NOT_FOUND), - ); - }); - it('should throw a bad request exception if the customer creation fails', async () => { const user = { id: 1, email: 'test@hmt.ai', + stripeCustomerId: undefined, }; - jest.spyOn(stripe.customers, 'create').mockRejectedValue(new Error()); await expect( paymentService.createCustomerAndAssignCard(user as any), - ).rejects.toThrow(ErrorPayment.CardNotAssigned); + ).rejects.toThrow( + new ControlledError( + ErrorPayment.CustomerNotCreated, + HttpStatus.NOT_FOUND, + ), + ); }); it('should throw a bad request exception if the setup intent creation fails', async () => { 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 2a9ed6ee6d..abb8379fc0 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 @@ -69,31 +69,35 @@ export class PaymentService { public async createCustomerAndAssignCard(user: UserEntity): Promise { let setupIntent: Stripe.Response; - - if (user.stripeCustomerId) { - this.logger.log(ErrorPayment.CardAssigned, PaymentService.name); - throw new ControlledError( - ErrorPayment.CardAssigned, - HttpStatus.NOT_FOUND, - ); + let customerId: string = user.stripeCustomerId; + + if (!user.stripeCustomerId) { + try { + customerId = ( + await this.stripe.customers.create({ + email: user.email, + }) + ).id; + } catch (error) { + this.logger.log(error.message, PaymentService.name); + throw new ControlledError( + ErrorPayment.CustomerNotCreated, + HttpStatus.BAD_REQUEST, + ); + } } - try { - const customer = await this.stripe.customers.create({ - email: user.email, - }); - setupIntent = await this.stripe.setupIntents.create({ automatic_payment_methods: { enabled: true, }, - customer: customer.id, + customer: customerId, }); } catch (error) { this.logger.log(error.message, PaymentService.name); throw new ControlledError( ErrorPayment.CardNotAssigned, - HttpStatus.NOT_FOUND, + HttpStatus.BAD_REQUEST, ); } @@ -124,15 +128,17 @@ export class PaymentService { HttpStatus.NOT_FOUND, ); } - - await this.stripe.customers.update(setup.customer, { - invoice_settings: { - default_payment_method: setup.payment_method, - }, - }); - - user.stripeCustomerId = setup.customer as string; - await this.userRepository.updateOne(user); + if (data.defaultCard || !user.stripeCustomerId) { + await this.stripe.customers.update(setup.customer, { + invoice_settings: { + default_payment_method: setup.payment_method, + }, + }); + } + if (!user.stripeCustomerId) { + user.stripeCustomerId = setup.customer as string; + await this.userRepository.updateOne(user); + } return true; } @@ -459,6 +465,8 @@ export class PaymentService { 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.default = defaultPaymentMethod === paymentMethod.id; cards.push(card); } @@ -494,18 +502,19 @@ export class PaymentService { const userBillingInfo = new BillingInfoDto(); if ((customer as Stripe.Customer).address) { const address = new AddressDto(); - address.country = (customer as Stripe.Customer).address - ?.country as string; + address.country = ( + (customer as Stripe.Customer).address?.country as string + ).toLowerCase(); address.postalCode = (customer as Stripe.Customer).address ?.postal_code as string; address.city = (customer as Stripe.Customer).address?.city as string; address.line = (customer as Stripe.Customer).address?.line1 as string; userBillingInfo.address = address; } - userBillingInfo.name = (customer as Stripe.Customer).name as string; - userBillingInfo.email = (customer as Stripe.Customer).email as string; - userBillingInfo.vat = taxIds.data[0].value; - userBillingInfo.vatType = taxIds.data[0].type as VatType; + userBillingInfo.name = (customer as Stripe.Customer)?.name as string; + userBillingInfo.email = (customer as Stripe.Customer)?.email as string; + userBillingInfo.vat = taxIds.data[0]?.value; + userBillingInfo.vatType = taxIds.data[0]?.type as VatType; return userBillingInfo; } From 016294a33c351aaeb2b8c052e428633d4cc66a05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Fri, 22 Nov 2024 14:18:46 +0100 Subject: [PATCH 07/11] Billing system in Job launcher client --- packages/apps/job-launcher/client/src/App.tsx | 9 + .../BillingDetails/BillingDetailsModal.tsx | 48 ++-- .../components/CreditCard/DeleteCardModal.tsx | 2 +- .../src/components/Headers/AuthHeader.tsx | 2 + .../src/components/Icons/CryptoIcon.tsx | 15 ++ .../components/Jobs/Create/FiatPayForm.tsx | 45 +++- .../src/components/Payment/PaymentTable.tsx | 238 ++++++++++++++++++ .../components/TopUpAccount/FiatTopUpForm.tsx | 44 +++- .../client/src/constants/payment.ts | 13 + .../client/src/hooks/usePayments.ts | 22 ++ .../src/pages/Profile/Settings/index.tsx | 113 +++++---- .../src/pages/Profile/TopUpAccount/index.tsx | 62 +---- .../src/pages/Profile/Transactions/index.tsx | 29 +++ .../client/src/services/payment.ts | 23 +- .../server/src/common/constants/errors.ts | 2 + .../server/src/common/enums/payment.ts | 5 + .../src/modules/payment/payment.controller.ts | 47 ++++ .../server/src/modules/payment/payment.dto.ts | 69 ++++- .../src/modules/payment/payment.interface.ts | 6 + .../src/modules/payment/payment.repository.ts | 25 +- .../src/modules/payment/payment.service.ts | 145 ++++++++++- 21 files changed, 820 insertions(+), 144 deletions(-) create mode 100644 packages/apps/job-launcher/client/src/components/Icons/CryptoIcon.tsx create mode 100644 packages/apps/job-launcher/client/src/components/Payment/PaymentTable.tsx create mode 100644 packages/apps/job-launcher/client/src/hooks/usePayments.ts create mode 100644 packages/apps/job-launcher/client/src/pages/Profile/Transactions/index.tsx create mode 100644 packages/apps/job-launcher/server/src/modules/payment/payment.interface.ts diff --git a/packages/apps/job-launcher/client/src/App.tsx b/packages/apps/job-launcher/client/src/App.tsx index 5f640fe725..17310db791 100644 --- a/packages/apps/job-launcher/client/src/App.tsx +++ b/packages/apps/job-launcher/client/src/App.tsx @@ -9,6 +9,7 @@ import JobDetail from './pages/Job/JobDetail'; import JobList from './pages/Job/JobList'; import Settings from './pages/Profile/Settings'; import TopUpAccount from './pages/Profile/TopUpAccount'; +import Transactions from './pages/Profile/Transactions'; import ResetPassword from './pages/ResetPassword'; import ValidateEmail from './pages/ValidateEmail'; import VerifyEmail from './pages/VerifyEmail'; @@ -65,6 +66,14 @@ export default function App() { } /> + + + + } + /> formData.address[field as keyof typeof formData.address], - ); - const allAddressFieldsFilled = addressFields.every( - (field) => formData.address[field as keyof typeof formData.address], - ); - - if (hasAddressFields && !allAddressFieldsFilled) { - newErrors.address = 'All address fields must be filled or none.'; - } + addressFields.forEach((field) => { + if (!formData.address[field as keyof typeof formData.address]) { + newErrors[field] = `${field} required`; + } + }); - if (formData.vat && !formData.vatType) { - newErrors.vatType = 'VAT type is required if VAT number is provided'; + if (!formData.vat) { + newErrors.vat = 'Tax ID required'; + } + if (!formData.vatType) { + newErrors.vatType = 'Tax ID type required'; } setErrors(newErrors); @@ -94,7 +92,7 @@ const BillingDetailsModal = ({ setBillingInfo(formData); } catch (err: any) { showError( - err.message || 'An error occurred while setting up the card.', + err.message || 'An error occurred while saving billing details.', ); } setIsLoading(false); @@ -150,20 +148,22 @@ const BillingDetailsModal = ({ helperText={errors.name} /> {Object.entries(countryOptions).map(([key, label]) => ( @@ -214,8 +216,8 @@ const BillingDetailsModal = ({ value={formData.vat || ''} onChange={handleInputChange} fullWidth - error={!!errors.vatType} - helperText="" + error={!!errors.vat} + helperText={errors.vat || ''} />
- Select a Credit Card + Cancel