Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion src/Resources/config/config.xml
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,19 @@
</input-field>
<input-field>
<name>shopName</name>
<required>true</required>
<label>AltaPay ShopName</label>
<helpText>
Enter the shop name that appears at the beginning of your AltaPay URL.
For example: demoshop (from https://demoshop.altapaysecure.com)
</helpText>
</input-field>
<input-field>
<name>gatewayUrl</name>
<label>Gateway URL (The complete URL of the gateway, This field overrides the AltaPay ShopName field.)</label>
<helpText>
Example: https://testgateway.altapaysecure.com or https://onl.preprod.mpg.market-pay.com
</helpText>
</input-field>
<input-field>
<name>username</name>
<required>true</required>
Expand Down
178 changes: 167 additions & 11 deletions src/Service/PaymentService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -192,14 +193,55 @@ 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(
$order,
$transaction->getReturnUrl(),
$salesChannelContext,
$terminal,
$paymentRequestType
$paymentRequestType,
$altapaySessionId
);
} catch (GuzzleException $e) {
throw PaymentException::asyncProcessInterrupted(
Expand All @@ -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<int, string>
*/
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<int, string> $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.
Expand Down Expand Up @@ -360,7 +491,7 @@ public function transactionCallback(
}
break;
}

if ($stateMachineState->getTechnicalName() !== OrderTransactionStates::STATE_OPEN) {
break;
}
Expand Down Expand Up @@ -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)) {
Comment thread
emicha marked this conversation as resolved.
$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
Expand All @@ -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,
Expand All @@ -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();

Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
48 changes: 48 additions & 0 deletions src/Subscriber/SystemConfigSubscriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php declare(strict_types=1);

namespace Wexo\AltaPay\Subscriber;

use Psr\Log\LoggerInterface;
use Shopware\Core\System\SystemConfig\Event\BeforeSystemConfigChangedEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Wexo\AltaPay\Exception\AltaPayConfigException;

class SystemConfigSubscriber implements EventSubscriberInterface
{
public const GATEWAY_URL_KEY = 'WexoAltaPay.config.gatewayUrl';

public function __construct(
private readonly LoggerInterface $logger
) {
}

public static function getSubscribedEvents(): array
{
return [
BeforeSystemConfigChangedEvent::class => '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();
}
}
}
2 changes: 1 addition & 1 deletion src/WexoAltaPay.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion wiki.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading