fix(payments): harden payment flow with idempotency keys, amount validation, and magic byte file validation
- Add dedicated idempotencyKey column with unique constraint (userId, provider, idempotencyKey) to prevent duplicate payments at DB level - Add @Min(1) @Max(100B) validators on amountVND in CreatePaymentDto to reject invalid amounts at API boundary - Replace read-check-write callback handler with atomic updateIfStatus to eliminate race condition on concurrent callbacks - Add magic byte verification in FileValidationPipe to validate file content matches declared MIME type server-side Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -48,50 +48,59 @@ export class HandleCallbackHandler implements ICommandHandler<HandleCallbackComm
|
||||
});
|
||||
}
|
||||
|
||||
// Find payment by orderId (which is the payment ID)
|
||||
const payment = await this.paymentRepo.findById(result.orderId);
|
||||
if (!payment) {
|
||||
this.logger.warn(`Payment not found for orderId=${result.orderId}`);
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.NOT_FOUND,
|
||||
message: 'Không tìm thấy thanh toán',
|
||||
});
|
||||
}
|
||||
// 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 any,
|
||||
callbackData: result.rawData,
|
||||
},
|
||||
);
|
||||
|
||||
// Idempotency: if already completed/failed, return current state
|
||||
if (payment.status === 'COMPLETED' || payment.status === 'FAILED' || payment.status === 'REFUNDED') {
|
||||
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}`);
|
||||
throw new NotFoundException({
|
||||
code: ErrorCode.NOT_FOUND,
|
||||
message: 'Không tìm thấy thanh toán',
|
||||
});
|
||||
}
|
||||
|
||||
// Already processed — return idempotent response
|
||||
this.logger.log(
|
||||
`Payment ${payment.id} already in terminal state: ${payment.status}`,
|
||||
`Payment ${existing.id} already in terminal state: ${existing.status}`,
|
||||
);
|
||||
return {
|
||||
paymentId: payment.id,
|
||||
status: payment.status,
|
||||
isSuccess: payment.status === 'COMPLETED',
|
||||
paymentId: existing.id,
|
||||
status: existing.status,
|
||||
isSuccess: existing.status === 'COMPLETED',
|
||||
};
|
||||
}
|
||||
|
||||
// Update payment status
|
||||
// Reconstruct domain entity and publish events
|
||||
if (result.isSuccess) {
|
||||
payment.markCompleted(result.rawData);
|
||||
updated.emitCompleted();
|
||||
} else {
|
||||
payment.markFailed(result.rawData);
|
||||
updated.emitFailed();
|
||||
}
|
||||
|
||||
await this.paymentRepo.update(payment);
|
||||
|
||||
// Publish domain events
|
||||
const events = payment.clearDomainEvents();
|
||||
const events = updated.clearDomainEvents();
|
||||
for (const event of events) {
|
||||
this.eventBus.publish(event);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Payment ${payment.id} callback processed: status=${payment.status}`,
|
||||
`Payment ${updated.id} callback processed: status=${updated.status}`,
|
||||
);
|
||||
|
||||
return {
|
||||
paymentId: payment.id,
|
||||
status: payment.status,
|
||||
paymentId: updated.id,
|
||||
status: updated.status,
|
||||
isSuccess: result.isSuccess,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user