ReconcilePendingPaymentJob.php 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  1. <?php
  2. namespace Webkul\BagistoApi\Jobs;
  3. use Illuminate\Bus\Queueable;
  4. use Illuminate\Contracts\Queue\ShouldQueue;
  5. use Illuminate\Foundation\Bus\Dispatchable;
  6. use Illuminate\Queue\InteractsWithQueue;
  7. use Illuminate\Queue\SerializesModels;
  8. use Illuminate\Support\Facades\DB;
  9. use Illuminate\Support\Facades\Event;
  10. use Illuminate\Support\Facades\Log;
  11. use Webkul\Paypal\Payment\SmartButton;
  12. use Webkul\Sales\Models\Order;
  13. use Webkul\Sales\Models\OrderProxy;
  14. use Webkul\Sales\Repositories\OrderRepository;
  15. /**
  16. * Delayed reconciliation for orders that are still PENDING / PENDING_PAYMENT
  17. * after the configured grace period.
  18. *
  19. * Behaviour:
  20. *
  21. * - If the order has already moved past pending we exit cleanly.
  22. * - If we can look up the gateway order id and it has been captured,
  23. * we DO NOT auto-mark the Bagisto order as paid; instead we emit
  24. * `bagistoapi.payment.reconcile.captured` so an ops listener can
  25. * investigate the missed callback (likely a network failure on the
  26. * client side).
  27. * - If the gateway has no record of the order id, we cancel the
  28. * Bagisto order via OrderRepository::cancel.
  29. *
  30. * This job is intentionally idempotent - it can be re-queued safely.
  31. */
  32. class ReconcilePendingPaymentJob implements ShouldQueue
  33. {
  34. use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
  35. public int $tries = 3;
  36. public int $backoff = 60;
  37. public function __construct(
  38. public int $orderId,
  39. ) {}
  40. public function handle(OrderRepository $orderRepository): void
  41. {
  42. /** @var Order|null $order */
  43. $order = $orderRepository->find($this->orderId);
  44. if (! $order) {
  45. return;
  46. }
  47. if (! in_array($order->status, [Order::STATUS_PENDING, Order::STATUS_PENDING_PAYMENT], true)) {
  48. return;
  49. }
  50. $additional = $order->payment?->additional ?? [];
  51. $gatewayOrderId = $additional['gateway_order_id'] ?? $additional['paypal_order_id'] ?? null;
  52. $method = $order->payment?->method;
  53. if (! $gatewayOrderId || $method !== 'paypal_smart_button') {
  54. Event::dispatch('bagistoapi.payment.reconcile.no-gateway-info', $order);
  55. return;
  56. }
  57. try {
  58. $gatewayOrder = app(SmartButton::class)->getOrder($gatewayOrderId);
  59. $status = (string) ($gatewayOrder->result->status ?? '');
  60. $captures = $gatewayOrder->result->purchase_units[0]->payments->captures ?? [];
  61. if (! empty($captures) || in_array(strtoupper($status), ['COMPLETED', 'CAPTURED'], true)) {
  62. /*
  63. * Capture exists but Bagisto is still pending - a
  64. * callback was missed. Surface this for ops review.
  65. */
  66. Event::dispatch('bagistoapi.payment.reconcile.captured', [
  67. 'order' => $order,
  68. 'gateway_status' => $status,
  69. 'gateway_order' => $gatewayOrder->result ?? null,
  70. ]);
  71. return;
  72. }
  73. if (in_array(strtoupper($status), ['VOIDED', 'EXPIRED', 'PAYER_ACTION_REQUIRED'], true)) {
  74. Event::dispatch('bagistoapi.payment.reconcile.voided', $order);
  75. }
  76. $cancelled = DB::transaction(function () use ($orderRepository, $order, $gatewayOrderId): bool {
  77. /*
  78. * Re-lock and re-check inside a transaction right before
  79. * cancellation to avoid racing with a late success callback.
  80. */
  81. $orderModelClass = OrderProxy::modelClass();
  82. $lockedOrder = $orderModelClass::query()->whereKey($order->id)->lockForUpdate()->first();
  83. if (! $lockedOrder) {
  84. return false;
  85. }
  86. if (! in_array($lockedOrder->status, [Order::STATUS_PENDING, Order::STATUS_PENDING_PAYMENT], true)) {
  87. return false;
  88. }
  89. $lockedAdditional = $lockedOrder->payment?->additional ?? [];
  90. $lockedGatewayOrderId = $lockedAdditional['gateway_order_id'] ?? $lockedAdditional['paypal_order_id'] ?? null;
  91. if (! $lockedGatewayOrderId || $lockedGatewayOrderId !== $gatewayOrderId) {
  92. return false;
  93. }
  94. return (bool) $orderRepository->cancel($lockedOrder,true);
  95. });
  96. if ($cancelled) {
  97. Event::dispatch('bagistoapi.payment.reconcile.cancelled', $order);
  98. }
  99. } catch (\Throwable $e) {
  100. Log::warning('ReconcilePendingPaymentJob: gateway lookup failed', [
  101. 'order_id' => $order->id,
  102. 'gateway_order_id' => $gatewayOrderId,
  103. 'error' => $e->getMessage(),
  104. ]);
  105. /*
  106. * The gateway didn't tell us anything useful. Let the
  107. * retry mechanism take over - we'd rather try again than
  108. * cancel a possibly-paid order.
  109. */
  110. throw $e;
  111. }
  112. }
  113. }