Auto-fix 862 lint errors: convert value imports used only as types to `import type`, fix import group ordering in seed.ts and du-an-api.ts, remove unused imports in auth controller, and clean up stale eslint-disable comments referencing non-existent rules. Co-Authored-By: Paperclip <noreply@paperclip.ing>
149 lines
4.9 KiB
TypeScript
149 lines
4.9 KiB
TypeScript
import * as crypto from 'crypto';
|
|
import { Injectable } from '@nestjs/common';
|
|
import { type ConfigService } from '@nestjs/config';
|
|
import { type PaymentProvider } from '@prisma/client';
|
|
import { type LoggerService } from '@modules/shared';
|
|
import {
|
|
type IPaymentGateway,
|
|
type CreatePaymentUrlParams,
|
|
type CreatePaymentUrlResult,
|
|
type CallbackVerifyResult,
|
|
type RefundParams,
|
|
type RefundResult,
|
|
} from './payment-gateway.interface';
|
|
|
|
/**
|
|
* Bank transfer payment gateway.
|
|
*
|
|
* Unlike VNPay/MoMo/ZaloPay, bank transfers have no external redirect.
|
|
* `createPaymentUrl` returns a reference page URL with transfer instructions
|
|
* (bank name, account number, amount, transfer content).
|
|
* Confirmation is manual — an admin calls the confirm-transfer endpoint
|
|
* which triggers `verifyCallback` with admin-signed data.
|
|
*/
|
|
@Injectable()
|
|
export class BankTransferService implements IPaymentGateway {
|
|
readonly provider: PaymentProvider = 'BANK_TRANSFER';
|
|
|
|
private readonly bankAccountNumber: string;
|
|
private readonly bankName: string;
|
|
private readonly accountHolder: string;
|
|
private readonly webhookSecret: string;
|
|
private readonly instructionsBaseUrl: string;
|
|
|
|
constructor(
|
|
private readonly config: ConfigService,
|
|
private readonly logger: LoggerService,
|
|
) {
|
|
this.bankAccountNumber = this.config.getOrThrow<string>('BANK_TRANSFER_ACCOUNT_NUMBER');
|
|
this.bankName = this.config.getOrThrow<string>('BANK_TRANSFER_BANK_NAME');
|
|
this.accountHolder = this.config.getOrThrow<string>('BANK_TRANSFER_ACCOUNT_HOLDER');
|
|
this.webhookSecret = this.config.getOrThrow<string>('BANK_TRANSFER_WEBHOOK_SECRET');
|
|
this.instructionsBaseUrl = this.config.get<string>(
|
|
'BANK_TRANSFER_INSTRUCTIONS_URL',
|
|
'https://goodgo.vn/thanh-toan/chuyen-khoan',
|
|
);
|
|
}
|
|
|
|
async createPaymentUrl(params: CreatePaymentUrlParams): Promise<CreatePaymentUrlResult> {
|
|
const transferContent = `GG ${params.orderId}`;
|
|
const providerTxId = `BT-${params.orderId}`;
|
|
|
|
const queryParams = new URLSearchParams({
|
|
ref: providerTxId,
|
|
amount: params.amountVND.toString(),
|
|
bank: this.bankName,
|
|
account: this.bankAccountNumber,
|
|
holder: this.accountHolder,
|
|
content: transferContent,
|
|
});
|
|
|
|
const paymentUrl = `${this.instructionsBaseUrl}?${queryParams.toString()}`;
|
|
|
|
this.logger.log(
|
|
`Bank transfer instructions created for order ${params.orderId}: bank=${this.bankName}, amount=${params.amountVND}`,
|
|
'BankTransferService',
|
|
);
|
|
|
|
return { paymentUrl, providerTxId };
|
|
}
|
|
|
|
verifyCallback(data: Record<string, string>): CallbackVerifyResult {
|
|
const signature = data['signature'] ?? '';
|
|
const orderId = data['orderId'] ?? '';
|
|
const providerTxId = data['providerTxId'] ?? '';
|
|
const status = data['status'] ?? '';
|
|
const confirmedBy = data['confirmedBy'] ?? '';
|
|
|
|
// Build canonical string for HMAC verification
|
|
const canonicalString = [orderId, providerTxId, status, confirmedBy].join('|');
|
|
const hmac = crypto.createHmac('sha256', this.webhookSecret);
|
|
const expectedSignature = hmac.update(Buffer.from(canonicalString, 'utf-8')).digest('hex');
|
|
|
|
let isValid = false;
|
|
try {
|
|
isValid =
|
|
signature.length === expectedSignature.length &&
|
|
crypto.timingSafeEqual(
|
|
Buffer.from(signature, 'hex'),
|
|
Buffer.from(expectedSignature, 'hex'),
|
|
);
|
|
} catch {
|
|
isValid = false;
|
|
}
|
|
|
|
const isSuccess = isValid && status === 'CONFIRMED';
|
|
|
|
this.logger.log(
|
|
`Bank transfer callback verified: orderId=${orderId}, valid=${isValid}, success=${isSuccess}`,
|
|
'BankTransferService',
|
|
);
|
|
|
|
return {
|
|
isValid,
|
|
orderId,
|
|
providerTxId,
|
|
isSuccess,
|
|
rawData: data,
|
|
};
|
|
}
|
|
|
|
async refund(params: RefundParams): Promise<RefundResult> {
|
|
// Bank transfer refunds require manual processing — not automatable
|
|
this.logger.warn(
|
|
`Bank transfer refund requires manual processing: providerTxId=${params.providerTxId}, amount=${params.amountVND}, reason=${params.reason}`,
|
|
'BankTransferService',
|
|
);
|
|
|
|
return { success: false, refundTxId: null };
|
|
}
|
|
|
|
/**
|
|
* Generate an HMAC-SHA256 signature for admin confirmation data.
|
|
* Used internally by the confirm-transfer command handler.
|
|
*/
|
|
generateConfirmationSignature(
|
|
orderId: string,
|
|
providerTxId: string,
|
|
status: string,
|
|
confirmedBy: string,
|
|
): string {
|
|
const canonicalString = [orderId, providerTxId, status, confirmedBy].join('|');
|
|
const hmac = crypto.createHmac('sha256', this.webhookSecret);
|
|
return hmac.update(Buffer.from(canonicalString, 'utf-8')).digest('hex');
|
|
}
|
|
|
|
/** Return bank transfer display info for a payment. */
|
|
getBankTransferInfo(): {
|
|
bankName: string;
|
|
accountNumber: string;
|
|
accountHolder: string;
|
|
} {
|
|
return {
|
|
bankName: this.bankName,
|
|
accountNumber: this.bankAccountNumber,
|
|
accountHolder: this.accountHolder,
|
|
};
|
|
}
|
|
}
|