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',
);
}
}
}