feat(payment): add refund and reversal functionality to payment gateways

- Implemented `refund` and `reverse` methods in `PaymentGatewayInterface`.
- Added `PaymentRefundResult` class to handle refund operation results.
- Enhanced `MockGateway` and `SepGateway` to support refund and reversal operations.
- Updated `PaymentManager` to include `refundPayment` and `reversePayment` methods for handling refunds and reversals in transactions.
- Modified `ClinicSubscriptionRepository` and `SubscriptionService` to manage subscriptions during refunds.
- Added admin API endpoints for processing refunds and reversals.
- Updated security headers to allow form actions to the sandbox environment.
- Documented the new refund and reversal features in the API documentation.
This commit is contained in:
hamed
2026-07-02 18:45:34 +03:30
parent 6bb47d343d
commit 1b171a82f4
20 changed files with 1111 additions and 21 deletions
+110
View File
@@ -9,6 +9,7 @@ use App\Payment\Entity\Payment;
use App\Payment\Entity\PaymentLog;
use App\Payment\Gateway\GatewayFactory;
use App\Payment\Gateway\PaymentInitResult;
use App\Payment\Gateway\PaymentRefundResult;
use App\Payment\Repository\PaymentLogRepository;
use App\Payment\Repository\PaymentRepository;
use App\Representation\Service\JalaliDateService;
@@ -155,6 +156,11 @@ final class PaymentManager
$payment->setStatus(Payment::STATUS_SUCCESS);
$payment->setReferenceId($result->referenceId);
// شمارهٔ کارت ماسک‌شدهٔ پرداخت‌کننده (ملت: CardHolderPan) برای نمایش در پنل.
$cardPan = $callbackData['CardHolderPan'] ?? $callbackData['SecurePan'] ?? null;
if ($cardPan) {
$payment->setMetadata(($payment->getMetadata() ?? []) + ['card_pan' => (string) $cardPan]);
}
$this->em->persist($payment);
$this->runPostAction($payment);
@@ -164,6 +170,76 @@ final class PaymentManager
});
}
/**
* استرداد وجه (کل یا جزئی) یک پرداخت موفق. داخل transaction + قفل ردیف.
* $amountRials = null → کل مبلغ. استرداد کامل → وضعیت refunded.
*/
public function refundPayment(Payment $payment, ?int $amountRials, string $clientIp): PaymentRefundResult
{
return $this->em->wrapInTransaction(function () use ($payment, $amountRials, $clientIp): PaymentRefundResult {
$locked = $this->paymentRepo->findByOrderIdForUpdate($payment->getOrderId());
if ($locked === null) {
return new PaymentRefundResult(false, errorMessage: 'پرداخت یافت نشد');
}
if ($locked->getStatus() !== Payment::STATUS_SUCCESS) {
return new PaymentRefundResult(false, errorMessage: 'فقط پرداخت موفق قابل استرداد است');
}
$total = $locked->getAmountRials();
$meta = $locked->getMetadata() ?? [];
$refunded = array_sum(array_column($meta['refunds'] ?? [], 'amount'));
$amount = $amountRials ?? ($total - $refunded); // null = باقی‌ماندهٔ قابل استرداد
if ($amount <= 0 || $refunded + $amount > $total) {
return new PaymentRefundResult(false, errorMessage: 'مبلغ استرداد نامعتبر است');
}
$gateway = $this->gateways->resolve($locked->getGateway());
$result = $gateway?->refund((string) $locked->getId(), (string) $locked->getReferenceId(), $amount)
?? new PaymentRefundResult(false, errorMessage: 'درگاه نامعتبر است');
if ($result->success) {
$meta['refunds'][] = ['amount' => $amount, 'ref' => $result->refundRefId, 'at' => time()];
$locked->setMetadata($meta);
// فقط استرداد کامل، post-action (نوبت/اشتراک/کیف‌پول) را معکوس می‌کند.
if ($refunded + $amount >= $total) {
$locked->setStatus(Payment::STATUS_REFUNDED);
$this->runReversePostAction($locked);
}
$this->em->persist($locked);
}
$this->log($locked, PaymentLog::ACTION_REFUND, $result->success ? 'success' : 'failed',
$result->refundRefId ?: null, $clientIp, ['amount' => $amount, 'error' => $result->errorMessage]);
return $result;
});
}
/** برگشت وجه یک پرداخت settle‌نشده. در موفقیت وضعیت refunded. داخل transaction + قفل. */
public function reversePayment(Payment $payment, string $clientIp): PaymentRefundResult
{
return $this->em->wrapInTransaction(function () use ($payment, $clientIp): PaymentRefundResult {
$locked = $this->paymentRepo->findByOrderIdForUpdate($payment->getOrderId());
if ($locked === null) {
return new PaymentRefundResult(false, errorMessage: 'پرداخت یافت نشد');
}
if ($locked->getStatus() !== Payment::STATUS_SUCCESS) {
return new PaymentRefundResult(false, errorMessage: 'فقط پرداخت موفق قابل برگشت است');
}
$gateway = $this->gateways->resolve($locked->getGateway());
$result = $gateway?->reverse((string) $locked->getId(), (string) $locked->getReferenceId())
?? new PaymentRefundResult(false, errorMessage: 'درگاه نامعتبر است');
if ($result->success) {
$locked->setStatus(Payment::STATUS_REFUNDED);
$this->runReversePostAction($locked);
$this->em->persist($locked);
}
$this->log($locked, PaymentLog::ACTION_REVERSE, $result->success ? 'success' : 'failed',
null, $clientIp, ['error' => $result->errorMessage]);
return $result;
});
}
public function callbackUrl(Payment $payment): string
{
$prefix = $payment->getType() === Payment::TYPE_SUBSCRIPTION
@@ -184,6 +260,40 @@ final class PaymentManager
};
}
/** معکوس‌سازی اثر پرداخت هنگام استرداد کامل / برگشت وجه. */
private function runReversePostAction(Payment $payment): void
{
match ($payment->getType()) {
Payment::TYPE_SUBSCRIPTION => $this->subscriptionService->deleteByPayment($payment),
Payment::TYPE_SMS_WALLET => $this->reverseSmsWalletCharge($payment),
Payment::TYPE_APPOINTMENT => $this->reverseAppointment($payment),
default => null,
};
}
private function reverseAppointment(Payment $payment): void
{
$appointment = $payment->getAppointment();
if ($appointment === null || !$appointment->canTransitionTo(Appointment::STATUS_CANCELLED_BY_USER)) {
return;
}
// لغو نوبت → اسلات به‌طور خودکار آزاد می‌شود (availability از نوبت‌های active محاسبه می‌شود).
$appointment->transitionTo(Appointment::STATUS_CANCELLED_BY_USER);
$this->em->persist($appointment);
}
private function reverseSmsWalletCharge(Payment $payment): void
{
$meta = $payment->getMetadata() ?? [];
$entityType = $meta['entity_type'] ?? null;
$entityId = isset($meta['entity_id']) ? (int) $meta['entity_id'] : null;
if ($entityType === null || $entityId === null) {
return;
}
$wallet = $this->smsWalletService->getOrCreate($entityType, $entityId);
$this->smsWalletService->deduct($wallet, $payment->getAmountRials(), 'استرداد شارژ کیف پیامک');
}
private function handleAppointmentConfirmation(Payment $payment): void
{
$appointment = $payment->getAppointment();