import { InjectQueue } from "@nestjs/bullmq"; import { BadRequestException, HttpException, HttpStatus, Injectable, InternalServerErrorException, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { Queue } from "bullmq"; import dayjs from "dayjs"; import Decimal from "decimal.js"; import { FastifyReply as FRply } from "fastify"; import { DataSource, Not, QueryRunner } from "typeorm"; import { PaginationDto } from "../../../common/DTO/pagination.dto"; import { CommonMessage, PaymentMessage, UserMessage, WalletMessage } from "../../../common/enums/message.enum"; import { NotificationQueue } from "../../notifications/queue/notification.queue"; import { ReferralRewardStatus } from "../../referrals/enums/referral-reward-status.enum"; import { ReferralStatus } from "../../referrals/enums/referral-status.enum"; import { ReferralsService } from "../../referrals/providers/referrals.service"; import { User } from "../../users/entities/user.entity"; import { PaginationUtils } from "../../utils/providers/pagination.utils"; import { WalletsService } from "../../wallets/providers/wallets.service"; import { PAYMENT } from "../constants"; import { CreateBankAccountDto } from "../DTO/create-bankaccount.dto"; import { GatewayDepositDto, TransferDepositDto } from "../DTO/deposit-wallet.dto"; import { PaymentTransactionQueryDto } from "../DTO/payment-transaction-query.dto"; import { SearchTransactionQueryDto } from "../DTO/search-transaction-query.dto"; import { UpdateBankAccountDto } from "../DTO/update-bankaccount.dto"; import { RejectDepositRequestDto } from "../DTO/update-deposit-request-status.dto"; import { VerifyParamDto, VerifyQueryDto } from "../DTO/verify-payment.dto"; import { BankAccount } from "../entities/bank-account.entity"; import { DepositRequest } from "../entities/deposit-request.entity"; import { PaymentGateway } from "../entities/payment-gateway.entity"; import { Payment } from "../entities/payment.entity"; import { DepositRequestStatus } from "../enums/deposit-request-status.enum"; import { PaymentStatus } from "../enums/payment-status.enum"; import { TransferType } from "../enums/payment-type.enum"; import { PaymentGatewayFactory } from "../factories/payment.factory"; import { IProcessPaymentParams, IVerifyPayment } from "../interfaces/IPayment"; import { BankAccountsRepository } from "../repositories/bank-accounts.repository"; import { DepositRequestsRepository } from "../repositories/deposit-requests.repository"; import { PaymentGatewaysRepository } from "../repositories/payment-gateway.repository"; import { PaymentsRepository } from "../repositories/payments.repository"; import { GatewayType } from "../types/gateway.type"; @Injectable() export class PaymentsService { private readonly logger = new Logger(PaymentsService.name); constructor( @InjectQueue(PAYMENT.QUEUE_NAME) private readonly paymentQueue: Queue, private readonly notificationQueue: NotificationQueue, private readonly configService: ConfigService, private readonly gatewayFactory: PaymentGatewayFactory, private readonly paymentGatewaysRepository: PaymentGatewaysRepository, private readonly paymentsRepository: PaymentsRepository, private readonly bankAccountsRepository: BankAccountsRepository, private readonly walletsService: WalletsService, private readonly depositRequestsRepository: DepositRequestsRepository, private readonly referralsService: ReferralsService, private dataSource: DataSource, ) {} //=============================================== async chargeWalletWithGateway(chargeDto: GatewayDepositDto, userId: string) { const queryRunner = this.dataSource.createQueryRunner(); try { await queryRunner.connect(); await queryRunner.startTransaction(); const { amount, gatewayId, invoiceId } = chargeDto; const user = await this.getUserById(userId, queryRunner); const paymentGateway = await this.getPaymentGatewayById(gatewayId, queryRunner); const gatewayData = await this.processPayment(paymentGateway.name, { amount, description: WalletMessage.DEPOSIT_WALLET_IPG, email: user.email, mobile: user.phone, invoiceId, }); const payment = await this.createGatewayPaymentForUser(user, amount, gatewayData.reference, paymentGateway.id, queryRunner); await queryRunner.commitTransaction(); return { payment, ...gatewayData, }; } catch (error) { await queryRunner.rollbackTransaction(); if (error instanceof HttpException) throw error; throw new InternalServerErrorException(WalletMessage.ERROR_IN_CHARGE_WALLET); } finally { await queryRunner.release(); } } //=============================================== async chargeWalletWithTransfer(depositDto: TransferDepositDto, userId: string) { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { const { amount, bankAccountId, method, transferReceiptUrl } = depositDto; const { bankAccount } = await this.getBankAccountById(bankAccountId); const user = await this.getUserById(userId, queryRunner); const depositRequest = this.createDepositRequest(bankAccount, user, transferReceiptUrl, amount, method, queryRunner); await queryRunner.commitTransaction(); return { message: PaymentMessage.DEPOSIT_CREATED, depositId: depositRequest.id, }; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } //=============================================== async processPayment(provider: GatewayType, params: IProcessPaymentParams) { const paymentGateway = this.gatewayFactory.getPaymentGateway(provider); return paymentGateway.processPayment(params); } //=============================================== async verifyPayment(verifyParamDto: VerifyParamDto, queryDto: VerifyQueryDto, rep: FRply) { const frontUrl = this.buildFrontendRedirectUrl(verifyParamDto.invoiceId); const queryRunner = this.dataSource.createQueryRunner(); try { await queryRunner.connect(); await queryRunner.startTransaction(); const paymentGateway = this.gatewayFactory.getPaymentGateway(verifyParamDto.gateway); const payment = await this.getPaymentByReference(queryDto.Authority, queryRunner); if (payment.status === PaymentStatus.COMPLETED) throw new BadRequestException(PaymentMessage.VALIDATED_BEFORE); if (queryDto.Status !== "OK") await this.handleFailedPaymentRedirect(payment, queryRunner, rep, frontUrl); const verifyData = await paymentGateway.verifyPayment({ reference: queryDto.Authority, amount: payment.amount, }); return await this.handlePaymentVerificationWithResponse(verifyData, payment, rep, frontUrl, queryRunner); } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } //=============================================== async handlePaymentVerificationWithResponse(verifyData: IVerifyPayment, payment: Payment, rp: FRply, frontUrl: URL, qryRnr: QueryRunner) { const result = await this.processVerificationResult(verifyData, payment, qryRnr); this.addPaymentParamsToUrl(frontUrl, result.payment, result.status); if ("additionalParams" in result) { Object.entries(result.additionalParams).forEach(([key, value]) => { frontUrl.searchParams.append(key, String(value)); }); } await qryRnr.commitTransaction(); return rp.status(HttpStatus.FOUND).redirect(frontUrl.toString()); } //=============================================== async processVerificationResult(verifyData: IVerifyPayment, payment: Payment, qryRnr: QueryRunner) { if (verifyData.code === 100) { return this.processSuccessfulVerification(payment, verifyData.ref_id, qryRnr); } else if (verifyData.code === 101) { return { status: PaymentStatus.PENDING, payment }; } else { await this.handleFailedPayment(payment.id, qryRnr); return { status: PaymentStatus.FAILED, payment }; } } //=============================================== private async processSuccessfulVerification(payment: Payment, refId: number, queryRunner: QueryRunner) { // Regular wallet charge const { wallet } = await this.handleSuccessfulPayment(payment, payment.user.id, payment.amount, refId, queryRunner); //for future use const additionalParams: Record = {}; // await this.notificationQueue.addWalletChargeNotification(payment.user.id, { amount: new Decimal(payment.amount).toNumber(), balance: new Decimal(wallet.balance).toNumber(), date: payment.createdAt, reason: WalletMessage.DEPOSIT_WALLET_IPG, userPhone: payment.user.phone, userEmail: payment.user.email, }); return { status: PaymentStatus.COMPLETED, payment, additionalParams, }; } //=============================================== async createGatewayPaymentForUser(usr: User, amt: number, ref_Id: string, gatewayId: string, qryRnr: QueryRunner) { const payment = qryRnr.manager.create(Payment, { amount: amt, reference: ref_Id, user: usr, paymentGateway: { id: gatewayId }, }); await qryRnr.manager.save(Payment, payment); this.logger.log(`Created payment ${payment.id} with reference ${ref_Id}`); await this.addPaymentReminderToQueue(payment); return payment; } //=============================================== async getAvailableGateways() { const paymentGateways = await this.paymentGatewaysRepository.find({ where: { isActive: true } }); return { paymentGateways }; } //=============================================== async getPaymentWithReference(reference: string) { const payment = await this.paymentsRepository.findOneWithReference(reference); if (!payment) throw new BadRequestException(PaymentMessage.PAYMENT_NOT_FOUND_WITH_REF); return { payment }; } //=============================================== async handleFailedPayment(paymentId: string, queryRunner: QueryRunner) { await queryRunner.manager.update(Payment, { id: paymentId }, { status: PaymentStatus.CANCELLED }); } //=============================================== async handleSuccessfulPayment(payment: Payment, userId: string, amount: Decimal, transactionId: number, queryRunner: QueryRunner) { await queryRunner.manager.update( Payment, { id: payment.id }, { status: PaymentStatus.COMPLETED, transactionId: transactionId.toString() }, ); await this.handleReferralReward(payment, queryRunner); return this.walletsService.createPaymentTransaction(userId, amount, payment, queryRunner); } //=============================================== async addBankAccount(createDto: CreateBankAccountDto) { await this.validateBankAccountDetails(createDto); const bankAccount = this.bankAccountsRepository.create({ ...createDto }); await this.bankAccountsRepository.save(bankAccount); return { message: CommonMessage.CREATED, bankAccount, }; } //=============================================== async updateBankAccount(updateDto: UpdateBankAccountDto, bankAccountId: string) { const bankAccount = await this.bankAccountsRepository.findOneBy({ id: bankAccountId }); if (!bankAccount) throw new BadRequestException(PaymentMessage.BANK_ACCOUNT_NOT_FOUND); await this.validateBankAccountUpdate(updateDto, bankAccountId); await this.bankAccountsRepository.save({ ...bankAccount, ...updateDto }); return { message: CommonMessage.UPDATED, }; } //=============================================== async getBankAccounts(isAdmin: boolean) { const bankAccounts = isAdmin ? await this.bankAccountsRepository.find({ where: { isActive: true } }) : await this.bankAccountsRepository.find(); return { bankAccounts }; } //=============================================== async getBankAccountById(bankAccountId: string) { const bankAccount = await this.bankAccountsRepository.findOneBy({ id: bankAccountId }); if (!bankAccount) throw new BadRequestException(PaymentMessage.BANK_ACCOUNT_NOT_FOUND); return { bankAccount, }; } //=============================================== async getTransactions(queryDto: SearchTransactionQueryDto) { return this.walletsService.getTransactionsForAdmin(queryDto); } //=============================================== async getDepositRequests(queryDto: PaymentTransactionQueryDto) { const { limit, skip } = PaginationUtils(queryDto); const { type } = queryDto; const queryBuilder = this.buildDepositRequestsQuery(skip, limit, type); const [depositRequests, count] = await queryBuilder.getManyAndCount(); return { depositRequests, count, paginate: true, }; } //=============================================== async getDepositGatewayPayment(queryDto: PaginationDto) { const { limit, skip } = PaginationUtils(queryDto); const queryBuilder = this.buildDepositGatewayPaymentsQuery(skip, limit); const [payments, count] = await queryBuilder.getManyAndCount(); return { payments, count, paginate: true, }; } //=============================================== async getDepositRequestById(depositId: string) { const depositRequest = await this.depositRequestsRepository.findOneBy({ id: depositId }); if (!depositRequest) throw new BadRequestException(PaymentMessage.DEPOSIT_NOT_FOUND); return { depositRequest, }; } //=============================================== async approverDepositRequest(depositId: string) { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { const depositRequest = await this.getDepositRequestWithLock(depositId, queryRunner); if (depositRequest.status === DepositRequestStatus.APPROVED) { throw new BadRequestException(PaymentMessage.DEPOSIT_ALREADY_APPROVED); } await this.updateDepositRequestStatus(depositRequest.id, DepositRequestStatus.APPROVED, null, queryRunner); await this.walletsService.createDepositTransaction(depositRequest.user.id, depositRequest.amount, depositRequest, queryRunner); await queryRunner.commitTransaction(); return { message: PaymentMessage.DEPOSIT_APPROVED, depositRequest: depositRequest.id, }; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } //=============================================== async rejectDepositRequest(depositId: string, rejectDto: RejectDepositRequestDto) { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { const depositRequest = await queryRunner.manager.findOne(DepositRequest, { where: { id: depositId }, lock: { mode: "pessimistic_write" }, }); if (!depositRequest) { throw new BadRequestException(PaymentMessage.DEPOSIT_NOT_FOUND); } if (depositRequest.status === DepositRequestStatus.REJECTED) { throw new BadRequestException(PaymentMessage.DEPOSIT_ALREADY_REJECTED); } await this.updateDepositRequestStatus(depositRequest.id, DepositRequestStatus.REJECTED, rejectDto.comment, queryRunner); await queryRunner.commitTransaction(); return { message: PaymentMessage.DEPOSIT_REJECTED, depositRequest, }; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } //---------------------------------------- //-private methods //---------------------------------------- private buildFrontendRedirectUrl(invoiceId?: string): URL { const frontUrl = new URL(this.configService.getOrThrow("SITE_URL")); if (invoiceId) { frontUrl.pathname = "/receipts/" + invoiceId; } else { frontUrl.pathname = "/payment"; } return frontUrl; } private async isFirstPurchase(userId: string, queryRunner: QueryRunner) { const paymentCount = await queryRunner.manager.count(Payment, { where: { user: { id: userId }, status: PaymentStatus.COMPLETED }, }); return paymentCount === 0; } //=============================================== private async handleReferralReward(payment: Payment, queryRunner: QueryRunner) { const isFirstPurchase = await this.isFirstPurchase(payment.user.id, queryRunner); if (!isFirstPurchase) return; const referral = await this.referralsService.getPendingReferral(payment.user.id, queryRunner); if (!referral) return; // 10% const rewardAmount = new Decimal(payment.amount).mul(0.1); const reward = await this.referralsService.createReferralReward(referral.referrer.id, referral.id, rewardAmount, queryRunner); // Charge referrer's wallet await this.walletsService.createReferralRewardTransaction(referral.referrer.id, rewardAmount, queryRunner); // Update reward status reward.status = ReferralRewardStatus.PAID; reward.paidAt = dayjs().toDate(); await queryRunner.manager.save(reward); // Update referral status referral.status = ReferralStatus.COMPLETED; referral.completedAt = dayjs().toDate(); await queryRunner.manager.save(referral); } //=============================================== private async handleFailedPaymentRedirect(payment: Payment, queryRunner: QueryRunner, rep: FRply, frontUrl: URL) { await this.handleFailedPayment(payment.id, queryRunner); await queryRunner.commitTransaction(); this.addPaymentParamsToUrl(frontUrl, payment, PaymentStatus.FAILED); return rep.status(HttpStatus.FOUND).redirect(frontUrl.toString()); } //=============================================== private addPaymentParamsToUrl(url: URL, payment: Payment, status: PaymentStatus) { url.searchParams.append("status", status); url.searchParams.append("id", payment.id); url.searchParams.append("date", payment.createdAt.toISOString()); url.searchParams.append("amount", payment.amount.toString()); } //=============================================== private async getUserById(userId: string, queryRunner: QueryRunner): Promise { const user = await queryRunner.manager.findOneBy(User, { id: userId }); if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); return user; } //=============================================== private async getPaymentGatewayById(gatewayId: string, queryRunner: QueryRunner): Promise { const paymentGateway = await queryRunner.manager.findOneBy(PaymentGateway, { id: gatewayId }); if (!paymentGateway) throw new BadRequestException(PaymentMessage.PAYMENT_GATEWAY_NOT_FOUND); return paymentGateway; } //=============================================== private createDepositRequest(bnkAcc: BankAccount, usr: User, recUrl: string, amt: number, typ: TransferType, qryRnr: QueryRunner) { const depositRequest = qryRnr.manager.create(DepositRequest, { bankAccount: bnkAcc, user: usr, receiptUrl: recUrl, amount: amt, type: typ, }); qryRnr.manager.save(DepositRequest, depositRequest); return depositRequest; } //=============================================== private async getPaymentByReference(reference: string, queryRunner: QueryRunner): Promise { const payment = await queryRunner.manager .createQueryBuilder(Payment, "payment") .innerJoinAndSelect("payment.user", "user") .where("payment.reference = :reference", { reference }) .setLock("pessimistic_write") .getOne(); if (!payment) throw new BadRequestException(PaymentMessage.PAYMENT_NOT_FOUND_WITH_REF); return payment; } //=============================================== private async validateBankAccountDetails(accountDetails: { cardNumber?: string; IBan?: string }) { if (accountDetails.cardNumber) { const existCardNumber = await this.bankAccountsRepository.findOneBy({ cardNumber: accountDetails.cardNumber }); if (existCardNumber) throw new BadRequestException(PaymentMessage.CARD_NUMBER_EXIST); } if (accountDetails.IBan) { const existIBan = await this.bankAccountsRepository.findOneBy({ IBan: accountDetails.IBan }); if (existIBan) throw new BadRequestException(PaymentMessage.IBAN_EXIST); } } //=============================================== private async validateBankAccountUpdate(updateDto: UpdateBankAccountDto, bankAccountId: string) { if (updateDto.cardNumber) { const existCardNumber = await this.bankAccountsRepository.findOneBy({ cardNumber: updateDto.cardNumber, id: Not(bankAccountId), }); if (existCardNumber) throw new BadRequestException(PaymentMessage.CARD_NUMBER_EXIST); } if (updateDto.IBan) { const existIBan = await this.bankAccountsRepository.findOneBy({ IBan: updateDto.IBan, id: Not(bankAccountId), }); if (existIBan) throw new BadRequestException(PaymentMessage.IBAN_EXIST); } } //=============================================== private buildDepositRequestsQuery(skip: number, limit: number, type?: TransferType) { const queryBuilder = this.depositRequestsRepository .createQueryBuilder("depositRequest") .leftJoinAndSelect("depositRequest.user", "user") .leftJoinAndSelect("depositRequest.bankAccount", "bankAccount") .orderBy("depositRequest.createdAt", "DESC") .skip(skip) .take(limit); if (type) { queryBuilder.andWhere("depositRequest.type = :type", { type }); } return queryBuilder; } //=============================================== private buildDepositGatewayPaymentsQuery(skip: number, limit: number) { return this.paymentsRepository .createQueryBuilder("payment") .leftJoinAndSelect("payment.user", "user") .leftJoinAndSelect("payment.paymentGateway", "paymentGateway") .orderBy("payment.createdAt", "DESC") .skip(skip) .take(limit); } //=============================================== private async getDepositRequestWithLock(depositId: string, queryRunner: QueryRunner) { const depositRequest = await queryRunner.manager .createQueryBuilder(DepositRequest, "deposit") .innerJoinAndSelect("deposit.user", "user") .where("deposit.id = :id", { id: depositId }) .setLock("pessimistic_write") .getOne(); if (!depositRequest) throw new BadRequestException(PaymentMessage.DEPOSIT_NOT_FOUND); return depositRequest; } //=============================================== private async updateDepositRequestStatus(depositId: string, status: DepositRequestStatus, comment: string | null, qryRnr: QueryRunner) { const updateData: Partial = { status }; if (comment !== null) { updateData.comment = comment; } await qryRnr.manager.update(DepositRequest, { id: depositId }, updateData); } //=============================================== private async addPaymentReminderToQueue(payment: Payment) { this.paymentQueue.add( PAYMENT.START, { payment }, { delay: PAYMENT.START_DELAY, priority: PAYMENT.JOB_PRIORITY, attempts: PAYMENT.JOB_ATTEMPTS, backoff: { type: "exponential", delay: PAYMENT.JOB_BACKOFF }, }, ); } }