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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# REPLACE_GLOBALLY_WITH_NEXT_VERSION
- Fixes an issue, where a custom tax provider's adjusted total was not charged, because the PayPal order used the stale cart or order transaction amount instead of the taxed total (shopware/SwagPayPal#722)
- Fixes an issue, where PayPal shipping tracking sync retried 429 RATE_LIMIT_REACHED responses too early instead of respecting the Retry-After header.
- Fixes an issue, where transient PayPal API transport errors during Express Checkout resulted in HTTP 500 and left the loading spinner stuck on the page
- Fixes an issue, where the extension card context menu showed a raw snippet key instead of "Configure" (shopware/shopware#19028)

# 10.8.0
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG_de-DE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# REPLACE_GLOBALLY_WITH_NEXT_VERSION
- Behebt ein Problem, bei dem der von einem benutzerdefinierten Tax Provider angepasste Gesamtbetrag nicht berechnet wurde, weil die PayPal-Bestellung den veralteten Warenkorb- oder Bestelltransaktionsbetrag anstelle des besteuerten Gesamtbetrags verwendete (shopware/SwagPayPal#722)
- Behebt ein Problem, bei dem die PayPal-Versandtracking-Synchronisierung 429 RATE_LIMIT_REACHED-Antworten zu früh erneut verarbeitet hat, anstatt den Retry-After-Header zu berücksichtigen.
- Behebt ein Problem, bei dem vorübergehende PayPal-API-Transportfehler während des Express Checkouts zu HTTP 500 führten und der Ladeindikator auf der Seite stecken blieb
- Behebt ein Problem, bei dem im Kontextmenü der Erweiterungs-Kachel der rohe Snippet-Key statt „Konfigurieren” angezeigt wurde (shopware/shopware#19028)

# 10.8.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -330,15 +330,19 @@ export default class SwagPayPalExpressCheckoutButton extends SwagPaypalAbstractB
(response, request) => {
if (request.status < 400) {
return actions.redirect(this.options.checkoutConfirmUrl);
} else if (request.status === 400) {
}

ElementLoadingIndicatorUtil.remove(document.body);

if (request.status === 400) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pass non-400 prepare-checkout errors to onError

When the new backend path returns a 502 PayPalApiException from /paypal/express/prepare-checkout, this condition still only parses 400 responses; the non-400 path falls through to this.onError() without the response body. In the inspected onApprove/handleError flow, that discards the ErrorResponseFactory code and stores SWAG_PAYPAL__EXPRESS_GENERIC_ERROR, so the transport-specific error added in this commit never reaches buyers for prepare-checkout failures. Pass the response payload to onError for non-400 errors too.

Useful? React with 👍 / 👎.

try {
this.onError(JSON.parse(request.response));
} catch (error) {
console.warn('SwagPayPalExpressCheckout: Could not parse error response', error);
this.onError();
}

return window.location.reload();
return;
}

return this.onError();
Expand All @@ -347,6 +351,8 @@ export default class SwagPayPalExpressCheckoutButton extends SwagPaypalAbstractB
}

onErrorHandled(code, fatal, error, isCheckout = false) {
ElementLoadingIndicatorUtil.remove(document.body);

if (code === this.USER_CANCELLED) {
window.scrollTo(0, 0);
window.location = this.options.cancelRedirectUrl;
Expand Down
11 changes: 11 additions & 0 deletions src/Resources/config/packages/framework.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,14 @@ framework:
paypal.base-client:
scope: https\://api\-m\.(sandbox\.)?paypal\.com/
max_duration: 30
retry_failed:
max_retries: 3
delay: 200
multiplier: 2
max_delay: 1000
jitter: 0.1
http_codes:
0: ['GET', 'HEAD', 'OPTIONS']
429: ['GET', 'HEAD', 'OPTIONS']
502: ['GET', 'HEAD', 'OPTIONS']
503: ['GET', 'HEAD', 'OPTIONS']
22 changes: 21 additions & 1 deletion src/RestApi/Client/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@

namespace Swag\PayPal\RestApi\Client;

use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\MessageInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerInterface;
use Shopware\Core\Framework\Log\Package;
use Swag\PayPal\RestApi\Exception\PayPalApiException;
use Symfony\Component\HttpClient\Psr18Client;

#[Package('checkout')]
Expand All @@ -33,9 +35,27 @@ public function __construct(
) {
}

/**
* @throws PayPalApiException
*/
public function sendRequest(RequestInterface $request): ResponseInterface
{
$response = $this->client->sendRequest($request);
try {
$response = $this->client->sendRequest($request);
} catch (ClientExceptionInterface $e) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Catch only transport exceptions as network failures

RequestExceptionInterface failures from the underlying PSR client also extend ClientExceptionInterface, so this catch now classifies malformed request/stream problems as PayPal network outages and turns them into a 502 NETWORK_ERROR. In contexts where the SDK or plugin builds an invalid request, that hides the real defect from callers and customer-facing errors; catch NetworkExceptionInterface for transport failures and let request exceptions surface separately.

Useful? React with 👍 / 👎.

$this->logger->error(
'PayPal network error: {message}',
[
'message' => $e->getMessage(),
'method' => \mb_strtoupper($request->getMethod()),
'target' => (string) $request->getUri(),
'requestId' => $request->getHeaderLine('paypal-request-id') ?: null,
'error' => $e,
],
);

throw PayPalApiException::fromClientException($e);
}

if ($response->getStatusCode() >= 400) {
$this->logger->error(
Expand Down
12 changes: 12 additions & 0 deletions src/RestApi/Exception/PayPalApiException.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

namespace Swag\PayPal\RestApi\Exception;

use Psr\Http\Client\ClientExceptionInterface;
use Shopware\Core\Checkout\Payment\PaymentException;
use Shopware\Core\Framework\Log\Package;
use Shopware\PayPalSDK\Exception\ApiException;
Expand All @@ -25,6 +26,7 @@ class PayPalApiException extends PaymentException
public const ISSUE_DUPLICATE_INVOICE_ID = 'DUPLICATE_INVOICE_ID';
public const ISSUE_INVALID_PARAMETER_VALUE = 'INVALID_PARAMETER_VALUE';
public const ISSUE_INVALID_RESOURCE_ID = 'INVALID_RESOURCE_ID';
public const ISSUE_NETWORK_ERROR = 'NETWORK_ERROR';

private readonly ?\DateTimeImmutable $retryAt;

Expand Down Expand Up @@ -99,6 +101,16 @@ public static function from(ApiException $e): self
);
}

public static function fromClientException(ClientExceptionInterface $e): self
{
return new self(
'SERVICE_UNAVAILABLE',
'PayPal is currently unreachable. Please try again later.',
Response::HTTP_BAD_GATEWAY,
self::ISSUE_NETWORK_ERROR,
);
}

/**
* @return array{message: string, issue: string|null}
*/
Expand Down
54 changes: 54 additions & 0 deletions tests/RestApi/Client/ClientTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,13 @@
use Monolog\Logger;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Client\NetworkExceptionInterface;
use Psr\Http\Message\RequestInterface;
use Shopware\Core\Framework\Log\Package;
use Swag\PayPal\RestApi\Client\Client;
use Swag\PayPal\RestApi\Exception\PayPalApiException;
use Symfony\Component\HttpFoundation\Response as HttpResponse;

/**
* @internal
Expand Down Expand Up @@ -132,4 +137,53 @@ public function testSendRequest400(): void
'requestId' => '1234567',
], $logs[0]->context);
}

public function testSendRequestWrapsNetworkException(): void
{
$request = new Request('GET', 'https://api-m.paypal.com/v2/checkout/orders/ORDER-ID', [
'paypal-request-id' => 'req-1',
]);

$networkException = new class($request) extends \RuntimeException implements NetworkExceptionInterface {
public function __construct(private readonly RequestInterface $request)
{
parent::__construct('Could not resolve host: api-m.paypal.com');
}

public function getRequest(): RequestInterface
{
return $this->request;
}
};

$innerClient = $this->createMock(ClientInterface::class);
$innerClient
->expects($this->once())
->method('sendRequest')
->with($request)
->willThrowException($networkException);

$client = new Client(
new Logger('test', [$this->logger]),
$innerClient,
);

try {
$client->sendRequest($request);
static::fail('Expected PayPalApiException to be thrown');
} catch (PayPalApiException $exception) {
static::assertSame(HttpResponse::HTTP_BAD_GATEWAY, $exception->getStatusCode());
static::assertTrue($exception->is(PayPalApiException::ISSUE_NETWORK_ERROR));
static::assertTrue($exception->is('SERVICE_UNAVAILABLE'));
static::assertSame('SWAG_PAYPAL__API_NETWORK_ERROR', $exception->getErrorCode());
}

$logs = $this->logger->getRecords();
static::assertCount(1, $logs);
static::assertSame('PayPal network error: {message}', $logs[0]->message);
static::assertSame('Could not resolve host: api-m.paypal.com', $logs[0]->context['message']);
static::assertSame('GET', $logs[0]->context['method']);
static::assertSame('https://api-m.paypal.com/v2/checkout/orders/ORDER-ID', $logs[0]->context['target']);
static::assertSame('req-1', $logs[0]->context['requestId']);
}
}
29 changes: 29 additions & 0 deletions tests/RestApi/Exception/PayPalApiExceptionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Psr\Http\Client\NetworkExceptionInterface;
use Psr\Http\Message\RequestInterface;
use Shopware\Core\Framework\Log\Package;
use Shopware\PayPalSDK\Exception\RetryAfterApiException;
use Shopware\PayPalSDK\Struct\Error\DetailCollection;
use Swag\PayPal\RestApi\Exception\PayPalApiException;
use Symfony\Component\HttpFoundation\Response;

/**
* @internal
Expand Down Expand Up @@ -59,4 +62,30 @@ public function getRetryAt(): \DateTimeImmutable
static::assertTrue($exception->is('RATE_LIMIT_REACHED'));
static::assertSame($retryAt, $exception->getRetryAt());
}

public function testFromClientException(): void
{
$request = $this->createMock(RequestInterface::class);

$networkException = new class($request) extends \RuntimeException implements NetworkExceptionInterface {
public function __construct(private readonly RequestInterface $request)
{
parent::__construct('Could not resolve host: api-m.paypal.com');
}

public function getRequest(): RequestInterface
{
return $this->request;
}
};

$exception = PayPalApiException::fromClientException($networkException);

static::assertSame(Response::HTTP_BAD_GATEWAY, $exception->getStatusCode());
static::assertTrue($exception->is(PayPalApiException::ISSUE_NETWORK_ERROR));
static::assertTrue($exception->is('SERVICE_UNAVAILABLE'));
static::assertSame('SWAG_PAYPAL__API_NETWORK_ERROR', $exception->getErrorCode());
static::assertStringContainsString('PayPal is currently unreachable', $exception->getMessage());
static::assertNull($exception->getRetryAt());
}
}
Loading