| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019 |
- <?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 (! \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;
- }
- }
|