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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user