The callback marked an order success on any verify-ok result without comparing the gateway-settled amount to the amount charged. SEP returns AffectiveAmount; an underpayment or a replayed RefNum from a cheaper order would confirm the expensive order. Now reject (status=failed, no activation) when the gateway reports an amount that mismatches the stored amount_rials. Gateways that don't report a settled amount (Mellat binds it server-side) skip the check. MockGateway now echoes mock_amount so the guard is exercisable in tests. Regression: tests/Payment/PaymentCallbackAmountTest (underpayment rejected, matching amount succeeds). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
75 lines
2.2 KiB
PHP
75 lines
2.2 KiB
PHP
<?php
|
|
|
|
namespace App\Tests\Payment;
|
|
|
|
use App\Config\Entity\SiteConfig;
|
|
use App\Payment\Entity\Payment;
|
|
use App\Tests\ApiTestCase;
|
|
|
|
/**
|
|
* The payment callback must confirm an order only when the gateway-reported
|
|
* settled amount matches what we charged. Guards against underpayment / a
|
|
* replayed RefNum from another (cheaper) order marking an expensive order paid.
|
|
*/
|
|
class PaymentCallbackAmountTest extends ApiTestCase
|
|
{
|
|
private function enableTestMode(): void
|
|
{
|
|
$cfg = $this->em->getRepository(SiteConfig::class)->findOneBy(['configKey' => 'payment_test_mode']);
|
|
if ($cfg === null) {
|
|
$cfg = new SiteConfig('payment_test_mode', '1');
|
|
$this->em->persist($cfg);
|
|
} else {
|
|
$cfg->setValue('1');
|
|
}
|
|
$this->em->flush();
|
|
}
|
|
|
|
private function makePayment(int $amountRials): Payment
|
|
{
|
|
$user = $this->createUser();
|
|
$payment = new Payment($user, $amountRials, 'mock', Payment::TYPE_SMS_WALLET);
|
|
$this->em->persist($payment);
|
|
$this->em->flush();
|
|
|
|
return $payment;
|
|
}
|
|
|
|
private function fireCallback(Payment $payment, int $reportedAmount): void
|
|
{
|
|
$this->client->request('POST', '/api/v1/payment/callback/mock?' . http_build_query([
|
|
'order_id' => $payment->getOrderId(),
|
|
'mock' => '1',
|
|
'ResCode' => '0',
|
|
'mock_amount' => (string) $reportedAmount,
|
|
]));
|
|
}
|
|
|
|
private function reload(Payment $payment): Payment
|
|
{
|
|
$this->em->clear();
|
|
|
|
return $this->em->getRepository(Payment::class)->find($payment->getId());
|
|
}
|
|
|
|
public function testUnderpaymentIsRejected(): void
|
|
{
|
|
$this->enableTestMode();
|
|
$payment = $this->makePayment(50000);
|
|
|
|
$this->fireCallback($payment, 10000);
|
|
|
|
$this->assertSame(Payment::STATUS_FAILED, $this->reload($payment)->getStatus());
|
|
}
|
|
|
|
public function testMatchingAmountSucceeds(): void
|
|
{
|
|
$this->enableTestMode();
|
|
$payment = $this->makePayment(50000);
|
|
|
|
$this->fireCallback($payment, 50000);
|
|
|
|
$this->assertSame(Payment::STATUS_SUCCESS, $this->reload($payment)->getStatus());
|
|
}
|
|
}
|