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:
Ho Ngoc Hai
2026-04-16 02:34:54 +07:00
parent 18bb6bfe17
commit 89aaa25bb6
11 changed files with 760 additions and 6 deletions

View File

@@ -0,0 +1,182 @@
import { PaymentEntity } from '../../domain/entities/payment.entity';
import { type IPaymentRepository } from '../../domain/repositories/payment.repository';
import { Money } from '../../domain/value-objects/money.vo';
import { ConfirmBankTransferCommand } from '../commands/confirm-bank-transfer/confirm-bank-transfer.command';
import { ConfirmBankTransferHandler } from '../commands/confirm-bank-transfer/confirm-bank-transfer.handler';
function createPendingBankTransferPayment(id = 'pay-bt-1'): PaymentEntity {
const money = Money.create(1_000_000n).unwrap();
const payment = PaymentEntity.createNew(id, 'user-1', 'BANK_TRANSFER', 'SUBSCRIPTION', money);
payment.markProcessing('BT-' + id);
payment.clearDomainEvents();
return payment;
}
function createCompletedBankTransferPayment(): PaymentEntity {
const money = Money.create(1_000_000n).unwrap();
const payment = PaymentEntity.createNew('pay-bt-done', 'user-1', 'BANK_TRANSFER', 'SUBSCRIPTION', money);
payment.markProcessing('BT-pay-bt-done');
payment.markCompleted({ confirmedBy: 'admin-prev' });
payment.clearDomainEvents();
return payment;
}
describe('ConfirmBankTransferHandler', () => {
let handler: ConfirmBankTransferHandler;
let mockPaymentRepo: { [K in keyof IPaymentRepository]: ReturnType<typeof vi.fn> };
let mockBankTransferService: {
generateConfirmationSignature: ReturnType<typeof vi.fn>;
verifyCallback: ReturnType<typeof vi.fn>;
};
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockPaymentRepo = {
findById: vi.fn(),
findByProviderTxId: vi.fn(),
findByIdempotencyKey: vi.fn(),
findByUserId: vi.fn(),
save: vi.fn(),
update: vi.fn(),
updateIfStatus: vi.fn(),
};
mockBankTransferService = {
generateConfirmationSignature: vi.fn().mockReturnValue('valid-signature-hex'),
verifyCallback: vi.fn().mockReturnValue({
isValid: true,
orderId: 'pay-bt-1',
providerTxId: 'BT-pay-bt-1',
isSuccess: true,
rawData: { orderId: 'pay-bt-1', status: 'CONFIRMED' },
}),
};
mockEventBus = { publish: vi.fn() };
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new ConfirmBankTransferHandler(
mockPaymentRepo as any,
mockBankTransferService as any,
mockEventBus as any,
mockLogger as any,
);
});
it('should confirm a pending bank transfer payment', async () => {
const payment = createPendingBankTransferPayment();
mockPaymentRepo.findById.mockResolvedValue(payment);
const updatedPayment = createPendingBankTransferPayment();
(updatedPayment as any)._status = 'COMPLETED';
mockPaymentRepo.updateIfStatus.mockResolvedValue(updatedPayment);
const command = new ConfirmBankTransferCommand('pay-bt-1', 'admin-user-1', 'FT123456');
const result = await handler.execute(command);
expect(result.paymentId).toBe('pay-bt-1');
expect(result.status).toBe('COMPLETED');
expect(result.confirmedBy).toBe('admin-user-1');
expect(mockBankTransferService.generateConfirmationSignature).toHaveBeenCalledWith(
'pay-bt-1',
'BT-pay-bt-1',
'CONFIRMED',
'admin-user-1',
);
expect(mockPaymentRepo.updateIfStatus).toHaveBeenCalledWith(
'pay-bt-1',
['PENDING', 'PROCESSING'],
expect.objectContaining({ status: 'COMPLETED' }),
);
expect(mockEventBus.publish).toHaveBeenCalled();
});
it('should include bankReference in callback data when provided', async () => {
const payment = createPendingBankTransferPayment();
mockPaymentRepo.findById.mockResolvedValue(payment);
const updatedPayment = createPendingBankTransferPayment();
(updatedPayment as any)._status = 'COMPLETED';
mockPaymentRepo.updateIfStatus.mockResolvedValue(updatedPayment);
const command = new ConfirmBankTransferCommand('pay-bt-1', 'admin-1', 'FT999888');
await handler.execute(command);
// verifyCallback should have been called with bankReference included
expect(mockBankTransferService.verifyCallback).toHaveBeenCalledWith(
expect.objectContaining({
bankReference: 'FT999888',
}),
);
});
it('should throw NotFoundException when payment does not exist', async () => {
mockPaymentRepo.findById.mockResolvedValue(null);
const command = new ConfirmBankTransferCommand('nonexistent', 'admin-1');
await expect(handler.execute(command)).rejects.toThrow('Payment');
});
it('should throw ValidationException when payment is not BANK_TRANSFER', async () => {
const money = Money.create(500_000n).unwrap();
const vnpayPayment = PaymentEntity.createNew('pay-vnpay', 'user-1', 'VNPAY', 'SUBSCRIPTION', money);
vnpayPayment.clearDomainEvents();
mockPaymentRepo.findById.mockResolvedValue(vnpayPayment);
const command = new ConfirmBankTransferCommand('pay-vnpay', 'admin-1');
await expect(handler.execute(command)).rejects.toThrow(/chuyển khoản ngân hàng/);
});
it('should handle already-completed payment idempotently', async () => {
const payment = createPendingBankTransferPayment('pay-bt-1');
// Create a completed version with the same ID
const money = Money.create(1_000_000n).unwrap();
const completedPayment = PaymentEntity.createNew('pay-bt-1', 'user-1', 'BANK_TRANSFER', 'SUBSCRIPTION', money);
completedPayment.markProcessing('BT-pay-bt-1');
completedPayment.markCompleted({ confirmedBy: 'admin-prev' });
completedPayment.clearDomainEvents();
// First call: find the payment for initial check
// Second call: find again after updateIfStatus returns null
mockPaymentRepo.findById
.mockResolvedValueOnce(payment)
.mockResolvedValueOnce(completedPayment);
// updateIfStatus returns null — payment already in terminal state
mockPaymentRepo.updateIfStatus.mockResolvedValue(null);
const command = new ConfirmBankTransferCommand('pay-bt-1', 'admin-1');
const result = await handler.execute(command);
expect(result.paymentId).toBe('pay-bt-1');
expect(result.status).toBe('COMPLETED');
expect(result.confirmedBy).toBe('admin-1');
});
it('should throw when payment is in non-confirmable terminal state', async () => {
const money = Money.create(500_000n).unwrap();
const failedPayment = PaymentEntity.createNew('pay-bt-failed', 'user-1', 'BANK_TRANSFER', 'SUBSCRIPTION', money);
(failedPayment as any)._status = 'FAILED';
(failedPayment as any)._providerTxId = 'BT-pay-bt-failed';
failedPayment.clearDomainEvents();
// First findById: initial check returns the failed payment
// Second findById: re-check after updateIfStatus returns null
mockPaymentRepo.findById
.mockResolvedValueOnce(failedPayment)
.mockResolvedValueOnce(failedPayment);
mockPaymentRepo.updateIfStatus.mockResolvedValue(null);
const command = new ConfirmBankTransferCommand('pay-bt-failed', 'admin-1');
await expect(handler.execute(command)).rejects.toThrow(/trạng thái/);
});
});

View File

@@ -0,0 +1,7 @@
export class ConfirmBankTransferCommand {
constructor(
public readonly paymentId: string,
public readonly confirmedBy: string,
public readonly bankReference?: string,
) {}
}

View File

@@ -0,0 +1,144 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, EventBus, ICommandHandler } from '@nestjs/cqrs';
import { PaymentStatus } from '@prisma/client';
import {
DomainException,
NotFoundException,
ValidationException,
LoggerService,
} from '@modules/shared';
import {
PAYMENT_REPOSITORY,
IPaymentRepository,
} from '../../../domain/repositories/payment.repository';
import { BankTransferService } from '../../../infrastructure/services/bank-transfer.service';
import { ConfirmBankTransferCommand } from './confirm-bank-transfer.command';
export interface ConfirmBankTransferResult {
paymentId: string;
status: string;
confirmedBy: string;
}
@CommandHandler(ConfirmBankTransferCommand)
export class ConfirmBankTransferHandler
implements ICommandHandler<ConfirmBankTransferCommand>
{
constructor(
@Inject(PAYMENT_REPOSITORY)
private readonly paymentRepo: IPaymentRepository,
private readonly bankTransferService: BankTransferService,
private readonly eventBus: EventBus,
private readonly logger: LoggerService,
) {}
async execute(
command: ConfirmBankTransferCommand,
): Promise<ConfirmBankTransferResult> {
try {
// Find the payment first
const payment = await this.paymentRepo.findById(command.paymentId);
if (!payment) {
throw new NotFoundException('Payment', command.paymentId);
}
// Ensure it's a bank transfer payment
if (payment.provider !== 'BANK_TRANSFER') {
throw new ValidationException(
`Chỉ có thể xác nhận thanh toán chuyển khoản ngân hàng, nhưng thanh toán này là ${payment.provider}`,
);
}
// Generate signed callback data using the bank transfer service
const providerTxId = payment.providerTxId ?? `BT-${payment.id}`;
const signature = this.bankTransferService.generateConfirmationSignature(
payment.id,
providerTxId,
'CONFIRMED',
command.confirmedBy,
);
const callbackData: Record<string, string> = {
orderId: payment.id,
providerTxId,
status: 'CONFIRMED',
confirmedBy: command.confirmedBy,
signature,
};
if (command.bankReference) {
callbackData['bankReference'] = command.bankReference;
}
// Verify via gateway (ensures signing consistency)
const verifyResult = this.bankTransferService.verifyCallback(callbackData);
if (!verifyResult.isValid || !verifyResult.isSuccess) {
throw new InternalServerErrorException(
'Lỗi nội bộ khi xác minh chữ ký xác nhận',
);
}
// Atomically update payment status
const targetStatus: PaymentStatus = 'COMPLETED';
const updated = await this.paymentRepo.updateIfStatus(
payment.id,
['PENDING', 'PROCESSING'],
{
status: targetStatus,
callbackData: {
...verifyResult.rawData,
confirmedAt: new Date().toISOString(),
},
},
);
if (!updated) {
// Payment already in terminal state — return idempotent response
const existing = await this.paymentRepo.findById(payment.id);
if (existing && existing.status === 'COMPLETED') {
this.logger.log(
`Bank transfer ${payment.id} already confirmed`,
'ConfirmBankTransferHandler',
);
return {
paymentId: payment.id,
status: existing.status,
confirmedBy: command.confirmedBy,
};
}
throw new ValidationException(
`Không thể xác nhận thanh toán ở trạng thái ${existing?.status ?? 'unknown'}`,
);
}
// Publish domain events
updated.emitCompleted();
const events = updated.clearDomainEvents();
for (const event of events) {
this.eventBus.publish(event);
}
this.logger.log(
`Bank transfer confirmed: paymentId=${payment.id}, confirmedBy=${command.confirmedBy}`,
'ConfirmBankTransferHandler',
);
return {
paymentId: updated.id,
status: updated.status,
confirmedBy: command.confirmedBy,
};
} catch (error) {
if (error instanceof DomainException) throw error;
this.logger.error(
`Failed to confirm bank transfer: ${error instanceof Error ? error.message : error}`,
error instanceof Error ? error.stack : undefined,
this.constructor.name,
);
throw new InternalServerErrorException(
'Không thể xác nhận chuyển khoản. Vui lòng thử lại sau',
);
}
}
}

View File

@@ -0,0 +1,220 @@
import * as crypto from 'crypto';
import { type ConfigService } from '@nestjs/config';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { BankTransferService } from '../services/bank-transfer.service';
describe('BankTransferService', () => {
let service: BankTransferService;
const webhookSecret = 'test-webhook-secret-key-12345678';
beforeEach(() => {
const mockConfig = {
get: vi.fn((key: string, defaultValue?: string) => {
const env: Record<string, string> = {
BANK_TRANSFER_ACCOUNT_NUMBER: '0123456789',
BANK_TRANSFER_BANK_NAME: 'Vietcombank',
BANK_TRANSFER_ACCOUNT_HOLDER: 'CONG TY GOODGO',
BANK_TRANSFER_WEBHOOK_SECRET: webhookSecret,
BANK_TRANSFER_INSTRUCTIONS_URL: 'https://goodgo.vn/thanh-toan/chuyen-khoan',
};
return env[key] ?? defaultValue;
}),
getOrThrow: vi.fn((key: string) => {
const env: Record<string, string> = {
BANK_TRANSFER_ACCOUNT_NUMBER: '0123456789',
BANK_TRANSFER_BANK_NAME: 'Vietcombank',
BANK_TRANSFER_ACCOUNT_HOLDER: 'CONG TY GOODGO',
BANK_TRANSFER_WEBHOOK_SECRET: webhookSecret,
};
if (!env[key]) throw new Error(`Missing ${key}`);
return env[key];
}),
} as unknown as ConfigService;
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
service = new BankTransferService(mockConfig, mockLogger as any);
});
describe('provider', () => {
it('should have BANK_TRANSFER provider', () => {
expect(service.provider).toBe('BANK_TRANSFER');
});
});
describe('createPaymentUrl', () => {
it('should return a payment URL with transfer instructions', async () => {
const result = await service.createPaymentUrl({
orderId: 'order-123',
amountVND: 500_000n,
description: 'Test payment',
returnUrl: 'https://goodgo.vn/callback',
ipAddress: '127.0.0.1',
});
expect(result.paymentUrl).toContain('https://goodgo.vn/thanh-toan/chuyen-khoan');
expect(result.paymentUrl).toContain('ref=BT-order-123');
expect(result.paymentUrl).toContain('amount=500000');
expect(result.paymentUrl).toContain('bank=Vietcombank');
expect(result.paymentUrl).toContain('account=0123456789');
expect(result.paymentUrl).toContain('holder=CONG+TY+GOODGO');
expect(result.paymentUrl).toContain('content=GG+order-123');
expect(result.providerTxId).toBe('BT-order-123');
});
it('should generate unique providerTxId per order', async () => {
const result1 = await service.createPaymentUrl({
orderId: 'order-1',
amountVND: 100_000n,
description: 'Payment 1',
returnUrl: 'https://goodgo.vn/callback',
ipAddress: '127.0.0.1',
});
const result2 = await service.createPaymentUrl({
orderId: 'order-2',
amountVND: 200_000n,
description: 'Payment 2',
returnUrl: 'https://goodgo.vn/callback',
ipAddress: '127.0.0.1',
});
expect(result1.providerTxId).toBe('BT-order-1');
expect(result2.providerTxId).toBe('BT-order-2');
expect(result1.providerTxId).not.toBe(result2.providerTxId);
});
});
describe('verifyCallback', () => {
function generateSignature(orderId: string, providerTxId: string, status: string, confirmedBy: string): string {
const canonicalString = [orderId, providerTxId, status, confirmedBy].join('|');
const hmac = crypto.createHmac('sha256', webhookSecret);
return hmac.update(Buffer.from(canonicalString, 'utf-8')).digest('hex');
}
it('should verify a valid confirmed callback', () => {
const orderId = 'order-123';
const providerTxId = 'BT-order-123';
const status = 'CONFIRMED';
const confirmedBy = 'admin-user-1';
const signature = generateSignature(orderId, providerTxId, status, confirmedBy);
const result = service.verifyCallback({
orderId,
providerTxId,
status,
confirmedBy,
signature,
});
expect(result.isValid).toBe(true);
expect(result.isSuccess).toBe(true);
expect(result.orderId).toBe('order-123');
expect(result.providerTxId).toBe('BT-order-123');
});
it('should reject an invalid signature', () => {
const result = service.verifyCallback({
orderId: 'order-123',
providerTxId: 'BT-order-123',
status: 'CONFIRMED',
confirmedBy: 'admin-user-1',
signature: 'invalid-signature-value',
});
expect(result.isValid).toBe(false);
expect(result.isSuccess).toBe(false);
});
it('should reject when status is not CONFIRMED', () => {
const orderId = 'order-123';
const providerTxId = 'BT-order-123';
const status = 'REJECTED';
const confirmedBy = 'admin-user-1';
const signature = generateSignature(orderId, providerTxId, status, confirmedBy);
const result = service.verifyCallback({
orderId,
providerTxId,
status,
confirmedBy,
signature,
});
expect(result.isValid).toBe(true);
expect(result.isSuccess).toBe(false);
});
it('should reject when signature is empty', () => {
const result = service.verifyCallback({
orderId: 'order-123',
providerTxId: 'BT-order-123',
status: 'CONFIRMED',
confirmedBy: 'admin-user-1',
signature: '',
});
expect(result.isValid).toBe(false);
expect(result.isSuccess).toBe(false);
});
it('should handle missing fields gracefully', () => {
const result = service.verifyCallback({});
expect(result.isValid).toBe(false);
expect(result.isSuccess).toBe(false);
expect(result.orderId).toBe('');
expect(result.providerTxId).toBe('');
});
});
describe('refund', () => {
it('should return unsuccessful result (manual processing required)', async () => {
const result = await service.refund({
providerTxId: 'BT-order-123',
amountVND: 500_000n,
reason: 'Customer refund request',
});
expect(result.success).toBe(false);
expect(result.refundTxId).toBeNull();
});
});
describe('generateConfirmationSignature', () => {
it('should produce consistent signatures', () => {
const sig1 = service.generateConfirmationSignature('order-1', 'BT-order-1', 'CONFIRMED', 'admin');
const sig2 = service.generateConfirmationSignature('order-1', 'BT-order-1', 'CONFIRMED', 'admin');
expect(sig1).toBe(sig2);
expect(sig1).toHaveLength(64); // SHA-256 hex digest
});
it('should produce different signatures for different inputs', () => {
const sig1 = service.generateConfirmationSignature('order-1', 'BT-order-1', 'CONFIRMED', 'admin');
const sig2 = service.generateConfirmationSignature('order-2', 'BT-order-2', 'CONFIRMED', 'admin');
expect(sig1).not.toBe(sig2);
});
it('should produce a signature that verifyCallback validates', () => {
const orderId = 'order-456';
const providerTxId = 'BT-order-456';
const status = 'CONFIRMED';
const confirmedBy = 'admin-user-2';
const signature = service.generateConfirmationSignature(orderId, providerTxId, status, confirmedBy);
const result = service.verifyCallback({ orderId, providerTxId, status, confirmedBy, signature });
expect(result.isValid).toBe(true);
expect(result.isSuccess).toBe(true);
});
});
describe('getBankTransferInfo', () => {
it('should return configured bank transfer information', () => {
const info = service.getBankTransferInfo();
expect(info.bankName).toBe('Vietcombank');
expect(info.accountNumber).toBe('0123456789');
expect(info.accountHolder).toBe('CONG TY GOODGO');
});
});
});

View File

@@ -1,5 +1,6 @@
import { type ConfigService } from '@nestjs/config';
import { describe, it, expect, vi } from 'vitest';
import { BankTransferService } from '../services/bank-transfer.service';
import { MomoService } from '../services/momo.service';
import { PaymentGatewayFactory } from '../services/payment-gateway.factory';
import { VnpayService } from '../services/vnpay.service';
@@ -10,12 +11,14 @@ describe('PaymentGatewayFactory', () => {
get: vi.fn((key: string, defaultValue?: string) => defaultValue ?? 'test'),
getOrThrow: vi.fn(() => 'test-value'),
} as unknown as ConfigService;
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
const vnpay = new VnpayService(mockConfig);
const momo = new MomoService(mockConfig);
const zalopay = new ZalopayService(mockConfig);
const vnpay = new VnpayService(mockConfig, mockLogger as any);
const momo = new MomoService(mockConfig, mockLogger as any);
const zalopay = new ZalopayService(mockConfig, mockLogger as any);
const bankTransfer = new BankTransferService(mockConfig, mockLogger as any);
const factory = new PaymentGatewayFactory(vnpay, momo, zalopay);
const factory = new PaymentGatewayFactory(vnpay, momo, zalopay, bankTransfer);
it('should return VNPay gateway', () => {
const gateway = factory.getGateway('VNPAY');
@@ -35,8 +38,14 @@ describe('PaymentGatewayFactory', () => {
expect(gateway.provider).toBe('ZALOPAY');
});
it('should return BankTransfer gateway', () => {
const gateway = factory.getGateway('BANK_TRANSFER');
expect(gateway).toBe(bankTransfer);
expect(gateway.provider).toBe('BANK_TRANSFER');
});
it('should throw for unsupported provider', () => {
expect(() => factory.getGateway('BANK_TRANSFER')).toThrow(
expect(() => factory.getGateway('UNKNOWN' as any)).toThrow(
'Nhà cung cấp thanh toán không được hỗ trợ',
);
});

View File

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

View File

@@ -9,6 +9,7 @@ export {
type RefundResult,
} from './payment-gateway.interface';
export { PaymentGatewayFactory } from './payment-gateway.factory';
export { BankTransferService } from './bank-transfer.service';
export { VnpayService } from './vnpay.service';
export { MomoService } from './momo.service';
export { ZalopayService } from './zalopay.service';

View File

@@ -1,5 +1,6 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { PaymentProvider } from '@prisma/client';
import { BankTransferService } from './bank-transfer.service';
import { MomoService } from './momo.service';
import {
type IPaymentGateway,
@@ -16,11 +17,13 @@ export class PaymentGatewayFactory implements IPaymentGatewayFactory {
private readonly vnpay: VnpayService,
private readonly momo: MomoService,
private readonly zalopay: ZalopayService,
private readonly bankTransfer: BankTransferService,
) {
this.gateways = new Map<PaymentProvider, IPaymentGateway>([
['VNPAY', vnpay],
['MOMO', momo],
['ZALOPAY', zalopay],
['BANK_TRANSFER', bankTransfer],
]);
}

View File

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { CancelOrderHandler } from './application/commands/cancel-order/cancel-order.handler';
import { ConfirmBankTransferHandler } from './application/commands/confirm-bank-transfer/confirm-bank-transfer.handler';
import { CreateOrderHandler } from './application/commands/create-order/create-order.handler';
import { CreatePaymentHandler } from './application/commands/create-payment/create-payment.handler';
import { HandleCallbackHandler } from './application/commands/handle-callback/handle-callback.handler';
@@ -16,6 +17,7 @@ import { PAYMENT_REPOSITORY } from './domain/repositories/payment.repository';
import { PrismaEscrowRepository } from './infrastructure/repositories/prisma-escrow.repository';
import { PrismaOrderRepository } from './infrastructure/repositories/prisma-order.repository';
import { PrismaPaymentRepository } from './infrastructure/repositories/prisma-payment.repository';
import { BankTransferService } from './infrastructure/services/bank-transfer.service';
import { MomoService } from './infrastructure/services/momo.service';
import { PaymentGatewayFactory } from './infrastructure/services/payment-gateway.factory';
import { PAYMENT_GATEWAY_FACTORY } from './infrastructure/services/payment-gateway.interface';
@@ -26,6 +28,7 @@ import { PaymentsController } from './presentation/controllers/payments.controll
const CommandHandlers = [
CancelOrderHandler,
ConfirmBankTransferHandler,
CreateOrderHandler,
CreatePaymentHandler,
HandleCallbackHandler,
@@ -53,6 +56,7 @@ const QueryHandlers = [
VnpayService,
MomoService,
ZalopayService,
BankTransferService,
{ provide: PAYMENT_GATEWAY_FACTORY, useClass: PaymentGatewayFactory },
// CQRS

View File

@@ -20,6 +20,8 @@ import { Throttle } from '@nestjs/throttler';
import { PaymentProvider } from '@prisma/client';
import { JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared';
import { ConfirmBankTransferCommand } from '../../application/commands/confirm-bank-transfer/confirm-bank-transfer.command';
import { ConfirmBankTransferResult } from '../../application/commands/confirm-bank-transfer/confirm-bank-transfer.handler';
import { CreatePaymentCommand } from '../../application/commands/create-payment/create-payment.command';
import { CreatePaymentResult } from '../../application/commands/create-payment/create-payment.handler';
import { HandleCallbackCommand } from '../../application/commands/handle-callback/handle-callback.command';
@@ -30,6 +32,7 @@ import { PaymentStatusDto } from '../../application/queries/get-payment-status/g
import { GetPaymentStatusQuery } from '../../application/queries/get-payment-status/get-payment-status.query';
import { TransactionListDto } from '../../application/queries/list-transactions/list-transactions.handler';
import { ListTransactionsQuery } from '../../application/queries/list-transactions/list-transactions.query';
import { ConfirmBankTransferDto } from '../dto/confirm-bank-transfer.dto';
import { CreatePaymentDto } from '../dto/create-payment.dto';
import { ListTransactionsDto } from '../dto/list-transactions.dto';
import { RefundPaymentDto } from '../dto/refund-payment.dto';
@@ -71,7 +74,7 @@ export class PaymentsController {
@ApiOperation({ summary: 'Handle payment provider callback (webhook)' })
@ApiResponse({ status: 201, description: 'Callback processed successfully' })
@ApiParam({ name: 'provider', enum: ['vnpay', 'momo', 'zalopay'] })
@ApiParam({ name: 'provider', enum: ['vnpay', 'momo', 'zalopay', 'bank_transfer'] })
@Throttle({ 'payment-callback': { ttl: 60_000, limit: 20 } })
@EndpointRateLimit({ limit: 100, windowSeconds: 60, keyStrategy: 'ip', adminBypass: false })
@UseGuards(EndpointRateLimitGuard)
@@ -136,4 +139,24 @@ export class PaymentsController {
new RefundPaymentCommand(id, dto.reason, user.sub),
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Confirm a bank transfer payment (admin only)' })
@ApiResponse({ status: 201, description: 'Bank transfer confirmed successfully' })
@ApiResponse({ status: 400, description: 'Payment is not a bank transfer or invalid status' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden — admin role required' })
@ApiResponse({ status: 404, description: 'Payment not found' })
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('ADMIN')
@Post(':id/confirm-transfer')
async confirmBankTransfer(
@Param('id') id: string,
@Body() dto: ConfirmBankTransferDto,
@CurrentUser() user: JwtPayload,
): Promise<ConfirmBankTransferResult> {
return this.commandBus.execute(
new ConfirmBankTransferCommand(id, user.sub, dto.bankReference),
);
}
}

View File

@@ -0,0 +1,13 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString, MinLength } from 'class-validator';
export class ConfirmBankTransferDto {
@ApiPropertyOptional({
description: 'Bank reference number from the actual bank transfer receipt',
example: 'FT26105123456789',
})
@IsOptional()
@IsString()
@MinLength(1)
bankReference?: string;
}