fix(api): add error handling to remaining 51 CQRS handlers across 8 modules
Wraps every handler's execute() method in a try-catch block that: - Re-throws DomainExceptions to preserve structured error responses - Logs unexpected infrastructure errors with full context - Throws InternalServerErrorException with Vietnamese user message Modules updated: - auth (11 handlers: register, refresh-token, verify-kyc, deletions, profile queries) - listings (7 handlers: create, moderate, upload, status, search, queries) - payments (5 handlers: create, callback, refund, status, transactions) - subscriptions (7 handlers: create, cancel, upgrade, meter, quota, billing, plans) - analytics (8 handlers: reports, events, market-index, district, heatmap, trends, valuation) - search (9 handlers: saved-search CRUD, reindex, sync, geo-search, properties) - notifications (1 handler: send-notification) - agents (3 handlers: quality-score, dashboard, public-profile) Combined with the previous commit (29 handlers in admin, inquiries, leads, reviews), all 80+ CQRS handlers now have comprehensive error handling. Verification: - pnpm typecheck: 0 errors - pnpm test: 1387 tests passed (228 files) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { createId } from '@paralleldrive/cuid2';
|
||||
import { ConflictException, ValidationException, type LoggerService } from '@modules/shared';
|
||||
import { ConflictException, DomainException, ValidationException, type LoggerService } from '@modules/shared';
|
||||
import { PaymentEntity } from '../../../domain/entities/payment.entity';
|
||||
import {
|
||||
PAYMENT_REPOSITORY,
|
||||
@@ -32,73 +32,83 @@ export class CreatePaymentHandler implements ICommandHandler<CreatePaymentComman
|
||||
) {}
|
||||
|
||||
async execute(command: CreatePaymentCommand): Promise<CreatePaymentResult> {
|
||||
// Idempotency check
|
||||
if (command.idempotencyKey) {
|
||||
const existing = await this.paymentRepo.findByIdempotencyKey(command.idempotencyKey);
|
||||
if (existing) {
|
||||
if (existing.status === 'PENDING' || existing.status === 'PROCESSING') {
|
||||
throw new ConflictException('Thanh toán với idempotency key này đã tồn tại');
|
||||
}
|
||||
throw new ConflictException('Thanh toán đã được xử lý');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate amount
|
||||
const moneyResult = Money.create(command.amountVND);
|
||||
if (moneyResult.isErr) {
|
||||
throw new ValidationException(moneyResult.unwrapErr());
|
||||
}
|
||||
|
||||
const money = moneyResult.unwrap();
|
||||
const paymentId = createId();
|
||||
|
||||
// Create domain entity
|
||||
const payment = PaymentEntity.createNew(
|
||||
paymentId,
|
||||
command.userId,
|
||||
command.provider,
|
||||
command.type,
|
||||
money,
|
||||
command.transactionId,
|
||||
command.idempotencyKey,
|
||||
);
|
||||
|
||||
// Get payment gateway and create URL
|
||||
const gateway = this.gatewayFactory.getGateway(command.provider);
|
||||
let paymentUrl: string;
|
||||
let providerTxId: string;
|
||||
try {
|
||||
({ paymentUrl, providerTxId } = await gateway.createPaymentUrl({
|
||||
orderId: paymentId,
|
||||
amountVND: command.amountVND,
|
||||
description: command.description,
|
||||
returnUrl: command.returnUrl,
|
||||
ipAddress: command.ipAddress,
|
||||
}));
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Payment gateway ${command.provider} failed for order ${paymentId}: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
// Idempotency check
|
||||
if (command.idempotencyKey) {
|
||||
const existing = await this.paymentRepo.findByIdempotencyKey(command.idempotencyKey);
|
||||
if (existing) {
|
||||
if (existing.status === 'PENDING' || existing.status === 'PROCESSING') {
|
||||
throw new ConflictException('Thanh toán với idempotency key này đã tồn tại');
|
||||
}
|
||||
throw new ConflictException('Thanh toán đã được xử lý');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate amount
|
||||
const moneyResult = Money.create(command.amountVND);
|
||||
if (moneyResult.isErr) {
|
||||
throw new ValidationException(moneyResult.unwrapErr());
|
||||
}
|
||||
|
||||
const money = moneyResult.unwrap();
|
||||
const paymentId = createId();
|
||||
|
||||
// Create domain entity
|
||||
const payment = PaymentEntity.createNew(
|
||||
paymentId,
|
||||
command.userId,
|
||||
command.provider,
|
||||
command.type,
|
||||
money,
|
||||
command.transactionId,
|
||||
command.idempotencyKey,
|
||||
);
|
||||
|
||||
// Get payment gateway and create URL
|
||||
const gateway = this.gatewayFactory.getGateway(command.provider);
|
||||
let paymentUrl: string;
|
||||
let providerTxId: string;
|
||||
try {
|
||||
({ paymentUrl, providerTxId } = await gateway.createPaymentUrl({
|
||||
orderId: paymentId,
|
||||
amountVND: command.amountVND,
|
||||
description: command.description,
|
||||
returnUrl: command.returnUrl,
|
||||
ipAddress: command.ipAddress,
|
||||
}));
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Payment gateway ${command.provider} failed for order ${paymentId}: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
'CreatePaymentHandler',
|
||||
);
|
||||
throw new ValidationException('Không thể tạo liên kết thanh toán, vui lòng thử lại');
|
||||
}
|
||||
|
||||
// Mark processing and save
|
||||
payment.markProcessing(providerTxId);
|
||||
await this.paymentRepo.save(payment);
|
||||
|
||||
// Publish domain events
|
||||
const events = payment.clearDomainEvents();
|
||||
for (const event of events) {
|
||||
this.eventBus.publish(event);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Payment created: id=${paymentId}, provider=${command.provider}, amount=${command.amountVND}`,
|
||||
'CreatePaymentHandler',
|
||||
);
|
||||
throw new ValidationException('Không thể tạo liên kết thanh toán, vui lòng thử lại');
|
||||
|
||||
return { paymentId, paymentUrl, providerTxId };
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to create payment: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể tạo thanh toán. Vui lòng thử lại sau');
|
||||
}
|
||||
|
||||
// Mark processing and save
|
||||
payment.markProcessing(providerTxId);
|
||||
await this.paymentRepo.save(payment);
|
||||
|
||||
// Publish domain events
|
||||
const events = payment.clearDomainEvents();
|
||||
for (const event of events) {
|
||||
this.eventBus.publish(event);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Payment created: id=${paymentId}, provider=${command.provider}, amount=${command.amountVND}`,
|
||||
'CreatePaymentHandler',
|
||||
);
|
||||
|
||||
return { paymentId, paymentUrl, providerTxId };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type EventBus, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { type PaymentStatus } from '@prisma/client';
|
||||
import { NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
|
||||
import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
|
||||
import {
|
||||
PAYMENT_REPOSITORY,
|
||||
type IPaymentRepository,
|
||||
@@ -30,70 +30,80 @@ export class HandleCallbackHandler implements ICommandHandler<HandleCallbackComm
|
||||
) {}
|
||||
|
||||
async execute(command: HandleCallbackCommand): Promise<HandleCallbackResult> {
|
||||
const gateway = this.gatewayFactory.getGateway(command.provider);
|
||||
const result = gateway.verifyCallback(command.callbackData);
|
||||
try {
|
||||
const gateway = this.gatewayFactory.getGateway(command.provider);
|
||||
const result = gateway.verifyCallback(command.callbackData);
|
||||
|
||||
if (!result.isValid) {
|
||||
this.logger.warn(
|
||||
`Invalid callback signature for provider=${command.provider}`,
|
||||
'HandleCallbackHandler',
|
||||
);
|
||||
throw new ValidationException('Chữ ký callback không hợp lệ');
|
||||
}
|
||||
|
||||
// Atomically transition payment status to prevent race conditions
|
||||
// on concurrent callbacks. Only PENDING/PROCESSING payments can be updated.
|
||||
const targetStatus = result.isSuccess ? 'COMPLETED' : 'FAILED';
|
||||
const updated = await this.paymentRepo.updateIfStatus(
|
||||
result.orderId,
|
||||
['PENDING', 'PROCESSING'],
|
||||
{
|
||||
status: targetStatus as PaymentStatus,
|
||||
callbackData: result.rawData,
|
||||
},
|
||||
);
|
||||
|
||||
if (!updated) {
|
||||
// Either payment doesn't exist or is already in a terminal state
|
||||
const existing = await this.paymentRepo.findById(result.orderId);
|
||||
if (!existing) {
|
||||
this.logger.warn(`Payment not found for orderId=${result.orderId}`, 'HandleCallbackHandler');
|
||||
throw new NotFoundException('Payment', result.orderId);
|
||||
if (!result.isValid) {
|
||||
this.logger.warn(
|
||||
`Invalid callback signature for provider=${command.provider}`,
|
||||
'HandleCallbackHandler',
|
||||
);
|
||||
throw new ValidationException('Chữ ký callback không hợp lệ');
|
||||
}
|
||||
|
||||
// Atomically transition payment status to prevent race conditions
|
||||
// on concurrent callbacks. Only PENDING/PROCESSING payments can be updated.
|
||||
const targetStatus = result.isSuccess ? 'COMPLETED' : 'FAILED';
|
||||
const updated = await this.paymentRepo.updateIfStatus(
|
||||
result.orderId,
|
||||
['PENDING', 'PROCESSING'],
|
||||
{
|
||||
status: targetStatus as PaymentStatus,
|
||||
callbackData: result.rawData,
|
||||
},
|
||||
);
|
||||
|
||||
if (!updated) {
|
||||
// Either payment doesn't exist or is already in a terminal state
|
||||
const existing = await this.paymentRepo.findById(result.orderId);
|
||||
if (!existing) {
|
||||
this.logger.warn(`Payment not found for orderId=${result.orderId}`, 'HandleCallbackHandler');
|
||||
throw new NotFoundException('Payment', result.orderId);
|
||||
}
|
||||
|
||||
// Already processed — return idempotent response
|
||||
this.logger.log(
|
||||
`Payment ${existing.id} already in terminal state: ${existing.status}`,
|
||||
'HandleCallbackHandler',
|
||||
);
|
||||
return {
|
||||
paymentId: existing.id,
|
||||
status: existing.status,
|
||||
isSuccess: existing.status === 'COMPLETED',
|
||||
};
|
||||
}
|
||||
|
||||
// Reconstruct domain entity and publish events
|
||||
if (result.isSuccess) {
|
||||
updated.emitCompleted();
|
||||
} else {
|
||||
updated.emitFailed();
|
||||
}
|
||||
|
||||
const events = updated.clearDomainEvents();
|
||||
for (const event of events) {
|
||||
this.eventBus.publish(event);
|
||||
}
|
||||
|
||||
// Already processed — return idempotent response
|
||||
this.logger.log(
|
||||
`Payment ${existing.id} already in terminal state: ${existing.status}`,
|
||||
`Payment ${updated.id} callback processed: status=${updated.status}`,
|
||||
'HandleCallbackHandler',
|
||||
);
|
||||
|
||||
return {
|
||||
paymentId: existing.id,
|
||||
status: existing.status,
|
||||
isSuccess: existing.status === 'COMPLETED',
|
||||
paymentId: updated.id,
|
||||
status: updated.status,
|
||||
isSuccess: result.isSuccess,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to handle payment callback: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể xử lý callback thanh toán. Vui lòng thử lại sau');
|
||||
}
|
||||
|
||||
// Reconstruct domain entity and publish events
|
||||
if (result.isSuccess) {
|
||||
updated.emitCompleted();
|
||||
} else {
|
||||
updated.emitFailed();
|
||||
}
|
||||
|
||||
const events = updated.clearDomainEvents();
|
||||
for (const event of events) {
|
||||
this.eventBus.publish(event);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Payment ${updated.id} callback processed: status=${updated.status}`,
|
||||
'HandleCallbackHandler',
|
||||
);
|
||||
|
||||
return {
|
||||
paymentId: updated.id,
|
||||
status: updated.status,
|
||||
isSuccess: result.isSuccess,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||
import { NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
|
||||
import { DomainException, NotFoundException, ValidationException, type LoggerService } from '@modules/shared';
|
||||
import {
|
||||
PAYMENT_REPOSITORY,
|
||||
type IPaymentRepository,
|
||||
@@ -28,43 +28,53 @@ export class RefundPaymentHandler implements ICommandHandler<RefundPaymentComman
|
||||
) {}
|
||||
|
||||
async execute(command: RefundPaymentCommand): Promise<RefundPaymentResult> {
|
||||
const payment = await this.paymentRepo.findById(command.paymentId);
|
||||
if (!payment) {
|
||||
throw new NotFoundException('Payment', command.paymentId);
|
||||
}
|
||||
|
||||
if (payment.status !== 'COMPLETED') {
|
||||
throw new ValidationException('Chỉ có thể hoàn tiền cho thanh toán đã hoàn tất');
|
||||
}
|
||||
|
||||
if (!payment.providerTxId) {
|
||||
throw new ValidationException('Không có mã giao dịch từ nhà cung cấp');
|
||||
}
|
||||
|
||||
const gateway = this.gatewayFactory.getGateway(payment.provider);
|
||||
const result = await gateway.refund({
|
||||
providerTxId: payment.providerTxId,
|
||||
amountVND: payment.amount.value,
|
||||
reason: command.reason,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
const refundResult = payment.markRefunded();
|
||||
if (refundResult.isErr) {
|
||||
throw refundResult.unwrapErr();
|
||||
try {
|
||||
const payment = await this.paymentRepo.findById(command.paymentId);
|
||||
if (!payment) {
|
||||
throw new NotFoundException('Payment', command.paymentId);
|
||||
}
|
||||
await this.paymentRepo.update(payment);
|
||||
|
||||
if (payment.status !== 'COMPLETED') {
|
||||
throw new ValidationException('Chỉ có thể hoàn tiền cho thanh toán đã hoàn tất');
|
||||
}
|
||||
|
||||
if (!payment.providerTxId) {
|
||||
throw new ValidationException('Không có mã giao dịch từ nhà cung cấp');
|
||||
}
|
||||
|
||||
const gateway = this.gatewayFactory.getGateway(payment.provider);
|
||||
const result = await gateway.refund({
|
||||
providerTxId: payment.providerTxId,
|
||||
amountVND: payment.amount.value,
|
||||
reason: command.reason,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
const refundResult = payment.markRefunded();
|
||||
if (refundResult.isErr) {
|
||||
throw refundResult.unwrapErr();
|
||||
}
|
||||
await this.paymentRepo.update(payment);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Refund ${result.success ? 'successful' : 'failed'} for payment ${command.paymentId}`,
|
||||
'RefundPaymentHandler',
|
||||
);
|
||||
|
||||
return {
|
||||
paymentId: command.paymentId,
|
||||
refundTxId: result.refundTxId,
|
||||
success: result.success,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to refund payment: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể hoàn tiền thanh toán. Vui lòng thử lại sau');
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Refund ${result.success ? 'successful' : 'failed'} for payment ${command.paymentId}`,
|
||||
'RefundPaymentHandler',
|
||||
);
|
||||
|
||||
return {
|
||||
paymentId: command.paymentId,
|
||||
refundTxId: result.refundTxId,
|
||||
success: result.success,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { NotFoundException, ForbiddenException } from '@modules/shared';
|
||||
import { DomainException, ForbiddenException, LoggerService, NotFoundException } from '@modules/shared';
|
||||
import {
|
||||
PAYMENT_REPOSITORY,
|
||||
type IPaymentRepository,
|
||||
@@ -23,27 +23,38 @@ export class GetPaymentStatusHandler implements IQueryHandler<GetPaymentStatusQu
|
||||
constructor(
|
||||
@Inject(PAYMENT_REPOSITORY)
|
||||
private readonly paymentRepo: IPaymentRepository,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetPaymentStatusQuery): Promise<PaymentStatusDto> {
|
||||
const payment = await this.paymentRepo.findById(query.paymentId);
|
||||
if (!payment) {
|
||||
throw new NotFoundException('Payment', query.paymentId);
|
||||
}
|
||||
try {
|
||||
const payment = await this.paymentRepo.findById(query.paymentId);
|
||||
if (!payment) {
|
||||
throw new NotFoundException('Payment', query.paymentId);
|
||||
}
|
||||
|
||||
if (payment.userId !== query.userId) {
|
||||
throw new ForbiddenException('Bạn không có quyền xem thanh toán này');
|
||||
}
|
||||
if (payment.userId !== query.userId) {
|
||||
throw new ForbiddenException('Bạn không có quyền xem thanh toán này');
|
||||
}
|
||||
|
||||
return {
|
||||
id: payment.id,
|
||||
provider: payment.provider,
|
||||
type: payment.type,
|
||||
amountVND: payment.amount.value.toString(),
|
||||
status: payment.status,
|
||||
providerTxId: payment.providerTxId,
|
||||
createdAt: payment.createdAt,
|
||||
updatedAt: payment.updatedAt,
|
||||
};
|
||||
return {
|
||||
id: payment.id,
|
||||
provider: payment.provider,
|
||||
type: payment.type,
|
||||
amountVND: payment.amount.value.toString(),
|
||||
status: payment.status,
|
||||
providerTxId: payment.providerTxId,
|
||||
createdAt: payment.createdAt,
|
||||
updatedAt: payment.updatedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to get payment status: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể truy vấn trạng thái thanh toán. Vui lòng thử lại sau');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||
import { DomainException, LoggerService } from '@modules/shared';
|
||||
import {
|
||||
PAYMENT_REPOSITORY,
|
||||
type IPaymentRepository,
|
||||
@@ -28,31 +29,42 @@ export class ListTransactionsHandler implements IQueryHandler<ListTransactionsQu
|
||||
constructor(
|
||||
@Inject(PAYMENT_REPOSITORY)
|
||||
private readonly paymentRepo: IPaymentRepository,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(query: ListTransactionsQuery): Promise<TransactionListDto> {
|
||||
const limit = Math.min(query.limit ?? 20, 100);
|
||||
const offset = query.offset ?? 0;
|
||||
try {
|
||||
const limit = Math.min(query.limit ?? 20, 100);
|
||||
const offset = query.offset ?? 0;
|
||||
|
||||
const { items, total } = await this.paymentRepo.findByUserId(query.userId, {
|
||||
status: query.status,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
const { items, total } = await this.paymentRepo.findByUserId(query.userId, {
|
||||
status: query.status,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
return {
|
||||
items: items.map((payment) => ({
|
||||
id: payment.id,
|
||||
provider: payment.provider,
|
||||
type: payment.type,
|
||||
amountVND: payment.amount.value.toString(),
|
||||
status: payment.status,
|
||||
providerTxId: payment.providerTxId,
|
||||
createdAt: payment.createdAt,
|
||||
})),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
return {
|
||||
items: items.map((payment) => ({
|
||||
id: payment.id,
|
||||
provider: payment.provider,
|
||||
type: payment.type,
|
||||
amountVND: payment.amount.value.toString(),
|
||||
status: payment.status,
|
||||
providerTxId: payment.providerTxId,
|
||||
createdAt: payment.createdAt,
|
||||
})),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DomainException) throw error;
|
||||
this.logger.error(
|
||||
`Failed to list transactions: ${error instanceof Error ? error.message : error}`,
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
this.constructor.name,
|
||||
);
|
||||
throw new InternalServerErrorException('Không thể truy vấn danh sách giao dịch. Vui lòng thử lại sau');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user