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 { type ConfigService } from '@nestjs/config';
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { BankTransferService } from '../services/bank-transfer.service';
|
||||||
import { MomoService } from '../services/momo.service';
|
import { MomoService } from '../services/momo.service';
|
||||||
import { PaymentGatewayFactory } from '../services/payment-gateway.factory';
|
import { PaymentGatewayFactory } from '../services/payment-gateway.factory';
|
||||||
import { VnpayService } from '../services/vnpay.service';
|
import { VnpayService } from '../services/vnpay.service';
|
||||||
@@ -10,12 +11,14 @@ describe('PaymentGatewayFactory', () => {
|
|||||||
get: vi.fn((key: string, defaultValue?: string) => defaultValue ?? 'test'),
|
get: vi.fn((key: string, defaultValue?: string) => defaultValue ?? 'test'),
|
||||||
getOrThrow: vi.fn(() => 'test-value'),
|
getOrThrow: vi.fn(() => 'test-value'),
|
||||||
} as unknown as ConfigService;
|
} 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 vnpay = new VnpayService(mockConfig, mockLogger as any);
|
||||||
const momo = new MomoService(mockConfig);
|
const momo = new MomoService(mockConfig, mockLogger as any);
|
||||||
const zalopay = new ZalopayService(mockConfig);
|
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', () => {
|
it('should return VNPay gateway', () => {
|
||||||
const gateway = factory.getGateway('VNPAY');
|
const gateway = factory.getGateway('VNPAY');
|
||||||
@@ -35,8 +38,14 @@ describe('PaymentGatewayFactory', () => {
|
|||||||
expect(gateway.provider).toBe('ZALOPAY');
|
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', () => {
|
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ợ',
|
'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,
|
type RefundResult,
|
||||||
} from './payment-gateway.interface';
|
} from './payment-gateway.interface';
|
||||||
export { PaymentGatewayFactory } from './payment-gateway.factory';
|
export { PaymentGatewayFactory } from './payment-gateway.factory';
|
||||||
|
export { BankTransferService } from './bank-transfer.service';
|
||||||
export { VnpayService } from './vnpay.service';
|
export { VnpayService } from './vnpay.service';
|
||||||
export { MomoService } from './momo.service';
|
export { MomoService } from './momo.service';
|
||||||
export { ZalopayService } from './zalopay.service';
|
export { ZalopayService } from './zalopay.service';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
import { PaymentProvider } from '@prisma/client';
|
import { PaymentProvider } from '@prisma/client';
|
||||||
|
import { BankTransferService } from './bank-transfer.service';
|
||||||
import { MomoService } from './momo.service';
|
import { MomoService } from './momo.service';
|
||||||
import {
|
import {
|
||||||
type IPaymentGateway,
|
type IPaymentGateway,
|
||||||
@@ -16,11 +17,13 @@ export class PaymentGatewayFactory implements IPaymentGatewayFactory {
|
|||||||
private readonly vnpay: VnpayService,
|
private readonly vnpay: VnpayService,
|
||||||
private readonly momo: MomoService,
|
private readonly momo: MomoService,
|
||||||
private readonly zalopay: ZalopayService,
|
private readonly zalopay: ZalopayService,
|
||||||
|
private readonly bankTransfer: BankTransferService,
|
||||||
) {
|
) {
|
||||||
this.gateways = new Map<PaymentProvider, IPaymentGateway>([
|
this.gateways = new Map<PaymentProvider, IPaymentGateway>([
|
||||||
['VNPAY', vnpay],
|
['VNPAY', vnpay],
|
||||||
['MOMO', momo],
|
['MOMO', momo],
|
||||||
['ZALOPAY', zalopay],
|
['ZALOPAY', zalopay],
|
||||||
|
['BANK_TRANSFER', bankTransfer],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { CqrsModule } from '@nestjs/cqrs';
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
import { CancelOrderHandler } from './application/commands/cancel-order/cancel-order.handler';
|
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 { CreateOrderHandler } from './application/commands/create-order/create-order.handler';
|
||||||
import { CreatePaymentHandler } from './application/commands/create-payment/create-payment.handler';
|
import { CreatePaymentHandler } from './application/commands/create-payment/create-payment.handler';
|
||||||
import { HandleCallbackHandler } from './application/commands/handle-callback/handle-callback.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 { PrismaEscrowRepository } from './infrastructure/repositories/prisma-escrow.repository';
|
||||||
import { PrismaOrderRepository } from './infrastructure/repositories/prisma-order.repository';
|
import { PrismaOrderRepository } from './infrastructure/repositories/prisma-order.repository';
|
||||||
import { PrismaPaymentRepository } from './infrastructure/repositories/prisma-payment.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 { MomoService } from './infrastructure/services/momo.service';
|
||||||
import { PaymentGatewayFactory } from './infrastructure/services/payment-gateway.factory';
|
import { PaymentGatewayFactory } from './infrastructure/services/payment-gateway.factory';
|
||||||
import { PAYMENT_GATEWAY_FACTORY } from './infrastructure/services/payment-gateway.interface';
|
import { PAYMENT_GATEWAY_FACTORY } from './infrastructure/services/payment-gateway.interface';
|
||||||
@@ -26,6 +28,7 @@ import { PaymentsController } from './presentation/controllers/payments.controll
|
|||||||
|
|
||||||
const CommandHandlers = [
|
const CommandHandlers = [
|
||||||
CancelOrderHandler,
|
CancelOrderHandler,
|
||||||
|
ConfirmBankTransferHandler,
|
||||||
CreateOrderHandler,
|
CreateOrderHandler,
|
||||||
CreatePaymentHandler,
|
CreatePaymentHandler,
|
||||||
HandleCallbackHandler,
|
HandleCallbackHandler,
|
||||||
@@ -53,6 +56,7 @@ const QueryHandlers = [
|
|||||||
VnpayService,
|
VnpayService,
|
||||||
MomoService,
|
MomoService,
|
||||||
ZalopayService,
|
ZalopayService,
|
||||||
|
BankTransferService,
|
||||||
{ provide: PAYMENT_GATEWAY_FACTORY, useClass: PaymentGatewayFactory },
|
{ provide: PAYMENT_GATEWAY_FACTORY, useClass: PaymentGatewayFactory },
|
||||||
|
|
||||||
// CQRS
|
// CQRS
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import { Throttle } from '@nestjs/throttler';
|
|||||||
import { PaymentProvider } from '@prisma/client';
|
import { PaymentProvider } from '@prisma/client';
|
||||||
import { JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
import { JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
||||||
import { EndpointRateLimit, EndpointRateLimitGuard } from '@modules/shared';
|
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 { CreatePaymentCommand } from '../../application/commands/create-payment/create-payment.command';
|
||||||
import { CreatePaymentResult } from '../../application/commands/create-payment/create-payment.handler';
|
import { CreatePaymentResult } from '../../application/commands/create-payment/create-payment.handler';
|
||||||
import { HandleCallbackCommand } from '../../application/commands/handle-callback/handle-callback.command';
|
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 { GetPaymentStatusQuery } from '../../application/queries/get-payment-status/get-payment-status.query';
|
||||||
import { TransactionListDto } from '../../application/queries/list-transactions/list-transactions.handler';
|
import { TransactionListDto } from '../../application/queries/list-transactions/list-transactions.handler';
|
||||||
import { ListTransactionsQuery } from '../../application/queries/list-transactions/list-transactions.query';
|
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 { CreatePaymentDto } from '../dto/create-payment.dto';
|
||||||
import { ListTransactionsDto } from '../dto/list-transactions.dto';
|
import { ListTransactionsDto } from '../dto/list-transactions.dto';
|
||||||
import { RefundPaymentDto } from '../dto/refund-payment.dto';
|
import { RefundPaymentDto } from '../dto/refund-payment.dto';
|
||||||
@@ -71,7 +74,7 @@ export class PaymentsController {
|
|||||||
|
|
||||||
@ApiOperation({ summary: 'Handle payment provider callback (webhook)' })
|
@ApiOperation({ summary: 'Handle payment provider callback (webhook)' })
|
||||||
@ApiResponse({ status: 201, description: 'Callback processed successfully' })
|
@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 } })
|
@Throttle({ 'payment-callback': { ttl: 60_000, limit: 20 } })
|
||||||
@EndpointRateLimit({ limit: 100, windowSeconds: 60, keyStrategy: 'ip', adminBypass: false })
|
@EndpointRateLimit({ limit: 100, windowSeconds: 60, keyStrategy: 'ip', adminBypass: false })
|
||||||
@UseGuards(EndpointRateLimitGuard)
|
@UseGuards(EndpointRateLimitGuard)
|
||||||
@@ -136,4 +139,24 @@ export class PaymentsController {
|
|||||||
new RefundPaymentCommand(id, dto.reason, user.sub),
|
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