fix(security): unique payment reference_id, reject replayed callbacks (H3)

reference_id (the gateway's settled-transaction ref) was not unique, so the
same successful callback — or a RefNum replayed onto another order — could
credit twice. Add a unique index (NULL until success, so pending/failed rows
don't collide) and an application-level pre-check in the callback that fails the
payment if the reference already belongs to another order. The unique index is
the hard backstop behind the check.

Regression: PaymentCallbackAmountTest::testReplayedGatewayReferenceIsRejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-28 19:08:26 +03:30
co-authored by Claude Opus 4.8
parent aa87b4a9cb
commit 7fa4b55d3f
7 changed files with 80 additions and 2 deletions
@@ -286,6 +286,19 @@ class PaymentController extends BaseController
return $this->redirectToFrontend($payment, false);
}
// A gateway reference identifies exactly one settled transaction. If it
// already belongs to another payment, this is a replay — reject it. The
// unique DB index on reference_id is the hard backstop behind this check.
if ($result->referenceId !== '') {
$owner = $this->paymentRepo->findByReferenceId($result->referenceId);
if ($owner !== null && $owner->getId() !== $payment->getId()) {
$payment->setStatus(Payment::STATUS_FAILED);
$this->paymentRepo->save($payment);
return $this->redirectToFrontend($payment, false);
}
}
$payment->setStatus(Payment::STATUS_SUCCESS);
$payment->setReferenceId($result->referenceId);
$this->paymentRepo->save($payment);