diff --git a/src/api/admin/admin.module.ts b/src/api/admin/admin.module.ts index 3f70141..7ceb127 100644 --- a/src/api/admin/admin.module.ts +++ b/src/api/admin/admin.module.ts @@ -5,9 +5,10 @@ import { TaxFormRepository } from '../repository/taxForm.repo'; import { PaymentMethodRepository } from '../repository/paymentMethod.repo'; import { WinningsRepository } from '../repository/winnings.repo'; import { TopcoderModule } from 'src/shared/topcoder/topcoder.module'; +import { PaymentsModule } from 'src/shared/payments'; @Module({ - imports: [TopcoderModule], + imports: [TopcoderModule, PaymentsModule], controllers: [AdminController], providers: [ AdminService, diff --git a/src/api/admin/admin.service.ts b/src/api/admin/admin.service.ts index 4653caa..4f7fb72 100644 --- a/src/api/admin/admin.service.ts +++ b/src/api/admin/admin.service.ts @@ -7,6 +7,7 @@ import { import { PrismaPromise } from '@prisma/client'; import { PrismaService } from 'src/shared/global/prisma.service'; +import { PaymentsService } from 'src/shared/payments'; import { TaxFormRepository } from '../repository/taxForm.repo'; import { PaymentMethodRepository } from '../repository/paymentMethod.repo'; @@ -28,6 +29,7 @@ export class AdminService { private readonly prisma: PrismaService, private readonly taxFormRepo: TaxFormRepository, private readonly paymentMethodRepo: PaymentMethodRepository, + private readonly paymentsService: PaymentsService, ) {} private getPaymentsByWinningsId(winningsId: string, paymentId?: string) { @@ -265,9 +267,7 @@ export class AdminService { }); if (winning?.winner_id) { - await this.reconcileWinningsStatusOnUserDetailsUpdate( - winning.winner_id, - ); + await this.paymentsService.reconcileUserPayments(winning.winner_id); } } @@ -448,49 +448,6 @@ export class AdminService { }); } - /** - * Update payment for user from one status to another - * - * @param userId user id - * @param fromStatus from status - * @param toStatus to status - * @param tx transaction - */ - updateWinningsStatus(userId, fromStatus, toStatus) { - return this.prisma.$executeRaw` - UPDATE payment - SET payment_status = ${toStatus}::payment_status, - updated_at = now(), - updated_by = 'system', - version = version + 1 - FROM winnings - WHERE payment.winnings_id = winnings.winning_id - AND winnings.winner_id = ${userId} - AND payment.payment_status = ${fromStatus}::payment_status AND version = version - `; - } - - /** - * Reconcile winning if user data updated - * - * @param userId user id - */ - async reconcileWinningsStatusOnUserDetailsUpdate(userId) { - const hasTaxForm = await this.taxFormRepo.hasActiveTaxForm(userId); - const hasPaymentMethod = - await this.paymentMethodRepo.hasVerifiedPaymentMethod(userId); - let fromStatus, toStatus; - if (hasTaxForm && hasPaymentMethod) { - fromStatus = PaymentStatus.ON_HOLD; - toStatus = PaymentStatus.OWED; - } else { - fromStatus = PaymentStatus.OWED; - toStatus = PaymentStatus.ON_HOLD; - } - - await this.updateWinningsStatus(userId, fromStatus, toStatus); - } - /** * Get winning audit for winningId * @param winningId the winningId diff --git a/src/api/repository/winnings.repo.ts b/src/api/repository/winnings.repo.ts index 9dc65d4..818970d 100644 --- a/src/api/repository/winnings.repo.ts +++ b/src/api/repository/winnings.repo.ts @@ -129,7 +129,7 @@ export class WinningsRepository { CASE WHEN utx.tax_form_status = 'ACTIVE' THEN TRUE ELSE FALSE END as "taxFormSetupComplete", CASE WHEN upm.status = 'CONNECTED' THEN TRUE ELSE FALSE END as "payoutSetupComplete" FROM user_payment_methods upm - LEFT JOIN user_tax_form_associations utx ON upm.user_id = utx.user_id + LEFT JOIN user_tax_form_associations utx ON upm.user_id = utx.user_id AND utx.tax_form_status = 'ACTIVE' WHERE upm.user_id IN (${Prisma.join(uniq(winnings.map((w) => w.winner_id)))}) `; diff --git a/src/api/webhooks/trolley/handlers/recipient-account.handler.ts b/src/api/webhooks/trolley/handlers/recipient-account.handler.ts index 0f15259..1c95361 100644 --- a/src/api/webhooks/trolley/handlers/recipient-account.handler.ts +++ b/src/api/webhooks/trolley/handlers/recipient-account.handler.ts @@ -7,10 +7,14 @@ import { RecipientAccountWebhookEvent, } from './recipient-account.types'; import { payment_method_status } from '@prisma/client'; +import { PaymentsService } from 'src/shared/payments'; @Injectable() export class RecipientAccountHandler { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly paymentsService: PaymentsService, + ) {} /** * Updates the status of the related trolley user_payment_method based on the presence of primary @@ -126,6 +130,8 @@ export class RecipientAccountHandler { } await this.updateUserPaymentMethod(payload.recipientId); + + await this.paymentsService.reconcileUserPayments(recipient.user_id); } /** @@ -162,5 +168,9 @@ export class RecipientAccountHandler { await this.updateUserPaymentMethod( recipientPaymentMethod.trolley_recipient.trolley_id, ); + + await this.paymentsService.reconcileUserPayments( + recipientPaymentMethod.trolley_recipient.user_id, + ); } } diff --git a/src/api/webhooks/trolley/handlers/tax-form.handler.ts b/src/api/webhooks/trolley/handlers/tax-form.handler.ts index 73d9937..e50907c 100644 --- a/src/api/webhooks/trolley/handlers/tax-form.handler.ts +++ b/src/api/webhooks/trolley/handlers/tax-form.handler.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { WebhookEvent } from '../../webhooks.decorators'; import { PrismaService } from 'src/shared/global/prisma.service'; import { tax_form_status, trolley_recipient } from '@prisma/client'; +import { PaymentsService } from 'src/shared/payments'; import { TrolleyTaxFormStatus, TaxFormStatusUpdatedEvent, @@ -11,7 +12,10 @@ import { @Injectable() export class TaxFormHandler { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly paymentsService: PaymentsService, + ) {} getDbRecipientById(id: string) { return this.prisma.trolley_recipient.findUnique({ @@ -34,7 +38,6 @@ export class TaxFormHandler { where: { user_id: recipient.user_id, tax_form_id: taxFormId, - tax_form_status: tax_form_status.ACTIVE, }, }); @@ -43,9 +46,10 @@ export class TaxFormHandler { taxFormData.status === TrolleyTaxFormStatus.Voided && existingFormAssociation ) { - return this.prisma.user_tax_form_associations.delete({ + return this.prisma.user_tax_form_associations.deleteMany({ where: { - id: existingFormAssociation.id, + user_id: recipient.user_id, + tax_form_id: taxFormId, }, }); } @@ -102,5 +106,7 @@ export class TaxFormHandler { recipient, taxFormData, ); + + await this.paymentsService.reconcileUserPayments(recipient.user_id); } } diff --git a/src/api/webhooks/trolley/trolley.service.ts b/src/api/webhooks/trolley/trolley.service.ts index b508e76..41988f5 100644 --- a/src/api/webhooks/trolley/trolley.service.ts +++ b/src/api/webhooks/trolley/trolley.service.ts @@ -22,7 +22,10 @@ if (!trolleyWhHmac) { export class TrolleyService { constructor( @Inject('trolleyHandlerFns') - private readonly handlers, + private readonly handlers: Map< + string, + (eventPayload: any) => Promise + >, private readonly prisma: PrismaService, ) {} diff --git a/src/api/webhooks/webhooks.module.ts b/src/api/webhooks/webhooks.module.ts index 6e12590..f73c6ac 100644 --- a/src/api/webhooks/webhooks.module.ts +++ b/src/api/webhooks/webhooks.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { WebhooksController } from './webhooks.controller'; import { TrolleyService } from './trolley/trolley.service'; import { TrolleyWebhookHandlers } from './trolley/handlers'; +import { PaymentsModule } from 'src/shared/payments'; @Module({ - imports: [], + imports: [PaymentsModule], controllers: [WebhooksController], providers: [...TrolleyWebhookHandlers, TrolleyService], }) diff --git a/src/shared/payments/index.ts b/src/shared/payments/index.ts new file mode 100644 index 0000000..8ab1eb6 --- /dev/null +++ b/src/shared/payments/index.ts @@ -0,0 +1,2 @@ +export * from './payments.module'; +export * from './payments.service'; diff --git a/src/shared/payments/payments.module.ts b/src/shared/payments/payments.module.ts new file mode 100644 index 0000000..da16549 --- /dev/null +++ b/src/shared/payments/payments.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { PaymentsService } from './payments.service'; + +@Module({ + providers: [PaymentsService], + exports: [PaymentsService], +}) +export class PaymentsModule {} diff --git a/src/shared/payments/payments.service.ts b/src/shared/payments/payments.service.ts new file mode 100644 index 0000000..b600827 --- /dev/null +++ b/src/shared/payments/payments.service.ts @@ -0,0 +1,100 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../global/prisma.service'; +import { payment_status, Prisma } from '@prisma/client'; +import { uniq } from 'lodash'; + +@Injectable() +export class PaymentsService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Retrieves the payout setup status for a list of users. + * + * This method queries the database to determine whether each user's payout setup + * is complete or still in progress. A user's payout setup is considered complete + * if their tax form status is 'ACTIVE' and their payment method status is 'CONNECTED'. + * + * @param userIds - An array of user IDs for which to retrieve the payout setup status. + * @returns A promise that resolves to an object containing two arrays: + * - `complete`: An array of user IDs whose payout setup is complete. + * - `inProgress`: An array of user IDs whose payout setup is still in progress. + * + * @throws Will throw an error if the database query fails. + */ + private async getUsersPayoutStatus(userIds: string[]) { + const usersPayoutStatus = await this.prisma.$queryRaw< + { + userId: string; + setupComplete: boolean; + }[] + >` + SELECT + upm.user_id as "userId", + CASE WHEN utx.tax_form_status = 'ACTIVE' AND upm.status = 'CONNECTED' THEN TRUE ELSE FALSE END as "setupComplete" + FROM user_payment_methods upm + LEFT JOIN user_tax_form_associations utx ON upm.user_id = utx.user_id AND utx.tax_form_status = 'ACTIVE' + WHERE upm.user_id IN (${Prisma.join(uniq(userIds))}) + `; + + const setupStatusMap = { + complete: [] as string[], + inProgress: [] as string[], + }; + + usersPayoutStatus.forEach((user) => { + setupStatusMap[user.setupComplete ? 'complete' : 'inProgress'].push( + user.userId, + ); + }); + + return setupStatusMap; + } + + /** + * Updates the payment status of users' payments based on the provided user IDs and the desired status. + * + * @param userIds - An array of user IDs whose payment statuses need to be updated. + * @param setOnHold - A boolean indicating whether to set the payment status to "ON_HOLD" (true) + * or revert it to "OWED" (false). + * @returns A raw Prisma query that updates the payment statuses in the database. + * + * The function performs the following: + * - Updates the `payment_status` field in the `payment` table. + * - Changes the status to "ON_HOLD" if `setOnHold` is true, or to "OWED" if `setOnHold` is false. + * - Ensures that only payments associated with the specified users' winnings are updated. + */ + private updateUserPaymentsStatus(userIds: string[], setOnHold: boolean) { + return this.prisma.$queryRaw` + UPDATE payment + SET payment_status = ${setOnHold ? payment_status.ON_HOLD : payment_status.OWED}::payment_status + FROM winnings w + WHERE payment.payment_status = ${setOnHold ? payment_status.OWED : payment_status.ON_HOLD}::payment_status + AND payment.winnings_id = w.winning_id + AND w.winner_id IN (${Prisma.join(uniq(userIds))}); + `; + } + + /** + * Reconciles the payment statuses of the specified users by updating their payment records. + * + * @param userIds - A list of user IDs whose payment statuses need to be reconciled. + * + * The method performs the following steps: + * 1. Retrieves the payout status of the specified users. + * 2. Updates the payment status for users whose payments are complete to `OWED`. + * 3. Updates the payment status for users whose payments are in progress to `ON_HOLD`. + * + * This ensures that the payment statuses are accurately reflected in the system. + */ + async reconcileUserPayments(...userIds: string[]) { + const usersPayoutStatus = await this.getUsersPayoutStatus(userIds); + + if (usersPayoutStatus.complete.length) { + await this.updateUserPaymentsStatus(usersPayoutStatus.complete, false); + } + + if (usersPayoutStatus.inProgress.length) { + await this.updateUserPaymentsStatus(usersPayoutStatus.inProgress, true); + } + } +}