| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952 |
- <?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 {
- if(!$cart->shipping_method){
- if ($input->shippingMethod) {
- 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]));
- }
- $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;
- }
- }
|