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:
Ho Ngoc Hai
2026-04-08 06:18:26 +07:00
parent be0deddeed
commit 9583d1cb66
9 changed files with 148 additions and 43 deletions

View File

@@ -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,
};
}