From dba7cec544756e47a05a3defaa398c0431f870bb Mon Sep 17 00:00:00 2001 From: mahyargdz Date: Thu, 5 Jun 2025 15:27:04 +0330 Subject: [PATCH] chore: change the payment logic --- ...24463575-changeEnumValueOfPaymentStatus.ts | 27 +++ src/configs/zarinpal.config.ts | 4 - .../payments/enums/payment-status.enum.ts | 1 - .../enums/zarinpal-error-codes.enum.ts | 38 +++++ .../payments/gateways/zarinpal.gateway.ts | 154 +++++++++++++----- src/modules/payments/interfaces/IPayment.ts | 16 +- .../payments/providers/payments.service.ts | 50 ++++-- .../payments/queue/payment.processor.ts | 41 ++++- 8 files changed, 259 insertions(+), 72 deletions(-) create mode 100644 database/migrations/1749124463575-changeEnumValueOfPaymentStatus.ts create mode 100644 src/modules/payments/enums/zarinpal-error-codes.enum.ts diff --git a/database/migrations/1749124463575-changeEnumValueOfPaymentStatus.ts b/database/migrations/1749124463575-changeEnumValueOfPaymentStatus.ts new file mode 100644 index 0000000..da16aca --- /dev/null +++ b/database/migrations/1749124463575-changeEnumValueOfPaymentStatus.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ChangeEnumValueOfPaymentStatus1749124463575 implements MigrationInterface { + name = "ChangeEnumValueOfPaymentStatus1749124463575"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TYPE "public"."payment_status_enum" RENAME TO "payment_status_enum_old"`); + await queryRunner.query(`CREATE TYPE "public"."payment_status_enum" AS ENUM('PENDING', 'COMPLETED', 'CANCELLED')`); + await queryRunner.query(`ALTER TABLE "payment" ALTER COLUMN "status" DROP DEFAULT`); + await queryRunner.query( + `ALTER TABLE "payment" ALTER COLUMN "status" TYPE "public"."payment_status_enum" USING "status"::"text"::"public"."payment_status_enum"`, + ); + await queryRunner.query(`ALTER TABLE "payment" ALTER COLUMN "status" SET DEFAULT 'PENDING'`); + await queryRunner.query(`DROP TYPE "public"."payment_status_enum_old"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "public"."payment_status_enum_old" AS ENUM('CANCELLED', 'COMPLETED', 'FAILED', 'PENDING')`); + await queryRunner.query(`ALTER TABLE "payment" ALTER COLUMN "status" DROP DEFAULT`); + await queryRunner.query( + `ALTER TABLE "payment" ALTER COLUMN "status" TYPE "public"."payment_status_enum_old" USING "status"::"text"::"public"."payment_status_enum_old"`, + ); + await queryRunner.query(`ALTER TABLE "payment" ALTER COLUMN "status" SET DEFAULT 'PENDING'`); + await queryRunner.query(`DROP TYPE "public"."payment_status_enum"`); + await queryRunner.query(`ALTER TYPE "public"."payment_status_enum_old" RENAME TO "payment_status_enum"`); + } +} diff --git a/src/configs/zarinpal.config.ts b/src/configs/zarinpal.config.ts index 63e2d5c..38b6110 100755 --- a/src/configs/zarinpal.config.ts +++ b/src/configs/zarinpal.config.ts @@ -5,16 +5,12 @@ export function zarinpalConfig() { inject: [ConfigService], useFactory: (configService: ConfigService) => ({ merchantId: configService.getOrThrow("ZARINPAL_MERCHANT_ID"), - gatewayApiUrl: configService.getOrThrow("ZARINPAL_API_URL"), callBackUrl: configService.getOrThrow("CALLBACK_URL"), - ipgType: configService.getOrThrow("IPG_TYPE"), }), }; } export interface IZarinpalConfig { merchantId: string; - gatewayApiUrl: string; callBackUrl: string; - ipgType: string; } diff --git a/src/modules/payments/enums/payment-status.enum.ts b/src/modules/payments/enums/payment-status.enum.ts index 4768a53..ea9725e 100755 --- a/src/modules/payments/enums/payment-status.enum.ts +++ b/src/modules/payments/enums/payment-status.enum.ts @@ -1,6 +1,5 @@ export enum PaymentStatus { PENDING = "PENDING", COMPLETED = "COMPLETED", - FAILED = "FAILED", CANCELLED = "CANCELLED", } diff --git a/src/modules/payments/enums/zarinpal-error-codes.enum.ts b/src/modules/payments/enums/zarinpal-error-codes.enum.ts new file mode 100644 index 0000000..bf2f278 --- /dev/null +++ b/src/modules/payments/enums/zarinpal-error-codes.enum.ts @@ -0,0 +1,38 @@ +export enum ZarinpalErrorCode { + // Success codes + SUCCESS = 100, + ALREADY_VERIFIED = 101, + + // Error codes + MERCHANT_NOT_FOUND = -9, + MERCHANT_INACTIVE = -10, + MERCHANT_INVALID = -11, + AMOUNT_INVALID = -12, + DESCRIPTION_INVALID = -15, + CALLBACK_URL_INVALID = -16, + CURRENCY_NOT_SUPPORTED = -17, + TRANSACTION_FAILED = -22, + AMOUNT_TOO_LOW = -33, + AMOUNT_TOO_HIGH = -34, + AUTHORITY_INVALID = -50, + SESSION_INVALID = -51, + PAYMENT_NOT_FOUND = -54, +} + +export const ZarinpalErrorMessages: Record = { + [ZarinpalErrorCode.SUCCESS]: "Payment successful", + [ZarinpalErrorCode.ALREADY_VERIFIED]: "Payment already verified", + [ZarinpalErrorCode.MERCHANT_NOT_FOUND]: "Merchant not found", + [ZarinpalErrorCode.MERCHANT_INACTIVE]: "Merchant inactive", + [ZarinpalErrorCode.MERCHANT_INVALID]: "Merchant invalid", + [ZarinpalErrorCode.AMOUNT_INVALID]: "Amount invalid", + [ZarinpalErrorCode.DESCRIPTION_INVALID]: "Description invalid", + [ZarinpalErrorCode.CALLBACK_URL_INVALID]: "Callback URL invalid", + [ZarinpalErrorCode.CURRENCY_NOT_SUPPORTED]: "Currency not supported", + [ZarinpalErrorCode.TRANSACTION_FAILED]: "Transaction failed", + [ZarinpalErrorCode.AMOUNT_TOO_LOW]: "Amount too low", + [ZarinpalErrorCode.AMOUNT_TOO_HIGH]: "Amount too high", + [ZarinpalErrorCode.AUTHORITY_INVALID]: "Authority invalid", + [ZarinpalErrorCode.SESSION_INVALID]: "Session is not valid, session is not active paid try", + [ZarinpalErrorCode.PAYMENT_NOT_FOUND]: "Payment not found", +}; diff --git a/src/modules/payments/gateways/zarinpal.gateway.ts b/src/modules/payments/gateways/zarinpal.gateway.ts index 1773ffa..b7a5b8e 100755 --- a/src/modules/payments/gateways/zarinpal.gateway.ts +++ b/src/modules/payments/gateways/zarinpal.gateway.ts @@ -1,7 +1,7 @@ import { HttpService } from "@nestjs/axios"; import { Inject, Injectable, InternalServerErrorException, Logger } from "@nestjs/common"; import { AxiosError } from "axios"; -import { catchError, firstValueFrom } from "rxjs"; +import { catchError, firstValueFrom, throwError } from "rxjs"; import { PaymentMessage } from "../../../common/enums/message.enum"; import { IZarinpalConfig } from "../../../configs/zarinpal.config"; @@ -18,6 +18,7 @@ import { @Injectable() export class ZarinpalGateway implements IPaymentGateway { + private readonly IPG_TYPE = "payment"; private readonly logger = new Logger(ZarinpalGateway.name); private readonly gatewayApiUrl: string; private readonly requestHeader: Record = { "Content-Type": "application/json", Accept: "application/json" }; @@ -26,73 +27,140 @@ export class ZarinpalGateway implements IPaymentGateway { @Inject(ZARINPAL_CONFIG) private readonly config: IZarinpalConfig, private readonly httpService: HttpService, ) { - this.gatewayApiUrl = `https://${this.config.ipgType}.${this.config.gatewayApiUrl}`; + this.gatewayApiUrl = `https://${this.IPG_TYPE}.zarinpal.com`; } - //*************************************** */ + 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 : ""}`, + callback_url: `${this.config.callBackUrl}/${GatewayEnum.ZARINPAL}`, description: processParams.description, currency: "IRT", metadata: { email: processParams.email, mobile: processParams.mobile }, }; + this.logger.log(`Processing payment request:`, { + merchant_id: this.config.merchantId, + amount: processParams.amount, + callback_url: `${this.config.callBackUrl}/${GatewayEnum.ZARINPAL}`, + description: processParams.description, + }); + const { data } = await firstValueFrom( this.httpService - .post(`${this.gatewayApiUrl}/v4/payment/request.json`, purchaseData, { + .post(`${this.gatewayApiUrl}/pg/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); + this.logger.error(`Payment request failed: ${err.message}`, err.stack); + return throwError(() => new InternalServerErrorException(PaymentMessage.ERROR_IN_PROCESS_PAYMENT)); }), ), ); + // Check for errors in response + if (data.errors && data.errors.length > 0) { + this.logger.error(`Zarinpal payment request error:`, data.errors); + throw new InternalServerErrorException(PaymentMessage.ERROR_IN_PROCESS_PAYMENT); + } + + // Validate response data + if (!data.data?.authority) { + this.logger.error(`Invalid response from Zarinpal:`, data); + throw new InternalServerErrorException(PaymentMessage.ERROR_IN_PROCESS_PAYMENT); + } + + this.logger.log(`Payment request successful - Authority: ${data.data.authority}`); + return { - code: data.data.code, + redirectUrl: `${this.gatewayApiUrl}/pg/StartPay/${data.data.authority}`, 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, + reference: data.data.authority, }; } catch (error) { - console.error({ error }); - this.logger.error(error); + this.logger.error(`Payment processing error:`, error); + throw new InternalServerErrorException(PaymentMessage.ERROR_IN_PROCESS_PAYMENT); + } + } + + async verifyPayment(verifyParams: IPaymentVerifyParams) { + const verifyData = { + merchant_id: this.config.merchantId, + amount: Number(verifyParams.amount), + authority: verifyParams.reference, + }; + + this.logger.log(`Verifying payment:`, { + merchant_id: this.config.merchantId, + amount: Number(verifyParams.amount), + authority: verifyParams.reference, + }); + + try { + const { data } = await firstValueFrom( + this.httpService + .post(`${this.gatewayApiUrl}/pg/v4/payment/verify.json`, verifyData, { + headers: this.requestHeader, + }) + .pipe( + catchError((err: AxiosError) => { + this.logger.error(`Verification request failed: ${err.message}`, err.stack); + + // If we have error response data from Zarinpal, extract it + if (err.response?.data) { + const errorData = err.response.data as any; + this.logger.error(`Zarinpal error response:`, errorData); + + // Return the Zarinpal error response if it has the expected structure + if (errorData.errors) { + return throwError(() => errorData); + } + } + + return throwError(() => new InternalServerErrorException(PaymentMessage.ERROR_IN_VERIFY_PAYMENT)); + }), + ), + ); + + // Log the verification result + if (data.data) { + this.logger.log(`Verification response - Code: ${data.data.code}, RefID: ${data.data.ref_id || "N/A"}`); + } else if (data.errors && data.errors.length > 0) { + this.logger.warn(`Verification error - Code: ${data.errors[0].code}, Message: ${data.errors[0].message}`); + } + + // Return the complete Zarinpal response - let the service handle the business logic + return { + code: data.data?.code || data.errors?.[0]?.code || -1, + message: data.data?.message || data.errors?.[0]?.message || "Unknown error", + ref_id: data.data?.ref_id || 0, + card_hash: data.data?.card_hash || "", + card_pan: data.data?.card_pan || "", + fee_type: data.data?.fee_type || "", + fee: data.data?.fee || 0, + }; + } catch (error) { + // If it's a Zarinpal error response, extract the error code and return it + if (error && typeof error === "object" && "errors" in error) { + const zarinpalError = error as any; + const firstError = zarinpalError.errors[0]; + this.logger.warn(`Zarinpal verification error - Code: ${firstError.code}, Message: ${firstError.message}`); + + return { + code: firstError.code, + message: firstError.message, + ref_id: 0, + card_hash: "", + card_pan: "", + fee_type: "", + fee: 0, + }; + } + + this.logger.error(`Payment verification 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 index fccd737..5646603 100755 --- a/src/modules/payments/interfaces/IPayment.ts +++ b/src/modules/payments/interfaces/IPayment.ts @@ -27,6 +27,11 @@ export interface IProcessPaymentData { export interface IVerifyPayment { code: number; ref_id: number; + message?: string; + card_hash?: string; + card_pan?: string; + fee_type?: string; + fee?: number; } export interface IPaymentVerifyParams { @@ -59,9 +64,16 @@ interface IZarinPalPGRequestData { fee: number; } +// Zarinpal error structure +interface ZarinpalError { + code: number; + message: string; + validations?: any[]; +} + export interface ZarinPalPGNewRequestData { data: IZarinPalPGRequestData; - error: string[]; + errors: ZarinpalError[]; } interface IZarinpalPaymentVerifyData { @@ -76,7 +88,7 @@ interface IZarinpalPaymentVerifyData { export interface ZarinPalPGVerifyData { data: IZarinpalPaymentVerifyData; - error: string[]; + errors: ZarinpalError[]; } export interface PaymentVerificationResult { diff --git a/src/modules/payments/providers/payments.service.ts b/src/modules/payments/providers/payments.service.ts index 490f40b..83b3681 100755 --- a/src/modules/payments/providers/payments.service.ts +++ b/src/modules/payments/providers/payments.service.ts @@ -31,6 +31,7 @@ import { Payment } from "../entities/payment.entity"; import { DepositRequestStatus } from "../enums/deposit-request-status.enum"; import { PaymentStatus } from "../enums/payment-status.enum"; import { TransferType } from "../enums/payment-type.enum"; +import { ZarinpalErrorCode } from "../enums/zarinpal-error-codes.enum"; import { PaymentGatewayFactory } from "../factories/payment.factory"; import { IProcessPaymentParams, IVerifyPayment } from "../interfaces/IPayment"; import { BankAccountsRepository } from "../repositories/bank-accounts.repository"; @@ -138,7 +139,9 @@ export class PaymentsService { if (payment.status === PaymentStatus.COMPLETED) throw new BadRequestException(PaymentMessage.VALIDATED_BEFORE); - if (queryDto.Status !== "OK") await this.handleFailedPaymentRedirect(payment, queryRunner, rep, frontUrl); + if (queryDto.Status !== "OK") { + return await this.handleFailedPaymentRedirect(payment, queryRunner, rep, frontUrl); + } const verifyData = await paymentGateway.verifyPayment({ reference: queryDto.Authority, @@ -173,22 +176,37 @@ export class PaymentsService { //=============================================== async processVerificationResult(verifyData: IVerifyPayment, payment: Payment, qryRnr: QueryRunner) { - if (verifyData.code === 100) { - return this.processSuccessfulVerification(payment, verifyData.ref_id, qryRnr); - } else if (verifyData.code === 101) { - const freshPayment = await qryRnr.manager.findOne(Payment, { where: { 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)`); + switch (verifyData.code) { + case ZarinpalErrorCode.SUCCESS: + this.logger.log(`Payment ${payment.id} verification successful (code 100)`); return await this.processSuccessfulVerification(payment, verifyData.ref_id, qryRnr); + + case ZarinpalErrorCode.ALREADY_VERIFIED: { + const freshPayment = await qryRnr.manager.findOne(Payment, { where: { 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, qryRnr); + } } - // - } else { - await this.handleFailedPayment(payment.id, qryRnr); - return { status: payment.status, payment }; + + case ZarinpalErrorCode.SESSION_INVALID: + this.logger.warn(`Payment ${payment.id} has invalid/expired session (code -51), marking as failed`); + await this.handleFailedPayment(payment.id, qryRnr); + return { status: PaymentStatus.CANCELLED, payment }; + + case ZarinpalErrorCode.PAYMENT_NOT_FOUND: + this.logger.warn(`Payment ${payment.id} not found in gateway (code -54), marking as failed`); + await this.handleFailedPayment(payment.id, qryRnr); + return { status: PaymentStatus.CANCELLED, payment }; + + default: + // For all other error codes, mark as failed + this.logger.warn(`Payment ${payment.id} verification failed with code ${verifyData.code}, marking as failed`); + await this.handleFailedPayment(payment.id, qryRnr); + return { status: PaymentStatus.CANCELLED, payment }; } } //=============================================== @@ -475,7 +493,7 @@ export class PaymentsService { await this.handleFailedPayment(payment.id, queryRunner); await queryRunner.commitTransaction(); - this.addPaymentParamsToUrl(frontUrl, payment, PaymentStatus.FAILED); + this.addPaymentParamsToUrl(frontUrl, payment, PaymentStatus.CANCELLED); return rep.status(HttpStatus.FOUND).redirect(frontUrl.toString()); } diff --git a/src/modules/payments/queue/payment.processor.ts b/src/modules/payments/queue/payment.processor.ts index 9568c08..e39448e 100755 --- a/src/modules/payments/queue/payment.processor.ts +++ b/src/modules/payments/queue/payment.processor.ts @@ -77,6 +77,13 @@ export class PaymentProcessor extends WorkerProcessor { private async handleFirstReminder(job: Job<{ paymentId: string }>, queryRunner: QueryRunner) { const payment = await this.fetchPayment(job.data.paymentId, queryRunner); + // Check if payment is cancelled - if so, handle deletion + if (payment.status === PaymentStatus.CANCELLED) { + this.logger.log(`Payment ${payment.id} is cancelled, triggering deletion of related entities`); + return payment; + } + + // Check if payment is still pending const isPending = await this.isPaymentPending(payment, queryRunner); if (!isPending) { @@ -94,6 +101,13 @@ export class PaymentProcessor extends WorkerProcessor { private async handleSecondReminder(job: Job<{ paymentId: string }>, queryRunner: QueryRunner) { const payment = await this.fetchPayment(job.data.paymentId, queryRunner); + // Check if payment is cancelled - if so, handle deletion + if (payment.status === PaymentStatus.CANCELLED) { + this.logger.log(`Payment ${payment.id} is cancelled, triggering deletion of related entities`); + return payment; + } + + // Check if payment is still pending const isPending = await this.isPaymentPending(payment, queryRunner); if (!isPending) { @@ -111,6 +125,13 @@ export class PaymentProcessor extends WorkerProcessor { private async handlePaymentCancellation(job: Job<{ paymentId: string }>, queryRunner: QueryRunner) { const payment = await this.fetchPayment(job.data.paymentId, queryRunner); + // Check if payment is already cancelled - if so, handle deletion + if (payment.status === PaymentStatus.CANCELLED) { + this.logger.log(`Payment ${payment.id} is already cancelled, triggering deletion of related entities`); + return payment; + } + + // Check if payment is still pending const isPending = await this.isPaymentPending(payment, queryRunner); if (!isPending) { @@ -118,6 +139,7 @@ export class PaymentProcessor extends WorkerProcessor { return payment; } + // Cancel the payment and schedule deletion await this.cancelPayment(payment, queryRunner); return payment; @@ -185,9 +207,9 @@ export class PaymentProcessor extends WorkerProcessor { //=============================================== private async isPaymentPending(payment: Payment, queryRunner: QueryRunner): Promise { - // 1. Trust DB first + // 1. Trust DB first - only check for PENDING status if (payment.status !== PaymentStatus.PENDING) { - this.logger.log(`Payment ${payment.id} is not in pending status`); + this.logger.log(`Payment ${payment.id} is not in pending status (current: ${payment.status})`); return false; } @@ -199,18 +221,25 @@ export class PaymentProcessor extends WorkerProcessor { const result = await this.paymentsService.processVerificationResult(verifyData, payment, queryRunner); - // 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})`); + // If still pending, return true + if (result.status === PaymentStatus.PENDING) { + this.logger.log(`Payment ${payment.id} is still pending (gateway code: ${verifyData.code})`); return true; } + // If completed, return false 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 + // If cancelled, return false (will be handled by cancellation logic) + if (result.status === PaymentStatus.CANCELLED) { + this.logger.log(`Payment ${payment.id} is cancelled (gateway code: ${verifyData.code})`); + return false; + } + + // For any other status, treat as not pending this.logger.log(`Payment ${payment.id} gateway returned code ${verifyData.code}, treating as not pending`); return false; } catch (error) {