Files
dsc-api/src/modules/payments/providers/payments.service.ts
T
2025-05-14 16:31:05 +03:30

618 lines
23 KiB
TypeScript
Executable File

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<string, string> = {};
//
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<string>("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<User> {
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<PaymentGateway> {
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<Payment> {
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<DepositRequest> = { 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 },
},
);
}
}