Files
goodgo-platform/apps/api/src/modules/payments/infrastructure/services/bank-transfer.service.ts
Ho Ngoc Hai c920934fb6 fix(lint): enforce consistent-type-imports and fix import ordering across codebase
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>
2026-04-16 05:13:56 +07:00

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,
};
}
}