refactor: dto, requests, connector

This commit is contained in:
2026-01-19 10:46:57 +03:00
parent b2786d8606
commit 78e1e82331
32 changed files with 894 additions and 42 deletions

View File

@@ -119,5 +119,15 @@ class AlfaBankConnector extends Connector implements HasPagination
return $this;
}
/**
* Enable automated JWS response verification and decoding.
*/
public function withJwsVerification(string $publicKeyPem): static
{
$this->middleware()->onResponse(new \Apoloz\AlfaBank\Http\Middleware\VerifyJwsResponseMiddleware($publicKeyPem));
return $this;
}
// </SECTION>
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace Apoloz\AlfaBank\Data\SelfEmployed;
use Spatie\LaravelData\Data;
class CreateIncomeRequestData extends Data
{
public function __construct(
public string $vatin,
public string $name,
public string $amount,
public string $date,
public ?string $inn = null,
public ?string $organizationName = null,
) {}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Apoloz\AlfaBank\Data\SelfEmployed;
use Spatie\LaravelData\Data;
class CreateIncomeResponseData extends Data
{
public function __construct(
public string $requestId,
public string $status,
) {}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Apoloz\AlfaBank\Http\Middleware;
use Apoloz\AlfaBank\Utils\JwsSigner;
use Saloon\Http\Response;
class VerifyJwsResponseMiddleware
{
public function __construct(
protected ?string $publicKey = null
) {}
public function __invoke(Response $response): Response
{
$contentType = $response->header('Content-Type');
if (str_contains((string)$contentType, 'application/jose')) {
if (!$this->publicKey) {
return $response;
}
$jws = $response->body();
$decoded = JwsSigner::verify($jws, $this->publicKey);
if ($decoded) {
// We create a new response with the decoded JSON body
return $response->fake(json_encode($decoded), $response->status(), $response->headers()->all());
}
}
return $response;
}
}

View File

@@ -15,12 +15,32 @@ class VerifyAlfaWebhook
*/
public function handle(Request $request, Closure $next): Response
{
// 1. Check Basic Auth (Legacy/Simple)
$user = config('alfa-bank.webhook.user');
$password = config('alfa-bank.webhook.password');
if ($user && $password) {
if ($request->getUser() !== $user || $request->getPassword() !== $password) {
return response()->json(['error' => 'Unauthorized Webhook'], 401);
return response()->json(['error' => 'Unauthorized Webhook (Basic Auth)'], 401);
}
}
// 2. Check JWS Signature (Advanced)
$publicKeyPath = config('alfa-bank.webhook.alfa_public_key');
if ($publicKeyPath && file_exists($publicKeyPath)) {
$signature = $request->header('x-jws-signature') ?: $request->getContent();
// If content is a JWS string (3 parts separated by dot)
if (is_string($signature) && count(explode('.', $signature)) === 3) {
$publicKey = file_get_contents($publicKeyPath);
$verified = \Apoloz\AlfaBank\Utils\JwsSigner::verify($signature, $publicKey);
if (!$verified) {
return response()->json(['error' => 'Invalid Webhook Signature'], 401);
}
// Replace request content with verified payload
$request->merge($verified);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Apoloz\AlfaBank\Requests\Accounts;
use Saloon\Enums\Method;
use Saloon\Http\Request;
class GetAccountStatusRequest extends Request
{
protected Method $method = Method::GET;
public function __construct(
protected string $accountNumber
) {}
public function resolveEndpoint(): string
{
return "/api/gd/v1/accounts/{$this->accountNumber}/status";
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Apoloz\AlfaBank\Requests\B2C;
use Saloon\Enums\Method;
use Saloon\Http\Request;
class GetB2CPackageStatusRequest extends Request
{
protected Method $method = Method::GET;
public function __construct(
protected string $packageId
) {}
public function resolveEndpoint(): string
{
return "/api/b2cpay/jp/v1/payments/packages/{$this->packageId}";
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Apoloz\AlfaBank\Requests\DigitalIdentity;
use Saloon\Enums\Method;
use Saloon\Http\Request;
class GetAccountChannelStatusRequest extends Request
{
protected Method $method = Method::GET;
public function __construct(
protected string $accountNumber
) {}
public function resolveEndpoint(): string
{
return "/api/gd/v1/accounts/{$this->accountNumber}/status";
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Apoloz\AlfaBank\Requests\Files;
use Saloon\Contracts\Body\HasBody;
use Saloon\Enums\Method;
use Saloon\Http\Request;
use Saloon\Traits\Body\HasStreamBody;
class UploadFileRequest extends Request implements HasBody
{
use HasStreamBody;
protected Method $method = Method::PUT;
/**
* @param string $url Full pre-signed URL from Alfa Bank
* @param mixed $resource File resource or stream
*/
public function __construct(
protected string $url,
protected $resource
) {}
public function resolveEndpoint(): string
{
return $this->url;
}
protected function defaultHeaders(): array
{
return [
'Accept' => '*/*',
'Content-Type' => 'application/octet-stream',
];
}
protected function defineBody(): mixed
{
return $this->resource;
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Apoloz\AlfaBank\Requests\Partner;
use Spatie\LaravelData\Data;
use Saloon\Contracts\Body\HasBody;
use Saloon\Enums\Method;
use Saloon\Http\Request;
use Saloon\Traits\Body\HasJsonBody;
class CreateIndividualRequest extends Request implements HasBody
{
use HasJsonBody;
protected Method $method = Method::POST;
public function __construct(
protected array $data
) {}
public function resolveEndpoint(): string
{
return '/partner/jp/v1/individuals';
}
protected function defaultBody(): array
{
return $this->data;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Apoloz\AlfaBank\Requests\Poa;
use Saloon\Enums\Method;
use Saloon\Http\Request;
class DownloadPoaRequest extends Request
{
protected Method $method = Method::GET;
public function __construct(
protected string $requestUuid
) {}
public function resolveEndpoint(): string
{
return "/api/jp/v1/poa/requests/{$this->requestUuid}/download";
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Apoloz\AlfaBank\Requests\Registries;
use Saloon\Enums\Method;
use Saloon\Http\Request;
class GetRegistryPaymentsRequest extends Request
{
protected Method $method = Method::GET;
public function __construct(
protected string $registryId
) {}
public function resolveEndpoint(): string
{
return "/api/jp/v1/registries/{$this->registryId}/payments";
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Apoloz\AlfaBank\Requests\Registries;
use Saloon\Enums\Method;
use Saloon\Http\Request;
class GetRegistryRequest extends Request
{
protected Method $method = Method::GET;
public function __construct(
protected string $registryId
) {}
public function resolveEndpoint(): string
{
return "/api/jp/v1/registries/{$this->registryId}";
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Apoloz\AlfaBank\Requests\SelfEmployed;
use Saloon\Enums\Method;
use Saloon\Http\Request;
class GetRegionsRequest extends Request
{
protected Method $method = Method::GET;
public function resolveEndpoint(): string
{
return '/semp/v1/dictionary/regions';
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Apoloz\AlfaBank\Requests\SelfEmployed;
use Apoloz\AlfaBank\Data\SelfEmployed\CreateIncomeRequestData;
use Apoloz\AlfaBank\Data\SelfEmployed\CreateIncomeResponseData;
use Saloon\Contracts\Body\HasBody;
use Saloon\Enums\Method;
use Saloon\Http\Request;
use Saloon\Http\Response;
use Saloon\Traits\Body\HasJsonBody;
class RegisterIncomeRequest extends Request implements HasBody
{
use HasJsonBody;
protected Method $method = Method::POST;
public function __construct(
protected CreateIncomeRequestData $data
) {}
public function resolveEndpoint(): string
{
return '/semp/v1/selfemployed/income';
}
protected function defaultBody(): array
{
return $this->data->toArray();
}
public function createDtoFromResponse(Response $response): CreateIncomeResponseData
{
return CreateIncomeResponseData::from($response->json());
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Apoloz\AlfaBank\Requests\Signature;
use Saloon\Enums\Method;
use Saloon\Http\Request;
class GetDistinguishedNameRequest extends Request
{
protected Method $method = Method::GET;
public function resolveEndpoint(): string
{
return '/jp/v3/signature/dn';
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Apoloz\AlfaBank\Sbp\Data\Reversal;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\DataCollection;
class ReversalHistoryResponseData extends Data
{
public function __construct(
public int $ErrorCode,
public string $message,
#[DataCollectionOf(ReversalHistoryItemData::class)]
public ?DataCollection $refunds = null,
) {}
}
class ReversalHistoryItemData extends Data
{
public function __construct(
public string $TermNo,
public string $amount,
public string $originalQrcId,
public string $originalTrxId,
public string $payerId,
public string $status,
public ?string $trxId = null,
public ?string $trxDT = null,
) {}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Apoloz\AlfaBank\Sbp\Data\Webhooks;
use Spatie\LaravelData\Data;
class SbpPaymentNotificationData extends Data
{
public function __construct(
public string $command,
public string $qrcId,
public string $trxId,
public string $status,
public string $amount,
public string $timestamp,
public string $payerId,
public string $payrrn,
public string $TermNo,
public ?string $kzo = null,
public ?string $CreditDatePos = null,
) {}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Apoloz\AlfaBank\Sbp\Data\Webhooks;
use Spatie\LaravelData\Data;
class SbpRefundNotificationData extends Data
{
public function __construct(
public string $command,
public string $TermNo,
public string $originalQrcId,
public string $originalTrxId,
public string $trxId,
public string $trxDT,
public string $status,
public string $amount,
public string $ErrorCode,
public string $message,
public string $payerId,
public ?string $messageID = null,
public ?string $agentRefundRequestId = null,
public ?string $opkcRefundRequestId = null,
) {}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Apoloz\AlfaBank\Sbp\Requests\Reversal;
use Apoloz\AlfaBank\Sbp\Data\Reversal\ReversalHistoryResponseData;
use Saloon\Contracts\Body\HasBody;
use Saloon\Enums\Method;
use Saloon\Http\Request;
use Saloon\Http\Response;
use Saloon\Traits\Body\HasJsonBody;
class GetQrcReversalHistoryRequest extends Request implements HasBody
{
use HasJsonBody;
protected Method $method = Method::POST;
public function __construct(
protected string $payrrn,
protected string $TermNo,
) {}
public function resolveEndpoint(): string
{
return '';
}
protected function defaultBody(): array
{
return [
'command' => 'GetQRCreversalHistory',
'TermNo' => $this->TermNo,
'payrrn' => $this->payrrn,
];
}
public function createDtoFromResponse(Response $response): ReversalHistoryResponseData
{
return ReversalHistoryResponseData::from($response->json());
}
}

View File

@@ -38,7 +38,23 @@ class SbpConnector extends Connector implements HasPagination
return $this->alfaConfig->baseUrl;
}
protected function defaultConfig(): array
/**
* Parse payment notification from request body.
*/
public function parsePaymentNotification(array $data): \Apoloz\AlfaBank\Sbp\Data\Webhooks\SbpPaymentNotificationData
{
return \Apoloz\AlfaBank\Sbp\Data\Webhooks\SbpPaymentNotificationData::from($data);
}
/**
* Parse refund notification from request body.
*/
public function parseRefundNotification(array $data): \Apoloz\AlfaBank\Sbp\Data\Webhooks\SbpRefundNotificationData
{
return \Apoloz\AlfaBank\Sbp\Data\Webhooks\SbpRefundNotificationData::from($data);
}
protected function defaultHeaders(): array
{
$config = [
'cert' => $this->alfaConfig->certificatePath,
@@ -92,6 +108,7 @@ class SbpConnector extends Connector implements HasPagination
// Mutate the request to add SBP-specific headers and set the exact signed body.
$pendingRequest->headers()->add('Authorization', $signatureBase64);
$pendingRequest->headers()->add('key-name', $this->keyNameAlias);
// The SBP API documentation specifies this content-type for signed requests.
$pendingRequest->headers()->add('Content-Type', 'application/x-www-form-urlencoded');