diff --git a/CHANGELOG.md b/CHANGELOG.md index 50228d6..79fc2d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog All notable changes to this project will be documented in this file. +## [2.2.0] +### Added +- Support Apple Pay functionality + ## [2.1.0] ### Added - Add support for MarketPay payment methods. diff --git a/Docs/Configuration/shopware_configure_altapay_terminal_detail.png b/Docs/Configuration/shopware_configure_altapay_terminal_detail.png index f49261a..2988f5c 100644 Binary files a/Docs/Configuration/shopware_configure_altapay_terminal_detail.png and b/Docs/Configuration/shopware_configure_altapay_terminal_detail.png differ diff --git a/composer.json b/composer.json index 6181673..5fb7123 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "AltaPay plugin for Shopware 6", "type": "shopware-platform-plugin", "license": "MIT", - "version": "2.1.0", + "version": "2.2.0", "authors": [ { "name": "AltaPay A/S", diff --git a/sonar-project.properties b/sonar-project.properties index 6b906d5..5fc80f5 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -2,3 +2,8 @@ sonar.projectKey=AltaPay_plugin-shopware_6ff55bfb-f13e-4cba-a54c-9f0804511bbf sonar.projectBaseDir=src sonar.coverage.exclusions=** sonar.cpd.exclusions=** +sonar.issue.ignore.multicriteria=e1,e2 +sonar.issue.ignore.multicriteria.e1.ruleKey=php:S1142 +sonar.issue.ignore.multicriteria.e1.resourceKey=**/Controller/*.php +sonar.issue.ignore.multicriteria.e2.ruleKey=php:S3776 +sonar.issue.ignore.multicriteria.e2.resourceKey=**/Service/*.php diff --git a/src/Controller/CallbackController.php b/src/Controller/CallbackController.php index eaf7f31..ac3513d 100644 --- a/src/Controller/CallbackController.php +++ b/src/Controller/CallbackController.php @@ -3,6 +3,7 @@ namespace Wexo\AltaPay\Controller; use Exception; +use GuzzleHttp\Exception\GuzzleException; use Psr\Log\LoggerInterface; use Shopware\Core\Checkout\Cart\CartException; use Shopware\Core\Checkout\Order\OrderEntity; @@ -16,19 +17,24 @@ use Shopware\Storefront\Controller\StorefrontController; use SimpleXMLElement; use Symfony\Component\HttpFoundation\IpUtils; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\RouterInterface; use Symfony\Contracts\Translation\TranslatorInterface; +use Wexo\AltaPay\Service\Exception\AltaPayException; use Wexo\AltaPay\Service\PaymentService; use Twig\Environment; #[Route(defaults: ['_routeScope' => ['storefront']])] class CallbackController { + private const ERROR_MISSING_PARAMETERS = 'Missing required parameters'; + public function __construct( protected readonly PaymentService $paymentService, protected readonly EntityRepository $orderRepository, @@ -37,7 +43,8 @@ public function __construct( protected readonly TranslatorInterface $translator, protected readonly SystemConfigService $systemConfigService, protected readonly EntityRepository $mediaRepository, - protected Environment $twig + protected Environment $twig, + protected readonly RequestStack $requestStack ) { } @@ -176,7 +183,7 @@ public function notification(Request $request, SalesChannelContext $salesChannel $result = new SimpleXMLElement($request->get('xml')); $orderNumber = (string)$result?->Body?->Transactions?->Transaction?->ShopOrderId; if (!$orderNumber) { - throw new Exception(); + throw new AltaPayException(); } } catch (Exception) { return new Response('Error processing request', 400); @@ -195,4 +202,165 @@ public function notification(Request $request, SalesChannelContext $salesChannel $this->paymentService->transactionCallback($result, $order, $transaction, $salesChannelContext, $allRequestParams, true); return new Response("Acknowledged", 200); } + + /** + * Validate an Apple Pay merchant session using only the payment method ID. + */ + #[Route( + path: '/altapay/applepay/validate-merchant-by-method', + name: 'altapay.applepay.validate_merchant_by_method', + defaults: ['auth_required' => false, 'csrf_protected' => false], + methods: ['POST'] + )] + public function validateMerchantByMethod(Request $request, SalesChannelContext $salesChannelContext): Response + { + $validationUrl = $request->get('validationUrl'); + $paymentMethodId = $request->get('paymentMethodId'); + + if (!$validationUrl || !$paymentMethodId) { + return new JsonResponse(['success' => false, 'error' => self::ERROR_MISSING_PARAMETERS]); + } + + try { + $config = $this->paymentService->getApplePayConfigByPaymentMethodId( + $paymentMethodId, + $salesChannelContext->getContext(), + $salesChannelContext->getSalesChannelId() + ); + } catch (\Throwable $e) { + $this->logger->error('Apple Pay validate-by-method config error: ' . $e->getMessage(), ['exception' => $e]); + return new JsonResponse(['success' => false, 'error' => 'Payment method config not found: ' . $e->getMessage()]); + } + + try { + $applePaySession = $this->paymentService->cardWalletSession( + $validationUrl, + $config['terminal'], + $request->getHost(), + $salesChannelContext->getSalesChannelId() + ); + return new JsonResponse(['success' => true, 'applePaySession' => $applePaySession]); + } catch (\Throwable $e) { + $this->logger->error('Apple Pay validate-by-method error: ' . $e->getMessage(), ['exception' => $e]); + return new JsonResponse(['success' => false, 'error' => $e->getMessage()]); + } + } + + /** + * Serve the dedicated Apple Pay checkout page. + */ + #[Route( + path: '/altapay/applepay/checkout', + name: 'altapay.applepay.checkout', + defaults: ['auth_required' => false], + methods: ['GET'] + )] + public function applePayCheckout(Request $request, SalesChannelContext $salesChannelContext): Response + { + $orderTransactionId = $request->get('orderTransactionId'); + $returnUrl = $request->get('returnUrl'); + + if (!$orderTransactionId || !$returnUrl) { + return new Response('Missing orderTransactionId or returnUrl', 400); + } + + try { + $config = $this->paymentService->getApplePayConfig($orderTransactionId, $salesChannelContext->getContext()); + } catch (\Exception $e) { + $this->logger->error('Apple Pay checkout config error: ' . $e->getMessage()); + return new Response('Error loading payment data: ' . $e->getMessage(), 500); + } + + $config['returnUrl'] = $returnUrl; + $config['sessionUrl'] = $this->router->generate('altapay.applepay.session', [], UrlGeneratorInterface::ABSOLUTE_URL); + $config['authorizeUrl']= $this->router->generate('altapay.applepay.authorize', [], UrlGeneratorInterface::ABSOLUTE_URL); + + return $this->renderTemplate( + '@WexoAltaPay/gateway/applepay.html.twig', + ['applePayData' => $config] + ); + } + + /** + * Validate the Apple Pay merchant session with AltaPay (called by onvalidatemerchant JS event). + */ + #[Route( + path: '/altapay/applepay/session', + name: 'altapay.applepay.session', + defaults: ['auth_required' => false, 'csrf_protected' => false], + methods: ['POST'] + )] + public function applePaySession(Request $request, SalesChannelContext $salesChannelContext): Response + { + $validationUrl = $request->get('validationUrl'); + $orderTransactionId = $request->get('orderTransactionId'); + + if (!$validationUrl || !$orderTransactionId) { + return new JsonResponse(['success' => false, 'error' => self::ERROR_MISSING_PARAMETERS], 400); + } + + try { + $config = $this->paymentService->getApplePayConfig($orderTransactionId, $salesChannelContext->getContext()); + } catch (GuzzleException $e) { + $this->logger->error('Apple Pay session config error: ' . $e->getMessage()); + return new JsonResponse(['success' => false, 'error' => 'Failed to load payment config: ' . $e->getMessage()]); + } + + try { + $applePaySession = $this->paymentService->cardWalletSession( + $validationUrl, + $config['terminal'], + $request->getHost(), + $config['salesChannelId'] + ); + return new JsonResponse(['success' => true, 'applePaySession' => $applePaySession]); + } catch (GuzzleException $e) { + $this->logger->error('Apple Pay merchant validation network error: ' . $e->getMessage()); + return new JsonResponse(['success' => false, 'error' => 'Network error during merchant validation: ' . $e->getMessage()]); + } catch (\Exception $e) { + $this->logger->error('Apple Pay merchant validation error: ' . $e->getMessage()); + return new JsonResponse(['success' => false, 'error' => $e->getMessage()]); + } + } + + /** + * Process the Apple Pay payment token received after onpaymentauthorized. + */ + #[Route( + path: '/altapay/applepay/authorize', + name: 'altapay.applepay.authorize', + defaults: ['auth_required' => false, 'csrf_protected' => false], + methods: ['POST'] + )] + public function applePayAuthorize(Request $request, SalesChannelContext $salesChannelContext): Response + { + $content = $request->getContent(); + $data = json_decode($content, true); + + $orderTransactionId = $data['orderTransactionId'] ?? null; + $providerData = $data['providerData'] ?? null; + $returnUrl = $data['returnUrl'] ?? null; + + if (!$orderTransactionId || $providerData === null || !$returnUrl) { + return new JsonResponse(['success' => false, 'error' => self::ERROR_MISSING_PARAMETERS], 400); + } + + $providerDataJson = json_encode($providerData); + + try { + $finalReturnUrl = $this->paymentService->processApplePayPayment( + $orderTransactionId, + $providerDataJson, + $returnUrl, + $salesChannelContext->getContext() + ); + return new JsonResponse(['success' => true, 'redirectUrl' => $finalReturnUrl]); + } catch (GuzzleException $e) { + $this->logger->error('Apple Pay authorize network error: ' . $e->getMessage()); + return new JsonResponse(['success' => false, 'error' => 'Network error during payment processing: ' . $e->getMessage()]); + } catch (\Exception $e) { + $this->logger->error('Apple Pay authorize error: ' . $e->getMessage()); + return new JsonResponse(['success' => false, 'error' => 'Payment processing failed: ' . $e->getMessage()]); + } + } } diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml index f2f28d6..ae26907 100644 --- a/src/Resources/config/services.xml +++ b/src/Resources/config/services.xml @@ -40,6 +40,7 @@ + @@ -71,5 +72,10 @@ + + + + + diff --git a/src/Resources/views/gateway/apple-pay-session.html.twig b/src/Resources/views/gateway/apple-pay-session.html.twig new file mode 100644 index 0000000..a99a230 --- /dev/null +++ b/src/Resources/views/gateway/apple-pay-session.html.twig @@ -0,0 +1,92 @@ +{# + Shared Apple Pay session helper. + + Wires the ApplePaySession event handlers that are identical regardless of + where the session is started from: merchant validation (POST to a caller + supplied URL), payment-method reselection, shipping no-ops, and cancel. + + Only `onvalidatemerchant`'s request body and `onpaymentauthorized` differ + between entry points (pre-order vs. post-order), so those are left to the + caller via `cfg.validateMerchantParams` and `cfg.onPaymentAuthorized`. + + Include this once before calling AltaPayApplePay.runSession(cfg), where + cfg is: + { + countryCode, currencyCode, supportedNetworks (array), label, amount, + validateMerchantUrl, validateMerchantParams (plain object, optional), + onPaymentAuthorized: function (event, session) {}, + onError: function (message) {}, + onCancel: function () {} + } +#} + diff --git a/src/Resources/views/gateway/applepay.html.twig b/src/Resources/views/gateway/applepay.html.twig new file mode 100644 index 0000000..6dfdfae --- /dev/null +++ b/src/Resources/views/gateway/applepay.html.twig @@ -0,0 +1,270 @@ + +{% block altapay_applepay_head %} + + + + + {{ 'altapay.gateway.title'|trans }} + + + + + +{% endblock %} + +{% block altapay_applepay_body %} + +
+ {% block applepay_title %} +

{{ 'altapay.gateway.title'|trans }}

+ {% endblock %} + + {% block applepay_order_info %} +

+ {{ 'altapay.gateway.orderNumber'|trans }}: {{ applePayData.orderTransactionId|slice(0,8)|upper }} +

+ {% endblock %} + + {% block applepay_amount %} +

{{ applePayData.currency }} {{ applePayData.amount }}

+ {% endblock %} + + {% block applepay_button %} +
+ +
+ Apple Pay is not available in this browser. Please use Safari on a supported Apple device. +
+
+ {% endblock %} + + {% block applepay_error %} +
+ {% endblock %} + + {% block applepay_back %} + + ← {{ 'checkout.finishButtonBackToShop'|trans|striptags }} + + {% endblock %} +
+ + {% block applepay_script %} + {% include '@WexoAltaPay/gateway/apple-pay-session.html.twig' %} + + {% endblock %} + +{% endblock %} + diff --git a/src/Resources/views/storefront/page/checkout/confirm/index.html.twig b/src/Resources/views/storefront/page/checkout/confirm/index.html.twig new file mode 100644 index 0000000..e7ea775 --- /dev/null +++ b/src/Resources/views/storefront/page/checkout/confirm/index.html.twig @@ -0,0 +1,204 @@ +{% sw_extends '@Storefront/storefront/page/checkout/confirm/index.html.twig' %} + +{% block page_checkout_confirm %} + {{ parent() }} + + {# + Detect Apple Pay methods. customFields may be on pm.customFields (not translated), + check that first, fall back to pm.translated.customFields. + #} + {% set altapayAppleMethods = [] %} + {% if page.paymentMethods is defined %} + {% for pm in page.paymentMethods %} + {% set cf = pm.customFields ?? pm.translated.customFields ?? {} %} + {% if cf.wexoAltaPayIsApplePay is defined and cf.wexoAltaPayIsApplePay %} + {% set altapayAppleMethods = altapayAppleMethods|merge([{ + 'id': pm.id, + 'label': pm.translated.name, + 'networks': (cf.wexoAltaPayApplePayNetworks is defined and cf.wexoAltaPayApplePayNetworks) + ? cf.wexoAltaPayApplePayNetworks + : 'visa,masterCard,amex' + }]) %} + {% endif %} + {% endfor %} + {% endif %} + + {% if altapayAppleMethods is not empty %} + + {% include '@WexoAltaPay/gateway/apple-pay-session.html.twig' %} + + {% endif %} +{% endblock %} diff --git a/src/Service/Exception/AltaPayException.php b/src/Service/Exception/AltaPayException.php new file mode 100644 index 0000000..08b3c31 --- /dev/null +++ b/src/Service/Exception/AltaPayException.php @@ -0,0 +1,7 @@ +get($orderTransactionId); if (!$orderTransaction) { - throw new \RuntimeException("OrderTransaction not found."); + throw new AltaPayException("OrderTransaction not found."); } $orderId = $orderTransaction->getOrderId(); @@ -141,7 +146,7 @@ private function loadOrderTransactionAndOrder(PaymentTransactionStruct $transact $order = $this->orderRepository->search($criteria, $context)->first(); if (!$order) { - throw new \RuntimeException("Order not found."); + throw new AltaPayException("Order not found."); } $billingAddress = $order->getBillingAddress(); @@ -234,6 +239,20 @@ public function pay( } $paymentRequestType = ($paymentMethod->getTranslated()['customFields'][self::ALTAPAY_AUTO_CAPTURE_CUSTOM_FIELD] ?? null) ? 'paymentAndCapture' : 'payment'; + + $isApplePay = (bool)($paymentMethod->getTranslated()['customFields'][self::ALTAPAY_IS_APPLE_PAY_CUSTOM_FIELD] ?? false); + if ($isApplePay) { + $applePayCheckoutUrl = $this->router->generate( + 'altapay.applepay.checkout', + [ + 'orderTransactionId' => $orderTransactionId, + 'returnUrl' => $transaction->getReturnUrl(), + ], + UrlGeneratorInterface::ABSOLUTE_URL + ); + return new RedirectResponse($applePayCheckoutUrl); + } + try { $altaPayResponse = $this->createPaymentRequest( $order, @@ -376,8 +395,26 @@ public function finalize( ); $allRequestParams = array_merge($request->query->all(), $request->request->all()); + $xmlString = $request->get('xml'); + + // For Apple Pay payments the XML result is stored in the session + if (empty($xmlString)) { + $sessionKey = self::ALTAPAY_APPLE_PAY_RESULT_PREFIX . $transaction->getOrderTransactionId(); + try { + $session = $this->requestStack->getSession(); + $storedData = $session->get($sessionKey); + if ($storedData) { + $xmlString = $storedData['xml']; + $allRequestParams = array_merge($allRequestParams, $storedData['params'] ?? []); + $session->remove($sessionKey); + } + } catch (\Exception $e) { + $this->logger->warning('Apple Pay: failed to restore stored payment result from session: ' . $e->getMessage()); + } + } + $this->transactionCallback( - new SimpleXMLElement($request->get('xml')), + new SimpleXMLElement($xmlString), $order, $orderTransaction, $salesChannelContext, @@ -655,7 +692,8 @@ public function createPaymentRequest( SalesChannelContext $context, string $terminal, string $paymentRequestType, - string $sessionId = null + string $sessionId = null, + string $providerData = null ): SimpleXMLElement { $orderLines = []; $itemIdCounter = 0; @@ -831,6 +869,10 @@ public function createPaymentRequest( $formParams['session_id'] = $sessionId; } + if ($providerData !== null) { + $formParams['provider_data'] = $providerData; + } + $checkoutStyle = $this->systemConfigService->get('WexoAltaPay.config.checkoutStyle', $salesChannelId); if (!empty($checkoutStyle)) { $formParams['form_template'] = $checkoutStyle; @@ -843,8 +885,222 @@ public function createPaymentRequest( return new SimpleXMLElement($response->getBody()->getContents()); } - public function getTransaction(OrderEntity $order, string $salesChannelId): ResponseInterface + /** + * Load Apple Pay config (terminal, label, networks) from a payment method ID directly. + * + * @throws \RuntimeException + */ + public function getApplePayConfigByPaymentMethodId( + string $paymentMethodId, + Context $context, + string $salesChannelId + ): array { + $criteria = new Criteria([$paymentMethodId]); + $paymentMethod = $this->container->get('payment_method.repository') + ->search($criteria, $context) + ->first(); + + if (!$paymentMethod) { + throw new AltaPayException('Payment method not found: ' . $paymentMethodId); + } + + $customFields = $paymentMethod->getTranslated()['customFields'] ?? []; + + if (empty($customFields[self::ALTAPAY_IS_APPLE_PAY_CUSTOM_FIELD])) { + throw new AltaPayException('Payment method is not an Apple Pay terminal: ' . $paymentMethodId); + } + + $terminal = $customFields[self::ALTAPAY_TERMINAL_ID_CUSTOM_FIELD] ?? null; + $salesChannelTerminal = $customFields[self::ALTAPAY_SALES_CHANNEL_TERMINAL_ID] ?? null; + + if (!empty($salesChannelTerminal)) { + $field = 'WexoAltaPay.config.' . $salesChannelTerminal; + $salesChannelTerminalValue = $this->systemConfigService->get($field, $salesChannelId); + if (!empty($salesChannelTerminalValue)) { + $terminal = $salesChannelTerminalValue; + } + } + + if (empty($terminal)) { + throw new AltaPayException( + 'Apple Pay terminal is not configured. Please set the AltaPay Terminal ID on the payment method "' + . ($paymentMethod->getName() ?? $paymentMethodId) . '" in the Shopware admin.' + ); + } + + return [ + 'terminal' => $terminal, + 'applePayLabel' => (string)($paymentMethod->getTranslated()['name'] ?? $paymentMethod->getName() ?? 'Payment'), + 'networks' => array_values(array_filter(array_map('trim', + explode(',', $customFields[self::ALTAPAY_APPLE_PAY_NETWORKS_CUSTOM_FIELD] ?? 'visa,masterCard,amex') + ))), + ]; + } + + /** + * Load all Apple Pay configuration needed for the checkout page, merchant validation, + * and payment authorisation. Reads fresh from the DB — no session dependency. + * + * @throws \RuntimeException + */ + public function getApplePayConfig(string $orderTransactionId, Context $context): array { + $criteria = new Criteria([$orderTransactionId]); + $orderTransaction = $this->orderTransactionRepository->search($criteria, $context)->get($orderTransactionId); + + if (!$orderTransaction) { + throw new AltaPayException('OrderTransaction not found: ' . $orderTransactionId); + } + + $orderId = $orderTransaction->getOrderId(); + $criteria = new Criteria([$orderId]); + $criteria->addAssociation('currency'); + $criteria->addAssociation('billingAddress.country'); + $criteria->addAssociation('salesChannel'); + + $order = $this->orderRepository->search($criteria, $context)->first(); + if (!$order) { + throw new AltaPayException('Order not found for transaction: ' . $orderTransactionId); + } + + $pmCriteria = new Criteria([$orderTransaction->getPaymentMethodId()]); + $paymentMethod = $this->container->get('payment_method.repository') + ->search($pmCriteria, $context) + ->first(); + + $customFields = ($paymentMethod ? $paymentMethod->getTranslated()['customFields'] : null) ?? []; + + $terminal = $customFields[self::ALTAPAY_TERMINAL_ID_CUSTOM_FIELD] ?? null; + $salesChannelTerminal = $customFields[self::ALTAPAY_SALES_CHANNEL_TERMINAL_ID] ?? null; + + if (!empty($salesChannelTerminal)) { + $field = 'WexoAltaPay.config.' . $salesChannelTerminal; + $salesChannelTerminalValue = $this->systemConfigService->get($field, $order->getSalesChannelId()); + if (!empty($salesChannelTerminalValue)) { + $terminal = $salesChannelTerminalValue; + } + } + + $paymentMethodName = $paymentMethod + ? ($paymentMethod->getTranslated()['name'] ?? $paymentMethod->getName()) + : null; + $applePayLabel = (string)$paymentMethodName ?: $order->getSalesChannel()?->getName() ?: 'Payment'; + $networksRaw = (string)($customFields[self::ALTAPAY_APPLE_PAY_NETWORKS_CUSTOM_FIELD] ?? 'visa,masterCard,amex'); + $networks = array_values(array_filter(array_map('trim', explode(',', $networksRaw)))); + $paymentRequestType = ($customFields[self::ALTAPAY_AUTO_CAPTURE_CUSTOM_FIELD] ?? false) ? 'paymentAndCapture' : 'payment'; + + return [ + 'orderTransactionId' => $orderTransactionId, + 'orderId' => $order->getId(), + 'terminal' => $terminal, + 'paymentRequestType' => $paymentRequestType, + 'amount' => (string)round($order->getAmountTotal(), 2), + 'currency' => $order->getCurrency()->getIsoCode(), + 'countryCode' => $order->getBillingAddress()?->getCountry()?->getIso() ?? 'US', + 'salesChannelId' => $order->getSalesChannelId(), + 'applePayLabel' => $applePayLabel, + 'supportedNetworks' => $networks, + ]; + } + + /** + * Validate an Apple Pay merchant session via AltaPay's cardWalletSession API. + * + * @see https://documentation.altapay.com/Content/Ecom/API/API%20Methods/cardWalletSession.htm + * @throws GuzzleException + */ + public function cardWalletSession( + string $validationUrl, + string $terminal, + string $domain, + string $salesChannelId + ): string { + $response = $this->getAltaPayClient($salesChannelId)->request('POST', 'cardWallet/session', [ + 'form_params' => [ + 'terminal' => $terminal, + 'validationUrl' => $validationUrl, + 'domain' => $domain, + ] + ]); + + $xml = new SimpleXMLElement($response->getBody()->getContents()); + + if ((string)$xml->Body->Result !== 'Success') { + throw new AltaPayException( + 'Apple Pay merchant validation failed: ' + . ((string)($xml->Body->MerchantErrorMessage ?? $xml->Header->ErrorMessage ?? 'Unknown error')) + ); + } + + return (string)$xml->Body->ApplePaySession; + } + + /** + * Process an Apple Pay payment token via AltaPay's createPaymentRequest with provider_data. + * + * @throws GuzzleException|\RuntimeException + * @return string The returnUrl that the browser should navigate to (Shopware's finalize URL). + */ + public function processApplePayPayment( + string $orderTransactionId, + string $providerData, + string $returnUrl, + Context $context + ): string { + $config = $this->getApplePayConfig($orderTransactionId, $context); + + $criteria = new Criteria([$config['orderId']]); + $criteria->addAssociation('currency'); + $order = $this->orderRepository->search($criteria, $context)->first(); + if (!$order) { + throw new AltaPayException('Order not found.'); + } + + $amount = number_format((float)$order->getAmountTotal(), 2, '.', ''); + $currency = $order->getCurrency()?->getIsoCode() ?? 'EUR'; + $shopOrderId = $order->getOrderNumber(); + + // --- Call AltaPay cardWallet/authorize --- + $response = $this->getAltaPayClient($config['salesChannelId'])->request('POST', 'cardWallet/authorize', [ + 'form_params' => [ + 'provider_data' => $providerData, + 'terminal' => $config['terminal'], + 'shop_orderid' => $shopOrderId, + 'amount' => $amount, + 'currency' => $currency, + ] + ]); + + $altaPayResponse = new SimpleXMLElement($response->getBody()->getContents()); + + $result = strtolower((string)($altaPayResponse->Body?->Result ?? '')); + if (!in_array($result, ['success', 'open'], true)) { + throw new AltaPayException( + 'AltaPay Apple Pay payment failed: ' + . ((string)($altaPayResponse->Body?->MerchantErrorMessage ?? $altaPayResponse->Header?->ErrorMessage ?? 'Unknown error')) + ); + } + + // --- Store XML result in session so finalize() can read it --- + $xmlString = $altaPayResponse->asXML(); + $resultKey = self::ALTAPAY_APPLE_PAY_RESULT_PREFIX . $orderTransactionId; + try { + $session = $this->requestStack->getSession(); + $session->set($resultKey, [ + 'xml' => $xmlString, + 'params' => [ + 'type' => $config['paymentRequestType'] ?? 'payment', + 'require_capture' => 'false', + ], + ]); + } catch (\Exception $e) { + $this->logger->warning('Apple Pay: could not store payment result in session: ' . $e->getMessage()); + } + + return $returnUrl; + } + + public function getTransaction(OrderEntity $order, string $salesChannelId): ResponseInterface { return $this->getAltaPayClient($salesChannelId)->request('GET', 'payments', [ 'query' => [ 'transaction_id' => $order->getCustomFields()[self::ALTAPAY_TRANSACTION_ID_CUSTOM_FIELD], diff --git a/src/Service/Setup/CustomFieldSetupService.php b/src/Service/Setup/CustomFieldSetupService.php index e9762e6..4bb572f 100644 --- a/src/Service/Setup/CustomFieldSetupService.php +++ b/src/Service/Setup/CustomFieldSetupService.php @@ -186,37 +186,46 @@ private function createPaymentMethodCustomField(Context $context): void context: $context ); - $terminals = []; - $languages = ['de-DE', 'en-GB', 'da-DK']; - - // Add default empty option - $terminals[] = [ - 'label' => [ - 'de-DE' => 'Wählen Sie ein Terminal aus', - 'en-GB' => 'Select a terminal', - 'da-DK' => 'Vælg en terminal', + $this->addCustomField( + name: PaymentService::ALTAPAY_IS_APPLE_PAY_CUSTOM_FIELD, + type: CustomFieldTypes::SWITCH, + config: [ + 'label' => [ + 'de-DE' => 'Ist Apple Pay', + 'en-GB' => 'Is Apple Pay', + 'da-DK' => 'Er Apple Pay', + ], + 'customFieldPosition' => 4, + 'defaultValue' => false, ], - 'value' => '', - ]; - - for ($i = 1; $i <= 10; $i++) { - $label = []; - - foreach ($languages as $lang) { - $label[$lang] = 'terminal ' . $i; - } + customFieldSetId: $fieldSetId, + context: $context + ); - $terminals[] = [ - 'label' => $label, - 'value' => 'terminal' . $i, - ]; - } + $this->addCustomField( + name: PaymentService::ALTAPAY_APPLE_PAY_NETWORKS_CUSTOM_FIELD, + type: CustomFieldTypes::TEXT, + config: [ + 'label' => [ + 'de-DE' => 'Apple Pay unterstützte Netzwerke', + 'en-GB' => 'Apple Pay Supported Networks', + 'da-DK' => 'Apple Pay understøttede netværk', + ], + 'helpText' => [ + 'en-GB' => 'Comma-separated list of supported networks, e.g.: visa,masterCard,amex', + ], + 'customFieldPosition' => 6, + 'defaultValue' => 'visa,masterCard,amex,discover', + ], + customFieldSetId: $fieldSetId, + context: $context + ); $this->addCustomField( name: PaymentService::ALTAPAY_SALES_CHANNEL_TERMINAL_ID, type: CustomFieldTypes::SELECT, config: [ - 'customFieldPosition' => 4, + 'customFieldPosition' => 7, 'componentName' => 'sw-single-select', 'customFieldType' => 'select', 'label' => [ @@ -234,12 +243,46 @@ private function createPaymentMethodCustomField(Context $context): void 'en-GB' => 'Select the terminal configuration for the sales channel. If left empty, the default terminal ID from the payment method will be used.', 'da-DK' => 'Vælg terminal-konfigurationen for salgskanalen. Hvis den efterlades tom, vil standard terminal-ID fra betalingsmetoden blive brugt.', ], - 'options' => $terminals, + 'options' => $this->buildTerminalOptions(), ], customFieldSetId: $fieldSetId, context: $context ); + } + + /** + * @return array, value: string}> + */ + private function buildTerminalOptions(): array + { + $languages = ['de-DE', 'en-GB', 'da-DK']; + + // Add default empty option + $terminals = [ + [ + 'label' => [ + 'de-DE' => 'Wählen Sie ein Terminal aus', + 'en-GB' => 'Select a terminal', + 'da-DK' => 'Vælg en terminal', + ], + 'value' => '', + ], + ]; + + for ($i = 1; $i <= 10; $i++) { + $label = []; + + foreach ($languages as $lang) { + $label[$lang] = 'terminal ' . $i; + } + + $terminals[] = [ + 'label' => $label, + 'value' => 'terminal' . $i, + ]; + } + return $terminals; } private function addCustomField( diff --git a/src/Subscriber/ApplePayAvailabilitySubscriber.php b/src/Subscriber/ApplePayAvailabilitySubscriber.php new file mode 100644 index 0000000..435c258 --- /dev/null +++ b/src/Subscriber/ApplePayAvailabilitySubscriber.php @@ -0,0 +1,52 @@ + 'hideApplePayForNonSafari', + ]; + } + + public function hideApplePayForNonSafari(CheckoutConfirmPageLoadedEvent $event): void + { + $request = $this->requestStack->getCurrentRequest(); + $userAgent = $request ? $request->headers->get('User-Agent', '') : ''; + + /* Same regex as AltaPay Magento2 plugin: exclude Chrome and Android browsers */ + if (preg_match('/^((?!chrome|android).)*safari/i', $userAgent)) { + return; + } + + $paymentMethods = $event->getPage()->getPaymentMethods(); + if (!$paymentMethods) { + return; + } + + $filtered = $paymentMethods->filter(function ($method) { + $customFields = $method->getTranslated()['customFields'] ?? $method->getCustomFields() ?? []; + return empty($customFields[PaymentService::ALTAPAY_IS_APPLE_PAY_CUSTOM_FIELD]); + }); + + $event->getPage()->setPaymentMethods($filtered); + } +} diff --git a/src/WexoAltaPay.php b/src/WexoAltaPay.php index 97bef51..caa8be0 100644 --- a/src/WexoAltaPay.php +++ b/src/WexoAltaPay.php @@ -21,7 +21,7 @@ class WexoAltaPay extends Plugin public const ALTAPAY_FIELD_SET_NAME = "wexoAltaPay"; public const ALTAPAY_PAYMENT_METHOD_FIELD_SET_NAME = "wexoAltaPayPaymentMethod"; public const ALTAPAY_CART_TOKEN = "wexoAltaPayCartToken"; - public const ALTAPAY_PLUGIN_VERSION = '2.1.0'; + public const ALTAPAY_PLUGIN_VERSION = '2.2.0'; public const ALTAPAY_PLUGIN_NAME = 'WexoAltaPay'; public function update(UpdateContext $updateContext): void diff --git a/wiki.md b/wiki.md index d6c491d..50905ee 100644 --- a/wiki.md +++ b/wiki.md @@ -106,23 +106,27 @@ be provided by AltaPay. 7. Click the **Save** button. 8. Once saved, a new section, **Custom fields**, will appear with the options **AltaPay Terminal ID**(where you must enter the terminal name from AltaPay), **Auto Capture**, and **Surcharge**. + +9. Enable this option when the payment method is for Apple Pay. This marks the terminal as an Apple Pay payment option. + +10. Enter the card networks supported for Apple Pay, separated by commas, for example: amex,visa,mastercard. This defines which card networks are allowed for Apple Pay payments. ![shopware_configure_altapay_terminal_detail](Docs/Configuration/shopware_configure_altapay_terminal_detail.png) -9. Select a sales channel-specific terminal configuration. If left empty, the default terminal ID (from the field above) will be used. +11. Select a sales channel-specific terminal configuration. If left empty, the default terminal ID (from the field above) will be used. -10. Click the **Save** button again. +12. Click the **Save** button again. -11. Now click on your desired shop from the **Sales Channels** menu on the left. +13. Now click on your desired shop from the **Sales Channels** menu on the left. -12. In the **General** tab, scroll down to the **Payment and shipping** section & search by name for the payment method you just created. +14. In the **General** tab, scroll down to the **Payment and shipping** section & search by name for the payment method you just created. ![shopware_show_payment_method_on_checkout.png](Docs/Configuration/shopware_show_payment_method_on_checkout.png) -13. Choose the payment method and click Save button in the top-right corner. +15. Choose the payment method and click Save button in the top-right corner. ![shopware_verify_payment_method.png](Docs/Configuration/shopware_verify_payment_method.png) -14. Once the payment methods are configured, you will be ready to process transactions through AltaPay. +16. Once the payment methods are configured, you will be ready to process transactions through AltaPay. ![shopware_checkout_page.png](Docs/Configuration/shopware_checkout_page.png)