|
|
@@ -3,139 +3,333 @@
|
|
|
namespace Webkul\BagistoApi\Jobs;
|
|
|
|
|
|
use Illuminate\Bus\Queueable;
|
|
|
+use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
|
|
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
|
|
use Illuminate\Foundation\Bus\Dispatchable;
|
|
|
use Illuminate\Queue\InteractsWithQueue;
|
|
|
use Illuminate\Queue\SerializesModels;
|
|
|
+use Illuminate\Support\Carbon;
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
use Illuminate\Support\Facades\Event;
|
|
|
use Illuminate\Support\Facades\Log;
|
|
|
-use Webkul\Paypal\Payment\SmartButton;
|
|
|
+use Webkul\BagistoApi\Exception\OperationFailedException;
|
|
|
+use Webkul\BagistoApi\Repositories\PaymentAttemptRepository;
|
|
|
use Webkul\BagistoApi\Services\PaymentService;
|
|
|
+use Webkul\Paypal\Payment\SmartButton;
|
|
|
use Webkul\Sales\Models\Order;
|
|
|
use Webkul\Sales\Models\OrderProxy;
|
|
|
use Webkul\Sales\Repositories\OrderRepository;
|
|
|
|
|
|
/**
|
|
|
- * Delayed reconciliation for orders that are still PENDING / PENDING_PAYMENT
|
|
|
- * after the configured grace period.
|
|
|
+ * Polls the gateway while an order is still PENDING / PENDING_PAYMENT and
|
|
|
+ * closes the loop when the storefront callback never made it back.
|
|
|
*
|
|
|
- * Behaviour:
|
|
|
+ * The job re-schedules itself instead of being queued as one long delay,
|
|
|
+ * so a buyer who pays after 40 seconds is settled within 20 seconds rather
|
|
|
+ * than waiting out a fixed window. Cadence comes from `bagistoapi.reconcile`
|
|
|
+ * (30s, 1min, 5min, then every 3min, widening to every 5min after 30min,
|
|
|
+ * hard stop at 3 hours).
|
|
|
*
|
|
|
- * - If the order has already moved past pending we exit cleanly.
|
|
|
- * - If we can look up the gateway order id and it has been captured,
|
|
|
- * we DO NOT auto-mark the Bagisto order as paid; instead we emit
|
|
|
- * `bagistoapi.payment.reconcile.captured` so an ops listener can
|
|
|
- * investigate the missed callback (likely a network failure on the
|
|
|
- * client side).
|
|
|
- * - If the gateway has no record of the order id, we cancel the
|
|
|
- * Bagisto order via OrderRepository::cancel.
|
|
|
+ * Behaviour per tick:
|
|
|
*
|
|
|
- * This job is intentionally idempotent - it can be re-queued safely.
|
|
|
+ * - Order already past pending: stop, nothing to do.
|
|
|
+ * - Gateway reports a capture: settle it locally via
|
|
|
+ * PaymentService::settleReconciledCapture (amount verified, invoice and
|
|
|
+ * order_transactions written) and emit `reconcile.settled`.
|
|
|
+ * - Gateway reports a terminal failure (VOIDED / EXPIRED / DECLINED):
|
|
|
+ * cancel the order right away.
|
|
|
+ * - Anything else: schedule the next tick. Once the deadline passes we
|
|
|
+ * only cancel states that are known to be unpaid; an APPROVED (authorized
|
|
|
+ * but not captured) order is escalated to ops instead, because cancelling
|
|
|
+ * it would throw away money the buyer already committed.
|
|
|
+ *
|
|
|
+ * Idempotent and safe to re-queue - the sweep command relies on that.
|
|
|
*/
|
|
|
-class ReconcilePendingPaymentJob implements ShouldQueue
|
|
|
+class ReconcilePendingPaymentJob implements ShouldQueue, ShouldBeUniqueUntilProcessing
|
|
|
{
|
|
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
|
|
|
|
|
+ /**
|
|
|
+ * Retries here cover gateway/transport failures only. The polling
|
|
|
+ * cadence is driven by re-dispatching, not by the retry mechanism.
|
|
|
+ */
|
|
|
public int $tries = 3;
|
|
|
|
|
|
public int $backoff = 60;
|
|
|
|
|
|
+ /**
|
|
|
+ * Comfortably above the widest polling interval, so a delayed tick keeps
|
|
|
+ * the sweep command from queueing a duplicate, while a genuinely lost job
|
|
|
+ * becomes recoverable within a few minutes.
|
|
|
+ */
|
|
|
+ public int $uniqueFor = 900;
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Gateway states that mean the money will never arrive.
|
|
|
+ */
|
|
|
+ protected const TERMINAL_STATUSES = ['VOIDED', 'EXPIRED', 'DECLINED'];
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Gateway states we are willing to cancel once the deadline passes.
|
|
|
+ * Deliberately excludes APPROVED and anything unrecognised.
|
|
|
+ */
|
|
|
+ protected const CANCELLABLE_STATUSES = ['', 'CREATED', 'SAVED', 'PAYER_ACTION_REQUIRED'];
|
|
|
+
|
|
|
public function __construct(
|
|
|
public int $orderId,
|
|
|
+ public int $tick = 1,
|
|
|
) {}
|
|
|
|
|
|
- public function handle(OrderRepository $orderRepository): void
|
|
|
+ public function uniqueId(): string
|
|
|
{
|
|
|
+ return (string) $this->orderId;
|
|
|
+ }
|
|
|
+
|
|
|
+ public function handle(
|
|
|
+ OrderRepository $orderRepository,
|
|
|
+ PaymentAttemptRepository $paymentAttemptRepository,
|
|
|
+ PaymentService $paymentService,
|
|
|
+ ): void {
|
|
|
/** @var Order|null $order */
|
|
|
$order = $orderRepository->find($this->orderId);
|
|
|
|
|
|
- if (! $order) {
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- if (! in_array($order->status, [Order::STATUS_PENDING, Order::STATUS_PENDING_PAYMENT], true)) {
|
|
|
+ if (! $order || ! $this->isPending($order)) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
$additional = $order->payment?->additional ?? [];
|
|
|
$gatewayOrderId = $additional['gateway_order_id'] ?? $additional['paypal_order_id'] ?? null;
|
|
|
- $method = $order->payment?->method;
|
|
|
+ $method = (string) ($order->payment?->method ?? '');
|
|
|
+
|
|
|
+ $gatewayHandler = $method !== ''
|
|
|
+ ? $paymentService->resolveGatewayHandler($method, $order)
|
|
|
+ : null;
|
|
|
+
|
|
|
+ $handlerCanQuery = $gatewayHandler && method_exists($gatewayHandler, 'fetchOrderStatus');
|
|
|
|
|
|
- if (! $gatewayOrderId || $method !== 'paypal_smart_button') {
|
|
|
+ if (! $gatewayOrderId || (! $handlerCanQuery && $method !== 'paypal_smart_button')) {
|
|
|
+ /*
|
|
|
+ * No way to ask the gateway anything, so polling would never
|
|
|
+ * learn more than we know now. Surface it and stop instead of
|
|
|
+ * cancelling an order we cannot judge.
|
|
|
+ */
|
|
|
Event::dispatch('bagistoapi.payment.reconcile.no-gateway-info', $order);
|
|
|
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
- $gatewayHandler = app(PaymentService::class)->resolveGatewayHandler($method, $order);
|
|
|
- if ($gatewayHandler && method_exists($gatewayHandler, 'fetchOrderStatus')) {
|
|
|
- list($captures, $status) = $gatewayHandler->fetchOrderStatus($gatewayOrderId);
|
|
|
- } else {
|
|
|
- $gatewayOrder = app(SmartButton::class)->getOrder($gatewayOrderId);
|
|
|
- $status = (string) ($gatewayOrder->result->status ?? '');
|
|
|
- $captures = $gatewayOrder->result->purchase_units[0]->payments->captures ?? [];
|
|
|
- }
|
|
|
- if (! empty($captures) || in_array(strtoupper($status), ['COMPLETED', 'CAPTURED'], true)) {
|
|
|
- /*
|
|
|
- * Capture exists but Bagisto is still pending - a
|
|
|
- * callback was missed. Surface this for ops review.
|
|
|
- */
|
|
|
- Event::dispatch('bagistoapi.payment.reconcile.captured', [
|
|
|
- 'order' => $order,
|
|
|
- 'gateway_status' => $status,
|
|
|
- 'gateway_order' => $gatewayOrder->result ?? null,
|
|
|
- ]);
|
|
|
-
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- if (in_array(strtoupper($status), ['VOIDED', 'EXPIRED', 'PAYER_ACTION_REQUIRED'], true)) {
|
|
|
- Event::dispatch('bagistoapi.payment.reconcile.voided', $order);
|
|
|
- }
|
|
|
- $cancelled = DB::transaction(function () use ($orderRepository, $order, $gatewayOrderId): bool {
|
|
|
- /*
|
|
|
- * Re-lock and re-check inside a transaction right before
|
|
|
- * cancellation to avoid racing with a late success callback.
|
|
|
- */
|
|
|
- $orderModelClass = OrderProxy::modelClass();
|
|
|
- $lockedOrder = $orderModelClass::query()->whereKey($order->id)->lockForUpdate()->first();
|
|
|
-
|
|
|
- if (! $lockedOrder) {
|
|
|
- return false;
|
|
|
- }
|
|
|
-
|
|
|
- if (! in_array($lockedOrder->status, [Order::STATUS_PENDING, Order::STATUS_PENDING_PAYMENT], true)) {
|
|
|
- return false;
|
|
|
- }
|
|
|
-
|
|
|
- $lockedAdditional = $lockedOrder->payment?->additional ?? [];
|
|
|
- $lockedGatewayOrderId = $lockedAdditional['gateway_order_id'] ?? $lockedAdditional['paypal_order_id'] ?? null;
|
|
|
-
|
|
|
- if (! $lockedGatewayOrderId || $lockedGatewayOrderId !== $gatewayOrderId) {
|
|
|
- return false;
|
|
|
- }
|
|
|
-
|
|
|
- return (bool) $orderRepository->cancel($lockedOrder,true);
|
|
|
- });
|
|
|
-
|
|
|
- if ($cancelled) {
|
|
|
- Event::dispatch('bagistoapi.payment.reconcile.cancelled', $order);
|
|
|
- }
|
|
|
+ [$status, $capture] = $handlerCanQuery
|
|
|
+ ? $this->queryHandler($gatewayHandler, $gatewayOrderId)
|
|
|
+ : $this->querySmartButton($paymentService, $gatewayOrderId);
|
|
|
} catch (\Throwable $e) {
|
|
|
Log::warning('ReconcilePendingPaymentJob: gateway lookup failed', [
|
|
|
'order_id' => $order->id,
|
|
|
'gateway_order_id' => $gatewayOrderId,
|
|
|
+ 'tick' => $this->tick,
|
|
|
'error' => $e->getMessage(),
|
|
|
]);
|
|
|
|
|
|
/*
|
|
|
- * The gateway didn't tell us anything useful. Let the
|
|
|
- * retry mechanism take over - we'd rather try again than
|
|
|
- * cancel a possibly-paid order.
|
|
|
+ * The gateway didn't tell us anything useful. Let the retry
|
|
|
+ * mechanism take over - we'd rather try again than cancel a
|
|
|
+ * possibly-paid order.
|
|
|
*/
|
|
|
throw $e;
|
|
|
}
|
|
|
+
|
|
|
+ if ($capture) {
|
|
|
+ $this->settle($paymentService, $order, (string) $gatewayOrderId, $capture, $status);
|
|
|
+
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (in_array($status, self::TERMINAL_STATUSES, true)) {
|
|
|
+ Event::dispatch('bagistoapi.payment.reconcile.voided', $order);
|
|
|
+
|
|
|
+ $this->cancel($orderRepository, $order, (string) $gatewayOrderId);
|
|
|
+
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $elapsed = $this->elapsedSeconds($order, $paymentAttemptRepository);
|
|
|
+ $delay = $this->nextDelaySeconds($elapsed);
|
|
|
+
|
|
|
+ if ($delay !== null) {
|
|
|
+ $this->scheduleNextTick($delay);
|
|
|
+
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (! in_array((string) $status, self::CANCELLABLE_STATUSES, true)) {
|
|
|
+ /*
|
|
|
+ * Most commonly APPROVED: the buyer authorized the payment but
|
|
|
+ * the capture never happened. Capturing here would move money
|
|
|
+ * without a user action, so this is an ops decision.
|
|
|
+ */
|
|
|
+ Event::dispatch('bagistoapi.payment.reconcile.approved-unsettled', [
|
|
|
+ 'order' => $order,
|
|
|
+ 'gateway_status' => $status,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ $this->cancel($orderRepository, $order, (string) $gatewayOrderId);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Ask a Longyi payment handler for the gateway state.
|
|
|
+ *
|
|
|
+ * Handlers return ['status' => string|null, 'capture' => array|null],
|
|
|
+ * where capture matches PaymentService::extractCapture().
|
|
|
+ *
|
|
|
+ * @return array{0: string, 1: ?array}
|
|
|
+ */
|
|
|
+ protected function queryHandler(object $gatewayHandler, string $gatewayOrderId): array
|
|
|
+ {
|
|
|
+ $result = (array) $gatewayHandler->fetchOrderStatus($gatewayOrderId);
|
|
|
+
|
|
|
+ $capture = $result['capture'] ?? null;
|
|
|
+
|
|
|
+ return [
|
|
|
+ strtoupper((string) ($result['status'] ?? '')),
|
|
|
+ is_array($capture) && ! empty($capture['transaction_id']) ? $capture : null,
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Read-only PayPal Smart Button lookup. Never captures - a capture
|
|
|
+ * discovered here has already been settled on PayPal's side.
|
|
|
+ *
|
|
|
+ * @return array{0: string, 1: ?array}
|
|
|
+ */
|
|
|
+ protected function querySmartButton(PaymentService $paymentService, string $gatewayOrderId): array
|
|
|
+ {
|
|
|
+ $capture = $paymentService->extractCapture(app(SmartButton::class)->getOrder($gatewayOrderId));
|
|
|
+
|
|
|
+ $orderStatus = strtoupper((string) ($capture['order_status'] ?? ''));
|
|
|
+ $captureStatus = strtoupper((string) ($capture['capture_status'] ?? ''));
|
|
|
+
|
|
|
+ $settled = in_array($captureStatus, ['COMPLETED', 'CAPTURED'], true)
|
|
|
+ && ! empty($capture['transaction_id']);
|
|
|
+
|
|
|
+ return [$orderStatus, $settled ? $capture : null];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Write the discovered capture into the order. A deterministic rejection
|
|
|
+ * (amount mismatch, gateway order id mismatch) stops the polling loop and
|
|
|
+ * is escalated; anything else bubbles up to the retry mechanism.
|
|
|
+ */
|
|
|
+ protected function settle(
|
|
|
+ PaymentService $paymentService,
|
|
|
+ $order,
|
|
|
+ string $gatewayOrderId,
|
|
|
+ array $capture,
|
|
|
+ string $status,
|
|
|
+ ): void {
|
|
|
+ try {
|
|
|
+ $paymentService->settleReconciledCapture($order, $gatewayOrderId, $capture);
|
|
|
+ } catch (OperationFailedException $e) {
|
|
|
+ Log::error('ReconcilePendingPaymentJob: failed to settle a discovered capture', [
|
|
|
+ 'order_id' => $order->id,
|
|
|
+ 'gateway_order_id' => $gatewayOrderId,
|
|
|
+ 'error' => $e->getMessage(),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ Event::dispatch('bagistoapi.payment.reconcile.settle-failed', [
|
|
|
+ 'order' => $order,
|
|
|
+ 'gateway_status' => $status,
|
|
|
+ 'error' => $e->getMessage(),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ Event::dispatch('bagistoapi.payment.reconcile.settled', [
|
|
|
+ 'order' => $order,
|
|
|
+ 'gateway_status' => $status,
|
|
|
+ 'capture' => $capture,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Cancel the order, re-checking under a row lock right before we commit
|
|
|
+ * so we never race a late success callback.
|
|
|
+ */
|
|
|
+ protected function cancel(OrderRepository $orderRepository, $order, string $gatewayOrderId): void
|
|
|
+ {
|
|
|
+ $cancelled = DB::transaction(function () use ($orderRepository, $order, $gatewayOrderId): bool {
|
|
|
+ $orderModelClass = OrderProxy::modelClass();
|
|
|
+ $lockedOrder = $orderModelClass::query()->whereKey($order->id)->lockForUpdate()->first();
|
|
|
+
|
|
|
+ if (! $lockedOrder || ! $this->isPending($lockedOrder)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ $lockedAdditional = $lockedOrder->payment?->additional ?? [];
|
|
|
+ $lockedGatewayOrderId = $lockedAdditional['gateway_order_id'] ?? $lockedAdditional['paypal_order_id'] ?? null;
|
|
|
+
|
|
|
+ if (! $lockedGatewayOrderId || $lockedGatewayOrderId !== $gatewayOrderId) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ return (bool) $orderRepository->cancel($lockedOrder, true);
|
|
|
+ });
|
|
|
+
|
|
|
+ if ($cancelled) {
|
|
|
+ Event::dispatch('bagistoapi.payment.reconcile.cancelled', $order);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function scheduleNextTick(int $delaySeconds): void
|
|
|
+ {
|
|
|
+ $job = (new static($this->orderId, $this->tick + 1))
|
|
|
+ ->onQueue((string) config('bagistoapi.reconcile.queue', 'payment-reconcile'))
|
|
|
+ ->delay(now()->addSeconds($delaySeconds));
|
|
|
+
|
|
|
+ dispatch($job);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Seconds since the buyer was last handed off to the gateway.
|
|
|
+ */
|
|
|
+ protected function elapsedSeconds($order, PaymentAttemptRepository $paymentAttemptRepository): int
|
|
|
+ {
|
|
|
+ $anchor = $order->created_at ? Carbon::parse($order->created_at) : now();
|
|
|
+
|
|
|
+ $redirectedAt = $paymentAttemptRepository->latestRedirectedAt($this->orderId);
|
|
|
+
|
|
|
+ if ($redirectedAt && $redirectedAt->greaterThan($anchor)) {
|
|
|
+ $anchor = $redirectedAt;
|
|
|
+ }
|
|
|
+
|
|
|
+ return max(0, now()->getTimestamp() - $anchor->getTimestamp());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Seconds to wait before the next tick, or null once the window closes.
|
|
|
+ */
|
|
|
+ protected function nextDelaySeconds(int $elapsed): ?int
|
|
|
+ {
|
|
|
+ foreach ((array) config('bagistoapi.reconcile.early_marks', [30, 60, 300]) as $mark) {
|
|
|
+ if ($elapsed < (int) $mark) {
|
|
|
+ return (int) $mark - $elapsed;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($elapsed >= (int) config('bagistoapi.reconcile.max_seconds', 10800)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return $elapsed < (int) config('bagistoapi.reconcile.late_after', 1800)
|
|
|
+ ? (int) config('bagistoapi.reconcile.interval', 180)
|
|
|
+ : (int) config('bagistoapi.reconcile.late_interval', 300);
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function isPending($order): bool
|
|
|
+ {
|
|
|
+ return in_array($order->status, [Order::STATUS_PENDING, Order::STATUS_PENDING_PAYMENT], true);
|
|
|
}
|
|
|
}
|