From 89aaa25bb6a0d372b131e680fdffbbc89ec8a4c4 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 16 Apr 2026 02:34:54 +0700 Subject: [PATCH] feat(payments): implement BankTransferService payment gateway with admin confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../confirm-bank-transfer.handler.spec.ts | 182 +++++++++++++++ .../confirm-bank-transfer.command.ts | 7 + .../confirm-bank-transfer.handler.ts | 144 ++++++++++++ .../__tests__/bank-transfer.service.spec.ts | 220 ++++++++++++++++++ .../__tests__/payment-gateway.factory.spec.ts | 19 +- .../services/bank-transfer.service.ts | 148 ++++++++++++ .../payments/infrastructure/services/index.ts | 1 + .../services/payment-gateway.factory.ts | 3 + .../src/modules/payments/payments.module.ts | 4 + .../controllers/payments.controller.ts | 25 +- .../dto/confirm-bank-transfer.dto.ts | 13 ++ 11 files changed, 760 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/modules/payments/application/__tests__/confirm-bank-transfer.handler.spec.ts create mode 100644 apps/api/src/modules/payments/application/commands/confirm-bank-transfer/confirm-bank-transfer.command.ts create mode 100644 apps/api/src/modules/payments/application/commands/confirm-bank-transfer/confirm-bank-transfer.handler.ts create mode 100644 apps/api/src/modules/payments/infrastructure/__tests__/bank-transfer.service.spec.ts create mode 100644 apps/api/src/modules/payments/infrastructure/services/bank-transfer.service.ts create mode 100644 apps/api/src/modules/payments/presentation/dto/confirm-bank-transfer.dto.ts diff --git a/apps/api/src/modules/payments/application/__tests__/confirm-bank-transfer.handler.spec.ts b/apps/api/src/modules/payments/application/__tests__/confirm-bank-transfer.handler.spec.ts new file mode 100644 index 0000000..63a79cb --- /dev/null +++ b/apps/api/src/modules/payments/application/__tests__/confirm-bank-transfer.handler.spec.ts @@ -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 }; + let mockBankTransferService: { + generateConfirmationSignature: ReturnType; + verifyCallback: ReturnType; + }; + let mockEventBus: { publish: ReturnType }; + + 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/); + }); +}); diff --git a/apps/api/src/modules/payments/application/commands/confirm-bank-transfer/confirm-bank-transfer.command.ts b/apps/api/src/modules/payments/application/commands/confirm-bank-transfer/confirm-bank-transfer.command.ts new file mode 100644 index 0000000..970a6d9 --- /dev/null +++ b/apps/api/src/modules/payments/application/commands/confirm-bank-transfer/confirm-bank-transfer.command.ts @@ -0,0 +1,7 @@ +export class ConfirmBankTransferCommand { + constructor( + public readonly paymentId: string, + public readonly confirmedBy: string, + public readonly bankReference?: string, + ) {} +} diff --git a/apps/api/src/modules/payments/application/commands/confirm-bank-transfer/confirm-bank-transfer.handler.ts b/apps/api/src/modules/payments/application/commands/confirm-bank-transfer/confirm-bank-transfer.handler.ts new file mode 100644 index 0000000..c730561 --- /dev/null +++ b/apps/api/src/modules/payments/application/commands/confirm-bank-transfer/confirm-bank-transfer.handler.ts @@ -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 +{ + 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 { + 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 = { + 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', + ); + } + } +} diff --git a/apps/api/src/modules/payments/infrastructure/__tests__/bank-transfer.service.spec.ts b/apps/api/src/modules/payments/infrastructure/__tests__/bank-transfer.service.spec.ts new file mode 100644 index 0000000..c09f8e5 --- /dev/null +++ b/apps/api/src/modules/payments/infrastructure/__tests__/bank-transfer.service.spec.ts @@ -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 = { + 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 = { + 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'); + }); + }); +}); diff --git a/apps/api/src/modules/payments/infrastructure/__tests__/payment-gateway.factory.spec.ts b/apps/api/src/modules/payments/infrastructure/__tests__/payment-gateway.factory.spec.ts index 8240f95..8147b12 100644 --- a/apps/api/src/modules/payments/infrastructure/__tests__/payment-gateway.factory.spec.ts +++ b/apps/api/src/modules/payments/infrastructure/__tests__/payment-gateway.factory.spec.ts @@ -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ợ', ); }); diff --git a/apps/api/src/modules/payments/infrastructure/services/bank-transfer.service.ts b/apps/api/src/modules/payments/infrastructure/services/bank-transfer.service.ts new file mode 100644 index 0000000..ebecbbb --- /dev/null +++ b/apps/api/src/modules/payments/infrastructure/services/bank-transfer.service.ts @@ -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('BANK_TRANSFER_ACCOUNT_NUMBER'); + this.bankName = this.config.getOrThrow('BANK_TRANSFER_BANK_NAME'); + this.accountHolder = this.config.getOrThrow('BANK_TRANSFER_ACCOUNT_HOLDER'); + this.webhookSecret = this.config.getOrThrow('BANK_TRANSFER_WEBHOOK_SECRET'); + this.instructionsBaseUrl = this.config.get( + 'BANK_TRANSFER_INSTRUCTIONS_URL', + 'https://goodgo.vn/thanh-toan/chuyen-khoan', + ); + } + + async createPaymentUrl(params: CreatePaymentUrlParams): Promise { + 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): 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 { + // 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, + }; + } +} diff --git a/apps/api/src/modules/payments/infrastructure/services/index.ts b/apps/api/src/modules/payments/infrastructure/services/index.ts index c5dc9d6..babf8f1 100644 --- a/apps/api/src/modules/payments/infrastructure/services/index.ts +++ b/apps/api/src/modules/payments/infrastructure/services/index.ts @@ -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'; diff --git a/apps/api/src/modules/payments/infrastructure/services/payment-gateway.factory.ts b/apps/api/src/modules/payments/infrastructure/services/payment-gateway.factory.ts index 919b527..ec62d8d 100644 --- a/apps/api/src/modules/payments/infrastructure/services/payment-gateway.factory.ts +++ b/apps/api/src/modules/payments/infrastructure/services/payment-gateway.factory.ts @@ -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([ ['VNPAY', vnpay], ['MOMO', momo], ['ZALOPAY', zalopay], + ['BANK_TRANSFER', bankTransfer], ]); } diff --git a/apps/api/src/modules/payments/payments.module.ts b/apps/api/src/modules/payments/payments.module.ts index 009afb6..c394cc3 100644 --- a/apps/api/src/modules/payments/payments.module.ts +++ b/apps/api/src/modules/payments/payments.module.ts @@ -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 diff --git a/apps/api/src/modules/payments/presentation/controllers/payments.controller.ts b/apps/api/src/modules/payments/presentation/controllers/payments.controller.ts index 919177e..e2130c8 100644 --- a/apps/api/src/modules/payments/presentation/controllers/payments.controller.ts +++ b/apps/api/src/modules/payments/presentation/controllers/payments.controller.ts @@ -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 { + return this.commandBus.execute( + new ConfirmBankTransferCommand(id, user.sub, dto.bankReference), + ); + } } diff --git a/apps/api/src/modules/payments/presentation/dto/confirm-bank-transfer.dto.ts b/apps/api/src/modules/payments/presentation/dto/confirm-bank-transfer.dto.ts new file mode 100644 index 0000000..6b059ba --- /dev/null +++ b/apps/api/src/modules/payments/presentation/dto/confirm-bank-transfer.dto.ts @@ -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; +}