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.

- > **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.