Files
goodgo-platform/apps/api/src/modules/payments/infrastructure/services/bank-transfer.service.ts
Ho Ngoc Hai 312532b1cb fix(api): resolve NestJS DI + ValidationPipe bugs from type-only imports
- Remove `type` modifier from imports used as DI constructor params
  across ~235 files (@Injectable, @Controller, @Module, @Catch,
  @CommandHandler, @QueryHandler, @EventsHandler, @WebSocketGateway).
  TypeScript emitDecoratorMetadata strips type-only imports, leaving
  Reflect.metadata with Function placeholder and breaking Nest DI.
- Fix controllers: DTOs used with @Body/@Query/@Param must be runtime
  imports so ValidationPipe can whitelist properties. Previously
  returned 400 "property X should not exist" on every request.
- Register ProjectsModule in AppModule (was defined but never wired).
- Add approve()/reject() methods to TransferListingEntity referenced by
  ModerateTransferListingHandler.
- Export BankTransferConfirmedEvent from payments barrel for
  subscription activation handler.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:50:30 +07:00

149 lines
4.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';
/**
* 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,
};
}
}