chore: add payment module

This commit is contained in:
Mahyargdz
2025-05-20 16:25:49 +03:30
parent 0b2bf8a00d
commit ed4a5ffff5
27 changed files with 249 additions and 315 deletions
+16
View File
@@ -0,0 +1,16 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsUUID } from "class-validator";
import { PaymentMessage } from "../../../common/enums/message.enum";
export class CheckoutPaymentDto {
@IsNotEmpty({ message: PaymentMessage.GATEWAY_REQUIRED })
@IsUUID("4", { message: PaymentMessage.GATEWAY_ID_SHOULD_BE_UUID })
@ApiProperty({ description: "Gateway id to pay", example: "e6fdce2a-9f91-47c4-8561-48368fc275b5" })
gatewayId: string;
@IsNotEmpty({ message: PaymentMessage.INVOICE_ID_REQUIRED })
@IsUUID("4", { message: PaymentMessage.INVOICE_ID_SHOULD_BE_UUID })
@ApiProperty({ description: "Invoice id to pay", example: "e6fdce2a-9f91-47c4-8561-48368fc275b5" })
invoiceId: string;
}
@@ -1,42 +0,0 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum, IsInt, IsNotEmpty, IsUUID, IsUrl, Min } from "class-validator";
import { WalletMessage } from "../../../common/enums/message.enum";
import { TransferType } from "../enums/payment-type.enum";
export class DepositDto {
@IsNotEmpty({ message: WalletMessage.AMOUNT_REQUIRED })
@IsInt({ message: WalletMessage.AMOUNT_MUST_BE_INTEGER })
@Min(100_000, { message: WalletMessage.AMOUNT_MINIMUM })
@ApiProperty({ description: "Amount to charge", example: 100_000 })
amount: number;
}
export class GatewayDepositDto extends DepositDto {
// @IsNotEmpty({ message: WalletMessage.GATEWAY_REQUIRED })
// @IsEnum(GatewayEnum)
// @ApiProperty({ description: "Gateway to use", example: "zarinpal" })
// gateway: GatewayEnum;
@IsNotEmpty({ message: WalletMessage.GATEWAY_REQUIRED })
@IsUUID("4", { message: WalletMessage.GATEWAY_ID_SHOULD_BE_UUID })
@ApiProperty({ description: "Gateway id to pay", example: "e6fdce2a-9f91-47c4-8561-48368fc275b5" })
gatewayId: string;
}
export class TransferDepositDto extends DepositDto {
@IsNotEmpty({ message: WalletMessage.RECEIPT_URL_REQUIRED })
@IsUrl({ protocols: ["http", "https"], require_protocol: true }, { message: WalletMessage.RECEIPT_URL_SHOULD_BE_URL })
@ApiProperty({ description: "the transferReceipt url", example: "https://example.com/image.png" })
transferReceiptUrl: string;
@IsNotEmpty({ message: WalletMessage.BANK_ACCOUNT_ID_REQUIRED })
@IsUUID("4", { message: WalletMessage.BANK_ACCOUNT_ID_SHOULD_BE_UUID })
@ApiProperty({ description: "bank account id", example: "e6fdce2a-9f91-47c4-8561-48368fc275b5" })
bankAccountId: string;
@IsNotEmpty({ message: WalletMessage.TRANSFER_METHOD_REQUIRED })
@IsEnum(TransferType)
@ApiProperty({ description: "transfer method", example: TransferType.CARD_TO_CARD, enum: TransferType })
method: TransferType;
}
@@ -1,17 +0,0 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum, IsNotEmpty } from "class-validator";
import { PaginationDto } from "../../../common/DTO/pagination.dto";
import { CommonMessage } from "../../../common/enums/message.enum";
import { TransferType } from "../enums/payment-type.enum";
export class PaymentTransactionQueryDto extends PaginationDto {
@IsNotEmpty({ message: CommonMessage.PaymentTypeQueryNotEmpty })
@IsEnum(TransferType)
@ApiProperty({
description: `payment type ${TransferType.CARD_TO_CARD} ${TransferType.SHEBA} `,
enum: TransferType,
example: TransferType.CARD_TO_CARD,
})
type: TransferType;
}
@@ -1,4 +1,4 @@
import { IsEnum, IsNotEmpty, IsOptional } from "class-validator";
import { IsEnum, IsNotEmpty } from "class-validator";
import { GatewayEnum } from "../enums/gateway.enum";
import { GatewayType } from "../types/gateway.type";
@@ -8,10 +8,6 @@ export class VerifyParamDto {
@IsNotEmpty()
@IsEnum(GatewayEnum)
gateway: GatewayType;
@IsOptional()
@IsNotEmpty()
invoiceId?: string;
}
export class VerifyQueryDto {
@@ -1,5 +0,0 @@
export enum TransferType {
CARD_TO_CARD = "CARD_TO_CARD",
SHEBA = "SHEBA",
// GATEWAY = "GATEWAY",
}
@@ -32,9 +32,9 @@ export class ZarinpalGateway implements IPaymentGateway {
async processPayment(processParams: IProcessPaymentParams) {
try {
const purchaseData: ZarinPalPGNewArgs = {
merchant_id: this.config.merchantId,
merchant_id: processParams.merchantId,
amount: processParams.amount,
callback_url: `${this.config.callBackUrl}/${GatewayEnum.ZARINPAL}${processParams.invoiceId ? "/" + processParams.invoiceId : ""}`,
callback_url: `${this.config.callBackUrl}/${GatewayEnum.ZARINPAL}`,
description: processParams.description,
currency: "IRT",
metadata: { email: processParams.email, mobile: processParams.mobile },
@@ -68,17 +68,15 @@ export class ZarinpalGateway implements IPaymentGateway {
const verifyData = {
authority: verifyParams.reference,
amount: verifyParams.amount,
merchant_id: this.config.merchantId,
merchant_id: verifyParams.merchantId,
};
const { data } = await firstValueFrom(
this.httpService
.post<ZarinPalPGVerifyData>(`${this.gatewayApiUrl}/v4/payment/verify.json`, verifyData, { headers: this.requestHeader })
.pipe(
catchError((err: AxiosError) => {
this.logger.error(err);
throw new InternalServerErrorException(PaymentMessage.ERROR_IN_VERIFY_PAYMENT);
}),
),
this.httpService.post<ZarinPalPGVerifyData>(`${this.gatewayApiUrl}/v4/payment/verify.json`, verifyData, { headers: this.requestHeader }).pipe(
catchError((err: AxiosError) => {
this.logger.error(err);
throw new InternalServerErrorException(PaymentMessage.ERROR_IN_VERIFY_PAYMENT);
}),
),
);
return {
+3 -2
View File
@@ -10,10 +10,10 @@ import { GatewayType } from "../types/gateway.type";
export interface IProcessPaymentParams {
amount: number;
description: string;
// callBackPath: string;
callBackPath: string;
merchantId: string;
email?: string;
mobile?: string;
invoiceId?: string;
}
export interface IProcessPaymentData {
@@ -32,6 +32,7 @@ export interface IVerifyPayment {
export interface IPaymentVerifyParams {
amount: Decimal;
reference: string;
merchantId: string;
// status: GatewayPaymentStatus;
}
+40 -2
View File
@@ -1,4 +1,42 @@
import { Controller } from "@nestjs/common";
import { Body, Controller, Get, Param, Post, Query, Res } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { FastifyReply } from "fastify";
import { CheckoutPaymentDto } from "./DTO/checkout-payment.dto";
import { VerifyParamDto, VerifyQueryDto } from "./DTO/verify-payment.dto";
import { PaymentsService } from "./services/payments.service";
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
import { UserDec } from "../../common/decorators/user.decorator";
import { PaginationDto } from "../../common/DTO/pagination.dto";
@Controller("payments")
export class PaymentsController {}
@ApiTags("Payments")
export class PaymentsController {
constructor(private readonly paymentsService: PaymentsService) {}
@AuthGuards()
@ApiOperation({ summary: "get all available gateways" })
@Get("gateways")
getAvailableGateways() {
return this.paymentsService.getAvailableGateways();
}
@ApiOperation({ summary: "Charge wallet ==> user route" })
@AuthGuards()
@Post("checkout")
checkoutPayment(@Body() checkoutDto: CheckoutPaymentDto, @UserDec("id") userId: string) {
return this.paymentsService.checkoutPayment(checkoutDto, userId);
}
@ApiOperation({ summary: "get deposit gateway payment ==> admin route" })
@AuthGuards()
@Get("checkout/gateway")
getDepositGatewayPayment(@Query() queryDto: PaginationDto) {
return this.paymentsService.getDepositGatewayPayment(queryDto);
}
@Get("verify/:gateway")
verifyPayments(@Param() paramDto: VerifyParamDto, @Query() queryDto: VerifyQueryDto, @Res({ passthrough: true }) rep: FastifyReply) {
return this.paymentsService.verifyPayment(paramDto, queryDto, rep);
}
}
+2 -1
View File
@@ -11,8 +11,8 @@ import { PaymentsController } from "./payments.controller";
import { PaymentProcessor } from "./queue/payment.processor";
import { PaymentsService } from "./services/payments.service";
import { zarinpalConfig } from "../../configs/zarinpal.config";
import { InvoicesModule } from "../invoices/invoices.module";
import { NotificationModule } from "../notifications/notifications.module";
@Module({
imports: [
MikroOrmModule.forFeature([Payment, PaymentGateway]),
@@ -24,6 +24,7 @@ import { NotificationModule } from "../notifications/notifications.module";
},
}),
NotificationModule,
InvoicesModule,
],
controllers: [PaymentsController],
providers: [
@@ -1,6 +1,7 @@
import { EntityManager } from "@mikro-orm/postgresql";
import { InjectQueue, Processor } from "@nestjs/bullmq";
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Job, Queue } from "bullmq";
import Decimal from "decimal.js";
@@ -16,15 +17,18 @@ import { PaymentJobData } from "../types/payment-job.type";
@Processor(PAYMENT.QUEUE_NAME)
export class PaymentProcessor extends WorkerProcessor {
protected readonly logger = new Logger(PaymentProcessor.name);
private readonly zarinpalMerchantId;
constructor(
@InjectQueue(PAYMENT.QUEUE_NAME) private readonly paymentQueue: Queue,
private readonly configService: ConfigService,
private readonly gatewayFactory: PaymentGatewayFactory,
private readonly em: EntityManager,
private readonly notificationQueue: NotificationQueue,
private readonly paymentsService: PaymentsService,
) {
super();
this.zarinpalMerchantId = this.configService.getOrThrow<string>("ZARINPAL_MERCHANT_ID");
}
//===============================================
async process(job: Job<PaymentJobData>) {
@@ -177,7 +181,7 @@ export class PaymentProcessor extends WorkerProcessor {
id: paymentId,
},
{
populate: ["user", "paymentGateway"],
populate: ["user", "paymentGateway", "business", "invoice"],
},
);
@@ -198,7 +202,11 @@ export class PaymentProcessor extends WorkerProcessor {
try {
const gateway = this.gatewayFactory.getPaymentGateway(payment.paymentGateway.name);
const verifyData = await gateway.verifyPayment({ reference: payment.reference, amount: payment.amount });
const verifyData = await gateway.verifyPayment({
reference: payment.reference,
amount: payment.amount,
merchantId: payment.business.zarinpalMerchantId || this.zarinpalMerchantId,
});
const result = await this.paymentsService.processVerificationResult(verifyData, payment, em);
+106 -27
View File
@@ -1,17 +1,20 @@
import { LockMode } from "@mikro-orm/core";
import { EntityManager } from "@mikro-orm/postgresql";
import { EntityManager, LockMode } from "@mikro-orm/postgresql";
import { InjectQueue } from "@nestjs/bullmq";
import { BadRequestException, HttpStatus, Injectable, Logger } from "@nestjs/common";
import { BadRequestException, HttpException, HttpStatus, Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Queue } from "bullmq";
import { Decimal } from "decimal.js";
import Decimal from "decimal.js";
import { FastifyReply as FRply } from "fastify";
import { PaymentMessage } from "../../../common/enums/message.enum";
import { PaginationDto } from "../../../common/DTO/pagination.dto";
import { InvoiceMessage, PaymentMessage, UserMessage } from "../../../common/enums/message.enum";
import { Business } from "../../businesses/entities/business.entity";
import { Invoice } from "../../invoices/entities/invoice.entity";
import { InvoicesService } from "../../invoices/providers/invoices.service";
import { User } from "../../users/entities/user.entity";
import { PaginationUtils } from "../../utils/providers/pagination.utils";
import { PAYMENT } from "../constants";
import { CheckoutPaymentDto } from "../DTO/checkout-payment.dto";
import { VerifyParamDto, VerifyQueryDto } from "../DTO/verify-payment.dto";
import { PaymentGateway } from "../entities/payment-gateway.entity";
import { Payment } from "../entities/payment.entity";
@@ -25,15 +28,65 @@ import { GatewayType } from "../types/gateway.type";
@Injectable()
export class PaymentsService {
private readonly logger = new Logger(PaymentsService.name);
private readonly zarinpalMerchantId;
constructor(
@InjectQueue(PAYMENT.QUEUE_NAME) private readonly paymentQueue: Queue,
private readonly configService: ConfigService,
private readonly gatewayFactory: PaymentGatewayFactory,
private readonly paymentGatewaysRepository: PaymentGatewaysRepository,
private readonly paymentsRepository: PaymentRepository,
private readonly invoiceService: InvoicesService,
private readonly em: EntityManager,
) {}
) {
this.zarinpalMerchantId = this.configService.getOrThrow<string>("ZARINPAL_MERCHANT_ID");
}
//===============================================
async checkoutPayment(chargeDto: CheckoutPaymentDto, userId: string) {
const em = this.em.fork();
try {
em.begin();
const { gatewayId, invoiceId } = chargeDto;
const user = await this.getUserById(userId, em);
const paymentGateway = await this.getPaymentGatewayById(gatewayId, em);
const invoice = await em.findOne(Invoice, { id: invoiceId }, { populate: ["company", "business", "company.user"] });
if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID);
if (invoice.company.user.id !== user.id) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID_OR_NOT_BELONG_TO_USER);
const gatewayData = await this.processPayment(paymentGateway.name, {
amount: invoice.totalPrice.toNumber(),
description: PaymentMessage.CHECKOUT_INVOICE_MESSAGE.replace("[invoiceNumber]", invoice.numericId.toString()),
email: user.email,
mobile: user.phone,
callBackPath: invoice.business.slug,
merchantId: invoice.business.zarinpalMerchantId || this.zarinpalMerchantId,
});
const payment = await this.createGatewayPaymentForUser(
user,
invoice.totalPrice.toNumber(),
gatewayData.reference,
paymentGateway.id,
invoice.business.id,
invoice.id,
em,
);
await em.commit();
return {
payment,
...gatewayData,
};
} catch (error) {
await em.rollback();
if (error instanceof HttpException) throw error;
throw new InternalServerErrorException(PaymentMessage.ERROR_IN_PAYMENT);
}
}
//===============================================
@@ -44,8 +97,6 @@ export class PaymentsService {
//===============================================
async verifyPayment(verifyParamDto: VerifyParamDto, queryDto: VerifyQueryDto, rep: FRply) {
const frontUrl = this.buildFrontendRedirectUrl(verifyParamDto.invoiceId);
const em = this.em.fork();
try {
@@ -53,6 +104,7 @@ export class PaymentsService {
const paymentGateway = this.gatewayFactory.getPaymentGateway(verifyParamDto.gateway);
const payment = await this.getPaymentByReference(queryDto.Authority, em);
const frontUrl = this.buildFrontendRedirectUrl(payment.business.slug);
if (payment.status === PaymentStatus.COMPLETED) throw new BadRequestException(PaymentMessage.VALIDATED_BEFORE);
@@ -61,6 +113,7 @@ export class PaymentsService {
const verifyData = await paymentGateway.verifyPayment({
reference: queryDto.Authority,
amount: payment.amount,
merchantId: payment.business.zarinpalMerchantId || this.zarinpalMerchantId,
});
return await this.handlePaymentVerificationWithResponse(verifyData, payment, rep, frontUrl, em);
@@ -111,33 +164,26 @@ export class PaymentsService {
//===============================================
private async processSuccessfulVerification(payment: Payment, refId: number, em: EntityManager) {
// Regular wallet charge
await this.handleSuccessfulPayment(payment, refId, em);
const { invoice } = await this.handleSuccessfulPayment(payment, refId, em);
const additionalParams: Record<string, string> = {};
return {
status: PaymentStatus.COMPLETED,
payment,
invoice,
additionalParams,
};
}
//===============================================
async createGatewayPaymentForUser(
usr: User,
amt: number,
ref_Id: string,
gatewayId: string,
businessId: string,
invoiceId: string,
em: EntityManager,
) {
async createGatewayPaymentForUser(usr: User, amt: number, ref_Id: string, gatewayId: string, bunsId: string, invoiceId: string, em: EntityManager) {
const payment = em.create(Payment, {
amount: new Decimal(amt),
reference: ref_Id,
user: usr,
paymentGateway: em.getReference(PaymentGateway, gatewayId),
business: em.getReference(Business, businessId),
business: em.getReference(Business, bunsId),
invoice: em.getReference(Invoice, invoiceId),
});
@@ -172,19 +218,38 @@ export class PaymentsService {
await em.nativeUpdate(Payment, { id: payment.id }, { status: PaymentStatus.COMPLETED, transactionId: transactionId.toString() });
await this.removePaymentReminderJobs(payment.id);
return await this.invoiceService.payInvoice(payment.invoice.id, payment.user.id, em);
}
//===============================================
async getDepositGatewayPayment(queryDto: PaginationDto) {
const { limit, skip } = PaginationUtils(queryDto);
const [payments, count] = await this.paymentsRepository.findAndCount(
{ deletedAt: null },
{
populate: ["user", "paymentGateway"],
orderBy: { createdAt: "DESC" },
offset: skip,
limit,
},
);
return {
payments,
count,
paginate: true,
};
}
//----------------------------------------
//-private methods
//----------------------------------------
private buildFrontendRedirectUrl(invoiceId?: string): URL {
private buildFrontendRedirectUrl(businessSlug: string): URL {
const frontUrl = new URL(this.configService.getOrThrow<string>("SITE_URL"));
if (invoiceId) {
frontUrl.pathname = "/receipts/" + invoiceId;
} else {
frontUrl.pathname = "/payment";
}
frontUrl.pathname = `/${businessSlug}/payment`;
return frontUrl;
}
@@ -206,11 +271,25 @@ export class PaymentsService {
url.searchParams.append("date", payment.createdAt.toISOString());
url.searchParams.append("amount", payment.amount.toString());
}
//===============================================
private async getUserById(userId: string, em: EntityManager): Promise<User> {
const user = await em.findOne(User, { id: userId }, { populate: ["company"] });
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
return user;
}
//===============================================
private async getPaymentGatewayById(gatewayId: string, em: EntityManager): Promise<PaymentGateway> {
const paymentGateway = await em.findOne(PaymentGateway, { id: gatewayId });
if (!paymentGateway) throw new BadRequestException(PaymentMessage.PAYMENT_GATEWAY_NOT_FOUND);
return paymentGateway;
}
//===============================================
private async getPaymentByReference(reference: string, em: EntityManager): Promise<Payment> {
const payment = await em.findOne(Payment, { reference }, { populate: ["user"], lockMode: LockMode.PESSIMISTIC_WRITE });
const payment = await em.findOne(Payment, { reference }, { populate: ["user", "invoice", "business"], lockMode: LockMode.PESSIMISTIC_WRITE });
if (!payment) throw new BadRequestException(PaymentMessage.PAYMENT_NOT_FOUND_WITH_REF);
return payment;
}