From ed4a5ffff57ffe7ff46defc8f43e127491ba79f7 Mon Sep 17 00:00:00 2001 From: Mahyargdz Date: Tue, 20 May 2025 16:25:49 +0330 Subject: [PATCH] chore: add payment module --- .prettierrc | 2 +- .../decorators/is-national-code.decorator.ts | 8 +- src/common/enums/message.enum.ts | 30 +--- src/configs/zarinpal.config.ts | 2 - src/core/interceptors/business.interceptor.ts | 10 +- .../announcements/announcement.controller.ts | 6 +- .../businesses/entities/business.entity.ts | 3 + .../companies/entities/company.entity.ts | 14 +- .../companies/services/companies.service.ts | 3 +- src/modules/invoices/invoices.controller.ts | 7 +- .../invoices/providers/invoices.service.ts | 119 +++++----------- .../notifications/notifications.controller.ts | 3 +- .../providers/notifications.service.ts | 15 +- .../queue/notification.processor.ts | 25 +--- .../payments/DTO/checkout-payment.dto.ts | 16 +++ .../payments/DTO/deposit-wallet.dto.ts | 42 ------ .../DTO/payment-transaction-query.dto.ts | 17 --- .../payments/DTO/verify-payment.dto.ts | 6 +- .../payments/enums/payment-type.enum.ts | 5 - .../payments/gateways/zarinpal.gateway.ts | 20 ++- src/modules/payments/interfaces/IPayment.ts | 5 +- src/modules/payments/payments.controller.ts | 42 +++++- src/modules/payments/payments.module.ts | 3 +- .../payments/queue/payment.processor.ts | 12 +- .../payments/services/payments.service.ts | 133 ++++++++++++++---- .../tickets/providers/tickets.service.ts | 9 +- src/modules/tickets/tickets.controller.ts | 7 +- 27 files changed, 249 insertions(+), 315 deletions(-) create mode 100755 src/modules/payments/DTO/checkout-payment.dto.ts delete mode 100755 src/modules/payments/DTO/deposit-wallet.dto.ts delete mode 100755 src/modules/payments/DTO/payment-transaction-query.dto.ts delete mode 100755 src/modules/payments/enums/payment-type.enum.ts diff --git a/.prettierrc b/.prettierrc index b63f3b5..f951e43 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,7 +2,7 @@ "singleQuote": false, "trailingComma": "all", "endOfLine": "auto", - "printWidth": 140, + "printWidth": 150, "arrowParens": "always", "semi": true, "tabWidth": 2 diff --git a/src/common/decorators/is-national-code.decorator.ts b/src/common/decorators/is-national-code.decorator.ts index a72680b..9ddba95 100755 --- a/src/common/decorators/is-national-code.decorator.ts +++ b/src/common/decorators/is-national-code.decorator.ts @@ -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; diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 37aea05..db26007 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -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 { diff --git a/src/configs/zarinpal.config.ts b/src/configs/zarinpal.config.ts index 63e2d5c..97c5dc9 100755 --- a/src/configs/zarinpal.config.ts +++ b/src/configs/zarinpal.config.ts @@ -4,7 +4,6 @@ export function zarinpalConfig() { return { inject: [ConfigService], useFactory: (configService: ConfigService) => ({ - merchantId: configService.getOrThrow("ZARINPAL_MERCHANT_ID"), gatewayApiUrl: configService.getOrThrow("ZARINPAL_API_URL"), callBackUrl: configService.getOrThrow("CALLBACK_URL"), ipgType: configService.getOrThrow("IPG_TYPE"), @@ -13,7 +12,6 @@ export function zarinpalConfig() { } export interface IZarinpalConfig { - merchantId: string; gatewayApiUrl: string; callBackUrl: string; ipgType: string; diff --git a/src/core/interceptors/business.interceptor.ts b/src/core/interceptors/business.interceptor.ts index 9e48625..0456abb 100644 --- a/src/core/interceptors/business.interceptor.ts +++ b/src/core/interceptors/business.interceptor.ts @@ -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"; diff --git a/src/modules/announcements/announcement.controller.ts b/src/modules/announcements/announcement.controller.ts index 1a800cf..af6a597 100755 --- a/src/modules/announcements/announcement.controller.ts +++ b/src/modules/announcements/announcement.controller.ts @@ -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); } diff --git a/src/modules/businesses/entities/business.entity.ts b/src/modules/businesses/entities/business.entity.ts index 6d3c8aa..78d350b 100644 --- a/src/modules/businesses/entities/business.entity.ts +++ b/src/modules/businesses/entities/business.entity.ts @@ -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] }) diff --git a/src/modules/companies/entities/company.entity.ts b/src/modules/companies/entities/company.entity.ts index 7aedccf..98c93b0 100644 --- a/src/modules/companies/entities/company.entity.ts +++ b/src/modules/companies/entities/company.entity.ts @@ -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"; diff --git a/src/modules/companies/services/companies.service.ts b/src/modules/companies/services/companies.service.ts index d7d958f..5b93bd4 100644 --- a/src/modules/companies/services/companies.service.ts +++ b/src/modules/companies/services/companies.service.ts @@ -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, }); diff --git a/src/modules/invoices/invoices.controller.ts b/src/modules/invoices/invoices.controller.ts index d15dc5d..3e161f8 100755 --- a/src/modules/invoices/invoices.controller.ts +++ b/src/modules/invoices/invoices.controller.ts @@ -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); } diff --git a/src/modules/invoices/providers/invoices.service.ts b/src/modules/invoices/providers/invoices.service.ts index 8e4011e..98e2602 100755 --- a/src/modules/invoices/providers/invoices.service.ts +++ b/src/modules/invoices/providers/invoices.service.ts @@ -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; + } + } //*********************************** */ diff --git a/src/modules/notifications/notifications.controller.ts b/src/modules/notifications/notifications.controller.ts index cb71250..86e0edc 100755 --- a/src/modules/notifications/notifications.controller.ts +++ b/src/modules/notifications/notifications.controller.ts @@ -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) {} diff --git a/src/modules/notifications/providers/notifications.service.ts b/src/modules/notifications/providers/notifications.service.ts index b29fd59..34fcdc2 100755 --- a/src/modules/notifications/providers/notifications.service.ts +++ b/src/modules/notifications/providers/notifications.service.ts @@ -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); } // //************************ */ diff --git a/src/modules/notifications/queue/notification.processor.ts b/src/modules/notifications/queue/notification.processor.ts index 092ba4d..10e2dfc 100644 --- a/src/modules/notifications/queue/notification.processor.ts +++ b/src/modules/notifications/queue/notification.processor.ts @@ -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: diff --git a/src/modules/payments/DTO/checkout-payment.dto.ts b/src/modules/payments/DTO/checkout-payment.dto.ts new file mode 100755 index 0000000..6abdcd6 --- /dev/null +++ b/src/modules/payments/DTO/checkout-payment.dto.ts @@ -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; +} diff --git a/src/modules/payments/DTO/deposit-wallet.dto.ts b/src/modules/payments/DTO/deposit-wallet.dto.ts deleted file mode 100755 index 554574f..0000000 --- a/src/modules/payments/DTO/deposit-wallet.dto.ts +++ /dev/null @@ -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; -} diff --git a/src/modules/payments/DTO/payment-transaction-query.dto.ts b/src/modules/payments/DTO/payment-transaction-query.dto.ts deleted file mode 100755 index 7904072..0000000 --- a/src/modules/payments/DTO/payment-transaction-query.dto.ts +++ /dev/null @@ -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; -} diff --git a/src/modules/payments/DTO/verify-payment.dto.ts b/src/modules/payments/DTO/verify-payment.dto.ts index eb820c9..e730b40 100755 --- a/src/modules/payments/DTO/verify-payment.dto.ts +++ b/src/modules/payments/DTO/verify-payment.dto.ts @@ -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 { diff --git a/src/modules/payments/enums/payment-type.enum.ts b/src/modules/payments/enums/payment-type.enum.ts deleted file mode 100755 index da869ce..0000000 --- a/src/modules/payments/enums/payment-type.enum.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum TransferType { - CARD_TO_CARD = "CARD_TO_CARD", - SHEBA = "SHEBA", - // GATEWAY = "GATEWAY", -} diff --git a/src/modules/payments/gateways/zarinpal.gateway.ts b/src/modules/payments/gateways/zarinpal.gateway.ts index 1773ffa..2f75823 100755 --- a/src/modules/payments/gateways/zarinpal.gateway.ts +++ b/src/modules/payments/gateways/zarinpal.gateway.ts @@ -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(`${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(`${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 { diff --git a/src/modules/payments/interfaces/IPayment.ts b/src/modules/payments/interfaces/IPayment.ts index fccd737..a10dd72 100755 --- a/src/modules/payments/interfaces/IPayment.ts +++ b/src/modules/payments/interfaces/IPayment.ts @@ -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; } diff --git a/src/modules/payments/payments.controller.ts b/src/modules/payments/payments.controller.ts index 5d616bd..1b19460 100644 --- a/src/modules/payments/payments.controller.ts +++ b/src/modules/payments/payments.controller.ts @@ -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); + } +} diff --git a/src/modules/payments/payments.module.ts b/src/modules/payments/payments.module.ts index 36c6b89..777d514 100644 --- a/src/modules/payments/payments.module.ts +++ b/src/modules/payments/payments.module.ts @@ -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: [ diff --git a/src/modules/payments/queue/payment.processor.ts b/src/modules/payments/queue/payment.processor.ts index 4eec744..a28c0e4 100755 --- a/src/modules/payments/queue/payment.processor.ts +++ b/src/modules/payments/queue/payment.processor.ts @@ -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("ZARINPAL_MERCHANT_ID"); } //=============================================== async process(job: Job) { @@ -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); diff --git a/src/modules/payments/services/payments.service.ts b/src/modules/payments/services/payments.service.ts index 5f71410..7d108c0 100644 --- a/src/modules/payments/services/payments.service.ts +++ b/src/modules/payments/services/payments.service.ts @@ -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("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 = {}; 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("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 { + 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 { + 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 { - 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; } diff --git a/src/modules/tickets/providers/tickets.service.ts b/src/modules/tickets/providers/tickets.service.ts index 5b69852..89a3c8a 100755 --- a/src/modules/tickets/providers/tickets.service.ts +++ b/src/modules/tickets/providers/tickets.service.ts @@ -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 { 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; diff --git a/src/modules/tickets/tickets.controller.ts b/src/modules/tickets/tickets.controller.ts index 75435ac..7deee4d 100755 --- a/src/modules/tickets/tickets.controller.ts +++ b/src/modules/tickets/tickets.controller.ts @@ -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); } }