diff --git a/src/api/admin-winning/adminWinning.service.ts b/src/api/admin-winning/adminWinning.service.ts index 3fcc388..20192d6 100644 --- a/src/api/admin-winning/adminWinning.service.ts +++ b/src/api/admin-winning/adminWinning.service.ts @@ -18,6 +18,8 @@ import { PaymentStatus, AuditPayoutDto, } from 'src/dto/adminWinning.dto'; +import { TaxFormRepository } from '../repository/taxForm.repo'; +import { PaymentMethodRepository } from '../repository/paymentMethod.repo'; const ONE_DAY = 24 * 60 * 60 * 1000; @@ -30,7 +32,11 @@ export class AdminWinningService { * Constructs the admin winning service with the given dependencies. * @param prisma the prisma service. */ - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly taxFormRepo: TaxFormRepository, + private readonly paymentMethodRepo: PaymentMethodRepository, + ) {} /** * Search winnings with parameters @@ -101,7 +107,7 @@ export class AdminWinningService { details: item.payment?.map((paymentItem) => ({ id: paymentItem.payment_id, netAmount: Number(paymentItem.net_amount), - grossAmount: Number(paymentItem.gross_amount), + grossAmount: (paymentItem.gross_amount), totalAmount: Number(paymentItem.total_amount), installmentNumber: paymentItem.installment_number, datePaid: paymentItem.date_paid ?? undefined, @@ -276,6 +282,21 @@ export class AdminWinningService { return orderBy; } + private getPaymentsByWinningsId(winningsId: string, paymentId?: string) { + return this.prisma.payment.findMany({ + where: { + winnings_id: { + equals: winningsId, + }, + payment_id: paymentId + ? { + equals: paymentId, + } + : undefined, + }, + }); + } + /** * Update winnings with parameters * @param body the request body @@ -291,18 +312,10 @@ export class AdminWinningService { let needsReconciliation = false; const winningsId = body.winningsId; try { - const payments = await this.prisma.payment.findMany({ - where: { - winnings_id: { - equals: winningsId, - }, - payment_id: body.paymentId - ? { - equals: body.paymentId, - } - : undefined, - }, - }); + const payments = await this.getPaymentsByWinningsId( + winningsId, + body.paymentId, + ); if (payments.length === 0) { throw new NotFoundException('failed to get current payments'); @@ -316,6 +329,13 @@ export class AdminWinningService { const transactions: PrismaPromise[] = []; const now = new Date().getTime(); payments.forEach((payment) => { + if ( + payment.payment_status && + payment.payment_status === PaymentStatus.CANCELLED + ) { + throw new BadRequestException('cannot update cancelled winnings'); + } + let version = payment.version ?? 1; // Update Payment Status if requested if (body.paymentStatus) { @@ -356,7 +376,10 @@ export class AdminWinningService { break; } - if (errMessage) { + if ( + errMessage && + payment.payment_status === PaymentStatus.PROCESSING + ) { throw new BadRequestException(errMessage); } @@ -371,7 +394,10 @@ export class AdminWinningService { ), ); version += 1; - needsReconciliation = true; + + if (body.paymentStatus === PaymentStatus.OWED) { + needsReconciliation = true; + } if (payment.installment_number === 1) { transactions.push( @@ -388,11 +414,6 @@ export class AdminWinningService { // Update Release Date if requested if (body.releaseDate) { const newReleaseDate = new Date(body.releaseDate); - if (newReleaseDate.getTime() < now) { - throw new BadRequestException( - 'new release date cannot be in the past', - ); - } transactions.push( this.updateReleaseDate( @@ -417,10 +438,11 @@ export class AdminWinningService { } } - // Update Release Date if requested + // Update payment amount if requested if ( body.paymentAmount !== undefined && (payment.payment_status === PaymentStatus.OWED || + payment.payment_status === PaymentStatus.ON_HOLD || payment.payment_status === PaymentStatus.ON_HOLD || payment.payment_status === PaymentStatus.ON_HOLD_ADMIN) ) { @@ -661,9 +683,47 @@ export class AdminWinningService { }); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - private async reconcileWinningsStatusOnUserDetailsUpdate(userId: string) { - // not implement, because it's about detail payment + /** + * 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); } /** diff --git a/src/api/api.module.ts b/src/api/api.module.ts index fe35f31..767fc84 100644 --- a/src/api/api.module.ts +++ b/src/api/api.module.ts @@ -12,6 +12,9 @@ import { APP_GUARD } from '@nestjs/core'; import { TokenValidatorMiddleware } from 'src/core/auth/middleware/tokenValidator.middleware'; import { AuthGuard, RolesGuard } from 'src/core/auth/guards'; import { TopcoderModule } from 'src/shared/topcoder/topcoder.module'; +import { OriginRepository } from './repository/origin.repo'; +import { TaxFormRepository } from './repository/taxForm.repo'; +import { PaymentMethodRepository } from './repository/paymentMethod.repo'; @Module({ imports: [GlobalProvidersModule, TopcoderModule], @@ -34,6 +37,9 @@ import { TopcoderModule } from 'src/shared/topcoder/topcoder.module'; AdminWinningService, WinningService, WalletService, + OriginRepository, + TaxFormRepository, + PaymentMethodRepository, ], }) export class ApiModule implements NestModule { diff --git a/src/api/repository/origin.repo.ts b/src/api/repository/origin.repo.ts new file mode 100644 index 0000000..32c2a2d --- /dev/null +++ b/src/api/repository/origin.repo.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from 'src/shared/global/prisma.service'; + +@Injectable() +export class OriginRepository { + constructor(private readonly prisma: PrismaService) {} + + /** + * Get origin id by name + * + * @param name origin name + * @param tx transaction + */ + async getOriginIdByName(name: string, tx?): Promise { + const db = tx || this.prisma; + const originData = await db.origin.findFirst({ + where: { origin_name: name }, + }); + if (!originData) { + return null; + } + return originData.origin_id as number; + } +} diff --git a/src/api/repository/paymentMethod.repo.ts b/src/api/repository/paymentMethod.repo.ts new file mode 100644 index 0000000..6611904 --- /dev/null +++ b/src/api/repository/paymentMethod.repo.ts @@ -0,0 +1,49 @@ +import { Injectable } from '@nestjs/common'; +import { + PaymentMethodQueryResult, + UserPaymentMethodStatus, +} from 'src/dto/paymentMethod.dto'; +import { PrismaService } from 'src/shared/global/prisma.service'; + +@Injectable() +export class PaymentMethodRepository { + constructor(private readonly prisma: PrismaService) {} + + /** + * Check user has verified payment method + * + * @param userId user id + * @param tx transaction + */ + async hasVerifiedPaymentMethod(userId: string, tx?): Promise { + const methods = await this.findPaymentMethodByUserId(userId, tx); + for (const method of methods) { + if ( + method.status === + UserPaymentMethodStatus.UserPaymentMethodStatusConnected.toString() + ) { + return true; + } + } + return false; + } + + /** + * Get user payment methods + * + * @param userId user id + * @param tx transaction + * @returns payment methods + */ + private async findPaymentMethodByUserId(userId: string, tx?) { + const query = ` + SELECT pm.payment_method_id, pm.payment_method_type, pm.name, pm.description, upm.status, upm.id + FROM payment_method pm + JOIN user_payment_methods upm ON pm.payment_method_id = upm.payment_method_id + WHERE upm.user_id = '${userId}' + `; + const db = tx || this.prisma; + const ret = await db.$queryRawUnsafe(query); + return (ret || []) as PaymentMethodQueryResult[]; + } +} diff --git a/src/api/repository/taxForm.repo.ts b/src/api/repository/taxForm.repo.ts new file mode 100644 index 0000000..c92a6f0 --- /dev/null +++ b/src/api/repository/taxForm.repo.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common'; +import { TaxFormStatus } from 'src/dto/adminWinning.dto'; +import { TaxFormQueryResult } from 'src/dto/taxForm.dto'; +import { PrismaService } from 'src/shared/global/prisma.service'; + +@Injectable() +export class TaxFormRepository { + constructor(private readonly prisma: PrismaService) {} + + /** + * Check user has tax form or not + * + * @param userId user id + * @returns true if user has active tax form + */ + async hasActiveTaxForm(userId: string): Promise { + const ret = await this.findTaxFormByUserId(userId); + for (const r of ret) { + if (r.status_id === TaxFormStatus.Active.toString()) { + return true; + } + } + return false; + } + + /** + * Find tax forms by user id + * + * @param userId user id + * @param tx transaction + * @returns tax forms + */ + async findTaxFormByUserId( + userId: string, + tx?, + ): Promise { + const query = ` + SELECT u.id, u.user_id, t.tax_form_id, t.name, t.text, t.description, u.date_filed, u.withholding_amount, u.withholding_percentage, u.status_id::text, u.use_percentage + FROM user_tax_form_associations AS u + JOIN tax_forms AS t ON u.tax_form_id = t.tax_form_id + WHERE u.user_id = '${userId}' + `; + const db = tx || this.prisma; + + const ret = await db.$queryRawUnsafe(query); + return (ret || []) as TaxFormQueryResult[]; + } +} diff --git a/src/api/winning/winning.controller.ts b/src/api/winning/winning.controller.ts index 0f19b0a..0d015f3 100644 --- a/src/api/winning/winning.controller.ts +++ b/src/api/winning/winning.controller.ts @@ -85,11 +85,10 @@ export class WinningController { @Body() body: WinningRequestDto, ): Promise> { const result = await this.adminWinningService.searchWinnings(body); - if (result.error) { - result.status = ResponseStatusType.ERROR; - } - result.status = ResponseStatusType.SUCCESS; + result.status = result.error + ? ResponseStatusType.ERROR + : ResponseStatusType.SUCCESS; return result; } diff --git a/src/api/winning/winning.service.ts b/src/api/winning/winning.service.ts index 827b304..b640a77 100644 --- a/src/api/winning/winning.service.ts +++ b/src/api/winning/winning.service.ts @@ -1,9 +1,16 @@ import { Injectable, HttpStatus } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { Prisma, payment, payment_status } from '@prisma/client'; import { PrismaService } from 'src/shared/global/prisma.service'; -import { ResponseDto, WinningCreateRequestDto } from 'src/dto/adminWinning.dto'; +import { + PaymentStatus, + ResponseDto, + WinningCreateRequestDto, +} from 'src/dto/adminWinning.dto'; +import { OriginRepository } from '../repository/origin.repo'; +import { TaxFormRepository } from '../repository/taxForm.repo'; +import { PaymentMethodRepository } from '../repository/paymentMethod.repo'; /** * The winning service. @@ -14,7 +21,12 @@ export class WinningService { * Constructs the admin winning service with the given dependencies. * @param prisma the prisma service. */ - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly taxFormRepo: TaxFormRepository, + private readonly paymentMethodRepo: PaymentMethodRepository, + private readonly originRepo: OriginRepository, + ) {} /** * Create winnings with parameters @@ -28,54 +40,93 @@ export class WinningService { ): Promise> { const result = new ResponseDto(); - try { - const winningsEntity = await this.prisma.winnings.create({ - data: { - winner_id: body.winnerId, - type: body.type, - origin: { - create: { - origin_name: body.origin, - }, + return this.prisma.$transaction(async (tx) => { + const originId = await this.originRepo.getOriginIdByName(body.origin, tx); + + if (!originId) { + return { + ...result, + error: { + code: HttpStatus.BAD_REQUEST, + message: 'Origin name does not exist', }, - category: body.category, - title: body.title, - description: body.description, - external_id: body.externalId, - attributes: body.attributes, - created_by: userId, - created_at: new Date(), + }; + } + + const winningModel = { + winner_id: body.winnerId, + type: body.type, + origin_id: originId, + category: body.category, + title: body.title, + description: body.description, + external_id: body.externalId, + attributes: body.attributes, + created_by: userId, + payment: { + create: [] as Partial[], }, - }); + }; + const taxForms = await this.taxFormRepo.findTaxFormByUserId( + body.winnerId, + tx, + ); + const payrollPayment = (body.attributes || {})['payroll'] === true; - const paymentData: Prisma.paymentCreateManyInput[] = body.details.map( - (item) => ({ - total_amount: new Prisma.Decimal(item.totalAmount), - gross_amount: new Prisma.Decimal(item.grossAmount), - installment_number: item.installmentNumber, - currency: item.currency, + const hasPaymentMethod = + await this.paymentMethodRepo.hasVerifiedPaymentMethod( + body.winnerId, + tx, + ); + + for (const detail of body.details || []) { + const paymentModel = { + gross_amount: Prisma.Decimal(detail.grossAmount), + total_amount: Prisma.Decimal(detail.totalAmount), + installment_number: detail.installmentNumber, + currency: detail.currency, + net_amount: Prisma.Decimal(0), + payment_status: '' as payment_status, created_by: userId, - created_at: new Date(), - payment_status: 'ON_HOLD', - version: 1, - winnings_id: winningsEntity.winning_id, - }), - ); + }; + if (taxForms.length > 0) { + let netAmount = detail.grossAmount; + for (const taxForm of taxForms) { + const withholding = taxForm.withholding_amount; + netAmount -= withholding; + if (netAmount <= 0) { + netAmount = 0; + break; + } + } + paymentModel.net_amount = Prisma.Decimal(netAmount); + paymentModel.payment_status = PaymentStatus.OWED; + } else { + paymentModel.net_amount = Prisma.Decimal(detail.grossAmount); + paymentModel.payment_status = PaymentStatus.ON_HOLD; + } - await this.prisma.payment.createMany({ - data: paymentData, - }); + if (!hasPaymentMethod) { + paymentModel.payment_status = PaymentStatus.ON_HOLD; + } + if (payrollPayment) { + paymentModel.payment_status = PaymentStatus.PAID; + } - result.data = 'Create winnings successfully'; - } catch (error) { - console.error('Getting winnings audit failed', error); - const message = 'Searching winnings failed. ' + error; - result.error = { - code: HttpStatus.INTERNAL_SERVER_ERROR, - message, - }; - } + winningModel.payment.create.push(paymentModel); + } + // use prisma nested writes to avoid foreign key checks + const createdWinning = await this.prisma.winnings.create({ + data: winningModel as any, + }); + if (!createdWinning) { + result.error = { + code: HttpStatus.INTERNAL_SERVER_ERROR, + message: 'Failed to create winning!', + }; + } - return result; + return result; + }); } } diff --git a/src/dto/adminWinning.dto.ts b/src/dto/adminWinning.dto.ts index cd90e5d..30de55c 100644 --- a/src/dto/adminWinning.dto.ts +++ b/src/dto/adminWinning.dto.ts @@ -92,6 +92,13 @@ export enum WinningsCategory { TASK_COPILOT_PAYMENT = 'TASK_COPILOT_PAYMENT', } +export enum TaxFormStatus { + Active = 'ACTIVE', + Inactive = 'INACTIVE', + Verified = 'OTP_VERIFIED', + OtpPending = 'OTP_PENDING', +} + export enum PaymentStatus { PAID = 'PAID', ON_HOLD = 'ON_HOLD', diff --git a/src/dto/paymentMethod.dto.ts b/src/dto/paymentMethod.dto.ts new file mode 100644 index 0000000..2920536 --- /dev/null +++ b/src/dto/paymentMethod.dto.ts @@ -0,0 +1,15 @@ +export enum UserPaymentMethodStatus { + UserPaymentMethodStatusOtpVerified = 'OTP_VERIFIED', + UserPaymentMethodStatusOtpPending = 'OTP_PENDING', + UserPaymentMethodStatusConnected = 'CONNECTED', + UserPaymentMethodStatusInactive = 'INACTIVE', +} + +export class PaymentMethodQueryResult { + payment_method_id: string; + payment_method_type: string; + name: string; + description: string | null; + status: string; + id: string; +} diff --git a/src/dto/taxForm.dto.ts b/src/dto/taxForm.dto.ts new file mode 100644 index 0000000..703439b --- /dev/null +++ b/src/dto/taxForm.dto.ts @@ -0,0 +1,13 @@ +export class TaxFormQueryResult { + id: string; + user_id: string; + tax_form_id: string; + name: string; + text: string; + description: string; + date_filed: Date; + withholding_amount: number; + withholding_percentage: number; + status_id: string; + use_percentage: number; +}