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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,4 +410,8 @@ Compatible from Shopware 6.5.0 up to 6.5.6.1
- Fix: SalesChannelContextServiceDecorator now uses context token from URL on payment return routes (buckaroo/cancel, checkout/finish, /payment/) to restore session when cookies are not sent.
- Fix: PaymentContextRestoreSubscriber runs earlier (priority 5) to restore context before Shopware resolves the sales channel.
- Fix: PaymentReturnContextSubscriber now appends context token to all storefront redirects (checkout, account), not just checkout/finish.
- Added: PaymentContextCookieSubscriber to explicitly set sw-context-token cookie when restored from URL, enabling use of cookie_samesite: lax without requiring null.
- Added: PaymentContextCookieSubscriber to explicitly set sw-context-token cookie when restored from URL, enabling use of cookie_samesite: lax without requiring null.

# 3.2.4

- BTI-684 Fix: Payment finalize token replay causes error after successful async payment (Wero/mobile) #405
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "buckaroo/shopware6",
"description": "Buckaroo payment provider plugin for Shopware 6",
"type": "shopware-platform-plugin",
"version": "3.2.3",
"version": "3.2.4",
"license": "proprietary",
"minimum-stability": "stable",
"require": {
Expand Down
7 changes: 7 additions & 0 deletions src/Resources/config/services.xml
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@
<tag name="kernel.event_subscriber"/>
</service>

<service id="Buckaroo\Shopware6\Subscribers\PaymentTokenInvalidatedSubscriber">
<argument type="service" id="order_transaction.repository"/>
<argument type="service" id="Symfony\Component\Routing\Generator\UrlGeneratorInterface"/>
<argument type="service" id="Psr\Log\LoggerInterface"/>
<tag name="kernel.event_subscriber"/>
</service>

<service id="Buckaroo\Shopware6\Subscribers\PaymentContextRestoreSubscriber">
<tag name="kernel.event_subscriber"/>
</service>
Expand Down
203 changes: 203 additions & 0 deletions src/Subscribers/PaymentTokenInvalidatedSubscriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
<?php

declare(strict_types=1);

namespace Buckaroo\Shopware6\Subscribers;

use Psr\Log\LoggerInterface;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;

/**
* Handles the back-button / double-redirect scenario for async payments (e.g. Wero, iDEAL).
*
* Shopware's _sw_payment_token is one-time-use: after the first successful finalize
* the record is deleted (or marked consumed) from the payment_token table. If the
* customer presses back and the Buckaroo page re-fires the redirect with the same URL,
* Shopware throws CHECKOUT__PAYMENT_TOKEN_INVALIDATED before the plugin handler is
* reached, resulting in a raw error page.
*/
class PaymentTokenInvalidatedSubscriber implements EventSubscriberInterface
{
private const TOKEN_INVALIDATED_CODE = 'CHECKOUT__PAYMENT_TOKEN_INVALIDATED';

/** Transaction technical names that represent a completed successful payment */
private const SUCCESS_STATES = [
'paid',
'paid_partially',
'authorized',
];

public function __construct(
private readonly EntityRepository $orderTransactionRepository,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly LoggerInterface $logger
) {
}

public static function getSubscribedEvents(): array
{
return [
KernelEvents::EXCEPTION => ['onKernelException', 10],
];
}

public function onKernelException(ExceptionEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}

if (!$this->isTokenInvalidatedException($event->getThrowable())) {
return;
}

$request = $event->getRequest();
$paymentToken = $request->query->get('_sw_payment_token');

if (!is_string($paymentToken) || $paymentToken === '') {
return;
}

try {
$redirectUrl = $this->resolveRedirectUrl($paymentToken, $request);
} catch (\Throwable $e) {
$this->logger->warning(
'Buckaroo: Could not resolve redirect URL for invalidated payment token — leaving default error handling in place',
['exception' => $e->getMessage()]
);
return;
}

$this->logger->info(
'Buckaroo: Redirecting after invalidated payment token (back-button / double-redirect scenario)',
['redirectUrl' => $redirectUrl]
);

$event->setResponse(new RedirectResponse($redirectUrl));
$event->stopPropagation();
}

private function isTokenInvalidatedException(\Throwable $exception): bool
{
// Shopware 6.4 (TokenInvalidatedException) and 6.5+ (PaymentException) both
// expose getErrorCode() returning the same constant.
if (method_exists($exception, 'getErrorCode')) {
return $exception->getErrorCode() === self::TOKEN_INVALIDATED_CODE;
}

return str_contains(get_class($exception), 'TokenInvalidated');
}

/**
* Decode the JWT payload directly — no parseToken() call, no DB access.
* JWTFactoryV2::parseToken() would throw the same tokenInvalidated exception
* because the record no longer exists in payment_token. The JWT signature is
* still valid; we only need the plaintext claims embedded in the token.
*/
private function resolveRedirectUrl(string $paymentToken, Request $request): string
{
$payload = $this->decodeJwtPayload($paymentToken);

// 'ful' and 'eul' are stored as relative paths (e.g. /checkout/finish?orderId=...)
$finishUrl = $this->makeAbsolute($payload['ful'] ?? null, $request);

Check failure on line 110 in src/Subscribers/PaymentTokenInvalidatedSubscriber.php

View workflow job for this annotation

GitHub Actions / Code Quality

Parameter #1 $path of method Buckaroo\Shopware6\Subscribers\PaymentTokenInvalidatedSubscriber::makeAbsolute() expects string|null, mixed given.
$errorUrl = $this->makeAbsolute($payload['eul'] ?? null, $request);

Check failure on line 111 in src/Subscribers/PaymentTokenInvalidatedSubscriber.php

View workflow job for this annotation

GitHub Actions / Code Quality

Parameter #1 $path of method Buckaroo\Shopware6\Subscribers\PaymentTokenInvalidatedSubscriber::makeAbsolute() expects string|null, mixed given.
$transactionId = isset($payload['sub']) && is_string($payload['sub']) ? $payload['sub'] : null;

if ($transactionId !== null) {
try {
if ($this->isPaymentSuccessful($transactionId)) {
return $finishUrl ?? $this->accountOrdersUrl();
}

return $errorUrl ?? $this->accountOrdersUrl();
} catch (\Throwable $e) {
$this->logger->warning(
'Buckaroo: Could not determine transaction state for invalidated token — falling back to finishUrl',
['transactionId' => $transactionId, 'exception' => $e->getMessage()]
);
}
}

return $finishUrl ?? $errorUrl ?? $this->accountOrdersUrl();
}

/**
* Decode the payload (second) segment of a JWT without signature verification.
* We only need the plaintext claims — the signature was already verified by
* Shopware core before tokenInvalidated was thrown.
*
* @return array<string, mixed>
* @throws \InvalidArgumentException if the token is structurally malformed
*/
private function decodeJwtPayload(string $token): array
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
throw new \InvalidArgumentException('Invalid JWT: expected 3 dot-separated parts');
}

// JWT uses base64url encoding (RFC 4648 §5) — swap - and _ back to + and /
$decoded = base64_decode(str_replace(['-', '_'], ['+', '/'], $parts[1]), true);
if ($decoded === false) {
throw new \InvalidArgumentException('JWT payload could not be base64-decoded');
}

$data = json_decode($decoded, true);
if (!is_array($data)) {
throw new \InvalidArgumentException('JWT payload is not a valid JSON object');
}

return $data;
}

private function makeAbsolute(?string $path, Request $request): ?string
{
if ($path === null || $path === '') {
return null;
}

if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://')) {
return $path;
}

return $request->getSchemeAndHttpHost() . $path;
}

private function isPaymentSuccessful(string $transactionId): bool
{
$criteria = new Criteria([$transactionId]);
$criteria->addAssociation('stateMachineState');

$transaction = $this->orderTransactionRepository
->search($criteria, Context::createDefaultContext())
->first();

if ($transaction === null) {
return false;
}

$state = $transaction->getStateMachineState();

Check failure on line 187 in src/Subscribers/PaymentTokenInvalidatedSubscriber.php

View workflow job for this annotation

GitHub Actions / Code Quality

Call to an undefined method Shopware\Core\Framework\DataAbstractionLayer\Entity::getStateMachineState().
if ($state === null) {
return false;
}

return in_array($state->getTechnicalName(), self::SUCCESS_STATES, true);
}

private function accountOrdersUrl(): string
{
return $this->urlGenerator->generate(
'frontend.account.order.page',
[],
UrlGeneratorInterface::ABSOLUTE_URL
);
}
}
Loading