- Add ROOM_RENTAL, CONDOTEL, SERVICED_APARTMENT to PropertyType enum in schema.prisma - Create migration 20260422010000_add_room_rental_property_types with ALTER TYPE ADD VALUE - Add DEFAULT_RANGES in PrismaPriceValidator: ROOM_RENTAL 1M-10M VND/month, CONDOTEL 20M-300M, SERVICED_APARTMENT 20M-250M VND/m² - Add i18n translations: vi "Phòng trọ / Condotel / Căn hộ dịch vụ", en "Room Rental / Condotel / Serviced Apartment" - Typesense indexes propertyType as a generic string facet — no schema change needed Co-Authored-By: Paperclip <noreply@paperclip.ing>
211 lines
5.9 KiB
TypeScript
211 lines
5.9 KiB
TypeScript
import * as crypto from 'crypto';
|
|
import { Injectable } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { type PaymentProvider } from '@prisma/client';
|
|
import { LoggerService } from '@modules/shared';
|
|
import {
|
|
type IPaymentGateway,
|
|
type CreatePaymentUrlParams,
|
|
type CreatePaymentUrlResult,
|
|
type CallbackVerifyResult,
|
|
type RefundParams,
|
|
type RefundResult,
|
|
} from './payment-gateway.interface';
|
|
|
|
@Injectable()
|
|
export class ZalopayService implements IPaymentGateway {
|
|
readonly provider: PaymentProvider = 'ZALOPAY';
|
|
|
|
private readonly appId: string;
|
|
private readonly key1: string;
|
|
private readonly key2: string;
|
|
private readonly endpoint: string;
|
|
private readonly callbackBaseUrl: string;
|
|
|
|
constructor(
|
|
private readonly config: ConfigService,
|
|
private readonly logger: LoggerService,
|
|
) {
|
|
this.appId = this.config.getOrThrow<string>('ZALOPAY_APP_ID');
|
|
this.key1 = this.config.getOrThrow<string>('ZALOPAY_KEY1');
|
|
this.key2 = this.config.getOrThrow<string>('ZALOPAY_KEY2');
|
|
this.endpoint = this.config.get<string>('ZALOPAY_ENDPOINT', 'https://sb-openapi.zalopay.vn/v2');
|
|
this.callbackBaseUrl = this.config.get<string>('PAYMENT_CALLBACK_BASE_URL', 'https://api.goodgo.vn');
|
|
}
|
|
|
|
async createPaymentUrl(params: CreatePaymentUrlParams): Promise<CreatePaymentUrlResult> {
|
|
const now = new Date();
|
|
const appTransId = `${this.formatYYMMDD(now)}_${params.orderId}`;
|
|
const appTime = now.getTime();
|
|
const amount = Number(params.amountVND);
|
|
|
|
// embed_data carries the frontend redirect URL; callback_url is the backend IPN endpoint
|
|
const embedData = JSON.stringify({ redirecturl: params.returnUrl });
|
|
const callbackUrl = params.callbackUrl ?? `${this.callbackBaseUrl}/api/v1/payments/callback/zalopay`;
|
|
const items = JSON.stringify([]);
|
|
|
|
const data = [
|
|
this.appId,
|
|
appTransId,
|
|
appTime,
|
|
amount,
|
|
embedData,
|
|
items,
|
|
].join('|');
|
|
|
|
const mac = crypto
|
|
.createHmac('sha256', this.key1)
|
|
.update(data)
|
|
.digest('hex');
|
|
|
|
const body = {
|
|
app_id: Number(this.appId),
|
|
app_trans_id: appTransId,
|
|
app_user: params.orderId,
|
|
app_time: appTime,
|
|
amount,
|
|
item: items,
|
|
description: params.description,
|
|
embed_data: embedData,
|
|
callback_url: callbackUrl,
|
|
mac,
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`${this.endpoint}/create`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
const result = await response.json() as {
|
|
return_code: number;
|
|
order_url: string;
|
|
zp_trans_token: string;
|
|
};
|
|
|
|
if (result.return_code !== 1) {
|
|
throw new Error(`ZaloPay create payment failed: return_code=${result.return_code}`);
|
|
}
|
|
|
|
this.logger.log(`ZaloPay payment URL created for order ${params.orderId}`, 'ZalopayService');
|
|
|
|
return {
|
|
paymentUrl: result.order_url,
|
|
providerTxId: appTransId,
|
|
};
|
|
} catch (error) {
|
|
this.logger.error(`ZaloPay createPaymentUrl error: ${error}`, undefined, 'ZalopayService');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
verifyCallback(data: Record<string, string>): CallbackVerifyResult {
|
|
const dataStr = data['data'] ?? '';
|
|
const reqMac = data['mac'] ?? '';
|
|
|
|
const mac = crypto
|
|
.createHmac('sha256', this.key2)
|
|
.update(dataStr)
|
|
.digest('hex');
|
|
|
|
const isValid =
|
|
reqMac.length > 0 &&
|
|
reqMac.length === mac.length &&
|
|
crypto.timingSafeEqual(Buffer.from(reqMac, 'hex'), Buffer.from(mac, 'hex'));
|
|
|
|
let parsedData: Record<string, unknown> = {};
|
|
let orderId = '';
|
|
let providerTxId = '';
|
|
|
|
if (isValid) {
|
|
try {
|
|
parsedData = JSON.parse(dataStr);
|
|
orderId = String(parsedData['app_trans_id'] ?? '');
|
|
providerTxId = String(parsedData['zp_trans_id'] ?? '');
|
|
} catch {
|
|
return {
|
|
isValid: false,
|
|
orderId: '',
|
|
providerTxId: '',
|
|
isSuccess: false,
|
|
rawData: data,
|
|
};
|
|
}
|
|
}
|
|
|
|
this.logger.log(
|
|
`ZaloPay callback verified: orderId=${orderId}, valid=${isValid}`,
|
|
'ZalopayService',
|
|
);
|
|
|
|
return {
|
|
isValid,
|
|
orderId,
|
|
providerTxId,
|
|
isSuccess: isValid,
|
|
rawData: { ...data, parsed: parsedData },
|
|
};
|
|
}
|
|
|
|
async refund(params: RefundParams): Promise<RefundResult> {
|
|
const now = Date.now();
|
|
const mRefundId = `${this.formatYYMMDD(new Date())}_${this.appId}_${now}`;
|
|
const amount = Number(params.amountVND);
|
|
|
|
const data = [
|
|
this.appId,
|
|
params.providerTxId,
|
|
amount,
|
|
params.reason,
|
|
now,
|
|
].join('|');
|
|
|
|
const mac = crypto
|
|
.createHmac('sha256', this.key1)
|
|
.update(data)
|
|
.digest('hex');
|
|
|
|
const body = {
|
|
app_id: Number(this.appId),
|
|
zp_trans_id: params.providerTxId,
|
|
m_refund_id: mRefundId,
|
|
amount,
|
|
timestamp: now,
|
|
description: params.reason,
|
|
mac,
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(`${this.endpoint}/refund`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
const result = await response.json() as { return_code: number };
|
|
const success = result.return_code === 1;
|
|
|
|
this.logger.log(
|
|
`ZaloPay refund ${success ? 'successful' : 'failed'} for ${params.providerTxId}`,
|
|
'ZalopayService',
|
|
);
|
|
|
|
return {
|
|
success,
|
|
refundTxId: success ? mRefundId : null,
|
|
};
|
|
} catch (error) {
|
|
this.logger.error(`ZaloPay refund error: ${error}`, undefined, 'ZalopayService');
|
|
return { success: false, refundTxId: null };
|
|
}
|
|
}
|
|
|
|
private formatYYMMDD(date: Date): string {
|
|
const yy = date.getFullYear().toString().slice(-2);
|
|
const mm = (date.getMonth() + 1).toString().padStart(2, '0');
|
|
const dd = date.getDate().toString().padStart(2, '0');
|
|
return `${yy}${mm}${dd}`;
|
|
}
|
|
}
|