chore: payment
This commit is contained in:
+2
-6
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Executable
+19
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
export enum GatewayEnum {
|
||||
ZARINPAL = "zarinpal",
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export enum PaymentStatus {
|
||||
PENDING = "PENDING",
|
||||
COMPLETED = "COMPLETED",
|
||||
FAILED = "FAILED",
|
||||
CANCELLED = "CANCELLED",
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export enum TransferType {
|
||||
CARD_TO_CARD = "CARD_TO_CARD",
|
||||
SHEBA = "SHEBA",
|
||||
// GATEWAY = "GATEWAY",
|
||||
}
|
||||
+34
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+105
@@ -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 {}
|
||||
@@ -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
@@ -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> {}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+1
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user