diff --git a/CHANGELOG.md b/CHANGELOG.md
index 46bb3a83..092e07ae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
\ No newline at end of file
+- 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
\ No newline at end of file
diff --git a/composer.json b/composer.json
index c5f4b1db..0a96e68c 100644
--- a/composer.json
+++ b/composer.json
@@ -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": {
diff --git a/src/Resources/config/services.xml b/src/Resources/config/services.xml
index eec0aef3..cd7e25c2 100644
--- a/src/Resources/config/services.xml
+++ b/src/Resources/config/services.xml
@@ -171,6 +171,13 @@
+
+
+
+
+
+
+
diff --git a/src/Subscribers/PaymentTokenInvalidatedSubscriber.php b/src/Subscribers/PaymentTokenInvalidatedSubscriber.php
new file mode 100644
index 00000000..dc1ec3ea
--- /dev/null
+++ b/src/Subscribers/PaymentTokenInvalidatedSubscriber.php
@@ -0,0 +1,203 @@
+ ['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);
+ $errorUrl = $this->makeAbsolute($payload['eul'] ?? null, $request);
+ $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
+ * @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();
+ 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
+ );
+ }
+}