chore: payment

This commit is contained in:
Mahyargdz
2025-05-19 16:15:15 +03:30
parent f755a3343a
commit 9afe89295c
29 changed files with 1048 additions and 26 deletions
+2 -6
View File
@@ -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) {
@@ -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) {
@@ -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" } },
);
@@ -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<InvoiceItem>(this);
//-----------------------------------
@ManyToOne(() => Business, { nullable: false, deleteRule: "cascade" })
business!: Business;
//-----------------------------------
@OneToMany(() => InvoiceItem, (item) => item.invoice, { cascade: [Cascade.PERSIST, Cascade.REMOVE] })
items = new Collection<InvoiceItem>(this);
@OneToMany(() => Payment, (payment) => payment.invoice, { cascade: [Cascade.PERSIST, Cascade.REMOVE] })
payments = new Collection<Payment>(this);
[EntityRepositoryType]?: InvoicesRepository;
}
@@ -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<Invoice> = {
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,
+42
View File
@@ -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;
}
+17
View File
@@ -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;
}
+27
View File
@@ -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;
}
+23
View File
@@ -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;
}
+19
View File
@@ -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
});
+25
View File
@@ -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;
}
+43
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
export enum GatewayEnum {
ZARINPAL = "zarinpal",
}
+6
View File
@@ -0,0 +1,6 @@
export enum PaymentStatus {
PENDING = "PENDING",
COMPLETED = "COMPLETED",
FAILED = "FAILED",
CANCELLED = "CANCELLED",
}
+5
View File
@@ -0,0 +1,5 @@
export enum TransferType {
CARD_TO_CARD = "CARD_TO_CARD",
SHEBA = "SHEBA",
// GATEWAY = "GATEWAY",
}
+34
View File
@@ -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<GatewayType, IPaymentGateway>;
constructor(private readonly zarinpalGateway: ZarinpalGateway) {
this.gateways = new Map<GatewayType, IPaymentGateway>();
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 };
}
}
+99
View File
@@ -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<string, string> = { "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<ZarinPalPGNewRequestData>(`${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<ZarinPalPGVerifyData>(`${this.gatewayApiUrl}/v4/payment/verify.json`, verifyData, { headers: this.requestHeader })
.pipe(
catchError((err: AxiosError) => {
this.logger.error(err);
throw new InternalServerErrorException(PaymentMessage.ERROR_IN_VERIFY_PAYMENT);
}),
),
);
return {
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);
}
}
}
+105
View File
@@ -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<string, string>;
}
//********************************************** */
//------------------abstract class----------------
export interface IPaymentGateway {
processPayment(processPaymentParam: IProcessPaymentParams): Promise<IProcessPaymentData>;
verifyPayment(verifyPaymentParam: IPaymentVerifyParams): Promise<IVerifyPayment>;
}
// export abstract class PaymentGateway {
// abstract processPayment(processPaymentParam: IProcessPaymentParams): Promise<IProcessPaymentData>;
// abstract verifyPayment(verifyPaymentParam: IPaymentVerifyParams): Promise<IPaymentVerifyData>;
// }
export interface IPaymentGatewayFactory {
getPaymentGateway(provider: GatewayType): IPaymentGateway;
getAvailablePaymentGateways(): { gateways: Array<{ name: GatewayEnum }> };
}
@@ -0,0 +1,4 @@
import { Controller } from "@nestjs/common";
@Controller("payments")
export class PaymentsController {}
+42
View File
@@ -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 {}
+249
View File
@@ -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<PaymentJobData>) {
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<PaymentJobData>, 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<Payment> {
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<boolean> {
// 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,
});
}
}
@@ -0,0 +1,5 @@
import { EntityRepository } from "@mikro-orm/core";
import { PaymentGateway } from "../entities/payment-gateway.entity";
export class PaymentGatewaysRepository extends EntityRepository<PaymentGateway> {}
+9
View File
@@ -0,0 +1,9 @@
import { EntityRepository } from "@mikro-orm/core";
import { Payment } from "../entities/payment.entity";
export class PaymentRepository extends EntityRepository<Payment> {
async findOneWithReference(reference: string): Promise<Payment | null> {
return this.findOne({ reference }, { populate: ["user"] });
}
}
@@ -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<string, string> = {};
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<string>("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<Payment> {
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}`);
}
}
}
}
+1
View File
@@ -0,0 +1 @@
export type GatewayType = "zarinpal";
@@ -0,0 +1,3 @@
import { Payment } from "../entities/payment.entity";
export type PaymentJobData = { payment: Payment } | { paymentId: string };
+1
View File
@@ -0,0 +1 @@
export type GatewayPaymentStatus = "OK" | "NOK";
@@ -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 })
@@ -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<TicketMessageAttachment>(ticketMessage, attachments);