feat(payments): implement BankTransferService payment gateway with admin confirmation
Add BANK_TRANSFER as a fully supported payment provider: - BankTransferService implementing IPaymentGateway with HMAC-SHA256 verification - ConfirmBankTransferCommand/Handler for admin manual payment confirmation - POST /payments/:id/confirm-transfer admin endpoint (RBAC-protected) - Atomic status updates with idempotency (PENDING/PROCESSING → COMPLETED) - Registered in PaymentGatewayFactory alongside VNPAY, MOMO, ZALOPAY - Comprehensive unit tests for service and handler Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import * as crypto from 'crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { 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';
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user