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 { if(!$cart->shipping_method){ if ($input->shippingMethod) { if (! \Webkul\Shipping\Facades\Shipping::isMethodCodeExists($input->shippingMethod)) { throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.invalid-shipping-method')); } \Webkul\Shipping\Facades\Shipping::collectRates(); if (! Cart::saveShippingMethod($input->shippingMethod)) { throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.shipping-method-save-failed')); } } } $this->validateStandardInitiation($cart, $input); Cart::collectTotals(); $cart = Cart::getCart(); } 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])); } if ($input->paymentMethod) { if(!$order->payment->method!=$input->paymentMethod){ $paymentMethodConfig = config('payment_methods.'.$input->paymentMethod); if (! $paymentMethodConfig || ! isset($paymentMethodConfig['class'])) { throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.invalid-payment-method')); } if (! $order->payment) { throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.payment-method-required')); } $order->payment->method = $input->paymentMethod; $order->payment->method_title = core()->getConfigData('sales.payment_methods.'.$input->paymentMethod.'.title'); $order->payment->save(); $order->refresh(); } } $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; $gatewayHandler = $this->resolveGatewayHandler($method, $order, $input); if ($gatewayHandler) { return $gatewayHandler->createGatewayOrder(); } else { 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; $gatewayHandler = $this->resolveGatewayHandler($method, $order); if ($gatewayHandler) { return $gatewayHandler->createGatewayOrder(); } else { 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; } } protected function resolveGatewayHandler(string $method, $order = null, PaymentInitiateInput $input = null): ?object { $config = config("payment_methods.{$method}"); if (! $config || ! isset($config['class'])) { return null; } try { $handler = app($config['class']); if (method_exists($handler, 'setOrder')) { $handler->setOrder($order); } if (method_exists($handler, 'setInput')) { $handler->setInput($input); } if (! method_exists($handler, 'createGatewayOrder')) { Log::warning('Payment handler does not implement createGatewayOrder', [ 'method' => $method, 'class' => $config['class'], ]); return null; } return $handler; } catch (\Throwable $e) { Log::error('Failed to instantiate payment handler', [ 'method' => $method, 'class' => $config['class'] ?? null, 'error' => $e->getMessage(), ]); return null; } } /** * 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; } }