Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fc0f42610 | |||
| 29645b7a5f | |||
| 012a4e780d | |||
| a35888a6f3 | |||
| a81f6b0d21 | |||
| 88760ed127 | |||
|
|
be2aa4b8d3 | ||
| da3ea113ef | |||
|
|
3437cba042 | ||
|
|
d93b1569f5 |
@@ -1,3 +1,38 @@
|
||||
# Alfa Bank SDK - Release Notes v1.1.0
|
||||
|
||||
## ⚠️ Behaviour change: safe retries
|
||||
|
||||
Saloon retries a request whenever an exception is thrown (`handleRetry` returns `true` by
|
||||
default) and both SDK connectors set `tries = 3`. As a result, non-idempotent operations
|
||||
(`POST /payoffs`, `PATCH /payoffs/{id}/accept`, SBP `QrcReversal`) were sent to the bank up to
|
||||
three times while the calling code believed it had made a single request. For payouts a repeated
|
||||
POST is a duplicate-payment risk, not a harmless retry — the SDK now stops this at the connector
|
||||
level, because Saloon only retries when the per-send callable, the request **and** the connector
|
||||
all allow it.
|
||||
|
||||
The new trait `Apoloz\AlfaBank\Traits\HasSafeRetries` replaces the default with an explicit policy:
|
||||
|
||||
1. A deterministic bank verdict (4xx/5xx) is never retried.
|
||||
2. An explicit deny-list of money operations is never retried.
|
||||
3. An explicit allow-list keeps retries for read-only SBP operations (that API uses POST for reads).
|
||||
4. By default only read-only HTTP methods (GET/HEAD/OPTIONS) are retried.
|
||||
|
||||
Every decision is reported through the connector's PSR-3 logger (`[alfa-sdk][retry]`), which makes
|
||||
the number of physical requests countable per operation via the `X-Request-ID` header.
|
||||
|
||||
## 🐞 Fixes
|
||||
|
||||
- Restored the missing `HasStringBody` and `CmsSigner` imports in
|
||||
`Sbp\Requests\DigitalRuble\CashLink\*` requests: the classes failed with a fatal error when loaded.
|
||||
|
||||
## 🧪 Tests
|
||||
|
||||
- Added `tests/Feature/RetryPolicyTest.php` (23 tests): money operations and bank verdicts are never
|
||||
repeated, read-only operations keep their retries, and the real Saloon retry loop is asserted
|
||||
(a failing non-idempotent POST reaches the wire exactly once).
|
||||
|
||||
---
|
||||
|
||||
# Alfa Bank SDK - Release Notes v1.0.0
|
||||
|
||||
## 🚀 Overview
|
||||
|
||||
@@ -12,6 +12,7 @@ use Apoloz\AlfaBank\Enums\Environment;
|
||||
use Apoloz\AlfaBank\Requests\Accounts\GetAccountsRequest;
|
||||
use Apoloz\AlfaBank\Sbp\SbpConnector;
|
||||
use Apoloz\AlfaBank\Services\OpenSslCmsSigner;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class AlfaBank
|
||||
{
|
||||
@@ -43,7 +44,8 @@ class AlfaBank
|
||||
?string $clientSecret = null,
|
||||
?string $redirectUri = null,
|
||||
?string $signingCertificatePath = null,
|
||||
?string $signingPrivateKeyPath = null
|
||||
?string $signingPrivateKeyPath = null,
|
||||
?LoggerInterface $logger = null,
|
||||
): AlfaBankConnector {
|
||||
$config = new AlfaConfig(
|
||||
baseUrl: $environment->value,
|
||||
@@ -62,7 +64,13 @@ class AlfaBank
|
||||
|
||||
$signer = new OpenSslCmsSigner($signerCert, $signerKey);
|
||||
|
||||
return new AlfaBankConnector($config, $signer);
|
||||
$connector = new AlfaBankConnector($config, $signer);
|
||||
|
||||
if ($logger) {
|
||||
$connector->withLogging($logger);
|
||||
}
|
||||
|
||||
return $connector;
|
||||
}
|
||||
// </SECTION>
|
||||
|
||||
@@ -75,7 +83,8 @@ class AlfaBank
|
||||
Environment $environment = Environment::SANDBOX,
|
||||
?string $caBundlePath = null,
|
||||
?string $signingCertificatePath = null,
|
||||
?string $signingPrivateKeyPath = null
|
||||
?string $signingPrivateKeyPath = null,
|
||||
?LoggerInterface $logger = null,
|
||||
): SbpConnector {
|
||||
$baseUrl = $environment === Environment::PRODUCTION
|
||||
? Environment::SBP_PRODUCTION->value
|
||||
@@ -90,7 +99,7 @@ class AlfaBank
|
||||
signingPrivateKeyPath: $signingPrivateKeyPath
|
||||
);
|
||||
|
||||
return new SbpConnector($config, $keyNameAlias);
|
||||
return new SbpConnector($config, $keyNameAlias, logger: $logger);
|
||||
}
|
||||
// </SECTION>
|
||||
}
|
||||
|
||||
@@ -13,8 +13,14 @@ use Apoloz\AlfaBank\Http\Middleware\LoggingResponseMiddleware;
|
||||
use Apoloz\AlfaBank\Http\Middleware\RequestIdMiddleware;
|
||||
use Apoloz\AlfaBank\Http\Middleware\VerifyJwsResponseMiddleware;
|
||||
use Apoloz\AlfaBank\Http\Pagination\AlfaOffsetPaginator;
|
||||
use Apoloz\AlfaBank\Requests\NominalAccounts\Payouts\CreatePayoffRequest;
|
||||
use Apoloz\AlfaBank\Requests\NominalAccounts\Payouts\CreateTaxRefundRequest;
|
||||
use Apoloz\AlfaBank\Requests\NominalAccounts\Payouts\PayoffAcceptRequest;
|
||||
use Apoloz\AlfaBank\Requests\NominalAccounts\Payouts\TaxRefundAcceptRequest;
|
||||
use Apoloz\AlfaBank\Traits\HasAlfaErrorHandling;
|
||||
use Apoloz\AlfaBank\Traits\HasSafeRetries;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Saloon\Helpers\OAuth2\OAuthConfig;
|
||||
use Saloon\Http\Connector;
|
||||
use Saloon\Http\OAuth2\GetClientCredentialsTokenRequest;
|
||||
@@ -25,7 +31,6 @@ use Saloon\PaginationPlugin\OffsetPaginator;
|
||||
use Saloon\Traits\OAuth2\AuthorizationCodeGrant;
|
||||
use Saloon\Traits\Plugins\AlwaysThrowOnErrors;
|
||||
use Saloon\Traits\Plugins\HasTimeout;
|
||||
use Saloon\Traits\RequestProperties\HasTries;
|
||||
|
||||
class AlfaBankConnector extends Connector implements HasPagination
|
||||
{
|
||||
@@ -33,14 +38,28 @@ class AlfaBankConnector extends Connector implements HasPagination
|
||||
use AuthorizationCodeGrant;
|
||||
use HasAlfaErrorHandling;
|
||||
use HasTimeout;
|
||||
use HasTries;
|
||||
// Replaces the HasTries default handleRetry (always retry) with a safe policy:
|
||||
// transport failures are retried for read-only requests only, bank verdicts never.
|
||||
use HasSafeRetries;
|
||||
|
||||
public function __construct(
|
||||
protected AlfaConfig $alfaConfig,
|
||||
protected ?SignerInterface $signer = null
|
||||
protected ?SignerInterface $signer = null,
|
||||
protected bool $loggingEnabled = true,
|
||||
protected ?LoggerInterface $logger = null,
|
||||
) {
|
||||
$this->tries = 3;
|
||||
$this->retryInterval = 500;
|
||||
$this->logger = $this->logger ?? new NullLogger;
|
||||
|
||||
// Money operations: repeating them is a duplicate-payment risk, so they are
|
||||
// denied explicitly in addition to the read-only-method rule.
|
||||
$this->denyRetriesFor([
|
||||
CreatePayoffRequest::class,
|
||||
PayoffAcceptRequest::class,
|
||||
CreateTaxRefundRequest::class,
|
||||
TaxRefundAcceptRequest::class,
|
||||
]);
|
||||
}
|
||||
|
||||
public function resolveBaseUrl(): string
|
||||
@@ -50,7 +69,12 @@ class AlfaBankConnector extends Connector implements HasPagination
|
||||
|
||||
public function boot(PendingRequest $pendingRequest): void
|
||||
{
|
||||
//
|
||||
$pendingRequest->middleware()->onRequest(new RequestIdMiddleware);
|
||||
|
||||
if ($this->loggingEnabled) {
|
||||
$pendingRequest->middleware()->onRequest(new LoggingRequestMiddleware($this->logger));
|
||||
$pendingRequest->middleware()->onResponse(new LoggingResponseMiddleware($this->logger));
|
||||
}
|
||||
}
|
||||
|
||||
public function paginate(Request $request): OffsetPaginator
|
||||
@@ -101,12 +125,19 @@ class AlfaBankConnector extends Connector implements HasPagination
|
||||
}
|
||||
// </SECTION>
|
||||
|
||||
// <SECTION name="logging_mutator" intent="Allow attaching a PSR-3 logger to the connector for debugging.">
|
||||
// <BELIEF_STATE "Logging should be an optional, opt-in feature. This method provides a fluent interface to attach a logging middleware, which will automatically sanitize sensitive data before output.">
|
||||
// <SECTION name="logging_mutator" intent="Attach a PSR-3 logger to the connector for debugging.">
|
||||
// <BELIEF_STATE "Logging is enabled by default with a NullLogger (silent). Pass a real logger to start seeing output, or disable entirely via withoutLogging().">
|
||||
public function withLogging(LoggerInterface $logger): static
|
||||
{
|
||||
$this->middleware()->onRequest(new LoggingRequestMiddleware($logger));
|
||||
$this->middleware()->onResponse(new LoggingResponseMiddleware($logger));
|
||||
$this->logger = $logger;
|
||||
$this->loggingEnabled = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function withoutLogging(): static
|
||||
{
|
||||
$this->loggingEnabled = false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
@@ -159,12 +190,5 @@ class AlfaBankConnector extends Connector implements HasPagination
|
||||
// OAuth токен запросы требуют application/x-www-form-urlencoded
|
||||
];
|
||||
}
|
||||
|
||||
protected function defaultMiddleware(): array
|
||||
{
|
||||
return [
|
||||
new RequestIdMiddleware,
|
||||
];
|
||||
}
|
||||
// </SECTION>
|
||||
}
|
||||
|
||||
36
src/Data/NominalAccounts/Payouts/PayOffsListResponseData.php
Normal file
36
src/Data/NominalAccounts/Payouts/PayOffsListResponseData.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/*
|
||||
// MODULE_CONTRACT: Defines the data structure for the GET /payoffs list response.
|
||||
// RESPONSIBILITY: To hold and type-safely represent the paginated list of payoffs returned by the bank API.
|
||||
// INPUTS: Raw JSON object from the API response.
|
||||
// OUTPUTS: A structured object containing pagination metadata, account number, and an array of payoff items.
|
||||
// LINKS: Requests/NominalAccounts/Payouts/GetPayoffsRequest.php, PayoffListItemData.php
|
||||
*/
|
||||
|
||||
namespace Apoloz\AlfaBank\Data\NominalAccounts\Payouts;
|
||||
|
||||
use Apoloz\AlfaBank\Data\Shared\PaginationData;
|
||||
use Spatie\LaravelData\Data;
|
||||
|
||||
/**
|
||||
* DTO for the GET /payoffs list response.
|
||||
*
|
||||
* @property-read PaginationData $pagination Pagination metadata
|
||||
* @property-read string $accountNumber Номер номинального счёта
|
||||
* @property-read PayoffListItemData[] $payoffs Список выплат
|
||||
*/
|
||||
class PayOffsListResponseData extends Data
|
||||
{
|
||||
/**
|
||||
* @param PaginationData $pagination Pagination metadata
|
||||
* @param string $accountNumber Номер номинального счёта
|
||||
* @param PayoffListItemData[] $payoffs Список выплат
|
||||
*/
|
||||
public function __construct(
|
||||
public PaginationData $pagination,
|
||||
public string $accountNumber,
|
||||
public array $payoffs,
|
||||
) {}
|
||||
}
|
||||
58
src/Data/NominalAccounts/Payouts/PayoffListItemData.php
Normal file
58
src/Data/NominalAccounts/Payouts/PayoffListItemData.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/*
|
||||
// MODULE_CONTRACT: Defines the data structure for a single payoff item in the list response from GET /payoffs.
|
||||
// RESPONSIBILITY: To hold and type-safely represent comprehensive information about a single payoff from the list endpoint.
|
||||
// INPUTS: Raw JSON object from the API response.
|
||||
// OUTPUTS: A structured object containing payoff details.
|
||||
// LINKS: PayOffsListResponseData.php, Requests/NominalAccounts/Payouts/GetPayoffsRequest.php
|
||||
*/
|
||||
|
||||
namespace Apoloz\AlfaBank\Data\NominalAccounts\Payouts;
|
||||
|
||||
use Spatie\LaravelData\Data;
|
||||
|
||||
/**
|
||||
* DTO for a single payoff item in the GET /payoffs list response.
|
||||
*
|
||||
* Based on OpenAPI PayoffItemResponse schema.
|
||||
* Поля payer и recipient — array (raw JSON) вместо typed DTO, потому что
|
||||
* существующие PayerInfoData/RecipientInfoData имеют другую структуру полей
|
||||
* (accountNumber вместо bankAccount, нет bankName/bankBik/beneficiaryId в нужных местах)
|
||||
* и их модификация может сломать DetailedPayoffData.
|
||||
*
|
||||
* @property-read string $id Идентификатор платежа (UUID)
|
||||
* @property-read string|null $dealId Идентификатор сделки (UUID)
|
||||
* @property-read string|null $stepId Идентификатор этапа сделки (UUID)
|
||||
* @property-read string $payoffType Тип платежа
|
||||
* @property-read string $type Тип платежа (PayoffType)
|
||||
* @property-read string $channel Направление: INTERNAL|EXTERNAL
|
||||
* @property-read string $requisiteType Тип реквизитов
|
||||
* @property-read int $amount Сумма в рублях
|
||||
* @property-read string|null $purpose Назначение платежа
|
||||
* @property-read array|null $payer Плательщик (raw JSON)
|
||||
* @property-read array|null $recipient Получатель (raw JSON)
|
||||
* @property-read string $status Статус
|
||||
* @property-read string|null $createdAt Дата создания (YYYY-MM-DD)
|
||||
* @property-read string|null $updatedAt Дата обновления (YYYY-MM-DD)
|
||||
*/
|
||||
class PayoffListItemData extends Data
|
||||
{
|
||||
public function __construct(
|
||||
public string $id,
|
||||
public ?string $dealId = null,
|
||||
public ?string $stepId = null,
|
||||
public string $payoffType = 'REGULAR',
|
||||
public string $type = 'REGULAR',
|
||||
public string $channel = 'EXTERNAL',
|
||||
public string $requisiteType = 'ACCOUNT',
|
||||
public int $amount = 0,
|
||||
public ?string $purpose = null,
|
||||
public ?array $payer = null,
|
||||
public ?array $recipient = null,
|
||||
public string $status = 'NEW',
|
||||
public ?string $createdAt = null,
|
||||
public ?string $updatedAt = null,
|
||||
) {}
|
||||
}
|
||||
30
src/Data/Shared/PaginationData.php
Normal file
30
src/Data/Shared/PaginationData.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/*
|
||||
// MODULE_CONTRACT: Defines the data structure for pagination metadata.
|
||||
// RESPONSIBILITY: To hold and type-safely represent pagination information from paginated API responses.
|
||||
// INPUTS: Raw JSON object from the API response.
|
||||
// OUTPUTS: A structured object containing pagination details.
|
||||
// LINKS: NominalAccounts/Payouts/PayOffsListResponseData.php
|
||||
*/
|
||||
|
||||
namespace Apoloz\AlfaBank\Data\Shared;
|
||||
|
||||
use Spatie\LaravelData\Data;
|
||||
|
||||
/**
|
||||
* DTO for pagination metadata.
|
||||
*
|
||||
* @property-read int $limit Maximum items per page
|
||||
* @property-read int $offset Number of items skipped
|
||||
* @property-read int $totalCount Total items across all pages
|
||||
*/
|
||||
class PaginationData extends Data
|
||||
{
|
||||
public function __construct(
|
||||
public int $limit,
|
||||
public int $offset,
|
||||
public int $totalCount,
|
||||
) {}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Apoloz\AlfaBank\Http\Middleware;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Saloon\Contracts\RequestMiddleware;
|
||||
use Saloon\Contracts\ResponseMiddleware;
|
||||
use Saloon\Http\PendingRequest;
|
||||
use Saloon\Http\Response;
|
||||
|
||||
class LoggingMiddleware implements RequestMiddleware, ResponseMiddleware
|
||||
{
|
||||
// <SECTION name="constructor_definition" intent="Initialize the middleware with a logger instance.">
|
||||
// <BELIEF_STATE "The constructor injects a `LoggerInterface` instance, making the logging functionality easily testable and configurable. This adheres to dependency inversion principles, allowing the middleware to log messages without being tightly coupled to a specific logging implementation.">
|
||||
public function __construct(protected LoggerInterface $logger) {}
|
||||
// </SECTION>
|
||||
|
||||
// <SECTION name="on_request_method" intent="Log outgoing API requests.">
|
||||
// <BELIEF_STATE "The `onRequest` method intercepts each outgoing HTTP request. It logs the request method and endpoint, along with masked headers and body content. Masking of sensitive information like authorization headers and payment data is performed by `maskHeaders` and `maskBody` methods, ensuring that debugging logs do not compromise security.">
|
||||
public function onRequest(PendingRequest $request): void
|
||||
{
|
||||
$this->logger->info("AlfaBank Request: {$request->getMethod()->value} {$request->getRequest()->resolveEndpoint()}", [
|
||||
'headers' => $this->maskHeaders($request->headers()->all()),
|
||||
'body' => $this->maskBody($request->body()?->all()),
|
||||
]);
|
||||
}
|
||||
// </SECTION>
|
||||
|
||||
// <SECTION name="on_response_method" intent="Log incoming API responses.">
|
||||
// <BELIEF_STATE "The `onResponse` method intercepts each incoming HTTP response. It logs the response status and the masked response body. This provides valuable information for debugging API integrations and understanding the server's responses, all while maintaining the privacy of potentially sensitive data within the response payload.">
|
||||
public function onResponse(Response $response): void
|
||||
{
|
||||
$this->logger->info("AlfaBank Response: {$response->status()}", [
|
||||
'body' => $this->maskBody($response->json()),
|
||||
]);
|
||||
}
|
||||
// </SECTION>
|
||||
|
||||
// <SECTION name="mask_headers_method" intent="Mask sensitive information in HTTP headers.">
|
||||
// <BELIEF_STATE "This protected helper method iterates through the request headers and replaces the `Authorization` header's value with a generic placeholder. This prevents sensitive tokens from being inadvertently exposed in logs, which is a crucial security measure.">
|
||||
protected function maskHeaders(array $headers): array
|
||||
{
|
||||
if (isset($headers['Authorization'])) {
|
||||
$headers['Authorization'] = 'Bearer *****';
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
// </SECTION>
|
||||
|
||||
// <SECTION name="mask_body_method" intent="Mask sensitive information in request/response bodies.">
|
||||
// <BELIEF_STATE "This protected recursive helper method inspects the request or response body for predefined sensitive keys (e.g., `pan`, `client_secret`). If a sensitive key is found, its value is replaced with a mask. The method recursively processes nested arrays, ensuring that sensitive data is masked even within complex JSON structures, thereby enhancing log security.">
|
||||
protected function maskBody(mixed $body): mixed
|
||||
{
|
||||
if (! is_array($body)) {
|
||||
return $body;
|
||||
}
|
||||
|
||||
$sensitiveKeys = ['pan', 'cvc', 'digestSignatures', 'client_secret', 'refresh_token', 'access_token'];
|
||||
|
||||
foreach ($body as $key => $value) {
|
||||
if (in_array($key, $sensitiveKeys, true)) {
|
||||
$body[$key] = '*****';
|
||||
} elseif (is_array($value)) {
|
||||
$body[$key] = $this->maskBody($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
// </SECTION>
|
||||
}
|
||||
// </SECTION>
|
||||
@@ -14,10 +14,14 @@ class LoggingRequestMiddleware implements RequestMiddleware
|
||||
|
||||
public function __invoke(PendingRequest $request): void
|
||||
{
|
||||
$this->logger->info("AlfaBank Request: {$request->getMethod()->value} {$request->getRequest()->resolveEndpoint()}", [
|
||||
'headers' => $this->maskHeaders($request->headers()->all()),
|
||||
'body' => $this->maskBody($request->body()?->all()),
|
||||
]);
|
||||
$this->logger->info(
|
||||
"AlfaBank Request: {$request->getMethod()->value} {$request->getUrl()}",
|
||||
[
|
||||
'host' => $request->getConnector()->resolveBaseUrl(),
|
||||
'headers' => $this->maskHeaders($request->headers()->all()),
|
||||
'body' => $this->maskBody($request->body()?->all()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
protected function maskHeaders(array $headers): array
|
||||
|
||||
@@ -14,9 +14,21 @@ class LoggingResponseMiddleware implements ResponseMiddleware
|
||||
|
||||
public function __invoke(Response $response): void
|
||||
{
|
||||
$this->logger->info("AlfaBank Response: {$response->status()}", [
|
||||
'body' => $this->maskBody($response->json()),
|
||||
]);
|
||||
$pendingRequest = $response->getPendingRequest();
|
||||
|
||||
try {
|
||||
$body = $this->maskBody($response->json());
|
||||
} catch (\JsonException) {
|
||||
$body = $response->body();
|
||||
}
|
||||
|
||||
$this->logger->info(
|
||||
"AlfaBank Response: {$response->status()} {$pendingRequest->getUrl()}",
|
||||
[
|
||||
'host' => $pendingRequest->getConnector()->resolveBaseUrl(),
|
||||
'body' => $body,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
protected function maskBody(mixed $body): mixed
|
||||
|
||||
@@ -24,6 +24,7 @@ use Apoloz\AlfaBank\Services\OpenSslCmsSigner;
|
||||
use Apoloz\AlfaBank\Services\PhpseclibCardEncryptor;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
// </SECTION>
|
||||
|
||||
@@ -68,10 +69,16 @@ class AlfaBankServiceProvider extends ServiceProvider
|
||||
});
|
||||
|
||||
$this->app->singleton(AlfaBankConnector::class, function ($app) {
|
||||
return new AlfaBankConnector(
|
||||
$loggingEnabled = $app['config']['alfa-bank.logging.enabled'] ?? true;
|
||||
|
||||
$connector = new AlfaBankConnector(
|
||||
alfaConfig: $app->make('alfa.baas.config'),
|
||||
signer: $app->make(SignerInterface::class)
|
||||
signer: $app->make(SignerInterface::class),
|
||||
loggingEnabled: $loggingEnabled,
|
||||
logger: $loggingEnabled ? $app->make(LoggerInterface::class) : null,
|
||||
);
|
||||
|
||||
return $connector;
|
||||
});
|
||||
$this->app->alias(AlfaBankConnector::class, 'alfa.baas');
|
||||
|
||||
@@ -90,10 +97,13 @@ class AlfaBankServiceProvider extends ServiceProvider
|
||||
|
||||
$this->app->singleton(SbpConnector::class, function ($app) {
|
||||
$config = $app['config']['alfa-bank.sbp'];
|
||||
$loggingEnabled = $app['config']['alfa-bank.logging.enabled'] ?? true;
|
||||
|
||||
return new SbpConnector(
|
||||
alfaConfig: $app->make('alfa.sbp.config'),
|
||||
keyNameAlias: $config['key_name_alias'] ?? null
|
||||
keyNameAlias: $config['key_name_alias'] ?? null,
|
||||
loggingEnabled: $loggingEnabled,
|
||||
logger: $loggingEnabled ? $app->make(LoggerInterface::class) : null,
|
||||
);
|
||||
});
|
||||
$this->app->alias(SbpConnector::class, 'alfa.sbp');
|
||||
|
||||
@@ -60,5 +60,18 @@ return [
|
||||
'encryption' => [
|
||||
'card_public_key' => env('ALFA_CARD_PUBLIC_KEY'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Logging
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By default, all requests and responses are logged using the Laravel logger.
|
||||
| Set ALFA_LOGGING_ENABLED=false to disable.
|
||||
|
|
||||
*/
|
||||
'logging' => [
|
||||
'enabled' => env('ALFA_LOGGING_ENABLED', true),
|
||||
],
|
||||
];
|
||||
// </SECTION>
|
||||
|
||||
@@ -23,7 +23,7 @@ class UpdateRequisiteRequest extends Request implements HasBody
|
||||
{
|
||||
use HasJsonBody;
|
||||
|
||||
protected Method $method = Method::PUT;
|
||||
protected Method $method = Method::PATCH;
|
||||
|
||||
public function __construct(
|
||||
protected string $beneficiaryId,
|
||||
|
||||
96
src/Requests/NominalAccounts/Payouts/GetPayoffsRequest.php
Normal file
96
src/Requests/NominalAccounts/Payouts/GetPayoffsRequest.php
Normal file
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
/*
|
||||
// MODULE_CONTRACT: Represents the API call to retrieve a list of payoffs by account number.
|
||||
// RESPONSIBILITY: To define the GET request for fetching a paginated list of payoffs with optional filters.
|
||||
// INPUTS: accountNumber (required), limit, offset, channel, status, createdDateFrom/To, updatedDateFrom/To.
|
||||
// OUTPUTS: PayOffsListResponseData (DTO).
|
||||
// LINKS: Data/NominalAccounts/Payouts/PayOffsListResponseData.php
|
||||
*/
|
||||
|
||||
namespace Apoloz\AlfaBank\Requests\NominalAccounts\Payouts;
|
||||
|
||||
use Apoloz\AlfaBank\Data\NominalAccounts\Payouts\PayOffsListResponseData;
|
||||
use Saloon\Enums\Method;
|
||||
use Saloon\Http\Request;
|
||||
use Saloon\Http\Response;
|
||||
|
||||
/**
|
||||
* Request to list payoffs by account number (GET /api/na/jp/v1/payoffs).
|
||||
*
|
||||
* Supports filtering by status, date range, and pagination.
|
||||
*/
|
||||
class GetPayoffsRequest extends Request
|
||||
{
|
||||
protected Method $method = Method::GET;
|
||||
|
||||
/**
|
||||
* @param string $accountNumber Номер номинального счёта (required, 20 digits)
|
||||
* @param int|null $limit Количество объектов на странице (1-100, default 100)
|
||||
* @param int|null $offset Количество объектов, которые необходимо пропустить (default 0)
|
||||
* @param string|null $channel Направление платежа: EXTERNAL|INTERNAL
|
||||
* @param string|null $status Статус платежа: NEW|IN_PROGRESS|FAILED|AWAIT_EXTERNAL|READY
|
||||
* @param string|null $createdDateFrom Начало выборки по дате создания (YYYY-MM-DD)
|
||||
* @param string|null $createdDateTo Конец выборки по дате создания (YYYY-MM-DD)
|
||||
* @param string|null $updatedDateFrom Начало выборки по дате обновления (YYYY-MM-DD)
|
||||
* @param string|null $updatedDateTo Конец выборки по дате обновления (YYYY-MM-DD)
|
||||
*/
|
||||
public function __construct(
|
||||
protected string $accountNumber,
|
||||
protected ?int $limit = 100,
|
||||
protected ?int $offset = 0,
|
||||
protected ?string $channel = null,
|
||||
protected ?string $status = null,
|
||||
protected ?string $createdDateFrom = null,
|
||||
protected ?string $createdDateTo = null,
|
||||
protected ?string $updatedDateFrom = null,
|
||||
protected ?string $updatedDateTo = null,
|
||||
) {}
|
||||
|
||||
public function resolveEndpoint(): string
|
||||
{
|
||||
$params = [];
|
||||
|
||||
$params['accountNumber'] = $this->accountNumber;
|
||||
|
||||
if ($this->limit !== null) {
|
||||
$params['limit'] = $this->limit;
|
||||
}
|
||||
|
||||
if ($this->offset !== null) {
|
||||
$params['offset'] = $this->offset;
|
||||
}
|
||||
|
||||
if ($this->channel !== null) {
|
||||
$params['channel'] = $this->channel;
|
||||
}
|
||||
|
||||
if ($this->status !== null) {
|
||||
$params['status'] = $this->status;
|
||||
}
|
||||
|
||||
if ($this->createdDateFrom !== null) {
|
||||
$params['createdDateFrom'] = $this->createdDateFrom;
|
||||
}
|
||||
|
||||
if ($this->createdDateTo !== null) {
|
||||
$params['createdDateTo'] = $this->createdDateTo;
|
||||
}
|
||||
|
||||
if ($this->updatedDateFrom !== null) {
|
||||
$params['updatedDateFrom'] = $this->updatedDateFrom;
|
||||
}
|
||||
|
||||
if ($this->updatedDateTo !== null) {
|
||||
$params['updatedDateTo'] = $this->updatedDateTo;
|
||||
}
|
||||
|
||||
return '/api/na/jp/v1/payoffs?' . http_build_query($params);
|
||||
}
|
||||
|
||||
public function createDtoFromResponse(Response $response): PayOffsListResponseData
|
||||
{
|
||||
return PayOffsListResponseData::from($response->json());
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace Apoloz\AlfaBank\Requests\NominalAccounts\Payouts;
|
||||
|
||||
use Apoloz\AlfaBank\Data\Shared\ResultResponseData;
|
||||
use Saloon\Contracts\Body\HasBody;
|
||||
use Apoloz\AlfaBank\Data\NominalAccounts\Payouts\PayoffResponseData;
|
||||
use Saloon\Enums\Method;
|
||||
use Saloon\Http\Request;
|
||||
use Saloon\Http\Response;
|
||||
use Saloon\Traits\Body\HasJsonBody;
|
||||
|
||||
class PayoffAcceptRequest extends Request implements HasBody
|
||||
class PayoffAcceptRequest extends Request
|
||||
{
|
||||
use HasJsonBody;
|
||||
|
||||
protected Method $method = Method::PATCH;
|
||||
|
||||
public function __construct(
|
||||
@@ -24,18 +20,13 @@ class PayoffAcceptRequest extends Request implements HasBody
|
||||
|
||||
public function resolveEndpoint(): string
|
||||
{
|
||||
return "/api/na/jp/v1/payoffs/{$this->payoffId}/accept";
|
||||
$query = http_build_query(['accept' => $this->accept ? 'true' : 'false']);
|
||||
|
||||
return "/api/na/jp/v1/payoffs/{$this->payoffId}/accept?{$query}";
|
||||
}
|
||||
|
||||
public function createDtoFromResponse(Response $response): ResultResponseData
|
||||
public function createDtoFromResponse(Response $response): PayoffResponseData
|
||||
{
|
||||
return ResultResponseData::from($response->json());
|
||||
}
|
||||
|
||||
protected function defaultBody(): array
|
||||
{
|
||||
return [
|
||||
'accept' => $this->accept,
|
||||
];
|
||||
return PayoffResponseData::from($response->json());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace Apoloz\AlfaBank\Sbp\Data\Reversal;
|
||||
// <SECTION name="module_imports" intent="Import the base Spatie Laravel Data class.">
|
||||
// <BELIEF_STATE "Extending `Spatie\LaravelData\Data` enables automatic casting from JSON and provides DTO benefits.">
|
||||
use Spatie\LaravelData\Data;
|
||||
use Spatie\LaravelData\Attributes\MapName;
|
||||
|
||||
// </SECTION>
|
||||
|
||||
@@ -36,6 +37,7 @@ class GetQrcReversalDataResponseData extends Data
|
||||
public ?string $qrcId = null,
|
||||
public ?string $trxDT = null,
|
||||
public ?string $trxId = null,
|
||||
#[MapName('RestAmount')]
|
||||
public ?string $restAmount = null,
|
||||
) {}
|
||||
// </SECTION>
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace Apoloz\AlfaBank\Sbp\Data\Reversal;
|
||||
// <SECTION name="module_imports" intent="Import the base Spatie Laravel Data class.">
|
||||
// <BELIEF_STATE "Extending `Spatie\LaravelData\Data` enables automatic casting from JSON and provides DTO benefits.">
|
||||
use Spatie\LaravelData\Data;
|
||||
use Spatie\LaravelData\Attributes\MapName;
|
||||
|
||||
// </SECTION>
|
||||
|
||||
@@ -33,6 +34,7 @@ class QrcReversalResponseData extends Data
|
||||
public ?string $trxDT = null,
|
||||
public ?string $trxId = null,
|
||||
public ?string $payrrn = null,
|
||||
#[MapName('RestAmount')]
|
||||
public ?string $restAmount = null,
|
||||
) {}
|
||||
// </SECTION>
|
||||
|
||||
@@ -14,10 +14,12 @@ namespace Apoloz\AlfaBank\Sbp\Requests\DigitalRuble\CashLink;
|
||||
// <BELIEF_STATE "This POST request requires specific DTOs for its JSON body and its response, along with Saloon's body-handling traits.">
|
||||
use Apoloz\AlfaBank\Sbp\Data\DigitalRuble\CashLink\ChangeAccForCashUniQrRequestData;
|
||||
use Apoloz\AlfaBank\Sbp\Data\DigitalRuble\CashLink\ChangeAccForCashUniQrResponseData;
|
||||
use Apoloz\AlfaBank\Utils\CmsSigner;
|
||||
use Saloon\Contracts\Body\HasBody;
|
||||
use Saloon\Enums\Method;
|
||||
use Saloon\Http\Request;
|
||||
use Saloon\Http\Response;
|
||||
use Saloon\Traits\Body\HasStringBody;
|
||||
|
||||
// </SECTION>
|
||||
|
||||
|
||||
@@ -14,10 +14,12 @@ namespace Apoloz\AlfaBank\Sbp\Requests\DigitalRuble\CashLink;
|
||||
// <BELIEF_STATE "This POST request requires specific DTOs for its JSON body and its response, along with Saloon's body-handling traits.">
|
||||
use Apoloz\AlfaBank\Sbp\Data\DigitalRuble\CashLink\CreateCashUniQrRequestData;
|
||||
use Apoloz\AlfaBank\Sbp\Data\DigitalRuble\CashLink\CreateCashUniQrResponseData;
|
||||
use Apoloz\AlfaBank\Utils\CmsSigner;
|
||||
use Saloon\Contracts\Body\HasBody;
|
||||
use Saloon\Enums\Method;
|
||||
use Saloon\Http\Request;
|
||||
use Saloon\Http\Response;
|
||||
use Saloon\Traits\Body\HasStringBody;
|
||||
|
||||
// </SECTION>
|
||||
|
||||
|
||||
@@ -14,10 +14,12 @@ namespace Apoloz\AlfaBank\Sbp\Requests\DigitalRuble\CashLink;
|
||||
// <BELIEF_STATE "This POST request requires specific DTOs for its JSON body and its response, along with Saloon's body-handling traits.">
|
||||
use Apoloz\AlfaBank\Sbp\Data\DigitalRuble\CashLink\GetCashUniQrStatusRequestData;
|
||||
use Apoloz\AlfaBank\Sbp\Data\DigitalRuble\CashLink\GetCashUniQrStatusResponseData;
|
||||
use Apoloz\AlfaBank\Utils\CmsSigner;
|
||||
use Saloon\Contracts\Body\HasBody;
|
||||
use Saloon\Enums\Method;
|
||||
use Saloon\Http\Request;
|
||||
use Saloon\Http\Response;
|
||||
use Saloon\Traits\Body\HasStringBody;
|
||||
|
||||
// </SECTION>
|
||||
|
||||
|
||||
@@ -5,10 +5,27 @@ declare(strict_types=1);
|
||||
namespace Apoloz\AlfaBank\Sbp;
|
||||
|
||||
use Apoloz\AlfaBank\Core\AlfaConfig;
|
||||
use Apoloz\AlfaBank\Http\Middleware\LoggingRequestMiddleware;
|
||||
use Apoloz\AlfaBank\Http\Middleware\LoggingResponseMiddleware;
|
||||
use Apoloz\AlfaBank\Http\Middleware\RequestIdMiddleware;
|
||||
use Apoloz\AlfaBank\Http\Pagination\AlfaOffsetPaginator;
|
||||
use Apoloz\AlfaBank\Sbp\Requests\CashLink\GetCashQrHistoryRequest;
|
||||
use Apoloz\AlfaBank\Sbp\Requests\CashLink\GetCashQrStatusRequest;
|
||||
use Apoloz\AlfaBank\Sbp\Requests\DigitalRuble\CashLink\GetCashUniQrStatusRequest;
|
||||
use Apoloz\AlfaBank\Sbp\Requests\DigitalRuble\GetUniQrStatusRequest;
|
||||
use Apoloz\AlfaBank\Sbp\Requests\Management\GetBlockedQrListRequest;
|
||||
use Apoloz\AlfaBank\Sbp\Requests\Registry\GetBankListRequest;
|
||||
use Apoloz\AlfaBank\Sbp\Requests\Reversal\GetQrcReversalDataRequest;
|
||||
use Apoloz\AlfaBank\Sbp\Requests\Reversal\GetQrcReversalHistoryRequest;
|
||||
use Apoloz\AlfaBank\Sbp\Requests\Reversal\GetQrcReversalStatusRequest;
|
||||
use Apoloz\AlfaBank\Sbp\Requests\Reversal\QrcReversalRequest;
|
||||
use Apoloz\AlfaBank\Sbp\Requests\Status\GetQrcStatusRequest;
|
||||
use Apoloz\AlfaBank\Sbp\Requests\Subscription\GetQrSubStatusRequest;
|
||||
use Apoloz\AlfaBank\Traits\HasAlfaErrorHandling;
|
||||
use Apoloz\AlfaBank\Traits\HasSafeRetries;
|
||||
use Apoloz\AlfaBank\Utils\CmsSigner;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Saloon\Http\Connector;
|
||||
use Saloon\Http\PendingRequest;
|
||||
use Saloon\Http\Request;
|
||||
@@ -16,21 +33,50 @@ use Saloon\PaginationPlugin\Contracts\HasPagination;
|
||||
use Saloon\PaginationPlugin\OffsetPaginator;
|
||||
use Saloon\Traits\Plugins\AlwaysThrowOnErrors;
|
||||
use Saloon\Traits\Plugins\HasTimeout;
|
||||
use Saloon\Traits\RequestProperties\HasTries;
|
||||
|
||||
class SbpConnector extends Connector implements HasPagination
|
||||
{
|
||||
use AlwaysThrowOnErrors;
|
||||
use HasAlfaErrorHandling;
|
||||
use HasTimeout;
|
||||
use HasTries;
|
||||
// Replaces the HasTries default handleRetry (always retry) with a safe policy.
|
||||
use HasSafeRetries;
|
||||
|
||||
public function __construct(
|
||||
protected AlfaConfig $alfaConfig,
|
||||
protected ?string $keyNameAlias = null
|
||||
protected ?string $keyNameAlias = null,
|
||||
protected bool $loggingEnabled = true,
|
||||
protected ?LoggerInterface $logger = null,
|
||||
) {
|
||||
$this->tries = 3;
|
||||
$this->retryInterval = 500;
|
||||
$this->logger = $this->logger ?? new NullLogger;
|
||||
|
||||
// The SBP API uses POST even for read-only operations, so the read-only method
|
||||
// rule alone would silently disable retries for status/list queries. These are
|
||||
// pure reads: repeating them creates no entity at the bank.
|
||||
$this->allowRetriesFor([
|
||||
GetQrcStatusRequest::class,
|
||||
GetQrcReversalStatusRequest::class,
|
||||
GetQrcReversalHistoryRequest::class,
|
||||
GetCashQrStatusRequest::class,
|
||||
GetCashQrHistoryRequest::class,
|
||||
GetQrSubStatusRequest::class,
|
||||
GetBlockedQrListRequest::class,
|
||||
GetBankListRequest::class,
|
||||
GetUniQrStatusRequest::class,
|
||||
GetCashUniQrStatusRequest::class,
|
||||
]);
|
||||
|
||||
// Money-mutating operations and calls that create/modify a document on the
|
||||
// bank side are denied explicitly. GetQrcReversalDataRequest is deliberately
|
||||
// NOT retried: the bank treats its message as the "previous message" the
|
||||
// following QRCreversal is matched against (EBD 44 / I14044), so it is not a
|
||||
// pure read despite its name.
|
||||
$this->denyRetriesFor([
|
||||
QrcReversalRequest::class,
|
||||
GetQrcReversalDataRequest::class,
|
||||
]);
|
||||
}
|
||||
|
||||
public function resolveBaseUrl(): string
|
||||
@@ -64,6 +110,13 @@ class SbpConnector extends Connector implements HasPagination
|
||||
|
||||
public function boot(PendingRequest $pendingRequest): void
|
||||
{
|
||||
$pendingRequest->middleware()->onRequest(new RequestIdMiddleware);
|
||||
|
||||
if ($this->loggingEnabled) {
|
||||
$pendingRequest->middleware()->onRequest(new LoggingRequestMiddleware($this->logger));
|
||||
$pendingRequest->middleware()->onResponse(new LoggingResponseMiddleware($this->logger));
|
||||
}
|
||||
|
||||
$bodyRepo = $pendingRequest->body();
|
||||
if (! $bodyRepo) {
|
||||
return;
|
||||
@@ -107,6 +160,21 @@ class SbpConnector extends Connector implements HasPagination
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function withLogging(LoggerInterface $logger): static
|
||||
{
|
||||
$this->logger = $logger;
|
||||
$this->loggingEnabled = true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function withoutLogging(): static
|
||||
{
|
||||
$this->loggingEnabled = false;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function paginate(Request $request): OffsetPaginator
|
||||
{
|
||||
return new AlfaOffsetPaginator($this, $request);
|
||||
@@ -133,11 +201,4 @@ class SbpConnector extends Connector implements HasPagination
|
||||
'Accept' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
protected function defaultMiddleware(): array
|
||||
{
|
||||
return [
|
||||
new RequestIdMiddleware,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
147
src/Traits/HasSafeRetries.php
Normal file
147
src/Traits/HasSafeRetries.php
Normal file
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Apoloz\AlfaBank\Traits;
|
||||
|
||||
use Saloon\Enums\Method;
|
||||
use Saloon\Exceptions\Request\FatalRequestException;
|
||||
use Saloon\Exceptions\Request\RequestException;
|
||||
use Saloon\Http\Request;
|
||||
use Saloon\Traits\RequestProperties\HasTries;
|
||||
|
||||
// <SECTION name="safe_retries" intent="Override Saloon's default 'retry anything' behaviour with a policy that never repeats non-idempotent money operations and never repeats deterministic bank verdicts.">
|
||||
// <BELIEF_STATE "Saloon retries by default (handleRetry returns true for any exception) and both SDK connectors set tries = 3, so POST /payoffs (CreatePayoffRequest), PATCH /payoffs/{id}/accept (PayoffAcceptRequest) and SBP QrcReversalRequest were sent up to three times without the calling code knowing. For payouts a repeated POST is a duplicate-payment risk, not a harmless retry, therefore the connector must be the level that stops it: Saloon only retries when the per-send callable, the request and the connector all return true.">
|
||||
trait HasSafeRetries
|
||||
{
|
||||
// HasTries provides $tries, $retryInterval and the retry loop contract.
|
||||
// We replace its handleRetry default (always true) with the policy below.
|
||||
use HasTries;
|
||||
|
||||
/**
|
||||
* HTTP methods whose repetition is idempotent by specification: they read state
|
||||
* and never create or mutate a bank entity.
|
||||
*
|
||||
* @var list<\Saloon\Enums\Method>
|
||||
*/
|
||||
protected const RETRYABLE_METHODS = [Method::GET, Method::HEAD, Method::OPTIONS];
|
||||
|
||||
/**
|
||||
* Explicit deny-list. Wins over every other rule: keeps a non-idempotent
|
||||
* operation safe even if it is ever declared with a read-only HTTP method.
|
||||
*
|
||||
* @var list<class-string<Request>>
|
||||
*/
|
||||
protected array $nonRetryableRequests = [];
|
||||
|
||||
/**
|
||||
* Explicit allow-list for safe operations that are not read-only by method.
|
||||
*
|
||||
* @var list<class-string<Request>>
|
||||
*/
|
||||
protected array $retryableRequests = [];
|
||||
|
||||
/**
|
||||
* Retry decision, evaluated by Saloon inside the retry loop.
|
||||
*
|
||||
* Called for every caught FatalRequestException (transport: timeout, DNS, TLS)
|
||||
* and RequestException (HTTP verdict: 4xx/5xx).
|
||||
*/
|
||||
public function handleRetry(FatalRequestException|RequestException $exception, Request $request): bool
|
||||
{
|
||||
$decision = $this->shouldRetryRequest($exception, $request);
|
||||
|
||||
$this->logRetryDecision($exception, $request, $decision);
|
||||
|
||||
return $decision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Policy itself, free of side effects (used directly by tests and subclasses).
|
||||
*
|
||||
* Rules, in priority order:
|
||||
* 1. The bank answered with a verdict (4xx/5xx) — never retry: repeating a
|
||||
* deterministic rejection only multiplies requests.
|
||||
* 2. Explicit deny-list — never retry.
|
||||
* 3. Explicit allow-list — retry on transport failure.
|
||||
* 4. Default — retry read-only methods only (GET/HEAD/OPTIONS).
|
||||
*/
|
||||
public function shouldRetryRequest(FatalRequestException|RequestException $exception, Request $request): bool
|
||||
{
|
||||
// Rule 1: bank verdict, not a transport failure.
|
||||
if ($exception instanceof RequestException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$requestClass = $request::class;
|
||||
|
||||
// Rule 2: explicit deny-list wins over everything.
|
||||
if (in_array($requestClass, $this->nonRetryableRequests, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rule 3: explicit allow-list.
|
||||
if (in_array($requestClass, $this->retryableRequests, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Rule 4: read-only methods only.
|
||||
return in_array($request->getMethod(), self::RETRYABLE_METHODS, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark requests as non-retryable (operations that create or mutate bank entities).
|
||||
*
|
||||
* @param list<class-string<Request>> $requestClasses
|
||||
*/
|
||||
public function denyRetriesFor(array $requestClasses): static
|
||||
{
|
||||
$this->nonRetryableRequests = array_values(array_unique([...$this->nonRetryableRequests, ...$requestClasses]));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark requests as retryable even when their HTTP method is not read-only.
|
||||
*
|
||||
* @param list<class-string<Request>> $requestClasses
|
||||
*/
|
||||
public function allowRetriesFor(array $requestClasses): static
|
||||
{
|
||||
$this->retryableRequests = array_values(array_unique([...$this->retryableRequests, ...$requestClasses]));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Observability: record every retry decision through the connector's PSR-3 logger
|
||||
* (in the Laravel application this is the finance_alfabank channel).
|
||||
*
|
||||
* X-Request-ID is stable across the attempts of one operation, so the number of
|
||||
* physical requests per operation is 1 + the number of records sharing that id.
|
||||
*
|
||||
* Requires the using connector to expose $logger (both SDK connectors do).
|
||||
*/
|
||||
protected function logRetryDecision(
|
||||
FatalRequestException|RequestException $exception,
|
||||
Request $request,
|
||||
bool $decision
|
||||
): void {
|
||||
if ($this->logger === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->warning('[alfa-sdk][retry]', [
|
||||
'connector' => static::class,
|
||||
'request' => $request::class,
|
||||
'http_method' => $request->getMethod()->value,
|
||||
'exception' => $exception::class,
|
||||
'http_status' => $exception instanceof RequestException ? $exception->getResponse()->status() : null,
|
||||
'decision' => $decision ? 'retry' : 'stop',
|
||||
'request_id' => $exception instanceof RequestException
|
||||
? $exception->getResponse()->header('X-Request-ID')
|
||||
: $exception->getPendingRequest()->headers()->get('X-Request-ID'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
// </SECTION>
|
||||
294
tests/Feature/LoggingTest.php
Normal file
294
tests/Feature/LoggingTest.php
Normal file
@@ -0,0 +1,294 @@
|
||||
<?php
|
||||
|
||||
use Apoloz\AlfaBank\Core\AlfaBankConnector;
|
||||
use Apoloz\AlfaBank\Core\AlfaConfig;
|
||||
use Apoloz\AlfaBank\Http\Middleware\LoggingRequestMiddleware;
|
||||
use Apoloz\AlfaBank\Http\Middleware\LoggingResponseMiddleware;
|
||||
use Apoloz\AlfaBank\Requests\Accounts\GetAccountsRequest;
|
||||
use Apoloz\AlfaBank\Sbp\SbpConnector;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Saloon\Http\Faking\MockClient;
|
||||
use Saloon\Http\Faking\MockResponse;
|
||||
|
||||
function createLoggingConfig(): AlfaConfig
|
||||
{
|
||||
$cert = tempnam(sys_get_temp_dir(), 'cert');
|
||||
$key = tempnam(sys_get_temp_dir(), 'key');
|
||||
|
||||
return new AlfaConfig(
|
||||
baseUrl: 'https://test.alfabank.ru',
|
||||
certificatePath: $cert,
|
||||
privateKeyPath: $key
|
||||
);
|
||||
}
|
||||
|
||||
test('AlfaBankConnector logs request with host by default', function (): void {
|
||||
/** @var LoggerInterface&MockObject */
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$logger->expects($this->exactly(2))
|
||||
->method('info')
|
||||
->with(
|
||||
$this->logicalOr(
|
||||
$this->stringContains('AlfaBank Request'),
|
||||
$this->stringContains('AlfaBank Response')
|
||||
),
|
||||
$this->callback(function (array $context): bool {
|
||||
return isset($context['host']) && $context['host'] === 'https://test.alfabank.ru';
|
||||
})
|
||||
);
|
||||
|
||||
$mockClient = new MockClient([
|
||||
'*' => MockResponse::make(['accounts' => []], 200),
|
||||
]);
|
||||
|
||||
$config = createLoggingConfig();
|
||||
$connector = new AlfaBankConnector($config, loggingEnabled: true, logger: $logger);
|
||||
$connector->withMockClient($mockClient);
|
||||
|
||||
$connector->send(new GetAccountsRequest);
|
||||
});
|
||||
|
||||
test('AlfaBankConnector logs response with host by default', function (): void {
|
||||
/** @var LoggerInterface&MockObject */
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$calls = [];
|
||||
$logger->expects($this->exactly(2))
|
||||
->method('info')
|
||||
->willReturnCallback(function (string $message, array $context) use (&$calls): void {
|
||||
$calls[] = $message;
|
||||
});
|
||||
|
||||
$mockClient = new MockClient([
|
||||
'*' => MockResponse::make(['accounts' => []], 200),
|
||||
]);
|
||||
|
||||
$config = createLoggingConfig();
|
||||
$connector = new AlfaBankConnector($config, loggingEnabled: true, logger: $logger);
|
||||
$connector->withMockClient($mockClient);
|
||||
|
||||
$connector->send(new GetAccountsRequest);
|
||||
|
||||
expect($calls)->toHaveCount(2);
|
||||
expect($calls[0])->toContain('AlfaBank Request');
|
||||
expect($calls[1])->toContain('AlfaBank Response');
|
||||
});
|
||||
|
||||
test('AlfaBankConnector does not log when loggingEnabled is false', function (): void {
|
||||
/** @var LoggerInterface&MockObject */
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$logger->expects($this->never())->method('info');
|
||||
|
||||
$mockClient = new MockClient([
|
||||
'*' => MockResponse::make(['accounts' => []], 200),
|
||||
]);
|
||||
|
||||
$config = createLoggingConfig();
|
||||
$connector = new AlfaBankConnector($config, loggingEnabled: false, logger: $logger);
|
||||
$connector->withMockClient($mockClient);
|
||||
|
||||
$connector->send(new GetAccountsRequest);
|
||||
});
|
||||
|
||||
test('withoutLogging disables logging', function (): void {
|
||||
/** @var LoggerInterface&MockObject */
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$logger->expects($this->never())->method('info');
|
||||
|
||||
$mockClient = new MockClient([
|
||||
'*' => MockResponse::make(['accounts' => []], 200),
|
||||
]);
|
||||
|
||||
$config = createLoggingConfig();
|
||||
$connector = new AlfaBankConnector($config, loggingEnabled: true, logger: $logger);
|
||||
$connector->withoutLogging();
|
||||
$connector->withMockClient($mockClient);
|
||||
|
||||
$connector->send(new GetAccountsRequest);
|
||||
});
|
||||
|
||||
test('withLogging re-enables logging after withoutLogging', function (): void {
|
||||
/** @var LoggerInterface&MockObject */
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$logger->expects($this->exactly(2))->method('info');
|
||||
|
||||
$mockClient = new MockClient([
|
||||
'*' => MockResponse::make(['accounts' => []], 200),
|
||||
]);
|
||||
|
||||
$config = createLoggingConfig();
|
||||
$connector = new AlfaBankConnector($config, loggingEnabled: false, logger: $logger);
|
||||
$connector->withLogging($logger);
|
||||
$connector->withMockClient($mockClient);
|
||||
|
||||
$connector->send(new GetAccountsRequest);
|
||||
});
|
||||
|
||||
test('SbpConnector logs request with host by default', function (): void {
|
||||
/** @var LoggerInterface&MockObject */
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$logger->expects($this->exactly(2))
|
||||
->method('info')
|
||||
->with(
|
||||
$this->logicalOr(
|
||||
$this->stringContains('AlfaBank Request'),
|
||||
$this->stringContains('AlfaBank Response')
|
||||
),
|
||||
$this->callback(function (array $context): bool {
|
||||
return isset($context['host']) && $context['host'] === 'https://test.alfabank.ru';
|
||||
})
|
||||
);
|
||||
|
||||
$mockClient = new MockClient([
|
||||
'*' => MockResponse::make([], 200),
|
||||
]);
|
||||
|
||||
$config = createLoggingConfig();
|
||||
$connector = new SbpConnector($config, 'alias', loggingEnabled: true, logger: $logger);
|
||||
$connector->withMockClient($mockClient);
|
||||
|
||||
$connector->send(new class extends \Saloon\Http\Request
|
||||
{
|
||||
protected \Saloon\Enums\Method $method = \Saloon\Enums\Method::GET;
|
||||
|
||||
public function resolveEndpoint(): string
|
||||
{
|
||||
return '/test';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('SbpConnector withoutLogging disables logging', function (): void {
|
||||
/** @var LoggerInterface&MockObject */
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$logger->expects($this->never())->method('info');
|
||||
|
||||
$mockClient = new MockClient([
|
||||
'*' => MockResponse::make([], 200),
|
||||
]);
|
||||
|
||||
$config = createLoggingConfig();
|
||||
$connector = new SbpConnector($config, 'alias', loggingEnabled: true, logger: $logger);
|
||||
$connector->withoutLogging();
|
||||
$connector->withMockClient($mockClient);
|
||||
|
||||
$connector->send(new class extends \Saloon\Http\Request
|
||||
{
|
||||
protected \Saloon\Enums\Method $method = \Saloon\Enums\Method::GET;
|
||||
|
||||
public function resolveEndpoint(): string
|
||||
{
|
||||
return '/test';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('LoggingRequestMiddleware masks sensitive headers', function (): void {
|
||||
$middleware = new LoggingRequestMiddleware(
|
||||
$this->createMock(LoggerInterface::class)
|
||||
);
|
||||
|
||||
$reflection = new ReflectionMethod($middleware, 'maskHeaders');
|
||||
$result = $reflection->invoke($middleware, [
|
||||
'Authorization' => 'Bearer secret-token',
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
|
||||
expect($result['Authorization'])->toBe('Bearer *****');
|
||||
expect($result['Accept'])->toBe('application/json');
|
||||
});
|
||||
|
||||
test('LoggingRequestMiddleware masks sensitive body fields', function (): void {
|
||||
$middleware = new LoggingRequestMiddleware(
|
||||
$this->createMock(LoggerInterface::class)
|
||||
);
|
||||
|
||||
$reflection = new ReflectionMethod($middleware, 'maskBody');
|
||||
$result = $reflection->invoke($middleware, [
|
||||
'pan' => '4276123456789012',
|
||||
'cvc' => '123',
|
||||
'amount' => 1000,
|
||||
'nested' => [
|
||||
'client_secret' => 'super-secret',
|
||||
],
|
||||
]);
|
||||
|
||||
expect($result['pan'])->toBe('*****');
|
||||
expect($result['cvc'])->toBe('*****');
|
||||
expect($result['amount'])->toBe(1000);
|
||||
expect($result['nested']['client_secret'])->toBe('*****');
|
||||
});
|
||||
|
||||
test('AlfaBankConnector without logger uses NullLogger silently', function (): void {
|
||||
$mockClient = new MockClient([
|
||||
'*' => MockResponse::make(['accounts' => []], 200),
|
||||
]);
|
||||
|
||||
$config = createLoggingConfig();
|
||||
$connector = new AlfaBankConnector($config, loggingEnabled: true);
|
||||
$connector->withMockClient($mockClient);
|
||||
|
||||
expect($connector->send(new GetAccountsRequest))->status()->toBe(200);
|
||||
});
|
||||
|
||||
test('logs non-JSON response body without throwing', function (): void {
|
||||
/** @var LoggerInterface&MockObject */
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$loggedBody = null;
|
||||
$logger->expects($this->exactly(2))
|
||||
->method('info')
|
||||
->willReturnCallback(function (string $message, array $context) use (&$loggedBody): void {
|
||||
if (str_contains($message, 'Response')) {
|
||||
$loggedBody = $context['body'];
|
||||
}
|
||||
});
|
||||
|
||||
$mockClient = new MockClient([
|
||||
'*' => MockResponse::make('eyJhbGciOiJIUzI1NiJ9...', 200, ['Content-Type' => 'application/jwt']),
|
||||
]);
|
||||
|
||||
$config = createLoggingConfig();
|
||||
$connector = new AlfaBankConnector($config, loggingEnabled: true, logger: $logger);
|
||||
$connector->withMockClient($mockClient);
|
||||
|
||||
$response = $connector->send(new GetAccountsRequest);
|
||||
|
||||
expect($response->status())->toBe(200);
|
||||
expect($loggedBody)->toBe('eyJhbGciOiJIUzI1NiJ9...');
|
||||
});
|
||||
|
||||
test('logs plain text response without throwing', function (): void {
|
||||
/** @var LoggerInterface&MockObject */
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
|
||||
$loggedBody = null;
|
||||
$logger->expects($this->exactly(2))
|
||||
->method('info')
|
||||
->willReturnCallback(function (string $message, array $context) use (&$loggedBody): void {
|
||||
if (str_contains($message, 'Response')) {
|
||||
$loggedBody = $context['body'];
|
||||
}
|
||||
});
|
||||
|
||||
$mockClient = new MockClient([
|
||||
'*' => MockResponse::make('Internal Server Error', 200, ['Content-Type' => 'text/plain']),
|
||||
]);
|
||||
|
||||
$config = createLoggingConfig();
|
||||
$connector = new AlfaBankConnector($config, loggingEnabled: true, logger: $logger);
|
||||
$connector->withMockClient($mockClient);
|
||||
|
||||
$response = $connector->send(new GetAccountsRequest);
|
||||
|
||||
expect($response->status())->toBe(200);
|
||||
expect($loggedBody)->toBe('Internal Server Error');
|
||||
});
|
||||
@@ -5,7 +5,9 @@ use Apoloz\AlfaBank\Core\AlfaConfig;
|
||||
use Apoloz\AlfaBank\Data\NominalAccounts\Beneficiaries\AddressData;
|
||||
use Apoloz\AlfaBank\Data\NominalAccounts\Beneficiaries\CreateBeneficiaryRequestData;
|
||||
use Apoloz\AlfaBank\Requests\NominalAccounts\Beneficiaries\CreateBeneficiaryRequest;
|
||||
use Apoloz\AlfaBank\Requests\NominalAccounts\Beneficiaries\Requisites\UpdateRequisiteRequest;
|
||||
use Apoloz\AlfaBank\Services\OpenSslCmsSigner;
|
||||
use Saloon\Enums\Method;
|
||||
use Saloon\Http\Faking\MockResponse;
|
||||
use Spatie\LaravelData\DataCollection;
|
||||
|
||||
@@ -58,3 +60,14 @@ test('can create a beneficiary with automatic signing', function (): void {
|
||||
expect($response->status())->toBe(202)
|
||||
->and($response->dto()->requestId)->toBe('req-123');
|
||||
});
|
||||
|
||||
test('update requisite request uses PATCH', function (): void {
|
||||
$request = new UpdateRequisiteRequest(
|
||||
beneficiaryId: 'ben-1',
|
||||
requisiteId: 'req-1',
|
||||
data: ['phoneNumber' => '79031234567'],
|
||||
);
|
||||
|
||||
expect($request->getMethod())->toBe(Method::PATCH)
|
||||
->and($request->resolveEndpoint())->toBe('/api/na/jp/v1/beneficiaries/ben-1/requisites/req-1');
|
||||
});
|
||||
|
||||
407
tests/Feature/RetryPolicyTest.php
Normal file
407
tests/Feature/RetryPolicyTest.php
Normal file
@@ -0,0 +1,407 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Apoloz\AlfaBank\Core\AlfaBankConnector;
|
||||
use Apoloz\AlfaBank\Requests\NominalAccounts\Payouts\CreatePayoffRequest;
|
||||
use Apoloz\AlfaBank\Requests\NominalAccounts\Payouts\CreateTaxRefundRequest;
|
||||
use Apoloz\AlfaBank\Requests\NominalAccounts\Payouts\PayoffAcceptRequest;
|
||||
use Apoloz\AlfaBank\Requests\NominalAccounts\Payouts\TaxRefundAcceptRequest;
|
||||
use Apoloz\AlfaBank\Sbp\Requests\Reversal\GetQrcReversalDataRequest;
|
||||
use Apoloz\AlfaBank\Sbp\Requests\Reversal\QrcReversalRequest;
|
||||
use Apoloz\AlfaBank\Sbp\SbpConnector;
|
||||
use GuzzleHttp\Psr7\Request as PsrRequest;
|
||||
use GuzzleHttp\Psr7\Response as PsrResponse;
|
||||
use Psr\Log\AbstractLogger;
|
||||
use Saloon\Enums\Method;
|
||||
use Saloon\Exceptions\Request\FatalRequestException;
|
||||
use Saloon\Exceptions\Request\RequestException;
|
||||
use Saloon\Http\Connector;
|
||||
use Saloon\Http\PendingRequest;
|
||||
use Saloon\Http\Request;
|
||||
use Saloon\Http\Response;
|
||||
use Saloon\Http\Senders\GuzzleSender;
|
||||
|
||||
// <SECTION name="retry_policy_tests" intent="Lock down the safe-retry policy: money operations and deterministic bank verdicts are never repeated, read-only operations still retry transport failures.">
|
||||
|
||||
/**
|
||||
* PSR-3 logger double: the SDK is framework-agnostic, so retry decisions are asserted
|
||||
* on the records the connector sends to its logger (in the app: channel finance_alfabank).
|
||||
*/
|
||||
class RetryRecordingLogger extends AbstractLogger
|
||||
{
|
||||
/** @var list<array{level: mixed, message: string, context: array<string, mixed>}> */
|
||||
public array $records = [];
|
||||
|
||||
public function log($level, string|Stringable $message, array $context = []): void
|
||||
{
|
||||
$this->records[] = [
|
||||
'level' => $level,
|
||||
'message' => (string) $message,
|
||||
'context' => $context,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sender that fails on every attempt with a transport error (timeout/DNS/TLS),
|
||||
* which is exactly the case where Saloon's retry loop asks the connector.
|
||||
* Counts physical attempts so a test can prove how many requests reached the wire.
|
||||
*/
|
||||
class RetryCountingFailingSender extends GuzzleSender
|
||||
{
|
||||
public static int $attempts = 0;
|
||||
|
||||
public function send(PendingRequest $pendingRequest): Response
|
||||
{
|
||||
self::$attempts++;
|
||||
|
||||
throw new FatalRequestException(new RuntimeException('Connection timed out'), $pendingRequest);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests that create or move money. Repeating any of them is a duplicate-payment
|
||||
* risk, so this list is enumerated on purpose: if a money operation ever becomes
|
||||
* retryable, this test must fail and force a conscious review.
|
||||
*
|
||||
* @return array<string, array{0: class-string<Request>}>
|
||||
*/
|
||||
function retryMoneyOperations(): array
|
||||
{
|
||||
return [
|
||||
'BaaS: POST /payoffs' => [CreatePayoffRequest::class],
|
||||
'BaaS: PATCH accept' => [PayoffAcceptRequest::class],
|
||||
'BaaS: POST tax refund' => [CreateTaxRefundRequest::class],
|
||||
'BaaS: PATCH tax refund accept' => [TaxRefundAcceptRequest::class],
|
||||
'SBP: QRCreversal' => [QrcReversalRequest::class],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* SBP uses POST even for read-only endpoints. These calls are safe to repeat because
|
||||
* they create nothing at the bank; the list is duplicated here deliberately so that
|
||||
* widening the connector's allow-list requires updating the test too.
|
||||
*
|
||||
* @return list<class-string<Request>>
|
||||
*/
|
||||
function retrySbpReadOnlyPostOperations(): array
|
||||
{
|
||||
return [
|
||||
Apoloz\AlfaBank\Sbp\Requests\Status\GetQrcStatusRequest::class,
|
||||
Apoloz\AlfaBank\Sbp\Requests\Reversal\GetQrcReversalStatusRequest::class,
|
||||
Apoloz\AlfaBank\Sbp\Requests\Reversal\GetQrcReversalHistoryRequest::class,
|
||||
Apoloz\AlfaBank\Sbp\Requests\CashLink\GetCashQrStatusRequest::class,
|
||||
Apoloz\AlfaBank\Sbp\Requests\CashLink\GetCashQrHistoryRequest::class,
|
||||
Apoloz\AlfaBank\Sbp\Requests\Subscription\GetQrSubStatusRequest::class,
|
||||
Apoloz\AlfaBank\Sbp\Requests\Management\GetBlockedQrListRequest::class,
|
||||
Apoloz\AlfaBank\Sbp\Requests\Registry\GetBankListRequest::class,
|
||||
Apoloz\AlfaBank\Sbp\Requests\DigitalRuble\GetUniQrStatusRequest::class,
|
||||
Apoloz\AlfaBank\Sbp\Requests\DigitalRuble\CashLink\GetCashUniQrStatusRequest::class,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* All request classes shipped by the SDK with the HTTP method declared in their defaults.
|
||||
*
|
||||
* @return array<class-string<Request>, Method>
|
||||
*/
|
||||
function retryAllRequestClasses(): array
|
||||
{
|
||||
$sourceRoot = dirname(__DIR__, 2).'/src';
|
||||
$classes = [];
|
||||
|
||||
foreach (['Requests', 'Sbp/Requests'] as $directory) {
|
||||
$path = $sourceRoot.'/'.$directory;
|
||||
|
||||
if (! is_dir($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path));
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relative = substr($file->getPathname(), strlen($sourceRoot) + 1, -4);
|
||||
$class = 'Apoloz\\AlfaBank\\'.str_replace('/', '\\', $relative);
|
||||
|
||||
if (! class_exists($class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$reflection = new ReflectionClass($class);
|
||||
|
||||
if ($reflection->isAbstract() || ! $reflection->isSubclassOf(Request::class)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$classes[$class] = $reflection->getDefaultProperties()['method'] ?? Method::GET;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($classes);
|
||||
|
||||
return $classes;
|
||||
}
|
||||
|
||||
function retryConnectorFor(string $requestClass): Connector
|
||||
{
|
||||
return str_starts_with($requestClass, 'Apoloz\\AlfaBank\\Sbp\\')
|
||||
? new SbpConnector(createTestConfig(), 'test-key-alias')
|
||||
: new AlfaBankConnector(createTestConfig());
|
||||
}
|
||||
|
||||
function retryRequestInstance(string $requestClass): Request
|
||||
{
|
||||
return (new ReflectionClass($requestClass))->newInstanceWithoutConstructor();
|
||||
}
|
||||
|
||||
/** Pending request built from a harmless GET so that no body is signed or merged. */
|
||||
function retryNeutralPendingRequest(Connector $connector): PendingRequest
|
||||
{
|
||||
$neutral = new class extends Request
|
||||
{
|
||||
protected Method $method = Method::GET;
|
||||
|
||||
public function resolveEndpoint(): string
|
||||
{
|
||||
return '/';
|
||||
}
|
||||
};
|
||||
|
||||
return new PendingRequest($connector, $neutral);
|
||||
}
|
||||
|
||||
function retryTransportFailure(Connector $connector): FatalRequestException
|
||||
{
|
||||
return new FatalRequestException(
|
||||
new RuntimeException('Connection timed out'),
|
||||
retryNeutralPendingRequest($connector),
|
||||
);
|
||||
}
|
||||
|
||||
function retryBankVerdict(Connector $connector, int $status = 400): RequestException
|
||||
{
|
||||
$pending = retryNeutralPendingRequest($connector);
|
||||
|
||||
$response = new Response(
|
||||
new PsrResponse($status, ['Content-Type' => 'application/json', 'X-Request-ID' => 'test-request-id'], '{"error":"bad_request"}'),
|
||||
$pending,
|
||||
new PsrRequest('GET', 'https://test.alfabank.ru/'),
|
||||
);
|
||||
|
||||
return new RequestException($response);
|
||||
}
|
||||
|
||||
// --- money operations are never repeated -------------------------------------
|
||||
|
||||
test('money operations are never retried on transport failure', function (string $requestClass): void {
|
||||
$connector = retryConnectorFor($requestClass);
|
||||
|
||||
$decision = $connector->handleRetry(
|
||||
retryTransportFailure($connector),
|
||||
retryRequestInstance($requestClass),
|
||||
);
|
||||
|
||||
expect($decision)->toBeFalse();
|
||||
})->with(retryMoneyOperations());
|
||||
|
||||
test('QRCreversalData is never retried: the bank treats it as the previous message of a reversal', function (): void {
|
||||
$connector = new SbpConnector(createTestConfig(), 'test-key-alias');
|
||||
|
||||
$decision = $connector->handleRetry(
|
||||
retryTransportFailure($connector),
|
||||
retryRequestInstance(GetQrcReversalDataRequest::class),
|
||||
);
|
||||
|
||||
expect($decision)->toBeFalse();
|
||||
});
|
||||
|
||||
// --- read-only operations keep their resilience ------------------------------
|
||||
|
||||
test('read-only GET requests are retried on transport failure', function (): void {
|
||||
$connector = new AlfaBankConnector(createTestConfig());
|
||||
|
||||
$getRequest = new class extends Request
|
||||
{
|
||||
protected Method $method = Method::GET;
|
||||
|
||||
public function resolveEndpoint(): string
|
||||
{
|
||||
return '/payoffs';
|
||||
}
|
||||
};
|
||||
|
||||
expect($connector->handleRetry(retryTransportFailure($connector), $getRequest))->toBeTrue();
|
||||
});
|
||||
|
||||
test('SBP read-only POST operations are still retried on transport failure', function (string $requestClass): void {
|
||||
$connector = new SbpConnector(createTestConfig(), 'test-key-alias');
|
||||
|
||||
$decision = $connector->handleRetry(
|
||||
retryTransportFailure($connector),
|
||||
retryRequestInstance($requestClass),
|
||||
);
|
||||
|
||||
expect($decision)->toBeTrue();
|
||||
})->with(retrySbpReadOnlyPostOperations());
|
||||
|
||||
test('every mutating request class in the SDK is non-retryable by default', function (): void {
|
||||
$sbpReadOnlyPost = retrySbpReadOnlyPostOperations();
|
||||
|
||||
$mutating = array_filter(
|
||||
retryAllRequestClasses(),
|
||||
static fn (Method $method, string $class): bool => ! in_array($method, [Method::GET, Method::HEAD, Method::OPTIONS], true)
|
||||
&& ! in_array($class, $sbpReadOnlyPost, true),
|
||||
ARRAY_FILTER_USE_BOTH,
|
||||
);
|
||||
|
||||
expect($mutating)->not->toBeEmpty();
|
||||
|
||||
foreach ($mutating as $class => $method) {
|
||||
$connector = retryConnectorFor($class);
|
||||
|
||||
$decision = $connector->handleRetry(
|
||||
retryTransportFailure($connector),
|
||||
retryRequestInstance($class),
|
||||
);
|
||||
|
||||
expect($decision)->toBeFalse("{$method->value} {$class} must not be retried");
|
||||
}
|
||||
});
|
||||
|
||||
// --- deterministic bank verdicts ---------------------------------------------
|
||||
|
||||
test('bank verdict responses are never retried, even for read-only requests', function (): void {
|
||||
$connector = new AlfaBankConnector(createTestConfig());
|
||||
|
||||
$getRequest = new class extends Request
|
||||
{
|
||||
protected Method $method = Method::GET;
|
||||
|
||||
public function resolveEndpoint(): string
|
||||
{
|
||||
return '/payoffs';
|
||||
}
|
||||
};
|
||||
|
||||
expect($connector->handleRetry(retryBankVerdict($connector, 400), $getRequest))->toBeFalse()
|
||||
->and($connector->handleRetry(retryBankVerdict($connector, 500), $getRequest))->toBeFalse();
|
||||
});
|
||||
|
||||
// --- the decision is actually enforced by Saloon's retry loop ----------------
|
||||
|
||||
test('a failing non-idempotent POST is sent exactly once', function (): void {
|
||||
$connector = new class(createTestConfig()) extends AlfaBankConnector
|
||||
{
|
||||
protected string $defaultSender = RetryCountingFailingSender::class;
|
||||
};
|
||||
$connector->retryInterval = 0;
|
||||
|
||||
$request = new class extends Request
|
||||
{
|
||||
protected Method $method = Method::POST;
|
||||
|
||||
public function resolveEndpoint(): string
|
||||
{
|
||||
return '/payoffs';
|
||||
}
|
||||
};
|
||||
|
||||
RetryCountingFailingSender::$attempts = 0;
|
||||
|
||||
try {
|
||||
$connector->send($request);
|
||||
} catch (FatalRequestException) {
|
||||
// expected: the original transport failure is surfaced to the caller
|
||||
}
|
||||
|
||||
expect(RetryCountingFailingSender::$attempts)->toBe(1);
|
||||
});
|
||||
|
||||
test('a failing read-only GET still uses all configured attempts', function (): void {
|
||||
$connector = new class(createTestConfig()) extends AlfaBankConnector
|
||||
{
|
||||
protected string $defaultSender = RetryCountingFailingSender::class;
|
||||
};
|
||||
$connector->retryInterval = 0;
|
||||
|
||||
$request = new class extends Request
|
||||
{
|
||||
protected Method $method = Method::GET;
|
||||
|
||||
public function resolveEndpoint(): string
|
||||
{
|
||||
return '/payoffs';
|
||||
}
|
||||
};
|
||||
|
||||
RetryCountingFailingSender::$attempts = 0;
|
||||
|
||||
try {
|
||||
$connector->send($request);
|
||||
} catch (FatalRequestException) {
|
||||
// expected: retries exhausted, original failure surfaced
|
||||
}
|
||||
|
||||
expect(RetryCountingFailingSender::$attempts)->toBe(3);
|
||||
});
|
||||
|
||||
// --- observability -----------------------------------------------------------
|
||||
|
||||
test('the retry decision is logged with the request, method and verdict', function (): void {
|
||||
$logger = new RetryRecordingLogger;
|
||||
$connector = new AlfaBankConnector(createTestConfig());
|
||||
$connector->withLogging($logger);
|
||||
|
||||
$getRequest = new class extends Request
|
||||
{
|
||||
protected Method $method = Method::GET;
|
||||
|
||||
public function resolveEndpoint(): string
|
||||
{
|
||||
return '/payoffs';
|
||||
}
|
||||
};
|
||||
|
||||
$connector->handleRetry(retryTransportFailure($connector), $getRequest);
|
||||
$connector->handleRetry(retryBankVerdict($connector, 400), $getRequest);
|
||||
|
||||
// The connector's logging middleware shares this logger, so keep only retry records.
|
||||
$records = array_values(array_filter(
|
||||
$logger->records,
|
||||
static fn (array $record): bool => $record['message'] === '[alfa-sdk][retry]',
|
||||
));
|
||||
|
||||
expect($records)->toHaveCount(2)
|
||||
->and($records[0]['context']['decision'])->toBe('retry')
|
||||
->and($records[0]['context']['http_method'])->toBe('GET')
|
||||
->and($records[1]['context']['decision'])->toBe('stop')
|
||||
->and($records[1]['context']['http_status'])->toBe(400)
|
||||
->and($records[1]['context']['request_id'])->toBe('test-request-id');
|
||||
});
|
||||
|
||||
// --- explicit policy overrides ------------------------------------------------
|
||||
|
||||
test('the deny-list overrides the read-only default', function (): void {
|
||||
$readOnly = new class extends Request
|
||||
{
|
||||
protected Method $method = Method::GET;
|
||||
|
||||
public function resolveEndpoint(): string
|
||||
{
|
||||
return '/';
|
||||
}
|
||||
};
|
||||
|
||||
$connector = new AlfaBankConnector(createTestConfig());
|
||||
expect($connector->handleRetry(retryTransportFailure($connector), $readOnly))->toBeTrue();
|
||||
|
||||
$connector->denyRetriesFor([$readOnly::class]);
|
||||
expect($connector->handleRetry(retryTransportFailure($connector), $readOnly))->toBeFalse();
|
||||
});
|
||||
|
||||
// </SECTION>
|
||||
Reference in New Issue
Block a user