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,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/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export class ConfirmBankTransferCommand {
|
||||
constructor(
|
||||
public readonly paymentId: string,
|
||||
public readonly confirmedBy: string,
|
||||
public readonly bankReference?: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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ợ',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user