|
|
@@ -577,6 +577,116 @@ class PaymentService
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Settle a capture reported by a verified PayPal webhook.
|
|
|
+ *
|
|
|
+ * Unlike callback(), this path must never call captureOrder() again:
|
|
|
+ * PAYMENT.CAPTURE.COMPLETED means PayPal has already moved the money.
|
|
|
+ */
|
|
|
+ public function completeWebhookCapture(
|
|
|
+ $order,
|
|
|
+ string $gatewayOrderId,
|
|
|
+ array $capture,
|
|
|
+ string $eventId,
|
|
|
+ ): array {
|
|
|
+ if (! $this->isPending($order)) {
|
|
|
+ return $this->successResponse($order, 'already_processed');
|
|
|
+ }
|
|
|
+
|
|
|
+ $method = $order->payment?->method;
|
|
|
+ $isExpress = $this->expressFlag($order);
|
|
|
+
|
|
|
+ try {
|
|
|
+ $result = DB::transaction(function () use ($order, $gatewayOrderId, $capture, $method, $isExpress) {
|
|
|
+ $orderModelClass = OrderProxy::modelClass();
|
|
|
+ $orderModelClass::query()->whereKey($order->id)->lockForUpdate()->first();
|
|
|
+
|
|
|
+ $order->refresh();
|
|
|
+
|
|
|
+ if (! $this->isPending($order)) {
|
|
|
+ return [
|
|
|
+ 'skipped' => true,
|
|
|
+ 'capture' => null,
|
|
|
+ 'response' => $this->successResponse($order, 'already_processed'),
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($method !== 'paypal_smart_button' || $order->payment?->method !== 'paypal_smart_button') {
|
|
|
+ throw new OperationFailedException('The order does not use PayPal Smart Button.');
|
|
|
+ }
|
|
|
+
|
|
|
+ $storedGatewayOrderId = $this->gatewayOrderIdFromOrder($order);
|
|
|
+
|
|
|
+ if (
|
|
|
+ ! $storedGatewayOrderId
|
|
|
+ || ! hash_equals((string) $storedGatewayOrderId, $gatewayOrderId)
|
|
|
+ || ! hash_equals((string) ($capture['gateway_order_id'] ?? ''), $gatewayOrderId)
|
|
|
+ ) {
|
|
|
+ throw new OperationFailedException('The PayPal order id does not match the local order.');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (
|
|
|
+ empty($capture['transaction_id'])
|
|
|
+ || strtoupper((string) ($capture['capture_status'] ?? '')) !== 'COMPLETED'
|
|
|
+ || ! array_key_exists('amount', $capture)
|
|
|
+ || $capture['amount'] === null
|
|
|
+ || empty($capture['currency'])
|
|
|
+ ) {
|
|
|
+ throw new OperationFailedException('The PayPal capture is incomplete or not completed.');
|
|
|
+ }
|
|
|
+
|
|
|
+ $this->assertAmountMatches($order, $capture);
|
|
|
+
|
|
|
+ if ($isExpress && $this->isPlaceholderAddress($order->shipping_address)) {
|
|
|
+ Log::warning('PayPal webhook settled an express order with a placeholder address', [
|
|
|
+ 'order_id' => $order->id,
|
|
|
+ 'gateway_order_id' => $gatewayOrderId,
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ return $this->finalizeCapturedOrder($order, $capture);
|
|
|
+ });
|
|
|
+ } catch (OperationFailedException $e) {
|
|
|
+ $this->recordAttempt([
|
|
|
+ 'order_id' => $order->id,
|
|
|
+ 'cart_id' => $this->resolveOldCartId($order),
|
|
|
+ 'payment_method' => $method,
|
|
|
+ 'gateway_order_id' => $gatewayOrderId,
|
|
|
+ 'action' => PaymentAttempt::ACTION_WEBHOOK,
|
|
|
+ 'status' => PaymentAttempt::STATUS_FAILED,
|
|
|
+ 'amount' => (float) ($order->grand_total ?? 0),
|
|
|
+ 'currency' => $order->order_currency_code ?? $order->cart_currency_code,
|
|
|
+ 'express' => $isExpress,
|
|
|
+ 'response_payload' => [
|
|
|
+ 'event_id' => $eventId,
|
|
|
+ 'error' => $e->getMessage(),
|
|
|
+ ],
|
|
|
+ ]);
|
|
|
+
|
|
|
+ throw $e;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (empty($result['skipped'])) {
|
|
|
+ $this->recordAttempt([
|
|
|
+ 'order_id' => $order->id,
|
|
|
+ 'cart_id' => $this->resolveOldCartId($order),
|
|
|
+ 'payment_method' => $method,
|
|
|
+ 'gateway_order_id' => $gatewayOrderId,
|
|
|
+ 'action' => PaymentAttempt::ACTION_WEBHOOK,
|
|
|
+ 'status' => PaymentAttempt::STATUS_CAPTURED,
|
|
|
+ 'amount' => (float) ($order->grand_total ?? 0),
|
|
|
+ 'currency' => $order->order_currency_code ?? $order->cart_currency_code,
|
|
|
+ 'express' => $isExpress,
|
|
|
+ 'idempotency_key' => 'paypal-webhook:'.$eventId,
|
|
|
+ 'response_payload' => $result['capture'] ?? null,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ Event::dispatch('bagistoapi.payment.success', $result['response']['order']);
|
|
|
+ }
|
|
|
+
|
|
|
+ return $result['response'];
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Success branch: capture the gateway order, verify the captured
|
|
|
* amount, fill in express addresses, flip the order to processing
|
|
|
@@ -626,25 +736,8 @@ class PaymentService
|
|
|
$capture = $this->captureAndVerify($order, $gatewayOrderId);
|
|
|
}
|
|
|
}
|
|
|
- if ($isExpress) {
|
|
|
- $this->fillAddressesFromCallback($order, $input);
|
|
|
- }
|
|
|
-
|
|
|
- if ($capture && ! empty($capture['transaction_id'])) {
|
|
|
- $this->writeTransactionId($order, (string) $capture['transaction_id']);
|
|
|
- }
|
|
|
-
|
|
|
- $this->orderRepository->updateOrderStatus($order, Order::STATUS_PROCESSING);
|
|
|
-
|
|
|
- $invoice = $this->createInvoiceIfPossible($order);
|
|
|
-
|
|
|
- if ($capture) {
|
|
|
- $this->recordOrderTransaction($order, $invoice, $capture);
|
|
|
- }
|
|
|
|
|
|
- $order->refresh();
|
|
|
-
|
|
|
- return ['skipped' => false, 'capture' => $capture, 'response' => $this->successResponse($order, 'captured')];
|
|
|
+ return $this->finalizeCapturedOrder($order, $capture, $isExpress ? $input : null);
|
|
|
});
|
|
|
} catch (OperationFailedException $e) {
|
|
|
/*
|
|
|
@@ -688,6 +781,41 @@ class PaymentService
|
|
|
return $result['response'];
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Apply the local side effects for an already completed capture.
|
|
|
+ *
|
|
|
+ * Callers must hold the order row lock before invoking this method.
|
|
|
+ */
|
|
|
+ protected function finalizeCapturedOrder(
|
|
|
+ $order,
|
|
|
+ ?array $capture,
|
|
|
+ ?PaymentCallbackInput $input = null,
|
|
|
+ ): array {
|
|
|
+ if ($input) {
|
|
|
+ $this->fillAddressesFromCallback($order, $input);
|
|
|
+ }
|
|
|
+
|
|
|
+ if ($capture && ! empty($capture['transaction_id'])) {
|
|
|
+ $this->writeTransactionId($order, (string) $capture['transaction_id']);
|
|
|
+ }
|
|
|
+
|
|
|
+ $this->orderRepository->updateOrderStatus($order, Order::STATUS_PROCESSING);
|
|
|
+
|
|
|
+ $invoice = $this->createInvoiceIfPossible($order);
|
|
|
+
|
|
|
+ if ($capture) {
|
|
|
+ $this->recordOrderTransaction($order, $invoice, $capture);
|
|
|
+ }
|
|
|
+
|
|
|
+ $order->refresh();
|
|
|
+
|
|
|
+ return [
|
|
|
+ 'skipped' => false,
|
|
|
+ 'capture' => $capture,
|
|
|
+ 'response' => $this->successResponse($order, 'captured'),
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* Cancel/failure branch: keep the order in PENDING and let the
|
|
|
* caller decide whether to actually cancel it via the dedicated
|