PaymentService.php 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952
  1. <?php
  2. namespace Webkul\BagistoApi\Services;
  3. use Illuminate\Support\Facades\DB;
  4. use Illuminate\Support\Facades\Event;
  5. use Illuminate\Support\Facades\Log;
  6. use Webkul\BagistoApi\Dto\PaymentCallbackInput;
  7. use Webkul\BagistoApi\Dto\PaymentInitiateInput;
  8. use Webkul\BagistoApi\Dto\PaymentReplayInput;
  9. use Webkul\BagistoApi\Exception\OperationFailedException;
  10. use Webkul\BagistoApi\Exception\ResourceNotFoundException;
  11. use Webkul\BagistoApi\Jobs\ReconcilePendingPaymentJob;
  12. use Webkul\BagistoApi\Models\PaymentAttempt;
  13. use Webkul\BagistoApi\Repositories\PaymentAttemptRepository;
  14. use Webkul\BagistoApi\Transformers\ExpressOrderResource;
  15. use Webkul\Checkout\Facades\Cart;
  16. use Webkul\Checkout\Repositories\CartRepository;
  17. use Webkul\Paypal\Payment\SmartButton;
  18. use Webkul\Sales\Models\Order;
  19. use Webkul\Sales\Models\OrderProxy;
  20. use Webkul\Sales\Repositories\InvoiceRepository;
  21. use Webkul\Sales\Repositories\OrderRepository;
  22. use Webkul\Sales\Repositories\OrderTransactionRepository;
  23. use Webkul\Sales\Transformers\OrderResource;
  24. /**
  25. * Single source of truth for the BagistoApi payment lifecycle:
  26. *
  27. * - initiate() : create order (express or standard) + gateway order
  28. * - callback() : handle success / cancel / failure from the gateway
  29. * - replay() : issue a new gateway order id for a still-pending order
  30. * - cancelPayment(): close out a pending payment per cart-token strategy
  31. *
  32. * The matching ProcessorInterface implementations are thin wrappers so
  33. * GraphQL resolvers stay testable in isolation.
  34. */
  35. class PaymentService
  36. {
  37. public function __construct(
  38. protected OrderRepository $orderRepository,
  39. protected CartRepository $cartRepository,
  40. protected CartTokenService $cartTokenService,
  41. protected InvoiceRepository $invoiceRepository,
  42. protected OrderTransactionRepository $orderTransactionRepository,
  43. protected PaymentAttemptRepository $paymentAttemptRepository,
  44. ) {}
  45. /*
  46. |--------------------------------------------------------------------------
  47. | Initiate payment (= create order + gateway order)
  48. |--------------------------------------------------------------------------
  49. */
  50. /**
  51. * Initiate a payment on the supplied cart.
  52. *
  53. * Returns an associative array: [
  54. * 'order' => OrderModel,
  55. * 'gatewayOrderId' => ?string,
  56. * 'newCartToken' => ?string,
  57. * 'express' => bool,
  58. * ]
  59. */
  60. public function initiate($cart, PaymentInitiateInput $input): array
  61. {
  62. $express = (bool) $input->expressCheckout;
  63. if ($express && ! config('bagistoapi.express_checkout.enabled', true)) {
  64. throw new OperationFailedException(__('bagistoapi::app.graphql.payment.express-disabled'));
  65. }
  66. if (! $cart || $cart->items()->count() === 0) {
  67. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.cart-empty'));
  68. }
  69. Cart::setCart($cart);
  70. if ($express) {
  71. $this->validateExpressInitiation($cart, $input);
  72. } else {
  73. if(!$cart->shipping_method){
  74. if ($input->shippingMethod) {
  75. if (! Cart::saveShippingMethod($input->shippingMethod)) {
  76. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.shipping-method-save-failed'));
  77. }
  78. }
  79. }
  80. $this->validateStandardInitiation($cart, $input);
  81. Cart::collectTotals();
  82. $cart = Cart::getCart();
  83. }
  84. if (! $input->paymentMethod && ! $cart->payment?->method) {
  85. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.payment-method-required'));
  86. }
  87. if ($input->paymentMethod) {
  88. if (! Cart::savePaymentMethod(['method' => $input->paymentMethod])) {
  89. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.payment-method-save-failed'));
  90. }
  91. // Cart::collectTotals();
  92. $cart = Cart::getCart();
  93. }
  94. $oldCartId = $cart->id;
  95. $oldCartToken = $cart->guest_cart_token ?? null;
  96. $orderData = $express
  97. ? (new ExpressOrderResource($cart))->jsonSerialize()
  98. : (new OrderResource($cart))->jsonSerialize();
  99. $order = null;
  100. $gatewayOrderId = null;
  101. DB::transaction(function () use (
  102. &$order,
  103. &$gatewayOrderId,
  104. $cart,
  105. $input,
  106. $express,
  107. $orderData,
  108. $oldCartId,
  109. $oldCartToken
  110. ) {
  111. $order = $this->orderRepository->create($orderData);
  112. if (! $order || ! data_get($order, 'id')) {
  113. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.order-creation-failed'));
  114. }
  115. $order = $this->orderRepository->find($order->id);
  116. $gatewayOrderId = $this->createGatewayOrder($cart, $order, $input, $express);
  117. $this->stampPaymentAdditional($order, $gatewayOrderId, $express, $oldCartId, $oldCartToken);
  118. $this->recordAttempt([
  119. 'order_id' => $order->id,
  120. 'cart_id' => $oldCartId,
  121. 'payment_method' => $order->payment?->method,
  122. 'gateway_order_id' => $gatewayOrderId,
  123. 'action' => PaymentAttempt::ACTION_INITIATE,
  124. 'status' => $gatewayOrderId ? PaymentAttempt::STATUS_REDIRECTED : PaymentAttempt::STATUS_CREATED,
  125. 'amount' => (float) ($order->grand_total ?? 0),
  126. 'currency' => $order->order_currency_code ?? $order->cart_currency_code,
  127. 'express' => $express,
  128. ]);
  129. });
  130. Cart::deActivateCart();
  131. $newCartToken = $this->cartTokenService->issueFreshCart($cart->customer_id);
  132. Event::dispatch('order.created.after', $order);
  133. Event::dispatch('bagistoapi.payment.initiated', $order);
  134. $this->scheduleReconciliation($order);
  135. return [
  136. 'order' => $order,
  137. 'gatewayOrderId' => $gatewayOrderId,
  138. 'newCartToken' => $newCartToken,
  139. 'express' => $express,
  140. ];
  141. }
  142. /*
  143. |--------------------------------------------------------------------------
  144. | Callback (success / cancel / failure)
  145. |--------------------------------------------------------------------------
  146. */
  147. /**
  148. * Returns the persisted order plus a status string for the resolver.
  149. *
  150. * The cancel branch is handled here for the case where the frontend
  151. * lands on the cancel URL right after the gateway redirect. The
  152. * separate CancelOrderProcessor mutation handles user-initiated
  153. * cancels from the order list.
  154. */
  155. public function callback(PaymentCallbackInput $input): array
  156. {
  157. if (! $input->orderId) {
  158. throw new OperationFailedException(__('bagistoapi::app.graphql.payment.order-id-required'));
  159. }
  160. $order = $this->orderRepository->find($input->orderId);
  161. if (! $order) {
  162. throw new ResourceNotFoundException(__('bagistoapi::app.graphql.payment.order-not-found', ['id' => $input->orderId]));
  163. }
  164. $status = strtolower((string) $input->status);
  165. return match ($status) {
  166. 'success' => $this->handleSuccess($order, $input),
  167. 'cancel' => $this->handleCancel($order, 'cancelled'),
  168. 'failure' => $this->handleCancel($order, 'failed'),
  169. default => throw new OperationFailedException(__('bagistoapi::app.graphql.payment.invalid-status', ['status' => $status])),
  170. };
  171. }
  172. /*
  173. |--------------------------------------------------------------------------
  174. | Replay (re-issue gateway order for pending Bagisto order)
  175. |--------------------------------------------------------------------------
  176. */
  177. public function replay(PaymentReplayInput $input, $customer = null): array
  178. {
  179. if (! $input->orderId) {
  180. throw new OperationFailedException(__('bagistoapi::app.graphql.payment.order-id-required'));
  181. }
  182. $order = $this->orderRepository->find($input->orderId);
  183. if (! $order) {
  184. throw new ResourceNotFoundException(__('bagistoapi::app.graphql.payment.order-not-found', ['id' => $input->orderId]));
  185. }
  186. if ($customer && $order->customer_id !== $customer->id) {
  187. throw new OperationFailedException(__('bagistoapi::app.graphql.payment.replay-not-allowed'));
  188. }
  189. if (! in_array($order->status, [Order::STATUS_PENDING, Order::STATUS_PENDING_PAYMENT], true)) {
  190. throw new OperationFailedException(__('bagistoapi::app.graphql.payment.not-pending', ['status' => $order->status]));
  191. }
  192. $gatewayOrderId = $this->createGatewayOrderForOrder($order);
  193. $this->writeGatewayOrderId($order, $gatewayOrderId);
  194. $this->recordAttempt([
  195. 'order_id' => $order->id,
  196. 'cart_id' => $this->resolveOldCartId($order),
  197. 'payment_method' => $order->payment?->method,
  198. 'gateway_order_id' => $gatewayOrderId,
  199. 'action' => PaymentAttempt::ACTION_REPLAY,
  200. 'status' => $gatewayOrderId ? PaymentAttempt::STATUS_REDIRECTED : PaymentAttempt::STATUS_CREATED,
  201. 'amount' => (float) ($order->grand_total ?? 0),
  202. 'currency' => $order->order_currency_code ?? $order->cart_currency_code,
  203. 'express' => $this->expressFlag($order),
  204. ]);
  205. $this->scheduleReconciliation($order);
  206. Event::dispatch('bagistoapi.payment.replayed', $order);
  207. return [
  208. 'order' => $order->fresh(),
  209. 'gatewayOrderId' => $gatewayOrderId,
  210. ];
  211. }
  212. /*
  213. |--------------------------------------------------------------------------
  214. | Cancel strategy (used by CancelOrderProcessor)
  215. |--------------------------------------------------------------------------
  216. */
  217. /**
  218. * Decide what to do with a pending order when the cancel mutation
  219. * is invoked. Returns an array describing the action so the caller
  220. * can report it back to the client.
  221. */
  222. public function decideCancelStrategy($order, bool $isGuest): array
  223. {
  224. $hasShippingAddress = (bool) $order->shipping_address && ! $this->isPlaceholderAddress($order->shipping_address);
  225. if ($isGuest) {
  226. return [
  227. 'action' => 'cancel',
  228. 'reason' => 'guest',
  229. 'reactivate_cart' => $this->resolveOldCartId($order),
  230. ];
  231. }
  232. if ($hasShippingAddress) {
  233. return [
  234. 'action' => 'keep_pending',
  235. 'reason' => 'customer_has_shipping_address',
  236. ];
  237. }
  238. $strategy = (string) config('bagistoapi.express_checkout.cancel_without_address', 'cancel');
  239. Event::dispatch('bagistoapi.express.cancel.no-address', [
  240. 'order' => $order,
  241. 'strategy' => $strategy,
  242. ]);
  243. return [
  244. 'action' => $strategy === 'keep_pending' ? 'keep_pending' : 'cancel',
  245. 'reason' => 'customer_no_shipping_address',
  246. ];
  247. }
  248. /*
  249. |--------------------------------------------------------------------------
  250. | Internals
  251. |--------------------------------------------------------------------------
  252. */
  253. /**
  254. * Express validations: just the bare minimum to avoid cart/empty
  255. * orders. Address, email and shipping checks are skipped on
  256. * purpose - those come from the gateway success callback.
  257. */
  258. protected function validateExpressInitiation($cart, PaymentInitiateInput $input): void
  259. {
  260. if (auth()->guard('customer')->check()) {
  261. $customer = auth()->guard('customer')->user();
  262. if ($customer && $customer->is_suspended) {
  263. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.account-suspended'));
  264. }
  265. if ($customer && ! $customer->status) {
  266. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.account-inactive'));
  267. }
  268. }
  269. $minimumOrderAmount = core()->getConfigData('sales.order_settings.minimum_order.minimum_order_amount') ?: 0;
  270. if (! Cart::haveMinimumOrderAmount()) {
  271. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.minimum-order-not-met', ['amount' => core()->currency($minimumOrderAmount)]));
  272. }
  273. }
  274. /**
  275. * Mirror the existing CheckoutProcessor::validateOrderCreation but
  276. * scoped to initiate. Keeps strict parity with the legacy mutation.
  277. */
  278. protected function validateStandardInitiation($cart, PaymentInitiateInput $input): void
  279. {
  280. if (auth()->guard('customer')->check()) {
  281. $customer = auth()->guard('customer')->user();
  282. if ($customer && $customer->is_suspended) {
  283. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.account-suspended'));
  284. }
  285. if ($customer && ! $customer->status) {
  286. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.account-inactive'));
  287. }
  288. }
  289. $minimumOrderAmount = core()->getConfigData('sales.order_settings.minimum_order.minimum_order_amount') ?: 0;
  290. if (! Cart::haveMinimumOrderAmount()) {
  291. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.minimum-order-not-met', ['amount' => core()->currency($minimumOrderAmount)]));
  292. }
  293. $hasBillingAddress = $input->billingAddress || $cart->billing_address()->exists();
  294. if (! $hasBillingAddress) {
  295. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.billing-address-required'));
  296. }
  297. $hasShippingAddress = $input->shippingAddress || $input->useForShipping || $cart->shipping_address()->exists();
  298. if (! $hasShippingAddress && $cart->haveStockableItems()) {
  299. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.shipping-address-required'));
  300. }
  301. $hasEmail = $cart->customer_email || $input->billingEmail || ($cart->billing_address && $cart->billing_address->email);
  302. if (! $hasEmail) {
  303. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.email-required'));
  304. }
  305. if ($cart->haveStockableItems()) {
  306. $hasShippingMethod = $input->shippingMethod || $cart->shipping_method;
  307. if (! $hasShippingMethod) {
  308. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.shipping-method-required'));
  309. }
  310. if (! $cart->selected_shipping_rate) {
  311. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.invalid-shipping-method'));
  312. }
  313. }
  314. }
  315. /**
  316. * Currently we only know how to create a PayPal smart-button order.
  317. * Returning null is fine - other gateways may have a redirect-only
  318. * flow that does not need a pre-flight order id.
  319. */
  320. protected function createGatewayOrder($cart, $order, PaymentInitiateInput $input, bool $express): ?string
  321. {
  322. $method = $cart->payment?->method ?? $order->payment?->method;
  323. if ($method !== 'paypal_smart_button') {
  324. return null;
  325. }
  326. $smartButton = app(SmartButton::class);
  327. $amount = $express
  328. ? max(0, (float) ($cart->sub_total ?? 0) - (float) ($cart->discount_amount ?? 0))
  329. : (float) ($order->grand_total ?? $cart->sub_total ?? 0);
  330. $response = $smartButton->createOrder([
  331. 'intent' => 'CAPTURE',
  332. 'purchase_units' => [[
  333. 'reference_id' => (string) $cart->id,
  334. 'amount' => [
  335. 'currency_code' => $cart->cart_currency_code,
  336. 'value' => $smartButton->formatCurrencyValue($amount),
  337. ],
  338. ]],
  339. ]);
  340. $gatewayOrderId = $response->result->id ?? null;
  341. if (! $gatewayOrderId) {
  342. throw new OperationFailedException(__('bagistoapi::app.graphql.payment.gateway-create-failed'));
  343. }
  344. return (string) $gatewayOrderId;
  345. }
  346. /**
  347. * Variant used by replay() where there's no Cart - amount comes
  348. * straight from the existing order.
  349. */
  350. protected function createGatewayOrderForOrder($order): ?string
  351. {
  352. $method = $order->payment?->method;
  353. if ($method !== 'paypal_smart_button') {
  354. return null;
  355. }
  356. $smartButton = app(SmartButton::class);
  357. $response = $smartButton->createOrder([
  358. 'intent' => 'CAPTURE',
  359. 'purchase_units' => [[
  360. 'reference_id' => (string) $order->id,
  361. 'amount' => [
  362. 'currency_code' => $order->cart_currency_code ?? $order->order_currency_code,
  363. 'value' => $smartButton->formatCurrencyValue((float) $order->grand_total),
  364. ],
  365. ]],
  366. ]);
  367. $gatewayOrderId = $response->result->id ?? null;
  368. if (! $gatewayOrderId) {
  369. throw new OperationFailedException(__('bagistoapi::app.graphql.payment.gateway-create-failed'));
  370. }
  371. return (string) $gatewayOrderId;
  372. }
  373. /**
  374. * Persist the gateway order id + express flag + old cart id onto
  375. * order_payment.additional so callbacks/reconciliation have all the
  376. * context they need.
  377. */
  378. protected function stampPaymentAdditional($order, ?string $gatewayOrderId, bool $express, int $oldCartId, ?string $oldCartToken): void
  379. {
  380. if (! $order->payment) {
  381. return;
  382. }
  383. $additional = $order->payment->additional ?? [];
  384. if ($gatewayOrderId) {
  385. $additional['paypal_order_id'] = $gatewayOrderId;
  386. $additional['gateway_order_id'] = $gatewayOrderId;
  387. }
  388. $additional['express'] = $express;
  389. $additional['cart_id'] = $oldCartId;
  390. if ($oldCartToken) {
  391. $additional['cart_token'] = $oldCartToken;
  392. }
  393. $order->payment->additional = $additional;
  394. $order->payment->save();
  395. }
  396. protected function writeGatewayOrderId($order, ?string $gatewayOrderId): void
  397. {
  398. if (! $order->payment || ! $gatewayOrderId) {
  399. return;
  400. }
  401. $additional = $order->payment->additional ?? [];
  402. $additional['paypal_order_id'] = $gatewayOrderId;
  403. $additional['gateway_order_id'] = $gatewayOrderId;
  404. $order->payment->additional = $additional;
  405. $order->payment->save();
  406. }
  407. protected function scheduleReconciliation($order): void
  408. {
  409. if (! config('bagistoapi.reconcile.enabled', true)) {
  410. return;
  411. }
  412. $delay = (int) config('bagistoapi.reconcile.delay_minutes', 15);
  413. $queue = (string) config('bagistoapi.reconcile.queue', 'payment-reconcile');
  414. try {
  415. $job = (new ReconcilePendingPaymentJob($order->id))
  416. ->onQueue($queue)
  417. ->delay(now()->addMinutes($delay));
  418. dispatch($job);
  419. } catch (\Throwable $e) {
  420. Log::warning('Failed to enqueue ReconcilePendingPaymentJob: '.$e->getMessage(), [
  421. 'order_id' => $order->id,
  422. ]);
  423. }
  424. }
  425. /**
  426. * Success branch: capture the gateway order, verify the captured
  427. * amount, fill in express addresses, flip the order to processing
  428. * and generate the invoice + settled transaction record.
  429. *
  430. * Idempotent: a second (possibly concurrent) success callback for an
  431. * order that is already past pending short-circuits to success
  432. * without re-capturing. Concurrency is serialized via a row lock.
  433. */
  434. protected function handleSuccess($order, PaymentCallbackInput $input): array
  435. {
  436. if (! $this->isPending($order)) {
  437. return $this->successResponse($order, 'already_processed');
  438. }
  439. $additional = $order->payment?->additional ?? [];
  440. $isExpress = ! empty($additional['express']);
  441. $method = $order->payment?->method;
  442. $gatewayOrderId = $input->gatewayOrderId ?: ($additional['paypal_order_id'] ?? null);
  443. try {
  444. $result = DB::transaction(function () use ($order, $input, $isExpress, $method, $gatewayOrderId) {
  445. /*
  446. * Lock the order row so concurrent success callbacks
  447. * serialize here; the second one will see PROCESSING
  448. * after acquiring the lock and short-circuit below.
  449. */
  450. $orderModelClass = OrderProxy::modelClass();
  451. $orderModelClass::query()->whereKey($order->id)->lockForUpdate()->first();
  452. $order->refresh();
  453. if (! $this->isPending($order)) {
  454. return ['skipped' => true, 'capture' => null, 'response' => $this->successResponse($order, 'already_processed')];
  455. }
  456. $capture = null;
  457. if ($method === 'paypal_smart_button') {
  458. if (! $gatewayOrderId) {
  459. throw new OperationFailedException(__('bagistoapi::app.graphql.payment.gateway-order-id-required'));
  460. }
  461. $capture = $this->captureAndVerify($order, $gatewayOrderId);
  462. }
  463. if ($isExpress) {
  464. $this->fillAddressesFromCallback($order, $input);
  465. }
  466. if ($capture && ! empty($capture['transaction_id'])) {
  467. $this->writeTransactionId($order, (string) $capture['transaction_id']);
  468. }
  469. $this->orderRepository->updateOrderStatus($order, Order::STATUS_PROCESSING);
  470. $invoice = $this->createInvoiceIfPossible($order);
  471. if ($capture) {
  472. $this->recordOrderTransaction($order, $invoice, $capture);
  473. }
  474. $order->refresh();
  475. return ['skipped' => false, 'capture' => $capture, 'response' => $this->successResponse($order, 'captured')];
  476. });
  477. } catch (OperationFailedException $e) {
  478. /*
  479. * Recorded outside the rolled-back transaction so the audit
  480. * trail survives the failure.
  481. */
  482. $this->recordAttempt([
  483. 'order_id' => $order->id,
  484. 'cart_id' => $this->resolveOldCartId($order),
  485. 'payment_method' => $method,
  486. 'gateway_order_id' => $gatewayOrderId,
  487. 'action' => PaymentAttempt::ACTION_CALLBACK,
  488. 'status' => PaymentAttempt::STATUS_FAILED,
  489. 'amount' => (float) ($order->grand_total ?? 0),
  490. 'currency' => $order->order_currency_code ?? $order->cart_currency_code,
  491. 'express' => $isExpress,
  492. 'response_payload' => ['error' => $e->getMessage()],
  493. ]);
  494. throw $e;
  495. }
  496. if (empty($result['skipped'])) {
  497. $this->recordAttempt([
  498. 'order_id' => $order->id,
  499. 'cart_id' => $this->resolveOldCartId($order),
  500. 'payment_method' => $method,
  501. 'gateway_order_id' => $gatewayOrderId,
  502. 'action' => PaymentAttempt::ACTION_CALLBACK,
  503. 'status' => PaymentAttempt::STATUS_CAPTURED,
  504. 'amount' => (float) ($order->grand_total ?? 0),
  505. 'currency' => $order->order_currency_code ?? $order->cart_currency_code,
  506. 'express' => $isExpress,
  507. 'idempotency_key' => $gatewayOrderId ? 'capture:'.$gatewayOrderId : null,
  508. 'response_payload' => $result['capture'] ?? null,
  509. ]);
  510. Event::dispatch('bagistoapi.payment.success', $result['response']['order']);
  511. }
  512. return $result['response'];
  513. }
  514. /**
  515. * Cancel/failure branch: keep the order in PENDING and let the
  516. * caller decide whether to actually cancel it via the dedicated
  517. * cancelOrder mutation. This keeps cancel logic in one place.
  518. */
  519. protected function handleCancel($order, string $reason): array
  520. {
  521. $this->recordAttempt([
  522. 'order_id' => $order->id,
  523. 'cart_id' => $this->resolveOldCartId($order),
  524. 'payment_method' => $order->payment?->method,
  525. 'gateway_order_id' => $this->gatewayOrderIdFromOrder($order),
  526. 'action' => PaymentAttempt::ACTION_CALLBACK,
  527. 'status' => $reason === 'cancelled' ? PaymentAttempt::STATUS_CANCELLED : PaymentAttempt::STATUS_FAILED,
  528. 'amount' => (float) ($order->grand_total ?? 0),
  529. 'currency' => $order->order_currency_code ?? $order->cart_currency_code,
  530. 'express' => $this->expressFlag($order),
  531. ]);
  532. Event::dispatch('bagistoapi.payment.cancelled', ['order' => $order, 'reason' => $reason]);
  533. return [
  534. 'order' => $order,
  535. 'status' => $reason,
  536. 'gatewayStatus' => $reason,
  537. 'message' => __('bagistoapi::app.graphql.payment.'.$reason),
  538. ];
  539. }
  540. /**
  541. * Capture the PayPal order then assert the gateway reported a
  542. * completed capture whose amount/currency match the Bagisto order.
  543. * Returns a normalized capture array for persistence/audit.
  544. */
  545. protected function captureAndVerify($order, string $gatewayOrderId): array
  546. {
  547. try {
  548. $response = app(SmartButton::class)->captureOrder($gatewayOrderId);
  549. } catch (\Throwable $e) {
  550. Log::error('PayPal capture failed: '.$e->getMessage(), [
  551. 'order_id' => $order->id,
  552. 'gateway_order_id' => $gatewayOrderId,
  553. ]);
  554. throw new OperationFailedException(__('bagistoapi::app.graphql.payment.gateway-capture-failed'));
  555. }
  556. $capture = $this->extractCapture($response);
  557. $orderStatus = strtoupper((string) ($capture['order_status'] ?? ''));
  558. $captureStatus = strtoupper((string) ($capture['capture_status'] ?? ''));
  559. $completed = in_array($orderStatus, ['COMPLETED', 'CAPTURED'], true)
  560. || in_array($captureStatus, ['COMPLETED', 'CAPTURED'], true);
  561. if (! $completed) {
  562. Event::dispatch('bagistoapi.payment.capture-not-completed', ['order' => $order, 'capture' => $capture]);
  563. Log::error('PayPal capture not completed', [
  564. 'order_id' => $order->id,
  565. 'order_status' => $orderStatus,
  566. 'capture_status' => $captureStatus,
  567. ]);
  568. throw new OperationFailedException(__('bagistoapi::app.graphql.payment.capture-not-completed'));
  569. }
  570. $this->assertAmountMatches($order, $capture);
  571. return $capture;
  572. }
  573. /**
  574. * Normalize a PayPal capture response into a flat array.
  575. */
  576. protected function extractCapture($response): array
  577. {
  578. $data = json_decode(json_encode($response), true) ?: [];
  579. $result = $data['result'] ?? [];
  580. $purchaseUnit = $result['purchase_units'][0] ?? [];
  581. $captureNode = $purchaseUnit['payments']['captures'][0] ?? [];
  582. $amount = $captureNode['amount']['value'] ?? $purchaseUnit['amount']['value'] ?? null;
  583. $currency = $captureNode['amount']['currency_code'] ?? $purchaseUnit['amount']['currency_code'] ?? null;
  584. return [
  585. 'transaction_id' => $captureNode['id'] ?? $result['id'] ?? null,
  586. 'gateway_order_id' => $result['id'] ?? null,
  587. 'order_status' => $result['status'] ?? null,
  588. 'capture_status' => $captureNode['status'] ?? null,
  589. 'intent' => $result['intent'] ?? 'CAPTURE',
  590. 'amount' => $amount !== null ? (float) $amount : null,
  591. 'currency' => $currency,
  592. 'raw' => $result,
  593. ];
  594. }
  595. /**
  596. * Guard against an under-payment / wrong-currency capture before we
  597. * mark the order as paid.
  598. */
  599. protected function assertAmountMatches($order, array $capture): void
  600. {
  601. $expected = round((float) ($order->grand_total ?? 0), 2);
  602. $captured = isset($capture['amount']) && $capture['amount'] !== null
  603. ? round((float) $capture['amount'], 2)
  604. : null;
  605. $expectedCurrency = strtoupper((string) ($order->order_currency_code ?? $order->cart_currency_code ?? ''));
  606. $capturedCurrency = strtoupper((string) ($capture['currency'] ?? ''));
  607. $amountOk = $captured !== null && abs($captured - $expected) < 0.01;
  608. $currencyOk = $capturedCurrency === '' || $expectedCurrency === '' || $capturedCurrency === $expectedCurrency;
  609. if ($amountOk && $currencyOk) {
  610. return;
  611. }
  612. Event::dispatch('bagistoapi.payment.amount-mismatch', [
  613. 'order' => $order,
  614. 'expected' => $expected,
  615. 'captured' => $captured,
  616. 'capture' => $capture,
  617. ]);
  618. Log::error('PayPal capture amount mismatch', [
  619. 'order_id' => $order->id,
  620. 'expected' => $expected,
  621. 'captured' => $captured,
  622. 'expected_currency' => $expectedCurrency,
  623. 'captured_currency' => $capturedCurrency,
  624. ]);
  625. throw new OperationFailedException(__('bagistoapi::app.graphql.payment.amount-mismatch'));
  626. }
  627. /**
  628. * Create the invoice for a fully-captured order, mirroring the
  629. * native PayPal SmartButtonController behaviour.
  630. */
  631. protected function createInvoiceIfPossible($order)
  632. {
  633. if (! $order->canInvoice()) {
  634. return null;
  635. }
  636. $invoiceData = ['order_id' => $order->id];
  637. foreach ($order->items as $item) {
  638. $invoiceData['invoice']['items'][$item->id] = $item->qty_to_invoice;
  639. }
  640. return $this->invoiceRepository->create($invoiceData);
  641. }
  642. /**
  643. * Persist the settled transaction. Unlike payment_attempts, this row
  644. * is tied to an invoice and represents money actually received.
  645. */
  646. protected function recordOrderTransaction($order, $invoice, array $capture): void
  647. {
  648. if (! $invoice || empty($capture['transaction_id'])) {
  649. return;
  650. }
  651. try {
  652. $this->orderTransactionRepository->create([
  653. 'transaction_id' => (string) $capture['transaction_id'],
  654. 'status' => $capture['capture_status'] ?? $capture['order_status'] ?? null,
  655. 'type' => $capture['intent'] ?? 'CAPTURE',
  656. 'amount' => $capture['amount'] ?? $order->grand_total,
  657. 'payment_method' => $order->payment?->method,
  658. 'order_id' => $order->id,
  659. 'invoice_id' => $invoice->id,
  660. 'data' => json_encode($capture['raw'] ?? $capture),
  661. ]);
  662. } catch (\Throwable $e) {
  663. Log::warning('Failed to record order transaction: '.$e->getMessage(), [
  664. 'order_id' => $order->id,
  665. ]);
  666. }
  667. }
  668. /**
  669. * Store the gateway transaction id on order_payment.additional for
  670. * later refunds (avoids a round-trip to re-fetch the capture id).
  671. */
  672. protected function writeTransactionId($order, string $transactionId): void
  673. {
  674. if (! $order->payment) {
  675. return;
  676. }
  677. $additional = $order->payment->additional ?? [];
  678. $additional['transaction_id'] = $transactionId;
  679. $order->payment->additional = $additional;
  680. $order->payment->save();
  681. }
  682. /**
  683. * Persist a payment_attempts row. Never let an audit-write failure
  684. * break the payment flow.
  685. */
  686. protected function recordAttempt(array $data): ?PaymentAttempt
  687. {
  688. try {
  689. return $this->paymentAttemptRepository->create($data);
  690. } catch (\Throwable $e) {
  691. Log::warning('Failed to record payment attempt: '.$e->getMessage(), [
  692. 'order_id' => $data['order_id'] ?? null,
  693. 'action' => $data['action'] ?? null,
  694. ]);
  695. return null;
  696. }
  697. }
  698. protected function isPending($order): bool
  699. {
  700. return in_array($order->status, [Order::STATUS_PENDING, Order::STATUS_PENDING_PAYMENT], true);
  701. }
  702. protected function successResponse($order, string $gatewayStatus): array
  703. {
  704. return [
  705. 'order' => $order,
  706. 'status' => 'success',
  707. 'gatewayStatus' => $gatewayStatus,
  708. 'message' => __('bagistoapi::app.graphql.payment.success'),
  709. ];
  710. }
  711. protected function gatewayOrderIdFromOrder($order): ?string
  712. {
  713. $additional = $order->payment?->additional ?? [];
  714. return $additional['paypal_order_id'] ?? $additional['gateway_order_id'] ?? null;
  715. }
  716. protected function expressFlag($order): bool
  717. {
  718. $additional = $order->payment?->additional ?? [];
  719. return ! empty($additional['express']);
  720. }
  721. /**
  722. * Replace the placeholder addresses with the real ones we got back
  723. * from the gateway. Only used for express orders.
  724. */
  725. protected function fillAddressesFromCallback($order, PaymentCallbackInput $input): void
  726. {
  727. $billing = $this->buildAddressPayloadFromCallback($input, 'billing');
  728. $shipping = $this->buildAddressPayloadFromCallback($input, 'shipping');
  729. if ($shipping) {
  730. $order->shipping_address?->update($shipping);
  731. }
  732. if ($billing) {
  733. $order->billing_address?->update($billing);
  734. }
  735. if (! empty($billing['email'])) {
  736. $order->customer_email = $billing['email'];
  737. }
  738. if (! empty($billing['first_name'])) {
  739. $order->customer_first_name = $billing['first_name'];
  740. }
  741. if (! empty($billing['last_name'])) {
  742. $order->customer_last_name = $billing['last_name'];
  743. }
  744. $order->save();
  745. }
  746. /**
  747. * Pull billing/shipping fields off the callback DTO and translate
  748. * them to the snake_case column names used by OrderAddress.
  749. */
  750. protected function buildAddressPayloadFromCallback(PaymentCallbackInput $input, string $type): array
  751. {
  752. $fields = [
  753. 'first_name' => $type.'FirstName',
  754. 'last_name' => $type.'LastName',
  755. 'email' => $type.'Email',
  756. 'address' => $type.'Address',
  757. 'country' => $type.'Country',
  758. 'state' => $type.'State',
  759. 'city' => $type.'City',
  760. 'postcode' => $type.'Postcode',
  761. 'phone' => $type.'PhoneNumber',
  762. ];
  763. $payload = [];
  764. foreach ($fields as $column => $property) {
  765. $value = $input->{$property} ?? null;
  766. if ($value !== null && $value !== '') {
  767. $payload[$column] = $value;
  768. }
  769. }
  770. return $payload;
  771. }
  772. /**
  773. * Heuristic check: the placeholder city/postcode is unlikely to
  774. * collide with a real address; we use this to decide whether the
  775. * express order really has a usable shipping address.
  776. */
  777. protected function isPlaceholderAddress($address): bool
  778. {
  779. $placeholder = (array) config('bagistoapi.express_checkout.placeholder_address', []);
  780. if (empty($placeholder)) {
  781. return false;
  782. }
  783. return ($address->city ?? null) === ($placeholder['city'] ?? null)
  784. && ($address->postcode ?? null) === ($placeholder['postcode'] ?? null);
  785. }
  786. protected function resolveOldCartId($order): ?int
  787. {
  788. $additional = $order->payment?->additional ?? [];
  789. $cartId = $additional['cart_id'] ?? null;
  790. return $cartId ? (int) $cartId : null;
  791. }
  792. }