Skip to content

PM-1161 - reconcile payments on user details updates #39

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/api/admin/admin.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
49 changes: 3 additions & 46 deletions src/api/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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) {
Expand Down Expand Up @@ -265,9 +267,7 @@ export class AdminService {
});

if (winning?.winner_id) {
await this.reconcileWinningsStatusOnUserDetailsUpdate(
winning.winner_id,
);
await this.paymentsService.reconcileUserPayments(winning.winner_id);
}
}

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/api/repository/winnings.repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)))})
`;

Expand Down
12 changes: 11 additions & 1 deletion src/api/webhooks/trolley/handlers/recipient-account.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -126,6 +130,8 @@ export class RecipientAccountHandler {
}

await this.updateUserPaymentMethod(payload.recipientId);

await this.paymentsService.reconcileUserPayments(recipient.user_id);
}

/**
Expand Down Expand Up @@ -162,5 +168,9 @@ export class RecipientAccountHandler {
await this.updateUserPaymentMethod(
recipientPaymentMethod.trolley_recipient.trolley_id,
);

await this.paymentsService.reconcileUserPayments(
recipientPaymentMethod.trolley_recipient.user_id,
);
}
}
14 changes: 10 additions & 4 deletions src/api/webhooks/trolley/handlers/tax-form.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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({
Expand All @@ -34,7 +38,6 @@ export class TaxFormHandler {
where: {
user_id: recipient.user_id,
tax_form_id: taxFormId,
tax_form_status: tax_form_status.ACTIVE,
},
});

Expand All @@ -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,
},
});
}
Expand Down Expand Up @@ -102,5 +106,7 @@ export class TaxFormHandler {
recipient,
taxFormData,
);

await this.paymentsService.reconcileUserPayments(recipient.user_id);
}
}
5 changes: 4 additions & 1 deletion src/api/webhooks/trolley/trolley.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ if (!trolleyWhHmac) {
export class TrolleyService {
constructor(
@Inject('trolleyHandlerFns')
private readonly handlers,
private readonly handlers: Map<
string,
(eventPayload: any) => Promise<unknown>
>,
private readonly prisma: PrismaService,
) {}

Expand Down
3 changes: 2 additions & 1 deletion src/api/webhooks/webhooks.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
})
Expand Down
2 changes: 2 additions & 0 deletions src/shared/payments/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './payments.module';
export * from './payments.service';
8 changes: 8 additions & 0 deletions src/shared/payments/payments.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { PaymentsService } from './payments.service';

@Module({
providers: [PaymentsService],
exports: [PaymentsService],
})
export class PaymentsModule {}
100 changes: 100 additions & 0 deletions src/shared/payments/payments.service.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}