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
+1 -1
View File
@@ -2,7 +2,7 @@
"singleQuote": false,
"trailingComma": "all",
"endOfLine": "auto",
"printWidth": 140,
"printWidth": 150,
"arrowParens": "always",
"semi": true,
"tabWidth": 2
@@ -1,10 +1,4 @@
import {
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
registerDecorator,
} from "class-validator";
import { ValidationArguments, ValidationOptions, ValidatorConstraint, ValidatorConstraintInterface, registerDecorator } from "class-validator";
export function isValidIranianNationalCode(nationalCode: string): boolean {
if (!/^\d{10}$/.test(nationalCode)) return false;
+6 -24
View File
@@ -299,30 +299,6 @@ export const enum TicketMessageEnum {
CATEGORY_DELETED = "دسته بندی با موفقیت حذف شد",
}
export const enum WalletMessage {
WALLET_NOT_FOUND = "کیف پول یافت نشد",
AMOUNT_MUST_BE_INTEGER = "مبلغ شارژ باید یک عدد صحیح باشد",
AMOUNT_REQUIRED = "مبلغ شارژ مورد نیاز است",
DEPOSIT_TYPE_REQUIRED = "نوع شارژ مورد نیاز است",
AMOUNT_MINIMUM = "حداقل مبلغ شارژ ۱۰۰,۰۰۰ تومان است",
GATEWAY_REQUIRED = "درگاه پرداخت مورد نیاز است",
RECEIPT_URL_SHOULD_BE_URL = "آدرس فیش باید یک آدرس یو آر ال باشد",
RECEIPT_URL_REQUIRED = "آدرس فیش مورد نیاز است",
BANK_ACCOUNT_ID_REQUIRED = "شناسه حساب بانکی مورد نیاز است",
BANK_ACCOUNT_ID_SHOULD_BE_UUID = "شناسه حساب بانکی باید یک UUID معتبر باشد",
ERROR_IN_CHARGE_WALLET = "خطا در شارژ کیف پول",
DEPOSIT_WALLET_IPG = "شارژ کیف پول از طریق درگاه",
TRANSFER_METHOD_REQUIRED = "روش انتفال اجباری است",
GATEWAY_ID_SHOULD_BE_UUID = "آیدی درگاه پرداخت باید UUID باشد",
DEPOSIT_WALLET_TRANSFER = "شارژ کیف پول از طریق انتقال بانکی",
INSUFFICIENT_BALANCE = "موجودی کیف پول کافی نیست",
SUBSCRIPTION_WALLET_TRANSFER = "پرداخت اشتراک از طریق کیف پول",
SUPPORT_PLAN_WALLET_TRANSFER = "پرداخت پلن پشتیبانی از طریق کیف پول",
INVOICE_WALLET_TRANSFER = "پرداخت صورت حساب از طریق کیف پول",
TRANSACTION_NOT_FOUND = "تراکنش پیدا نشد",
REFERRAL_REWARD_WALLET_TRANSFER = "پرداخت پاداش ارجاع از طریق کیف پول",
}
export const enum PaymentMessage {
PAYMENT_GATEWAY_NOT_FOUND = "درگاه پرداخت یافت نشد",
ERROR_IN_PROCESS_PAYMENT = "خطا در پردازش پرداخت",
@@ -357,6 +333,12 @@ export const enum PaymentMessage {
TRANSFER_METHOD_NOT_ALLOWED = "روش انتقال انتخابی مجاز نیست",
DEPOSIT_ALREADY_APPROVED = "درخواست واریز قبلا تایید شده است",
DEPOSIT_ALREADY_REJECTED = "درخواسن واریز قبلا رد شده است",
GATEWAY_REQUIRED = "درگاه پرداخت مورد نیاز است",
GATEWAY_ID_SHOULD_BE_UUID = "آیدی درگاه پرداخت باید UUID باشد",
INVOICE_ID_REQUIRED = "صورت حساب مورد نیاز است",
INVOICE_ID_SHOULD_BE_UUID = "صورت حساب باید UUID باشد",
ERROR_IN_PAYMENT = "خطا در پرداخت",
CHECKOUT_INVOICE_MESSAGE = "پرداخت صورت حساب شماره [invoiceNumber] ثبت شد",
}
export const enum SettingMessageEnum {
-2
View File
@@ -4,7 +4,6 @@ export function zarinpalConfig() {
return {
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
merchantId: configService.getOrThrow<string>("ZARINPAL_MERCHANT_ID"),
gatewayApiUrl: configService.getOrThrow<string>("ZARINPAL_API_URL"),
callBackUrl: configService.getOrThrow<string>("CALLBACK_URL"),
ipgType: configService.getOrThrow<string>("IPG_TYPE"),
@@ -13,7 +12,6 @@ export function zarinpalConfig() {
}
export interface IZarinpalConfig {
merchantId: string;
gatewayApiUrl: string;
callBackUrl: string;
ipgType: string;
@@ -1,12 +1,4 @@
import {
BadRequestException,
CallHandler,
ExecutionContext,
ForbiddenException,
Injectable,
Logger,
NestInterceptor,
} from "@nestjs/common";
import { BadRequestException, CallHandler, ExecutionContext, ForbiddenException, Injectable, Logger, NestInterceptor } from "@nestjs/common";
import { FastifyRequest } from "fastify";
import { Observable } from "rxjs";
@@ -44,11 +44,7 @@ export class AnnouncementController {
@ApiProperty({ description: "Get all announcements for user " })
@Get("user")
getAnnouncementsByUser(
@Query() queryDto: SearchAnnouncementQueryDto,
@UserDec("id") userId: string,
@BusinessDec("id") businessId: string,
) {
getAnnouncementsByUser(@Query() queryDto: SearchAnnouncementQueryDto, @UserDec("id") userId: string, @BusinessDec("id") businessId: string) {
return this.announcementService.getAnnouncementsByUser(queryDto, userId, businessId);
}
@@ -29,6 +29,9 @@ export class Business extends BaseEntity {
@Property({ type: "varchar", length: 255, nullable: true })
logoUrl?: string;
@Property({ type: "varchar", length: 255, nullable: true })
zarinpalMerchantId?: string;
//=========================
@OneToMany(() => Company, (company) => company.business, { cascade: [Cascade.ALL] })
@@ -1,16 +1,4 @@
import {
Cascade,
Collection,
Entity,
EntityRepositoryType,
Enum,
Formula,
ManyToOne,
OneToMany,
OneToOne,
Opt,
Property,
} from "@mikro-orm/core";
import { Cascade, Collection, Entity, EntityRepositoryType, Enum, Formula, ManyToOne, OneToMany, OneToOne, Opt, Property } from "@mikro-orm/core";
import { CompanyProduct } from "./company-product.entity";
import { CompanyService } from "./company-service.entity";
@@ -366,8 +366,7 @@ export class CompaniesService {
services,
user,
business,
chiefExecutiveOfficer:
"chiefExecutiveOfficer" in createDto ? createDto.chiefExecutiveOfficer : `${createDto.firstName} ${createDto.lastName}`,
chiefExecutiveOfficer: "chiefExecutiveOfficer" in createDto ? createDto.chiefExecutiveOfficer : `${createDto.firstName} ${createDto.lastName}`,
email: createDto.email,
phone: createDto.phone,
});
+1 -6
View File
@@ -46,12 +46,7 @@ export class InvoicesController {
@ApiOperation({ summary: "get single invoice by Id " })
@Get(":id")
getInvoiceById(
@Param() paramDto: ParamDto,
@UserDec("role") role: RoleEnum,
@UserDec("id") userId: string,
@BusinessDec("id") businessId: string,
) {
getInvoiceById(@Param() paramDto: ParamDto, @UserDec("role") role: RoleEnum, @UserDec("id") userId: string, @BusinessDec("id") businessId: string) {
return this.invoiceService.getInvoiceById(paramDto.id, role, userId, businessId);
}
@@ -199,13 +199,7 @@ export class InvoicesService {
const otpCode = await this.otpService.generateAndSetInCache(user.phone, "INVOICE_VERIFY");
const items = invoice.items.map((item) => item.name).join(", ");
await this.smsService.sendInvoiceVerifyCode(
user.phone,
otpCode,
invoice.numericId,
new Decimal(invoice.totalPrice).toNumber(),
items,
);
await this.smsService.sendInvoiceVerifyCode(user.phone, otpCode, invoice.numericId, new Decimal(invoice.totalPrice).toNumber(), items);
await em.commit();
@@ -318,10 +312,7 @@ export class InvoicesService {
{ populate: ["items", "company"] },
);
} else {
invoice = await this.invoiceRepository.findOne(
{ id: invoiceId, business: { id: businessId } },
{ populate: ["items", "company", "business"] },
);
invoice = await this.invoiceRepository.findOne({ id: invoiceId, business: { id: businessId } }, { populate: ["items", "company", "business"] });
}
if (!invoice) throw new BadRequestException(InvoiceMessage.NOT_FOUND_BY_ID);
@@ -360,88 +351,48 @@ export class InvoicesService {
}
//*********************************** */
// async payInvoice(invoiceId: string, userId: string, em = this.em.fork()) {
// let transactionStarted = false;
async payInvoice(invoiceId: string, userId: string, em = this.em.fork()) {
let transactionStarted = false;
// try {
// if (!em.isInTransaction) {
// await em.begin();
// transactionStarted = true;
// }
try {
if (!em.isInTransaction) {
await em.begin();
transactionStarted = true;
}
// const user = await this.usersService.findOneByIdWithEntityManager(userId, em);
const user = await this.usersService.findOneByIdWithEntityManager(userId, em);
// const invoice = await this.getPendingInvoiceByIdWithQueryRunner(invoiceId, user.id, em);
const invoice = await this.getPendingInvoiceByIdWithQueryRunner(invoiceId, user.id, em);
// if (invoice.status !== InvoiceStatus.WAIT_PAYMENT) throw new BadRequestException(InvoiceMessage.INVOICE_CAN_NOT_PAID);
if (invoice.status !== InvoiceStatus.WAIT_PAYMENT) throw new BadRequestException(InvoiceMessage.INVOICE_CAN_NOT_PAID);
// const userWallet = await this.walletsService.getWalletByUserId(user.id, em);
invoice.status = InvoiceStatus.PAID;
invoice.paidAt = dayjs().toDate();
// if (userWallet.balance < invoice.totalPrice) throw new BadRequestException(WalletMessage.INSUFFICIENT_BALANCE);
await em.persistAndFlush(invoice);
// userWallet.balance = new Decimal(userWallet.balance).sub(invoice.totalPrice);
await this.notificationQueue.addBillInvoiceNotification(userId, {
invoiceId: invoice.numericId.toString(),
dueDate: invoice.dueDate,
createDate: invoice.createdAt,
price: new Decimal(invoice.totalPrice).toNumber(),
userPhone: user.phone,
userEmail: user.email,
items: invoice.items.map((item) => item.name).join(", "),
paidAt: invoice.paidAt,
});
// await em.persistAndFlush(userWallet);
if (transactionStarted) await em.commit();
// if (invoice.items[0]?.subscriptionPlan) {
// const userSubscription = invoice.items[0].subscriptionPlan;
// userSubscription.status = SubscriptionStatus.ACTIVE;
// //
// await queryRunner.manager.save(UserSubscription, userSubscription);
// await this.walletsService.createSubscriptionTransaction(invoice.totalPrice, userWallet.id, queryRunner);
// await this.addNotifyForWalletDeduction(invoice, user, userWallet, WalletMessage.SUBSCRIPTION_WALLET_TRANSFER);
// await this.addNotifyAdminForSubscriptionPaymentToQueue(invoice);
// //
// } else if (invoice.items[0]?.supportPlan) {
// const oldUserSupportPlan = await queryRunner.manager.findOne(UserSupportPlan, {
// where: { user: { id: user.id }, status: UserSupportPlanStatus.ACTIVE },
// });
// if (oldUserSupportPlan) {
// await queryRunner.manager.update(UserSupportPlan, oldUserSupportPlan.id, { status: UserSupportPlanStatus.INACTIVE });
// }
// const userSupportPlan = invoice.items[0].supportPlan;
// userSupportPlan.status = UserSupportPlanStatus.ACTIVE;
// await queryRunner.manager.save(UserSupportPlan, userSupportPlan);
// await this.walletsService.createSupportPlanTransaction(invoice.totalPrice, userWallet.id, queryRunner);
// await this.addNotifyForWalletDeduction(invoice, user, userWallet, WalletMessage.SUPPORT_PLAN_WALLET_TRANSFER);
// //
// } else {
// await this.walletsService.createInvoiceTransaction(invoice.totalPrice, userWallet.id, queryRunner);
// await this.addNotifyForWalletDeduction(invoice, user, userWallet, WalletMessage.INVOICE_WALLET_TRANSFER);
// }
// invoice.status = InvoiceStatus.PAID;
// invoice.paidAt = dayjs().toDate();
// await queryRunner.manager.save(Invoice, invoice);
// await this.notificationQueue.addBillInvoiceNotification(userId, {
// invoiceId: invoice.numericId.toString(),
// dueDate: invoice.dueDate,
// createDate: invoice.createdAt,
// price: new Decimal(invoice.totalPrice).toNumber(),
// userPhone: user.phone,
// userEmail: user.email,
// items: invoice.items.map((item) => item.name).join(", "),
// paidAt: invoice.paidAt,
// });
// if (transactionStarted) await queryRunner.commitTransaction();
// return {
// message: InvoiceMessage.INVOICE_PAID,
// invoice,
// };
// } catch (error) {
// if (transactionStarted) await queryRunner.rollbackTransaction();
// throw error;
// } finally {
// if (transactionStarted) await queryRunner.release();
// }
// }
return {
message: InvoiceMessage.INVOICE_PAID,
invoice,
};
} catch (error) {
if (transactionStarted) await em.rollback();
throw error;
}
}
//*********************************** */
@@ -1,5 +1,5 @@
import { Controller, Get, Param, Patch, Query } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { ApiOperation } from "@nestjs/swagger";
import { SearchNotificationQueryDto } from "./DTO/search-notification-query.dto";
import { NotificationsService } from "./providers/notifications.service";
@@ -9,7 +9,6 @@ import { UserDec } from "../../common/decorators/user.decorator";
import { ParamDto } from "../../common/DTO/param.dto";
@Controller("notifications")
@ApiTags("Notifications")
@AuthGuards()
export class NotificationController {
constructor(private readonly notificationService: NotificationsService) {}
@@ -142,10 +142,7 @@ export class NotificationsService {
);
if (data.userEmail) await this.emailService.sendInvoiceEmail(data);
return this.createNotification(
{ title: NotificationMessage.INVOICE_CREATION, type: NotifType.CREATE_INVOICE, message, recipientId },
em,
);
return this.createNotification({ title: NotificationMessage.INVOICE_CREATION, type: NotifType.CREATE_INVOICE, message, recipientId }, em);
}
//************************ */
@@ -238,10 +235,7 @@ export class NotificationsService {
await this.smsService.sendInvoiceOverdueSms(data.userPhone, data.invoiceId, data.price, data.lateFee || 0, dateFormat(data.dueDate));
if (data.userEmail) await this.emailService.sendInvoiceEmail(data);
return this.createNotification(
{ title: NotificationMessage.INVOICE_OVERDUE, type: NotifType.INVOICE_OVERDUE, message, recipientId },
em,
);
return this.createNotification({ title: NotificationMessage.INVOICE_OVERDUE, type: NotifType.INVOICE_OVERDUE, message, recipientId }, em);
}
//************************ */
@@ -252,10 +246,7 @@ export class NotificationsService {
await this.smsService.sendRecurringInvoiceDraftSms(data.userPhone, data.invoiceId, data.price);
if (data.userEmail) await this.emailService.sendInvoiceEmail(data);
return this.createNotification(
{ title: NotificationMessage.RECURRING_INVOICE, type: NotifType.RECURRING_INVOICE, message, recipientId },
em,
);
return this.createNotification({ title: NotificationMessage.RECURRING_INVOICE, type: NotifType.RECURRING_INVOICE, message, recipientId }, em);
}
// //************************ */
@@ -18,12 +18,7 @@ import { NotificationsService } from "../providers/notifications.service";
type NotificationJobData = {
type: NotifType;
recipientId: string;
data:
| ITicketNotificationData
| IInvoiceNotificationData
| IAnnouncementNotificationData
| IPaymentNotificationData
| IOtpNotificationData;
data: ITicketNotificationData | IInvoiceNotificationData | IAnnouncementNotificationData | IPaymentNotificationData | IOtpNotificationData;
};
@Processor(NOTIFICATION.QUEUE_NAME, { concurrency: 5 })
@@ -68,11 +63,7 @@ export class NotificationProcessor extends WorkerProcessor {
await this.notificationsService.createTicketNotification(recipientId, data as ITicketNotificationData, entityManager);
break;
case NotifType.ASSIGN_TICKET:
await this.notificationsService.createAssignTicketNotificationForAdmin(
recipientId,
data as ITicketNotificationData,
entityManager,
);
await this.notificationsService.createAssignTicketNotificationForAdmin(recipientId, data as ITicketNotificationData, entityManager);
break;
// Invoice Notifications
@@ -80,11 +71,7 @@ export class NotificationProcessor extends WorkerProcessor {
await this.notificationsService.createInvoiceCreationNotification(recipientId, data as IInvoiceNotificationData, entityManager);
break;
case NotifType.BILL_INVOICE_REMINDER:
await this.notificationsService.createBillInvoiceReminderNotification(
recipientId,
data as IInvoiceNotificationData,
entityManager,
);
await this.notificationsService.createBillInvoiceReminderNotification(recipientId, data as IInvoiceNotificationData, entityManager);
break;
case NotifType.BILL_INVOICE:
await this.notificationsService.createBillInvoiceNotification(recipientId, data as IInvoiceNotificationData, entityManager);
@@ -104,11 +91,7 @@ export class NotificationProcessor extends WorkerProcessor {
await this.notificationsService.createPaymentReminderNotification(recipientId, data as IPaymentNotificationData, entityManager);
break;
case NotifType.PAYMENT_CANCELLATION:
await this.notificationsService.createPaymentCancellationNotification(
recipientId,
data as IPaymentNotificationData,
entityManager,
);
await this.notificationsService.createPaymentCancellationNotification(recipientId, data as IPaymentNotificationData, entityManager);
break;
default:
+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;
}
@@ -206,8 +206,7 @@ export class TicketsService {
const ticket = await this.findTicket(em, ticketId, businessId, userId, role === RoleEnum.ADMIN);
if (ticket.status === TicketStatus.CLOSED) throw new BadRequestException(TicketMessageEnum.TICKET_CLOSED);
if (role === RoleEnum.ADMIN && ticket.assignedTo?.id !== userId)
throw new BadRequestException(TicketMessageEnum.TICKET_NOT_ASSIGNED_TO_USER);
if (role === RoleEnum.ADMIN && ticket.assignedTo?.id !== userId) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_ASSIGNED_TO_USER);
const user = await em.findOne(User, { id: userId });
if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND);
@@ -380,11 +379,7 @@ export class TicketsService {
private async findTicket(em: EntityManager, ticketId: string, businessId: string, userId: string, isAdmin: boolean): Promise<Ticket> {
const ticket = isAdmin
? await em.findOne(Ticket, { id: ticketId, user: { id: userId }, business: { id: businessId } }, { populate: ["user", "assignedTo"] })
: await em.findOne(
Ticket,
{ id: ticketId, user: { id: userId }, business: { id: businessId } },
{ populate: ["user", "assignedTo"] },
);
: await em.findOne(Ticket, { id: ticketId, user: { id: userId }, business: { id: businessId } }, { populate: ["user", "assignedTo"] });
if (!ticket) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_FOUND);
return ticket;
+1 -6
View File
@@ -125,12 +125,7 @@ export class TicketsController {
@ApiOperation({ summary: "referer ticket ==> admin route" })
@Post(":id/refer")
referTicket(
@Param() paramDto: ParamDto,
@UserDec("id") userId: string,
@Body() referDto: ReferTicketDto,
@BusinessDec("id") businessId: string,
) {
referTicket(@Param() paramDto: ParamDto, @UserDec("id") userId: string, @Body() referDto: ReferTicketDto, @BusinessDec("id") businessId: string) {
return this.ticketsService.referTicket(paramDto.id, userId, referDto, businessId);
}
}