11 Commits

Author SHA1 Message Date
4fc0f42610 Merge pull request 'fix/safe-retries' (#2) from fix/safe-retries into main
Reviewed-on: #2
2026-09-29 07:47:02 +00:00
29645b7a5f feat(retries): never repeat non-idempotent or verdict requests
Saloon retries whenever an exception is thrown (handleRetry defaults to true)
and both connectors set tries = 3, so POST /payoffs, PATCH /payoffs/{id}/accept
and SBP QrcReversalRequest were sent to the bank up to three times while the
caller believed it had made a single request. For payouts a repeated POST is a
duplicate-payment risk, not a harmless retry.

HasSafeRetries replaces the HasTries default at the connector level (Saloon
retries only when the per-send callable, the request and the connector all
allow it):
- a deterministic bank verdict (4xx/5xx) is never retried;
- an explicit deny-list covers money operations;
- an explicit allow-list keeps retries for read-only SBP operations, since that
  API uses POST even for reads (GetQrcReversalDataRequest stays denied: the bank
  matches the following reversal against it, EBD 44 / I14044);
- by default only GET/HEAD/OPTIONS are retried.

Every decision is logged through the connector's PSR-3 logger as
'[alfa-sdk][retry]', so physical request count per operation is countable via
X-Request-ID.

Tests: tests/Feature/RetryPolicyTest.php (23 tests), including an assertion on
the real retry loop that a failing non-idempotent POST is sent exactly once,
while a read-only GET still uses all configured attempts.
2026-09-29 10:44:18 +03:00
012a4e780d fix(sbp): restore missing HasStringBody and CmsSigner imports in DigitalRuble CashLink requests
Three request classes in Sbp\Requests\DigitalRuble\CashLink used the HasStringBody
trait and CmsSigner::canonicalize() without importing them, so loading the class
fataled with 'Trait not found' / 'Class not found'.
2026-09-29 10:44:18 +03:00
a35888a6f3 Merge pull request 'move put to patch UpdateRequisiteRequest.php' (#1) from Hasan/alfa-bank-sdk:fix/update-requisite-patch into main
Reviewed-on: #1
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
2026-09-03 10:58:39 +00:00
a81f6b0d21 move put to patch UpdateRequisiteRequest.php 2026-09-02 17:17:51 +03:00
88760ed127 fix(PayoffDubleAccept): response struct 2026-07-08 21:38:24 +03:00
Anatoliy Poloz
be2aa4b8d3 refactor(payoff): manual acceptens scrukt chancged 2026-07-08 17:08:04 +03:00
da3ea113ef refactor(sbp): refund 2026-06-25 16:05:46 +03:00
Anatoliy Poloz
3437cba042 payoff list 2026-06-16 22:25:04 +03:00
Anatoliy
d93b1569f5 refactor(logging) 2026-06-09 16:11:15 +03:00
Anatoliy Poloz
7ea5836f44 repair(PayoffRequest): data struct 2026-06-08 15:21:32 +03:00
25 changed files with 1311 additions and 131 deletions

View File

@@ -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 # Alfa Bank SDK - Release Notes v1.0.0
## 🚀 Overview ## 🚀 Overview

View File

@@ -12,6 +12,7 @@ use Apoloz\AlfaBank\Enums\Environment;
use Apoloz\AlfaBank\Requests\Accounts\GetAccountsRequest; use Apoloz\AlfaBank\Requests\Accounts\GetAccountsRequest;
use Apoloz\AlfaBank\Sbp\SbpConnector; use Apoloz\AlfaBank\Sbp\SbpConnector;
use Apoloz\AlfaBank\Services\OpenSslCmsSigner; use Apoloz\AlfaBank\Services\OpenSslCmsSigner;
use Psr\Log\LoggerInterface;
class AlfaBank class AlfaBank
{ {
@@ -43,7 +44,8 @@ class AlfaBank
?string $clientSecret = null, ?string $clientSecret = null,
?string $redirectUri = null, ?string $redirectUri = null,
?string $signingCertificatePath = null, ?string $signingCertificatePath = null,
?string $signingPrivateKeyPath = null ?string $signingPrivateKeyPath = null,
?LoggerInterface $logger = null,
): AlfaBankConnector { ): AlfaBankConnector {
$config = new AlfaConfig( $config = new AlfaConfig(
baseUrl: $environment->value, baseUrl: $environment->value,
@@ -62,7 +64,13 @@ class AlfaBank
$signer = new OpenSslCmsSigner($signerCert, $signerKey); $signer = new OpenSslCmsSigner($signerCert, $signerKey);
return new AlfaBankConnector($config, $signer); $connector = new AlfaBankConnector($config, $signer);
if ($logger) {
$connector->withLogging($logger);
}
return $connector;
} }
// </SECTION> // </SECTION>
@@ -75,7 +83,8 @@ class AlfaBank
Environment $environment = Environment::SANDBOX, Environment $environment = Environment::SANDBOX,
?string $caBundlePath = null, ?string $caBundlePath = null,
?string $signingCertificatePath = null, ?string $signingCertificatePath = null,
?string $signingPrivateKeyPath = null ?string $signingPrivateKeyPath = null,
?LoggerInterface $logger = null,
): SbpConnector { ): SbpConnector {
$baseUrl = $environment === Environment::PRODUCTION $baseUrl = $environment === Environment::PRODUCTION
? Environment::SBP_PRODUCTION->value ? Environment::SBP_PRODUCTION->value
@@ -90,7 +99,7 @@ class AlfaBank
signingPrivateKeyPath: $signingPrivateKeyPath signingPrivateKeyPath: $signingPrivateKeyPath
); );
return new SbpConnector($config, $keyNameAlias); return new SbpConnector($config, $keyNameAlias, logger: $logger);
} }
// </SECTION> // </SECTION>
} }

View File

@@ -13,8 +13,14 @@ use Apoloz\AlfaBank\Http\Middleware\LoggingResponseMiddleware;
use Apoloz\AlfaBank\Http\Middleware\RequestIdMiddleware; use Apoloz\AlfaBank\Http\Middleware\RequestIdMiddleware;
use Apoloz\AlfaBank\Http\Middleware\VerifyJwsResponseMiddleware; use Apoloz\AlfaBank\Http\Middleware\VerifyJwsResponseMiddleware;
use Apoloz\AlfaBank\Http\Pagination\AlfaOffsetPaginator; 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\HasAlfaErrorHandling;
use Apoloz\AlfaBank\Traits\HasSafeRetries;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Saloon\Helpers\OAuth2\OAuthConfig; use Saloon\Helpers\OAuth2\OAuthConfig;
use Saloon\Http\Connector; use Saloon\Http\Connector;
use Saloon\Http\OAuth2\GetClientCredentialsTokenRequest; use Saloon\Http\OAuth2\GetClientCredentialsTokenRequest;
@@ -25,7 +31,6 @@ use Saloon\PaginationPlugin\OffsetPaginator;
use Saloon\Traits\OAuth2\AuthorizationCodeGrant; use Saloon\Traits\OAuth2\AuthorizationCodeGrant;
use Saloon\Traits\Plugins\AlwaysThrowOnErrors; use Saloon\Traits\Plugins\AlwaysThrowOnErrors;
use Saloon\Traits\Plugins\HasTimeout; use Saloon\Traits\Plugins\HasTimeout;
use Saloon\Traits\RequestProperties\HasTries;
class AlfaBankConnector extends Connector implements HasPagination class AlfaBankConnector extends Connector implements HasPagination
{ {
@@ -33,14 +38,28 @@ class AlfaBankConnector extends Connector implements HasPagination
use AuthorizationCodeGrant; use AuthorizationCodeGrant;
use HasAlfaErrorHandling; use HasAlfaErrorHandling;
use HasTimeout; 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( public function __construct(
protected AlfaConfig $alfaConfig, protected AlfaConfig $alfaConfig,
protected ?SignerInterface $signer = null protected ?SignerInterface $signer = null,
protected bool $loggingEnabled = true,
protected ?LoggerInterface $logger = null,
) { ) {
$this->tries = 3; $this->tries = 3;
$this->retryInterval = 500; $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 public function resolveBaseUrl(): string
@@ -50,7 +69,12 @@ class AlfaBankConnector extends Connector implements HasPagination
public function boot(PendingRequest $pendingRequest): void 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 public function paginate(Request $request): OffsetPaginator
@@ -101,12 +125,19 @@ class AlfaBankConnector extends Connector implements HasPagination
} }
// </SECTION> // </SECTION>
// <SECTION name="logging_mutator" intent="Allow attaching a PSR-3 logger to the connector for debugging."> // <SECTION name="logging_mutator" intent="Attach 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."> // <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 public function withLogging(LoggerInterface $logger): static
{ {
$this->middleware()->onRequest(new LoggingRequestMiddleware($logger)); $this->logger = $logger;
$this->middleware()->onResponse(new LoggingResponseMiddleware($logger)); $this->loggingEnabled = true;
return $this;
}
public function withoutLogging(): static
{
$this->loggingEnabled = false;
return $this; return $this;
} }
@@ -159,12 +190,5 @@ class AlfaBankConnector extends Connector implements HasPagination
// OAuth токен запросы требуют application/x-www-form-urlencoded // OAuth токен запросы требуют application/x-www-form-urlencoded
]; ];
} }
protected function defaultMiddleware(): array
{
return [
new RequestIdMiddleware,
];
}
// </SECTION> // </SECTION>
} }

View 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,
) {}
}

View 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,
) {}
}

View File

@@ -47,5 +47,10 @@ class PayoffRequestData extends Data
public ?DataCollection $requisites = null, public ?DataCollection $requisites = null,
public ?TaxData $tax = null, public ?TaxData $tax = null,
) {} ) {}
public function toArray(): array
{
return array_filter(parent::toArray(), fn ($v) => $v !== null);
}
// </SECTION> // </SECTION>
} }

View 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,
) {}
}

View File

@@ -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>

View File

@@ -14,10 +14,14 @@ class LoggingRequestMiddleware implements RequestMiddleware
public function __invoke(PendingRequest $request): void public function __invoke(PendingRequest $request): void
{ {
$this->logger->info("AlfaBank Request: {$request->getMethod()->value} {$request->getRequest()->resolveEndpoint()}", [ $this->logger->info(
'headers' => $this->maskHeaders($request->headers()->all()), "AlfaBank Request: {$request->getMethod()->value} {$request->getUrl()}",
'body' => $this->maskBody($request->body()?->all()), [
]); 'host' => $request->getConnector()->resolveBaseUrl(),
'headers' => $this->maskHeaders($request->headers()->all()),
'body' => $this->maskBody($request->body()?->all()),
]
);
} }
protected function maskHeaders(array $headers): array protected function maskHeaders(array $headers): array

View File

@@ -14,9 +14,21 @@ class LoggingResponseMiddleware implements ResponseMiddleware
public function __invoke(Response $response): void public function __invoke(Response $response): void
{ {
$this->logger->info("AlfaBank Response: {$response->status()}", [ $pendingRequest = $response->getPendingRequest();
'body' => $this->maskBody($response->json()),
]); 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 protected function maskBody(mixed $body): mixed

View File

@@ -24,6 +24,7 @@ use Apoloz\AlfaBank\Services\OpenSslCmsSigner;
use Apoloz\AlfaBank\Services\PhpseclibCardEncryptor; use Apoloz\AlfaBank\Services\PhpseclibCardEncryptor;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Psr\Log\LoggerInterface;
// </SECTION> // </SECTION>
@@ -68,10 +69,16 @@ class AlfaBankServiceProvider extends ServiceProvider
}); });
$this->app->singleton(AlfaBankConnector::class, function ($app) { $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'), 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'); $this->app->alias(AlfaBankConnector::class, 'alfa.baas');
@@ -90,10 +97,13 @@ class AlfaBankServiceProvider extends ServiceProvider
$this->app->singleton(SbpConnector::class, function ($app) { $this->app->singleton(SbpConnector::class, function ($app) {
$config = $app['config']['alfa-bank.sbp']; $config = $app['config']['alfa-bank.sbp'];
$loggingEnabled = $app['config']['alfa-bank.logging.enabled'] ?? true;
return new SbpConnector( return new SbpConnector(
alfaConfig: $app->make('alfa.sbp.config'), 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'); $this->app->alias(SbpConnector::class, 'alfa.sbp');

View File

@@ -60,5 +60,18 @@ return [
'encryption' => [ 'encryption' => [
'card_public_key' => env('ALFA_CARD_PUBLIC_KEY'), '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> // </SECTION>

View File

@@ -23,7 +23,7 @@ class UpdateRequisiteRequest extends Request implements HasBody
{ {
use HasJsonBody; use HasJsonBody;
protected Method $method = Method::PUT; protected Method $method = Method::PATCH;
public function __construct( public function __construct(
protected string $beneficiaryId, protected string $beneficiaryId,

View 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());
}
}

View File

@@ -4,17 +4,13 @@ declare(strict_types=1);
namespace Apoloz\AlfaBank\Requests\NominalAccounts\Payouts; namespace Apoloz\AlfaBank\Requests\NominalAccounts\Payouts;
use Apoloz\AlfaBank\Data\Shared\ResultResponseData; use Apoloz\AlfaBank\Data\NominalAccounts\Payouts\PayoffResponseData;
use Saloon\Contracts\Body\HasBody;
use Saloon\Enums\Method; use Saloon\Enums\Method;
use Saloon\Http\Request; use Saloon\Http\Request;
use Saloon\Http\Response; 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; protected Method $method = Method::PATCH;
public function __construct( public function __construct(
@@ -24,18 +20,13 @@ class PayoffAcceptRequest extends Request implements HasBody
public function resolveEndpoint(): string 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()); return PayoffResponseData::from($response->json());
}
protected function defaultBody(): array
{
return [
'accept' => $this->accept,
];
} }
} }

View File

@@ -14,6 +14,7 @@ namespace Apoloz\AlfaBank\Sbp\Data\Reversal;
// <SECTION name="module_imports" intent="Import the base Spatie Laravel Data class."> // <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."> // <BELIEF_STATE "Extending `Spatie\LaravelData\Data` enables automatic casting from JSON and provides DTO benefits.">
use Spatie\LaravelData\Data; use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\MapName;
// </SECTION> // </SECTION>
@@ -36,6 +37,7 @@ class GetQrcReversalDataResponseData extends Data
public ?string $qrcId = null, public ?string $qrcId = null,
public ?string $trxDT = null, public ?string $trxDT = null,
public ?string $trxId = null, public ?string $trxId = null,
#[MapName('RestAmount')]
public ?string $restAmount = null, public ?string $restAmount = null,
) {} ) {}
// </SECTION> // </SECTION>

View File

@@ -14,6 +14,7 @@ namespace Apoloz\AlfaBank\Sbp\Data\Reversal;
// <SECTION name="module_imports" intent="Import the base Spatie Laravel Data class."> // <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."> // <BELIEF_STATE "Extending `Spatie\LaravelData\Data` enables automatic casting from JSON and provides DTO benefits.">
use Spatie\LaravelData\Data; use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\MapName;
// </SECTION> // </SECTION>
@@ -33,6 +34,7 @@ class QrcReversalResponseData extends Data
public ?string $trxDT = null, public ?string $trxDT = null,
public ?string $trxId = null, public ?string $trxId = null,
public ?string $payrrn = null, public ?string $payrrn = null,
#[MapName('RestAmount')]
public ?string $restAmount = null, public ?string $restAmount = null,
) {} ) {}
// </SECTION> // </SECTION>

View File

@@ -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."> // <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\ChangeAccForCashUniQrRequestData;
use Apoloz\AlfaBank\Sbp\Data\DigitalRuble\CashLink\ChangeAccForCashUniQrResponseData; use Apoloz\AlfaBank\Sbp\Data\DigitalRuble\CashLink\ChangeAccForCashUniQrResponseData;
use Apoloz\AlfaBank\Utils\CmsSigner;
use Saloon\Contracts\Body\HasBody; use Saloon\Contracts\Body\HasBody;
use Saloon\Enums\Method; use Saloon\Enums\Method;
use Saloon\Http\Request; use Saloon\Http\Request;
use Saloon\Http\Response; use Saloon\Http\Response;
use Saloon\Traits\Body\HasStringBody;
// </SECTION> // </SECTION>

View File

@@ -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."> // <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\CreateCashUniQrRequestData;
use Apoloz\AlfaBank\Sbp\Data\DigitalRuble\CashLink\CreateCashUniQrResponseData; use Apoloz\AlfaBank\Sbp\Data\DigitalRuble\CashLink\CreateCashUniQrResponseData;
use Apoloz\AlfaBank\Utils\CmsSigner;
use Saloon\Contracts\Body\HasBody; use Saloon\Contracts\Body\HasBody;
use Saloon\Enums\Method; use Saloon\Enums\Method;
use Saloon\Http\Request; use Saloon\Http\Request;
use Saloon\Http\Response; use Saloon\Http\Response;
use Saloon\Traits\Body\HasStringBody;
// </SECTION> // </SECTION>

View File

@@ -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."> // <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\GetCashUniQrStatusRequestData;
use Apoloz\AlfaBank\Sbp\Data\DigitalRuble\CashLink\GetCashUniQrStatusResponseData; use Apoloz\AlfaBank\Sbp\Data\DigitalRuble\CashLink\GetCashUniQrStatusResponseData;
use Apoloz\AlfaBank\Utils\CmsSigner;
use Saloon\Contracts\Body\HasBody; use Saloon\Contracts\Body\HasBody;
use Saloon\Enums\Method; use Saloon\Enums\Method;
use Saloon\Http\Request; use Saloon\Http\Request;
use Saloon\Http\Response; use Saloon\Http\Response;
use Saloon\Traits\Body\HasStringBody;
// </SECTION> // </SECTION>

View File

@@ -5,10 +5,27 @@ declare(strict_types=1);
namespace Apoloz\AlfaBank\Sbp; namespace Apoloz\AlfaBank\Sbp;
use Apoloz\AlfaBank\Core\AlfaConfig; 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\Middleware\RequestIdMiddleware;
use Apoloz\AlfaBank\Http\Pagination\AlfaOffsetPaginator; 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\HasAlfaErrorHandling;
use Apoloz\AlfaBank\Traits\HasSafeRetries;
use Apoloz\AlfaBank\Utils\CmsSigner; use Apoloz\AlfaBank\Utils\CmsSigner;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Saloon\Http\Connector; use Saloon\Http\Connector;
use Saloon\Http\PendingRequest; use Saloon\Http\PendingRequest;
use Saloon\Http\Request; use Saloon\Http\Request;
@@ -16,21 +33,50 @@ use Saloon\PaginationPlugin\Contracts\HasPagination;
use Saloon\PaginationPlugin\OffsetPaginator; use Saloon\PaginationPlugin\OffsetPaginator;
use Saloon\Traits\Plugins\AlwaysThrowOnErrors; use Saloon\Traits\Plugins\AlwaysThrowOnErrors;
use Saloon\Traits\Plugins\HasTimeout; use Saloon\Traits\Plugins\HasTimeout;
use Saloon\Traits\RequestProperties\HasTries;
class SbpConnector extends Connector implements HasPagination class SbpConnector extends Connector implements HasPagination
{ {
use AlwaysThrowOnErrors; use AlwaysThrowOnErrors;
use HasAlfaErrorHandling; use HasAlfaErrorHandling;
use HasTimeout; use HasTimeout;
use HasTries; // Replaces the HasTries default handleRetry (always retry) with a safe policy.
use HasSafeRetries;
public function __construct( public function __construct(
protected AlfaConfig $alfaConfig, protected AlfaConfig $alfaConfig,
protected ?string $keyNameAlias = null protected ?string $keyNameAlias = null,
protected bool $loggingEnabled = true,
protected ?LoggerInterface $logger = null,
) { ) {
$this->tries = 3; $this->tries = 3;
$this->retryInterval = 500; $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 public function resolveBaseUrl(): string
@@ -64,6 +110,13 @@ class SbpConnector extends Connector implements HasPagination
public function boot(PendingRequest $pendingRequest): void 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(); $bodyRepo = $pendingRequest->body();
if (! $bodyRepo) { if (! $bodyRepo) {
return; return;
@@ -107,6 +160,21 @@ class SbpConnector extends Connector implements HasPagination
return $this; 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 public function paginate(Request $request): OffsetPaginator
{ {
return new AlfaOffsetPaginator($this, $request); return new AlfaOffsetPaginator($this, $request);
@@ -133,11 +201,4 @@ class SbpConnector extends Connector implements HasPagination
'Accept' => 'application/json', 'Accept' => 'application/json',
]; ];
} }
protected function defaultMiddleware(): array
{
return [
new RequestIdMiddleware,
];
}
} }

View 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>

View 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');
});

View File

@@ -5,7 +5,9 @@ use Apoloz\AlfaBank\Core\AlfaConfig;
use Apoloz\AlfaBank\Data\NominalAccounts\Beneficiaries\AddressData; use Apoloz\AlfaBank\Data\NominalAccounts\Beneficiaries\AddressData;
use Apoloz\AlfaBank\Data\NominalAccounts\Beneficiaries\CreateBeneficiaryRequestData; use Apoloz\AlfaBank\Data\NominalAccounts\Beneficiaries\CreateBeneficiaryRequestData;
use Apoloz\AlfaBank\Requests\NominalAccounts\Beneficiaries\CreateBeneficiaryRequest; use Apoloz\AlfaBank\Requests\NominalAccounts\Beneficiaries\CreateBeneficiaryRequest;
use Apoloz\AlfaBank\Requests\NominalAccounts\Beneficiaries\Requisites\UpdateRequisiteRequest;
use Apoloz\AlfaBank\Services\OpenSslCmsSigner; use Apoloz\AlfaBank\Services\OpenSslCmsSigner;
use Saloon\Enums\Method;
use Saloon\Http\Faking\MockResponse; use Saloon\Http\Faking\MockResponse;
use Spatie\LaravelData\DataCollection; use Spatie\LaravelData\DataCollection;
@@ -58,3 +60,14 @@ test('can create a beneficiary with automatic signing', function (): void {
expect($response->status())->toBe(202) expect($response->status())->toBe(202)
->and($response->dto()->requestId)->toBe('req-123'); ->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');
});

View 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>