From 9afe89295cc04c46379f6fb442840c1b59ce05f2 Mon Sep 17 00:00:00 2001 From: Mahyargdz Date: Mon, 19 May 2025 16:15:15 +0330 Subject: [PATCH] chore: payment --- src/app.module.ts | 8 +- src/modules/companies/companies.controller.ts | 14 +- .../companies/services/companies.service.ts | 6 +- .../invoices/entities/invoice.entity.ts | 12 +- .../invoices/providers/invoices.service.ts | 20 +- .../payments/DTO/deposit-wallet.dto.ts | 42 +++ .../DTO/payment-transaction-query.dto.ts | 17 ++ .../DTO/search-transaction-query.dto.ts | 27 ++ .../payments/DTO/verify-payment.dto.ts | 23 ++ src/modules/payments/constants/index.ts | 19 ++ .../entities/payment-gateway.entity.ts | 25 ++ .../payments/entities/payment.entity.ts | 43 +++ src/modules/payments/enums/gateway.enum.ts | 3 + .../payments/enums/payment-status.enum.ts | 6 + .../payments/enums/payment-type.enum.ts | 5 + .../payments/factories/payment.factory.ts | 34 +++ .../payments/gateways/zarinpal.gateway.ts | 99 +++++++ src/modules/payments/interfaces/IPayment.ts | 105 ++++++++ src/modules/payments/payments.controller.ts | 4 + src/modules/payments/payments.module.ts | 42 +++ .../payments/queue/payment.processor.ts | 249 ++++++++++++++++++ .../payment-gateway.repository.ts | 5 + .../repositories/payment.repository.ts | 9 + .../payments/services/payments.service.ts | 249 ++++++++++++++++++ src/modules/payments/types/gateway.type.ts | 1 + .../payments/types/payment-job.type.ts | 3 + .../payments/types/payment-status.type.ts | 1 + src/modules/tickets/entities/ticket.entity.ts | 2 +- .../tickets/providers/tickets.service.ts | 1 + 29 files changed, 1048 insertions(+), 26 deletions(-) create mode 100755 src/modules/payments/DTO/deposit-wallet.dto.ts create mode 100755 src/modules/payments/DTO/payment-transaction-query.dto.ts create mode 100755 src/modules/payments/DTO/search-transaction-query.dto.ts create mode 100755 src/modules/payments/DTO/verify-payment.dto.ts create mode 100755 src/modules/payments/constants/index.ts create mode 100755 src/modules/payments/entities/payment-gateway.entity.ts create mode 100755 src/modules/payments/entities/payment.entity.ts create mode 100755 src/modules/payments/enums/gateway.enum.ts create mode 100755 src/modules/payments/enums/payment-status.enum.ts create mode 100755 src/modules/payments/enums/payment-type.enum.ts create mode 100755 src/modules/payments/factories/payment.factory.ts create mode 100755 src/modules/payments/gateways/zarinpal.gateway.ts create mode 100755 src/modules/payments/interfaces/IPayment.ts create mode 100644 src/modules/payments/payments.controller.ts create mode 100644 src/modules/payments/payments.module.ts create mode 100755 src/modules/payments/queue/payment.processor.ts create mode 100755 src/modules/payments/repositories/payment-gateway.repository.ts create mode 100755 src/modules/payments/repositories/payment.repository.ts create mode 100644 src/modules/payments/services/payments.service.ts create mode 100755 src/modules/payments/types/gateway.type.ts create mode 100644 src/modules/payments/types/payment-job.type.ts create mode 100755 src/modules/payments/types/payment-status.type.ts diff --git a/src/app.module.ts b/src/app.module.ts index 407468a..4a3752c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,6 +22,7 @@ import { CriticismModule } from "./modules/criticisms/criticisms.module"; import { IndustriesModule } from "./modules/industries/industries.module"; import { InvoicesModule } from "./modules/invoices/invoices.module"; import { NotificationModule } from "./modules/notifications/notifications.module"; +import { PaymentsModule } from "./modules/payments/payments.module"; import { TicketsModule } from "./modules/tickets/tickets.module"; import { UploaderModule } from "./modules/uploader/uploader.module"; import { UsersModule } from "./modules/users/users.module"; @@ -57,13 +58,8 @@ import { UtilsModule } from "./modules/utils/utils.module"; BusinessesModule, AnnouncementsModule, CriticismModule, + PaymentsModule, ], - // providers: [ - // { - // provide: APP_INTERCEPTOR, - // useClass: BusinessInterceptor, - // }, - // ], }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { diff --git a/src/modules/companies/companies.controller.ts b/src/modules/companies/companies.controller.ts index e9f3734..15de5f3 100644 --- a/src/modules/companies/companies.controller.ts +++ b/src/modules/companies/companies.controller.ts @@ -11,7 +11,6 @@ import { UserDec } from "../../common/decorators/user.decorator"; import { ParamDto } from "../../common/DTO/param.dto"; import { BusinessInterceptor } from "../../core/interceptors/business.interceptor"; import { Business } from "../businesses/entities/business.entity"; -import { User } from "../users/entities/user.entity"; @Controller("companies") @UseInterceptors(BusinessInterceptor) @@ -72,27 +71,28 @@ export class CompaniesController { return this.companiesService.toggleStatus(paramDto.id, businessId); } - @Post("/company-requests") + @Post("requests") @AuthGuards() @ApiOperation({ summary: "submit company registration request (user)" }) - createCompanyRequest(@Body() createDto: CreateCompanyRequestDto, @UserDec() user: User, @BusinessDec() business: Business) { - return this.companiesService.createCompanyRequest(createDto, user, business); + createCompanyRequest(@Body() createDto: CreateCompanyRequestDto, @UserDec("id") userId: string, @BusinessDec() business: Business) { + return this.companiesService.createCompanyRequest(createDto, userId, business); } - @Get("/company-requests/user") + @Get("requests/user") + @AuthGuards() @ApiOperation({ summary: "get all company registration requests (user)" }) getCompanyRequestsUser(@UserDec("id") userId: string, @BusinessDec("id") businessId: string) { return this.companiesService.getCompanyRequestsUser(userId, businessId); } - @Get("/company-requests") + @Get("requests") @AuthGuards() @ApiOperation({ summary: "get all company registration requests (admin)" }) getCompanyRequests(@BusinessDec("id") businessId: string) { return this.companiesService.getCompanyRequests(businessId); } - @Patch("/company-requests/:id/approve") + @Patch("requests/:id/approve") @AuthGuards() @ApiOperation({ summary: "approve/reject company registration request (admin)" }) approveCompanyRequest(@Param() paramDto: ParamDto, @Body() approveDto: ApproveCompanyRequestDto, @BusinessDec("id") businessId: string) { diff --git a/src/modules/companies/services/companies.service.ts b/src/modules/companies/services/companies.service.ts index 4490f15..d7d958f 100644 --- a/src/modules/companies/services/companies.service.ts +++ b/src/modules/companies/services/companies.service.ts @@ -245,11 +245,13 @@ export class CompaniesService { //----------------------------------- - async createCompanyRequest(createDto: CreateCompanyRequestDto, user: User, business: Business) { + async createCompanyRequest(createDto: CreateCompanyRequestDto, userId: string, business: Business) { const em = this.em.fork(); try { await em.begin(); + const user = await this.usersService.findOneByIdWithEntityManager(userId, em); + const existRequest = await em.findOne(CompanyRequest, { user: { id: user.id }, business: { id: business.id }, @@ -281,7 +283,7 @@ export class CompaniesService { //----------------------------------- async getCompanyRequestsUser(userId: string, businessId: string) { - const requests = await this.companyRequestRepository.find( + const requests = await this.companyRequestRepository.findOne( { user: { id: userId }, business: { id: businessId }, deletedAt: null }, { populate: ["company"], orderBy: { createdAt: "DESC" } }, ); diff --git a/src/modules/invoices/entities/invoice.entity.ts b/src/modules/invoices/entities/invoice.entity.ts index 77b4abb..ad0f72f 100755 --- a/src/modules/invoices/entities/invoice.entity.ts +++ b/src/modules/invoices/entities/invoice.entity.ts @@ -5,6 +5,7 @@ import { InvoiceItem } from "./invoice-item.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; import { Business } from "../../businesses/entities/business.entity"; import { Company } from "../../companies/entities/company.entity"; +import { Payment } from "../../payments/entities/payment.entity"; import { RecurringPeriodEnum } from "../enums/invoice-recurring-period.enum"; import { InvoiceStatus } from "../enums/invoice-status.enum"; import { InvoicesRepository } from "../repositories/invoices.repository"; @@ -50,11 +51,18 @@ export class Invoice extends BaseEntity { @Property({ type: "int", default: 0 }) currentRecurringCycle: number & Opt; - @OneToMany(() => InvoiceItem, (item) => item.invoice, { cascade: [Cascade.PERSIST, Cascade.REMOVE] }) - items = new Collection(this); + //----------------------------------- @ManyToOne(() => Business, { nullable: false, deleteRule: "cascade" }) business!: Business; + //----------------------------------- + + @OneToMany(() => InvoiceItem, (item) => item.invoice, { cascade: [Cascade.PERSIST, Cascade.REMOVE] }) + items = new Collection(this); + + @OneToMany(() => Payment, (payment) => payment.invoice, { cascade: [Cascade.PERSIST, Cascade.REMOVE] }) + payments = new Collection(this); + [EntityRepositoryType]?: InvoicesRepository; } diff --git a/src/modules/invoices/providers/invoices.service.ts b/src/modules/invoices/providers/invoices.service.ts index 374fbb5..8e4011e 100755 --- a/src/modules/invoices/providers/invoices.service.ts +++ b/src/modules/invoices/providers/invoices.service.ts @@ -1,4 +1,4 @@ -import { EntityManager } from "@mikro-orm/postgresql"; +import { EntityManager, FilterQuery } from "@mikro-orm/postgresql"; import { InjectQueue } from "@nestjs/bullmq"; import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { Queue } from "bullmq"; @@ -336,17 +336,21 @@ export class InvoicesService { async getUserInvoices(queryDto: UserInvoicesSearchQueryDto, userId: string, businessId: string) { const { limit, skip } = PaginationUtils(queryDto); - const qb = this.em - .createQueryBuilder(Invoice, "invoice") - .leftJoinAndSelect("invoice.items", "items") - .where({ company: { user: { id: userId } }, business: { id: businessId } }) - .orderBy({ createdAt: "DESC" }); + const where: FilterQuery = { + company: { user: { id: userId } }, + business: { id: businessId }, + }; if (queryDto.status) { - qb.andWhere({ status: queryDto.status }); + where.status = queryDto.status; } - const [invoices, count] = await qb.limit(limit).offset(skip).getResultAndCount(); + const [invoices, count] = await this.invoiceRepository.findAndCount(where, { + populate: ["items"], + orderBy: { createdAt: "DESC" }, + limit, + offset: skip, + }); return { invoices, diff --git a/src/modules/payments/DTO/deposit-wallet.dto.ts b/src/modules/payments/DTO/deposit-wallet.dto.ts new file mode 100755 index 0000000..554574f --- /dev/null +++ b/src/modules/payments/DTO/deposit-wallet.dto.ts @@ -0,0 +1,42 @@ +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 new file mode 100755 index 0000000..7904072 --- /dev/null +++ b/src/modules/payments/DTO/payment-transaction-query.dto.ts @@ -0,0 +1,17 @@ +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/search-transaction-query.dto.ts b/src/modules/payments/DTO/search-transaction-query.dto.ts new file mode 100755 index 0000000..dd7a7de --- /dev/null +++ b/src/modules/payments/DTO/search-transaction-query.dto.ts @@ -0,0 +1,27 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsDateString, IsEnum, IsOptional, IsString } from "class-validator"; + +import { PaginationDto } from "../../../common/DTO/pagination.dto"; +import { PaymentStatus } from "../enums/payment-status.enum"; + +export class SearchTransactionQueryDto extends PaginationDto { + @IsOptional() + @IsString() + @ApiPropertyOptional({ description: "user name or family or ...", example: "mamad" }) + user?: string; + + @IsOptional() + @IsDateString() + @ApiPropertyOptional({ description: "the date string", example: "2024-01-25" }) + date?: string; + + @IsOptional() + @IsEnum(PaymentStatus) + @ApiPropertyOptional({ enum: PaymentStatus, description: "payment status", example: PaymentStatus.PENDING }) + status?: PaymentStatus; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ description: "search quey", example: "harchi" }) + q?: string; +} diff --git a/src/modules/payments/DTO/verify-payment.dto.ts b/src/modules/payments/DTO/verify-payment.dto.ts new file mode 100755 index 0000000..eb820c9 --- /dev/null +++ b/src/modules/payments/DTO/verify-payment.dto.ts @@ -0,0 +1,23 @@ +import { IsEnum, IsNotEmpty, IsOptional } from "class-validator"; + +import { GatewayEnum } from "../enums/gateway.enum"; +import { GatewayType } from "../types/gateway.type"; +import { GatewayPaymentStatus } from "../types/payment-status.type"; + +export class VerifyParamDto { + @IsNotEmpty() + @IsEnum(GatewayEnum) + gateway: GatewayType; + + @IsOptional() + @IsNotEmpty() + invoiceId?: string; +} + +export class VerifyQueryDto { + @IsNotEmpty() + Authority: string; + + @IsNotEmpty() + Status: GatewayPaymentStatus; +} diff --git a/src/modules/payments/constants/index.ts b/src/modules/payments/constants/index.ts new file mode 100755 index 0000000..3c109c8 --- /dev/null +++ b/src/modules/payments/constants/index.ts @@ -0,0 +1,19 @@ +export const ZARINPAL_CONFIG = "ZARINPAL_CONFIG"; +//=============================================== + +export const PAYMENT = Object.freeze({ + QUEUE_NAME: "payment", + START: "payment_start", + FIRST_REMINDER: "payment_first_reminder", + SECOND_REMINDER: "payment_second_reminder", + CANCELLATION: "payment_cancellation", + + JOB_PRIORITY: 1, // high priority + JOB_ATTEMPTS: 3, // 3 times + JOB_BACKOFF: 5 * 1000, // ms + JOB_TIMEOUT: 10000, // timeout after 10 seconds + START_DELAY: 1 * 60 * 1000, // 1 minute delay in milliseconds + FIRST_REMINDER_DELAY: 5 * 60 * 1000, // 5 minutes + SECOND_REMINDER_DELAY: 10 * 60 * 1000, // 10 minutes + CANCELLATION_DELAY: 15 * 60 * 1000, // 15 minutes +}); diff --git a/src/modules/payments/entities/payment-gateway.entity.ts b/src/modules/payments/entities/payment-gateway.entity.ts new file mode 100755 index 0000000..e057251 --- /dev/null +++ b/src/modules/payments/entities/payment-gateway.entity.ts @@ -0,0 +1,25 @@ +import { Entity, EntityRepositoryType, Enum, Opt, Property } from "@mikro-orm/core"; + +import { BaseEntity } from "../../../common/entities/base.entity"; +import { GatewayEnum } from "../enums/gateway.enum"; +import { PaymentGatewaysRepository } from "../repositories/payment-gateway.repository"; + +@Entity({ repository: () => PaymentGatewaysRepository }) +export class PaymentGateway extends BaseEntity { + @Enum({ items: () => GatewayEnum, nullable: false, unique: true }) + name!: GatewayEnum; + + @Property({ type: "varchar", length: 150, nullable: false }) + nameFa!: string; + + @Property({ type: "varchar", length: 150, nullable: false }) + logoUrl!: string; + + @Property({ type: "boolean", default: true }) + isActive: boolean & Opt; + + @Property({ type: "text", nullable: true }) + description?: string; + + [EntityRepositoryType]?: PaymentGatewaysRepository; +} diff --git a/src/modules/payments/entities/payment.entity.ts b/src/modules/payments/entities/payment.entity.ts new file mode 100755 index 0000000..9ea2ca3 --- /dev/null +++ b/src/modules/payments/entities/payment.entity.ts @@ -0,0 +1,43 @@ +import { Entity, EntityRepositoryType, Enum, ManyToOne, Opt, Property } from "@mikro-orm/core"; +import Decimal from "decimal.js"; + +import { PaymentGateway } from "./payment-gateway.entity"; +import { BaseEntity } from "../../../common/entities/base.entity"; +import { Business } from "../../businesses/entities/business.entity"; +import { Invoice } from "../../invoices/entities/invoice.entity"; +import { User } from "../../users/entities/user.entity"; +import { PaymentStatus } from "../enums/payment-status.enum"; +import { PaymentRepository } from "../repositories/payment.repository"; + +@Entity({ repository: () => PaymentRepository }) +export class Payment extends BaseEntity { + @Property({ type: () => Decimal, columnType: "numeric", precision: 16, scale: 2, nullable: false }) + amount!: Decimal; + + @Property({ type: "varchar", length: 150, nullable: false }) + reference!: string; + + @Property({ type: "varchar", length: 150, nullable: true }) + transactionId?: string; + + @Enum({ items: () => PaymentStatus, default: PaymentStatus.PENDING }) + status: PaymentStatus & Opt; + + //----------------------------------- + + @ManyToOne(() => User, { nullable: false, deleteRule: "cascade" }) + user!: User; + + @ManyToOne(() => PaymentGateway, { nullable: false, deleteRule: "cascade" }) + paymentGateway!: PaymentGateway; + + @ManyToOne(() => Business, { nullable: false, deleteRule: "cascade" }) + business!: Business; + + @ManyToOne(() => Invoice, { nullable: false, deleteRule: "cascade" }) + invoice!: Invoice; + + //----------------------------------- + + [EntityRepositoryType]?: PaymentRepository; +} diff --git a/src/modules/payments/enums/gateway.enum.ts b/src/modules/payments/enums/gateway.enum.ts new file mode 100755 index 0000000..ae9eb46 --- /dev/null +++ b/src/modules/payments/enums/gateway.enum.ts @@ -0,0 +1,3 @@ +export enum GatewayEnum { + ZARINPAL = "zarinpal", +} diff --git a/src/modules/payments/enums/payment-status.enum.ts b/src/modules/payments/enums/payment-status.enum.ts new file mode 100755 index 0000000..4768a53 --- /dev/null +++ b/src/modules/payments/enums/payment-status.enum.ts @@ -0,0 +1,6 @@ +export enum PaymentStatus { + PENDING = "PENDING", + COMPLETED = "COMPLETED", + FAILED = "FAILED", + CANCELLED = "CANCELLED", +} diff --git a/src/modules/payments/enums/payment-type.enum.ts b/src/modules/payments/enums/payment-type.enum.ts new file mode 100755 index 0000000..da869ce --- /dev/null +++ b/src/modules/payments/enums/payment-type.enum.ts @@ -0,0 +1,5 @@ +export enum TransferType { + CARD_TO_CARD = "CARD_TO_CARD", + SHEBA = "SHEBA", + // GATEWAY = "GATEWAY", +} diff --git a/src/modules/payments/factories/payment.factory.ts b/src/modules/payments/factories/payment.factory.ts new file mode 100755 index 0000000..34046af --- /dev/null +++ b/src/modules/payments/factories/payment.factory.ts @@ -0,0 +1,34 @@ +import { BadGatewayException, Injectable } from "@nestjs/common"; + +import { PaymentMessage } from "../../../common/enums/message.enum"; +import { GatewayEnum } from "../enums/gateway.enum"; +import { ZarinpalGateway } from "../gateways/zarinpal.gateway"; +import { IPaymentGateway, IPaymentGatewayFactory } from "../interfaces/IPayment"; +import { GatewayType } from "../types/gateway.type"; + +@Injectable() +export class PaymentGatewayFactory implements IPaymentGatewayFactory { + private readonly gateways: Map; + + constructor(private readonly zarinpalGateway: ZarinpalGateway) { + this.gateways = new Map(); + this.initializeGateways(); + } + + private initializeGateways(): void { + this.gateways.set(GatewayEnum.ZARINPAL, this.zarinpalGateway); + } + + getPaymentGateway(provider: GatewayType): IPaymentGateway { + const gateway = this.gateways.get(provider); + if (!gateway) { + throw new BadGatewayException(PaymentMessage.PAYMENT_GATEWAY_NOT_FOUND); + } + return gateway; + } + + getAvailablePaymentGateways() { + const gateways = Array.from(this.gateways.keys()).map((name) => ({ name: name as GatewayEnum })); + return { gateways }; + } +} diff --git a/src/modules/payments/gateways/zarinpal.gateway.ts b/src/modules/payments/gateways/zarinpal.gateway.ts new file mode 100755 index 0000000..1773ffa --- /dev/null +++ b/src/modules/payments/gateways/zarinpal.gateway.ts @@ -0,0 +1,99 @@ +import { HttpService } from "@nestjs/axios"; +import { Inject, Injectable, InternalServerErrorException, Logger } from "@nestjs/common"; +import { AxiosError } from "axios"; +import { catchError, firstValueFrom } from "rxjs"; + +import { PaymentMessage } from "../../../common/enums/message.enum"; +import { IZarinpalConfig } from "../../../configs/zarinpal.config"; +import { ZARINPAL_CONFIG } from "../constants"; +import { GatewayEnum } from "../enums/gateway.enum"; +import { + IPaymentGateway, + IPaymentVerifyParams, + IProcessPaymentParams, + ZarinPalPGNewArgs, + ZarinPalPGNewRequestData, + ZarinPalPGVerifyData, +} from "../interfaces/IPayment"; + +@Injectable() +export class ZarinpalGateway implements IPaymentGateway { + private readonly logger = new Logger(ZarinpalGateway.name); + private readonly gatewayApiUrl: string; + private readonly requestHeader: Record = { "Content-Type": "application/json", Accept: "application/json" }; + + constructor( + @Inject(ZARINPAL_CONFIG) private readonly config: IZarinpalConfig, + private readonly httpService: HttpService, + ) { + this.gatewayApiUrl = `https://${this.config.ipgType}.${this.config.gatewayApiUrl}`; + } + //*************************************** */ + async processPayment(processParams: IProcessPaymentParams) { + try { + const purchaseData: ZarinPalPGNewArgs = { + merchant_id: this.config.merchantId, + amount: processParams.amount, + callback_url: `${this.config.callBackUrl}/${GatewayEnum.ZARINPAL}${processParams.invoiceId ? "/" + processParams.invoiceId : ""}`, + description: processParams.description, + currency: "IRT", + metadata: { email: processParams.email, mobile: processParams.mobile }, + }; + + const { data } = await firstValueFrom( + this.httpService + .post(`${this.gatewayApiUrl}/v4/payment/request.json`, purchaseData, { + headers: this.requestHeader, + }) + .pipe( + catchError((err: AxiosError) => { + this.logger.error(err); + throw new InternalServerErrorException(PaymentMessage.ERROR_IN_PROCESS_PAYMENT); + }), + ), + ); + return { + redirectUrl: `${this.gatewayApiUrl}/StartPay/${data.data.authority}`, + message: data.data.message, + reference: data.data.authority, + }; + } catch (error) { + this.logger.error(error); + throw new InternalServerErrorException(PaymentMessage.ERROR_IN_PROCESS_PAYMENT); + } + } + //********************************** */ + async verifyPayment(verifyParams: IPaymentVerifyParams) { + try { + const verifyData = { + authority: verifyParams.reference, + amount: verifyParams.amount, + merchant_id: this.config.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); + }), + ), + ); + + return { + code: data.data.code, + message: data.data.message, + card_hash: data.data.card_hash, + card_pan: data.data.card_pan, + ref_id: data.data.ref_id, + fee_type: data.data.fee_type, + fee: data.data.fee, + }; + } catch (error) { + console.error({ error }); + this.logger.error(error); + throw new InternalServerErrorException(PaymentMessage.ERROR_IN_VERIFY_PAYMENT); + } + } +} diff --git a/src/modules/payments/interfaces/IPayment.ts b/src/modules/payments/interfaces/IPayment.ts new file mode 100755 index 0000000..fccd737 --- /dev/null +++ b/src/modules/payments/interfaces/IPayment.ts @@ -0,0 +1,105 @@ +//------------------ process payment ------------------------- + +import Decimal from "decimal.js"; + +import { Payment } from "../entities/payment.entity"; +import { GatewayEnum } from "../enums/gateway.enum"; +import { PaymentStatus } from "../enums/payment-status.enum"; +import { GatewayType } from "../types/gateway.type"; + +export interface IProcessPaymentParams { + amount: number; + description: string; + // callBackPath: string; + email?: string; + mobile?: string; + invoiceId?: string; +} + +export interface IProcessPaymentData { + redirectUrl: string; + message: string; + reference: string; +} + +//------------------ Verify payment ------------------------- + +export interface IVerifyPayment { + code: number; + ref_id: number; +} + +export interface IPaymentVerifyParams { + amount: Decimal; + reference: string; + // status: GatewayPaymentStatus; +} + +//--------------------- zarinpal -------------------------------- +interface MetaData { + mobile?: string; + email?: string; + order_id?: string; +} + +export interface ZarinPalPGNewArgs { + merchant_id: string; + amount: number; + description: string; + callback_url: string; + metadata?: MetaData; + currency?: "IRR" | "IRT"; +} + +interface IZarinPalPGRequestData { + code: number; + message: string; + authority: string; + fee_type: string; + fee: number; +} + +export interface ZarinPalPGNewRequestData { + data: IZarinPalPGRequestData; + error: string[]; +} + +interface IZarinpalPaymentVerifyData { + code: number; + message: string; + card_hash: string; + card_pan: string; + ref_id: number; + fee_type: string; + fee: number; +} + +export interface ZarinPalPGVerifyData { + data: IZarinpalPaymentVerifyData; + error: string[]; +} + +export interface PaymentVerificationResult { + status: PaymentStatus; + payment: Payment; + redirectUrl?: URL; + additionalParams?: Record; +} + +//********************************************** */ +//------------------abstract class---------------- + +export interface IPaymentGateway { + processPayment(processPaymentParam: IProcessPaymentParams): Promise; + verifyPayment(verifyPaymentParam: IPaymentVerifyParams): Promise; +} + +// export abstract class PaymentGateway { +// abstract processPayment(processPaymentParam: IProcessPaymentParams): Promise; +// abstract verifyPayment(verifyPaymentParam: IPaymentVerifyParams): Promise; +// } + +export interface IPaymentGatewayFactory { + getPaymentGateway(provider: GatewayType): IPaymentGateway; + getAvailablePaymentGateways(): { gateways: Array<{ name: GatewayEnum }> }; +} diff --git a/src/modules/payments/payments.controller.ts b/src/modules/payments/payments.controller.ts new file mode 100644 index 0000000..5d616bd --- /dev/null +++ b/src/modules/payments/payments.controller.ts @@ -0,0 +1,4 @@ +import { Controller } from "@nestjs/common"; + +@Controller("payments") +export class PaymentsController {} diff --git a/src/modules/payments/payments.module.ts b/src/modules/payments/payments.module.ts new file mode 100644 index 0000000..36c6b89 --- /dev/null +++ b/src/modules/payments/payments.module.ts @@ -0,0 +1,42 @@ +import { MikroOrmModule } from "@mikro-orm/nestjs"; +import { BullModule } from "@nestjs/bullmq"; +import { Module } from "@nestjs/common"; + +import { PAYMENT, ZARINPAL_CONFIG } from "./constants"; +import { PaymentGateway } from "./entities/payment-gateway.entity"; +import { Payment } from "./entities/payment.entity"; +import { PaymentGatewayFactory } from "./factories/payment.factory"; +import { ZarinpalGateway } from "./gateways/zarinpal.gateway"; +import { PaymentsController } from "./payments.controller"; +import { PaymentProcessor } from "./queue/payment.processor"; +import { PaymentsService } from "./services/payments.service"; +import { zarinpalConfig } from "../../configs/zarinpal.config"; +import { NotificationModule } from "../notifications/notifications.module"; + +@Module({ + imports: [ + MikroOrmModule.forFeature([Payment, PaymentGateway]), + BullModule.registerQueue({ + name: PAYMENT.QUEUE_NAME, + defaultJobOptions: { + removeOnComplete: true, + removeOnFail: false, + }, + }), + NotificationModule, + ], + controllers: [PaymentsController], + providers: [ + PaymentsService, + PaymentGatewayFactory, + ZarinpalGateway, + PaymentProcessor, + { + provide: ZARINPAL_CONFIG, + useFactory: zarinpalConfig().useFactory, + inject: zarinpalConfig().inject, + }, + ], + exports: [PaymentsService], +}) +export class PaymentsModule {} diff --git a/src/modules/payments/queue/payment.processor.ts b/src/modules/payments/queue/payment.processor.ts new file mode 100755 index 0000000..4eec744 --- /dev/null +++ b/src/modules/payments/queue/payment.processor.ts @@ -0,0 +1,249 @@ +import { EntityManager } from "@mikro-orm/postgresql"; +import { InjectQueue, Processor } from "@nestjs/bullmq"; +import { Logger } from "@nestjs/common"; +import { Job, Queue } from "bullmq"; +import Decimal from "decimal.js"; + +import { WorkerProcessor } from "../../../common/queues/worker.processor"; +import { NotificationQueue } from "../../notifications/queue/notification.queue"; +import { PAYMENT } from "../constants"; +import { Payment } from "../entities/payment.entity"; +import { PaymentStatus } from "../enums/payment-status.enum"; +import { PaymentGatewayFactory } from "../factories/payment.factory"; +import { PaymentsService } from "../services/payments.service"; +import { PaymentJobData } from "../types/payment-job.type"; + +@Processor(PAYMENT.QUEUE_NAME) +export class PaymentProcessor extends WorkerProcessor { + protected readonly logger = new Logger(PaymentProcessor.name); + + constructor( + @InjectQueue(PAYMENT.QUEUE_NAME) private readonly paymentQueue: Queue, + private readonly gatewayFactory: PaymentGatewayFactory, + private readonly em: EntityManager, + private readonly notificationQueue: NotificationQueue, + private readonly paymentsService: PaymentsService, + ) { + super(); + } + //=============================================== + async process(job: Job) { + this.logger.log(`Processing payment job: ${job.name}`); + + const em = this.em.fork(); + + try { + await em.begin(); + + const result = await this.processJobWithTransaction(job, em); + + await em.commit(); + return result; + } catch (error) { + this.logger.error(`Error processing payment job: ${error}`); + await em.rollback(); + throw error; + } + } + //=============================================== + + private async processJobWithTransaction(job: Job, em: EntityManager) { + switch (job.name) { + case PAYMENT.START: + return await this.handlePaymentStart(job as Job<{ payment: Payment }>, em); + case PAYMENT.FIRST_REMINDER: + return await this.handleFirstReminder(job as Job<{ paymentId: string }>, em); + case PAYMENT.SECOND_REMINDER: + return await this.handleSecondReminder(job as Job<{ paymentId: string }>, em); + case PAYMENT.CANCELLATION: + return await this.handlePaymentCancellation(job as Job<{ paymentId: string }>, em); + default: + this.logger.error(`Unknown job name: ${job.name}`); + return; + } + } + //=============================================== + + private async handlePaymentStart(job: Job<{ payment: Payment }>, _em: EntityManager) { + const { payment } = job.data; + + await this.scheduleFirstReminder(payment.id); + return payment; + } + //=============================================== + + private async handleFirstReminder(job: Job<{ paymentId: string }>, em: EntityManager) { + const payment = await this.fetchPayment(job.data.paymentId, em); + + const isPending = await this.isPaymentPending(payment, em); + + if (!isPending) { + this.logger.log(`Payment ${payment.id} is no longer pending, skipping reminder`); + return payment; + } + + await this.sendPaymentReminderNotification(payment, "first"); + await this.scheduleSecondReminder(payment.id); + + return payment; + } + //=============================================== + + private async handleSecondReminder(job: Job<{ paymentId: string }>, em: EntityManager) { + const payment = await this.fetchPayment(job.data.paymentId, em); + + const isPending = await this.isPaymentPending(payment, em); + + if (!isPending) { + this.logger.log(`Payment ${payment.id} is no longer pending, skipping reminder`); + return payment; + } + + await this.sendPaymentReminderNotification(payment, "second"); + await this.schedulePaymentCancellation(payment.id); + + return payment; + } + //=============================================== + + private async handlePaymentCancellation(job: Job<{ paymentId: string }>, em: EntityManager) { + const payment = await this.fetchPayment(job.data.paymentId, em); + + const isPending = await this.isPaymentPending(payment, em); + + if (!isPending) { + this.logger.log(`Payment ${payment.id} is no longer pending, skipping cancellation`); + return payment; + } + + await this.cancelPayment(payment, em); + + return payment; + } + //=============================================== + private async scheduleFirstReminder(paymentId: string) { + await this.paymentQueue.add( + PAYMENT.FIRST_REMINDER, + { paymentId }, + { + delay: PAYMENT.FIRST_REMINDER_DELAY, + priority: PAYMENT.JOB_PRIORITY, + attempts: PAYMENT.JOB_ATTEMPTS, + backoff: { type: "exponential", delay: PAYMENT.JOB_BACKOFF }, + }, + ); + } + //=============================================== + + private async scheduleSecondReminder(paymentId: string) { + await this.paymentQueue.add( + PAYMENT.SECOND_REMINDER, + { paymentId }, + { + delay: PAYMENT.SECOND_REMINDER_DELAY, + priority: PAYMENT.JOB_PRIORITY, + attempts: PAYMENT.JOB_ATTEMPTS, + backoff: { type: "exponential", delay: PAYMENT.JOB_BACKOFF }, + }, + ); + } + //=============================================== + + private async schedulePaymentCancellation(paymentId: string) { + await this.paymentQueue.add( + PAYMENT.CANCELLATION, + { paymentId }, + { + delay: PAYMENT.CANCELLATION_DELAY, + priority: PAYMENT.JOB_PRIORITY, + attempts: PAYMENT.JOB_ATTEMPTS, + backoff: { type: "exponential", delay: PAYMENT.JOB_BACKOFF }, + }, + ); + } + //=============================================== + + private async cancelPayment(payment: Payment, em: EntityManager) { + await em.nativeUpdate(Payment, { id: payment.id }, { status: PaymentStatus.CANCELLED }); + await this.sendPaymentCancellationNotification(payment); + } + + //=============================================== + + private async fetchPayment(paymentId: string, em: EntityManager): Promise { + const payment = await em.findOne( + Payment, + { + id: paymentId, + }, + { + populate: ["user", "paymentGateway"], + }, + ); + + if (!payment) throw new Error(`Payment not found: ${paymentId}`); + + return payment; + } + //=============================================== + + private async isPaymentPending(payment: Payment, em: EntityManager): Promise { + // 1. Trust DB first + if (payment.status !== PaymentStatus.PENDING) { + this.logger.log(`Payment ${payment.id} is not in pending status`); + return false; + } + + this.logger.log(`Verifying pending status for payment ${payment.id}`); + + try { + const gateway = this.gatewayFactory.getPaymentGateway(payment.paymentGateway.name); + const verifyData = await gateway.verifyPayment({ reference: payment.reference, amount: payment.amount }); + + const result = await this.paymentsService.processVerificationResult(verifyData, payment, em); + + // If still pending or failed, return true (will trigger reminder/cancellation as needed) + if (result.status === PaymentStatus.PENDING || result.status === PaymentStatus.FAILED || result.status === PaymentStatus.CANCELLED) { + this.logger.log(`Payment ${payment.id} is still pending or failed or cancelled (gateway code: ${verifyData.code})`); + return true; + } + + if (result.status === PaymentStatus.COMPLETED) { + this.logger.log(`Payment ${payment.id} is completed (gateway code: ${verifyData.code})`); + return false; + } + + // For any other code, treat as not pending + this.logger.log(`Payment ${payment.id} gateway returned code ${verifyData.code}, treating as not pending`); + return false; + } catch (error) { + this.logger.error(`Error verifying payment ${payment.id}: ${error instanceof Error ? error.message : error}`); + // On error, assume still pending to avoid false negatives + return true; + } + } + + //=============================================== + private async sendPaymentReminderNotification(payment: Payment, reminderType: "first" | "second") { + this.logger.log(`Sending ${reminderType} payment reminder for payment ${payment.id}`); + + await this.notificationQueue.addPaymentReminderNotification(payment.user.id, { + paymentId: payment.id, + amount: new Decimal(payment.amount).toNumber(), + userPhone: payment.user.phone, + userEmail: payment.user.email, + }); + } + //=============================================== + + private async sendPaymentCancellationNotification(payment: Payment) { + this.logger.log(`Sending payment cancellation notification for payment ${payment.id}`); + + await this.notificationQueue.addPaymentCancellationNotification(payment.user.id, { + paymentId: payment.id, + amount: new Decimal(payment.amount).toNumber(), + userPhone: payment.user.phone, + userEmail: payment.user.email, + }); + } +} diff --git a/src/modules/payments/repositories/payment-gateway.repository.ts b/src/modules/payments/repositories/payment-gateway.repository.ts new file mode 100755 index 0000000..20177fb --- /dev/null +++ b/src/modules/payments/repositories/payment-gateway.repository.ts @@ -0,0 +1,5 @@ +import { EntityRepository } from "@mikro-orm/core"; + +import { PaymentGateway } from "../entities/payment-gateway.entity"; + +export class PaymentGatewaysRepository extends EntityRepository {} diff --git a/src/modules/payments/repositories/payment.repository.ts b/src/modules/payments/repositories/payment.repository.ts new file mode 100755 index 0000000..1940d6b --- /dev/null +++ b/src/modules/payments/repositories/payment.repository.ts @@ -0,0 +1,9 @@ +import { EntityRepository } from "@mikro-orm/core"; + +import { Payment } from "../entities/payment.entity"; + +export class PaymentRepository extends EntityRepository { + async findOneWithReference(reference: string): Promise { + return this.findOne({ reference }, { populate: ["user"] }); + } +} diff --git a/src/modules/payments/services/payments.service.ts b/src/modules/payments/services/payments.service.ts new file mode 100644 index 0000000..5f71410 --- /dev/null +++ b/src/modules/payments/services/payments.service.ts @@ -0,0 +1,249 @@ +import { LockMode } from "@mikro-orm/core"; +import { EntityManager } from "@mikro-orm/postgresql"; +import { InjectQueue } from "@nestjs/bullmq"; +import { BadRequestException, HttpStatus, Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Queue } from "bullmq"; +import { Decimal } from "decimal.js"; +import { FastifyReply as FRply } from "fastify"; + +import { PaymentMessage } from "../../../common/enums/message.enum"; +import { Business } from "../../businesses/entities/business.entity"; +import { Invoice } from "../../invoices/entities/invoice.entity"; +import { User } from "../../users/entities/user.entity"; +import { PAYMENT } from "../constants"; +import { VerifyParamDto, VerifyQueryDto } from "../DTO/verify-payment.dto"; +import { PaymentGateway } from "../entities/payment-gateway.entity"; +import { Payment } from "../entities/payment.entity"; +import { PaymentStatus } from "../enums/payment-status.enum"; +import { PaymentGatewayFactory } from "../factories/payment.factory"; +import { IProcessPaymentParams, IVerifyPayment } from "../interfaces/IPayment"; +import { PaymentGatewaysRepository } from "../repositories/payment-gateway.repository"; +import { PaymentRepository } from "../repositories/payment.repository"; +import { GatewayType } from "../types/gateway.type"; + +@Injectable() +export class PaymentsService { + private readonly logger = new Logger(PaymentsService.name); + + constructor( + @InjectQueue(PAYMENT.QUEUE_NAME) private readonly paymentQueue: Queue, + private readonly configService: ConfigService, + private readonly gatewayFactory: PaymentGatewayFactory, + private readonly paymentGatewaysRepository: PaymentGatewaysRepository, + private readonly paymentsRepository: PaymentRepository, + private readonly em: EntityManager, + ) {} + + //=============================================== + + async processPayment(provider: GatewayType, params: IProcessPaymentParams) { + const paymentGateway = this.gatewayFactory.getPaymentGateway(provider); + return paymentGateway.processPayment(params); + } + //=============================================== + + async verifyPayment(verifyParamDto: VerifyParamDto, queryDto: VerifyQueryDto, rep: FRply) { + const frontUrl = this.buildFrontendRedirectUrl(verifyParamDto.invoiceId); + + const em = this.em.fork(); + + try { + await em.begin(); + + const paymentGateway = this.gatewayFactory.getPaymentGateway(verifyParamDto.gateway); + const payment = await this.getPaymentByReference(queryDto.Authority, em); + + if (payment.status === PaymentStatus.COMPLETED) throw new BadRequestException(PaymentMessage.VALIDATED_BEFORE); + + if (queryDto.Status !== "OK") await this.handleFailedPaymentRedirect(payment, em, rep, frontUrl); + + const verifyData = await paymentGateway.verifyPayment({ + reference: queryDto.Authority, + amount: payment.amount, + }); + + return await this.handlePaymentVerificationWithResponse(verifyData, payment, rep, frontUrl, em); + } catch (error) { + await em.rollback(); + throw error; + } + } + //=============================================== + + async handlePaymentVerificationWithResponse(verifyData: IVerifyPayment, payment: Payment, rp: FRply, frontUrl: URL, em: EntityManager) { + const result = await this.processVerificationResult(verifyData, payment, em); + + this.addPaymentParamsToUrl(frontUrl, result.payment, result.status); + + if ("additionalParams" in result) { + Object.entries(result.additionalParams).forEach(([key, value]) => { + frontUrl.searchParams.append(key, String(value)); + }); + } + + await em.commit(); + + return rp.status(HttpStatus.FOUND).redirect(frontUrl.toString()); + } + //=============================================== + + async processVerificationResult(verifyData: IVerifyPayment, payment: Payment, em: EntityManager) { + if (verifyData.code === 100) { + return this.processSuccessfulVerification(payment, verifyData.ref_id, em); + } else if (verifyData.code === 101) { + // + const freshPayment = await em.findOne(Payment, { id: payment.id }); + // + if (freshPayment && freshPayment.status === PaymentStatus.COMPLETED) { + this.logger.log(`Payment ${payment.id} already marked as COMPLETED in DB (code 101)`); + return { status: payment.status, payment }; + } else { + this.logger.log(`Payment ${payment.id} marked as COMPLETED after gateway verification (code 101)`); + return await this.processSuccessfulVerification(payment, verifyData.ref_id, em); + } + // + } else { + await this.handleFailedPayment(payment.id, em); + return { status: payment.status, payment }; + } + } + //=============================================== + private async processSuccessfulVerification(payment: Payment, refId: number, em: EntityManager) { + // Regular wallet charge + await this.handleSuccessfulPayment(payment, refId, em); + + const additionalParams: Record = {}; + + return { + status: PaymentStatus.COMPLETED, + payment, + additionalParams, + }; + } + //=============================================== + + async createGatewayPaymentForUser( + usr: User, + amt: number, + ref_Id: string, + gatewayId: string, + businessId: 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), + invoice: em.getReference(Invoice, invoiceId), + }); + + await em.persistAndFlush(payment); + this.logger.log(`Created payment ${payment.id} with reference ${ref_Id}`); + + await this.addPaymentReminderToQueue(payment); + return payment; + } + //=============================================== + async getAvailableGateways() { + const paymentGateways = await this.paymentGatewaysRepository.find({ isActive: true, deletedAt: null }); + return { paymentGateways }; + } + + //=============================================== + + async getPaymentWithReference(reference: string) { + const payment = await this.paymentsRepository.findOneWithReference(reference); + if (!payment) throw new BadRequestException(PaymentMessage.PAYMENT_NOT_FOUND_WITH_REF); + + return { payment }; + } + + //=============================================== + async handleFailedPayment(paymentId: string, em: EntityManager) { + await em.nativeUpdate(Payment, { id: paymentId }, { status: PaymentStatus.CANCELLED }); + } + + //=============================================== + async handleSuccessfulPayment(payment: Payment, transactionId: number, em: EntityManager) { + await em.nativeUpdate(Payment, { id: payment.id }, { status: PaymentStatus.COMPLETED, transactionId: transactionId.toString() }); + + await this.removePaymentReminderJobs(payment.id); + } + + //---------------------------------------- + //-private methods + //---------------------------------------- + + private buildFrontendRedirectUrl(invoiceId?: string): URL { + const frontUrl = new URL(this.configService.getOrThrow("SITE_URL")); + if (invoiceId) { + frontUrl.pathname = "/receipts/" + invoiceId; + } else { + frontUrl.pathname = "/payment"; + } + return frontUrl; + } + + //=============================================== + + private async handleFailedPaymentRedirect(payment: Payment, em: EntityManager, rep: FRply, frontUrl: URL) { + await this.handleFailedPayment(payment.id, em); + await em.commit(); + + this.addPaymentParamsToUrl(frontUrl, payment, PaymentStatus.FAILED); + + return rep.status(HttpStatus.FOUND).redirect(frontUrl.toString()); + } + + //=============================================== + private addPaymentParamsToUrl(url: URL, payment: Payment, status: PaymentStatus) { + url.searchParams.append("status", status); + url.searchParams.append("id", payment.id); + url.searchParams.append("date", payment.createdAt.toISOString()); + url.searchParams.append("amount", payment.amount.toString()); + } + + //=============================================== + + private async getPaymentByReference(reference: string, em: EntityManager): Promise { + const payment = await em.findOne(Payment, { reference }, { populate: ["user"], lockMode: LockMode.PESSIMISTIC_WRITE }); + if (!payment) throw new BadRequestException(PaymentMessage.PAYMENT_NOT_FOUND_WITH_REF); + return payment; + } + + //=============================================== + private async addPaymentReminderToQueue(payment: Payment) { + this.paymentQueue.add( + PAYMENT.START, + { payment }, + { + delay: PAYMENT.START_DELAY, + priority: PAYMENT.JOB_PRIORITY, + attempts: PAYMENT.JOB_ATTEMPTS, + backoff: { type: "exponential", delay: PAYMENT.JOB_BACKOFF }, + }, + ); + } + + //=============================================== + /** + * Remove all scheduled/delayed payment jobs for a paymentId from the queue + */ + private async removePaymentReminderJobs(paymentId: string) { + const jobs = await this.paymentQueue.getJobs(["delayed", "waiting"]); + for (const job of jobs) { + if ( + (job.name === PAYMENT.FIRST_REMINDER || job.name === PAYMENT.SECOND_REMINDER || job.name === PAYMENT.CANCELLATION) && + job.data && + job.data.paymentId === paymentId + ) { + await job.remove(); + this.logger.log(`Removed scheduled job ${job.name} for payment ${paymentId}`); + } + } + } +} diff --git a/src/modules/payments/types/gateway.type.ts b/src/modules/payments/types/gateway.type.ts new file mode 100755 index 0000000..856119c --- /dev/null +++ b/src/modules/payments/types/gateway.type.ts @@ -0,0 +1 @@ +export type GatewayType = "zarinpal"; diff --git a/src/modules/payments/types/payment-job.type.ts b/src/modules/payments/types/payment-job.type.ts new file mode 100644 index 0000000..a029ffd --- /dev/null +++ b/src/modules/payments/types/payment-job.type.ts @@ -0,0 +1,3 @@ +import { Payment } from "../entities/payment.entity"; + +export type PaymentJobData = { payment: Payment } | { paymentId: string }; diff --git a/src/modules/payments/types/payment-status.type.ts b/src/modules/payments/types/payment-status.type.ts new file mode 100755 index 0000000..b9d2848 --- /dev/null +++ b/src/modules/payments/types/payment-status.type.ts @@ -0,0 +1 @@ +export type GatewayPaymentStatus = "OK" | "NOK"; diff --git a/src/modules/tickets/entities/ticket.entity.ts b/src/modules/tickets/entities/ticket.entity.ts index 2e799a8..651f0df 100755 --- a/src/modules/tickets/entities/ticket.entity.ts +++ b/src/modules/tickets/entities/ticket.entity.ts @@ -12,7 +12,7 @@ import { TicketsRepository } from "../repositories/tickets.repository"; @Entity({ repository: () => TicketsRepository }) export class Ticket extends BaseEntity { - @Property({ type: "int", autoincrement: true, persist: false }) + @Property({ type: "int", autoincrement: true }) numericId!: number & Opt; @Property({ type: "varchar", length: 150 }) diff --git a/src/modules/tickets/providers/tickets.service.ts b/src/modules/tickets/providers/tickets.service.ts index bcdbda6..5b69852 100755 --- a/src/modules/tickets/providers/tickets.service.ts +++ b/src/modules/tickets/providers/tickets.service.ts @@ -149,6 +149,7 @@ export class TicketsService { const attachments = createDto.attachmentUrls.map((url) => { const attachment = new TicketMessageAttachment(); attachment.attachmentUrl = url; + attachment.ticketMessage = ticketMessage; return attachment; }); ticketMessage.attachments = new Collection(ticketMessage, attachments);