diff --git a/README.md b/README.md index 6ebcee9..1fd541d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,96 @@ This is an unofficial PHP SDK for the [Alfa Bank API](https://developers.alfaban composer require apoloz/alfa-bank-sdk ``` +## Architecture & Data Flow + +The SDK abstracts complex banking protocols into a clean, object-oriented interface. + +### 1. Unified Request Architecture +Every request follows a strict "Input DTO -> Request -> Connector -> Output DTO" pipeline. + +```mermaid +graph LR + User[Client App] --> DTO_IN[Request DTO] + DTO_IN --> REQ[Request Class] + REQ --> CON[Connector] + CON -->|mTLS + Signature| API((Alfa Bank)) + API -->|Raw JSON| CON + CON --> REQ + REQ -->|Data Mapping| DTO_OUT[Response DTO/Collection] + DTO_OUT --> User + + style CON fill:#f96,stroke:#333,stroke-width:2px + style DTO_IN fill:#bbf,stroke:#333 + style DTO_OUT fill:#9f6,stroke:#333 +``` + +### 2. Authentication & Security Flow (BaaS) +The BaaS API requires a two-step handshake: mTLS for the tunnel and OAuth 2.0 for authorization. + +```mermaid +sequenceDiagram + participant App as Client Application + participant SDK as AlfaBank SDK + participant API as Alfa Bank Gateway + + Note over App, API: 1. Connection (mTLS) + SDK->>API: Client Hello (X.509 Certificate) + API-->>SDK: Server Hello & Encrypted Tunnel established + + Note over App, API: 2. Authorization (OAuth 2.0) + App->>SDK: login(id, secret) + SDK->>API: POST /token (Client Credentials) + API-->>SDK: Access Token (JWT) + SDK->>SDK: Store Token in Authenticator + + Note over App, API: 3. Business Requests + App->>SDK: send(CreatePaymentRequest) + SDK->>API: POST /payments (Header: Bearer Token) + API-->>SDK: 201 Created +``` + +### 3. SBP Security (JCS & Body Signing) +Unlike BaaS, SBP requires signing the actual JSON body using a recursive key-sorting algorithm (JCS). + +```mermaid +flowchart TD + A["Raw Data (Array)"] -->|Recursive ksort| B("Canonical Array") + B -->|JSON_UNESCAPED_UNICODE| C("JCS JSON String") + C -->|RSA-SHA256 Sign| D("Binary Signature") + D -->|Base64 Encode| E["'Authorization' Header"] + E -->|HTTPS + mTLS| F((Alfa Bank SBP Gateway)) + + subgraph SDK Automatics + B + C + D + end +``` + +### 4. Webhook Pipeline (Laravel Integration) +The SDK acts as a security firewall for incoming bank notifications. + +```mermaid +sequenceDiagram + participant Bank as Alfa Bank + participant Mid as VerifyAlfaWebhook (Middleware) + participant Fact as WebhookFactory + participant Event as AlfaWebhookReceived (Event) + participant Listener as Your Listener + + Bank->>Mid: POST /webhooks/alfa + Mid->>Mid: 1. Validate Credentials + alt Authorized + Mid->>Fact: 2. Parse Raw JSON + Fact->>Fact: 3. Detect Type (BaaS/SBP) + Fact->>Event: 4. Dispatch(Typed DTO) + Event->>Listener: 5. Handle Business Logic + Mid-->>Bank: 200 OK + else Unauthorized + Mid-->>Bank: 401 Unauthorized + end +``` + ## Security & Setup (Crucial) Alfa Bank uses a comprehensive security model requiring **two distinct sets of keys**: @@ -23,13 +113,13 @@ Alfa Bank uses a comprehensive security model requiring **two distinct sets of k 2. **Signing (JWS/CMS):** To digitally sign the request body. ### Where to get keys? -- **Sandbox:** Provided by the bank support or downloaded from the Developer Portal (usually as `.pfx` or `.zip`). +- **Sandbox:** Provided by the bank support or downloaded from the Developer Portal. - **Production:** - - **Transport (mTLS):** Generate a private key and CSR using OpenSSL (`openssl genrsa`, `openssl req`). Send the CSR to `alfa_api@alfabank.ru`. The bank will reply with your Certificate. - - **Signing:** Generated by you (the client). The public certificate must be registered in "Alfa Business Online" or sent to your onboarding manager. + - **Transport (mTLS):** Generate a private key and CSR using OpenSSL. Send the CSR to `alfa_api@alfabank.ru`. + - **Signing:** Generated by you (the client). Register the public certificate in "Alfa Business Online". ### 1. Converting PFX to PEM -If the bank provided a `.pfx` file for signing, you must convert it to PEM format: +If the bank provided a `.pfx` file for signing, convert it to PEM: ```bash # Extract Private Key openssl pkcs12 -in signing.pfx -nocerts -out signing_key.pem -nodes @@ -39,39 +129,21 @@ openssl pkcs12 -in signing.pfx -clcerts -nokeys -out signing_cert.pem ``` ### 2. Initialization -You can now initialize the SDK with both key pairs: - ```php use Apoloz\AlfaBank\AlfaBank; use Apoloz\AlfaBank\Enums\Environment; $baas = AlfaBank::baas( - // Transport Keys (mTLS) certificatePath: '/path/to/transport_cert.pem', privateKeyPath: '/path/to/transport_key.pem', - - // Signing Keys (Business Logic) - Optional, defaults to transport keys if not set signingCertificatePath: '/path/to/signing_cert.pem', signingPrivateKeyPath: '/path/to/signing_key.pem', - - environment: Environment::SANDBOX, - caBundlePath: '/path/to/root_ca_bundle.crt' + environment: Environment::SANDBOX ); ``` ## Core Usage -### Authentication (BaaS) - -Use the `login` method to perform OAuth Client Credentials flow: - -```php -$baas->login( - clientId: 'your-client-id', - clientSecret: 'your-client-secret' -); -``` - ### Making Requests #### SBP: Generate QR Code @@ -79,13 +151,6 @@ $baas->login( use Apoloz\AlfaBank\Sbp\Requests\GetQrCodeRequest; use Apoloz\AlfaBank\Sbp\Data\QrCodeRequestData; -$sbp = AlfaBank::sbp( - certificatePath: '...', - privateKeyPath: '...', - keyNameAlias: '9728109955', // Terminal ID - signingPrivateKeyPath: '/path/to/signing_key.pem' // Specific signing key for SBP body -); - $response = $sbp->send(new GetQrCodeRequest( new QrCodeRequestData(TermNo: '...', amount: '100') )); @@ -93,464 +158,37 @@ $response = $sbp->send(new GetQrCodeRequest( $link = $response->dto()->payload; ``` -#### Nominal Accounts -```php -$response = $baas->send(new CreateBeneficiaryRequest($payload)); -``` - -### Self-Employed (Payouts & Status) - -The SDK supports managing self-employed beneficiaries, checking their FTS status, and processing payouts. - -#### Check Taxpayer Status -```php -use Apoloz\AlfaBank\Requests\SelfEmployed\GetTaxpayerStatusRequest; -use Apoloz\AlfaBank\Data\SelfEmployed\TaxpayerStatusRequestData; - -$payload = new TaxpayerStatusRequestData( - externalId: 'unique-id', - selfemployedVatinList: ['123456789012'] -); - -$response = $baas->send(new GetTaxpayerStatusRequest($payload)); -$status = $response->dto()->taxpayerStatusList[0]; -``` - -#### Process Payout -```php -use Apoloz\AlfaBank\Requests\SelfEmployed\CreatePayoutRequest; -use Apoloz\AlfaBank\Data\SelfEmployed\PayoutRequestData; - -$payload = new PayoutRequestData( - externalId: 'ext-id-456', - company: ['vatin' => '7700000000'], - requisite: [ - ['amount' => 1000, 'vatin' => '112233445566'] - ] -); -$response = $baas->send(new CreatePayoutRequest($payload)); -``` - -### File Exchange - -Upload and download files securely. - -```php -use Apoloz\AlfaBank\Requests\FileExchange\UploadFileRequest; -use Apoloz\AlfaBank\Requests\FileExchange\DownloadFileRequest; - -// Upload -$stream = fopen('path/to/file', 'r'); -$baas->send(new UploadFileRequest($uploadUrl, $stream, $fileSize)); - -// Download -$response = $baas->send(new DownloadFileRequest($downloadUrl)); -$content = $response->body(); -``` - -### Payments (RUR Payment Orders) - -Create standard payment orders and check their status. - +#### Standard Payments ```php use Apoloz\AlfaBank\Requests\Payments\CreatePaymentRequest; use Apoloz\AlfaBank\Data\Payments\PaymentData; -$payload = new PaymentData( - amount: 100.50, - date: '2023-10-25', - externalId: 'order-12345', - operationCode: '01', - payeeAccount: '40702...', - payeeBankBic: '044525225', - payeeName: 'Partner LLC', - payerAccount: '40702...', - payerBankBic: '044525593', - payerBankCorrAccount: '30101...', - payerInn: '7700000000', - payerName: 'My Company', - priority: '5', - purpose: 'Payment for services' -); - -// Create Payment -$baas->send(new CreatePaymentRequest($payload)); - -// Check State -use Apoloz\AlfaBank\Requests\Payments\GetPaymentStateRequest; -$state = $baas->send(new GetPaymentStateRequest('order-12345'))->dto(); -echo $state->bankStatus; // e.g. ACCEPTED +$payload = new PaymentData(amount: 100.50, ...); +$response = $baas->send(new CreatePaymentRequest($payload)); +echo $response->dto()->id; ``` -### Cards +### Advanced Features -Retrieve a list of client cards. - -```php -use Apoloz\AlfaBank\Requests\Cards\GetCardsRequest; - -$response = $baas->send(new GetCardsRequest()); -foreach ($response->dto() as $card) { - echo $card->maskedNumber . ' - ' . $card->status; -} -``` - -### B2C Payouts (SBP via Phone) - -Send money to individuals using their phone number through SBP. - -```php -use Apoloz\AlfaBank\Requests\B2C\CreateB2CPaymentRequest; -use Apoloz\AlfaBank\Data\B2C\B2CPaymentRequestData; -use Apoloz\AlfaBank\Data\B2C\B2CClientData; -use Apoloz\AlfaBank\Data\B2C\B2CPaymentRecipientData; - -$payload = new B2CPaymentRequestData( - client: new B2CClientData(b2cClientId: '...', partnerId: 123), - inn: '7700000000', - account: '40702...', - total: 100.53, - signedBody: '...base64_signature...', - payments: B2CPaymentRecipientData::collect([ - [ - 'phone' => '79001112233', - 'amount' => 100.53, - 'nspkBankId' => '100000000001', - 'sequence' => 'unique-seq-1', - 'name' => ['first' => 'Ivan', 'last' => 'Ivanov'] - ] - ], DataCollection::class) -); - -$baas->send(new CreateB2CPaymentRequest($payload)); -``` - -### Payment Registries (Batch Ruble Payments) - -Create a registry for multiple ruble payments and sign it. - -```php -use Apoloz\AlfaBank\Requests\Registries\CreateRegistryRequest; -use Apoloz\AlfaBank\Data\Registries\RegistryRequestData; -use Apoloz\AlfaBank\Data\Registries\RegistryPaymentData; - -$payload = new RegistryRequestData( - payments: RegistryPaymentData::collect([ - [ - 'amount' => 1000.0, - 'date' => '2023-11-01', - 'payeeAccount' => '40702...', - 'payeeBankBic' => '044525225', - 'payeeInn' => '7700000000', - 'payeeName' => 'Vendor LLC', - 'payerAccount' => '40702...', - 'purpose' => 'Batch payment part 1' - ] - ], DataCollection::class), - description: 'Weekly payroll' -); - -$response = $baas->send(new CreateRegistryRequest($payload)); -$registryId = $response->json('id'); - -// Sign Registry -use Apoloz\AlfaBank\Requests\Registries\SignRegistryRequest; -use Apoloz\AlfaBank\Data\Registries\RegistrySignatureData; -use Spatie\LaravelData\DataCollection; - -$signatures = RegistrySignatureData::collect([ - [ - 'base64Encoded' => '...', // Base64 signature - 'signatureType' => 'RSA', - 'certificateUuid' => '...' - ] -], DataCollection::class); - -$baas->send(new SignRegistryRequest($registryId, $signatures)); -``` - -### Customer Info - -Get organization profile. - -```php -use Apoloz\AlfaBank\Requests\CustomerInfo\GetCustomerInfoRequest; - -$info = $baas->send(new GetCustomerInfoRequest())->dto(); -echo $info->fullName; -``` - -### Limits - -Get client's operation limits. - -```php -use Apoloz\AlfaBank\Requests\Limits\GetGeneralLimitsRequest; - -$response = $baas->send(new GetGeneralLimitsRequest()); -$limits = $response->json(); -``` - -### Debit Cards - -Apply for a new debit card. - -```php -use Apoloz\AlfaBank\Requests\DebitCards\CreateDebitCardApplicationRequest; -use Apoloz\AlfaBank\Data\DebitCards\DebitCardApplicationData; - -$payload = new DebitCardApplicationData( - embossingName: 'IVAN IVANOV', - firstName: 'Иван', - lastName: 'Иванов', - middleName: 'Иванович', - mobilePhone: '79001112233', - passportBirthDate: '1990-01-01', - sex: 'm', - sopdConfirmed: true -); - -$baas->send(new CreateDebitCardApplicationRequest($payload)); -``` - -### User Compliance - -Check user compliance for loans. - -```php -use Apoloz\AlfaBank\Requests\Compliance\GetUserComplianceRequest; - -$baas->send(new GetUserComplianceRequest()); -``` - -### DFA (Digital Financial Assets) - -List initial placements. - -```php -use Apoloz\AlfaBank\Requests\DFA\GetDfaInitialPlacementListRequest; - -$baas->send(new GetDfaInitialPlacementListRequest(limit: 5)); -``` - -### Currency Rates - -Get currency rates by office. - -```php -use Apoloz\AlfaBank\Requests\Rates\GetOfficeRatesRequest; - -$baas->send(new GetOfficeRatesRequest()); -``` - -### Escrow Accounts - -Manage construction projects and escrow orders. - -```php -use Apoloz\AlfaBank\Requests\Escrow\GetBuildProjectsRequest; - -$projects = $baas->send(new GetBuildProjectsRequest())->json(); -``` - -### Power of Attorney (Poa) - -Request and check status of digital POAs. - -```php -use Apoloz\AlfaBank\Requests\Poa\CreatePoaRequest; - -$response = $baas->send(new CreatePoaRequest('poa-123', '7700000000')); -$uuid = $response->json('requestUuid'); -``` - -### Loyalty - -Access bonus accounts and transaction history. - -```php -use Apoloz\AlfaBank\Requests\Loyalty\GetLoyaltyAccountsRequest; - -$accounts = $baas->send(new GetLoyaltyAccountsRequest())->json(); -``` - -### Reports - -Order certificates and reports. - -```php -use Apoloz\AlfaBank\Requests\Reports\CreateAccountBalanceReportRequest; - -$baas->send(new CreateAccountBalanceReportRequest(['40702...'], 'eng')); -``` - -### Crypto Service - -Manage encryption and signing certificates. - -```php -use Apoloz\AlfaBank\Requests\Crypto\GetCertificatesRequest; - -$certificates = $baas->send(new GetCertificatesRequest())->json(); -``` - -### Partner (MMB) - -Access partner program data and applications. - -```php -use Apoloz\AlfaBank\Requests\Partner\GetPartnerApplicationsRequest; - -$apps = $baas->send(new GetPartnerApplicationsRequest())->json(); -``` - -### Miscellaneous - -- **Bonus Numbers**: `$baas->send(new GetBonusNumbersRequest());` -- **Credit Offers**: `$baas->send(new GetCreditOffersRequest());` -- **Self-encashment**: `$baas->send(new CreateSelfEncashmentRegistryRequest($data));` -- **Business Lounge**: `$baas->send(new GetLoungeBalanceRequest());` -- **Scoring**: `$baas->send(new GetComplianceScoreRequest('7700000000'));` -- **Security**: `$baas->send(new GetSubscriptionsRequest());` -- **Logistics**: `$baas->send(new GetDeliveriesRequest('...'));` -- **Webhooks**: `$baas->send(new GetWebhooksRequest());` -- **1C Integration**: `$baas->send(new CheckMerchantRequest('MF123', 'Basic ...'));` -- **Claims**: `$baas->send(new ResolveClaimRequest('ID', $data));` -- **Orders (Travel/Afisha)**: `$baas->send(new CreateTravelOrderRequest($data));` -- **Salary Project**: `$baas->send(new CreatePayrollApplicationRequest($payload));` -- **Cloud Signature**: `$baas->send(new SubmitSigningRequest('REQ-ID'));` -- **Aff Integration**: `$baas->send(new CheckComplianceRequest('INN', 'Name'));` -- **Digital Ruble**: `$sbp->send(new CreateUniQrRequest($uniQrData));` - -### Signing Data (CMS) - -For requests requiring a digital signature (e.g., Payments), you must canonicalize and sign the data manually. - -```php -use Apoloz\AlfaBank\Utils\CmsSigner; -use Apoloz\AlfaBank\Data\NominalAccounts\Payouts\DigestSignatureData; -use Spatie\LaravelData\DataCollection; - -// 1. Prepare data -$fields = [ - 'amount' => 100.50, - // ... other fields -]; - -// 2. Canonicalize & Sign -$json = CmsSigner::canonicalize($fields); -$cert = file_get_contents('/path/to/signing_cert.pem'); -$key = file_get_contents('/path/to/signing_key.pem'); -$signature = CmsSigner::sign($json, $cert, $key); - -// 3. Attach to DTO -$paymentData->digestSignatures = DigestSignatureData::collect([ - [ - 'base64Encoded' => $signature, - 'signatureType' => 'CMS' - ] -], DataCollection::class); -``` - -## Advanced Features - -### Pagination -The SDK supports automatic pagination for list endpoints: +#### Pagination +The SDK supports automatic pagination for all list endpoints: ```php use Apoloz\AlfaBank\Requests\Accounts\GetAccountsRequest; $paginator = $baas->paginate(new GetAccountsRequest()); -foreach ($paginator as $account) { - // Each item is processed automatically +foreach ($paginator->items() as $account) { + echo $account->number; // Typed AccountData object } ``` -## Laravel Integration - -The package supports auto-discovery. Publish the config file: - -```bash -php artisan vendor:publish --tag=alfa-bank-config -``` - -Configure your `.env`: -```env -ALFA_BAAS_CERT_PATH=/path/to/transport_cert.pem -# ... other settings -ALFA_WEBHOOK_USER=your_webhook_user -ALFA_WEBHOOK_PASSWORD=your_webhook_password -``` - -### Webhook Handling (Laravel) - -The SDK provides a built-in controller and route macro to handle incoming webhooks and dispatch Laravel events. - -1. **Register the route** in `routes/api.php`: -```php -Route::alfaWebhooks('/webhooks/alfa'); -``` - -2. **Listen for the event** in `app/Providers/EventServiceProvider.php`: -```php -use Apoloz\AlfaBank\Laravel\Events\AlfaWebhookReceived; - -protected $listen = [ - AlfaWebhookReceived::class => [ - \App\Listeners\HandleAlfaWebhook::class, - ], -]; -``` - -3. **Handle the DTO** in your listener: -```php -public function handle(AlfaWebhookReceived $event) -{ - $webhook = $event->webhook; // BaseWebhookData DTO - - if ($webhook->object === 'ul_transaction_default') { - $transaction = $webhook->data; - // ... - } -} -``` - -## Error Handling - -The SDK throws `Apoloz\AlfaBank\Exceptions\AlfaBankException` when the API returns an error. - -```php -try { - $baas->send($request); -} catch (\Apoloz\AlfaBank\Exceptions\AlfaBankException $e) { - echo $e->getErrorCode(); // e.g. "invalid_token" or "RQ05014" - echo $e->getMessage(); -} -``` - -## Utilities - -- **Card PAN Encryption**: `CardEncryptor::encrypt($pan, $pubKey)` -- **CMS Signing**: `CmsSigner::sign($data, $cert, $key)` -- **JWS/JWE**: `JwsSigner` and `JweEncryptor` for transport security. - -## Diagnostics - -### Health Check (Laravel) -Run the built-in Artisan command to verify your mTLS certificates and connection: - +#### Diagnostics +Run the built-in Artisan command to verify your configuration: ```bash php artisan alfa:check ``` -### Logging -Attach a PSR-3 logger to debug requests (sensitive data redacted): - -```php -$baas->withLogging(Log::getLogger()); -``` - ## License MIT diff --git a/src/AlfaBank.php b/src/AlfaBank.php index df7bb5e..48166d6 100644 --- a/src/AlfaBank.php +++ b/src/AlfaBank.php @@ -1,11 +1,6 @@ +use Apoloz\AlfaBank\Requests\Accounts\GetAccountsRequest; -//
-// class AlfaBank { - //
- // + /** + * Performs a health check on the connection. + * Throws an exception if authentication or account listing fails. + */ + public static function check( + string $certificatePath, + string $privateKeyPath, + string $clientId, + string $clientSecret, + Environment $environment = Environment::SANDBOX, + ?string $caBundlePath = null + ): bool { + $baas = self::baas($certificatePath, $privateKeyPath, $environment, $caBundlePath); + $baas->login($clientId, $clientSecret); + $baas->send(new GetAccountsRequest()); + return true; + } + public static function baas( string $certificatePath, string $privateKeyPath, diff --git a/src/Contracts/EncryptorInterface.php b/src/Contracts/EncryptorInterface.php index 1d9f54d..d3a1661 100644 --- a/src/Contracts/EncryptorInterface.php +++ b/src/Contracts/EncryptorInterface.php @@ -1,5 +1,7 @@ // use Apoloz\AlfaBank\Contracts\SignerInterface; -use Apoloz\AlfaBank\Http\Middleware\AlfaResponseMiddleware; use Apoloz\AlfaBank\Http\Middleware\RequestIdMiddleware; use Apoloz\AlfaBank\Http\Pagination\AlfaOffsetPaginator; use Apoloz\AlfaBank\Requests\Auth\GetTokenRequest; +use Apoloz\AlfaBank\Traits\HasAlfaErrorHandling; use Saloon\Http\Connector; use Saloon\Http\Request; use Saloon\PaginationPlugin\Contracts\HasPagination; use Saloon\PaginationPlugin\OffsetPaginator; +use Saloon\Traits\Plugins\AlwaysThrowOnErrors; use Saloon\Traits\RequestProperties\HasTries; use Saloon\Traits\Plugins\HasTimeout; @@ -31,6 +23,8 @@ class AlfaBankConnector extends Connector implements HasPagination { use HasTimeout; use HasTries; + use AlwaysThrowOnErrors; + use HasAlfaErrorHandling; public function __construct( protected AlfaConfig $alfaConfig, @@ -62,13 +56,14 @@ class AlfaBankConnector extends Connector implements HasPagination protected function defaultMiddleware(): array { - return []; + return [ + new RequestIdMiddleware(), + ]; } public function boot(\Saloon\Http\PendingRequest $pendingRequest): void { - $pendingRequest->middleware()->onResponse(new AlfaResponseMiddleware()); - $pendingRequest->middleware()->onRequest(new RequestIdMiddleware()); + // } public function paginate(Request $request): OffsetPaginator diff --git a/src/Core/AlfaConfig.php b/src/Core/AlfaConfig.php index 460264a..239e32d 100644 --- a/src/Core/AlfaConfig.php +++ b/src/Core/AlfaConfig.php @@ -1,4 +1,6 @@ use Apoloz\AlfaBank\Data\NominalAccounts\Payouts\DigestSignatureData; use Spatie\LaravelData\Attributes\DataCollectionOf; +use Spatie\LaravelData\Attributes\Validation\Email; +use Spatie\LaravelData\Attributes\Validation\Numeric; use Spatie\LaravelData\Data; use Spatie\LaravelData\DataCollection; //
@@ -36,11 +40,15 @@ class CreateBeneficiaryRequestData extends Data public ?bool $financialActivities = null, public ?string $name = null, public ?string $registrationDate = null, + #[Numeric] public ?string $inn = null, + #[Numeric] public ?string $kpp = null, + #[Numeric] public ?string $ogrn = null, public ?PersonalInformationData $personalInformation = null, public ?string $phoneNumber = null, + #[Email] public ?string $email = null, #[DataCollectionOf(DocumentData::class)] public ?DataCollection $documents = null, diff --git a/src/Data/NominalAccounts/Beneficiaries/CreateBeneficiaryResponseData.php b/src/Data/NominalAccounts/Beneficiaries/CreateBeneficiaryResponseData.php index c08ca2d..60caa2e 100644 --- a/src/Data/NominalAccounts/Beneficiaries/CreateBeneficiaryResponseData.php +++ b/src/Data/NominalAccounts/Beneficiaries/CreateBeneficiaryResponseData.php @@ -1,4 +1,6 @@ // +use Spatie\LaravelData\Attributes\Validation\Numeric; +use Spatie\LaravelData\Attributes\Validation\Size; use Spatie\LaravelData\Data; //
@@ -23,7 +27,9 @@ class PayerInfoData extends Data public function __construct( public string $accountNumber, public string $name, + #[Numeric] public string $inn, + #[Numeric, Size(9)] public string $kpp, ) {} // diff --git a/src/Data/Shared/RecipientInfoData.php b/src/Data/Shared/RecipientInfoData.php index 88ab288..fd30634 100644 --- a/src/Data/Shared/RecipientInfoData.php +++ b/src/Data/Shared/RecipientInfoData.php @@ -1,4 +1,6 @@ // +use Spatie\LaravelData\Attributes\Validation\Numeric; +use Spatie\LaravelData\Attributes\Validation\Size; use Spatie\LaravelData\Data; + // //
@@ -23,8 +28,10 @@ class RecipientInfoData extends Data public function __construct( public ?string $beneficiaryId = null, public ?string $accountNumber = null, + #[Numeric, Size(9)] public ?string $bik = null, public ?string $name = null, + #[Numeric] public ?string $inn = null, ) {} //
diff --git a/src/Data/Shared/ResultResponseData.php b/src/Data/Shared/ResultResponseData.php new file mode 100644 index 0000000..a56bbea --- /dev/null +++ b/src/Data/Shared/ResultResponseData.php @@ -0,0 +1,14 @@ + -// use Psr\Log\LoggerInterface; +use Saloon\Contracts\RequestMiddleware; +use Saloon\Contracts\ResponseMiddleware; use Saloon\Http\PendingRequest; use Saloon\Http\Response; -// -//
-// -class LoggingMiddleware +class LoggingMiddleware implements RequestMiddleware, ResponseMiddleware { //
// diff --git a/src/Http/Middleware/RequestIdMiddleware.php b/src/Http/Middleware/RequestIdMiddleware.php index 26d5729..ce5401d 100644 --- a/src/Http/Middleware/RequestIdMiddleware.php +++ b/src/Http/Middleware/RequestIdMiddleware.php @@ -1,26 +1,17 @@ // use Apoloz\AlfaBank\Utils\Uuid; +use Saloon\Contracts\RequestMiddleware; use Saloon\Http\PendingRequest; -//
-//
-// -class RequestIdMiddleware +class RequestIdMiddleware implements RequestMiddleware { - //
- // public function __invoke(PendingRequest $pendingRequest): void { if (!$pendingRequest->headers()->get('X-Request-ID')) { diff --git a/src/Http/Pagination/AlfaOffsetPaginator.php b/src/Http/Pagination/AlfaOffsetPaginator.php index 9edfdd4..0a62621 100644 --- a/src/Http/Pagination/AlfaOffsetPaginator.php +++ b/src/Http/Pagination/AlfaOffsetPaginator.php @@ -1,5 +1,7 @@ -// +use Apoloz\AlfaBank\Data\Shared\ResultResponseData; use Saloon\Contracts\Body\HasBody; use Saloon\Enums\Method; use Saloon\Http\Request; use Saloon\Http\Response; use Saloon\Traits\Body\HasJsonBody; -//
-//
-// class PayoffAcceptRequest extends Request implements HasBody { use HasJsonBody; - //
- // protected Method $method = Method::PATCH; - //
- //
- // public function __construct( protected string $payoffId, protected bool $accept = true ) {} - //
- //
- // public function resolveEndpoint(): string { return "/api/na/jp/v1/payoffs/{$this->payoffId}/accept"; } - //
- //
- // protected function defaultBody(): array { return [ 'accept' => $this->accept, ]; } - //
- //
- // - public function createDtoFromResponse(Response $response): bool + public function createDtoFromResponse(Response $response): ResultResponseData { - return (bool)($response->json('result') ?? false); + return ResultResponseData::from($response->json()); } - //
-} +} \ No newline at end of file diff --git a/src/Requests/NominalAccounts/Payouts/TaxRefundAcceptRequest.php b/src/Requests/NominalAccounts/Payouts/TaxRefundAcceptRequest.php index ff437de..2f49203 100644 --- a/src/Requests/NominalAccounts/Payouts/TaxRefundAcceptRequest.php +++ b/src/Requests/NominalAccounts/Payouts/TaxRefundAcceptRequest.php @@ -1,61 +1,41 @@ -// +use Apoloz\AlfaBank\Data\Shared\ResultResponseData; use Saloon\Contracts\Body\HasBody; use Saloon\Enums\Method; use Saloon\Http\Request; use Saloon\Http\Response; use Saloon\Traits\Body\HasJsonBody; -//
-//
-// class TaxRefundAcceptRequest extends Request implements HasBody { use HasJsonBody; - //
- // protected Method $method = Method::PATCH; - //
- //
- // public function __construct( protected string $taxRefundId, - protected bool $accept + protected bool $accept = true ) {} - //
- //
- // public function resolveEndpoint(): string { return "/api/na/jp/v1/tax-refund/{$this->taxRefundId}/accept"; } - //
- //
- // - protected function defaultQuery(): array + protected function defaultBody(): array { - return ['accept' => $this->accept ? 'true' : 'false']; + return [ + 'accept' => $this->accept, + ]; } - //
- //
- // - public function createDtoFromResponse(Response $response): bool + public function createDtoFromResponse(Response $response): ResultResponseData { - return (bool)($response->json('result') ?? false); + return ResultResponseData::from($response->json()); } - //
-} +} \ No newline at end of file diff --git a/src/Requests/NominalAccounts/UniversalRecipients/CreateUniversalRecipientRequest.php b/src/Requests/NominalAccounts/UniversalRecipients/CreateUniversalRecipientRequest.php index 35c564c..fc2dcb9 100644 --- a/src/Requests/NominalAccounts/UniversalRecipients/CreateUniversalRecipientRequest.php +++ b/src/Requests/NominalAccounts/UniversalRecipients/CreateUniversalRecipientRequest.php @@ -1,4 +1,6 @@ -// use Apoloz\AlfaBank\Sbp\Data\DigitalRuble\CreateUniQrRequestData; +use Apoloz\AlfaBank\Sbp\Data\DigitalRuble\UniQrResponseData; use Saloon\Contracts\Body\HasBody; use Saloon\Enums\Method; use Saloon\Http\Request; +use Saloon\Http\Response; use Saloon\Traits\Body\HasJsonBody; -// -// use Saloon\Http\Response; // Explicit import would be cleaner -//
- -//
-// class CreateUniQrRequest extends Request implements HasBody { use HasJsonBody; - //
- // protected Method $method = Method::POST; - //
- //
- // public function __construct( protected CreateUniQrRequestData $data ) {} - //
- // public function resolveEndpoint(): string { - return ''; // Endpoint is determined by SbpConnector's base URL + return ''; } - //
- //
- // protected function defaultBody(): array { $body = [ @@ -55,7 +35,6 @@ class CreateUniQrRequest extends Request implements HasBody 'uniQrType' => $this->data->uniQrType, ]; - // Conditional mappings if ($this->data->amount !== null) $body['amount'] = $this->data->amount; if ($this->data->paymentPurpose !== null) $body['paymentPurpose'] = $this->data->paymentPurpose; if ($this->data->redirectUrl !== null) $body['redirectUrl'] = $this->data->redirectUrl; @@ -78,13 +57,9 @@ class CreateUniQrRequest extends Request implements HasBody return $body; } - //
- //
- // - public function createDtoFromResponse(\Saloon\Http\Response $response): array + public function createDtoFromResponse(Response $response): UniQrResponseData { - return $response->json(); + return UniQrResponseData::from($response->json()); } - //
-} +} \ No newline at end of file diff --git a/src/Sbp/Requests/DigitalRuble/GetUniQrStatusRequest.php b/src/Sbp/Requests/DigitalRuble/GetUniQrStatusRequest.php index 2145aa0..469242e 100644 --- a/src/Sbp/Requests/DigitalRuble/GetUniQrStatusRequest.php +++ b/src/Sbp/Requests/DigitalRuble/GetUniQrStatusRequest.php @@ -1,4 +1,6 @@ middleware()->onResponse(new AlfaResponseMiddleware()); - $pendingRequest->middleware()->onRequest(new RequestIdMiddleware()); - $bodyRepo = $pendingRequest->body(); if (!$bodyRepo || empty($bodyRepo->all())) { return; diff --git a/src/Services/OpenSslCmsSigner.php b/src/Services/OpenSslCmsSigner.php index b834729..ae7d992 100644 --- a/src/Services/OpenSslCmsSigner.php +++ b/src/Services/OpenSslCmsSigner.php @@ -1,4 +1,6 @@ status() >= 400) { + return true; + } + + $contentType = $response->header('Content-Type') ?? ''; + if (str_contains($contentType, 'application/json')) { + $data = $response->json(); + if (isset($data['error']) || (isset($data['ErrorCode']) && (string)$data['ErrorCode'] !== '0')) { + return true; + } + } + + return false; + } + + /** + * Get the custom exception for the failed request. + */ + public function getRequestException(Response $response, ?Throwable $senderException): ?Throwable + { + $requestId = $response->header('X-Request-ID') ?? 'Unknown'; + $errorCode = 'UNKNOWN'; + $errorDescription = 'No description'; + + $contentType = $response->header('Content-Type') ?? ''; + if (str_contains($contentType, 'application/json')) { + $data = $response->json(); + $errorCode = $data['error'] ?? $data['ErrorCode'] ?? 'HTTP_' . $response->status(); + $errorDescription = $data['error_description'] ?? $data['message'] ?? 'Request failed'; + } else { + $errorCode = 'HTTP_' . $response->status(); + $errorDescription = 'Request failed with status ' . $response->status(); + } + + return new AlfaBankException( + $response, + (string)$errorCode, + "({$requestId}) {$errorDescription}" + ); + } +} diff --git a/src/Traits/HasAlfaPagination.php b/src/Traits/HasAlfaPagination.php index 5dff825..dce8a72 100644 --- a/src/Traits/HasAlfaPagination.php +++ b/src/Traits/HasAlfaPagination.php @@ -1,4 +1,6 @@ [ [ 'number' => '40702810...', - 'dateCreated' => '2023-01-01', + 'openDate' => '2023-01-01', 'type' => 'Checking', 'balance' => ['amount' => 1000] ] diff --git a/tests/Feature/ErrorHandlingTest.php b/tests/Feature/ErrorHandlingTest.php index 7e2f141..763eac8 100644 --- a/tests/Feature/ErrorHandlingTest.php +++ b/tests/Feature/ErrorHandlingTest.php @@ -19,7 +19,7 @@ test('it throws AlfaBankException on BaaS error', function () { $connector->withMockClient($mockClient); $connector->login('id', 'secret'); -})->throws(AlfaBankException::class, 'Alfa Bank API Error: [invalid_client] Bad client secret'); +})->throws(AlfaBankException::class, 'Alfa Bank API Error: [invalid_client] (Unknown) Bad client secret'); test('it throws AlfaBankException on SBP error', function () { $mockClient = new \Saloon\Http\Faking\MockClient([ @@ -37,4 +37,4 @@ test('it throws AlfaBankException on SBP error', function () { protected \Saloon\Enums\Method $method = \Saloon\Enums\Method::GET; public function resolveEndpoint(): string { return '/'; } }); -})->throws(AlfaBankException::class, 'Alfa Bank API Error: [RQ05014] Invalid amount'); +})->throws(AlfaBankException::class, 'Alfa Bank API Error: [RQ05014] (Unknown) Invalid amount'); diff --git a/tests/Feature/Webhooks/WebhookTest.php b/tests/Feature/Webhooks/WebhookTest.php index 1abd84e..f1937ca 100644 --- a/tests/Feature/Webhooks/WebhookTest.php +++ b/tests/Feature/Webhooks/WebhookTest.php @@ -1,5 +1,7 @@ 'ul_transaction_default', 'actionType' => 'create', 'eventTime' => '2022-01-12T00:00:00', - 'data' => [] + 'data' => [ + 'transactionId' => 'TX123', + 'amount' => ['amount' => 100, 'currencyName' => 'RUR'], + 'direction' => 'DEBIT', + 'documentDate' => '2022-01-12', + 'operationCode' => '01', + 'paymentPurpose' => 'Test', + 'uuid' => 'uuid-123' + ] ] ]; @@ -18,19 +28,29 @@ test('can detect baas webhook', function () { $webhook = WebhookFactory::create($payload); expect($webhook)->toBeInstanceOf(BaasWebhookData::class) - ->and($webhook->object)->toBe('ul_transaction_default'); + ->and($webhook->object)->toBe('ul_transaction_default') + ->and($webhook->data->transactionId)->toBe('TX123'); }); test('can detect sbp webhook', function () { $payload = [ - 'command' => 'qrc/notification', - 'status' => 'ACWP', - 'qrcId' => 'test-id' + [ + 'object' => 'jp_sbp_incoming_payments', + 'actionType' => 'update', + 'data' => [ + 'qrcId' => 'QR123', + 'nspkTransactionId' => 'NSPK123', + 'amount' => 1000, + 'currency' => 'RUR', + 'timestamp' => '2024-11-26T09:09:32Z' + ] + ] ]; expect(WebhookFactory::isSbp($payload))->toBeTrue(); $webhook = WebhookFactory::create($payload); expect($webhook)->toBeInstanceOf(SbpWebhookData::class) - ->and($webhook->command)->toBe('qrc/notification'); -}); \ No newline at end of file + ->and($webhook->object)->toBe('jp_sbp_incoming_payments') + ->and($webhook->data->qrcId)->toBe('QR123'); +});