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