diff --git a/CHANGELOG.md b/CHANGELOG.md index 89c071a..50228d6 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.1.0] +### Added +- Add support for MarketPay payment methods. + ## [2.0.10] ### Fixed - Fix: Payment not captured when order status is changed to done for digital products. diff --git a/composer.json b/composer.json index ca22d4b..6181673 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.0.10", + "version": "2.1.0", "authors": [ { "name": "AltaPay A/S", diff --git a/src/Resources/config/config.xml b/src/Resources/config/config.xml index 70710da..0662f89 100644 --- a/src/Resources/config/config.xml +++ b/src/Resources/config/config.xml @@ -28,13 +28,19 @@ shopName - true Enter the shop name that appears at the beginning of your AltaPay URL. For example: demoshop (from https://demoshop.altapaysecure.com) + + gatewayUrl + + + Example: https://testgateway.altapaysecure.com or https://onl.preprod.mpg.market-pay.com + + username true diff --git a/src/Service/PaymentService.php b/src/Service/PaymentService.php index a6bf19a..09e1b7f 100644 --- a/src/Service/PaymentService.php +++ b/src/Service/PaymentService.php @@ -39,6 +39,7 @@ use Shopware\Core\System\SalesChannel\Context\SalesChannelContextFactory; use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService; use Shopware\Core\Checkout\Cart\Order\OrderConverter; +use Shopware\Core\Framework\Util\Hasher; use Shopware\Core\Framework\Uuid\Uuid; use Shopware\Core\Checkout\Cart\Order\RecalculationService; use Symfony\Contracts\Translation\TranslatorInterface; @@ -192,6 +193,46 @@ public function pay( } + $terminals = $this->getActiveTerminals($salesChannelContext); + if (($key = array_search($terminal, $terminals)) !== false) { + unset($terminals[$key]); + } + array_unshift($terminals, $terminal); + + $sessionKey = 'altapay_checkout_session_id_' . $order->getId(); + $altapaySessionId = null; + + try { + $session = $this->requestStack->getSession(); + $altapaySessionId = $session->get($sessionKey); + } catch (\Exception $e) { + $session = null; + } + if (empty($altapaySessionId)) { + $sessionIdentifier = Hasher::hash([ + 'cartToken' => $order->getCustomFieldsValue(WexoAltaPay::ALTAPAY_CART_TOKEN), + 'orderId' => $order->getId(), + 'orderNumber' => $order->getOrderNumber() + ]); + $altapaySessionId = $this->checkoutSession( + $sessionIdentifier, + $terminals, + $order->getSalesChannelId(), + $order->getOrderNumber(), + $order->getAmountTotal(), + $order->getCurrency()->getIsoCode(), + $terminal + ); + + if ($session && $altapaySessionId) { + try { + $session->set($sessionKey, $altapaySessionId); + } catch (\Exception $e) { + // Optional: Failure to store in session should not interrupt the payment flow. + } + } + } + $paymentRequestType = ($paymentMethod->getTranslated()['customFields'][self::ALTAPAY_AUTO_CAPTURE_CUSTOM_FIELD] ?? null) ? 'paymentAndCapture' : 'payment'; try { $altaPayResponse = $this->createPaymentRequest( @@ -199,7 +240,8 @@ public function pay( $transaction->getReturnUrl(), $salesChannelContext, $terminal, - $paymentRequestType + $paymentRequestType, + $altapaySessionId ); } catch (GuzzleException $e) { throw PaymentException::asyncProcessInterrupted( @@ -218,6 +260,95 @@ public function pay( 'X-Dynamic-JavaScript-Url' => (string)$altaPayResponse->DynamicJavascriptUrl ]); } + /** + * Get active terminals for the current sales channel, with fallback to global terminal if sales channel specific terminal is not set. + * + * @param SalesChannelContext $context + * + * @return array + */ + public function getActiveTerminals(SalesChannelContext $context): array + { + $criteria = new Criteria(); + $criteria->addFilter(new EqualsFilter('handlerIdentifier', PaymentService::class)); + $criteria->addFilter(new EqualsFilter('active', true)); + + $paymentMethods = $this->container->get('payment_method.repository') + ->search($criteria, $context->getContext()) + ->getEntities(); + + $terminals = []; + foreach ($paymentMethods as $paymentMethod) { + $terminal = $paymentMethod->getTranslated()['customFields'][self::ALTAPAY_TERMINAL_ID_CUSTOM_FIELD] ?? null; + $salesChannelTerminal = $paymentMethod->getTranslated()['customFields'][self::ALTAPAY_SALES_CHANNEL_TERMINAL_ID] ?? null; + + if (!empty($salesChannelTerminal)) { + $field = 'WexoAltaPay.config.' . $salesChannelTerminal; + $salesChannelTerminalValue = $this->systemConfigService->get($field, $context->getSalesChannelId()); + + if (!empty($salesChannelTerminalValue)) { + $terminal = $salesChannelTerminalValue; + } + } + + if (!empty($terminal)) { + $terminals[] = $terminal; + } + } + + return array_unique($terminals); + } + + /** + * Creates or retrieves an AltaPay checkout session. + * + * @throws GuzzleException + * @param string $sessionId + * @param array $terminals + * @param string $salesChannelId + * @param string $shopOrderId + * @param float $amount + * @param string $currency + * @param string $terminal + * + * @return string|null + */ + public function checkoutSession( + string $sessionId, + array $terminals, + string $salesChannelId, + string $shopOrderId, + float $amount, + string $currency, + string $terminal + ): ?string { + try { + $response = $this->getAltaPayClient($salesChannelId)->request('POST', 'checkoutSession', [ + 'form_params' => [ + 'session_id' => $sessionId, + 'terminals' => $terminals, + 'shop_orderid' => $shopOrderId, + 'amount' => $amount, + 'currency' => $currency, + 'terminal' => $terminal, + ] + ]); + + $xml = new SimpleXMLElement($response->getBody()->getContents()); + + if ($xml->Body->Session->Id) { + return (string)$xml->Body->Session->Id; + } + } catch (\Exception $e) { + $this->logger->error('AltaPay CheckoutSession error: ' . $e->getMessage(), [ + 'exception' => $e, + 'shopOrderId' => $shopOrderId, + 'sessionId' => $sessionId, + ]); + } + + return null; + } /** * This method will be called after redirect from the external payment provider. @@ -360,7 +491,7 @@ public function transactionCallback( } break; } - + if ($stateMachineState->getTechnicalName() !== OrderTransactionStates::STATE_OPEN) { break; } @@ -461,12 +592,29 @@ public function transactionCallback( public function getAltaPayClient(string $salesChannelId): Client { - $paymentEnvironment = $this->systemConfigService->get('WexoAltaPay.config.paymentEnvironment', $salesChannelId); - $shopName = $this->systemConfigService->get('WexoAltaPay.config.shopName', $salesChannelId); + $paymentEnvironment = (string) $this->systemConfigService->get('WexoAltaPay.config.paymentEnvironment', $salesChannelId); + $shopName = (string) $this->systemConfigService->get('WexoAltaPay.config.shopName', $salesChannelId); + $gatewayUrl = (string) $this->systemConfigService->get('WexoAltaPay.config.gatewayUrl', $salesChannelId); $username = $this->systemConfigService->get('WexoAltaPay.config.username', $salesChannelId); $password = $this->systemConfigService->get('WexoAltaPay.config.password', $salesChannelId); + + // Default behavior: expand $PLACEHOLDER$ with shopName. + // Override behavior: if a Gateway URL is configured, it overrides the shopName. + // The merchant/API/ path is appended automatically. + if (!empty($gatewayUrl)) { + $gatewayUrl = trim($gatewayUrl); + if (preg_match('#^http://#i', $gatewayUrl)) { + $gatewayUrl = 'https://' . substr($gatewayUrl, 7); + } elseif (!preg_match('#^https://#i', $gatewayUrl)) { + $gatewayUrl = 'https://' . ltrim($gatewayUrl, '/'); + } + $baseUri = rtrim($gatewayUrl, '/') . '/merchant/API/'; + } else { + $baseUri = str_replace('$PLACEHOLDER$', $shopName, $paymentEnvironment); + } + return new Client([ - 'base_uri' => str_replace('$PLACEHOLDER$', $shopName, $paymentEnvironment), + 'base_uri' => $baseUri, 'auth' => [ $username, $password @@ -477,11 +625,11 @@ public function getAltaPayClient(string $salesChannelId): Client /** * Escape hatch that can be overridden for custom line items. */ - public function getUnknownLineItemFormat(OrderEntity $order, OrderLineItemEntity $lineItem): array + public function getUnknownLineItemFormat(OrderEntity $order, OrderLineItemEntity $lineItem, ?string $gatewayItemId = null): array { return [ 'description' => $lineItem->getLabel(), - 'itemId' => $lineItem->getId(), + 'itemId' => $gatewayItemId ?? $lineItem->getId(), 'quantity' => $lineItem->getQuantity(), 'unitPrice' => $lineItem->getPrice()?->getUnitPrice() ?? 0.0, 'taxAmount' => $lineItem->getPrice()?->getCalculatedTaxes()->getAmount() ?? 0.0, @@ -506,9 +654,11 @@ public function createPaymentRequest( string $returnUrl, SalesChannelContext $context, string $terminal, - string $paymentRequestType + string $paymentRequestType, + string $sessionId = null ): SimpleXMLElement { $orderLines = []; + $itemIdCounter = 0; foreach ($order->getLineItems() as $lineItem) { $unitTaxRate = $lineItem->getPrice()?->getCalculatedTaxes()->getAmount() / $lineItem->getQuantity(); @@ -519,6 +669,7 @@ public function createPaymentRequest( } $taxAmount = $lineItem->getPrice()?->getCalculatedTaxes()->getAmount() ?? 0.0; + $gatewayItemId = 'item-' . (++$itemIdCounter); $orderLines[] = match ($lineItem->getType()) { LineItem::PRODUCT_LINE_ITEM_TYPE, @@ -528,7 +679,7 @@ public function createPaymentRequest( LineItem::DISCOUNT_LINE_ITEM, LineItem::PROMOTION_LINE_ITEM_TYPE => [ 'description' => $lineItem->getLabel(), - 'itemId' => $lineItem->getId(), + 'itemId' => $gatewayItemId, 'quantity' => $lineItem->getQuantity(), 'unitPrice' => $unitPrice, 'taxAmount' => $taxAmount, @@ -542,9 +693,10 @@ public function createPaymentRequest( LineItem::PROMOTION_LINE_ITEM_TYPE => 'handling', } ], - default => $this->getUnknownLineItemFormat($order, $lineItem) + default => $this->getUnknownLineItemFormat($order, $lineItem, $gatewayItemId) }; } + $shippingIdCounter = 0; foreach ($order->getDeliveries() as $delivery) { $netUnitPrice = round($delivery->getShippingCosts()->getUnitPrice() - $delivery->getShippingCosts()->getCalculatedTaxes()->getAmount(), 2); @@ -557,7 +709,7 @@ public function createPaymentRequest( $orderLines[] = [ 'description' => $delivery->getShippingMethod()?->getDescription() ?? 'Shipping', - 'itemId' => $delivery->getId(), + 'itemId' => 'shipping-' . (++$shippingIdCounter), 'quantity' => 1, 'unitPrice' => $netUnitPrice, 'taxAmount' => $taxAmount, @@ -675,6 +827,10 @@ public function createPaymentRequest( ] ]; + if ($sessionId) { + $formParams['session_id'] = $sessionId; + } + $checkoutStyle = $this->systemConfigService->get('WexoAltaPay.config.checkoutStyle', $salesChannelId); if (!empty($checkoutStyle)) { $formParams['form_template'] = $checkoutStyle; diff --git a/src/Subscriber/SystemConfigSubscriber.php b/src/Subscriber/SystemConfigSubscriber.php new file mode 100644 index 0000000..1d291b1 --- /dev/null +++ b/src/Subscriber/SystemConfigSubscriber.php @@ -0,0 +1,48 @@ + 'onBeforeSystemConfigChanged', + ]; + } + + public function onBeforeSystemConfigChanged(BeforeSystemConfigChangedEvent $event): void + { + if ($event->getKey() !== self::GATEWAY_URL_KEY) { + return; + } + + $value = $event->getValue(); + $trimmed = is_string($value) ? trim($value) : ''; + + if ($trimmed === '') { + return; + } + + if (!str_starts_with(strtolower($trimmed), 'https://')) { + $this->logger->error('Rejected AltaPay gateway URL because it is not HTTPS.', [ + 'gatewayUrl' => $trimmed, + 'salesChannelId' => $event->getSalesChannelId(), + ]); + + throw AltaPayConfigException::gatewayUrlNotHttps(); + } + } +} diff --git a/src/WexoAltaPay.php b/src/WexoAltaPay.php index 90acbaa..97bef51 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.0.10'; + public const ALTAPAY_PLUGIN_VERSION = '2.1.0'; public const ALTAPAY_PLUGIN_NAME = 'WexoAltaPay'; public function update(UpdateContext $updateContext): void diff --git a/wiki.md b/wiki.md index 3e22c28..d6c491d 100644 --- a/wiki.md +++ b/wiki.md @@ -59,7 +59,7 @@ be provided by AltaPay. ![shopware_setup_altapay_credentials](Docs/Configuration/shopware_setup_altapay_credentials.png) - > **Note:** The **AltaPay ShopName** should match the subdomain at the beginning of your AltaPay URL. For example, if your payment URL is `https://demoshop.altapaysecure.com`, then the AltaPay ShopName is `demoshop`. + > **Note:** We recommend entering the complete URL in the **Gateway URL** field (e.g., `https://testgateway.altapaysecure.com` or `https://onl.preprod.mpg.market-pay.com`). If provided, this will override the ShopName. If you prefer to use the **AltaPay ShopName** field, enter only the subdomain at the beginning of your AltaPay URL (e.g., `demoshop` from `https://demoshop.altapaysecure.com`). 3. Enable the **Change the order status to In-Progress after a successful payment** setting if you want to update the order status to **In Progress** automatically after a successful payment.