CheckoutProcessor.php 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. <?php
  2. namespace Webkul\BagistoApi\State;
  3. use ApiPlatform\Metadata\Operation;
  4. use ApiPlatform\State\ProcessorInterface;
  5. use Illuminate\Support\Facades\Request;
  6. use Webkul\BagistoApi\Dto\CartData;
  7. use Webkul\BagistoApi\Dto\CheckoutAddressInput;
  8. use Webkul\BagistoApi\Dto\CheckoutAddressOutput;
  9. use Webkul\BagistoApi\Dto\PaymentInitiateInput;
  10. use Webkul\BagistoApi\Exception\AuthenticationException;
  11. use Webkul\BagistoApi\Exception\OperationFailedException;
  12. use Webkul\BagistoApi\Exception\ResourceNotFoundException;
  13. use Webkul\BagistoApi\Facades\CartTokenFacade;
  14. use Webkul\BagistoApi\Facades\TokenHeaderFacade;
  15. use Webkul\BagistoApi\Services\PaymentService;
  16. use Webkul\Checkout\Facades\Cart;
  17. use Webkul\Checkout\Repositories\CartRepository;
  18. use Webkul\Customer\Repositories\CustomerRepository;
  19. use Webkul\Sales\Repositories\OrderRepository;
  20. /**
  21. * Handles checkout operations including address, shipping, payment, and order creation.
  22. */
  23. class CheckoutProcessor implements ProcessorInterface
  24. {
  25. public function __construct(
  26. protected CustomerRepository $customerRepository,
  27. protected OrderRepository $orderRepository,
  28. protected CartRepository $cartRepository,
  29. protected ?PaymentService $paymentService = null,
  30. ) {}
  31. /**
  32. * Process checkout operation.
  33. */
  34. public function process(
  35. mixed $data,
  36. Operation $operation,
  37. array $uriVariables = [],
  38. array $context = []
  39. ): mixed {
  40. $request = Request::instance() ?? ($context['request'] ?? null);
  41. $operationName = $this->mapOperation($operation);
  42. if ($operationName === 'read') {
  43. // Extract token from Authorization header only (no context/input parameters)
  44. $token = TokenHeaderFacade::getAuthorizationBearerToken($request);
  45. if (! $token) {
  46. throw new AuthenticationException(__('bagistoapi::app.graphql.cart.authentication-required'));
  47. }
  48. $cart = CartTokenFacade::getCartByToken($token);
  49. if (! $cart) {
  50. throw new ResourceNotFoundException(__('bagistoapi::app.graphql.cart.invalid-token'));
  51. }
  52. return $this->fetchAddresses($cart);
  53. }
  54. if (! $data instanceof CheckoutAddressInput) {
  55. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.invalid-input'));
  56. }
  57. // Extract token from Authorization header (Bearer token) via TokenHeaderFacade
  58. // Token is NOT a DTO property - it's extracted from Authorization header only
  59. $token = null;
  60. if ($request) {
  61. $token = TokenHeaderFacade::getAuthorizationBearerToken($request);
  62. }
  63. if (! $token) {
  64. throw new AuthenticationException(__('bagistoapi::app.graphql.cart.authentication-required'));
  65. }
  66. $cart = CartTokenFacade::getCartByToken($token);
  67. if (! $cart) {
  68. throw new ResourceNotFoundException(__('bagistoapi::app.graphql.cart.invalid-token'));
  69. }
  70. return match ($operationName) {
  71. 'saveAddress' => $this->saveAddress($cart, $data),
  72. 'saveShippingMethod' => $this->saveShippingMethod($cart, $data),
  73. 'savePaymentMethod' => $this->savePaymentMethod($cart, $data),
  74. 'createOrder' => $this->createOrder($cart, $data),
  75. default => throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.unknown-operation', ['operation' => $operationName])),
  76. };
  77. }
  78. /**
  79. * Map BagistoApi operation name to internal operation type
  80. */
  81. private function mapOperation(Operation $operation): string
  82. {
  83. $operationName = $operation->getName();
  84. $resourceClass = $operation->getClass();
  85. $resourceClassName = $resourceClass ? class_basename($resourceClass) : '';
  86. return match ($resourceClassName) {
  87. 'CheckoutAddress' => 'saveAddress',
  88. 'CheckoutShippingMethod' => 'saveShippingMethod',
  89. 'CheckoutPaymentMethod' => 'savePaymentMethod',
  90. 'CheckoutOrder' => 'createOrder',
  91. default => $operationName,
  92. };
  93. }
  94. /**
  95. * Save billing and shipping addresses for cart.
  96. */
  97. private function saveAddress($cart, CheckoutAddressInput $input)
  98. {
  99. try {
  100. if (! $input->billingFirstName && ! $input->billingAddress) {
  101. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.billing-address-required'));
  102. }
  103. if ($cart->haveStockableItems()) {
  104. $hasShippingData = $input->shippingFirstName || $input->shippingAddress || $input->useForShipping;
  105. if (! $hasShippingData) {
  106. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.shipping-address-required'));
  107. }
  108. }
  109. cart()->setCart($cart);
  110. Cart::saveAddresses($this->buildAddressPayload($input));
  111. $cart->refresh();
  112. $billingAddress = $cart->billing_address;
  113. $shippingAddress = $cart->shipping_address;
  114. if (! $billingAddress) {
  115. throw new OperationFailedException('No billing address was provided');
  116. }
  117. Cart::collectTotals();
  118. if ($cart->haveStockableItems()) {
  119. \Webkul\Shipping\Facades\Shipping::collectRates();
  120. }
  121. return $this->buildAddressOutput($billingAddress, $shippingAddress);
  122. } catch (\Exception $e) {
  123. throw new OperationFailedException($e->getMessage(), 0, $e);
  124. }
  125. }
  126. /**
  127. * Build payload expected by Cart::saveAddresses from GraphQL input.
  128. */
  129. private function buildAddressPayload(CheckoutAddressInput $input): array
  130. {
  131. $payload = [
  132. 'billing' => [
  133. 'first_name' => $input->billingFirstName,
  134. 'last_name' => $input->billingLastName,
  135. 'email' => $input->billingEmail,
  136. 'company_name' => $input->billingCompanyName,
  137. 'address' => $this->normalizeAddressLines($input->billingAddress),
  138. 'country' => $input->billingCountry,
  139. 'state' => $input->billingState,
  140. 'city' => $input->billingCity,
  141. 'postcode' => $input->billingPostcode,
  142. 'phone' => $input->billingPhoneNumber,
  143. 'use_for_shipping' => (bool) $input->useForShipping,
  144. ],
  145. ];
  146. if (! $input->useForShipping) {
  147. $payload['shipping'] = [
  148. 'first_name' => $input->shippingFirstName,
  149. 'last_name' => $input->shippingLastName,
  150. 'email' => $input->shippingEmail,
  151. 'company_name' => $input->shippingCompanyName,
  152. 'address' => $this->normalizeAddressLines($input->shippingAddress),
  153. 'country' => $input->shippingCountry,
  154. 'state' => $input->shippingState,
  155. 'city' => $input->shippingCity,
  156. 'postcode' => $input->shippingPostcode,
  157. 'phone' => $input->shippingPhoneNumber,
  158. ];
  159. }
  160. return $payload;
  161. }
  162. /**
  163. * Convert textarea-like address string into Cart::saveAddresses line array.
  164. */
  165. private function normalizeAddressLines(?string $address): array
  166. {
  167. if ($address === null) {
  168. return [];
  169. }
  170. $lines = preg_split('/\r\n|\r|\n/', $address) ?: [];
  171. $lines = array_values(array_filter(array_map('trim', $lines), static fn ($line) => $line !== ''));
  172. return $lines ?: [''];
  173. }
  174. /**
  175. * Save shipping method for cart.
  176. */
  177. private function saveShippingMethod($cart, CheckoutAddressInput $input)
  178. {
  179. try {
  180. cart()->setCart($cart);
  181. if (! $input->shippingMethod) {
  182. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.shipping-method-required'));
  183. }
  184. \Webkul\Shipping\Facades\Shipping::collectRates();
  185. if (! \Webkul\Shipping\Facades\Shipping::isMethodCodeExists($input->shippingMethod)) {
  186. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.invalid-shipping-method'));
  187. }
  188. if (! \Webkul\Checkout\Facades\Cart::saveShippingMethod($input->shippingMethod)) {
  189. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.shipping-method-save-failed'));
  190. }
  191. \Webkul\Checkout\Facades\Cart::collectTotals();
  192. return (object) [
  193. 'id' => (string) $cart->id,
  194. 'success' => true,
  195. 'message' => __('bagistoapi::app.graphql.checkout.shipping-method-saved'),
  196. 'cartToken' => (string) ($cart->guest_cart_token ?? $cart->customer_id),
  197. 'shippingMethod' => (string) ($cart->shipping_method ?? ''),
  198. ];
  199. } catch (\Exception $e) {
  200. throw new OperationFailedException($e->getMessage(), 0, $e);
  201. }
  202. }
  203. /**
  204. * Save payment method for cart.
  205. */
  206. private function savePaymentMethod($cart, CheckoutAddressInput $input)
  207. {
  208. cart()->setCart($cart);
  209. if (! $input->paymentMethod) {
  210. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.payment-method-required'));
  211. }
  212. $paymentMethodConfig = config('payment_methods.'.$input->paymentMethod);
  213. if (! $paymentMethodConfig || ! isset($paymentMethodConfig['class'])) {
  214. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.invalid-payment-method'));
  215. }
  216. if (! \Webkul\Checkout\Facades\Cart::savePaymentMethod(['method' => $input->paymentMethod])) {
  217. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.payment-method-save-failed'));
  218. }
  219. try {
  220. \Webkul\Checkout\Facades\Cart::collectTotals();
  221. $cart = \Webkul\Checkout\Facades\Cart::getCart();
  222. $response = (object) [
  223. 'success' => true,
  224. 'message' => __('bagistoapi::app.graphql.checkout.payment-method-saved'),
  225. 'cartToken' => (string) ($cart->guest_cart_token ?? $cart->customer_id),
  226. 'paymentMethod' => (string) ($cart->payment?->method ?? ''),
  227. ];
  228. if ($cart->payment) {
  229. $paymentMethodClass = app($paymentMethodConfig['class']);
  230. if (method_exists($paymentMethodClass, 'getPaymentUrl') && method_exists($paymentMethodClass, 'getPaymentData')) {
  231. $paymentData = $paymentMethodClass->getPaymentData($cart);
  232. if ($input->paymentSuccessUrl) {
  233. $paymentData['surl'] = $input->paymentSuccessUrl;
  234. }
  235. if ($input->paymentFailureUrl) {
  236. $paymentData['furl'] = $input->paymentFailureUrl;
  237. }
  238. if ($input->paymentCancelUrl) {
  239. $paymentData['curl'] = $input->paymentCancelUrl;
  240. }
  241. if ($input->paymentSuccessUrl || $input->paymentFailureUrl || $input->paymentCancelUrl) {
  242. if (method_exists($paymentMethodClass, 'generateHash')) {
  243. $paymentData['hash'] = $paymentMethodClass->generateHash(
  244. $paymentData['txnid'],
  245. $paymentData['amount'],
  246. $paymentData['productinfo'],
  247. $paymentData['firstname'],
  248. $paymentData['email'],
  249. $paymentData['udf1']
  250. );
  251. }
  252. }
  253. $response->paymentGatewayUrl = $paymentMethodClass->getPaymentUrl();
  254. $response->paymentData = json_encode($paymentData);
  255. }
  256. }
  257. return $response;
  258. } catch (\Exception $e) {
  259. throw new OperationFailedException($e->getMessage(), 0, $e);
  260. }
  261. }
  262. /**
  263. * Create order from cart data.
  264. *
  265. * Kept for backwards compatibility — the actual implementation now
  266. * lives in `PaymentService::initiate()` so the unified payment
  267. * initiation/replay/callback pipeline can share the same logic.
  268. */
  269. private function createOrder($cart, CheckoutAddressInput $input)
  270. {
  271. try {
  272. $paymentService = $this->paymentService ?: app(PaymentService::class);
  273. $result = $paymentService->initiate($cart, $this->toInitiateInput($input));
  274. $order = $result['order'];
  275. return (object) [
  276. 'id' => $cart->id,
  277. 'cartToken' => (string) ($cart->guest_cart_token ?? $cart->customer_id),
  278. 'orderId' => (string) $order->id,
  279. 'paymentTransactionId' => $result['gatewayOrderId'],
  280. ];
  281. } catch (\Exception $e) {
  282. throw new OperationFailedException($e->getMessage(), 0, $e);
  283. }
  284. }
  285. /**
  286. * Map the legacy CheckoutAddressInput onto the new PaymentInitiateInput.
  287. * This keeps `checkoutOrderCreate` byte-for-byte compatible while the
  288. * actual logic moves to PaymentService.
  289. */
  290. private function toInitiateInput(CheckoutAddressInput $input): PaymentInitiateInput
  291. {
  292. $payload = new PaymentInitiateInput;
  293. $payload->expressCheckout = false;
  294. $payload->paymentMethod = $input->paymentMethod;
  295. $payload->paymentSuccessUrl = $input->paymentSuccessUrl;
  296. $payload->paymentFailureUrl = $input->paymentFailureUrl;
  297. $payload->paymentCancelUrl = $input->paymentCancelUrl;
  298. $payload->billingFirstName = $input->billingFirstName;
  299. $payload->billingLastName = $input->billingLastName;
  300. $payload->billingEmail = $input->billingEmail;
  301. $payload->billingCompanyName = $input->billingCompanyName;
  302. $payload->billingAddress = $input->billingAddress;
  303. $payload->billingCountry = $input->billingCountry;
  304. $payload->billingState = $input->billingState;
  305. $payload->billingCity = $input->billingCity;
  306. $payload->billingPostcode = $input->billingPostcode;
  307. $payload->billingPhoneNumber = $input->billingPhoneNumber;
  308. $payload->shippingFirstName = $input->shippingFirstName;
  309. $payload->shippingLastName = $input->shippingLastName;
  310. $payload->shippingEmail = $input->shippingEmail;
  311. $payload->shippingCompanyName = $input->shippingCompanyName;
  312. $payload->shippingAddress = $input->shippingAddress;
  313. $payload->shippingCountry = $input->shippingCountry;
  314. $payload->shippingState = $input->shippingState;
  315. $payload->shippingCity = $input->shippingCity;
  316. $payload->shippingPostcode = $input->shippingPostcode;
  317. $payload->shippingPhoneNumber = $input->shippingPhoneNumber;
  318. $payload->useForShipping = $input->useForShipping;
  319. $payload->shippingMethod = $input->shippingMethod;
  320. return $payload;
  321. }
  322. /**
  323. * Validate order can be created.
  324. *
  325. * Retained because tests assert on its error messages; the actual
  326. * order creation now runs through PaymentService::initiate which
  327. * re-applies the same checks internally.
  328. */
  329. private function validateOrderCreation($cart, CheckoutAddressInput $input): void
  330. {
  331. if (! $cart || $cart->items()->count() === 0) {
  332. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.cart-empty'));
  333. }
  334. if (auth()->guard('customer')->check()) {
  335. $customer = auth()->guard('customer')->user();
  336. if ($customer && $customer->is_suspended) {
  337. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.account-suspended'));
  338. }
  339. if ($customer && ! $customer->status) {
  340. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.account-inactive'));
  341. }
  342. }
  343. $minimumOrderAmount = core()->getConfigData('sales.order_settings.minimum_order.minimum_order_amount') ?: 0;
  344. if (! \Webkul\Checkout\Facades\Cart::haveMinimumOrderAmount()) {
  345. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.minimum-order-not-met', ['amount' => core()->currency($minimumOrderAmount)]));
  346. }
  347. $hasBillingAddress = $input->billingAddress || $cart->billing_address()->exists();
  348. if (! $hasBillingAddress) {
  349. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.billing-address-required'));
  350. }
  351. $hasShippingAddress = $input->shippingAddress || $input->useForShipping || $cart->shipping_address()->exists();
  352. if (! $hasShippingAddress && $cart->haveStockableItems()) {
  353. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.shipping-address-required'));
  354. }
  355. $hasEmail = $cart->customer_email || $input->billingEmail || ($cart->billing_address && $cart->billing_address->email);
  356. if (! $hasEmail) {
  357. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.email-required'));
  358. }
  359. if ($cart->haveStockableItems()) {
  360. $hasShippingMethod = $input->shippingMethod || $cart->shipping_method;
  361. if (! $hasShippingMethod) {
  362. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.shipping-method-required'));
  363. }
  364. if (! $cart->selected_shipping_rate) {
  365. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.invalid-shipping-method'));
  366. }
  367. }
  368. $hasPaymentMethod = $input->paymentMethod || $cart->payment()->exists();
  369. if (! $hasPaymentMethod) {
  370. throw new OperationFailedException(__('bagistoapi::app.graphql.checkout.payment-method-required'));
  371. }
  372. }
  373. /**
  374. * Build CartData from cart model.
  375. */
  376. private function buildCartData($cart): CartData
  377. {
  378. $cartData = CartData::fromModel($cart);
  379. return $cartData;
  380. }
  381. /**
  382. * Build CheckoutAddressOutput from cart address models.
  383. */
  384. private function buildAddressOutput($billingAddress = null, $shippingAddress = null)
  385. {
  386. $output = (object) [
  387. 'success' => true,
  388. 'message' => __('bagistoapi::app.graphql.checkout.address-saved'),
  389. ];
  390. if ($billingAddress) {
  391. $output->id = $billingAddress->id;
  392. $output->cartToken = (string) ($billingAddress->cart->guest_cart_token ?? $billingAddress->cart->customer_id);
  393. $output->customerId = $billingAddress->cart->customer_id;
  394. $output->billingFirstName = (string) ($billingAddress->first_name ?? '');
  395. $output->billingLastName = (string) ($billingAddress->last_name ?? '');
  396. $output->billingEmail = (string) ($billingAddress->email ?? '');
  397. $output->billingCompanyName = (string) ($billingAddress->company_name ?? '');
  398. $output->billingAddress = (string) ($billingAddress->address ?? '');
  399. $output->billingCountry = (string) ($billingAddress->country ?? '');
  400. $output->billingState = (string) ($billingAddress->state ?? '');
  401. $output->billingCity = (string) ($billingAddress->city ?? '');
  402. $output->billingPostcode = (string) ($billingAddress->postcode ?? '');
  403. $output->billingPhoneNumber = (string) ($billingAddress->phone ?? '');
  404. }
  405. if ($shippingAddress) {
  406. $output->shippingFirstName = (string) ($shippingAddress->first_name ?? '');
  407. $output->shippingLastName = (string) ($shippingAddress->last_name ?? '');
  408. $output->shippingEmail = (string) ($shippingAddress->email ?? '');
  409. $output->shippingCompanyName = (string) ($shippingAddress->company_name ?? '');
  410. $output->shippingAddress = (string) ($shippingAddress->address ?? '');
  411. $output->shippingCountry = (string) ($shippingAddress->country ?? '');
  412. $output->shippingState = (string) ($shippingAddress->state ?? '');
  413. $output->shippingCity = (string) ($shippingAddress->city ?? '');
  414. $output->shippingPostcode = (string) ($shippingAddress->postcode ?? '');
  415. $output->shippingPhoneNumber = (string) ($shippingAddress->phone ?? '');
  416. }
  417. return $output;
  418. }
  419. /**
  420. * Fetch billing and shipping addresses for cart.
  421. */
  422. private function fetchAddresses($cart)
  423. {
  424. try {
  425. $output = new \Webkul\BagistoApi\Dto\CheckoutAddressOutput;
  426. $output->id = $cart->id;
  427. $output->cartToken = $cart->guest_cart_token ?? $cart->customer_id;
  428. $output->customerId = $cart->customer_id;
  429. $billingAddress = $cart->billing_address;
  430. if ($billingAddress) {
  431. $output->billingFirstName = $billingAddress->first_name;
  432. $output->billingLastName = $billingAddress->last_name;
  433. $output->billingEmail = $billingAddress->email;
  434. $output->billingCompanyName = $billingAddress->company_name;
  435. $output->billingAddress = $billingAddress->address;
  436. $output->billingCountry = $billingAddress->country;
  437. $output->billingState = $billingAddress->state;
  438. $output->billingCity = $billingAddress->city;
  439. $output->billingPostcode = $billingAddress->postcode;
  440. $output->billingPhoneNumber = $billingAddress->phone;
  441. }
  442. $shippingAddress = $cart->shipping_address;
  443. if ($shippingAddress) {
  444. $output->shippingFirstName = $shippingAddress->first_name;
  445. $output->shippingLastName = $shippingAddress->last_name;
  446. $output->shippingEmail = $shippingAddress->email;
  447. $output->shippingCompanyName = $shippingAddress->company_name;
  448. $output->shippingAddress = $shippingAddress->address;
  449. $output->shippingCountry = $shippingAddress->country;
  450. $output->shippingState = $shippingAddress->state;
  451. $output->shippingCity = $shippingAddress->city;
  452. $output->shippingPostcode = $shippingAddress->postcode;
  453. $output->shippingPhoneNumber = $shippingAddress->phone;
  454. }
  455. $output->success = true;
  456. $output->message = __('bagistoapi::app.graphql.address.retrieved');
  457. return $output;
  458. } catch (\Exception $e) {
  459. throw new OperationFailedException($e->getMessage(), 0, $e);
  460. }
  461. }
  462. }