chengwl hai 2 semanas
pai
achega
919a5ccf29

+ 26 - 0
packages/Longyi/Feishu/composer.json

@@ -0,0 +1,26 @@
+{
+    "name": "longyi/feishu",
+    "description": "Feishu (Lark) custom-bot alerting for Bagisto operations",
+    "type": "library",
+    "license": "MIT",
+    "authors": [
+        {
+            "name": "Longyi",
+            "email": "dev@longyi.com"
+        }
+    ],
+    "minimum-stability": "dev",
+    "require": {},
+    "autoload": {
+        "psr-4": {
+            "Longyi\\Feishu\\": "src/"
+        }
+    },
+    "extra": {
+        "laravel": {
+            "providers": [
+                "Longyi\\Feishu\\Providers\\FeishuServiceProvider"
+            ]
+        }
+    }
+}

+ 108 - 0
packages/Longyi/Feishu/src/Config/feishu.php

@@ -0,0 +1,108 @@
+<?php
+
+use Longyi\Feishu\Listeners\PaymentAlertSubscriber;
+
+/*
+|--------------------------------------------------------------------------
+| 飞书群自定义机器人
+|--------------------------------------------------------------------------
+|
+| 用于把需要人工介入的运营事件推送到飞书群。发送始终走队列,飞书不可用
+| 不会影响支付主流程。
+|
+*/
+
+return [
+
+    /*
+    | 总开关。未配置 webhook_url 时即使打开也不会发送。
+    */
+    'enabled' => env('FEISHU_BOT_ENABLED', false),
+
+    /*
+    | 群机器人的 Webhook 地址,形如
+    | https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx
+    */
+    'webhook_url' => env('FEISHU_BOT_WEBHOOK_URL', ''),
+
+    /*
+    | 机器人「加签」安全设置里的密钥。留空表示未开启加签。
+    */
+    'secret' => env('FEISHU_BOT_SECRET', ''),
+
+    /*
+    | 单次请求超时(秒)。飞书接口很快,不要设太长,免得拖住队列。
+    */
+    'timeout' => (int) env('FEISHU_BOT_TIMEOUT', 5),
+
+    /*
+    | 发送任务所在队列。
+    */
+    'queue' => env('FEISHU_BOT_QUEUE', 'notifications'),
+
+    /*
+    | 同一订单的同一类告警在该时间窗内只发一次,避免对账反复扫描时刷屏。
+    | 设为 0 关闭去重。
+    */
+    'throttle_minutes' => (int) env('FEISHU_BOT_THROTTLE_MINUTES', 60),
+
+    /*
+    | 卡片里「查看订单」按钮的地址模板,{id} 会被订单 id 替换。
+    | 留空时回退到 admin.sales.orders.view 路由。
+    */
+    'admin_order_url' => env('FEISHU_BOT_ADMIN_ORDER_URL', ''),
+
+    /*
+    | 环境标识,会显示在卡片脚注,避免测试环境的告警被误当成线上。
+    */
+    'environment' => env('FEISHU_BOT_ENVIRONMENT', env('APP_ENV', 'production')),
+
+    /*
+    |--------------------------------------------------------------------------
+    | 告警映射
+    |--------------------------------------------------------------------------
+    |
+    | key 为事件名,value 描述这条告警怎么呈现:
+    |
+    |   category — 归类,manual = 需人工处理的异常单,cancelled = 超时取消单
+    |   title    — 卡片标题
+    |   color    — 卡片头部颜色(red / orange / carmine / blue / green / grey)
+    |
+    | 想停掉某类告警,注释掉对应行即可,无需改代码。
+    */
+    'alerts' => [
+        /* 需人工处理的异常单 */
+        'bagistoapi.payment.reconcile.approved-unsettled' => [
+            'category' => PaymentAlertSubscriber::CATEGORY_MANUAL,
+            'title'    => '支付已授权但未入账,需人工确认',
+            'color'    => 'red',
+        ],
+        'bagistoapi.payment.reconcile.settle-failed' => [
+            'category' => PaymentAlertSubscriber::CATEGORY_MANUAL,
+            'title'    => '对账发现扣款但落账失败',
+            'color'    => 'red',
+        ],
+        'bagistoapi.payment.amount-mismatch' => [
+            'category' => PaymentAlertSubscriber::CATEGORY_MANUAL,
+            'title'    => '支付金额与订单金额不符',
+            'color'    => 'red',
+        ],
+        'bagistoapi.payment.capture-not-completed' => [
+            'category' => PaymentAlertSubscriber::CATEGORY_MANUAL,
+            'title'    => '网关扣款未完成',
+            'color'    => 'orange',
+        ],
+        'bagistoapi.payment.reconcile.no-gateway-info' => [
+            'category' => PaymentAlertSubscriber::CATEGORY_MANUAL,
+            'title'    => '订单缺少网关信息,无法对账',
+            'color'    => 'orange',
+        ],
+
+        /* 超时取消单 */
+        'bagistoapi.payment.reconcile.cancelled' => [
+            'category' => PaymentAlertSubscriber::CATEGORY_CANCELLED,
+            'title'    => '支付超时,订单已自动取消',
+            'color'    => 'grey',
+        ],
+    ],
+];

+ 44 - 0
packages/Longyi/Feishu/src/Jobs/SendFeishuMessageJob.php

@@ -0,0 +1,44 @@
+<?php
+
+namespace Longyi\Feishu\Jobs;
+
+use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Log;
+use Longyi\Feishu\Services\FeishuBot;
+
+/**
+ * 异步投递飞书消息。
+ *
+ * 告警只是旁路,飞书超时或挂掉都不能反过来影响支付、对账这些主流程,
+ * 所以发送一律丢到队列,并且失败只记日志。
+ */
+class SendFeishuMessageJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public int $tries = 3;
+
+    /**
+     * 飞书偶发 5xx 时给它一点恢复时间,同时避免把队列堵住。
+     */
+    public array $backoff = [10, 30, 60];
+
+    public function __construct(
+        public array $message,
+        public array $context = [],
+    ) {}
+
+    public function handle(FeishuBot $bot): void
+    {
+        $bot->send($this->message);
+    }
+
+    public function failed(\Throwable $e): void
+    {
+        Log::error('Feishu alert dropped after retries: '.$e->getMessage(), $this->context);
+    }
+}

+ 193 - 0
packages/Longyi/Feishu/src/Listeners/PaymentAlertSubscriber.php

@@ -0,0 +1,193 @@
+<?php
+
+namespace Longyi\Feishu\Listeners;
+
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Log;
+use Longyi\Feishu\Jobs\SendFeishuMessageJob;
+use Longyi\Feishu\Messages\AlertCard;
+
+/**
+ * 把支付对账过程中需要人关注的事件推到飞书。
+ *
+ * 通过通配符监听 bagistoapi.payment.*,再按 config('feishu.alerts') 过滤,
+ * 这样新增/停用一类告警只需要改配置。用通配符还有个好处:这些事件的 payload
+ * 有的是裸 Order、有的是关联数组,通配符监听器统一拿到原始数组,
+ * 不会被 Laravel 的参数展开规则坑到。
+ */
+class PaymentAlertSubscriber
+{
+    /** 需人工处理的异常单 */
+    public const CATEGORY_MANUAL = 'manual';
+
+    /** 支付超时被自动取消的订单 */
+    public const CATEGORY_CANCELLED = 'cancelled';
+
+    /**
+     * @param  array  $payload  裸 Order 时为 [0 => $order],否则为事件自带的关联数组
+     */
+    public function handle(string $eventName, array $payload): void
+    {
+        try {
+            /*
+             * 取整张表再按事件名索引:事件名本身带点,直接 config('feishu.alerts.'.$name)
+             * 会被当成多层嵌套键去解析,永远取不到。
+             */
+            $definition = config('feishu.alerts', [])[$eventName] ?? null;
+
+            if (! $definition || ! config('feishu.enabled', false)) {
+                return;
+            }
+
+            $order = $this->resolveOrder($payload);
+
+            if (! $order) {
+                return;
+            }
+
+            if (! $this->shouldSend($eventName, $order->id)) {
+                return;
+            }
+
+            SendFeishuMessageJob::dispatch(
+                $this->buildCard($eventName, $definition, $order, $payload)->toMessage(),
+                ['event' => $eventName, 'order_id' => $order->id],
+            )->onQueue((string) config('feishu.queue', 'notifications'));
+        } catch (\Throwable $e) {
+            /*
+             * 告警链路自身出问题,绝不能把异常抛回支付/对账流程。
+             */
+            Log::warning('Feishu payment alert skipped: '.$e->getMessage(), [
+                'event' => $eventName,
+            ]);
+        }
+    }
+
+    /**
+     * 事件 payload 形态不一:裸 Order 会被 Arr::wrap 成 [0 => $order],
+     * 其余事件用 'order' 键携带。
+     */
+    protected function resolveOrder(array $payload): mixed
+    {
+        $order = $payload['order'] ?? $payload[0] ?? null;
+
+        return is_object($order) && isset($order->id) ? $order : null;
+    }
+
+    /**
+     * 同一订单的同一类告警在窗口内只发一次。
+     *
+     * 对账兜底扫描可能在窗口边界上把同一个订单重复捞起来,没有这层去重
+     * 值班群会被同一条告警刷屏。
+     */
+    protected function shouldSend(string $eventName, int|string $orderId): bool
+    {
+        $minutes = (int) config('feishu.throttle_minutes', 60);
+
+        if ($minutes <= 0) {
+            return true;
+        }
+
+        return Cache::add("feishu:alert:{$eventName}:{$orderId}", true, now()->addMinutes($minutes));
+    }
+
+    protected function buildCard(string $eventName, array $definition, mixed $order, array $payload): AlertCard
+    {
+        $category = $definition['category'] ?? self::CATEGORY_MANUAL;
+
+        $card = AlertCard::make(
+            $definition['title'] ?? $eventName,
+            $definition['color'] ?? 'red',
+        )
+            ->description($this->describe($category, $payload))
+            ->field('订单号', $order->increment_id ?? $order->id)
+            ->field('金额', $this->formatAmount($order))
+            ->field('订单状态', $order->status ?? null)
+            ->field('支付方式', $order->payment->method ?? null)
+            ->field('网关单号', $this->gatewayOrderId($order))
+            ->field('客户', $order->customer_email ?? null)
+            ->field('下单时间', $this->formatDate($order->created_at ?? null))
+            ->button('查看订单', $this->adminUrl($order->id))
+            ->footer(sprintf(
+                '%s · %s · %s',
+                config('feishu.environment', config('app.env')),
+                $eventName,
+                now()->toDateTimeString(),
+            ));
+
+        if (! empty($payload['gateway_status'])) {
+            $card->field('网关状态', $payload['gateway_status']);
+        }
+
+        if (isset($payload['expected'], $payload['captured'])) {
+            $card->field('应收 / 实收', $payload['expected'].' / '.$payload['captured']);
+        }
+
+        if (! empty($payload['error'])) {
+            $card->field('错误信息', $payload['error']);
+        }
+
+        return $card;
+    }
+
+    /**
+     * 两类告警要传达的行动不同:异常单要人去查,取消单只是知会。
+     */
+    protected function describe(string $category, array $payload): string
+    {
+        if ($category === self::CATEGORY_CANCELLED) {
+            return '该订单在支付窗口内未完成付款,系统已自动取消并释放库存。若客户反馈已扣款,请走退款或人工补单。';
+        }
+
+        return '**该订单需要人工介入。** 请核对网关后台的实际扣款情况,再决定补单或退款。';
+    }
+
+    protected function gatewayOrderId(mixed $order): ?string
+    {
+        $additional = $order->payment->additional ?? [];
+
+        if (! is_array($additional)) {
+            return null;
+        }
+
+        return $additional['gateway_order_id'] ?? $additional['paypal_order_id'] ?? null;
+    }
+
+    protected function formatAmount(mixed $order): ?string
+    {
+        if (! isset($order->grand_total)) {
+            return null;
+        }
+
+        return trim(($order->order_currency_code ?? '').' '.number_format((float) $order->grand_total, 2));
+    }
+
+    protected function formatDate(mixed $date): ?string
+    {
+        if (! $date) {
+            return null;
+        }
+
+        return $date instanceof \DateTimeInterface
+            ? $date->format('Y-m-d H:i:s')
+            : (string) $date;
+    }
+
+    /**
+     * 优先用配置的模板,方便后台域名和站点域名不同的部署。
+     */
+    protected function adminUrl(int|string $orderId): ?string
+    {
+        $template = (string) config('feishu.admin_order_url', '');
+
+        if ($template !== '') {
+            return str_replace('{id}', (string) $orderId, $template);
+        }
+
+        try {
+            return route('admin.sales.orders.view', $orderId);
+        } catch (\Throwable) {
+            return null;
+        }
+    }
+}

+ 132 - 0
packages/Longyi/Feishu/src/Messages/AlertCard.php

@@ -0,0 +1,132 @@
+<?php
+
+namespace Longyi\Feishu\Messages;
+
+/**
+ * 构造飞书交互卡片。
+ *
+ * 卡片比纯文本贵不了多少,但值班的人一眼就能看到订单号、金额和原因,
+ * 并且能直接点进后台,所以告警统一走卡片。
+ */
+class AlertCard
+{
+    protected array $fields = [];
+
+    protected ?string $description = null;
+
+    protected ?string $buttonUrl = null;
+
+    protected ?string $buttonText = null;
+
+    protected ?string $footer = null;
+
+    public function __construct(
+        protected string $title,
+        protected string $color = 'red',
+    ) {}
+
+    public static function make(string $title, string $color = 'red'): static
+    {
+        return new static($title, $color);
+    }
+
+    /**
+     * 正文首段,一般写这条告警发生了什么、需要人做什么。
+     */
+    public function description(?string $description): static
+    {
+        $this->description = $description;
+
+        return $this;
+    }
+
+    /**
+     * 追加一个「字段名: 值」。值为空时自动跳过,避免卡片出现空行。
+     */
+    public function field(string $label, mixed $value): static
+    {
+        if ($value === null || $value === '') {
+            return $this;
+        }
+
+        $this->fields[] = ['label' => $label, 'value' => (string) $value];
+
+        return $this;
+    }
+
+    public function button(?string $text, ?string $url): static
+    {
+        if ($text && $url) {
+            $this->buttonText = $text;
+            $this->buttonUrl = $url;
+        }
+
+        return $this;
+    }
+
+    public function footer(?string $footer): static
+    {
+        $this->footer = $footer;
+
+        return $this;
+    }
+
+    /**
+     * 输出可直接 POST 给 webhook 的消息体。
+     */
+    public function toMessage(): array
+    {
+        $elements = [];
+
+        if ($this->description) {
+            $elements[] = [
+                'tag'  => 'div',
+                'text' => ['tag' => 'lark_md', 'content' => $this->description],
+            ];
+        }
+
+        if ($this->fields) {
+            $elements[] = [
+                'tag'    => 'div',
+                'fields' => array_map(fn (array $field) => [
+                    'is_short' => true,
+                    'text'     => [
+                        'tag'     => 'lark_md',
+                        'content' => "**{$field['label']}**\n{$field['value']}",
+                    ],
+                ], $this->fields),
+            ];
+        }
+
+        if ($this->buttonUrl) {
+            $elements[] = [
+                'tag'     => 'action',
+                'actions' => [[
+                    'tag'  => 'button',
+                    'text' => ['tag' => 'plain_text', 'content' => $this->buttonText],
+                    'url'  => $this->buttonUrl,
+                    'type' => 'primary',
+                ]],
+            ];
+        }
+
+        if ($this->footer) {
+            $elements[] = [
+                'tag'      => 'note',
+                'elements' => [['tag' => 'plain_text', 'content' => $this->footer]],
+            ];
+        }
+
+        return [
+            'msg_type' => 'interactive',
+            'card'     => [
+                'config' => ['wide_screen_mode' => true],
+                'header' => [
+                    'template' => $this->color,
+                    'title'    => ['tag' => 'plain_text', 'content' => $this->title],
+                ],
+                'elements' => $elements,
+            ],
+        ];
+    }
+}

+ 19 - 0
packages/Longyi/Feishu/src/Providers/EventServiceProvider.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace Longyi\Feishu\Providers;
+
+use Illuminate\Support\Facades\Event;
+use Illuminate\Support\ServiceProvider;
+use Longyi\Feishu\Listeners\PaymentAlertSubscriber;
+
+class EventServiceProvider extends ServiceProvider
+{
+    public function boot(): void
+    {
+        /*
+         * 通配符监听拿到的是 (事件名, 原始 payload 数组),由订阅者自己
+         * 按 config('feishu.alerts') 决定是否要发,未配置的事件直接忽略。
+         */
+        Event::listen('bagistoapi.payment.*', [PaymentAlertSubscriber::class, 'handle']);
+    }
+}

+ 32 - 0
packages/Longyi/Feishu/src/Providers/FeishuServiceProvider.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace Longyi\Feishu\Providers;
+
+use Illuminate\Support\ServiceProvider;
+use Longyi\Feishu\Console\Commands\FeishuTestCommand;
+use Longyi\Feishu\Services\FeishuBot;
+
+class FeishuServiceProvider extends ServiceProvider
+{
+    public function register(): void
+    {
+        $this->mergeConfigFrom(__DIR__.'/../Config/feishu.php', 'feishu');
+
+        $this->app->singleton(FeishuBot::class, fn () => new FeishuBot());
+    }
+
+    public function boot(): void
+    {
+        $this->publishes([
+            __DIR__.'/../Config/feishu.php' => config_path('feishu.php'),
+        ], 'feishu-config');
+
+        $this->app->register(EventServiceProvider::class);
+
+        if ($this->app->runningInConsole()) {
+            $this->commands([
+                FeishuTestCommand::class,
+            ]);
+        }
+    }
+}

+ 99 - 0
packages/Longyi/Feishu/src/Services/FeishuBot.php

@@ -0,0 +1,99 @@
+<?php
+
+namespace Longyi\Feishu\Services;
+
+use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Log;
+
+/**
+ * 飞书群自定义机器人客户端。
+ *
+ * 只负责把一条已经构造好的消息投递到 webhook,不关心业务语义。
+ */
+class FeishuBot
+{
+    public function __construct(
+        protected ?string $webhookUrl = null,
+        protected ?string $secret = null,
+        protected ?int $timeout = null,
+    ) {
+        $this->webhookUrl ??= (string) config('feishu.webhook_url', '');
+        $this->secret ??= (string) config('feishu.secret', '');
+        $this->timeout ??= (int) config('feishu.timeout', 5);
+    }
+
+    /**
+     * 开关打开且配置了 webhook 才算可用。
+     */
+    public function isEnabled(): bool
+    {
+        return (bool) config('feishu.enabled', false) && $this->webhookUrl !== '';
+    }
+
+    /**
+     * 投递一条飞书消息。
+     *
+     * @param  array  $message  形如 ['msg_type' => 'interactive', 'card' => [...]]
+     *
+     * @throws \RuntimeException 飞书返回业务错误时抛出,交给调用方重试
+     */
+    public function send(array $message): bool
+    {
+        if (! $this->isEnabled()) {
+            return false;
+        }
+
+        if ($this->secret !== '') {
+            $timestamp = time();
+
+            $message['timestamp'] = (string) $timestamp;
+            $message['sign'] = $this->sign($timestamp);
+        }
+
+        $response = Http::timeout($this->timeout)
+            ->asJson()
+            ->post($this->webhookUrl, $message);
+
+        /*
+         * 飞书对业务错误同样返回 HTTP 200,必须看响应体里的 code,
+         * 否则加签失败、机器人被停用这类问题会被当成发送成功。
+         */
+        $body = $response->json() ?? [];
+        $code = $body['code'] ?? $body['StatusCode'] ?? null;
+
+        if ($response->successful() && (int) $code === 0) {
+            return true;
+        }
+
+        throw new \RuntimeException(sprintf(
+            'Feishu webhook rejected the message (http %d, code %s): %s',
+            $response->status(),
+            var_export($code, true),
+            $body['msg'] ?? $body['StatusMessage'] ?? 'unknown error',
+        ));
+    }
+
+    /**
+     * 发送但不抛异常,用于绝对不能影响调用方的场景。
+     */
+    public function sendQuietly(array $message): bool
+    {
+        try {
+            return $this->send($message);
+        } catch (\Throwable $e) {
+            Log::warning('Feishu notification failed: '.$e->getMessage());
+
+            return false;
+        }
+    }
+
+    /**
+     * 加签:对 "{timestamp}\n{secret}" 做 HMAC-SHA256(空消息体),再 Base64。
+     */
+    public function sign(int $timestamp): string
+    {
+        return base64_encode(
+            hash_hmac('sha256', '', $timestamp."\n".$this->secret, true)
+        );
+    }
+}

+ 78 - 39
packages/Longyi/Pay/Afterpay/src/Payment/Afterpay.php

@@ -298,55 +298,94 @@ class Afterpay extends Payment
         }
     }
 
+    /**
+     * 查询网关订单状态,供 ReconcilePendingPaymentJob 对账使用。
+     *
+     * 返回统一契约 ['status' => string|null, 'capture' => array|null],
+     * 其中 capture 与 PaymentService::extractCapture() 同结构,未扣款时为 null。
+     *
+     * 这里只做查询,不允许产生扣款副作用;查询失败直接抛出,由调用方重试。
+     */
     public function fetchOrderStatus($gatewayOrderId = null)
     {
         $order = $this->getOrder();
-        if ($order) {
-            $gatewayOrderId = $order->payment->additional['gateway_order_id'];
+
+        if (empty($gatewayOrderId)) {
+            $gatewayOrderId = $order->payment->additional['gateway_order_id'] ?? null;
         }
+
         if (empty($gatewayOrderId)) {
-            return new \Exception('gatewayOrderId is empty');
+            throw new \Exception('gatewayOrderId is empty');
         }
+
         $countryCode = $order->billing_address->country ?? null;
         $this->resolveCredentials($countryCode);
-        $paymentStatus = [];
-        $paymentStatus['captures'] = null;
-        $paymentStatus['status'] = null;
-        try {
-            $client = new Client();
-            $response = $client->request('GET', $this->orderStatusUlr . $gatewayOrderId, [
-                'headers' => [
-                    'Accept' => 'application/json',
-                    'Content-Type' => 'application/json',
-                    'Authorization' => 'Basic ' . base64_encode("{$this->clientId}:{$this->apikey}")
+
+        $response = $this->httpClient()->request('GET', $this->orderStatusUlr . $gatewayOrderId, [
+            '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('afterpay fetchOrderStatus response', [
+            'gatewayOrderId' => $gatewayOrderId,
+            'status' => $response->getStatusCode(),
+            'body' => $responseBody
+        ]);
+
+        $result = json_decode($responseBody, true) ?: [];
+        $gatewayStatus = strtoupper((string) ($result['status'] ?? ''));
+        $paymentState = strtoupper((string) ($result['paymentState'] ?? ''));
+
+        if ($gatewayStatus === 'APPROVED' && $paymentState === 'CAPTURED') {
+            $amount = data_get($result, 'events.0.amount.amount', data_get($result, 'originalAmount.amount'));
+            $currency = data_get($result, 'events.0.amount.currency', data_get($result, 'originalAmount.currency'));
+
+            return [
+                'status'  => 'COMPLETED',
+                'capture' => [
+                    'transaction_id'   => $result['token'] ?? $gatewayOrderId,
+                    'gateway_order_id' => $gatewayOrderId,
+                    'order_status'     => 'COMPLETED',
+                    'capture_status'   => 'COMPLETED',
+                    'intent'           => 'CAPTURE',
+                    'amount'           => $amount !== null ? (float) $amount : null,
+                    'currency'         => $currency,
+                    'raw'              => $result,
                 ],
-                'timeout' => 30,
-                'verify'  => false,
-            ]);
-            $responseBody = $response->getBody()->getContents();
-            Log::channel('payment')->info("{$order->payment->method} fetchOrderStatus response", [
-                'gatewayOrderId' => $gatewayOrderId,
-                'status' => $response->getStatusCode(),
-                'body' => $responseBody
-            ]);
-            $resultObject = json_decode($responseBody);
-            if ($resultObject->status == 'APPROVED' && $resultObject->paymentState == 'CAPTURED') {
-                $paymentStatus['captures'] = true;
-                $paymentStatus['status'] = 'COMPLETED';
-            }
-        } catch (\Exception $e) {
-            Log::channel('payment')->error("{$order->payment->method} fetchOrderStatus error: " . $e->getMessage());
-            try {
-                // 如果获取失败,尝试进行一次确认
-                $captureArr = $this->capture($gatewayOrderId);
-                $paymentStatus['captures'] = $captureArr['transaction_id'] ? true : null;
-                $paymentStatus['status'] = $captureArr['transaction_id'] ? 'COMPLETED' : null;
-            } catch (\Exception $e) {
-                $paymentStatus['captures'] = null;
-                $paymentStatus['status'] = null;
-            }
+            ];
         }
-        return $paymentStatus;
+
+        return [
+            'status'  => $this->normalizeOrderStatus($gatewayStatus, $paymentState),
+            'capture' => null,
+        ];
+    }
+
+    /**
+     * 供测试替换的 HTTP 客户端出口。
+     */
+    protected function httpClient(): Client
+    {
+        return new Client();
+    }
+
+    /**
+     * 把 Afterpay 的 status/paymentState 映射成对账任务认得的终态词。
+     */
+    protected function normalizeOrderStatus(string $gatewayStatus, string $paymentState): ?string
+    {
+        return match ($gatewayStatus) {
+            ''         => null,
+            'DECLINED' => 'VOIDED',
+            'EXPIRED'  => 'EXPIRED',
+            'APPROVED' => $paymentState === 'AUTH_APPROVED' ? 'APPROVED' : $gatewayStatus,
+            default    => $gatewayStatus,
+        };
     }
 
     public function isAvailable($isShow = false)

+ 29 - 11
packages/Webkul/BagistoApi/config/bagistoapi.php

@@ -79,22 +79,40 @@ return [
     |--------------------------------------------------------------------------
     |
     | reconcile.enabled
-    |   Toggle the delayed reconciliation job that polls the gateway after
-    |   `delay_minutes` if the order is still PENDING.
+    |   Toggle the reconciliation job that polls the gateway while the order
+    |   is still PENDING.
     |
-    | reconcile.delay_minutes
-    |   How long to wait before a pending order is reconciled against the
-    |   gateway.
+    | The job re-schedules itself. All values below are in SECONDS and are
+    | measured from the anchor time (order creation, or the most recent
+    | `redirected` payment attempt when the buyer used paymentReplay):
+    |
+    | reconcile.early_marks
+    |   Fixed early probes, e.g. [30, 60, 300] = 30s, 1min, 5min.
+    |
+    | reconcile.interval
+    |   Polling interval once the early probes are done (default 3 min).
+    |
+    | reconcile.late_after / reconcile.late_interval
+    |   After `late_after` seconds the interval widens to `late_interval`
+    |   (default: after 30 min, poll every 5 min).
+    |
+    | reconcile.max_seconds
+    |   Hard stop. A PayPal order expires after roughly 3 hours, so polling
+    |   past that point cannot tell us anything new.
     |
     | reconcile.queue
-    |   Name of the queue the reconciliation job is dispatched onto. Operators
-    |   can route this to a dead-letter capable broker (e.g. RabbitMQ) via the
-    |   Laravel queue config without touching code.
+    |   Name of the queue the reconciliation job is dispatched onto. This must
+    |   be consumed by a worker, and the Redis instance backing it must not
+    |   evict keys (see the package README).
     */
     'reconcile' => [
-        'enabled'        => env('BAGISTOAPI_PAYMENT_RECONCILE_ENABLED', true),
-        'delay_minutes'  => (int) env('BAGISTOAPI_PAYMENT_RECONCILE_DELAY', 15),
-        'queue'          => env('BAGISTOAPI_PAYMENT_RECONCILE_QUEUE', 'payment-reconcile'),
+        'enabled'       => env('BAGISTOAPI_PAYMENT_RECONCILE_ENABLED', true),
+        'early_marks'   => [30, 60, 300],
+        'interval'      => (int) env('BAGISTOAPI_PAYMENT_RECONCILE_INTERVAL', 180),
+        'late_after'    => (int) env('BAGISTOAPI_PAYMENT_RECONCILE_LATE_AFTER', 1800),
+        'late_interval' => (int) env('BAGISTOAPI_PAYMENT_RECONCILE_LATE_INTERVAL', 300),
+        'max_seconds'   => (int) env('BAGISTOAPI_PAYMENT_RECONCILE_MAX_SECONDS', 10800),
+        'queue'         => env('BAGISTOAPI_PAYMENT_RECONCILE_QUEUE', 'payment-reconcile'),
     ],
 
 ];

+ 88 - 0
packages/Webkul/BagistoApi/src/Console/Commands/ReconcileSweepCommand.php

@@ -0,0 +1,88 @@
+<?php
+
+namespace Webkul\BagistoApi\Console\Commands;
+
+use Illuminate\Console\Command;
+use Webkul\BagistoApi\Jobs\ReconcilePendingPaymentJob;
+use Webkul\BagistoApi\Models\PaymentAttempt;
+use Webkul\Sales\Models\Order;
+use Webkul\Sales\Models\OrderProxy;
+
+/**
+ * Safety net for orders that fell out of the reconciliation queue.
+ *
+ * ReconcilePendingPaymentJob normally keeps itself alive by re-dispatching,
+ * but that chain can break: the initial enqueue failed, no worker consumed
+ * the queue for a while, or Redis dropped the delayed key. Without a sweep
+ * such an order would sit in PENDING forever.
+ *
+ * Re-dispatching is free of side effects - ShouldBeUniqueUntilProcessing
+ * drops the duplicate whenever a tick is already queued.
+ */
+class ReconcileSweepCommand extends Command
+{
+    protected $signature = 'bagistoapi:payment:reconcile-sweep
+                            {--dry-run : Report what would be queued without dispatching}';
+
+    protected $description = 'Re-queue reconciliation for pending orders whose polling chain was lost';
+
+    public function handle(): int
+    {
+        if (! config('bagistoapi.reconcile.enabled', true)) {
+            $this->info('Payment reconciliation is disabled; nothing to sweep.');
+
+            return self::SUCCESS;
+        }
+
+        $cutoff = now()->subSeconds((int) config('bagistoapi.reconcile.max_seconds', 10800));
+        $queue = (string) config('bagistoapi.reconcile.queue', 'payment-reconcile');
+        $dryRun = (bool) $this->option('dry-run');
+
+        /*
+         * A replayed order can be older than the cutoff and still be inside
+         * its polling window, because the window is measured from the last
+         * redirect rather than from order creation.
+         */
+        $replayedOrderIds = PaymentAttempt::query()
+            ->where('status', PaymentAttempt::STATUS_REDIRECTED)
+            ->where('created_at', '>=', $cutoff)
+            ->distinct()
+            ->pluck('order_id')
+            ->filter()
+            ->all();
+
+        $orderModelClass = OrderProxy::modelClass();
+
+        $queued = 0;
+
+        $orderModelClass::query()
+            ->whereIn('status', [Order::STATUS_PENDING, Order::STATUS_PENDING_PAYMENT])
+            ->where(function ($query) use ($cutoff, $replayedOrderIds) {
+                $query->where('created_at', '>=', $cutoff);
+
+                if ($replayedOrderIds) {
+                    $query->orWhereIn('id', $replayedOrderIds);
+                }
+            })
+            ->orderBy('id')
+            ->chunkById(200, function ($orders) use (&$queued, $queue, $dryRun) {
+                foreach ($orders as $order) {
+                    $queued++;
+
+                    if ($dryRun) {
+                        $this->line("Would queue reconciliation for order {$order->id}");
+
+                        continue;
+                    }
+
+                    dispatch((new ReconcilePendingPaymentJob($order->id))->onQueue($queue));
+                }
+            });
+
+        $this->info($dryRun
+            ? "Sweep found {$queued} pending order(s)."
+            : "Sweep queued reconciliation for {$queued} pending order(s).");
+
+        return self::SUCCESS;
+    }
+}

+ 273 - 79
packages/Webkul/BagistoApi/src/Jobs/ReconcilePendingPaymentJob.php

@@ -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);
     }
 }

+ 4 - 1
packages/Webkul/BagistoApi/src/Models/PaymentAttempt.php

@@ -8,7 +8,8 @@ use Webkul\BagistoApi\Contracts\PaymentAttempt as PaymentAttemptContract;
 use Webkul\Sales\Models\OrderProxy;
 
 /**
- * Audit trail for the full payment lifecycle (initiate / replay / callback).
+ * Audit trail for the full payment lifecycle (initiate / replay / callback /
+ * webhook / reconcile).
  *
  * This is intentionally separate from `order_transactions`, which only
  * records the final settled (invoiced) transaction. A payment attempt is
@@ -29,6 +30,8 @@ class PaymentAttempt extends Model implements PaymentAttemptContract
 
     public const ACTION_WEBHOOK = 'webhook';
 
+    public const ACTION_RECONCILE = 'reconcile';
+
     /**
      * Status of the attempt.
      */

+ 15 - 0
packages/Webkul/BagistoApi/src/Providers/BagistoApiServiceProvider.php

@@ -13,6 +13,7 @@ use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface;
 use ApiPlatform\State\Pagination\Pagination;
 use ApiPlatform\State\ProcessorInterface;
 use ApiPlatform\State\ProviderInterface;
+use Illuminate\Console\Scheduling\Schedule;
 use Illuminate\Support\ServiceProvider;
 use Webkul\BagistoApi\Console\Commands\GenerateStorefrontKey;
 use Webkul\BagistoApi\Facades\CartTokenFacade;
@@ -807,6 +808,7 @@ class BagistoApiServiceProvider extends ServiceProvider
 
         if ($this->app->runningInConsole()) {
             $this->registerCommands();
+            $this->registerScheduledTasks();
         }
     }
 
@@ -956,8 +958,21 @@ class BagistoApiServiceProvider extends ServiceProvider
             GenerateStorefrontKey::class,
             \Webkul\BagistoApi\Console\Commands\ApiKeyManagementCommand::class,
             \Webkul\BagistoApi\Console\Commands\ApiKeyMaintenanceCommand::class,
+            \Webkul\BagistoApi\Console\Commands\ReconcileSweepCommand::class,
         ]);
     }
+
+    /**
+     * Register scheduled tasks.
+     */
+    protected function registerScheduledTasks(): void
+    {
+        $this->callAfterResolving(Schedule::class, function (Schedule $schedule) {
+            $schedule->command(\Webkul\BagistoApi\Console\Commands\ReconcileSweepCommand::class)
+                ->everyFiveMinutes()
+                ->withoutOverlapping();
+        });
+    }
     /**
      * Override API Platform's ItemProvider and CollectionProvider to wrap the
      * LinksHandler with SnakeCaseLinksHandler, fixing the camelCase/snake_case

+ 18 - 0
packages/Webkul/BagistoApi/src/Repositories/PaymentAttemptRepository.php

@@ -2,6 +2,7 @@
 
 namespace Webkul\BagistoApi\Repositories;
 
+use Illuminate\Support\Carbon;
 use Webkul\BagistoApi\Models\PaymentAttempt;
 use Webkul\Core\Eloquent\Repository;
 
@@ -25,4 +26,21 @@ class PaymentAttemptRepository extends Repository
             ->latest('id')
             ->first();
     }
+
+    /**
+     * When the buyer was last sent off to the gateway for this order.
+     *
+     * Reconciliation measures its polling window from here rather than from
+     * the order creation time, so a paymentReplay naturally restarts the
+     * window instead of landing straight past the deadline.
+     */
+    public function latestRedirectedAt(int $orderId): ?Carbon
+    {
+        $timestamp = $this->model
+            ->where('order_id', $orderId)
+            ->where('status', PaymentAttempt::STATUS_REDIRECTED)
+            ->max('created_at');
+
+        return $timestamp ? Carbon::parse($timestamp) : null;
+    }
 }

+ 113 - 3
packages/Webkul/BagistoApi/src/Services/PaymentService.php

@@ -567,19 +567,24 @@ class PaymentService
         $order->payment->save();
     }
 
+    /**
+     * Fire the first reconciliation tick. The job re-schedules itself from
+     * there, so only the opening delay is decided here.
+     */
     protected function scheduleReconciliation($order): void
     {
         if (! config('bagistoapi.reconcile.enabled', true)) {
             return;
         }
 
-        $delay = (int) config('bagistoapi.reconcile.delay_minutes', 15);
+        $marks = (array) config('bagistoapi.reconcile.early_marks', [30, 60, 300]);
+        $delay = (int) ($marks[0] ?? 30);
         $queue = (string) config('bagistoapi.reconcile.queue', 'payment-reconcile');
 
         try {
             $job = (new ReconcilePendingPaymentJob($order->id))
                 ->onQueue($queue)
-                ->delay(now()->addMinutes($delay));
+                ->delay(now()->addSeconds($delay));
 
             dispatch($job);
         } catch (\Throwable $e) {
@@ -699,6 +704,108 @@ class PaymentService
         return $result['response'];
     }
 
+    /**
+     * Settle a capture discovered by ReconcilePendingPaymentJob.
+     *
+     * Same contract as completeWebhookCapture(): the money has already
+     * moved on the gateway side, so this must never re-capture. Unlike
+     * the webhook path this is gateway agnostic - the caller supplies a
+     * capture normalized by the gateway handler.
+     */
+    public function settleReconciledCapture($order, string $gatewayOrderId, array $capture): 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, $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'),
+                    ];
+                }
+
+                $storedGatewayOrderId = $this->gatewayOrderIdFromOrder($order);
+
+                if (
+                    ! $storedGatewayOrderId
+                    || ! hash_equals((string) $storedGatewayOrderId, $gatewayOrderId)
+                ) {
+                    throw new OperationFailedException('The gateway order id does not match the local order.');
+                }
+
+                $captureStatus = strtoupper((string) ($capture['capture_status'] ?? $capture['order_status'] ?? ''));
+
+                if (
+                    empty($capture['transaction_id'])
+                    || ! in_array($captureStatus, ['COMPLETED', 'CAPTURED'], true)
+                    || ! array_key_exists('amount', $capture)
+                    || $capture['amount'] === null
+                    || empty($capture['currency'])
+                ) {
+                    throw new OperationFailedException('The reconciled capture is incomplete or not completed.');
+                }
+
+                $this->assertAmountMatches($order, $capture);
+
+                if ($isExpress && $this->isPlaceholderAddress($order->shipping_address)) {
+                    Log::warning('Reconciliation 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_RECONCILE,
+                'status'           => PaymentAttempt::STATUS_FAILED,
+                'amount'           => (float) ($order->grand_total ?? 0),
+                'currency'         => $order->order_currency_code ?? $order->cart_currency_code,
+                'express'          => $isExpress,
+                'response_payload' => ['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_RECONCILE,
+                'status'           => PaymentAttempt::STATUS_CAPTURED,
+                'amount'           => (float) ($order->grand_total ?? 0),
+                'currency'         => $order->order_currency_code ?? $order->cart_currency_code,
+                'express'          => $isExpress,
+                'idempotency_key'  => 'reconcile:'.$gatewayOrderId,
+                '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
@@ -902,8 +1009,11 @@ class PaymentService
 
     /**
      * Normalize a PayPal capture response into a flat array.
+     *
+     * Public because ReconcilePendingPaymentJob reuses it to normalize
+     * the `getOrder()` lookup it performs on the Smart Button path.
      */
-    protected function extractCapture($response): array
+    public function extractCapture($response): array
     {
         $data = json_decode(json_encode($response), true) ?: [];
         $result = $data['result'] ?? [];