Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions payfast/src/Plugin/Commerce/PaymentGateway/OffsiteRedirect.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,21 +131,28 @@ protected function getRequestDataArray(string $request_content): array
protected function loadPaymentByRemoteId($remote_id): mixed
{
$message = '@message';
$storage = null;
/** @var PaymentStorage $storage */
try {
$storage = $this->entityTypeManager->getStorage('commerce_payment');
} catch (Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException $e) {
Drupal::logger('your_module')->error(
Drupal::logger('commerce_payfast')->error(
'Invalid plugin definition exception: @message',
[$message => $e->getMessage()]
);
} catch (Drupal\Component\Plugin\Exception\PluginNotFoundException $e) {
Drupal::logger('your_module')->error(
Drupal::logger('commerce_payfast')->error(
'Plugin not found exception: @message',
[$message => $e->getMessage()]
);
}
$payment_by_remote_id = $storage->loadByProperties(['order_id' => $remote_id]);
if ($storage === null) {
return false;
}
// Payfast's unique transaction id is stored on the payment as its
// remote_id, so dedupe against that - not the order id, which every
// payment for the order would share.
$payment_by_remote_id = $storage->loadByProperties(['remote_id' => $remote_id]);

return reset($payment_by_remote_id);
}
Expand Down Expand Up @@ -254,6 +261,36 @@ public function processOrder($pfError, $pfData, $pfErrMsg): void
if ($order) {
$message = '@message';

// Idempotency: Payfast retries the ITN until it gets a 200,
// so the same pf_payment_id can arrive several times. Never
// record a second payment for a transaction already stored.
$existing = $this->loadPaymentByRemoteId($pfData['pf_payment_id']);
if ($existing) {
$paymentRequest->pflog(
'Duplicate ITN ignored for pf_payment_id ' . $pfData['pf_payment_id']
);

return;
}

// Amount tampering check: the ITN is signature-verified, but
// the buyer controls the amount posted to the process page,
// so confirm the paid gross matches the order total before
// recording a completed payment. Payfast trades in ZAR only.
$orderTotal = $order->getTotalPrice();
$paidAmount = (string) $pfData['amount_gross'];
if ($orderTotal === null
|| abs((float) $orderTotal->getNumber() - (float) $paidAmount) > 0.01) {
$paymentRequest->pflog(sprintf(
'Amount mismatch for order %s: paid %s, expected %s. Payment rejected.',
$order_id,
$paidAmount,
$orderTotal === null ? 'unknown' : $orderTotal->getNumber()
));

return;
}

try {
$payment_storage = $this->entityTypeManager->getStorage('commerce_payment');

Expand All @@ -273,7 +310,7 @@ public function processOrder($pfError, $pfData, $pfErrMsg): void
// Mark order as completed in state storage
\Drupal::state()->set('payfast_order_notified_' . $order_id, true);
} catch (EntityStorageException $e) {
Drupal::logger('your_module')->error(
Drupal::logger('commerce_payfast')->error(
'Entity storage exception: @message',
[$message => $e->getMessage()]
);
Expand Down