浏览代码

Merge branch 'variant' of http://gogs.hnwmzp.cn/chengwenliang/nshop into dev

chengwl 1 天之前
父节点
当前提交
0df4b58e0a
共有 35 个文件被更改,包括 3134 次插入203 次删除
  1. 93 1
      packages/Webkul/BagistoApi/README.md
  2. 75 0
      packages/Webkul/BagistoApi/config/bagistoapi.php
  3. 5 0
      packages/Webkul/BagistoApi/src/Contracts/PaymentAttempt.php
  4. 35 0
      packages/Webkul/BagistoApi/src/Database/Migrations/2026_06_02_000000_create_payment_attempts_table.php
  5. 62 0
      packages/Webkul/BagistoApi/src/Dto/PaymentCallbackInput.php
  6. 77 0
      packages/Webkul/BagistoApi/src/Dto/PaymentInitiateInput.php
  7. 37 0
      packages/Webkul/BagistoApi/src/Dto/PaymentReplayInput.php
  8. 2 0
      packages/Webkul/BagistoApi/src/Facades/CartTokenFacade.php
  9. 137 0
      packages/Webkul/BagistoApi/src/Jobs/ReconcilePendingPaymentJob.php
  10. 2 1
      packages/Webkul/BagistoApi/src/Models/Country.php
  11. 3 5
      packages/Webkul/BagistoApi/src/Models/CountryState.php
  12. 82 0
      packages/Webkul/BagistoApi/src/Models/PaymentAttempt.php
  13. 7 0
      packages/Webkul/BagistoApi/src/Models/PaymentAttemptProxy.php
  14. 72 0
      packages/Webkul/BagistoApi/src/Models/PaymentCallback.php
  15. 74 0
      packages/Webkul/BagistoApi/src/Models/PaymentInitiate.php
  16. 64 0
      packages/Webkul/BagistoApi/src/Models/PaymentReplay.php
  17. 43 2
      packages/Webkul/BagistoApi/src/Providers/BagistoApiServiceProvider.php
  18. 1 0
      packages/Webkul/BagistoApi/src/Providers/ModuleServiceProvider.php
  19. 28 0
      packages/Webkul/BagistoApi/src/Repositories/PaymentAttemptRepository.php
  20. 20 0
      packages/Webkul/BagistoApi/src/Resources/lang/en/app.php
  21. 49 0
      packages/Webkul/BagistoApi/src/Services/CartTokenService.php
  22. 942 0
      packages/Webkul/BagistoApi/src/Services/PaymentService.php
  23. 109 21
      packages/Webkul/BagistoApi/src/State/CancelOrderProcessor.php
  24. 117 156
      packages/Webkul/BagistoApi/src/State/CheckoutProcessor.php
  25. 10 16
      packages/Webkul/BagistoApi/src/State/CountryStateCollectionProvider.php
  26. 41 0
      packages/Webkul/BagistoApi/src/State/PaymentCallbackProcessor.php
  27. 62 0
      packages/Webkul/BagistoApi/src/State/PaymentInitiateProcessor.php
  28. 48 0
      packages/Webkul/BagistoApi/src/State/PaymentReplayProcessor.php
  29. 172 0
      packages/Webkul/BagistoApi/src/Transformers/ExpressOrderResource.php
  30. 64 0
      packages/Webkul/BagistoApi/tests/Unit/Jobs/ReconcilePendingPaymentJobTest.php
  31. 142 0
      packages/Webkul/BagistoApi/tests/Unit/Services/PaymentServiceCancelStrategyTest.php
  32. 242 0
      packages/Webkul/BagistoApi/tests/Unit/Services/PaymentServiceCaptureTest.php
  33. 78 0
      packages/Webkul/BagistoApi/tests/Unit/Services/PaymentServiceP1Test.php
  34. 138 0
      packages/Webkul/BagistoApi/tests/Unit/Transformers/ExpressOrderResourceTest.php
  35. 1 1
      packages/Webkul/Paypal/src/Payment/SmartButton.php

+ 93 - 1
packages/Webkul/BagistoApi/README.md

@@ -1,4 +1,4 @@
-# Bagisto API Platform
+# Bagisto API Platform
 
 Comprehensive REST and GraphQL APIs for seamless e-commerce integration and extensibility.
 
@@ -84,6 +84,98 @@ Once verified, access the APIs at:
 - **GraphQL Endpoint**: https://your-domain.com/graphql`
 - **GraphQL Playground**: [https://your-domain.com/graphqli](https://api-demo.bagisto.com/api/graphiql?)
 
+## Payment Flow
+
+The payment pipeline supports two paths through a single set of GraphQL
+mutations:
+
+1. **Standard checkout** – buyer fills address/email/shipping method before
+   payment. Order is created with full tax/shipping totals.
+2. **Express checkout** – buyer skips address/email entirely. Order is
+   created with placeholder address and `tax_amount = shipping_amount = 0`;
+   the gateway response fills the buyer's real address on success.
+
+Both paths use the same state machine:
+
+```
++----------------+   paymentInitiate    +----------+   paymentCallback
+|  cart token    |  ─────────────────▶  | order(PENDING)|  ───────────▶ order(PROCESSING)
++----------------+                       +----------+                   (success)
+                                              │
+                                              │   paymentReplay
+                                              │   (re-issue gateway order id)
+                                              ▼
+                                          cancelOrder ── strategy ──▶ cancel / keep_pending
+```
+
+### Mutations
+
+| Mutation                  | Purpose                                                                |
+|---------------------------|------------------------------------------------------------------------|
+| `paymentInitiateCreate`   | Create the Bagisto order + a gateway order id in one shot. Returns the new cart token so the buyer can keep adding products to a fresh cart. Set `expressCheckout: true` to skip shipping/email validation. |
+| `paymentCallbackCreate`   | Frontend hits this after the gateway redirect. `status = success` triggers capture + (for express orders) writes the real shipping/billing address. `status = cancel | failure` leaves the order pending. |
+| `paymentReplayCreate`     | Authenticated. Generates a fresh gateway order id for a still-pending order so the buyer can retry payment. |
+| `cancelOrderCreate`       | Existing mutation; now strategy-aware: guest cancels immediately and reactivates the old cart; customer with shipping address stays pending; customer without shipping address follows the `bagistoapi.express_checkout.cancel_without_address` config (`cancel` by default, switchable to `keep_pending`). |
+
+### `payment.additional` keys
+
+`paymentInitiateCreate` stores the following keys onto the order's payment
+record so callbacks and the reconciliation job can pick the right branch:
+
+- `express`            – boolean; true for express-checkout orders.
+- `gateway_order_id`   – same value as `paypal_order_id` (kept for parity).
+- `paypal_order_id`    – PayPal order id when the gateway is Smart Button.
+- `cart_id`            – original cart id, used when reactivating a cart.
+- `cart_token`         – original guest cart token (guest carts only).
+
+### Dead-letter reconciliation
+
+Each successful `paymentInitiate` enqueues `ReconcilePendingPaymentJob`
+on the `bagistoapi.reconcile.queue` queue with a configurable delay
+(`bagistoapi.reconcile.delay_minutes`, default 15 min). The job:
+
+- exits cleanly if the order is no longer pending;
+- if the gateway reports a capture, dispatches
+  `bagistoapi.payment.reconcile.captured` so ops can investigate a missed
+  callback (likely a network failure on the client side);
+- otherwise calls `OrderRepository::cancel($order)` and dispatches
+  `bagistoapi.payment.reconcile.cancelled`.
+
+Route the queue to a dead-letter capable broker (e.g. RabbitMQ) via the
+standard Laravel queue config; the job itself is broker-agnostic.
+
+### Extension events
+
+The flow emits the following events that listeners can subscribe to:
+
+- `bagistoapi.payment.initiated`            – after order + gateway order created.
+- `bagistoapi.payment.success`              – after a successful capture.
+- `bagistoapi.payment.cancelled`            – cancel callback received.
+- `bagistoapi.payment.replayed`             – new gateway order issued for pending order.
+- `bagistoapi.express.cancel.no-address`    – customer cancels an express order that still has the placeholder address; config decides whether to actually cancel.
+- `bagistoapi.order.cancel.kept-pending`    – cancel mutation kept the order pending instead of cancelling.
+- `bagistoapi.payment.reconcile.captured`   – reconciliation found a capture on the gateway side but Bagisto is still pending.
+- `bagistoapi.payment.reconcile.cancelled`  – reconciliation cancelled the order.
+- `bagistoapi.payment.reconcile.voided`     – reconciliation found the gateway order in a terminal failed state.
+- `bagistoapi.payment.reconcile.no-gateway-info` – reconciliation found no gateway info on the order; surfaced for monitoring.
+
+### Configuration
+
+`config/bagistoapi.php` (publish via `php artisan vendor:publish --tag=bagistoapi-config`):
+
+```php
+'express_checkout' => [
+    'enabled'                => env('BAGISTOAPI_EXPRESS_CHECKOUT_ENABLED', true),
+    'cancel_without_address' => env('BAGISTOAPI_EXPRESS_CANCEL_WITHOUT_ADDRESS', 'cancel'),
+    'placeholder_address'    => [/* address used pre-payment */],
+],
+'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'),
+],
+```
+
 ## Documentation
 - Bagisto API: [Demo Page](https://api-demo.bagisto.com/api) 
 - API Documentation: [Bagisto API Docs](https://api-docs.bagisto.com/)

+ 75 - 0
packages/Webkul/BagistoApi/config/bagistoapi.php

@@ -0,0 +1,75 @@
+<?php
+
+/*
+|--------------------------------------------------------------------------
+| BagistoApi Configuration
+|--------------------------------------------------------------------------
+|
+| Switches for the BagistoApi payment / express-checkout flow.
+|
+*/
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Express Checkout
+    |--------------------------------------------------------------------------
+    |
+    | express_checkout.enabled
+    |   Master switch for the quick (no email/address) payment path.
+    |
+    | express_checkout.cancel_without_address
+    |   Strategy for "registered customer cancels express payment AND order
+    |   has no shipping address" scenario:
+    |     - "cancel"       (default) cancel the order immediately.
+    |     - "keep_pending" leave order pending; caller can call paymentReplay
+    |                      later or operations team can intervene via the
+    |                      `bagistoapi.express.cancel.no-address` event.
+    |
+    | express_checkout.placeholder_address
+    |   Placeholder address used for the pre-payment order when no real
+    |   shipping address is known yet. Re-written from the gateway response
+    |   on a successful capture callback.
+    */
+    'express_checkout' => [
+        'enabled'                   => env('BAGISTOAPI_EXPRESS_CHECKOUT_ENABLED', true),
+        'cancel_without_address'    => env('BAGISTOAPI_EXPRESS_CANCEL_WITHOUT_ADDRESS', 'cancel'),
+        'placeholder_address'       => [
+            'first_name'   => 'Express',
+            'last_name'    => 'Checkout',
+            'email'        => 'express-checkout@placeholder.local',
+            'address'      => 'Pending gateway response',
+            'city'         => 'Pending',
+            'state'        => 'Pending',
+            'country'      => 'US',
+            'postcode'     => '00000',
+            'phone'        => '0000000000',
+        ],
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | Payment Reconciliation Job
+    |--------------------------------------------------------------------------
+    |
+    | reconcile.enabled
+    |   Toggle the delayed reconciliation job that polls the gateway after
+    |   `delay_minutes` if the order is still PENDING.
+    |
+    | reconcile.delay_minutes
+    |   How long to wait before a pending order is reconciled against the
+    |   gateway.
+    |
+    | 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.
+    */
+    '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'),
+    ],
+
+];

+ 5 - 0
packages/Webkul/BagistoApi/src/Contracts/PaymentAttempt.php

@@ -0,0 +1,5 @@
+<?php
+
+namespace Webkul\BagistoApi\Contracts;
+
+interface PaymentAttempt {}

+ 35 - 0
packages/Webkul/BagistoApi/src/Database/Migrations/2026_06_02_000000_create_payment_attempts_table.php

@@ -0,0 +1,35 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('payment_attempts', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedInteger('order_id')->nullable()->index();
+            $table->unsignedInteger('cart_id')->nullable()->index();
+            $table->string('payment_method')->nullable();
+            $table->string('gateway_order_id')->nullable()->index();
+            $table->string('action')->index();
+            $table->string('status')->index();
+            $table->decimal('amount', 12, 4)->default(0)->nullable();
+            $table->string('currency')->nullable();
+            $table->boolean('express')->default(false);
+            $table->json('request_payload')->nullable();
+            $table->json('response_payload')->nullable();
+            $table->string('idempotency_key')->nullable()->unique();
+            $table->timestamps();
+
+            $table->foreign('order_id')->references('id')->on('orders')->onDelete('cascade');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('payment_attempts');
+    }
+};

+ 62 - 0
packages/Webkul/BagistoApi/src/Dto/PaymentCallbackInput.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Serializer\Annotation\SerializedName;
+
+/**
+ * GraphQL input for paymentCallbackCreate.
+ *
+ * Frontends call this after the gateway redirects back, indicating
+ * `status = success | cancel | failure`. For express success calls
+ * the gateway shipping/billing fields populated from the PayPal
+ * response should be forwarded so the order address can be filled.
+ */
+class PaymentCallbackInput
+{
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Bagisto order id created by paymentInitiate')]
+    #[SerializedName('orderId')]
+    public ?int $orderId = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Gateway-side order id / token (e.g. PayPal order id)')]
+    #[SerializedName('gatewayOrderId')]
+    public ?string $gatewayOrderId = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'success | cancel | failure')]
+    #[SerializedName('status')]
+    public ?string $status = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Optional signature/proof forwarded from gateway')]
+    #[SerializedName('paymentSignature')]
+    public ?string $paymentSignature = null;
+
+    /*
+    | Optional address payload (express flow only)
+    */
+
+    #[Groups(['mutation'])] #[SerializedName('shippingFirstName')] public ?string $shippingFirstName = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingLastName')]  public ?string $shippingLastName = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingEmail')]     public ?string $shippingEmail = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingAddress')]   public ?string $shippingAddress = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingCountry')]   public ?string $shippingCountry = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingState')]     public ?string $shippingState = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingCity')]      public ?string $shippingCity = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingPostcode')]  public ?string $shippingPostcode = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingPhoneNumber')] public ?string $shippingPhoneNumber = null;
+
+    #[Groups(['mutation'])] #[SerializedName('billingFirstName')] public ?string $billingFirstName = null;
+    #[Groups(['mutation'])] #[SerializedName('billingLastName')]  public ?string $billingLastName = null;
+    #[Groups(['mutation'])] #[SerializedName('billingEmail')]     public ?string $billingEmail = null;
+    #[Groups(['mutation'])] #[SerializedName('billingAddress')]   public ?string $billingAddress = null;
+    #[Groups(['mutation'])] #[SerializedName('billingCountry')]   public ?string $billingCountry = null;
+    #[Groups(['mutation'])] #[SerializedName('billingState')]     public ?string $billingState = null;
+    #[Groups(['mutation'])] #[SerializedName('billingCity')]      public ?string $billingCity = null;
+    #[Groups(['mutation'])] #[SerializedName('billingPostcode')]  public ?string $billingPostcode = null;
+    #[Groups(['mutation'])] #[SerializedName('billingPhoneNumber')] public ?string $billingPhoneNumber = null;
+}

+ 77 - 0
packages/Webkul/BagistoApi/src/Dto/PaymentInitiateInput.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Serializer\Annotation\SerializedName;
+
+/**
+ * GraphQL input for paymentInitiateCreate.
+ *
+ * The standard path requires the regular address/email/shipping fields
+ * (same shape as `CheckoutAddressInput`). The express path only needs
+ * `paymentMethod` plus optional return URLs and the `expressCheckout`
+ * flag set to true; address/email blocks may be omitted entirely.
+ */
+class PaymentInitiateInput
+{
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Mark this initiation as an express checkout (skip shipping/email/tax)')]
+    #[SerializedName('expressCheckout')]
+    public ?bool $expressCheckout = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Payment method code (e.g. paypal_smart_button)')]
+    #[SerializedName('paymentMethod')]
+    public ?string $paymentMethod = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Optional success redirect URL forwarded to the gateway')]
+    #[SerializedName('paymentSuccessUrl')]
+    public ?string $paymentSuccessUrl = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Optional failure redirect URL forwarded to the gateway')]
+    #[SerializedName('paymentFailureUrl')]
+    public ?string $paymentFailureUrl = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Optional cancel redirect URL forwarded to the gateway')]
+    #[SerializedName('paymentCancelUrl')]
+    public ?string $paymentCancelUrl = null;
+
+    /*
+    |--------------------------------------------------------------------------
+    | Address fields (standard checkout only)
+    |--------------------------------------------------------------------------
+    | Express initiations may leave every field below null; they are kept
+    | on the same DTO so the existing GraphQL client can keep posting one
+    | shape.
+    */
+
+    #[Groups(['mutation'])] #[SerializedName('billingFirstName')] public ?string $billingFirstName = null;
+    #[Groups(['mutation'])] #[SerializedName('billingLastName')]  public ?string $billingLastName = null;
+    #[Groups(['mutation'])] #[SerializedName('billingEmail')]     public ?string $billingEmail = null;
+    #[Groups(['mutation'])] #[SerializedName('billingCompanyName')] public ?string $billingCompanyName = null;
+    #[Groups(['mutation'])] #[SerializedName('billingAddress')]   public ?string $billingAddress = null;
+    #[Groups(['mutation'])] #[SerializedName('billingCountry')]   public ?string $billingCountry = null;
+    #[Groups(['mutation'])] #[SerializedName('billingState')]     public ?string $billingState = null;
+    #[Groups(['mutation'])] #[SerializedName('billingCity')]      public ?string $billingCity = null;
+    #[Groups(['mutation'])] #[SerializedName('billingPostcode')]  public ?string $billingPostcode = null;
+    #[Groups(['mutation'])] #[SerializedName('billingPhoneNumber')] public ?string $billingPhoneNumber = null;
+
+    #[Groups(['mutation'])] #[SerializedName('shippingFirstName')] public ?string $shippingFirstName = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingLastName')]  public ?string $shippingLastName = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingEmail')]     public ?string $shippingEmail = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingCompanyName')] public ?string $shippingCompanyName = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingAddress')]   public ?string $shippingAddress = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingCountry')]   public ?string $shippingCountry = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingState')]     public ?string $shippingState = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingCity')]      public ?string $shippingCity = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingPostcode')]  public ?string $shippingPostcode = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingPhoneNumber')] public ?string $shippingPhoneNumber = null;
+
+    #[Groups(['mutation'])] #[SerializedName('useForShipping')] public ?bool $useForShipping = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingMethod')] public ?string $shippingMethod = null;
+}

+ 37 - 0
packages/Webkul/BagistoApi/src/Dto/PaymentReplayInput.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Serializer\Annotation\SerializedName;
+
+/**
+ * GraphQL input for paymentReplayCreate.
+ *
+ * Used to issue a fresh gateway order id for an order that is still
+ * pending payment (the customer cancelled / abandoned the original
+ * gateway flow but the Bagisto order is intact).
+ */
+class PaymentReplayInput
+{
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Bagisto order id to replay payment for')]
+    #[SerializedName('orderId')]
+    public ?int $orderId = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Optional override of payment success URL')]
+    #[SerializedName('paymentSuccessUrl')]
+    public ?string $paymentSuccessUrl = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Optional override of payment failure URL')]
+    #[SerializedName('paymentFailureUrl')]
+    public ?string $paymentFailureUrl = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Optional override of payment cancel URL')]
+    #[SerializedName('paymentCancelUrl')]
+    public ?string $paymentCancelUrl = null;
+}

+ 2 - 0
packages/Webkul/BagistoApi/src/Facades/CartTokenFacade.php

@@ -11,6 +11,8 @@ use Illuminate\Support\Facades\Facade;
  * @method static object|null getGuestTokenRecord(string $token)
  * @method static string getTokenType(string $token)
  * @method static bool isValidToken(string $token)
+ * @method static string|null issueFreshCart(?int $customerId = null)
+ * @method static object|null reactivateCart(int $cartId)
  */
 class CartTokenFacade extends Facade
 {

+ 137 - 0
packages/Webkul/BagistoApi/src/Jobs/ReconcilePendingPaymentJob.php

@@ -0,0 +1,137 @@
+<?php
+
+namespace Webkul\BagistoApi\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\DB;
+use Illuminate\Support\Facades\Event;
+use Illuminate\Support\Facades\Log;
+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.
+ *
+ * Behaviour:
+ *
+ * - 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.
+ *
+ * This job is intentionally idempotent - it can be re-queued safely.
+ */
+class ReconcilePendingPaymentJob implements ShouldQueue
+{
+    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+
+    public int $tries = 3;
+
+    public int $backoff = 60;
+
+    public function __construct(
+        public int $orderId,
+    ) {}
+
+    public function handle(OrderRepository $orderRepository): 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)) {
+            return;
+        }
+
+        $additional = $order->payment?->additional ?? [];
+        $gatewayOrderId = $additional['gateway_order_id'] ?? $additional['paypal_order_id'] ?? null;
+        $method = $order->payment?->method;
+
+        if (! $gatewayOrderId || $method !== 'paypal_smart_button') {
+            Event::dispatch('bagistoapi.payment.reconcile.no-gateway-info', $order);
+
+            return;
+        }
+
+        try {
+            $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);
+            });
+
+            if ($cancelled) {
+                Event::dispatch('bagistoapi.payment.reconcile.cancelled', $order);
+            }
+        } catch (\Throwable $e) {
+            Log::warning('ReconcilePendingPaymentJob: gateway lookup failed', [
+                'order_id'         => $order->id,
+                'gateway_order_id' => $gatewayOrderId,
+                '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.
+             */
+            throw $e;
+        }
+    }
+}

+ 2 - 1
packages/Webkul/BagistoApi/src/Models/Country.php

@@ -14,13 +14,14 @@ use Webkul\Core\Models\Country as BaseCountry;
 
 #[ApiResource(
     routePrefix: '/api/shop',
+    paginationEnabled: false,
     operations: [
         new GetCollection,
         new Get,
     ],
     graphQlOperations: [
         new Query(resolver: BaseQueryItemResolver::class),
-        new QueryCollection(provider: CursorAwareCollectionProvider::class),
+        new QueryCollection(provider: CursorAwareCollectionProvider::class, paginationEnabled: false),
     ]
 )]
 class Country extends BaseCountry

+ 3 - 5
packages/Webkul/BagistoApi/src/Models/CountryState.php

@@ -24,6 +24,7 @@ use Webkul\Core\Models\CountryState as BaseCountryState;
 // Subresource nested collection: /countries/{country_id}/states
 #[ApiResource(
     routePrefix: '/api/shop',
+    paginationEnabled: false,
     uriTemplate: '/countries/{country_id}/states',
     uriVariables: [
         'country_id' => new Link(
@@ -58,6 +59,7 @@ use Webkul\Core\Models\CountryState as BaseCountryState;
 #[ApiResource(
     routePrefix: '/api/shop',
     shortName: 'CountryState',
+    paginationEnabled: false,
     uriTemplate: '/country-states',
     operations: [
         new GetCollection,
@@ -65,16 +67,12 @@ use Webkul\Core\Models\CountryState as BaseCountryState;
     graphQlOperations: [
         new QueryCollection(
             provider: CountryStateCollectionProvider::class,
-            paginationType: 'cursor',
+            paginationEnabled: false,
             args: [
                 'countryId' => [
                     'type'        => 'Int!',
                     'description' => 'Filter states by country ID (required)',
                 ],
-                'first'  => ['type' => 'Int', 'description' => 'Limit results (forward pagination)'],
-                'last'   => ['type' => 'Int', 'description' => 'Limit results (backward pagination)'],
-                'after'  => ['type' => 'String', 'description' => 'Cursor for forward pagination'],
-                'before' => ['type' => 'String', 'description' => 'Cursor for backward pagination'],
             ]
         ),
     ]

+ 82 - 0
packages/Webkul/BagistoApi/src/Models/PaymentAttempt.php

@@ -0,0 +1,82 @@
+<?php
+
+namespace Webkul\BagistoApi\Models;
+
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
+use Webkul\BagistoApi\Contracts\PaymentAttempt as PaymentAttemptContract;
+use Webkul\Sales\Models\OrderProxy;
+
+/**
+ * Audit trail for the full payment lifecycle (initiate / replay / callback).
+ *
+ * This is intentionally separate from `order_transactions`, which only
+ * records the final settled (invoiced) transaction. A payment attempt is
+ * an intent that may fail, be replayed multiple times, or be cancelled.
+ */
+class PaymentAttempt extends Model implements PaymentAttemptContract
+{
+    protected $table = 'payment_attempts';
+
+    /**
+     * Lifecycle stage of the attempt.
+     */
+    public const ACTION_INITIATE = 'initiate';
+
+    public const ACTION_REPLAY = 'replay';
+
+    public const ACTION_CALLBACK = 'callback';
+
+    /**
+     * Status of the attempt.
+     */
+    public const STATUS_CREATED = 'created';
+
+    public const STATUS_REDIRECTED = 'redirected';
+
+    public const STATUS_CAPTURED = 'captured';
+
+    public const STATUS_CANCELLED = 'cancelled';
+
+    public const STATUS_FAILED = 'failed';
+
+    /**
+     * The attributes that are mass assignable.
+     *
+     * @var array
+     */
+    protected $fillable = [
+        'order_id',
+        'cart_id',
+        'payment_method',
+        'gateway_order_id',
+        'action',
+        'status',
+        'amount',
+        'currency',
+        'express',
+        'request_payload',
+        'response_payload',
+        'idempotency_key',
+    ];
+
+    /**
+     * The attributes that should be cast.
+     *
+     * @var array
+     */
+    protected $casts = [
+        'express'          => 'boolean',
+        'request_payload'  => 'array',
+        'response_payload' => 'array',
+        'amount'           => 'float',
+    ];
+
+    /**
+     * Get the order associated with this payment attempt.
+     */
+    public function order(): BelongsTo
+    {
+        return $this->belongsTo(OrderProxy::modelClass(), 'order_id');
+    }
+}

+ 7 - 0
packages/Webkul/BagistoApi/src/Models/PaymentAttemptProxy.php

@@ -0,0 +1,7 @@
+<?php
+
+namespace Webkul\BagistoApi\Models;
+
+use Konekt\Concord\Proxies\ModelProxy;
+
+class PaymentAttemptProxy extends ModelProxy {}

+ 72 - 0
packages/Webkul/BagistoApi/src/Models/PaymentCallback.php

@@ -0,0 +1,72 @@
+<?php
+
+namespace Webkul\BagistoApi\Models;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Mutation;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Webkul\BagistoApi\Dto\PaymentCallbackInput;
+use Webkul\BagistoApi\State\PaymentCallbackProcessor;
+
+/**
+ * paymentCallbackCreate — Frontend invokes this after the gateway
+ * redirect lands back on the storefront. `status` drives the branch:
+ *
+ *  - success: captures the gateway order (PayPal) and, for express
+ *    flows, fills the order's shipping/billing addresses with the data
+ *    forwarded from the gateway.
+ *  - cancel / failure: leaves the order in PENDING so the user can
+ *    decide to either retry (paymentReplay) or cancel (cancelOrder).
+ */
+#[ApiResource(
+    routePrefix: '/api/shop',
+    shortName: 'PaymentCallback',
+    operations: [],
+    graphQlOperations: [
+        new Mutation(
+            name: 'create',
+            input: PaymentCallbackInput::class,
+            output: self::class,
+            processor: PaymentCallbackProcessor::class,
+            denormalizationContext: [
+                'allow_extra_attributes' => true,
+                'groups'                 => ['mutation'],
+            ],
+            normalizationContext: [
+                'groups' => ['mutation'],
+            ],
+            description: 'Handle gateway return (success/cancel/failure). Captures the gateway order on success and fills express addresses if forwarded.',
+        ),
+    ]
+)]
+class PaymentCallback
+{
+    #[ApiProperty(identifier: true, readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?int $id = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public bool $success = false;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public string $message = '';
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $orderId = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $status = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $orderStatus = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $gatewayStatus = null;
+}

+ 74 - 0
packages/Webkul/BagistoApi/src/Models/PaymentInitiate.php

@@ -0,0 +1,74 @@
+<?php
+
+namespace Webkul\BagistoApi\Models;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Mutation;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Webkul\BagistoApi\Dto\PaymentInitiateInput;
+use Webkul\BagistoApi\State\PaymentInitiateProcessor;
+
+/**
+ * paymentInitiateCreate — Unified entry point that creates an order
+ * + a gateway order id in a single round-trip.
+ *
+ * Set `expressCheckout: true` in the input to skip shipping/email/tax
+ * checks. Address fields then become optional and are filled later
+ * via paymentCallback once the gateway returns the buyer's details.
+ */
+#[ApiResource(
+    routePrefix: '/api/shop',
+    shortName: 'PaymentInitiate',
+    operations: [],
+    graphQlOperations: [
+        new Mutation(
+            name: 'create',
+            input: PaymentInitiateInput::class,
+            output: self::class,
+            processor: PaymentInitiateProcessor::class,
+            denormalizationContext: [
+                'allow_extra_attributes' => true,
+                'groups'                 => ['mutation'],
+            ],
+            normalizationContext: [
+                'groups' => ['mutation'],
+            ],
+            description: 'Initiate payment for the current cart. Creates the Bagisto order and the matching gateway order id; returns both so the frontend can redirect to the gateway.',
+        ),
+    ]
+)]
+class PaymentInitiate
+{
+    #[ApiProperty(identifier: true, readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?int $id = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public bool $success = false;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public string $message = '';
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $orderId = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $status = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $gatewayOrderId = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $newCartToken = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public bool $express = false;
+}

+ 64 - 0
packages/Webkul/BagistoApi/src/Models/PaymentReplay.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace Webkul\BagistoApi\Models;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Mutation;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Webkul\BagistoApi\Dto\PaymentReplayInput;
+use Webkul\BagistoApi\State\PaymentReplayProcessor;
+
+/**
+ * paymentReplayCreate — Generates a fresh gateway order id for an
+ * existing pending Bagisto order so the buyer can retry payment
+ * without starting checkout from scratch. Restricted to the order
+ * owner (Sanctum-authenticated customer).
+ */
+#[ApiResource(
+    routePrefix: '/api/shop',
+    shortName: 'PaymentReplay',
+    operations: [],
+    graphQlOperations: [
+        new Mutation(
+            name: 'create',
+            input: PaymentReplayInput::class,
+            output: self::class,
+            processor: PaymentReplayProcessor::class,
+            denormalizationContext: [
+                'allow_extra_attributes' => true,
+                'groups'                 => ['mutation'],
+            ],
+            normalizationContext: [
+                'groups' => ['mutation'],
+            ],
+            description: 'Issue a new gateway order id for a still-pending order so the buyer can retry payment.',
+        ),
+    ]
+)]
+class PaymentReplay
+{
+    #[ApiProperty(identifier: true, readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?int $id = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public bool $success = false;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public string $message = '';
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $orderId = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $status = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $gatewayOrderId = null;
+}

+ 43 - 2
packages/Webkul/BagistoApi/src/Providers/BagistoApiServiceProvider.php

@@ -32,8 +32,12 @@ use Webkul\BagistoApi\Resolver\PageByUrlKeyResolver;
 use Webkul\BagistoApi\Routing\CustomIriConverter;
 use Webkul\BagistoApi\Serializer\TokenHeaderDenormalizer;
 use Webkul\BagistoApi\Services\CartTokenService;
+use Webkul\BagistoApi\Services\PaymentService;
 use Webkul\BagistoApi\Services\StorefrontKeyService;
 use Webkul\BagistoApi\Services\TokenHeaderService;
+use Webkul\BagistoApi\State\PaymentCallbackProcessor;
+use Webkul\BagistoApi\State\PaymentInitiateProcessor;
+use Webkul\BagistoApi\State\PaymentReplayProcessor;
 use Webkul\BagistoApi\State\BookingSlotProvider;
 use Webkul\BagistoApi\State\PageProvider;
 use Webkul\BagistoApi\State\AttributeCollectionProvider;
@@ -103,6 +107,8 @@ class BagistoApiServiceProvider extends ServiceProvider
      */
     public function register(): void
     {
+        $this->mergeConfigFrom(__DIR__.'/../../config/bagistoapi.php', 'bagistoapi');
+
         $this->app->singleton(IterableType::class);
         $this->app->tag(IterableType::class, 'api_platform.graphql.type');
 
@@ -152,6 +158,9 @@ class BagistoApiServiceProvider extends ServiceProvider
         $this->app->tag(CustomerAddressTokenProcessor::class, ProcessorInterface::class);
         $this->app->tag(CartTokenProcessor::class, ProcessorInterface::class);
         $this->app->tag(CheckoutProcessor::class, ProcessorInterface::class);
+        $this->app->tag(PaymentInitiateProcessor::class, ProcessorInterface::class);
+        $this->app->tag(PaymentCallbackProcessor::class, ProcessorInterface::class);
+        $this->app->tag(PaymentReplayProcessor::class, ProcessorInterface::class);
         $this->app->tag(ProductReviewProcessor::class, ProcessorInterface::class);
         $this->app->tag(CompareItemProcessor::class, ProcessorInterface::class);
         $this->app->tag(DownloadableProductProcessor::class, ProcessorInterface::class);
@@ -198,11 +207,41 @@ class BagistoApiServiceProvider extends ServiceProvider
             );
         });
 
+        $this->app->singleton(PaymentService::class, function ($app) {
+            return new PaymentService(
+                $app->make('Webkul\Sales\Repositories\OrderRepository'),
+                $app->make('Webkul\Checkout\Repositories\CartRepository'),
+                $app->make('cart-token-service'),
+                $app->make('Webkul\Sales\Repositories\InvoiceRepository'),
+                $app->make('Webkul\Sales\Repositories\OrderTransactionRepository'),
+                $app->make('Webkul\BagistoApi\Repositories\PaymentAttemptRepository'),
+            );
+        });
+
         $this->app->singleton(CheckoutProcessor::class, function ($app) {
             return new CheckoutProcessor(
                 $app->make('Webkul\Customer\Repositories\CustomerRepository'),
                 $app->make('Webkul\Sales\Repositories\OrderRepository'),
-                $app->make('Webkul\Checkout\Repositories\CartRepository')
+                $app->make('Webkul\Checkout\Repositories\CartRepository'),
+                $app->make(PaymentService::class),
+            );
+        });
+
+        $this->app->singleton(PaymentInitiateProcessor::class, function ($app) {
+            return new PaymentInitiateProcessor(
+                $app->make(PaymentService::class),
+            );
+        });
+
+        $this->app->singleton(PaymentCallbackProcessor::class, function ($app) {
+            return new PaymentCallbackProcessor(
+                $app->make(PaymentService::class),
+            );
+        });
+
+        $this->app->singleton(PaymentReplayProcessor::class, function ($app) {
+            return new PaymentReplayProcessor(
+                $app->make(PaymentService::class),
             );
         });
 
@@ -246,7 +285,8 @@ class BagistoApiServiceProvider extends ServiceProvider
         $this->app->singleton(CancelOrderProcessor::class, function ($app) {
             return new CancelOrderProcessor(
                 $app->make(PersistProcessor::class),
-                $app->make('Webkul\Sales\Repositories\OrderRepository')
+                $app->make('Webkul\Sales\Repositories\OrderRepository'),
+                $app->make(PaymentService::class),
             );
         });
 
@@ -553,6 +593,7 @@ class BagistoApiServiceProvider extends ServiceProvider
         $this->publishes([
             __DIR__.'/../config/graphql-auth.php' => config_path('graphql-auth.php'),
             __DIR__.'/../config/storefront.php'   => config_path('storefront.php'),
+            __DIR__.'/../config/bagistoapi.php'   => config_path('bagistoapi.php'),
         ], 'bagistoapi-config');
 
         $this->publishes([

+ 1 - 0
packages/Webkul/BagistoApi/src/Providers/ModuleServiceProvider.php

@@ -19,5 +19,6 @@ class ModuleServiceProvider extends CoreModuleServiceProvider
      */
     protected $models = [
         \Webkul\BagistoApi\Models\GuestCartTokens::class,
+        \Webkul\BagistoApi\Models\PaymentAttempt::class,
     ];
 }

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

@@ -0,0 +1,28 @@
+<?php
+
+namespace Webkul\BagistoApi\Repositories;
+
+use Webkul\BagistoApi\Models\PaymentAttempt;
+use Webkul\Core\Eloquent\Repository;
+
+class PaymentAttemptRepository extends Repository
+{
+    /**
+     * Specify model class name.
+     */
+    public function model(): string
+    {
+        return PaymentAttempt::class;
+    }
+
+    /**
+     * Find the most recent attempt for a gateway order id.
+     */
+    public function findByGatewayOrderId(string $gatewayOrderId): ?PaymentAttempt
+    {
+        return $this->model
+            ->where('gateway_order_id', $gatewayOrderId)
+            ->latest('id')
+            ->first();
+    }
+}

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

@@ -147,6 +147,26 @@ return [
             'not-found'                         => 'Order with ID ":id" not found or does not belong to this customer',
             'cancel-success'                    => 'Order has been canceled successfully',
             'cancel-failed'                     => 'Order cannot be canceled. It may have already been processed, shipped, or canceled',
+            'kept-pending'                      => 'Order kept in pending state. Use paymentReplay to retry the payment.',
+        ],
+
+        'payment' => [
+            'order-id-required'                 => 'Order ID is required',
+            'order-not-found'                   => 'Order with ID ":id" not found',
+            'invalid-status'                    => 'Unsupported payment callback status ":status"',
+            'gateway-create-failed'             => 'Failed to create gateway order',
+            'gateway-order-id-required'         => 'Gateway order ID is required for capture',
+            'gateway-capture-failed'            => 'Gateway capture failed',
+            'capture-not-completed'             => 'Gateway capture was not completed',
+            'amount-mismatch'                   => 'Captured amount does not match the order total',
+            'not-pending'                       => 'Order is not in a pending state (current: ":status")',
+            'replay-not-allowed'                => 'You are not allowed to replay payment for this order',
+            'initiated'                         => 'Payment initiated successfully',
+            'replayed'                          => 'A new gateway order has been issued',
+            'success'                           => 'Payment captured successfully',
+            'cancelled'                         => 'Payment was cancelled',
+            'failed'                            => 'Payment failed',
+            'express-disabled'                  => 'Express checkout is disabled',
         ],
 
         'reorder' => [

+ 49 - 0
packages/Webkul/BagistoApi/src/Services/CartTokenService.php

@@ -101,4 +101,53 @@ class CartTokenService
     {
         return $this->getCartByToken($token) !== null;
     }
+
+    /**
+     * Issue a brand-new empty cart + guest token so the buyer can keep
+     * shopping while their previous cart is locked behind a pending payment.
+     *
+     * Always returns the freshly created token string (guest) or null (the
+     * caller is a logged-in customer; the new cart is keyed by customer_id
+     * and reachable through the existing sanctum token).
+     */
+    public function issueFreshCart(?int $customerId = null): ?string
+    {
+        $channel = core()->getCurrentChannel();
+
+        if ($customerId) {
+            $this->cartRepository->create([
+                'customer_id' => $customerId,
+                'channel_id'  => $channel->id,
+                'is_active'   => 1,
+                'is_guest'    => 0,
+            ]);
+
+            return null;
+        }
+
+        $cart = $this->cartRepository->create([
+            'channel_id' => $channel->id,
+            'is_active'  => 1,
+            'is_guest'   => 1,
+        ]);
+
+        return $this->guestCartTokensRepository->createToken($cart->id)->token;
+    }
+
+    /**
+     * Reactivate a previously deactivated cart (used when a guest cancels
+     * payment and we need to revive the original cart instance).
+     */
+    public function reactivateCart(int $cartId): ?object
+    {
+        $cart = $this->cartRepository->find($cartId);
+
+        if (! $cart) {
+            return null;
+        }
+
+        $this->cartRepository->update(['is_active' => 1], $cartId);
+
+        return $cart->refresh();
+    }
 }

+ 942 - 0
packages/Webkul/BagistoApi/src/Services/PaymentService.php

@@ -0,0 +1,942 @@
+<?php
+
+namespace Webkul\BagistoApi\Services;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Event;
+use Illuminate\Support\Facades\Log;
+use Webkul\BagistoApi\Dto\PaymentCallbackInput;
+use Webkul\BagistoApi\Dto\PaymentInitiateInput;
+use Webkul\BagistoApi\Dto\PaymentReplayInput;
+use Webkul\BagistoApi\Exception\OperationFailedException;
+use Webkul\BagistoApi\Exception\ResourceNotFoundException;
+use Webkul\BagistoApi\Jobs\ReconcilePendingPaymentJob;
+use Webkul\BagistoApi\Models\PaymentAttempt;
+use Webkul\BagistoApi\Repositories\PaymentAttemptRepository;
+use Webkul\BagistoApi\Transformers\ExpressOrderResource;
+use Webkul\Checkout\Facades\Cart;
+use Webkul\Checkout\Repositories\CartRepository;
+use Webkul\Paypal\Payment\SmartButton;
+use Webkul\Sales\Models\Order;
+use Webkul\Sales\Models\OrderProxy;
+use Webkul\Sales\Repositories\InvoiceRepository;
+use Webkul\Sales\Repositories\OrderRepository;
+use Webkul\Sales\Repositories\OrderTransactionRepository;
+use Webkul\Sales\Transformers\OrderResource;
+
+/**
+ * Single source of truth for the BagistoApi payment lifecycle:
+ *
+ * - initiate()  : create order (express or standard) + gateway order
+ * - callback()  : handle success / cancel / failure from the gateway
+ * - replay()    : issue a new gateway order id for a still-pending order
+ * - cancelPayment(): close out a pending payment per cart-token strategy
+ *
+ * The matching ProcessorInterface implementations are thin wrappers so
+ * GraphQL resolvers stay testable in isolation.
+ */
+class PaymentService
+{
+    public function __construct(
+        protected OrderRepository $orderRepository,
+        protected CartRepository $cartRepository,
+        protected CartTokenService $cartTokenService,
+        protected InvoiceRepository $invoiceRepository,
+        protected OrderTransactionRepository $orderTransactionRepository,
+        protected PaymentAttemptRepository $paymentAttemptRepository,
+    ) {}
+
+    /*
+    |--------------------------------------------------------------------------
+    | Initiate payment (= create order + gateway order)
+    |--------------------------------------------------------------------------
+    */
+
+    /**
+     * Initiate a payment on the supplied cart.
+     *
+     * Returns an associative array: [
+     *   'order' => OrderModel,
+     *   'gatewayOrderId' => ?string,
+     *   'newCartToken' => ?string,
+     *   'express' => bool,
+     * ]
+     */
+    public function initiate($cart, PaymentInitiateInput $input): array
+    {
+        $express = (bool) $input->expressCheckout;
+
+        if ($express && ! config('bagistoapi.express_checkout.enabled', true)) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.payment.express-disabled'));
+        }
+
+        if (! $cart || $cart->items()->count() === 0) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.cart-empty'));
+        }
+
+        Cart::setCart($cart);
+
+        if ($express) {
+            $this->validateExpressInitiation($cart, $input);
+        } else {
+            $this->validateStandardInitiation($cart, $input);
+            Cart::collectTotals();
+        }
+
+        if (! $input->paymentMethod && ! $cart->payment?->method) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.payment-method-required'));
+        }
+
+        if ($input->paymentMethod) {
+            if (! Cart::savePaymentMethod(['method' => $input->paymentMethod])) {
+                throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.payment-method-save-failed'));
+            }
+            Cart::collectTotals();
+            $cart = Cart::getCart();
+        }
+
+        $oldCartId = $cart->id;
+        $oldCartToken = $cart->guest_cart_token ?? null;
+
+        $orderData = $express
+            ? (new ExpressOrderResource($cart))->jsonSerialize()
+            : (new OrderResource($cart))->jsonSerialize();
+
+        $order = null;
+        $gatewayOrderId = null;
+
+        DB::transaction(function () use (
+            &$order,
+            &$gatewayOrderId,
+            $cart,
+            $input,
+            $express,
+            $orderData,
+            $oldCartId,
+            $oldCartToken
+        ) {
+            $order = $this->orderRepository->create($orderData);
+
+            if (! $order || ! data_get($order, 'id')) {
+                throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.order-creation-failed'));
+            }
+
+            $order = $this->orderRepository->find($order->id);
+
+            $gatewayOrderId = $this->createGatewayOrder($cart, $order, $input, $express);
+
+            $this->stampPaymentAdditional($order, $gatewayOrderId, $express, $oldCartId, $oldCartToken);
+
+            $this->recordAttempt([
+                'order_id'         => $order->id,
+                'cart_id'          => $oldCartId,
+                'payment_method'   => $order->payment?->method,
+                'gateway_order_id' => $gatewayOrderId,
+                'action'           => PaymentAttempt::ACTION_INITIATE,
+                'status'           => $gatewayOrderId ? PaymentAttempt::STATUS_REDIRECTED : PaymentAttempt::STATUS_CREATED,
+                'amount'           => (float) ($order->grand_total ?? 0),
+                'currency'         => $order->order_currency_code ?? $order->cart_currency_code,
+                'express'          => $express,
+            ]);
+        });
+
+        Cart::deActivateCart();
+        $newCartToken = $this->cartTokenService->issueFreshCart($cart->customer_id);
+
+        Event::dispatch('order.created.after', $order);
+        Event::dispatch('bagistoapi.payment.initiated', $order);
+
+        $this->scheduleReconciliation($order);
+
+        return [
+            'order'          => $order,
+            'gatewayOrderId' => $gatewayOrderId,
+            'newCartToken'   => $newCartToken,
+            'express'        => $express,
+        ];
+    }
+
+    /*
+    |--------------------------------------------------------------------------
+    | Callback (success / cancel / failure)
+    |--------------------------------------------------------------------------
+    */
+
+    /**
+     * Returns the persisted order plus a status string for the resolver.
+     *
+     * The cancel branch is handled here for the case where the frontend
+     * lands on the cancel URL right after the gateway redirect. The
+     * separate CancelOrderProcessor mutation handles user-initiated
+     * cancels from the order list.
+     */
+    public function callback(PaymentCallbackInput $input): array
+    {
+        if (! $input->orderId) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.payment.order-id-required'));
+        }
+
+        $order = $this->orderRepository->find($input->orderId);
+
+        if (! $order) {
+            throw new ResourceNotFoundException(__('bagistoapi::app.graphql.payment.order-not-found', ['id' => $input->orderId]));
+        }
+
+        $status = strtolower((string) $input->status);
+
+        return match ($status) {
+            'success' => $this->handleSuccess($order, $input),
+            'cancel'  => $this->handleCancel($order, 'cancelled'),
+            'failure' => $this->handleCancel($order, 'failed'),
+            default   => throw new OperationFailedException(__('bagistoapi::app.graphql.payment.invalid-status', ['status' => $status])),
+        };
+    }
+
+    /*
+    |--------------------------------------------------------------------------
+    | Replay (re-issue gateway order for pending Bagisto order)
+    |--------------------------------------------------------------------------
+    */
+
+    public function replay(PaymentReplayInput $input, $customer = null): array
+    {
+        if (! $input->orderId) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.payment.order-id-required'));
+        }
+
+        $order = $this->orderRepository->find($input->orderId);
+
+        if (! $order) {
+            throw new ResourceNotFoundException(__('bagistoapi::app.graphql.payment.order-not-found', ['id' => $input->orderId]));
+        }
+
+        if ($customer && $order->customer_id !== $customer->id) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.payment.replay-not-allowed'));
+        }
+
+        if (! in_array($order->status, [Order::STATUS_PENDING, Order::STATUS_PENDING_PAYMENT], true)) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.payment.not-pending', ['status' => $order->status]));
+        }
+
+        $gatewayOrderId = $this->createGatewayOrderForOrder($order);
+
+        $this->writeGatewayOrderId($order, $gatewayOrderId);
+
+        $this->recordAttempt([
+            'order_id'         => $order->id,
+            'cart_id'          => $this->resolveOldCartId($order),
+            'payment_method'   => $order->payment?->method,
+            'gateway_order_id' => $gatewayOrderId,
+            'action'           => PaymentAttempt::ACTION_REPLAY,
+            'status'           => $gatewayOrderId ? PaymentAttempt::STATUS_REDIRECTED : PaymentAttempt::STATUS_CREATED,
+            'amount'           => (float) ($order->grand_total ?? 0),
+            'currency'         => $order->order_currency_code ?? $order->cart_currency_code,
+            'express'          => $this->expressFlag($order),
+        ]);
+
+        $this->scheduleReconciliation($order);
+
+        Event::dispatch('bagistoapi.payment.replayed', $order);
+
+        return [
+            'order'          => $order->fresh(),
+            'gatewayOrderId' => $gatewayOrderId,
+        ];
+    }
+
+    /*
+    |--------------------------------------------------------------------------
+    | Cancel strategy (used by CancelOrderProcessor)
+    |--------------------------------------------------------------------------
+    */
+
+    /**
+     * Decide what to do with a pending order when the cancel mutation
+     * is invoked. Returns an array describing the action so the caller
+     * can report it back to the client.
+     */
+    public function decideCancelStrategy($order, bool $isGuest): array
+    {
+        $hasShippingAddress = (bool) $order->shipping_address && ! $this->isPlaceholderAddress($order->shipping_address);
+
+        if ($isGuest) {
+            return [
+                'action'   => 'cancel',
+                'reason'   => 'guest',
+                'reactivate_cart' => $this->resolveOldCartId($order),
+            ];
+        }
+
+        if ($hasShippingAddress) {
+            return [
+                'action' => 'keep_pending',
+                'reason' => 'customer_has_shipping_address',
+            ];
+        }
+
+        $strategy = (string) config('bagistoapi.express_checkout.cancel_without_address', 'cancel');
+
+        Event::dispatch('bagistoapi.express.cancel.no-address', [
+            'order'    => $order,
+            'strategy' => $strategy,
+        ]);
+
+        return [
+            'action' => $strategy === 'keep_pending' ? 'keep_pending' : 'cancel',
+            'reason' => 'customer_no_shipping_address',
+        ];
+    }
+
+    /*
+    |--------------------------------------------------------------------------
+    | Internals
+    |--------------------------------------------------------------------------
+    */
+
+    /**
+     * Express validations: just the bare minimum to avoid cart/empty
+     * orders. Address, email and shipping checks are skipped on
+     * purpose - those come from the gateway success callback.
+     */
+    protected function validateExpressInitiation($cart, PaymentInitiateInput $input): void
+    {
+        if (auth()->guard('customer')->check()) {
+            $customer = auth()->guard('customer')->user();
+            if ($customer && $customer->is_suspended) {
+                throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.account-suspended'));
+            }
+            if ($customer && ! $customer->status) {
+                throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.account-inactive'));
+            }
+        }
+
+        $minimumOrderAmount = core()->getConfigData('sales.order_settings.minimum_order.minimum_order_amount') ?: 0;
+        if (! Cart::haveMinimumOrderAmount()) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.minimum-order-not-met', ['amount' => core()->currency($minimumOrderAmount)]));
+        }
+    }
+
+    /**
+     * Mirror the existing CheckoutProcessor::validateOrderCreation but
+     * scoped to initiate. Keeps strict parity with the legacy mutation.
+     */
+    protected function validateStandardInitiation($cart, PaymentInitiateInput $input): void
+    {
+        if (auth()->guard('customer')->check()) {
+            $customer = auth()->guard('customer')->user();
+            if ($customer && $customer->is_suspended) {
+                throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.account-suspended'));
+            }
+            if ($customer && ! $customer->status) {
+                throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.account-inactive'));
+            }
+        }
+
+        $minimumOrderAmount = core()->getConfigData('sales.order_settings.minimum_order.minimum_order_amount') ?: 0;
+        if (! Cart::haveMinimumOrderAmount()) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.minimum-order-not-met', ['amount' => core()->currency($minimumOrderAmount)]));
+        }
+
+        $hasBillingAddress = $input->billingAddress || $cart->billing_address()->exists();
+        if (! $hasBillingAddress) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.billing-address-required'));
+        }
+
+        $hasShippingAddress = $input->shippingAddress || $input->useForShipping || $cart->shipping_address()->exists();
+        if (! $hasShippingAddress && $cart->haveStockableItems()) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.shipping-address-required'));
+        }
+
+        $hasEmail = $cart->customer_email || $input->billingEmail || ($cart->billing_address && $cart->billing_address->email);
+        if (! $hasEmail) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.email-required'));
+        }
+
+        if ($cart->haveStockableItems()) {
+            $hasShippingMethod = $input->shippingMethod || $cart->shipping_method;
+            if (! $hasShippingMethod) {
+                throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.shipping-method-required'));
+            }
+
+            if (! $cart->selected_shipping_rate) {
+                throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.invalid-shipping-method'));
+            }
+        }
+    }
+
+    /**
+     * Currently we only know how to create a PayPal smart-button order.
+     * Returning null is fine - other gateways may have a redirect-only
+     * flow that does not need a pre-flight order id.
+     */
+    protected function createGatewayOrder($cart, $order, PaymentInitiateInput $input, bool $express): ?string
+    {
+        $method = $cart->payment?->method ?? $order->payment?->method;
+
+        if ($method !== 'paypal_smart_button') {
+            return null;
+        }
+
+        $smartButton = app(SmartButton::class);
+        $amount = $express
+            ? max(0, (float) ($cart->sub_total ?? 0) - (float) ($cart->discount_amount ?? 0))
+            : (float) ($order->grand_total ?? $cart->sub_total ?? 0);
+
+        $response = $smartButton->createOrder([
+            'intent'         => 'CAPTURE',
+            'purchase_units' => [[
+                'reference_id' => (string) $cart->id,
+                'amount'       => [
+                    'currency_code' => $cart->cart_currency_code,
+                    'value'         => $smartButton->formatCurrencyValue($amount),
+                ],
+            ]],
+        ]);
+
+        $gatewayOrderId = $response->result->id ?? null;
+
+        if (! $gatewayOrderId) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.payment.gateway-create-failed'));
+        }
+
+        return (string) $gatewayOrderId;
+    }
+
+    /**
+     * Variant used by replay() where there's no Cart - amount comes
+     * straight from the existing order.
+     */
+    protected function createGatewayOrderForOrder($order): ?string
+    {
+        $method = $order->payment?->method;
+
+        if ($method !== 'paypal_smart_button') {
+            return null;
+        }
+
+        $smartButton = app(SmartButton::class);
+
+        $response = $smartButton->createOrder([
+            'intent'         => 'CAPTURE',
+            'purchase_units' => [[
+                'reference_id' => (string) $order->id,
+                'amount'       => [
+                    'currency_code' => $order->cart_currency_code ?? $order->order_currency_code,
+                    'value'         => $smartButton->formatCurrencyValue((float) $order->grand_total),
+                ],
+            ]],
+        ]);
+
+        $gatewayOrderId = $response->result->id ?? null;
+
+        if (! $gatewayOrderId) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.payment.gateway-create-failed'));
+        }
+
+        return (string) $gatewayOrderId;
+    }
+
+    /**
+     * Persist the gateway order id + express flag + old cart id onto
+     * order_payment.additional so callbacks/reconciliation have all the
+     * context they need.
+     */
+    protected function stampPaymentAdditional($order, ?string $gatewayOrderId, bool $express, int $oldCartId, ?string $oldCartToken): void
+    {
+        if (! $order->payment) {
+            return;
+        }
+
+        $additional = $order->payment->additional ?? [];
+
+        if ($gatewayOrderId) {
+            $additional['paypal_order_id'] = $gatewayOrderId;
+            $additional['gateway_order_id'] = $gatewayOrderId;
+        }
+
+        $additional['express'] = $express;
+        $additional['cart_id'] = $oldCartId;
+
+        if ($oldCartToken) {
+            $additional['cart_token'] = $oldCartToken;
+        }
+
+        $order->payment->additional = $additional;
+        $order->payment->save();
+    }
+
+    protected function writeGatewayOrderId($order, ?string $gatewayOrderId): void
+    {
+        if (! $order->payment || ! $gatewayOrderId) {
+            return;
+        }
+
+        $additional = $order->payment->additional ?? [];
+        $additional['paypal_order_id'] = $gatewayOrderId;
+        $additional['gateway_order_id'] = $gatewayOrderId;
+        $order->payment->additional = $additional;
+        $order->payment->save();
+    }
+
+    protected function scheduleReconciliation($order): void
+    {
+        if (! config('bagistoapi.reconcile.enabled', true)) {
+            return;
+        }
+
+        $delay = (int) config('bagistoapi.reconcile.delay_minutes', 15);
+        $queue = (string) config('bagistoapi.reconcile.queue', 'payment-reconcile');
+
+        try {
+            $job = (new ReconcilePendingPaymentJob($order->id))
+                ->onQueue($queue)
+                ->delay(now()->addMinutes($delay));
+
+            dispatch($job);
+        } catch (\Throwable $e) {
+            Log::warning('Failed to enqueue ReconcilePendingPaymentJob: '.$e->getMessage(), [
+                'order_id' => $order->id,
+            ]);
+        }
+    }
+
+    /**
+     * Success branch: capture the gateway order, verify the captured
+     * amount, fill in express addresses, flip the order to processing
+     * and generate the invoice + settled transaction record.
+     *
+     * Idempotent: a second (possibly concurrent) success callback for an
+     * order that is already past pending short-circuits to success
+     * without re-capturing. Concurrency is serialized via a row lock.
+     */
+    protected function handleSuccess($order, PaymentCallbackInput $input): array
+    {
+        if (! $this->isPending($order)) {
+            return $this->successResponse($order, 'already_processed');
+        }
+
+        $additional = $order->payment?->additional ?? [];
+        $isExpress = ! empty($additional['express']);
+        $method = $order->payment?->method;
+        $gatewayOrderId = $input->gatewayOrderId ?: ($additional['paypal_order_id'] ?? null);
+
+        try {
+            $result = DB::transaction(function () use ($order, $input, $isExpress, $method, $gatewayOrderId) {
+                /*
+                 * Lock the order row so concurrent success callbacks
+                 * serialize here; the second one will see PROCESSING
+                 * after acquiring the lock and short-circuit below.
+                 */
+                $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')];
+                }
+
+                $capture = null;
+
+                if ($method === 'paypal_smart_button') {
+                    if (! $gatewayOrderId) {
+                        throw new OperationFailedException(__('bagistoapi::app.graphql.payment.gateway-order-id-required'));
+                    }
+
+                    $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')];
+            });
+        } catch (OperationFailedException $e) {
+            /*
+             * Recorded outside the rolled-back transaction so the audit
+             * trail survives the failure.
+             */
+            $this->recordAttempt([
+                'order_id'         => $order->id,
+                'cart_id'          => $this->resolveOldCartId($order),
+                'payment_method'   => $method,
+                'gateway_order_id' => $gatewayOrderId,
+                'action'           => PaymentAttempt::ACTION_CALLBACK,
+                '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_CALLBACK,
+                'status'           => PaymentAttempt::STATUS_CAPTURED,
+                'amount'           => (float) ($order->grand_total ?? 0),
+                'currency'         => $order->order_currency_code ?? $order->cart_currency_code,
+                'express'          => $isExpress,
+                'idempotency_key'  => $gatewayOrderId ? 'capture:'.$gatewayOrderId : null,
+                'response_payload' => $result['capture'] ?? null,
+            ]);
+
+            Event::dispatch('bagistoapi.payment.success', $result['response']['order']);
+        }
+
+        return $result['response'];
+    }
+
+    /**
+     * Cancel/failure branch: keep the order in PENDING and let the
+     * caller decide whether to actually cancel it via the dedicated
+     * cancelOrder mutation. This keeps cancel logic in one place.
+     */
+    protected function handleCancel($order, string $reason): array
+    {
+        $this->recordAttempt([
+            'order_id'         => $order->id,
+            'cart_id'          => $this->resolveOldCartId($order),
+            'payment_method'   => $order->payment?->method,
+            'gateway_order_id' => $this->gatewayOrderIdFromOrder($order),
+            'action'           => PaymentAttempt::ACTION_CALLBACK,
+            'status'           => $reason === 'cancelled' ? PaymentAttempt::STATUS_CANCELLED : PaymentAttempt::STATUS_FAILED,
+            'amount'           => (float) ($order->grand_total ?? 0),
+            'currency'         => $order->order_currency_code ?? $order->cart_currency_code,
+            'express'          => $this->expressFlag($order),
+        ]);
+
+        Event::dispatch('bagistoapi.payment.cancelled', ['order' => $order, 'reason' => $reason]);
+
+        return [
+            'order'         => $order,
+            'status'        => $reason,
+            'gatewayStatus' => $reason,
+            'message'       => __('bagistoapi::app.graphql.payment.'.$reason),
+        ];
+    }
+
+    /**
+     * Capture the PayPal order then assert the gateway reported a
+     * completed capture whose amount/currency match the Bagisto order.
+     * Returns a normalized capture array for persistence/audit.
+     */
+    protected function captureAndVerify($order, string $gatewayOrderId): array
+    {
+        try {
+            $response = app(SmartButton::class)->captureOrder($gatewayOrderId);
+        } catch (\Throwable $e) {
+            Log::error('PayPal capture failed: '.$e->getMessage(), [
+                'order_id'         => $order->id,
+                'gateway_order_id' => $gatewayOrderId,
+            ]);
+
+            throw new OperationFailedException(__('bagistoapi::app.graphql.payment.gateway-capture-failed'));
+        }
+
+        $capture = $this->extractCapture($response);
+
+        $orderStatus = strtoupper((string) ($capture['order_status'] ?? ''));
+        $captureStatus = strtoupper((string) ($capture['capture_status'] ?? ''));
+
+        $completed = in_array($orderStatus, ['COMPLETED', 'CAPTURED'], true)
+            || in_array($captureStatus, ['COMPLETED', 'CAPTURED'], true);
+
+        if (! $completed) {
+            Event::dispatch('bagistoapi.payment.capture-not-completed', ['order' => $order, 'capture' => $capture]);
+
+            Log::error('PayPal capture not completed', [
+                'order_id'       => $order->id,
+                'order_status'   => $orderStatus,
+                'capture_status' => $captureStatus,
+            ]);
+
+            throw new OperationFailedException(__('bagistoapi::app.graphql.payment.capture-not-completed'));
+        }
+
+        $this->assertAmountMatches($order, $capture);
+
+        return $capture;
+    }
+
+    /**
+     * Normalize a PayPal capture response into a flat array.
+     */
+    protected function extractCapture($response): array
+    {
+        $data = json_decode(json_encode($response), true) ?: [];
+        $result = $data['result'] ?? [];
+
+        $purchaseUnit = $result['purchase_units'][0] ?? [];
+        $captureNode = $purchaseUnit['payments']['captures'][0] ?? [];
+
+        $amount = $captureNode['amount']['value'] ?? $purchaseUnit['amount']['value'] ?? null;
+        $currency = $captureNode['amount']['currency_code'] ?? $purchaseUnit['amount']['currency_code'] ?? null;
+
+        return [
+            'transaction_id'   => $captureNode['id'] ?? $result['id'] ?? null,
+            'gateway_order_id' => $result['id'] ?? null,
+            'order_status'     => $result['status'] ?? null,
+            'capture_status'   => $captureNode['status'] ?? null,
+            'intent'           => $result['intent'] ?? 'CAPTURE',
+            'amount'           => $amount !== null ? (float) $amount : null,
+            'currency'         => $currency,
+            'raw'              => $result,
+        ];
+    }
+
+    /**
+     * Guard against an under-payment / wrong-currency capture before we
+     * mark the order as paid.
+     */
+    protected function assertAmountMatches($order, array $capture): void
+    {
+        $expected = round((float) ($order->grand_total ?? 0), 2);
+        $captured = isset($capture['amount']) && $capture['amount'] !== null
+            ? round((float) $capture['amount'], 2)
+            : null;
+
+        $expectedCurrency = strtoupper((string) ($order->order_currency_code ?? $order->cart_currency_code ?? ''));
+        $capturedCurrency = strtoupper((string) ($capture['currency'] ?? ''));
+
+        $amountOk = $captured !== null && abs($captured - $expected) < 0.01;
+        $currencyOk = $capturedCurrency === '' || $expectedCurrency === '' || $capturedCurrency === $expectedCurrency;
+
+        if ($amountOk && $currencyOk) {
+            return;
+        }
+
+        Event::dispatch('bagistoapi.payment.amount-mismatch', [
+            'order'    => $order,
+            'expected' => $expected,
+            'captured' => $captured,
+            'capture'  => $capture,
+        ]);
+
+        Log::error('PayPal capture amount mismatch', [
+            'order_id'          => $order->id,
+            'expected'          => $expected,
+            'captured'          => $captured,
+            'expected_currency' => $expectedCurrency,
+            'captured_currency' => $capturedCurrency,
+        ]);
+
+        throw new OperationFailedException(__('bagistoapi::app.graphql.payment.amount-mismatch'));
+    }
+
+    /**
+     * Create the invoice for a fully-captured order, mirroring the
+     * native PayPal SmartButtonController behaviour.
+     */
+    protected function createInvoiceIfPossible($order)
+    {
+        if (! $order->canInvoice()) {
+            return null;
+        }
+
+        $invoiceData = ['order_id' => $order->id];
+
+        foreach ($order->items as $item) {
+            $invoiceData['invoice']['items'][$item->id] = $item->qty_to_invoice;
+        }
+
+        return $this->invoiceRepository->create($invoiceData);
+    }
+
+    /**
+     * Persist the settled transaction. Unlike payment_attempts, this row
+     * is tied to an invoice and represents money actually received.
+     */
+    protected function recordOrderTransaction($order, $invoice, array $capture): void
+    {
+        if (! $invoice || empty($capture['transaction_id'])) {
+            return;
+        }
+
+        try {
+            $this->orderTransactionRepository->create([
+                'transaction_id' => (string) $capture['transaction_id'],
+                'status'         => $capture['capture_status'] ?? $capture['order_status'] ?? null,
+                'type'           => $capture['intent'] ?? 'CAPTURE',
+                'amount'         => $capture['amount'] ?? $order->grand_total,
+                'payment_method' => $order->payment?->method,
+                'order_id'       => $order->id,
+                'invoice_id'     => $invoice->id,
+                'data'           => json_encode($capture['raw'] ?? $capture),
+            ]);
+        } catch (\Throwable $e) {
+            Log::warning('Failed to record order transaction: '.$e->getMessage(), [
+                'order_id' => $order->id,
+            ]);
+        }
+    }
+
+    /**
+     * Store the gateway transaction id on order_payment.additional for
+     * later refunds (avoids a round-trip to re-fetch the capture id).
+     */
+    protected function writeTransactionId($order, string $transactionId): void
+    {
+        if (! $order->payment) {
+            return;
+        }
+
+        $additional = $order->payment->additional ?? [];
+        $additional['transaction_id'] = $transactionId;
+        $order->payment->additional = $additional;
+        $order->payment->save();
+    }
+
+    /**
+     * Persist a payment_attempts row. Never let an audit-write failure
+     * break the payment flow.
+     */
+    protected function recordAttempt(array $data): ?PaymentAttempt
+    {
+        try {
+            return $this->paymentAttemptRepository->create($data);
+        } catch (\Throwable $e) {
+            Log::warning('Failed to record payment attempt: '.$e->getMessage(), [
+                'order_id' => $data['order_id'] ?? null,
+                'action'   => $data['action'] ?? null,
+            ]);
+
+            return null;
+        }
+    }
+
+    protected function isPending($order): bool
+    {
+        return in_array($order->status, [Order::STATUS_PENDING, Order::STATUS_PENDING_PAYMENT], true);
+    }
+
+    protected function successResponse($order, string $gatewayStatus): array
+    {
+        return [
+            'order'         => $order,
+            'status'        => 'success',
+            'gatewayStatus' => $gatewayStatus,
+            'message'       => __('bagistoapi::app.graphql.payment.success'),
+        ];
+    }
+
+    protected function gatewayOrderIdFromOrder($order): ?string
+    {
+        $additional = $order->payment?->additional ?? [];
+
+        return $additional['paypal_order_id'] ?? $additional['gateway_order_id'] ?? null;
+    }
+
+    protected function expressFlag($order): bool
+    {
+        $additional = $order->payment?->additional ?? [];
+
+        return ! empty($additional['express']);
+    }
+
+    /**
+     * Replace the placeholder addresses with the real ones we got back
+     * from the gateway. Only used for express orders.
+     */
+    protected function fillAddressesFromCallback($order, PaymentCallbackInput $input): void
+    {
+        $billing = $this->buildAddressPayloadFromCallback($input, 'billing');
+        $shipping = $this->buildAddressPayloadFromCallback($input, 'shipping');
+
+        if ($shipping) {
+            $order->shipping_address?->update($shipping);
+        }
+
+        if ($billing) {
+            $order->billing_address?->update($billing);
+        }
+
+        if (! empty($billing['email'])) {
+            $order->customer_email = $billing['email'];
+        }
+        if (! empty($billing['first_name'])) {
+            $order->customer_first_name = $billing['first_name'];
+        }
+        if (! empty($billing['last_name'])) {
+            $order->customer_last_name = $billing['last_name'];
+        }
+
+        $order->save();
+    }
+
+    /**
+     * Pull billing/shipping fields off the callback DTO and translate
+     * them to the snake_case column names used by OrderAddress.
+     */
+    protected function buildAddressPayloadFromCallback(PaymentCallbackInput $input, string $type): array
+    {
+        $fields = [
+            'first_name' => $type.'FirstName',
+            'last_name'  => $type.'LastName',
+            'email'      => $type.'Email',
+            'address'    => $type.'Address',
+            'country'    => $type.'Country',
+            'state'      => $type.'State',
+            'city'       => $type.'City',
+            'postcode'   => $type.'Postcode',
+            'phone'      => $type.'PhoneNumber',
+        ];
+
+        $payload = [];
+        foreach ($fields as $column => $property) {
+            $value = $input->{$property} ?? null;
+            if ($value !== null && $value !== '') {
+                $payload[$column] = $value;
+            }
+        }
+
+        return $payload;
+    }
+
+    /**
+     * Heuristic check: the placeholder city/postcode is unlikely to
+     * collide with a real address; we use this to decide whether the
+     * express order really has a usable shipping address.
+     */
+    protected function isPlaceholderAddress($address): bool
+    {
+        $placeholder = (array) config('bagistoapi.express_checkout.placeholder_address', []);
+
+        if (empty($placeholder)) {
+            return false;
+        }
+
+        return ($address->city ?? null) === ($placeholder['city'] ?? null)
+            && ($address->postcode ?? null) === ($placeholder['postcode'] ?? null);
+    }
+
+    protected function resolveOldCartId($order): ?int
+    {
+        $additional = $order->payment?->additional ?? [];
+
+        $cartId = $additional['cart_id'] ?? null;
+
+        return $cartId ? (int) $cartId : null;
+    }
+}

+ 109 - 21
packages/Webkul/BagistoApi/src/State/CancelOrderProcessor.php

@@ -5,33 +5,42 @@ namespace Webkul\BagistoApi\State;
 use ApiPlatform\Metadata\Operation;
 use ApiPlatform\State\ProcessorInterface;
 use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Event;
 use Illuminate\Support\Facades\Request;
 use Webkul\BagistoApi\Dto\CancelOrderInput;
 use Webkul\BagistoApi\Exception\AuthorizationException;
 use Webkul\BagistoApi\Exception\InvalidInputException;
 use Webkul\BagistoApi\Exception\ResourceNotFoundException;
+use Webkul\BagistoApi\Facades\CartTokenFacade;
+use Webkul\BagistoApi\Facades\TokenHeaderFacade;
 use Webkul\BagistoApi\Models\CancelOrder;
+use Webkul\BagistoApi\Services\PaymentService;
 use Webkul\Sales\Repositories\OrderRepository;
 
 /**
  * CancelOrderProcessor — Handles the cancel order mutation
  *
- * Delegates to Bagisto's OrderRepository::cancel() which:
- * - Checks $order->canCancel() (items with qty_to_cancel > 0, status not closed/fraud)
- * - Dispatches sales.order.cancel.before / after events
- * - Returns inventory to stock
- * - Updates order status
+ * Strategy:
+ *  - Guest cancel: cancel immediately + reactivate the original cart so
+ *    the buyer can keep going if they change their mind.
+ *  - Customer with a real shipping address: keep order PENDING so the
+ *    frontend can call paymentReplay to retry the payment.
+ *  - Customer without a usable shipping address (express flow): follow
+ *    the configured `bagistoapi.express_checkout.cancel_without_address`
+ *    strategy and dispatch `bagistoapi.express.cancel.no-address` for
+ *    downstream listeners.
+ *
+ * The processor still works for the legacy customer-only flow because
+ * a customer with stockable items always has a real shipping address.
  */
 class CancelOrderProcessor implements ProcessorInterface
 {
     public function __construct(
         private readonly ProcessorInterface $persistProcessor,
         private readonly OrderRepository $orderRepository,
+        private readonly PaymentService $paymentService,
     ) {}
 
-    /**
-     * Process the cancel order operation
-     */
     public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
     {
         if ($data instanceof CancelOrderInput) {
@@ -43,23 +52,13 @@ class CancelOrderProcessor implements ProcessorInterface
         return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
     }
 
-    /**
-     * Cancel the order for the authenticated customer
-     */
     private function handleCancel(CancelOrderInput $input): CancelOrder
     {
-        $customer = Auth::guard('sanctum')->user();
-
-        if (! $customer) {
-            throw new AuthorizationException(__('bagistoapi::app.graphql.logout.unauthenticated'));
-        }
-
         if (empty($input->orderId)) {
             throw new InvalidInputException(__('bagistoapi::app.graphql.cancel-order.order-id-required'));
         }
 
-        /** Find order scoped to the authenticated customer */
-        $order = $customer->orders()->find($input->orderId);
+        [$order, $isGuest] = $this->resolveOrderAndPrincipal((int) $input->orderId);
 
         if (! $order) {
             throw new ResourceNotFoundException(
@@ -67,10 +66,28 @@ class CancelOrderProcessor implements ProcessorInterface
             );
         }
 
-        /** Delegate to Bagisto's core cancel logic */
+        $strategy = $this->paymentService->decideCancelStrategy($order, $isGuest);
+
+        if ($strategy['action'] === 'keep_pending') {
+            Event::dispatch('bagistoapi.order.cancel.kept-pending', [
+                'order'  => $order,
+                'reason' => $strategy['reason'],
+            ]);
+
+            return new CancelOrder(
+                success: true,
+                message: __('bagistoapi::app.graphql.cancel-order.kept-pending'),
+                orderId: $order->id,
+                status: $order->status,
+            );
+        }
+
         $result = $this->orderRepository->cancel($order);
 
-        /** Refresh the order to get updated status */
+        if ($result && ! empty($strategy['reactivate_cart'])) {
+            CartTokenFacade::reactivateCart((int) $strategy['reactivate_cart']);
+        }
+
         $order->refresh();
 
         if ($result) {
@@ -90,6 +107,77 @@ class CancelOrderProcessor implements ProcessorInterface
         );
     }
 
+    /**
+     * Look up the order by id and determine whether the caller is a
+     * logged-in customer or an anonymous guest holding the cart token
+     * that paid for the order.
+     *
+     * Returns [order|null, isGuest].
+     */
+    private function resolveOrderAndPrincipal(int $orderId): array
+    {
+        $customer = Auth::guard('sanctum')->user();
+
+        if ($customer) {
+            $order = $customer->orders()->find($orderId);
+
+            return [$order, false];
+        }
+
+        $request = Request::instance();
+        $token = $request ? TokenHeaderFacade::getAuthorizationBearerToken($request) : null;
+
+        if (! $token) {
+            throw new AuthorizationException(__('bagistoapi::app.graphql.logout.unauthenticated'));
+        }
+
+        $tokenType = CartTokenFacade::getTokenType($token);
+
+        if ($tokenType === 'customer') {
+            $customer = CartTokenFacade::getCustomerByToken($token);
+            $order = $customer ? $customer->orders()->find($orderId) : null;
+
+            return [$order, false];
+        }
+
+        if ($tokenType !== 'guest') {
+            throw new AuthorizationException(__('bagistoapi::app.graphql.logout.unauthenticated'));
+        }
+
+        $order = $this->orderRepository->find($orderId);
+
+        if ($order && $this->guestTokenOwnsOrder($token, $order)) {
+            return [$order, true];
+        }
+
+        throw new AuthorizationException(__('bagistoapi::app.graphql.cancel-order.not-found', ['id' => $orderId]));
+    }
+
+    /**
+     * Allow a guest to cancel only orders they actually paid for. We
+     * reuse the cart_token we stamped onto payment.additional at
+     * initiation time.
+     */
+    private function guestTokenOwnsOrder(string $token, $order): bool
+    {
+        $additional = $order->payment?->additional ?? [];
+        $expectedToken = $additional['cart_token'] ?? null;
+
+        if ($expectedToken && hash_equals((string) $expectedToken, $token)) {
+            return true;
+        }
+
+        $tokenRecord = CartTokenFacade::getGuestTokenRecord($token);
+
+        if (! $tokenRecord) {
+            return false;
+        }
+
+        $expectedCartId = $additional['cart_id'] ?? null;
+
+        return $expectedCartId && (int) $tokenRecord->cart_id === (int) $expectedCartId;
+    }
+
     /**
      * GraphQL input can reach the processor in slightly different shapes depending on
      * whether the client sends variables or an inline literal. Normalize those shapes

+ 117 - 156
packages/Webkul/BagistoApi/src/State/CheckoutProcessor.php

@@ -4,21 +4,20 @@ namespace Webkul\BagistoApi\State;
 
 use ApiPlatform\Metadata\Operation;
 use ApiPlatform\State\ProcessorInterface;
-use Illuminate\Support\Facades\Event;
 use Illuminate\Support\Facades\Request;
 use Webkul\BagistoApi\Dto\CartData;
 use Webkul\BagistoApi\Dto\CheckoutAddressInput;
 use Webkul\BagistoApi\Dto\CheckoutAddressOutput;
+use Webkul\BagistoApi\Dto\PaymentInitiateInput;
 use Webkul\BagistoApi\Exception\AuthenticationException;
 use Webkul\BagistoApi\Exception\OperationFailedException;
 use Webkul\BagistoApi\Exception\ResourceNotFoundException;
 use Webkul\BagistoApi\Facades\CartTokenFacade;
 use Webkul\BagistoApi\Facades\TokenHeaderFacade;
+use Webkul\BagistoApi\Services\PaymentService;
 use Webkul\Checkout\Facades\Cart;
-use Webkul\Checkout\Models\CartAddress;
 use Webkul\Checkout\Repositories\CartRepository;
 use Webkul\Customer\Repositories\CustomerRepository;
-use Webkul\Paypal\Payment\SmartButton;
 use Webkul\Sales\Repositories\OrderRepository;
 
 /**
@@ -29,7 +28,8 @@ class CheckoutProcessor implements ProcessorInterface
     public function __construct(
         protected CustomerRepository $customerRepository,
         protected OrderRepository $orderRepository,
-        protected CartRepository $cartRepository
+        protected CartRepository $cartRepository,
+        protected ?PaymentService $paymentService = null,
     ) {}
 
     /**
@@ -126,72 +126,18 @@ class CheckoutProcessor implements ProcessorInterface
                 }
             }
 
-            $billingAddress = null;
-            $shippingAddress = null;
-
-            $cart->billing_address()->delete();
-            $cart->shipping_address()->delete();
-
-            if ($input->billingFirstName || $input->billingAddress) {
-                $billingAddress = new CartAddress;
-                $billingAddress->cart_id = $cart->id;
-                $billingAddress->address_type = CartAddress::ADDRESS_TYPE_BILLING;
-                $billingAddress->first_name = $input->billingFirstName;
-                $billingAddress->last_name = $input->billingLastName;
-                $billingAddress->email = $input->billingEmail;
-                $billingAddress->company_name = $input->billingCompanyName;
-                $billingAddress->address = $input->billingAddress;
-                $billingAddress->country = $input->billingCountry;
-                $billingAddress->state = $input->billingState;
-                $billingAddress->city = $input->billingCity;
-                $billingAddress->postcode = $input->billingPostcode;
-                $billingAddress->phone = $input->billingPhoneNumber;
-                $billingAddress->use_for_shipping = $input->useForShipping;
-                $billingAddress->save();
-
-                if ($input->billingEmail && ! $cart->customer_email) {
-                    $cart->customer_email = $input->billingEmail;
-                    $cart->save();
-                }
-            }
+            cart()->setCart($cart);
+            Cart::saveAddresses($this->buildAddressPayload($input));
 
-            if ($input->useForShipping && $billingAddress !== null) {
-                $shippingAddress = new CartAddress;
-                $shippingAddress->cart_id = $cart->id;
-                $shippingAddress->address_type = CartAddress::ADDRESS_TYPE_SHIPPING;
-                $shippingAddress->first_name = $input->billingFirstName;
-                $shippingAddress->last_name = $input->billingLastName;
-                $shippingAddress->email = $input->billingEmail;
-                $shippingAddress->company_name = $input->billingCompanyName;
-                $shippingAddress->address = $input->billingAddress;
-                $shippingAddress->country = $input->billingCountry;
-                $shippingAddress->state = $input->billingState;
-                $shippingAddress->city = $input->billingCity;
-                $shippingAddress->postcode = $input->billingPostcode;
-                $shippingAddress->phone = $input->billingPhoneNumber;
-                $shippingAddress->save();
-            } elseif ($input->shippingFirstName || $input->shippingAddress) {
-                $shippingAddress = new CartAddress;
-                $shippingAddress->cart_id = $cart->id;
-                $shippingAddress->address_type = CartAddress::ADDRESS_TYPE_SHIPPING;
-                $shippingAddress->first_name = $input->shippingFirstName;
-                $shippingAddress->last_name = $input->shippingLastName;
-                $shippingAddress->email = $input->shippingEmail;
-                $shippingAddress->company_name = $input->shippingCompanyName;
-                $shippingAddress->address = $input->shippingAddress;
-                $shippingAddress->country = $input->shippingCountry;
-                $shippingAddress->state = $input->shippingState;
-                $shippingAddress->city = $input->shippingCity;
-                $shippingAddress->postcode = $input->shippingPostcode;
-                $shippingAddress->phone = $input->shippingPhoneNumber;
-                $shippingAddress->save();
-            }
+            $cart->refresh();
+            $billingAddress = $cart->billing_address;
+            $shippingAddress = $cart->shipping_address;
 
             if (! $billingAddress) {
                 throw new OperationFailedException('No billing address was provided');
             }
 
-            \Webkul\Checkout\Facades\Cart::collectTotals();
+            Cart::collectTotals();
 
             if ($cart->haveStockableItems()) {
                 \Webkul\Shipping\Facades\Shipping::collectRates();
@@ -203,6 +149,60 @@ class CheckoutProcessor implements ProcessorInterface
         }
     }
 
+    /**
+     * Build payload expected by Cart::saveAddresses from GraphQL input.
+     */
+    private function buildAddressPayload(CheckoutAddressInput $input): array
+    {
+        $payload = [
+            'billing' => [
+                'first_name'       => $input->billingFirstName,
+                'last_name'        => $input->billingLastName,
+                'email'            => $input->billingEmail,
+                'company_name'     => $input->billingCompanyName,
+                'address'          => $this->normalizeAddressLines($input->billingAddress),
+                'country'          => $input->billingCountry,
+                'state'            => $input->billingState,
+                'city'             => $input->billingCity,
+                'postcode'         => $input->billingPostcode,
+                'phone'            => $input->billingPhoneNumber,
+                'use_for_shipping' => (bool) $input->useForShipping,
+            ],
+        ];
+
+        if (! $input->useForShipping) {
+            $payload['shipping'] = [
+                'first_name'   => $input->shippingFirstName,
+                'last_name'    => $input->shippingLastName,
+                'email'        => $input->shippingEmail,
+                'company_name' => $input->shippingCompanyName,
+                'address'      => $this->normalizeAddressLines($input->shippingAddress),
+                'country'      => $input->shippingCountry,
+                'state'        => $input->shippingState,
+                'city'         => $input->shippingCity,
+                'postcode'     => $input->shippingPostcode,
+                'phone'        => $input->shippingPhoneNumber,
+            ];
+        }
+
+        return $payload;
+    }
+
+    /**
+     * Convert textarea-like address string into Cart::saveAddresses line array.
+     */
+    private function normalizeAddressLines(?string $address): array
+    {
+        if ($address === null) {
+            return [];
+        }
+
+        $lines = preg_split('/\r\n|\r|\n/', $address) ?: [];
+        $lines = array_values(array_filter(array_map('trim', $lines), static fn ($line) => $line !== ''));
+
+        return $lines ?: [''];
+    }
+
     /**
      * Save shipping method for cart.
      */
@@ -312,118 +312,79 @@ class CheckoutProcessor implements ProcessorInterface
 
     /**
      * Create order from cart data.
+     *
+     * Kept for backwards compatibility — the actual implementation now
+     * lives in `PaymentService::initiate()` so the unified payment
+     * initiation/replay/callback pipeline can share the same logic.
      */
     private function createOrder($cart, CheckoutAddressInput $input)
     {
         try {
-            $this->validateOrderCreation($cart, $input);
-
-            Cart::setCart($cart);
-            Cart::collectTotals();
+            $paymentService = $this->paymentService ?: app(PaymentService::class);
 
-            $paymentTransactionId = $this->resolvePaymentTransactionId($cart);
-
-            $orderData = $this->buildOrderDataFromCart($cart);
-            $order = $this->orderRepository->create($orderData);
-
-            $createdOrderId = data_get($order, 'id');
-
-            if (! $order || ! $createdOrderId) {
-                throw new \Exception(__('bagistoapi::app.graphql.checkout.order-creation-failed'));
-            }
+            $result = $paymentService->initiate($cart, $this->toInitiateInput($input));
 
-            $orderId = $createdOrderId;
-            $order = $this->orderRepository->find($orderId);
+            $order = $result['order'];
 
-            if (! $order) {
-                throw new \Exception(__('bagistoapi::app.graphql.checkout.order-retrieval-failed', ['orderId' => $orderId]));
-            }
-
-            if ($paymentTransactionId && $order->payment) {
-                $additionalData = $order->payment->additional ?? [];
-                $additionalData['paypal_order_id'] = $paymentTransactionId;
-
-                $order->payment->additional = $additionalData;
-                $order->payment->save();
-            }
-
-            Cart::deActivateCart();
-
-            // Dispatch event for order creation (for push notifications)
-            Event::dispatch('order.created.after', $order);
-
-            $response = (object) [
-                'id'        => $cart->id,
-                'cartToken' => (string) ($cart->guest_cart_token ?? $cart->customer_id),
-                'orderId'   => (string) $order->id,
-                'paymentTransactionId' => $paymentTransactionId,
+            return (object) [
+                'id'                   => $cart->id,
+                'cartToken'            => (string) ($cart->guest_cart_token ?? $cart->customer_id),
+                'orderId'              => (string) $order->id,
+                'paymentTransactionId' => $result['gatewayOrderId'],
             ];
-
-            return $response;
         } catch (\Exception $e) {
             throw new OperationFailedException($e->getMessage(), 0, $e);
         }
     }
 
     /**
-     * Build order data from cart.
+     * Map the legacy CheckoutAddressInput onto the new PaymentInitiateInput.
+     * This keeps `checkoutOrderCreate` byte-for-byte compatible while the
+     * actual logic moves to PaymentService.
      */
-    private function buildOrderDataFromCart($cart): array
+    private function toInitiateInput(CheckoutAddressInput $input): PaymentInitiateInput
     {
-        $orderResource = new \Webkul\Sales\Transformers\OrderResource($cart);
-
-        return $orderResource->jsonSerialize();
-    }
-
-    /**
-     * Resolve payment transaction id for gateway methods.
-     */
-    private function resolvePaymentTransactionId($cart): ?string
-    {
-        $paymentMethod = $cart->payment?->method;
-
-        if ($paymentMethod !== 'paypal_smart_button') {
-            return null;
-        }
-
-        $smartButton = app(SmartButton::class);
-
-        $paypalOrder = $smartButton->createOrder($this->buildPayPalOrderRequestBody($cart, $smartButton));
-
-        $paypalOrderId = $paypalOrder->result->id ?? null;
-
-        if (! $paypalOrderId) {
-            throw new \Exception('Failed to create PayPal order transaction.');
-        }
-
-        return (string) $paypalOrderId;
-    }
-
-    /**
-     * Build PayPal create-order payload for Smart Button.
-     */
-    private function buildPayPalOrderRequestBody($cart, SmartButton $smartButton): array
-    {
-        $shippingAmount = (float) ($cart->selected_shipping_rate?->price ?? 0);
-        $discountAmount = (float) ($cart->discount_amount ?? 0);
-        $subTotal = (float) ($cart->sub_total ?? 0);
-        $taxTotal = (float) ($cart->tax_total ?? 0);
-        $grandTotal = $subTotal + $taxTotal + $shippingAmount - $discountAmount;
-
-        return [
-            'intent'         => 'CAPTURE',
-            'purchase_units' => [[
-                'reference_id' => (string) $cart->id,
-                'amount'       => [
-                    'currency_code' => $cart->cart_currency_code,
-                    'value'         => $smartButton->formatCurrencyValue($grandTotal),
-                ],
-            ]],
-        ];
+        $payload = new PaymentInitiateInput;
+        $payload->expressCheckout = false;
+        $payload->paymentMethod = $input->paymentMethod;
+        $payload->paymentSuccessUrl = $input->paymentSuccessUrl;
+        $payload->paymentFailureUrl = $input->paymentFailureUrl;
+        $payload->paymentCancelUrl = $input->paymentCancelUrl;
+
+        $payload->billingFirstName = $input->billingFirstName;
+        $payload->billingLastName = $input->billingLastName;
+        $payload->billingEmail = $input->billingEmail;
+        $payload->billingCompanyName = $input->billingCompanyName;
+        $payload->billingAddress = $input->billingAddress;
+        $payload->billingCountry = $input->billingCountry;
+        $payload->billingState = $input->billingState;
+        $payload->billingCity = $input->billingCity;
+        $payload->billingPostcode = $input->billingPostcode;
+        $payload->billingPhoneNumber = $input->billingPhoneNumber;
+
+        $payload->shippingFirstName = $input->shippingFirstName;
+        $payload->shippingLastName = $input->shippingLastName;
+        $payload->shippingEmail = $input->shippingEmail;
+        $payload->shippingCompanyName = $input->shippingCompanyName;
+        $payload->shippingAddress = $input->shippingAddress;
+        $payload->shippingCountry = $input->shippingCountry;
+        $payload->shippingState = $input->shippingState;
+        $payload->shippingCity = $input->shippingCity;
+        $payload->shippingPostcode = $input->shippingPostcode;
+        $payload->shippingPhoneNumber = $input->shippingPhoneNumber;
+
+        $payload->useForShipping = $input->useForShipping;
+        $payload->shippingMethod = $input->shippingMethod;
+
+        return $payload;
     }
 
     /**
      * Validate order can be created.
+     *
+     * Retained because tests assert on its error messages; the actual
+     * order creation now runs through PaymentService::initiate which
+     * re-applies the same checks internally.
      */
     private function validateOrderCreation($cart, CheckoutAddressInput $input): void
     {

+ 10 - 16
packages/Webkul/BagistoApi/src/State/CountryStateCollectionProvider.php

@@ -13,7 +13,7 @@ use Webkul\BagistoApi\Models\CountryState;
 /**
  * Collection provider for CountryState
  *
- * Provides cursor-based pagination for country states
+ * Returns all states for a country by default (pagination disabled).
  * - Subresource: /countries/{country_id}/states (country_id provided via URI)
  * - Direct query: countryStates(countryId: 244) (countryId REQUIRED in args for GraphQL and REST)
  */
@@ -42,22 +42,20 @@ class CountryStateCollectionProvider implements ProviderInterface
             );
         }
 
+        $query = CountryState::where('country_id', $countryId)
+            ->with('translations')
+            ->orderBy('id', 'asc');
+
+        if ($this->pagination->isEnabled($operation, $context) === false) {
+            return $query->get();
+        }
+
         $first = isset($args['first']) ? (int) $args['first'] : null;
         $last = isset($args['last']) ? (int) $args['last'] : null;
         $after = $args['after'] ?? null;
         $before = $args['before'] ?? null;
 
-        $defaultPerPage = 10;
-
-        // Determine page size
-        if ($first !== null) {
-            $perPage = $first;
-        } elseif ($last !== null) {
-            $perPage = $last;
-        } else {
-            $perPage = $defaultPerPage;
-        }
-
+        $perPage = $first ?? $last ?? 10;
         $offset = 0;
 
         if ($after) {
@@ -71,10 +69,6 @@ class CountryStateCollectionProvider implements ProviderInterface
             $offset = max(0, $cursor - $perPage);
         }
 
-        $query = CountryState::where('country_id', $countryId)
-            ->with('translations')
-            ->orderBy('id', 'asc');
-
         $total = (clone $query)->count();
 
         if ($offset > $total) {

+ 41 - 0
packages/Webkul/BagistoApi/src/State/PaymentCallbackProcessor.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace Webkul\BagistoApi\State;
+
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\State\ProcessorInterface;
+use Webkul\BagistoApi\Dto\PaymentCallbackInput;
+use Webkul\BagistoApi\Exception\OperationFailedException;
+use Webkul\BagistoApi\Services\PaymentService;
+
+/**
+ * Single point of entry for the gateway return (success/cancel/failure).
+ * Address blocks on the input are required only for express success
+ * calls where the gateway supplied the buyer's real shipping address.
+ */
+class PaymentCallbackProcessor implements ProcessorInterface
+{
+    public function __construct(
+        protected PaymentService $paymentService,
+    ) {}
+
+    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
+    {
+        if (! $data instanceof PaymentCallbackInput) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.invalid-input'));
+        }
+
+        $result = $this->paymentService->callback($data);
+
+        $order = $result['order'];
+
+        return (object) [
+            'success'       => $result['status'] === 'success',
+            'message'       => $result['message'],
+            'orderId'       => (string) $order->id,
+            'status'        => $result['status'],
+            'orderStatus'   => (string) $order->status,
+            'gatewayStatus' => $result['gatewayStatus'],
+        ];
+    }
+}

+ 62 - 0
packages/Webkul/BagistoApi/src/State/PaymentInitiateProcessor.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace Webkul\BagistoApi\State;
+
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\State\ProcessorInterface;
+use Illuminate\Support\Facades\Request;
+use Webkul\BagistoApi\Dto\PaymentInitiateInput;
+use Webkul\BagistoApi\Exception\AuthenticationException;
+use Webkul\BagistoApi\Exception\OperationFailedException;
+use Webkul\BagistoApi\Exception\ResourceNotFoundException;
+use Webkul\BagistoApi\Facades\CartTokenFacade;
+use Webkul\BagistoApi\Facades\TokenHeaderFacade;
+use Webkul\BagistoApi\Services\PaymentService;
+
+/**
+ * GraphQL processor for paymentInitiateCreate.
+ *
+ * Resolves the cart from the Authorization Bearer token, delegates to
+ * PaymentService::initiate and shapes the response for the GraphQL
+ * resource (`PaymentInitiate`).
+ */
+class PaymentInitiateProcessor implements ProcessorInterface
+{
+    public function __construct(
+        protected PaymentService $paymentService,
+    ) {}
+
+    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
+    {
+        if (! $data instanceof PaymentInitiateInput) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.invalid-input'));
+        }
+
+        $request = Request::instance() ?? ($context['request'] ?? null);
+        $token = $request ? TokenHeaderFacade::getAuthorizationBearerToken($request) : null;
+
+        if (! $token) {
+            throw new AuthenticationException(__('bagistoapi::app.graphql.cart.authentication-required'));
+        }
+
+        $cart = CartTokenFacade::getCartByToken($token);
+
+        if (! $cart) {
+            throw new ResourceNotFoundException(__('bagistoapi::app.graphql.cart.invalid-token'));
+        }
+
+        $result = $this->paymentService->initiate($cart, $data);
+
+        $order = $result['order'];
+
+        return (object) [
+            'success'        => true,
+            'message'        => __('bagistoapi::app.graphql.payment.initiated'),
+            'orderId'        => (string) $order->id,
+            'status'         => (string) $order->status,
+            'gatewayOrderId' => $result['gatewayOrderId'],
+            'newCartToken'   => $result['newCartToken'],
+            'express'        => (bool) $result['express'],
+        ];
+    }
+}

+ 48 - 0
packages/Webkul/BagistoApi/src/State/PaymentReplayProcessor.php

@@ -0,0 +1,48 @@
+<?php
+
+namespace Webkul\BagistoApi\State;
+
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\State\ProcessorInterface;
+use Illuminate\Support\Facades\Auth;
+use Webkul\BagistoApi\Dto\PaymentReplayInput;
+use Webkul\BagistoApi\Exception\AuthorizationException;
+use Webkul\BagistoApi\Exception\OperationFailedException;
+use Webkul\BagistoApi\Services\PaymentService;
+
+/**
+ * Re-issues a gateway order id for a Bagisto order that is still in
+ * a pending state. Customer-scoped: callers must hold a Sanctum token
+ * matching the order owner.
+ */
+class PaymentReplayProcessor implements ProcessorInterface
+{
+    public function __construct(
+        protected PaymentService $paymentService,
+    ) {}
+
+    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
+    {
+        if (! $data instanceof PaymentReplayInput) {
+            throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.invalid-input'));
+        }
+
+        $customer = Auth::guard('sanctum')->user();
+
+        if (! $customer) {
+            throw new AuthorizationException(__('bagistoapi::app.graphql.logout.unauthenticated'));
+        }
+
+        $result = $this->paymentService->replay($data, $customer);
+
+        $order = $result['order'];
+
+        return (object) [
+            'success'        => true,
+            'message'        => __('bagistoapi::app.graphql.payment.replayed'),
+            'orderId'        => (string) $order->id,
+            'status'         => (string) $order->status,
+            'gatewayOrderId' => $result['gatewayOrderId'],
+        ];
+    }
+}

+ 172 - 0
packages/Webkul/BagistoApi/src/Transformers/ExpressOrderResource.php

@@ -0,0 +1,172 @@
+<?php
+
+namespace Webkul\BagistoApi\Transformers;
+
+use Illuminate\Http\Resources\Json\JsonResource;
+use Webkul\Sales\Transformers\OrderItemResource;
+use Webkul\Sales\Transformers\OrderPaymentResource;
+
+/**
+ * Express-checkout flavoured replacement for
+ * \Webkul\Sales\Transformers\OrderResource.
+ *
+ * Two key deviations vs. the stock resource:
+ *
+ * - shipping_amount / tax_amount are forced to 0 because the buyer
+ *   pays only the subtotal at checkout (the real shipping/tax can be
+ *   reconciled after the gateway returns the verified address).
+ * - billing/shipping address blocks are filled from the placeholder
+ *   defined in config('bagistoapi.express_checkout.placeholder_address')
+ *   so OrderRepository::create() never trips on null addresses.
+ */
+class ExpressOrderResource extends JsonResource
+{
+    public $preserveKeys = true;
+
+    /**
+     * @param  \Illuminate\Http\Request  $request
+     */
+    public function toArray($request): array
+    {
+        $placeholder = $this->buildPlaceholderAddress();
+        $billing = $this->resolveBillingAddress($placeholder);
+        $shipping = $this->resolveShippingAddress($billing);
+
+        $subTotal = (float) ($this->sub_total ?? 0);
+        $baseSubTotal = (float) ($this->base_sub_total ?? $subTotal);
+        $discount = (float) ($this->discount_amount ?? 0);
+        $baseDiscount = (float) ($this->base_discount_amount ?? $discount);
+        $grandTotal = max(0, $subTotal - $discount);
+        $baseGrandTotal = max(0, $baseSubTotal - $baseDiscount);
+
+        return [
+            'cart_id'                  => $this->id,
+            'is_guest'                 => $this->is_guest,
+            'customer_id'              => $this->customer_id,
+            'customer_type'            => $this->customer ? get_class($this->customer) : null,
+            'customer_email'           => $this->customer_email ?? $placeholder['email'],
+            'customer_first_name'      => $this->customer_first_name ?? $placeholder['first_name'],
+            'customer_last_name'       => $this->customer_last_name ?? $placeholder['last_name'],
+            'channel_id'               => $this->channel_id,
+            'channel_name'             => $this->channel->name,
+            'channel_type'             => get_class($this->channel),
+            'total_item_count'         => $this->items_count,
+            'total_qty_ordered'        => $this->items_qty,
+            'base_currency_code'       => $this->base_currency_code,
+            'channel_currency_code'    => $this->channel_currency_code,
+            'order_currency_code'      => $this->cart_currency_code,
+            'grand_total'              => $grandTotal,
+            'base_grand_total'         => $baseGrandTotal,
+            'sub_total'                => $subTotal,
+            'sub_total_incl_tax'       => $subTotal,
+            'base_sub_total'           => $baseSubTotal,
+            'base_sub_total_incl_tax'  => $baseSubTotal,
+            'tax_amount'               => 0,
+            'base_tax_amount'          => 0,
+            'shipping_tax_amount'      => 0,
+            'base_shipping_tax_amount' => 0,
+            'coupon_code'              => $this->coupon_code,
+            'applied_cart_rule_ids'    => $this->applied_cart_rule_ids,
+            'discount_amount'          => $discount,
+            'base_discount_amount'     => $baseDiscount,
+            'billing_address'          => $billing,
+            'shipping_address'         => $shipping,
+            'payment'                  => (new OrderPaymentResource($this->payment))->jsonSerialize(),
+            'items'                    => OrderItemResource::collection($this->items)->jsonSerialize(),
+        ];
+    }
+
+    /**
+     * Pull the configured placeholder block and guarantee every key exists.
+     */
+    private function buildPlaceholderAddress(): array
+    {
+        $defaults = [
+            'first_name'   => 'Express',
+            'last_name'    => 'Checkout',
+            'email'        => 'express-checkout@placeholder.local',
+            'address'      => 'Pending gateway response',
+            'city'         => 'Pending',
+            'state'        => 'Pending',
+            'country'      => 'US',
+            'postcode'     => '00000',
+            'phone'        => '0000000000',
+        ];
+
+        $configured = (array) config('bagistoapi.express_checkout.placeholder_address', []);
+
+        return array_merge($defaults, $configured);
+    }
+
+    /**
+     * Prefer an existing billing address attached to the cart, otherwise
+     * use the placeholder so OrderRepository can persist the order.
+     */
+    private function resolveBillingAddress(array $placeholder): array
+    {
+        if ($this->billing_address) {
+            return [
+                'address_type' => 'order_billing',
+                'first_name'   => $this->billing_address->first_name ?: $placeholder['first_name'],
+                'last_name'    => $this->billing_address->last_name ?: $placeholder['last_name'],
+                'gender'       => $this->billing_address->gender,
+                'company_name' => $this->billing_address->company_name,
+                'address'      => $this->billing_address->address ?: $placeholder['address'],
+                'city'         => $this->billing_address->city ?: $placeholder['city'],
+                'state'        => $this->billing_address->state ?: $placeholder['state'],
+                'country'      => $this->billing_address->country ?: $placeholder['country'],
+                'postcode'     => $this->billing_address->postcode ?: $placeholder['postcode'],
+                'email'        => $this->billing_address->email ?: $this->customer_email ?: $placeholder['email'],
+                'phone'        => $this->billing_address->phone ?: $placeholder['phone'],
+                'vat_id'       => $this->billing_address->vat_id,
+            ];
+        }
+
+        return [
+            'address_type' => 'order_billing',
+            'first_name'   => $placeholder['first_name'],
+            'last_name'    => $placeholder['last_name'],
+            'gender'       => null,
+            'company_name' => null,
+            'address'      => $placeholder['address'],
+            'city'         => $placeholder['city'],
+            'state'        => $placeholder['state'],
+            'country'      => $placeholder['country'],
+            'postcode'     => $placeholder['postcode'],
+            'email'        => $this->customer_email ?: $placeholder['email'],
+            'phone'        => $placeholder['phone'],
+            'vat_id'       => null,
+        ];
+    }
+
+    /**
+     * Mirror the billing block into shipping when the cart has stockable
+     * items; downloadable-only carts skip the shipping address entirely.
+     */
+    private function resolveShippingAddress(array $billing): ?array
+    {
+        if (! $this->haveStockableItems()) {
+            return null;
+        }
+
+        if ($this->shipping_address) {
+            return [
+                'address_type' => 'order_shipping',
+                'first_name'   => $this->shipping_address->first_name ?: $billing['first_name'],
+                'last_name'    => $this->shipping_address->last_name ?: $billing['last_name'],
+                'gender'       => $this->shipping_address->gender,
+                'company_name' => $this->shipping_address->company_name,
+                'address'      => $this->shipping_address->address ?: $billing['address'],
+                'city'         => $this->shipping_address->city ?: $billing['city'],
+                'state'        => $this->shipping_address->state ?: $billing['state'],
+                'country'      => $this->shipping_address->country ?: $billing['country'],
+                'postcode'     => $this->shipping_address->postcode ?: $billing['postcode'],
+                'email'        => $this->shipping_address->email ?: $billing['email'],
+                'phone'        => $this->shipping_address->phone ?: $billing['phone'],
+                'vat_id'       => $this->shipping_address->vat_id,
+            ];
+        }
+
+        return array_merge($billing, ['address_type' => 'order_shipping']);
+    }
+}

+ 64 - 0
packages/Webkul/BagistoApi/tests/Unit/Jobs/ReconcilePendingPaymentJobTest.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Jobs;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Event;
+use Mockery;
+use Tests\TestCase;
+use Webkul\BagistoApi\Jobs\ReconcilePendingPaymentJob;
+use Webkul\Paypal\Payment\SmartButton;
+use Webkul\Sales\Models\Order;
+use Webkul\Sales\Repositories\OrderRepository;
+
+class ReconcilePendingPaymentJobTest extends TestCase
+{
+    protected function tearDown(): void
+    {
+        Mockery::close();
+        parent::tearDown();
+    }
+
+    public function test_cancel_path_is_wrapped_in_db_transaction(): void
+    {
+        Event::fake();
+
+        $order = new class
+        {
+            public int $id = 99;
+            public string $status = Order::STATUS_PENDING;
+            public object $payment;
+
+            public function __construct()
+            {
+                $this->payment = new class
+                {
+                    public string $method = 'paypal_smart_button';
+                    public array $additional = ['gateway_order_id' => 'GW-LOCK-1'];
+                };
+            }
+        };
+
+        $orderRepository = Mockery::mock(OrderRepository::class);
+        $orderRepository->shouldReceive('find')->once()->with(99)->andReturn($order);
+        $orderRepository->shouldReceive('cancel')->never();
+
+        $smartButton = Mockery::mock(SmartButton::class);
+        $smartButton->shouldReceive('getOrder')->once()->with('GW-LOCK-1')->andReturn(
+            (object) [
+                'result' => (object) [
+                    'status'         => 'VOIDED',
+                    'purchase_units' => [(object) ['payments' => (object) ['captures' => []]]],
+                ],
+            ]
+        );
+        $this->app->instance(SmartButton::class, $smartButton);
+
+        DB::shouldReceive('transaction')->once()->andReturn(false);
+
+        (new ReconcilePendingPaymentJob(99))->handle($orderRepository);
+
+        Event::assertDispatched('bagistoapi.payment.reconcile.voided');
+        Event::assertNotDispatched('bagistoapi.payment.reconcile.cancelled');
+    }
+}

+ 142 - 0
packages/Webkul/BagistoApi/tests/Unit/Services/PaymentServiceCancelStrategyTest.php

@@ -0,0 +1,142 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Services;
+
+use Illuminate\Support\Facades\Event;
+use Mockery;
+use Tests\TestCase;
+use Webkul\BagistoApi\Repositories\PaymentAttemptRepository;
+use Webkul\BagistoApi\Services\CartTokenService;
+use Webkul\BagistoApi\Services\PaymentService;
+use Webkul\Checkout\Repositories\CartRepository;
+use Webkul\Sales\Repositories\InvoiceRepository;
+use Webkul\Sales\Repositories\OrderRepository;
+use Webkul\Sales\Repositories\OrderTransactionRepository;
+
+/**
+ * Coverage for the cancel-order strategy branches required by the
+ * "支付流程" spec:
+ *
+ *   - guest          -> immediate cancel + reactivate old cart
+ *   - customer + addr -> keep order pending
+ *   - customer + !addr + config=cancel       -> cancel
+ *   - customer + !addr + config=keep_pending -> keep pending
+ *   - !addr branch ALWAYS dispatches the no-address event
+ */
+class PaymentServiceCancelStrategyTest extends TestCase
+{
+    protected function tearDown(): void
+    {
+        Mockery::close();
+        parent::tearDown();
+    }
+
+    private function makeService(): PaymentService
+    {
+        return new PaymentService(
+            Mockery::mock(OrderRepository::class),
+            Mockery::mock(CartRepository::class),
+            Mockery::mock(CartTokenService::class),
+            Mockery::mock(InvoiceRepository::class),
+            Mockery::mock(OrderTransactionRepository::class),
+            Mockery::mock(PaymentAttemptRepository::class),
+        );
+    }
+
+    private function fakeOrder(?array $shippingFields, array $additional = []): object
+    {
+        $payment = (object) ['additional' => $additional];
+
+        $shipping = $shippingFields ? (object) $shippingFields : null;
+
+        return new class($payment, $shipping)
+        {
+            public function __construct(
+                public object $payment,
+                public ?object $shipping_address,
+            ) {}
+        };
+    }
+
+    public function test_guest_cancel_reactivates_old_cart(): void
+    {
+        Event::fake();
+
+        $order = $this->fakeOrder([
+            'city'     => 'NYC',
+            'postcode' => '10001',
+        ], ['cart_id' => 42]);
+
+        $result = $this->makeService()->decideCancelStrategy($order, isGuest: true);
+
+        $this->assertSame('cancel', $result['action']);
+        $this->assertSame('guest', $result['reason']);
+        $this->assertSame(42, $result['reactivate_cart']);
+    }
+
+    public function test_customer_with_real_shipping_address_keeps_pending(): void
+    {
+        Event::fake();
+
+        $order = $this->fakeOrder([
+            'city'     => 'San Francisco',
+            'postcode' => '94016',
+        ]);
+
+        $result = $this->makeService()->decideCancelStrategy($order, isGuest: false);
+
+        $this->assertSame('keep_pending', $result['action']);
+        $this->assertSame('customer_has_shipping_address', $result['reason']);
+        Event::assertNotDispatched('bagistoapi.express.cancel.no-address');
+    }
+
+    public function test_customer_without_shipping_address_default_cancel_strategy(): void
+    {
+        config(['bagistoapi.express_checkout.cancel_without_address' => 'cancel']);
+        Event::fake();
+
+        $order = $this->fakeOrder(null);
+
+        $result = $this->makeService()->decideCancelStrategy($order, isGuest: false);
+
+        $this->assertSame('cancel', $result['action']);
+        $this->assertSame('customer_no_shipping_address', $result['reason']);
+        Event::assertDispatched('bagistoapi.express.cancel.no-address');
+    }
+
+    public function test_customer_without_shipping_address_keep_pending_strategy(): void
+    {
+        config(['bagistoapi.express_checkout.cancel_without_address' => 'keep_pending']);
+        Event::fake();
+
+        $order = $this->fakeOrder(null);
+
+        $result = $this->makeService()->decideCancelStrategy($order, isGuest: false);
+
+        $this->assertSame('keep_pending', $result['action']);
+        $this->assertSame('customer_no_shipping_address', $result['reason']);
+        Event::assertDispatched('bagistoapi.express.cancel.no-address');
+    }
+
+    public function test_customer_with_placeholder_address_is_treated_as_no_address(): void
+    {
+        config([
+            'bagistoapi.express_checkout.cancel_without_address' => 'cancel',
+            'bagistoapi.express_checkout.placeholder_address'    => [
+                'city'     => 'Pending',
+                'postcode' => '00000',
+            ],
+        ]);
+        Event::fake();
+
+        $order = $this->fakeOrder([
+            'city'     => 'Pending',
+            'postcode' => '00000',
+        ]);
+
+        $result = $this->makeService()->decideCancelStrategy($order, isGuest: false);
+
+        $this->assertSame('cancel', $result['action']);
+        $this->assertSame('customer_no_shipping_address', $result['reason']);
+    }
+}

+ 242 - 0
packages/Webkul/BagistoApi/tests/Unit/Services/PaymentServiceCaptureTest.php

@@ -0,0 +1,242 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Services;
+
+use Illuminate\Support\Facades\Event;
+use Mockery;
+use ReflectionMethod;
+use Tests\TestCase;
+use Webkul\BagistoApi\Dto\PaymentCallbackInput;
+use Webkul\BagistoApi\Exception\OperationFailedException;
+use Webkul\BagistoApi\Repositories\PaymentAttemptRepository;
+use Webkul\BagistoApi\Services\CartTokenService;
+use Webkul\BagistoApi\Services\PaymentService;
+use Webkul\Checkout\Repositories\CartRepository;
+use Webkul\Paypal\Payment\SmartButton;
+use Webkul\Sales\Models\Order;
+use Webkul\Sales\Repositories\InvoiceRepository;
+use Webkul\Sales\Repositories\OrderRepository;
+use Webkul\Sales\Repositories\OrderTransactionRepository;
+
+/**
+ * Coverage for the P0 payment-success hardening:
+ *
+ *   - callback success is idempotent (no re-capture on an order that
+ *     already moved past pending)
+ *   - the PayPal capture response is normalized correctly
+ *   - the captured amount/currency is verified before the order is paid
+ *   - capture failures / non-completed captures surface as errors
+ */
+class PaymentServiceCaptureTest extends TestCase
+{
+    protected function tearDown(): void
+    {
+        Mockery::close();
+        parent::tearDown();
+    }
+
+    private function makeService(?OrderRepository $orderRepository = null): PaymentService
+    {
+        return new PaymentService(
+            $orderRepository ?? Mockery::mock(OrderRepository::class),
+            Mockery::mock(CartRepository::class),
+            Mockery::mock(CartTokenService::class),
+            Mockery::mock(InvoiceRepository::class),
+            Mockery::mock(OrderTransactionRepository::class),
+            Mockery::mock(PaymentAttemptRepository::class),
+        );
+    }
+
+    private function callProtected(PaymentService $service, string $method, array $args)
+    {
+        $ref = new ReflectionMethod($service, $method);
+        $ref->setAccessible(true);
+
+        return $ref->invoke($service, ...$args);
+    }
+
+    /**
+     * Shape a PayPal capture response the way the SDK would.
+     */
+    private function paypalCaptureResponse(
+        string $orderStatus,
+        ?string $captureStatus,
+        ?string $amount,
+        string $currency = 'USD',
+    ): object {
+        $captures = [];
+
+        if ($captureStatus !== null) {
+            $captures[] = (object) [
+                'id'     => 'CAP-123',
+                'status' => $captureStatus,
+                'amount' => (object) [
+                    'value'         => $amount,
+                    'currency_code' => $currency,
+                ],
+            ];
+        }
+
+        return (object) [
+            'statusCode' => 200,
+            'result'     => (object) [
+                'id'             => 'PAYPAL-ORDER-1',
+                'status'         => $orderStatus,
+                'intent'         => 'CAPTURE',
+                'purchase_units' => [
+                    (object) [
+                        'amount'   => (object) [
+                            'value'         => $amount,
+                            'currency_code' => $currency,
+                        ],
+                        'payments' => (object) ['captures' => $captures],
+                    ],
+                ],
+            ],
+        ];
+    }
+
+    private function fakeOrder(array $attributes = []): object
+    {
+        return (object) array_merge([
+            'id'                  => 1,
+            'status'              => Order::STATUS_PENDING,
+            'grand_total'         => 100.0,
+            'order_currency_code' => 'USD',
+        ], $attributes);
+    }
+
+    public function test_callback_success_is_idempotent_for_already_processed_order(): void
+    {
+        $order = $this->fakeOrder(['status' => Order::STATUS_PROCESSING]);
+
+        $orderRepository = Mockery::mock(OrderRepository::class);
+        $orderRepository->shouldReceive('find')->once()->with(1)->andReturn($order);
+
+        $input = new PaymentCallbackInput;
+        $input->orderId = 1;
+        $input->status = 'success';
+
+        $result = $this->makeService($orderRepository)->callback($input);
+
+        $this->assertSame('success', $result['status']);
+        $this->assertSame('already_processed', $result['gatewayStatus']);
+        $this->assertSame($order, $result['order']);
+    }
+
+    public function test_extract_capture_normalizes_response(): void
+    {
+        $service = $this->makeService();
+
+        $response = $this->paypalCaptureResponse('COMPLETED', 'COMPLETED', '100.00');
+
+        $capture = $this->callProtected($service, 'extractCapture', [$response]);
+
+        $this->assertSame('CAP-123', $capture['transaction_id']);
+        $this->assertSame('PAYPAL-ORDER-1', $capture['gateway_order_id']);
+        $this->assertSame('COMPLETED', $capture['order_status']);
+        $this->assertSame('COMPLETED', $capture['capture_status']);
+        $this->assertSame('CAPTURE', $capture['intent']);
+        $this->assertSame(100.0, $capture['amount']);
+        $this->assertSame('USD', $capture['currency']);
+    }
+
+    public function test_assert_amount_matches_passes_on_equal_amount(): void
+    {
+        $service = $this->makeService();
+
+        $order = $this->fakeOrder(['grand_total' => 100.0, 'order_currency_code' => 'USD']);
+        $capture = ['amount' => 100.0, 'currency' => 'USD'];
+
+        $this->callProtected($service, 'assertAmountMatches', [$order, $capture]);
+
+        $this->assertTrue(true);
+    }
+
+    public function test_assert_amount_matches_throws_on_amount_mismatch(): void
+    {
+        Event::fake();
+
+        $service = $this->makeService();
+
+        $order = $this->fakeOrder(['grand_total' => 100.0, 'order_currency_code' => 'USD']);
+        $capture = ['amount' => 1.0, 'currency' => 'USD'];
+
+        try {
+            $this->callProtected($service, 'assertAmountMatches', [$order, $capture]);
+            $this->fail('Expected OperationFailedException');
+        } catch (OperationFailedException $e) {
+            $this->assertStringContainsString('does not match', $e->getMessage());
+        }
+
+        Event::assertDispatched('bagistoapi.payment.amount-mismatch');
+    }
+
+    public function test_assert_amount_matches_throws_on_currency_mismatch(): void
+    {
+        Event::fake();
+
+        $service = $this->makeService();
+
+        $order = $this->fakeOrder(['grand_total' => 100.0, 'order_currency_code' => 'USD']);
+        $capture = ['amount' => 100.0, 'currency' => 'EUR'];
+
+        $this->expectException(OperationFailedException::class);
+
+        $this->callProtected($service, 'assertAmountMatches', [$order, $capture]);
+    }
+
+    public function test_capture_and_verify_throws_when_gateway_capture_fails(): void
+    {
+        $smartButton = Mockery::mock(SmartButton::class);
+        $smartButton->shouldReceive('captureOrder')->once()->andThrow(new \Exception('network down'));
+        $this->app->instance(SmartButton::class, $smartButton);
+
+        $service = $this->makeService();
+        $order = $this->fakeOrder();
+
+        $this->expectException(OperationFailedException::class);
+        $this->expectExceptionMessage('Gateway capture failed');
+
+        $this->callProtected($service, 'captureAndVerify', [$order, 'PAYPAL-ORDER-1']);
+    }
+
+    public function test_capture_and_verify_throws_when_not_completed(): void
+    {
+        Event::fake();
+
+        $smartButton = Mockery::mock(SmartButton::class);
+        $smartButton->shouldReceive('captureOrder')->once()
+            ->andReturn($this->paypalCaptureResponse('PENDING', null, '100.00'));
+        $this->app->instance(SmartButton::class, $smartButton);
+
+        $service = $this->makeService();
+        $order = $this->fakeOrder();
+
+        try {
+            $this->callProtected($service, 'captureAndVerify', [$order, 'PAYPAL-ORDER-1']);
+            $this->fail('Expected OperationFailedException');
+        } catch (OperationFailedException $e) {
+            $this->assertStringContainsString('not completed', $e->getMessage());
+        }
+
+        Event::assertDispatched('bagistoapi.payment.capture-not-completed');
+    }
+
+    public function test_capture_and_verify_returns_normalized_capture_on_success(): void
+    {
+        $smartButton = Mockery::mock(SmartButton::class);
+        $smartButton->shouldReceive('captureOrder')->once()->with('PAYPAL-ORDER-1')
+            ->andReturn($this->paypalCaptureResponse('COMPLETED', 'COMPLETED', '100.00'));
+        $this->app->instance(SmartButton::class, $smartButton);
+
+        $service = $this->makeService();
+        $order = $this->fakeOrder(['grand_total' => 100.0, 'order_currency_code' => 'USD']);
+
+        $capture = $this->callProtected($service, 'captureAndVerify', [$order, 'PAYPAL-ORDER-1']);
+
+        $this->assertSame('CAP-123', $capture['transaction_id']);
+        $this->assertSame(100.0, $capture['amount']);
+        $this->assertSame('USD', $capture['currency']);
+    }
+}

+ 78 - 0
packages/Webkul/BagistoApi/tests/Unit/Services/PaymentServiceP1Test.php

@@ -0,0 +1,78 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Services;
+
+use Mockery;
+use Tests\TestCase;
+use Webkul\BagistoApi\Dto\PaymentReplayInput;
+use Webkul\BagistoApi\Repositories\PaymentAttemptRepository;
+use Webkul\BagistoApi\Services\CartTokenService;
+use Webkul\BagistoApi\Services\PaymentService;
+use Webkul\Checkout\Repositories\CartRepository;
+use Webkul\Sales\Models\Order;
+use Webkul\Sales\Repositories\InvoiceRepository;
+use Webkul\Sales\Repositories\OrderRepository;
+use Webkul\Sales\Repositories\OrderTransactionRepository;
+
+class PaymentServiceP1Test extends TestCase
+{
+    protected function tearDown(): void
+    {
+        Mockery::close();
+        parent::tearDown();
+    }
+
+    public function test_replay_reschedules_reconciliation(): void
+    {
+        $order = new class
+        {
+            public int $id = 42;
+            public ?int $customer_id = null;
+            public string $status = Order::STATUS_PENDING;
+            public float $grand_total = 10.5;
+            public string $order_currency_code = 'USD';
+            public string $cart_currency_code = 'USD';
+            public object $payment;
+
+            public function __construct()
+            {
+                $this->payment = new class
+                {
+                    public string $method = 'paypal_smart_button';
+                    public array $additional = ['cart_id' => 5, 'express' => true];
+                };
+            }
+
+            public function fresh(): static
+            {
+                return $this;
+            }
+        };
+
+        $orderRepository = Mockery::mock(OrderRepository::class);
+        $orderRepository->shouldReceive('find')->once()->with(42)->andReturn($order);
+
+        $service = Mockery::mock(PaymentService::class, [
+            $orderRepository,
+            Mockery::mock(CartRepository::class),
+            Mockery::mock(CartTokenService::class),
+            Mockery::mock(InvoiceRepository::class),
+            Mockery::mock(OrderTransactionRepository::class),
+            Mockery::mock(PaymentAttemptRepository::class),
+        ])->makePartial()->shouldAllowMockingProtectedMethods();
+
+        $service->shouldReceive('createGatewayOrderForOrder')->once()->with($order)->andReturn('GW-NEW-1');
+        $service->shouldReceive('writeGatewayOrderId')->once()->with($order, 'GW-NEW-1');
+        $service->shouldReceive('recordAttempt')->once();
+        $service->shouldReceive('scheduleReconciliation')->once()->with($order);
+
+        $input = new PaymentReplayInput;
+        $input->orderId = 42;
+
+        /** @var PaymentService $service */
+        $result = $service->replay($input, null);
+
+        $this->assertSame('GW-NEW-1', $result['gatewayOrderId']);
+        $this->assertSame($order, $result['order']);
+    }
+}

+ 138 - 0
packages/Webkul/BagistoApi/tests/Unit/Transformers/ExpressOrderResourceTest.php

@@ -0,0 +1,138 @@
+<?php
+
+namespace Webkul\BagistoApi\Tests\Unit\Transformers;
+
+use Illuminate\Http\Request;
+use Tests\TestCase;
+use Webkul\BagistoApi\Transformers\ExpressOrderResource;
+
+/**
+ * Coverage for express-checkout order shaping:
+ *
+ *   - shipping_amount / tax_amount are zeroed
+ *   - grand_total == sub_total - discount_amount
+ *   - billing address is filled from the configured placeholder when
+ *     the cart has no real address attached
+ */
+class ExpressOrderResourceTest extends TestCase
+{
+    public function test_zeros_out_tax_and_shipping_and_uses_placeholder_address(): void
+    {
+        config(['bagistoapi.express_checkout.placeholder_address' => [
+            'first_name' => 'Express',
+            'last_name'  => 'Checkout',
+            'email'      => 'expr@placeholder.local',
+            'address'    => 'Pending',
+            'city'       => 'PendingCity',
+            'state'      => 'PendingState',
+            'country'    => 'US',
+            'postcode'   => '00000',
+            'phone'      => '0000000000',
+        ]]);
+
+        $cart = $this->fakeCart([
+            'id'                   => 99,
+            'sub_total'            => 100.0,
+            'base_sub_total'       => 100.0,
+            'discount_amount'      => 10.0,
+            'base_discount_amount' => 10.0,
+        ]);
+
+        $payload = (new ExpressOrderResource($cart))->toArray(Request::create('/'));
+
+        $this->assertSame(0, $payload['tax_amount']);
+        $this->assertSame(0, $payload['shipping_tax_amount']);
+        $this->assertEquals(90.0, $payload['grand_total']);
+        $this->assertEquals(90.0, $payload['base_grand_total']);
+
+        $this->assertSame('PendingCity', $payload['billing_address']['city']);
+        $this->assertSame('00000', $payload['billing_address']['postcode']);
+        $this->assertSame('order_billing', $payload['billing_address']['address_type']);
+    }
+
+    public function test_shipping_address_omitted_for_non_stockable_carts(): void
+    {
+        $cart = $this->fakeCart([
+            'id'              => 1,
+            'sub_total'       => 50.0,
+            'base_sub_total'  => 50.0,
+            'discount_amount' => 0,
+            'haveStockable'   => false,
+        ]);
+
+        $payload = (new ExpressOrderResource($cart))->toArray(Request::create('/'));
+
+        $this->assertNull($payload['shipping_address']);
+        $this->assertNotEmpty($payload['billing_address']);
+    }
+
+    /**
+     * Build a cart-shaped stdClass that exposes every property the
+     * resource reads. Eloquent's behavior is irrelevant here - the
+     * resource only does property access + a `haveStockableItems()`
+     * method call.
+     */
+    private function fakeCart(array $overrides): object
+    {
+        $defaults = [
+            'id'                      => 1,
+            'is_guest'                => 1,
+            'customer_id'             => null,
+            'customer'                => null,
+            'customer_email'          => null,
+            'customer_first_name'     => null,
+            'customer_last_name'      => null,
+            'channel_id'              => 1,
+            'items_count'             => 1,
+            'items_qty'               => 1,
+            'base_currency_code'      => 'USD',
+            'channel_currency_code'   => 'USD',
+            'cart_currency_code'      => 'USD',
+            'sub_total'               => 0,
+            'sub_total_incl_tax'      => 0,
+            'base_sub_total'          => 0,
+            'base_sub_total_incl_tax' => 0,
+            'coupon_code'             => null,
+            'applied_cart_rule_ids'   => null,
+            'discount_amount'         => 0,
+            'base_discount_amount'    => 0,
+            'billing_address'         => null,
+            'shipping_address'        => null,
+            'payment'                 => null,
+            'items'                   => collect(),
+            'haveStockable'           => true,
+        ];
+
+        $data = array_merge($defaults, $overrides);
+
+        $cart = new class
+        {
+            public ?object $channel = null;
+            public ?object $payment = null;
+            public ?object $billing_address = null;
+            public ?object $shipping_address = null;
+            public mixed $items = null;
+            public bool $haveStockable = true;
+
+            public function haveStockableItems(): bool
+            {
+                return $this->haveStockable;
+            }
+        };
+
+        foreach ($data as $key => $value) {
+            $cart->{$key} = $value;
+        }
+
+        $cart->channel = new class {
+            public string $name = 'Default';
+        };
+
+        $cart->payment = (object) [
+            'method'       => 'paypal_smart_button',
+            'method_title' => 'PayPal Smart Button',
+        ];
+
+        return $cart;
+    }
+}

+ 1 - 1
packages/Webkul/Paypal/src/Payment/SmartButton.php

@@ -89,7 +89,7 @@ class SmartButton extends Paypal
         $request->headers['PayPal-Partner-Attribution-Id'] = $this->paypalPartnerAttributionId;
         $request->prefer('return=representation');
 
-        $this->client()->execute($request);
+        return $this->client()->execute($request);
     }
 
     /**