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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user