feat(payment): canceled status, manageable origin allowlist, CORS subdomains

Unify and harden the payment flow (same API for the main site and all
consumer sites; per-client difference is only frontend_address).

- Payment gains STATUS_CANCELED. Gateways distinguish user-cancel from
  failure (Mellat ResCode=17, SEP CanceledByUser, mock cancel=1) via a new
  PaymentVerifyResult::canceled flag; callback sets canceled vs failed and
  skips the circuit-breaker on cancel.
- Expiry job now cancels the pending payment when a booking lapses
  (AppointmentExpiryService + PaymentRepository::findPendingByAppointment).
- frontend_address allowlist is read from the payment_allowed_frontend_hosts
  site setting (manageable via PATCH /api/v1/admin/settings), falling back to
  the ALLOWED_FRONTEND_HOSTS env var — so a new consumer site needs no code
  change.
- .env: broaden CORS_ALLOW_ORIGIN to city subdomains (*.localhost /
  *.clinic-pro.ddev.site) and add yazd-nobat.localhost to ALLOWED_FRONTEND_HOSTS.
- Update docs/api/payment.md and docs/api/admin.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-16 10:06:43 +03:30
co-authored by Claude Opus 4.8
parent 45242a3128
commit 492a7df989
13 changed files with 237 additions and 16 deletions
+14 -3
View File
@@ -259,9 +259,12 @@ class PaymentController extends BaseController
$result = $gw?->verify($callbackData) ?? null;
if ($result === null || !$result->success) {
$payment->setStatus(Payment::STATUS_FAILED);
$canceled = $result?->canceled ?? false;
$payment->setStatus($canceled ? Payment::STATUS_CANCELED : Payment::STATUS_FAILED);
$this->paymentRepo->save($payment);
$this->circuitBreaker->recordFailure($gateway);
if (!$canceled) {
$this->circuitBreaker->recordFailure($gateway);
}
return $this->redirectToFrontend($payment, false);
}
@@ -566,9 +569,17 @@ class PaymentController extends BaseController
};
}
/** @return string[] allowed frontend hosts — from SiteConfig, falling back to env. */
private function allowedHosts(): array
{
$fromConfig = (string) ($this->configRepo->get('payment_allowed_frontend_hosts') ?? '');
$raw = $fromConfig !== '' ? $fromConfig : $this->allowedFrontendHosts;
return array_filter(array_map('trim', explode(',', $raw)));
}
private function isAllowedFrontend(string $url): bool
{
$hosts = array_filter(array_map('trim', explode(',', $this->allowedFrontendHosts)));
$hosts = $this->allowedHosts();
if (empty($hosts)) {
return false;
}
+1
View File
@@ -16,6 +16,7 @@ class Payment
public const STATUS_PENDING = 'pending';
public const STATUS_SUCCESS = 'success';
public const STATUS_FAILED = 'failed';
public const STATUS_CANCELED = 'canceled';
public const STATUS_REFUNDED = 'refunded';
public const TYPE_APPOINTMENT = 'appointment';
+4
View File
@@ -55,6 +55,10 @@ class MellatGateway implements PaymentGatewayInterface
$refId = $callbackData['RefId'] ?? '';
$resCode = $callbackData['ResCode'] ?? '';
if ($resCode === '17') {
return new PaymentVerifyResult(false, errorMessage: 'پرداخت توسط کاربر لغو شد', canceled: true);
}
if ($resCode !== '0') {
return new PaymentVerifyResult(false, errorMessage: "Payment failed: $resCode");
}
+4
View File
@@ -21,6 +21,10 @@ class MockGateway implements PaymentGatewayInterface
return new PaymentVerifyResult(false, errorMessage: 'mock callback مجاز نیست');
}
if (($callbackData['cancel'] ?? '0') === '1' || $resCode === '17') {
return new PaymentVerifyResult(false, errorMessage: 'پرداخت توسط کاربر لغو شد', canceled: true);
}
$refId = $callbackData['RefId'] ?? $callbackData['order_id'] ?? 'MOCK-REF';
return new PaymentVerifyResult(true, referenceId: $refId);
}
@@ -9,5 +9,6 @@ final class PaymentVerifyResult
public readonly string $referenceId = '',
public readonly string $errorMessage = '',
public readonly int $amountRials = 0,
public readonly bool $canceled = false,
) {}
}
+3
View File
@@ -54,6 +54,9 @@ class SepGateway implements PaymentGatewayInterface
public function verify(array $callbackData): PaymentVerifyResult
{
$state = $callbackData['State'] ?? '';
if (strtolower($state) === 'canceledbyuser') {
return new PaymentVerifyResult(false, errorMessage: 'پرداخت توسط کاربر لغو شد', canceled: true);
}
if (strtolower($state) !== 'ok') {
return new PaymentVerifyResult(false, errorMessage: "Payment state: $state");
}
@@ -2,6 +2,7 @@
namespace App\Payment\Repository;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Payment\Entity\Payment;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
@@ -53,6 +54,14 @@ class PaymentRepository extends ServiceEntityRepository
return $this->findOneBy(['orderId' => $orderId]);
}
public function findPendingByAppointment(Appointment $appointment): ?Payment
{
return $this->findOneBy([
'appointment' => $appointment,
'status' => Payment::STATUS_PENDING,
]);
}
public function save(Payment $entity, bool $flush = true): void
{
$this->getEntityManager()->persist($entity);