PaymentService.php 39 KB

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