Skip to content
Open
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.2.0]
### Added
- Support Apple Pay functionality

## [2.1.0]
### Added
- Add support for MarketPay payment methods.
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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.1.0",
"version": "2.2.0",
"authors": [
{
"name": "AltaPay A/S",
Expand Down
5 changes: 5 additions & 0 deletions sonar-project.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
172 changes: 170 additions & 2 deletions src/Controller/CallbackController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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
) {
}

Expand Down Expand Up @@ -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);
Expand All @@ -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()]);
}
}
}
6 changes: 6 additions & 0 deletions src/Resources/config/services.xml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
<argument type="service" id="Shopware\Core\System\SystemConfig\SystemConfigService"/>
<argument type="service" id="media.repository"/>
<argument type="service" id="twig"/>
<argument type="service" id="request_stack"/>
</service>
<service id="Wexo\AltaPay\Controller\ApiController" public="true">
<argument type="service" id="Wexo\AltaPay\Service\PaymentService" />
Expand Down Expand Up @@ -71,5 +72,10 @@
<tag name="kernel.event_subscriber"/>
</service>

<service id="Wexo\AltaPay\Subscriber\ApplePayAvailabilitySubscriber">
<argument type="service" id="request_stack"/>
<tag name="kernel.event_subscriber"/>
</service>

</services>
</container>
92 changes: 92 additions & 0 deletions src/Resources/views/gateway/apple-pay-session.html.twig
Original file line number Diff line number Diff line change
@@ -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 () {}
}
#}
<script>
window.AltaPayApplePay = window.AltaPayApplePay || {};

AltaPayApplePay.runSession = function (cfg) {
'use strict';

var session = new ApplePaySession(3, {
countryCode: cfg.countryCode,
currencyCode: cfg.currencyCode,
merchantCapabilities: ['supports3DS'],
supportedNetworks: cfg.supportedNetworks,
total: { label: cfg.label, type: 'final', amount: cfg.amount }
});

session.onvalidatemerchant = function (event) {
var body = new URLSearchParams(Object.assign(
{ validationUrl: event.validationURL },
cfg.validateMerchantParams || {}
));

fetch(cfg.validateMerchantUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
credentials: 'same-origin',
body: body.toString()
})
.then(function (res) {
/* Read as text first — if the server returns an HTML error page, res.json() would throw. */
return res.text().then(function (text) {
try {
return JSON.parse(text);
} catch (e) {
throw new Error('HTTP ' + res.status + ' — non-JSON merchant validation response.');
}
});
})
.then(function (data) {
if (data.success) {
var merchantSession = (typeof data.applePaySession === 'string')
? JSON.parse(data.applePaySession)
: data.applePaySession;
session.completeMerchantValidation(merchantSession);
} else {
session.abort();
cfg.onError(data.error || 'Merchant validation failed.');
}
})
.catch(function (err) {
session.abort();
cfg.onError(err.message || 'Network error during merchant validation.');
});
};

session.onpaymentmethodselected = function () {
session.completePaymentMethodSelection({
newTotal: { label: cfg.label, type: 'final', amount: cfg.amount }
});
};

session.onpaymentauthorized = function (event) {
cfg.onPaymentAuthorized(event, session);
};

session.onshippingmethodselected = function () { session.completeShippingMethodSelection({}); };
session.onshippingcontactselected = function () { session.completeShippingContactSelection({}); };
session.oncancel = function () { cfg.onCancel && cfg.onCancel(); };

session.begin();
return session;
};
</script>
Loading
Loading