payment refactor
This commit is contained in:
@@ -23,7 +23,7 @@ export class PaymentsController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly paymentsService: PaymentsService,
|
private readonly paymentsService: PaymentsService,
|
||||||
private readonly paymentMethodService: PaymentMethodService,
|
private readonly paymentMethodService: PaymentMethodService,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -68,7 +68,7 @@ export class PaymentsController {
|
|||||||
required: true,
|
required: true,
|
||||||
})
|
})
|
||||||
payAnOrder(@Param('orderId') orderId: string, @RestId() restId: string) {
|
payAnOrder(@Param('orderId') orderId: string, @RestId() restId: string) {
|
||||||
return this.paymentsService.startPayment(orderId, restId);
|
return this.paymentsService.payOrder(orderId, restId);
|
||||||
}
|
}
|
||||||
// @UseGuards(AdminAuthGuard)
|
// @UseGuards(AdminAuthGuard)
|
||||||
// @ApiBearerAuth()
|
// @ApiBearerAuth()
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator';
|
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { PaymentGatewayEnum, PaymentMethodEnum } from '../interface/payment';
|
|
||||||
|
|
||||||
export class CreatePaymentDto {
|
|
||||||
@ApiProperty({ description: 'Order ID' })
|
|
||||||
@IsString()
|
|
||||||
orderId: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Payment amount' })
|
|
||||||
@IsNumber()
|
|
||||||
amount: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Payment method' })
|
|
||||||
@IsEnum(PaymentMethodEnum)
|
|
||||||
method: PaymentMethodEnum;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Payment authority' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
merchantId?: string | null;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Payment gateway' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsEnum(PaymentGatewayEnum)
|
|
||||||
gateway?: PaymentGatewayEnum | null;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,7 @@
|
|||||||
import { PartialType } from '@nestjs/mapped-types';
|
|
||||||
import { CreatePaymentDto } from './create-payment.dto';
|
|
||||||
import { IsNumber } from 'class-validator';
|
import { IsNumber } from 'class-validator';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class UpdatePaymentDto extends PartialType(CreatePaymentDto) {
|
export class UpdatePaymentDto {
|
||||||
@ApiProperty({ description: 'Payment ID' })
|
@ApiProperty({ description: 'Payment ID' })
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ export class Payment extends BaseEntity {
|
|||||||
amount!: number;
|
amount!: number;
|
||||||
|
|
||||||
@Property({ unique: true, nullable: true })
|
@Property({ unique: true, nullable: true })
|
||||||
authority?: string | null;
|
referenceId?: string | null;
|
||||||
|
|
||||||
@Enum(() => PaymentGatewayEnum)
|
@Enum(() => PaymentGatewayEnum)
|
||||||
gateway?: PaymentGatewayEnum | null = null;
|
gateway?: PaymentGatewayEnum | null = null;
|
||||||
|
|
||||||
@Property({ nullable: true })
|
@Property({ nullable: true })
|
||||||
refId?: string | null = null;
|
transactionId?: string | null = null;
|
||||||
|
|
||||||
@Enum(() => PaymentStatusEnum)
|
@Enum(() => PaymentStatusEnum)
|
||||||
status!: PaymentStatusEnum;
|
status!: PaymentStatusEnum;
|
||||||
|
|||||||
@@ -3,63 +3,58 @@ import axios from 'axios';
|
|||||||
import {
|
import {
|
||||||
IPaymentGateway,
|
IPaymentGateway,
|
||||||
IPaymentVerifyParams,
|
IPaymentVerifyParams,
|
||||||
IProcessPaymentData,
|
IRequestPaymentData,
|
||||||
IProcessPaymentParams,
|
IRequestPaymentParams,
|
||||||
IVerifyPayment,
|
IPaymentVerifyData,
|
||||||
|
IZarinpalPaymentResponse,
|
||||||
|
IZarinpalRequestPayment,
|
||||||
|
IZarinpalVerifyRequest,
|
||||||
|
IZarinpalVerifyResponse,
|
||||||
} from '../interface/gateway';
|
} from '../interface/gateway';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PaymentGatewayEnum } from '../interface/payment';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ZarinpalGateway implements IPaymentGateway {
|
export class ZarinpalGateway implements IPaymentGateway {
|
||||||
private readonly logger = new Logger(ZarinpalGateway.name);
|
private readonly logger = new Logger(ZarinpalGateway.name);
|
||||||
private readonly zarinpalRequestUrl: string;
|
private readonly zarinpalRequestUrl: string;
|
||||||
private readonly zarinpalPaymentBaseUrl: string;
|
|
||||||
private readonly zarinpalVerifyUrl: string;
|
private readonly zarinpalVerifyUrl: string;
|
||||||
|
private readonly zarinpalPaymentBaseUrl: string;
|
||||||
|
private readonly axiosConfig = {
|
||||||
|
timeout: 10_000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(private readonly configService: ConfigService) {
|
||||||
const zarinpalBaseUrl = this.configService.get<string>('ZARINPAL_BASE_URL') || 'https://sandbox.zarinpal.com';
|
const zarinpalBaseUrl = this.configService.get<string>('ZARINPAL_BASE_URL') || 'https://sandbox.zarinpal.com';
|
||||||
|
this.zarinpalPaymentBaseUrl = zarinpalBaseUrl;
|
||||||
this.zarinpalRequestUrl = `${zarinpalBaseUrl}/pg/v4/payment/request.json`;
|
this.zarinpalRequestUrl = `${zarinpalBaseUrl}/pg/v4/payment/request.json`;
|
||||||
this.zarinpalPaymentBaseUrl = `${zarinpalBaseUrl}/pg/StartPay`;
|
|
||||||
this.zarinpalVerifyUrl = `${zarinpalBaseUrl}/pg/v4/payment/verify.json`;
|
this.zarinpalVerifyUrl = `${zarinpalBaseUrl}/pg/v4/payment/verify.json`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async processPayment(params: IProcessPaymentParams): Promise<IProcessPaymentData> {
|
async requestPayment({ amount, merchantId, orderId, domain }: IRequestPaymentParams): Promise<IRequestPaymentData> {
|
||||||
// Transform camelCase to snake_case for Zarinpal API v4
|
// Transform camelCase to snake_case for Zarinpal API v4
|
||||||
const zarinpalRequest = {
|
const callbackUrl = `${domain}/verify/${orderId}`;
|
||||||
amount: params.amount,
|
const zarinpalRequest: IZarinpalRequestPayment = {
|
||||||
merchant_id: params.merchantId,
|
amount,
|
||||||
description: params.description,
|
merchant_id: merchantId,
|
||||||
callback_url: params.callbackUrl,
|
description: `Payment for order #${orderId}`,
|
||||||
|
callback_url: callbackUrl,
|
||||||
metadata: {
|
metadata: {
|
||||||
order_id: params.metadata.orderId,
|
order_id: orderId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Zarinpal API v4 returns response wrapped in { data: {...}, errors: [] }
|
const res = await axios.post<IZarinpalPaymentResponse>(this.zarinpalRequestUrl, zarinpalRequest, this.axiosConfig);
|
||||||
interface ZarinpalError {
|
const { code, message, authority, errors } = res.data;
|
||||||
message?: string;
|
|
||||||
code?: number;
|
|
||||||
}
|
|
||||||
interface ZarinpalResponse {
|
|
||||||
data: IProcessPaymentData;
|
|
||||||
errors: ZarinpalError[];
|
|
||||||
}
|
|
||||||
const response = await axios.post<ZarinpalResponse>(this.zarinpalRequestUrl, zarinpalRequest);
|
|
||||||
|
|
||||||
// Check if there are errors in the response
|
// Check if there are errors in the response
|
||||||
if (response.data.errors && response.data.errors.length > 0) {
|
if ((code !== 100 && code !== 0) || !authority) {
|
||||||
const errorMessage = response.data.errors.map(err => err.message || JSON.stringify(err)).join(', ');
|
throw new BadRequestException(message ?? 'Zarinpal payment request error');
|
||||||
throw new BadRequestException(`Payment gateway error: ${errorMessage}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the nested data object
|
return { transactionId: authority };
|
||||||
if (!response.data.data) {
|
|
||||||
throw new BadRequestException('Payment gateway returned invalid response structure');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.data.data;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Log the actual API error response for debugging
|
// Log the actual API error response for debugging
|
||||||
if (axios.isAxiosError(error) && error.response) {
|
if (axios.isAxiosError(error) && error.response) {
|
||||||
@@ -73,185 +68,47 @@ export class ZarinpalGateway implements IPaymentGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
zarinpalPaymentUrl(gateway: PaymentGatewayEnum | null, authority: string | null) {
|
async verifyPayment({ merchantId, amount, transactionId }: IPaymentVerifyParams): Promise<IPaymentVerifyData> {
|
||||||
if (gateway === PaymentGatewayEnum.ZarinPal && authority) {
|
|
||||||
return `${this.zarinpalPaymentBaseUrl}/${authority}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifyPayment(verifyRequest: IPaymentVerifyParams): Promise<IVerifyPayment> {
|
|
||||||
try {
|
try {
|
||||||
// Transform camelCase to snake_case for Zarinpal API v4
|
// Transform camelCase to snake_case for Zarinpal API v4
|
||||||
const zarinpalVerifyRequest = {
|
const zarinpalVerifyRequest: IZarinpalVerifyRequest = {
|
||||||
merchant_id: verifyRequest.merchantId,
|
merchant_id: merchantId,
|
||||||
amount: verifyRequest.amount,
|
amount,
|
||||||
authority: verifyRequest.authority,
|
authority: transactionId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Zarinpal API v4 returns response wrapped in { data: {...}, errors: [] }
|
const res = await axios.post<IZarinpalVerifyResponse>(this.zarinpalVerifyUrl, zarinpalVerifyRequest, this.axiosConfig);
|
||||||
interface ZarinpalError {
|
|
||||||
message?: string;
|
// Check if response data exists
|
||||||
code?: number;
|
if (!res.data || !res.data.data) {
|
||||||
|
throw new BadRequestException('Invalid response from Zarinpal API');
|
||||||
}
|
}
|
||||||
interface ZarinpalVerifyResponse {
|
|
||||||
data: IVerifyPayment;
|
const { code, message, ref_id, card_pan } = res.data.data;
|
||||||
errors: ZarinpalError[];
|
|
||||||
}
|
|
||||||
const response = await axios.post<ZarinpalVerifyResponse>(this.zarinpalVerifyUrl, zarinpalVerifyRequest);
|
|
||||||
|
|
||||||
// Check if there are errors in the response
|
// Check if there are errors in the response
|
||||||
if (response.data.errors && response.data.errors.length > 0) {
|
if (code !== 100 && code !== 0) {
|
||||||
const errorMessage = response.data.errors.map(err => err.message || JSON.stringify(err)).join(', ');
|
throw new BadRequestException(message ?? 'Zarinpal error');
|
||||||
throw new BadRequestException(`Payment gateway error: ${errorMessage}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the nested data object
|
return {
|
||||||
if (!response.data.data) {
|
success: code === 100,
|
||||||
throw new BadRequestException('Payment gateway returned invalid response structure');
|
referenceId: ref_id ? ref_id.toString() : undefined,
|
||||||
}
|
cardPan: card_pan,
|
||||||
|
raw: res.data,
|
||||||
return response.data.data;
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof BadRequestException) {
|
if (axios.isAxiosError(error) && error.response) {
|
||||||
throw error;
|
this.logger.error('Zarinpal verify failed', error.response.data);
|
||||||
|
throw new BadRequestException(error.response.data);
|
||||||
}
|
}
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
throw new BadRequestException(`Failed to verify payment with gateway: ${errorMessage}`);
|
throw new BadRequestException(`Failed to verify payment with gateway: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// async processPayment(processParams: IProcessPaymentParams) {
|
|
||||||
// try {
|
|
||||||
// const purchaseData: ZarinPalPGNewArgs = {
|
|
||||||
// merchant_id: this.config.merchantId,
|
|
||||||
// amount: processParams.amount,
|
|
||||||
// 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:`, {
|
getPaymentUrl(authority: string): string {
|
||||||
// merchant_id: this.config.merchantId,
|
return `${this.zarinpalPaymentBaseUrl}/pg/StartPay/${authority}`;
|
||||||
// amount: processParams.amount,
|
}
|
||||||
// callback_url: `${this.config.callBackUrl}/${GatewayEnum.ZARINPAL}`,
|
|
||||||
// description: processParams.description,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// const { data } = await firstValueFrom(
|
|
||||||
// this.httpService
|
|
||||||
// .post<ZarinPalPGNewRequestData>(`${this.gatewayApiUrl}/pg/v4/payment/request.json`, purchaseData, {
|
|
||||||
// headers: this.requestHeader,
|
|
||||||
// })
|
|
||||||
// .pipe(
|
|
||||||
// catchError((err: AxiosError) => {
|
|
||||||
// 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 {
|
|
||||||
// redirectUrl: `${this.gatewayApiUrl}/pg/StartPay/${data.data.authority}`,
|
|
||||||
// message: data.data.message,
|
|
||||||
// reference: data.data.authority,
|
|
||||||
// };
|
|
||||||
// } catch (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<ZarinPalPGVerifyData>(`${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 ZarinPalPGVerifyData;
|
|
||||||
// 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 ZarinPalPGVerifyData;
|
|
||||||
// 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);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,67 @@
|
|||||||
export interface IPaymentGateway {
|
export interface IPaymentGateway {
|
||||||
processPayment(processPaymentParam: IProcessPaymentParams): Promise<IProcessPaymentData>;
|
requestPayment(requestPaymentParams: IRequestPaymentParams): Promise<IRequestPaymentData>;
|
||||||
verifyPayment(verifyPaymentParam: IPaymentVerifyParams): Promise<IVerifyPayment>;
|
verifyPayment(verifyPaymentParam: IPaymentVerifyParams): Promise<IPaymentVerifyData>;
|
||||||
|
getPaymentUrl(authority: string): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IProcessPaymentParams {
|
export interface IRequestPaymentParams {
|
||||||
amount: number;
|
amount: number;
|
||||||
callbackUrl: string;
|
|
||||||
merchantId: string;
|
merchantId: string;
|
||||||
description: string;
|
domain: string;
|
||||||
metadata: {
|
|
||||||
orderId: string;
|
orderId: string;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IProcessPaymentData {
|
export interface IRequestPaymentData {
|
||||||
code: number;
|
transactionId: string;
|
||||||
message: string;
|
|
||||||
authority: string;
|
|
||||||
fee_type: string;
|
|
||||||
fee: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPaymentVerifyParams {
|
export interface IPaymentVerifyParams {
|
||||||
|
amount: number;
|
||||||
merchantId: string;
|
merchantId: string;
|
||||||
|
transactionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPaymentVerifyData {
|
||||||
|
success: boolean;
|
||||||
|
referenceId?: string;
|
||||||
|
cardPan?: string;
|
||||||
|
raw: Record<string, any>;
|
||||||
|
}
|
||||||
|
////////////////////////// Zarinpal API v4 //////////////////////////
|
||||||
|
export interface IZarinpalRequestPayment {
|
||||||
|
amount: number;
|
||||||
|
merchant_id: string;
|
||||||
|
description: string;
|
||||||
|
callback_url: string;
|
||||||
|
metadata?: {
|
||||||
|
order_id?: string;
|
||||||
|
mobile?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
referrer_id?: string;
|
||||||
|
}
|
||||||
|
export interface IZarinpalPaymentResponse {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
authority: string;
|
||||||
|
fee_type: string;
|
||||||
|
fee: number;
|
||||||
|
errors: [];
|
||||||
|
}
|
||||||
|
export interface IZarinpalVerifyRequest {
|
||||||
|
merchant_id: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
authority: string;
|
authority: string;
|
||||||
}
|
}
|
||||||
|
export interface IZarinpalVerifyResponse {
|
||||||
export interface IVerifyPayment {
|
data: {
|
||||||
code: number;
|
code: number;
|
||||||
message: string;
|
message: string;
|
||||||
refId: number;
|
card_hash: string;
|
||||||
cardPan: string;
|
card_pan: string;
|
||||||
cardHash: string;
|
ref_id: number;
|
||||||
fee_type: string;
|
fee_type: string;
|
||||||
fee: number;
|
fee: number;
|
||||||
|
};
|
||||||
|
errors: [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,3 +11,11 @@ export enum PaymentStatusEnum {
|
|||||||
export enum PaymentGatewayEnum {
|
export enum PaymentGatewayEnum {
|
||||||
ZarinPal = 'zarinpal',
|
ZarinPal = 'zarinpal',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ICreatePayment {
|
||||||
|
orderId: string;
|
||||||
|
amount: number;
|
||||||
|
method: PaymentMethodEnum;
|
||||||
|
transactionId: string;
|
||||||
|
gateway?: PaymentGatewayEnum | null;
|
||||||
|
}
|
||||||
@@ -10,11 +10,12 @@ import { AuthModule } from '../auth/auth.module';
|
|||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
import { Payment } from './entities/payment.entity';
|
import { Payment } from './entities/payment.entity';
|
||||||
import { ZarinpalGateway } from './gateways/zarinpal.gateway';
|
import { ZarinpalGateway } from './gateways/zarinpal.gateway';
|
||||||
|
import { GatewayManager } from './services/gateway.manager';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [MikroOrmModule.forFeature([PaymentMethod, Payment, Restaurant]), AuthModule, JwtModule],
|
imports: [MikroOrmModule.forFeature([PaymentMethod, Payment, Restaurant]), AuthModule, JwtModule],
|
||||||
controllers: [PaymentsController],
|
controllers: [PaymentsController],
|
||||||
providers: [PaymentsService, PaymentMethodService, PaymentMethodRepository, ZarinpalGateway],
|
providers: [PaymentsService, PaymentMethodService, PaymentMethodRepository, ZarinpalGateway, GatewayManager],
|
||||||
exports: [
|
exports: [
|
||||||
PaymentMethodRepository,
|
PaymentMethodRepository,
|
||||||
PaymentMethodService,
|
PaymentMethodService,
|
||||||
@@ -22,4 +23,4 @@ import { ZarinpalGateway } from './gateways/zarinpal.gateway';
|
|||||||
// PaymentGatewayService,
|
// PaymentGatewayService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class PaymentsModule {}
|
export class PaymentsModule { }
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { PaymentGatewayEnum } from '../interface/payment';
|
||||||
|
import { ZarinpalGateway } from '../gateways/zarinpal.gateway';
|
||||||
|
import { IPaymentGateway } from '../interface/gateway';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GatewayManager {
|
||||||
|
constructor(
|
||||||
|
private readonly zarinpal: ZarinpalGateway,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
get(gateway: PaymentGatewayEnum): IPaymentGateway {
|
||||||
|
switch (gateway) {
|
||||||
|
case PaymentGatewayEnum.ZarinPal:
|
||||||
|
return this.zarinpal;
|
||||||
|
default:
|
||||||
|
throw new BadRequestException(`Unsupported gateway: ${gateway}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,9 @@ import { Payment } from '../entities/payment.entity';
|
|||||||
import { EntityManager, RequiredEntityData } from '@mikro-orm/core';
|
import { EntityManager, RequiredEntityData } from '@mikro-orm/core';
|
||||||
import { Order } from '../../orders/entities/order.entity';
|
import { Order } from '../../orders/entities/order.entity';
|
||||||
import { PaymentMethod } from '../entities/payment-method.entity';
|
import { PaymentMethod } from '../entities/payment-method.entity';
|
||||||
import { CreatePaymentDto } from '../dto/create-payment.dto';
|
import { Logger } from '@nestjs/common';
|
||||||
import { User } from '../../users/entities/user.entity';
|
import { GatewayManager } from './gateway.manager';
|
||||||
import { ZarinpalGateway } from '../gateways/zarinpal.gateway';
|
import { UserWallet } from 'src/modules/users/entities/user-wallet.entity';
|
||||||
import { Logger } from '@nestjs/common';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PaymentsService {
|
export class PaymentsService {
|
||||||
@@ -15,36 +14,62 @@ export class PaymentsService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly em: EntityManager,
|
private readonly em: EntityManager,
|
||||||
private readonly zarinpalGateway: ZarinpalGateway,
|
private readonly gatewayManager: GatewayManager,
|
||||||
) {}
|
) { }
|
||||||
|
|
||||||
async startPayment(orderId: string, restId: string): Promise<{ paymentUrl: string | null }> {
|
async payOrder(orderId: string, restId: string): Promise<{ paymentUrl: string | null }> {
|
||||||
const { amount, method, restaurantDomain, gateway, merchantId, user } = await this.validateOrder(orderId, restId);
|
const { amount, method, restaurantDomain, gateway, merchantId, user } =
|
||||||
|
await this.validateOrder(orderId, restId);
|
||||||
|
|
||||||
// Handle Wallet payment immediately
|
// Handle Wallet payment immediately
|
||||||
// if (method === PaymentMethodEnum.Wallet) {
|
if (method === PaymentMethodEnum.Cash) {
|
||||||
// // Check wallet balance
|
const payment = this.em.create(Payment, {
|
||||||
// if (user.wallet < amount) {
|
amount,
|
||||||
// throw new BadRequestException('Insufficient wallet balance');
|
transactionId: null,
|
||||||
// }
|
order: this.em.getReference(Order, orderId),
|
||||||
|
gateway: null,
|
||||||
|
method,
|
||||||
|
status: PaymentStatusEnum.Pending,
|
||||||
|
} as RequiredEntityData<Payment>);
|
||||||
|
|
||||||
// // Deduct from wallet and create payment record
|
await this.em.persistAndFlush(payment);
|
||||||
// const payment = await this.processWalletPayment(orderId, amount, user);
|
|
||||||
|
|
||||||
// return { paymentUrl: null }; // No redirect needed for wallet
|
}
|
||||||
// }
|
if (method === PaymentMethodEnum.Wallet) {
|
||||||
|
const userWallet = await this.em.findOne(UserWallet, { user: { id: user.id }, restaurant: { id: restId } });
|
||||||
|
// Check wallet balance
|
||||||
|
if (!userWallet) {
|
||||||
|
throw new NotFoundException('User wallet not found');
|
||||||
|
}
|
||||||
|
if (userWallet.wallet < amount) {
|
||||||
|
throw new BadRequestException('Insufficient wallet balance');
|
||||||
|
}
|
||||||
|
|
||||||
const { authority } = await this.createPayment(restaurantDomain, {
|
// Deduct from wallet and create payment record
|
||||||
|
await this.processWalletPayment(orderId, amount, user.id, restId);
|
||||||
|
|
||||||
|
return { paymentUrl: null }; // No redirect needed for wallet
|
||||||
|
}
|
||||||
|
const gatewayInstance = this.gatewayManager.get(gateway as PaymentGatewayEnum);
|
||||||
|
const { transactionId } = await gatewayInstance.requestPayment({
|
||||||
amount,
|
amount,
|
||||||
orderId,
|
orderId,
|
||||||
method,
|
merchantId: merchantId as string,
|
||||||
merchantId,
|
domain: restaurantDomain,
|
||||||
gateway,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const paymentUrl = this.zarinpalGateway.zarinpalPaymentUrl(gateway, authority);
|
const payment = this.em.create(Payment, {
|
||||||
|
amount,
|
||||||
|
transactionId,
|
||||||
|
order: this.em.getReference(Order, orderId),
|
||||||
|
gateway,
|
||||||
|
method,
|
||||||
|
status: PaymentStatusEnum.Pending,
|
||||||
|
} as RequiredEntityData<Payment>);
|
||||||
|
|
||||||
return { paymentUrl };
|
await this.em.persistAndFlush(payment);
|
||||||
|
|
||||||
|
return { paymentUrl: gatewayInstance.getPaymentUrl(transactionId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validateOrder(orderId: string, restId: string) {
|
private async validateOrder(orderId: string, restId: string) {
|
||||||
@@ -81,39 +106,23 @@ export class PaymentsService {
|
|||||||
return { amount, method, restaurantDomain, gateway, merchantId, user };
|
return { amount, method, restaurantDomain, gateway, merchantId, user };
|
||||||
}
|
}
|
||||||
|
|
||||||
async createPayment(domain: string, dto: CreatePaymentDto) {
|
|
||||||
const { amount, orderId, merchantId, gateway, method } = dto;
|
|
||||||
|
|
||||||
const { authority } = await this.requestToGateway(method, amount, orderId, merchantId, domain, gateway);
|
private async processWalletPayment(orderId: string, amount: number, userId: string, restId: string): Promise<Payment> {
|
||||||
|
|
||||||
const payment = this.em.create(Payment, {
|
|
||||||
amount,
|
|
||||||
authority: authority,
|
|
||||||
order: this.em.getReference(Order, orderId),
|
|
||||||
gateway,
|
|
||||||
status: PaymentStatusEnum.Pending,
|
|
||||||
} as RequiredEntityData<Payment>);
|
|
||||||
|
|
||||||
await this.em.persistAndFlush(payment);
|
|
||||||
return { authority, payment };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processWalletPayment(orderId: string, amount: number, user: User): Promise<Payment> {
|
|
||||||
return this.em.transactional(async em => {
|
return this.em.transactional(async em => {
|
||||||
// Reload user to get latest wallet balance
|
// Reload user to get latest wallet balance
|
||||||
const freshUser = await em.findOne(User, { id: user.id });
|
const userWallet = await em.findOne(UserWallet, { user: { id: userId }, restaurant: { id: restId } });
|
||||||
if (!freshUser) {
|
if (!userWallet) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User wallet not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Double-check balance
|
// Double-check balance
|
||||||
// if (freshUser.wallet < amount) {
|
if (userWallet.wallet < amount) {
|
||||||
// throw new BadRequestException('Insufficient wallet balance');
|
throw new BadRequestException('Insufficient wallet balance');
|
||||||
// }
|
}
|
||||||
|
|
||||||
// Deduct from wallet
|
// Deduct from wallet
|
||||||
// freshUser.wallet -= amount;
|
userWallet.wallet -= amount;
|
||||||
// em.persist(freshUser);
|
em.persist(userWallet);
|
||||||
|
|
||||||
// Create payment record
|
// Create payment record
|
||||||
const payment = em.create(Payment, {
|
const payment = em.create(Payment, {
|
||||||
@@ -139,11 +148,13 @@ export class PaymentsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyPayment(authority: string, orderId: string): Promise<Payment> {
|
async verifyPayment(transactionId: string, orderId: string): Promise<Payment> {
|
||||||
|
return this.em.transactional(async em => {
|
||||||
|
|
||||||
// Find payment by authority and orderId
|
// Find payment by authority and orderId
|
||||||
const payment = await this.em.findOne(
|
const payment = await em.findOne(
|
||||||
Payment,
|
Payment,
|
||||||
{ authority, order: { id: orderId } },
|
{ transactionId, order: { id: orderId } },
|
||||||
{ populate: ['order', 'order.paymentMethod'] },
|
{ populate: ['order', 'order.paymentMethod'] },
|
||||||
);
|
);
|
||||||
if (!payment) {
|
if (!payment) {
|
||||||
@@ -160,27 +171,11 @@ export class PaymentsService {
|
|||||||
throw new NotFoundException('Payment method not found for this order');
|
throw new NotFoundException('Payment method not found for this order');
|
||||||
}
|
}
|
||||||
|
|
||||||
const paymentMethod = await this.em.findOne(PaymentMethod, { id: payment.order.paymentMethod.id });
|
const paymentMethod = await em.findOne(PaymentMethod, { id: payment.order.paymentMethod.id });
|
||||||
if (!paymentMethod) {
|
if (!paymentMethod) {
|
||||||
throw new NotFoundException('Payment method not found');
|
throw new NotFoundException('Payment method not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Wallet payments, they're already paid during startPayment
|
|
||||||
// if (paymentMethod.method === PaymentMethodEnum.Wallet) {
|
|
||||||
// if (payment.status === PaymentStatusEnum.Paid ) {
|
|
||||||
// return payment;
|
|
||||||
// }
|
|
||||||
// throw new BadRequestException('Wallet payment was not processed correctly');
|
|
||||||
// }
|
|
||||||
|
|
||||||
// For non-online payments, mark as paid directly
|
|
||||||
// if (paymentMethod.method !== PaymentMethodEnum.Online) {
|
|
||||||
// payment.status = PaymentStatusEnum.Paid;
|
|
||||||
// payment.paidAt = new Date();
|
|
||||||
// await this.em.persistAndFlush(payment);
|
|
||||||
// return payment;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Online payments require gateway verification
|
// Online payments require gateway verification
|
||||||
if (!paymentMethod.merchantId) {
|
if (!paymentMethod.merchantId) {
|
||||||
throw new BadRequestException('Merchant ID is required for online payment verification');
|
throw new BadRequestException('Merchant ID is required for online payment verification');
|
||||||
@@ -193,59 +188,39 @@ export class PaymentsService {
|
|||||||
// Use payment amount
|
// Use payment amount
|
||||||
const verifyAmount = payment.amount;
|
const verifyAmount = payment.amount;
|
||||||
|
|
||||||
// Handle ZarinPal gateway verification
|
const gatewayInstance = this.gatewayManager.get(payment.gateway as PaymentGatewayEnum);
|
||||||
if (payment.gateway === PaymentGatewayEnum.ZarinPal) {
|
|
||||||
try {
|
|
||||||
const verifyResponse = await this.zarinpalGateway.verifyPayment({
|
const verifyResponse = await gatewayInstance.verifyPayment({
|
||||||
merchantId: paymentMethod.merchantId,
|
merchantId: paymentMethod.merchantId,
|
||||||
amount: verifyAmount,
|
amount: verifyAmount,
|
||||||
authority,
|
transactionId: payment.transactionId as string,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Type guard to ensure we have a valid response
|
const { success, referenceId, cardPan, raw } = verifyResponse;
|
||||||
if (!verifyResponse || typeof verifyResponse !== 'object') {
|
|
||||||
throw new BadRequestException('Invalid verification response from payment gateway');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = verifyResponse;
|
if (!success) {
|
||||||
|
|
||||||
if (response.code === 100) {
|
|
||||||
// Payment successful
|
|
||||||
payment.status = PaymentStatusEnum.Paid;
|
|
||||||
payment.refId = String(response.refId);
|
|
||||||
|
|
||||||
payment.cardPan = response.cardPan;
|
|
||||||
payment.verifyResponse = response as unknown as Record<string, any>;
|
|
||||||
payment.paidAt = new Date();
|
|
||||||
payment.order.paymentStatus = PaymentStatusEnum.Paid;
|
|
||||||
} else {
|
|
||||||
// Payment failed
|
// Payment failed
|
||||||
payment.status = PaymentStatusEnum.Failed;
|
payment.status = PaymentStatusEnum.Failed;
|
||||||
payment.verifyResponse = response as unknown as Record<string, any>;
|
payment.verifyResponse = raw;
|
||||||
payment.failedAt = new Date();
|
payment.failedAt = new Date();
|
||||||
payment.order.paymentStatus = PaymentStatusEnum.Failed;
|
payment.order.paymentStatus = PaymentStatusEnum.Failed;
|
||||||
}
|
}
|
||||||
|
// Payment successful
|
||||||
|
payment.status = PaymentStatusEnum.Paid;
|
||||||
|
payment.referenceId = referenceId
|
||||||
|
|
||||||
await this.em.persistAndFlush(payment);
|
payment.cardPan = cardPan;
|
||||||
|
payment.verifyResponse = raw;
|
||||||
|
payment.paidAt = new Date();
|
||||||
|
payment.order.paymentStatus = PaymentStatusEnum.Paid;
|
||||||
|
|
||||||
|
|
||||||
|
await em.persistAndFlush(payment);
|
||||||
return payment;
|
return payment;
|
||||||
} catch (error: unknown) {
|
})
|
||||||
// Mark payment as failed on error
|
|
||||||
payment.status = PaymentStatusEnum.Failed;
|
|
||||||
payment.failedAt = new Date();
|
|
||||||
payment.order.paymentStatus = PaymentStatusEnum.Failed;
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
payment.verifyResponse = {
|
|
||||||
error: errorMessage,
|
|
||||||
};
|
|
||||||
await this.em.persistAndFlush(payment);
|
|
||||||
|
|
||||||
throw new BadRequestException(`Payment verification failed: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If gateway is not supported, throw an error
|
|
||||||
throw new BadRequestException(`Unsupported payment gateway: ${String(payment.gateway)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
findAllByRestaurantId(restId: string, userId: string) {
|
findAllByRestaurantId(restId: string, userId: string) {
|
||||||
return this.em.find(
|
return this.em.find(
|
||||||
@@ -255,65 +230,4 @@ export class PaymentsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestToGateway(
|
|
||||||
paymentMethod: PaymentMethodEnum,
|
|
||||||
amount: number,
|
|
||||||
orderId: string,
|
|
||||||
merchantId: string | null | undefined,
|
|
||||||
domain: string,
|
|
||||||
gateway: PaymentGatewayEnum | null | undefined,
|
|
||||||
): Promise<{ authority: string | null }> {
|
|
||||||
// For non-online payment methods, no gateway request is needed
|
|
||||||
if (paymentMethod !== PaymentMethodEnum.Online) {
|
|
||||||
return { authority: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Online payments require merchantId and gateway
|
|
||||||
if (!merchantId) {
|
|
||||||
throw new BadRequestException('Merchant ID is required for online payments');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!gateway) {
|
|
||||||
throw new BadRequestException('Payment gateway is required for online payments');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle ZarinPal gateway
|
|
||||||
if (gateway === PaymentGatewayEnum.ZarinPal) {
|
|
||||||
const callbackUrl = `${domain}/verify/${orderId}`;
|
|
||||||
try {
|
|
||||||
const gatewayResponse = await this.zarinpalGateway.processPayment({
|
|
||||||
amount,
|
|
||||||
merchantId,
|
|
||||||
description: `Payment for order #${orderId}`,
|
|
||||||
callbackUrl,
|
|
||||||
metadata: {
|
|
||||||
orderId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.logger.log('gatewayResponse', gatewayResponse);
|
|
||||||
// Check gateway response code (typically 100 means success for Iranian gateways)
|
|
||||||
if (gatewayResponse.code !== 100 && gatewayResponse.code !== 0) {
|
|
||||||
throw new BadRequestException(`Payment gateway error: ${gatewayResponse.message || 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!gatewayResponse.authority) {
|
|
||||||
throw new BadRequestException('Payment gateway did not return an authority token');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { authority: gatewayResponse.authority };
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Error in request to gateway', error);
|
|
||||||
|
|
||||||
if (error instanceof BadRequestException) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
throw new BadRequestException(`Failed to connect to payment gateway: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If gateway is not supported, throw an error
|
|
||||||
throw new BadRequestException(`Unsupported payment gateway: ${String(gateway)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user