Explorar el Código

支付添加fetchOrderStatus方法

llp hace 1 semana
padre
commit
17104737e8

+ 1 - 1
packages/Longyi/Pay/Afterpay/src/Payment/Afterpay.php

@@ -29,7 +29,7 @@ class Afterpay extends Payment
             $this->createPaymentUlr = 'https://global-api.afterpay.com/v2/checkouts';
             $this->createPaymentUlr = 'https://global-api.afterpay.com/v2/checkouts';
             $this->capturePaymentUlr = 'https://global-api.afterpay.com/v2/payments/capture';
             $this->capturePaymentUlr = 'https://global-api.afterpay.com/v2/payments/capture';
             $this->configurationUlr = 'https://global-api.afterpay.com/v2/configuration';
             $this->configurationUlr = 'https://global-api.afterpay.com/v2/configuration';
-            $this->orderStatusUlr = 'https://global-api-sandbox.afterpay.com/v2/payments/token:';
+            $this->orderStatusUlr = 'https://global-api.afterpay.com/v2/payments/token:';
         }
         }
         $this->clientId = $this->getConfigData('client_id_us');
         $this->clientId = $this->getConfigData('client_id_us');
         $this->apikey = $this->getConfigData('secret_id_us');
         $this->apikey = $this->getConfigData('secret_id_us');

+ 107 - 0
packages/Longyi/Pay/Airwallex/src/Payment/Airwallex.php

@@ -211,6 +211,113 @@ class Airwallex extends Payment
 
 
         return $invoiceData;
         return $invoiceData;
     }
     }
+
+    /**
+     * 查询网关订单状态,供 ReconcilePendingPaymentJob 对账使用。
+     *
+     * 返回统一契约 ['status' => string|null, 'capture' => array|null],
+     * 其中 capture 与 PaymentService::extractCapture() 同结构,未扣款时为 null。
+     *
+     * 这里只做查询,不允许产生扣款副作用;查询失败直接抛出,由调用方重试。
+     */
+    public function fetchOrderStatus($gatewayOrderId = null)
+    {
+        $order = $this->getOrder();
+
+        if (empty($gatewayOrderId)) {
+            $gatewayOrderId = $order?->payment->additional['gateway_order_id'] ?? null;
+        }
+
+        if (empty($gatewayOrderId)) {
+            throw new \Exception('gatewayOrderId is empty');
+        }
+
+        /*
+         * gateway_order_id 形如 "int_xxx:client_secret",查询只用到 intent id。
+         */
+        $paymentIntentId = $this->extractPaymentIntentId($gatewayOrderId);
+
+        $response = $this->httpClient()->request('GET', $this->paymentIntentsUlr . $paymentIntentId, [
+            'headers' => [
+                'Accept' => 'application/json',
+                'Content-Type' => 'application/json',
+                'Authorization' => 'Bearer ' . $this->getToken()
+            ],
+            'timeout' => 30,
+            'verify'  => false,
+        ]);
+        $responseBody = $response->getBody()->getContents();
+        Log::channel('payment')->info('airwallex fetchOrderStatus response', [
+            'gatewayOrderId'  => $gatewayOrderId,
+            'paymentIntentId' => $paymentIntentId,
+            'status'          => $response->getStatusCode(),
+            'body'            => $responseBody
+        ]);
+
+        $result = json_decode($responseBody, true) ?: [];
+        $gatewayStatus = strtoupper((string) ($result['status'] ?? ''));
+
+        if ($gatewayStatus === 'SUCCEEDED') {
+            $amount = $result['amount'] ?? null;
+            $currency = $result['currency'] ?? null;
+
+            return [
+                'status'  => 'COMPLETED',
+                'capture' => [
+                    'transaction_id'   => data_get($result, 'latest_payment_attempt.id', $result['id'] ?? $paymentIntentId),
+                    'gateway_order_id' => $gatewayOrderId,
+                    'order_status'     => 'COMPLETED',
+                    'capture_status'   => 'COMPLETED',
+                    'intent'           => 'CAPTURE',
+                    'amount'           => $amount !== null ? (float) $amount : null,
+                    'currency'         => $currency,
+                    'raw'              => $result,
+                ],
+            ];
+        }
+
+        return [
+            'status'  => $this->normalizeOrderStatus($gatewayStatus),
+            'capture' => null,
+        ];
+    }
+
+    /**
+     * 供测试替换的 HTTP 客户端出口。
+     */
+    protected function httpClient(): Client
+    {
+        return new Client();
+    }
+
+    /**
+     * 从 gateway_order_id 中取出 payment intent id。
+     */
+    protected function extractPaymentIntentId(string $gatewayOrderId): string
+    {
+        return explode(':', $gatewayOrderId, 2)[0];
+    }
+
+    /**
+     * 把 Airwallex payment intent 状态映射成对账任务认得的终态词。
+     *
+     * 映射成 CREATED 的状态表示买家还没真正付钱,超时之后可以被安全取消;
+     * REQUIRES_CAPTURE 是已授权未扣款,映射成 APPROVED 交给人工处理,
+     * 避免把买家已经承诺的钱丢掉。
+     */
+    protected function normalizeOrderStatus(string $gatewayStatus): ?string
+    {
+        return match ($gatewayStatus) {
+            ''                        => null,
+            'CANCELLED'               => 'VOIDED',
+            'REQUIRES_PAYMENT_METHOD' => 'CREATED',
+            'REQUIRES_CONFIRMATION'   => 'CREATED',
+            'REQUIRES_CAPTURE'        => 'APPROVED',
+            'EXPIRED'                 => 'EXPIRED',
+            default                   => $gatewayStatus,
+        };
+    }
+
     /**
     /**
      * 获取一个唯一的id
      * 获取一个唯一的id
      */
      */

+ 86 - 0
packages/Longyi/Pay/Applepay/src/Payment/Applepay.php

@@ -183,6 +183,92 @@ class Applepay extends Payment
             throw new \Exception("applepay capture is error");
             throw new \Exception("applepay capture is error");
         }
         }
     }
     }
+
+    /**
+     * 查询网关订单状态,供 ReconcilePendingPaymentJob 对账使用。
+     *
+     * 返回统一契约 ['status' => string|null, 'capture' => array|null],
+     * 其中 capture 与 PaymentService::extractCapture() 同结构,未扣款时为 null。
+     *
+     * 这里只做查询,不允许产生扣款副作用;查询失败直接抛出,由调用方重试。
+     *
+     * PayPal 的订单状态本身就落在对账任务认得的词表里,不需要额外映射:
+     * CREATED / SAVED / PAYER_ACTION_REQUIRED 可取消,VOIDED 立即取消,
+     * APPROVED 已授权未扣款则留给人工处理。
+     */
+    public function fetchOrderStatus($gatewayOrderId = null)
+    {
+        $order = $this->getOrder();
+
+        if (empty($gatewayOrderId)) {
+            $gatewayOrderId = $order?->payment->additional['gateway_order_id'] ?? null;
+        }
+
+        if (empty($gatewayOrderId)) {
+            throw new \Exception('gatewayOrderId is empty');
+        }
+
+        $response = $this->httpClient()->request(
+            'GET',
+            str_replace('{id}', $gatewayOrderId, $this->detailOrderApi),
+            [
+                'headers' => [
+                    'Accept' => 'application/json',
+                    'Content-Type' => 'application/json',
+                    'Authorization' => 'Basic ' . base64_encode("{$this->clientId}:{$this->apikey}")
+                ],
+                'timeout' => 30,
+                'verify'  => false,
+            ]
+        );
+        $responseBody = $response->getBody()->getContents();
+        Log::channel('payment')->info('applepay fetchOrderStatus response', [
+            'gatewayOrderId' => $gatewayOrderId,
+            'status'         => $response->getStatusCode(),
+            'body'           => $responseBody
+        ]);
+
+        $result = json_decode($responseBody, true) ?: [];
+        $gatewayStatus = strtoupper((string) ($result['status'] ?? ''));
+        $captureNode = data_get($result, 'purchase_units.0.payments.captures.0', []);
+        $captureStatus = strtoupper((string) ($captureNode['status'] ?? ''));
+
+        if (
+            in_array($gatewayStatus, ['COMPLETED', 'CAPTURED'], true)
+            && in_array($captureStatus, ['COMPLETED', 'CAPTURED'], true)
+            && ! empty($captureNode['id'])
+        ) {
+            return [
+                'status'  => 'COMPLETED',
+                'capture' => [
+                    'transaction_id'   => $captureNode['id'],
+                    'gateway_order_id' => $gatewayOrderId,
+                    'order_status'     => $gatewayStatus,
+                    'capture_status'   => $captureStatus,
+                    'intent'           => 'CAPTURE',
+                    'amount'           => isset($captureNode['amount']['value'])
+                        ? (float) $captureNode['amount']['value']
+                        : null,
+                    'currency'         => $captureNode['amount']['currency_code']
+                        ?? data_get($result, 'purchase_units.0.amount.currency_code'),
+                    'raw'              => $result,
+                ],
+            ];
+        }
+
+        return [
+            'status'  => $gatewayStatus === '' ? null : $gatewayStatus,
+            'capture' => null,
+        ];
+    }
+
+    /**
+     * 供测试替换的 HTTP 客户端出口。
+     */
+    protected function httpClient(): Client
+    {
+        return new Client();
+    }
     /**
     /**
      * Return cart items.
      * Return cart items.
      *
      *

+ 126 - 0
packages/Longyi/Pay/AwxAfterpay/src/Payment/AwxAfterpay.php

@@ -140,6 +140,7 @@ class AwxAfterpay extends Payment
             throw new \Exception($resultObject->details->original_response_message ?:$resultObject->message);
             throw new \Exception($resultObject->details->original_response_message ?:$resultObject->message);
         } else {
         } else {
             $id = $resultObject->id;
             $id = $resultObject->id;
+            $this->rememberPaymentIntentId($order, $id);
             $confirmResultObject = $this->confirmRequest($id, $order);
             $confirmResultObject = $this->confirmRequest($id, $order);
             if (empty($confirmResultObject->next_action->url)) {
             if (empty($confirmResultObject->next_action->url)) {
                 throw new \Exception($confirmResultObject->details->original_response_message ?:$confirmResultObject->message);
                 throw new \Exception($confirmResultObject->details->original_response_message ?:$confirmResultObject->message);
@@ -258,6 +259,131 @@ class AwxAfterpay extends Payment
             ]);
             ]);
         }
         }
     }
     }
+
+    /**
+     * 记下 Airwallex payment intent id,供 fetchOrderStatus 对账查询使用。
+     *
+     * gateway_order_id 存的是跳转地址而不是 intent id,反推不可靠,所以单独存档。
+     */
+    protected function rememberPaymentIntentId($order, string $paymentIntentId): void
+    {
+        if (! $order instanceof Order || ! $order->payment) {
+            return;
+        }
+
+        $additional = $order->payment->additional ?? [];
+        $additional['airwallex_intent_id'] = $paymentIntentId;
+        $order->payment->additional = $additional;
+        $order->payment->save();
+    }
+
+    /**
+     * 查询网关订单状态,供 ReconcilePendingPaymentJob 对账使用。
+     *
+     * 返回统一契约 ['status' => string|null, 'capture' => array|null],
+     * 其中 capture 与 PaymentService::extractCapture() 同结构,未扣款时为 null。
+     *
+     * 这里只做查询,不允许产生扣款副作用;查询失败直接抛出,由调用方重试。
+     */
+    public function fetchOrderStatus($gatewayOrderId = null)
+    {
+        $order = $this->getOrder();
+        $additional = $order?->payment->additional ?? [];
+
+        $paymentIntentId = $additional['airwallex_intent_id']
+            ?? $this->extractPaymentIntentId((string) ($gatewayOrderId ?: ($additional['gateway_order_id'] ?? '')));
+
+        if (empty($paymentIntentId)) {
+            /*
+             * 定位不到 payment intent 就无从判断网关状态;映射成 APPROVED
+             * 让对账任务走人工处理分支,而不是取消一个可能已经付过款的订单。
+             */
+            return [
+                'status'  => 'APPROVED',
+                'capture' => null,
+            ];
+        }
+
+        $response = $this->httpClient()->request('GET', $this->paymentIntentsUlr . $paymentIntentId, [
+            'headers' => [
+                'Accept' => 'application/json',
+                'Content-Type' => 'application/json',
+                'Authorization' => 'Bearer ' . $this->getToken()
+            ],
+            'timeout' => 30,
+            'verify'  => false,
+        ]);
+        $responseBody = $response->getBody()->getContents();
+        Log::channel('payment')->info('awxafterpay fetchOrderStatus response', [
+            'gatewayOrderId'  => $gatewayOrderId,
+            'paymentIntentId' => $paymentIntentId,
+            'status'          => $response->getStatusCode(),
+            'body'            => $responseBody
+        ]);
+
+        $result = json_decode($responseBody, true) ?: [];
+        $gatewayStatus = strtoupper((string) ($result['status'] ?? ''));
+
+        if ($gatewayStatus === 'SUCCEEDED') {
+            $amount = $result['amount'] ?? null;
+
+            return [
+                'status'  => 'COMPLETED',
+                'capture' => [
+                    'transaction_id'   => data_get($result, 'latest_payment_attempt.id', $result['id'] ?? $paymentIntentId),
+                    'gateway_order_id' => $gatewayOrderId,
+                    'order_status'     => 'COMPLETED',
+                    'capture_status'   => 'COMPLETED',
+                    'intent'           => 'CAPTURE',
+                    'amount'           => $amount !== null ? (float) $amount : null,
+                    'currency'         => $result['currency'] ?? null,
+                    'raw'              => $result,
+                ],
+            ];
+        }
+
+        return [
+            'status'  => $this->normalizeOrderStatus($gatewayStatus),
+            'capture' => null,
+        ];
+    }
+
+    /**
+     * 供测试替换的 HTTP 客户端出口。
+     */
+    protected function httpClient(): Client
+    {
+        return new Client();
+    }
+
+    /**
+     * 从跳转地址之类的字符串里捞出 Airwallex payment intent id。
+     */
+    protected function extractPaymentIntentId(string $value): ?string
+    {
+        return preg_match('/int_[A-Za-z0-9_-]+/', $value, $matches) ? $matches[0] : null;
+    }
+
+    /**
+     * 把 Airwallex payment intent 状态映射成对账任务认得的终态词。
+     *
+     * 映射成 CREATED 的状态表示买家还没真正付钱,超时之后可以被安全取消;
+     * REQUIRES_CAPTURE 是已授权未扣款,映射成 APPROVED 交给人工处理,
+     * 避免把买家已经承诺的钱丢掉。
+     */
+    protected function normalizeOrderStatus(string $gatewayStatus): ?string
+    {
+        return match ($gatewayStatus) {
+            ''                        => null,
+            'CANCELLED'               => 'VOIDED',
+            'REQUIRES_PAYMENT_METHOD' => 'CREATED',
+            'REQUIRES_CONFIRMATION'   => 'CREATED',
+            'REQUIRES_CAPTURE'        => 'APPROVED',
+            'EXPIRED'                 => 'EXPIRED',
+            default                   => $gatewayStatus,
+        };
+    }
+
     /**
     /**
      * 获取一个唯一的id
      * 获取一个唯一的id
      */
      */

+ 126 - 0
packages/Longyi/Pay/AwxKlarna/src/Payment/AwxKlarna.php

@@ -141,6 +141,7 @@ class AwxKlarna extends Payment
             throw new \Exception($resultObject->details->original_response_message ?:$resultObject->message);
             throw new \Exception($resultObject->details->original_response_message ?:$resultObject->message);
         } else {
         } else {
             $id = $resultObject->id;
             $id = $resultObject->id;
+            $this->rememberPaymentIntentId($order, $id);
             $confirmResultObject = $this->confirmRequest($id, $order);
             $confirmResultObject = $this->confirmRequest($id, $order);
             if (empty($confirmResultObject->next_action->url)) {
             if (empty($confirmResultObject->next_action->url)) {
                 throw new \Exception($confirmResultObject->details->original_response_message ?:$confirmResultObject->message);
                 throw new \Exception($confirmResultObject->details->original_response_message ?:$confirmResultObject->message);
@@ -265,6 +266,131 @@ class AwxKlarna extends Payment
             ]);
             ]);
         }
         }
     }
     }
+
+    /**
+     * 记下 Airwallex payment intent id,供 fetchOrderStatus 对账查询使用。
+     *
+     * gateway_order_id 存的是跳转地址而不是 intent id,反推不可靠,所以单独存档。
+     */
+    protected function rememberPaymentIntentId($order, string $paymentIntentId): void
+    {
+        if (! $order instanceof Order || ! $order->payment) {
+            return;
+        }
+
+        $additional = $order->payment->additional ?? [];
+        $additional['airwallex_intent_id'] = $paymentIntentId;
+        $order->payment->additional = $additional;
+        $order->payment->save();
+    }
+
+    /**
+     * 查询网关订单状态,供 ReconcilePendingPaymentJob 对账使用。
+     *
+     * 返回统一契约 ['status' => string|null, 'capture' => array|null],
+     * 其中 capture 与 PaymentService::extractCapture() 同结构,未扣款时为 null。
+     *
+     * 这里只做查询,不允许产生扣款副作用;查询失败直接抛出,由调用方重试。
+     */
+    public function fetchOrderStatus($gatewayOrderId = null)
+    {
+        $order = $this->getOrder();
+        $additional = $order?->payment->additional ?? [];
+
+        $paymentIntentId = $additional['airwallex_intent_id']
+            ?? $this->extractPaymentIntentId((string) ($gatewayOrderId ?: ($additional['gateway_order_id'] ?? '')));
+
+        if (empty($paymentIntentId)) {
+            /*
+             * 定位不到 payment intent 就无从判断网关状态;映射成 APPROVED
+             * 让对账任务走人工处理分支,而不是取消一个可能已经付过款的订单。
+             */
+            return [
+                'status'  => 'APPROVED',
+                'capture' => null,
+            ];
+        }
+
+        $response = $this->httpClient()->request('GET', $this->paymentIntentsUlr . $paymentIntentId, [
+            'headers' => [
+                'Accept' => 'application/json',
+                'Content-Type' => 'application/json',
+                'Authorization' => 'Bearer ' . $this->getToken()
+            ],
+            'timeout' => 30,
+            'verify'  => false,
+        ]);
+        $responseBody = $response->getBody()->getContents();
+        Log::channel('payment')->info('awxklarna fetchOrderStatus response', [
+            'gatewayOrderId'  => $gatewayOrderId,
+            'paymentIntentId' => $paymentIntentId,
+            'status'          => $response->getStatusCode(),
+            'body'            => $responseBody
+        ]);
+
+        $result = json_decode($responseBody, true) ?: [];
+        $gatewayStatus = strtoupper((string) ($result['status'] ?? ''));
+
+        if ($gatewayStatus === 'SUCCEEDED') {
+            $amount = $result['amount'] ?? null;
+
+            return [
+                'status'  => 'COMPLETED',
+                'capture' => [
+                    'transaction_id'   => data_get($result, 'latest_payment_attempt.id', $result['id'] ?? $paymentIntentId),
+                    'gateway_order_id' => $gatewayOrderId,
+                    'order_status'     => 'COMPLETED',
+                    'capture_status'   => 'COMPLETED',
+                    'intent'           => 'CAPTURE',
+                    'amount'           => $amount !== null ? (float) $amount : null,
+                    'currency'         => $result['currency'] ?? null,
+                    'raw'              => $result,
+                ],
+            ];
+        }
+
+        return [
+            'status'  => $this->normalizeOrderStatus($gatewayStatus),
+            'capture' => null,
+        ];
+    }
+
+    /**
+     * 供测试替换的 HTTP 客户端出口。
+     */
+    protected function httpClient(): Client
+    {
+        return new Client();
+    }
+
+    /**
+     * 从跳转地址之类的字符串里捞出 Airwallex payment intent id。
+     */
+    protected function extractPaymentIntentId(string $value): ?string
+    {
+        return preg_match('/int_[A-Za-z0-9_-]+/', $value, $matches) ? $matches[0] : null;
+    }
+
+    /**
+     * 把 Airwallex payment intent 状态映射成对账任务认得的终态词。
+     *
+     * 映射成 CREATED 的状态表示买家还没真正付钱,超时之后可以被安全取消;
+     * REQUIRES_CAPTURE 是已授权未扣款,映射成 APPROVED 交给人工处理,
+     * 避免把买家已经承诺的钱丢掉。
+     */
+    protected function normalizeOrderStatus(string $gatewayStatus): ?string
+    {
+        return match ($gatewayStatus) {
+            ''                        => null,
+            'CANCELLED'               => 'VOIDED',
+            'REQUIRES_PAYMENT_METHOD' => 'CREATED',
+            'REQUIRES_CONFIRMATION'   => 'CREATED',
+            'REQUIRES_CAPTURE'        => 'APPROVED',
+            'EXPIRED'                 => 'EXPIRED',
+            default                   => $gatewayStatus,
+        };
+    }
+
     /**
     /**
      * 获取一个唯一的id
      * 获取一个唯一的id
      */
      */

+ 118 - 0
packages/Longyi/Pay/Klarna/src/Payment/Klarna.php

@@ -284,6 +284,124 @@ class Klarna extends Payment
         return true;
         return true;
     }
     }
 
 
+    /**
+     * 查询网关订单状态,供 ReconcilePendingPaymentJob 对账使用。
+     *
+     * 返回统一契约 ['status' => string|null, 'capture' => array|null],
+     * 其中 capture 与 PaymentService::extractCapture() 同结构,未扣款时为 null。
+     *
+     * 这里只做查询,不允许产生扣款副作用;查询失败直接抛出,由调用方重试。
+     *
+     * Klarna 侧只有商户调用 createOrder 之后才有订单,商户订单号存在
+     * order_payment.additional.transaction_id 里;没有它说明买家没走完授权
+     * (Klarna 不会扣款,授权也会自动释放),映射成 CREATED 让对账任务
+     * 在超时后取消本地订单。$gatewayOrderId 传进来的是会话 client_token,
+     * 查不到 Klarna 订单,这里不使用。
+     */
+    public function fetchOrderStatus($gatewayOrderId = null)
+    {
+        $order = $this->getOrder();
+        $additional = $order?->payment->additional ?? [];
+        $klarnaOrderId = $additional['transaction_id'] ?? ($order?->payment->transaction_id ?? null);
+
+        if (empty($klarnaOrderId)) {
+            return [
+                'status'  => 'CREATED',
+                'capture' => null,
+            ];
+        }
+
+        $response = $this->httpClient()->request(
+            'GET',
+            str_replace('{order_id}', $klarnaOrderId, $this->getOrderUlr),
+            [
+                'headers' => [
+                    'Accept' => 'application/json',
+                    'Content-Type' => 'application/json',
+                    'Authorization' => 'Basic ' . base64_encode("{$this->clientId}:{$this->apikey}")
+                ],
+                'timeout' => 30,
+                'verify'  => false,
+            ]
+        );
+        $responseBody = $response->getBody()->getContents();
+        Log::channel('payment')->info('klarna fetchOrderStatus response', [
+            'klarnaOrderId' => $klarnaOrderId,
+            'status'        => $response->getStatusCode(),
+            'body'          => $responseBody
+        ]);
+
+        $result = json_decode($responseBody, true) ?: [];
+        $gatewayStatus = strtoupper((string) ($result['status'] ?? ''));
+        $fraudStatus = strtoupper((string) ($result['fraud_status'] ?? ''));
+
+        // 风控拒绝的订单永远扣不到钱,交给对账任务取消。
+        if ($fraudStatus === 'REJECTED') {
+            return [
+                'status'  => 'DECLINED',
+                'capture' => null,
+            ];
+        }
+
+        if ($gatewayStatus === 'CAPTURED') {
+            $amount = $result['captured_amount'] ?? $result['order_amount'] ?? null;
+
+            return [
+                'status'  => 'COMPLETED',
+                'capture' => [
+                    'transaction_id'   => $result['order_id'] ?? $klarnaOrderId,
+                    'gateway_order_id' => $result['order_id'] ?? $klarnaOrderId,
+                    'order_status'     => 'COMPLETED',
+                    'capture_status'   => 'COMPLETED',
+                    'intent'           => 'CAPTURE',
+                    'amount'           => $amount !== null ? $this->toMajorUnits($amount) : null,
+                    'currency'         => $result['purchase_currency'] ?? $order?->order_currency_code,
+                    'raw'              => $result,
+                ],
+            ];
+        }
+
+        return [
+            'status'  => $this->normalizeOrderStatus($gatewayStatus),
+            'capture' => null,
+        ];
+    }
+
+    /**
+     * 供测试替换的 HTTP 客户端出口。
+     */
+    protected function httpClient(): Client
+    {
+        return new Client();
+    }
+
+    /**
+     * Klarna 的金额是分(minor units),转回主单位用于和订单金额比对。
+     */
+    protected function toMajorUnits($amount): float
+    {
+        return round(((int) $amount) / 100, 2);
+    }
+
+    /**
+     * 把 Klarna 订单状态映射成对账任务认得的终态词。
+     *
+     * AUTHORIZED / PART_CAPTURED 表示钱已经(部分)落袋,映射成 APPROVED
+     * 交给人工处理,避免对账任务把订单取消掉;其余未知状态原样返回,
+     * 对账任务的取消白名单里没有它,同样不会被误取消。
+     */
+    protected function normalizeOrderStatus(string $gatewayStatus): ?string
+    {
+        return match ($gatewayStatus) {
+            ''              => null,
+            'CANCELLED'     => 'VOIDED',
+            'EXPIRED'       => 'EXPIRED',
+            'AUTHORIZED'    => 'APPROVED',
+            'PART_CAPTURED' => 'APPROVED',
+            default         => $gatewayStatus,
+        };
+    }
+
     /**
     /**
      * Prepares order's invoice data for creation.
      * Prepares order's invoice data for creation.
      */
      */

+ 1 - 0
packages/Webkul/BagistoApi/src/Resources/lang/en/app.php

@@ -183,6 +183,7 @@ return [
             'initiated'                         => 'Payment initiated successfully',
             'initiated'                         => 'Payment initiated successfully',
             'replayed'                          => 'A new gateway order has been issued',
             'replayed'                          => 'A new gateway order has been issued',
             'success'                           => 'Payment captured successfully',
             'success'                           => 'Payment captured successfully',
+            'awaiting-confirmation'             => 'Payment is awaiting gateway confirmation',
             'cancelled'                         => 'Payment was cancelled',
             'cancelled'                         => 'Payment was cancelled',
             'failed'                            => 'Payment failed',
             'failed'                            => 'Payment failed',
             'express-disabled'                  => 'Express checkout is disabled',
             'express-disabled'                  => 'Express checkout is disabled',

+ 46 - 9
packages/Webkul/BagistoApi/src/Services/PaymentService.php

@@ -811,6 +811,10 @@ class PaymentService
      * amount, fill in express addresses, flip the order to processing
      * amount, fill in express addresses, flip the order to processing
      * and generate the invoice + settled transaction record.
      * and generate the invoice + settled transaction record.
      *
      *
+     * Only a capture we can verify flips the order to processing. When the
+     * gateway settles asynchronously (webhook) instead, the callback is
+     * reported as awaiting confirmation and the order stays pending.
+     *
      * Idempotent: a second (possibly concurrent) success callback for an
      * Idempotent: a second (possibly concurrent) success callback for an
      * order that is already past pending short-circuits to success
      * order that is already past pending short-circuits to success
      * without re-capturing. Concurrency is serialized via a row lock.
      * without re-capturing. Concurrency is serialized via a row lock.
@@ -826,6 +830,21 @@ class PaymentService
         $method = $order->payment?->method;
         $method = $order->payment?->method;
         $gatewayOrderId = $input->gatewayOrderId ?: ($additional['paypal_order_id'] ?? null);
         $gatewayOrderId = $input->gatewayOrderId ?: ($additional['paypal_order_id'] ?? null);
 
 
+        if (! $this->canVerifyCapture($method, $order, $input)) {
+            Log::warning('Payment success callback could not be verified; order left pending', [
+                'order_id'         => $order->id,
+                'payment_method'   => $method,
+                'gateway_order_id' => $gatewayOrderId,
+            ]);
+
+            Event::dispatch('bagistoapi.payment.callback-unverified', [
+                'order'          => $order,
+                'gateway_status' => 'awaiting-confirmation',
+            ]);
+
+            return $this->awaitingConfirmationResponse($order, 'awaiting-confirmation');
+        }
+
         try {
         try {
             $result = DB::transaction(function () use ($order, $input, $isExpress, $method, $gatewayOrderId) {
             $result = DB::transaction(function () use ($order, $input, $isExpress, $method, $gatewayOrderId) {
                 /*
                 /*
@@ -841,19 +860,16 @@ class PaymentService
                 if (! $this->isPending($order)) {
                 if (! $this->isPending($order)) {
                     return ['skipped' => true, 'capture' => null, 'response' => $this->successResponse($order, 'already_processed')];
                     return ['skipped' => true, 'capture' => null, 'response' => $this->successResponse($order, 'already_processed')];
                 }
                 }
-
                 $capture = null;
                 $capture = null;
                 $gatewayHandler = $this->resolveGatewayHandler($method, $order, $input);
                 $gatewayHandler = $this->resolveGatewayHandler($method, $order, $input);
-                if ($gatewayHandler) {
+                if ($gatewayHandler && method_exists($gatewayHandler, 'captureAndVerify')) {
                     $capture = $gatewayHandler->captureAndVerify($order, $gatewayOrderId);
                     $capture = $gatewayHandler->captureAndVerify($order, $gatewayOrderId);
-                } else {
-                    if ($method === 'paypal_smart_button') {
-                        if (! $gatewayOrderId) {
-                            throw new OperationFailedException(__('bagistoapi::app.graphql.payment.gateway-order-id-required'));
-                        }
-
-                        $capture = $this->captureAndVerify($order, $gatewayOrderId);
+                } elseif ($method === 'paypal_smart_button') {
+                    if (! $gatewayOrderId) {
+                        throw new OperationFailedException(__('bagistoapi::app.graphql.payment.gateway-order-id-required'));
                     }
                     }
+
+                    $capture = $this->captureAndVerify($order, $gatewayOrderId);
                 }
                 }
 
 
                 return $this->finalizeCapturedOrder($order, $capture, $isExpress ? $input : null);
                 return $this->finalizeCapturedOrder($order, $capture, $isExpress ? $input : null);
@@ -1171,6 +1187,27 @@ class PaymentService
         ];
         ];
     }
     }
 
 
+    protected function canVerifyCapture(?string $method, $order, $input = null): bool
+    {
+        $gatewayHandler = $this->resolveGatewayHandler((string) $method, $order, $input);
+
+        if ($gatewayHandler) {
+            return method_exists($gatewayHandler, 'captureAndVerify');
+        }
+
+        return $method === 'paypal_smart_button';
+    }
+
+    protected function awaitingConfirmationResponse($order, string $gatewayStatus): array
+    {
+        return [
+            'order'         => $order,
+            'status'        => 'pending',
+            'gatewayStatus' => $gatewayStatus,
+            'message'       => __('bagistoapi::app.graphql.payment.awaiting-confirmation'),
+        ];
+    }
+
     protected function gatewayOrderIdFromOrder($order): ?string
     protected function gatewayOrderIdFromOrder($order): ?string
     {
     {
         $additional = $order->payment?->additional ?? [];
         $additional = $order->payment?->additional ?? [];