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('BANK_TRANSFER_ACCOUNT_NUMBER'); this.bankName = this.config.getOrThrow('BANK_TRANSFER_BANK_NAME'); this.accountHolder = this.config.getOrThrow('BANK_TRANSFER_ACCOUNT_HOLDER'); this.webhookSecret = this.config.getOrThrow('BANK_TRANSFER_WEBHOOK_SECRET'); this.instructionsBaseUrl = this.config.get( 'BANK_TRANSFER_INSTRUCTIONS_URL', 'https://goodgo.vn/thanh-toan/chuyen-khoan', ); } async createPaymentUrl(params: CreatePaymentUrlParams): Promise { 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): 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 { // 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, }; } }