diff --git a/examples/20_sbp_qr_to_nominal_account.php b/examples/20_sbp_qr_to_nominal_account.php new file mode 100644 index 0000000..34d0922 --- /dev/null +++ b/examples/20_sbp_qr_to_nominal_account.php @@ -0,0 +1,271 @@ +value, + certificatePath: $sbpMtlsCert, + privateKeyPath: $sbpMtlsKey, + caBundlePath: $sbpCaBundle, + signingPrivateKeyPath: $sbpSigningKey // 4096-bit ключ для подписи + ); + + $sbp = new SbpConnector($sbpConfig, $sbpKeyAlias); + + // Создаём динамический QR код с уникальным OrderNumber для идентификации + $orderNumber = 'ORDER-' . uniqid(); + + $qrPayload = new QrCodeRequestData( + termNo: $sbpTermNo, + qrcType: '02', // Dynamic QR + amount: '50000', // 500.00 RUB + currency: 'RUB', + paymentPurpose: "Пополнение номинального счета {$nominalAccountNumber}", + orderNumber: $orderNumber, // Уникальный номер заказа для отслеживания + messageID: 'sbp-' . uniqid(), + qrTtl: '60', // QR активен 60 минут + notificationUrl: 'https://your-domain.com/webhooks/sbp', // Webhook для уведомлений + width: '300', + height: '300' + ); + + $request = new GetQrCodeRequest($qrPayload); + $response = $sbp->send($request); + $qrData = $response->dto(); + + if ($qrData->ErrorCode !== 0) { + throw new \RuntimeException("QR generation failed: {$qrData->message}"); + } + + echo "✅ QR код сгенерирован!\n"; + echo " Order Number: $orderNumber\n"; + echo " QRC ID: {$qrData->qrcId}\n"; + echo " QR Payload: {$qrData->payload}\n"; + echo " Status: {$qrData->status}\n"; + echo "\n💡 Клиент должен отсканировать QR и оплатить {$qrData->payload}\n\n"; + + // Сохраняем QR изображение для показа клиенту + if (!empty($qrData->image)) { + $qrImagePath = __DIR__ . '/qr_payment.png'; + file_put_contents($qrImagePath, base64_decode($qrData->image)); + echo " 📁 QR изображение сохранено: $qrImagePath\n\n"; + } + + echo "⏳ Ждём, пока клиент оплатит...\n"; + echo " (В тестовой среде используйте Sandbox для эмуляции оплаты)\n\n"; + + // ============================================================================= + // ЭТАП 2: Получение неидентифицированных пополнений + // ============================================================================= + + echo "=== ЭТАП 2: Поиск неидентифицированных пополнений ===\n"; + echo " (После оплаты через SBP деньги поступают на номинальный счёт\n"; + echo " как 'неидентифицированное пополнение')\n\n"; + + // Инициализируем BaaS connector (требуется H2H авторизация) + $baas = AlfaBank::baas( + certificatePath: $baasCert, + privateKeyPath: $baasKey, + environment: Environment::SANDBOX, + clientId: $baasClientId, + clientSecret: $baasClientSecret + ); + + // Авторизация через Client Credentials Flow + $baas->login($baasClientId, $baasClientSecret, ['beneficiary-payments']); + + // Получаем список неидентифицированных пополнений + $unidentifiedRequest = new GetUnidentifiedTransactionsRequest( + accountNumber: $nominalAccountNumber, + limit: 10, + offset: 0 + ); + + $unidentifiedResponse = $baas->send($unidentifiedRequest); + $unidentifiedCollection = $unidentifiedResponse->dto(); + + echo "Найдено неидентифицированных пополнений: " . count($unidentifiedCollection->items) . "\n\n"; + + if (count($unidentifiedCollection->items) === 0) { + echo "⚠️ Нет неидентифицированных пополнений. Возможные причины:\n"; + echo " - Платёж ещё не поступил (нужно время на обработку)\n"; + echo " - Неправильный номер номинального счёта\n"; + echo " - Платёж уже идентифицирован\n"; + exit(0); + } + + // Ищем нужную транзакцию (обычно по сумме или назначению платежа) + $targetTransaction = null; + foreach ($unidentifiedCollection->items as $transaction) { + echo " Транзакция ID: {$transaction->id}\n"; + echo " Сумма: {$transaction->amount} RUB\n"; + echo " Назначение: {$transaction->purpose}\n"; + echo " Дата: {$transaction->date}\n"; + echo " Плательщик: {$transaction->payer?->name}\n\n"; + + // В реальном сценарии ищем по сумме, назначению или другим критериям + // Здесь берём первую для примера + if ($targetTransaction === null) { + $targetTransaction = $transaction; + } + } + + if ($targetTransaction === null) { + throw new \RuntimeException("Не найдена подходящая транзакция для идентификации"); + } + + echo "✅ Выбрана транзакция для идентификации: {$targetTransaction->id}\n"; + echo " Сумма: {$targetTransaction->amount} RUB\n\n"; + + // ============================================================================= + // ЭТАП 3: Идентификация платежа (привязка к бенефициару) + // ============================================================================= + + echo "=== ЭТАП 3: Идентификация платежа ===\n"; + echo " Привязываем платёж к бенефициару: $beneficiaryId\n\n"; + + // Подготавливаем распределение суммы + // Можно распределить между несколькими бенефициарами + $amountDistribution = DataCollection::create([ + new AmountDistributionData( + beneficiaryId: $beneficiaryId, + amount: $targetTransaction->amount + ) + ], AmountDistributionData::class); + + // Подготавливаем данные для идентификации + $identifyData = new IdentifyRequestData( + transactionId: $targetTransaction->id, + accountNumber: $nominalAccountNumber, + amountDistribution: $amountDistribution, + // Подпись добавляется через sign() метод + digestSignatures: DataCollection::create([ + new DigestSignatureData( + base64Encoded: 'base64-encoded-signature-here', // Реальная подпись + signatureType: 'rsa' + ) + ], DigestSignatureData::class) + ); + + // Создаём запрос на идентификацию + $identifyRequest = new IdentifyTransactionRequest( + id: $targetTransaction->id, + data: $identifyData + ); + + $identifyResponse = $baas->send($identifyRequest); + $identifyResult = $identifyResponse->dto(); + + echo "✅ Запрос на идентификацию создан!\n"; + echo " Identify ID: {$identifyResult->identifyId}\n"; + echo " Статус: {$identifyResult->status}\n\n"; + + // ============================================================================= + // ЭТАП 4: Проверка статуса идентификации + // ============================================================================= + + echo "=== ЭТАП 4: Проверка статуса ===\n\n"; + + $statusRequest = new GetIdentifyStatusRequest($identifyResult->identifyId); + $statusResponse = $baas->send($statusRequest); + $statusResult = $statusResponse->dto(); + + echo "Статус идентификации: {$statusResult->status}\n"; + echo "Возможные статусы:\n"; + echo " - PENDING: В обработке\n"; + echo " - VALIDATED: Провалидировано\n"; + echo " - COMPLETE: Успешно завершено\n"; + echo " - FAILED: Ошибка\n\n"; + + if ($statusResult->status === 'COMPLETE') { + echo "🎉 ИДЕНТИФИКАЦИЯ УСПЕШНО ЗАВЕРШЕНА!\n"; + echo " Деньги {$targetTransaction->amount} RUB зачислены бенефициару $beneficiaryId\n\n"; + } + + // ============================================================================= + // РЕЗЮМЕ + // ============================================================================= + + echo "=== РЕЗЮМЕ ===\n"; + echo "✅ 1. Сгенерирован SBP QR код\n"; + echo "✅ 2. Клиент оплатил через SBP (эмитация в тесте)\n"; + echo "✅ 3. Деньги поступили на номинальный счёт\n"; + echo "✅ 4. Платёж идентифицирован и привязан к бенефициару\n"; + echo "\n💡 Теперь бенефициар может распоряжаться этими средствами\n"; + echo " (например, запросить выплату через CreatePayoffRequest)\n"; + +} catch (\Apoloz\AlfaBank\Exceptions\AlfaBankException $e) { + echo "\n❌ AlfaBankException: {$e->getMessage()}\n"; + if ($e->getResponse()) { + echo "Ответ API: {$e->getResponse()->body()}\n"; + } +} catch (\Exception $e) { + echo "\n❌ Ошибка: {$e->getMessage()}\n"; + echo "Файл: {$e->getFile()}:{$e->getLine()}\n"; +} + +echo "\n=== Пример завершён ===\n"; diff --git a/examples/21_laravel_tinker_sbp_identification.php b/examples/21_laravel_tinker_sbp_identification.php new file mode 100644 index 0000000..f433603 --- /dev/null +++ b/examples/21_laravel_tinker_sbp_identification.php @@ -0,0 +1,177 @@ +>> include 'examples/21_laravel_tinker_sbp_identification.php' + */ + +use Apoloz\AlfaBank\Core\AlfaBankConnector; +use Apoloz\AlfaBank\Core\AlfaConfig; +use Apoloz\AlfaBank\Enums\Environment; +use Apoloz\AlfaBank\Sbp\SbpConnector; +use Apoloz\AlfaBank\Sbp\Data\QrCodeRequestData; +use Apoloz\AlfaBank\Sbp\Requests\GetQrCodeRequest; +use Apoloz\AlfaBank\Requests\NominalAccounts\Identification\GetUnidentifiedTransactionsRequest; +use Apoloz\AlfaBank\Requests\NominalAccounts\Identification\IdentifyTransactionRequest; +use Apoloz\AlfaBank\Requests\NominalAccounts\Identification\GetIdentifyStatusRequest; +use Apoloz\AlfaBank\Data\NominalAccounts\Identification\IdentifyRequestData; +use Apoloz\AlfaBank\Data\NominalAccounts\Identification\AmountDistributionData; +use Apoloz\AlfaBank\Data\NominalAccounts\Payouts\DigestSignatureData; +use Spatie\LaravelData\DataCollection; + +echo "=== SBP QR → Номинальный счёт → Идентификация (Tinker) ===\n\n"; + +// ============================================================================= +// КОНФИГУРАЦИЯ +// ============================================================================= + +$nominalAccountNumber = '40702810601880001584'; // Номинальный счёт из sandbox +$beneficiaryId = 'your-beneficiary-id-here'; // ID бенефициара (получить из списка) +$amount = '15000'; // 150.00 RUB в копейках + +echo "Номинальный счёт: $nominalAccountNumber\n"; +echo "Сумма: " . number_format($amount / 100, 2) . " RUB\n\n"; + +// ============================================================================= +// ШАГ 1: Генерация SBP QR +// ============================================================================= + +echo "=== ШАГ 1: Генерация SBP QR кода ===\n"; + +try { + // SBP Connector + $sbpConfig = new AlfaConfig( + baseUrl: Environment::SBP_SANDBOX->value, + certificatePath: config('alfa-bank.sbp.certificate_path'), + privateKeyPath: config('alfa-bank.sbp.private_key_path'), + caBundlePath: config('alfa-bank.sbp.ca_bundle'), + signingPrivateKeyPath: config('alfa-bank.sbp.signing_private_key_path') + ); + + $sbp = new SbpConnector($sbpConfig, config('alfa-bank.sbp.key_name_alias')); + + $orderNumber = 'ORDER-' . uniqid(); + + $qrResponse = $sbp->send(new GetQrCodeRequest( + new QrCodeRequestData( + termNo: config('alfa-bank.sbp.terminal_no'), + qrcType: '02', // Dynamic QR + amount: $amount, + currency: 'RUB', + paymentPurpose: "Пополнение счета $nominalAccountNumber", + orderNumber: $orderNumber, + messageID: 'sbp-' . uniqid(), + qrTtl: '60' + ) + )); + + $qrData = $qrResponse->dto(); + + if ($qrData->ErrorCode !== 0) { + echo "❌ Ошибка генерации QR: {$qrData->message}\n"; + return; + } + + echo "✅ QR код сгенерирован!\n"; + echo " Order: $orderNumber\n"; + echo " QRC ID: {$qrData->qrcId}\n"; + echo " Payload: {$qrData->payload}\n\n"; + + // ============================================================================= + // ШАГ 2: Получение неидентифицированных пополнений + // ============================================================================= + + echo "=== ШАГ 2: Поиск неидентифицированных пополнений ===\n"; + echo "(После оплаты клиентом через SBP)\n\n"; + + $baas = app(AlfaBankConnector::class); + + // Получаем список + $unidentifiedResponse = $baas->send( + new GetUnidentifiedTransactionsRequest($nominalAccountNumber, limit: 5) + ); + + $transactions = $unidentifiedResponse->dto()->items; + echo "Найдено транзакций: " . count($transactions) . "\n\n"; + + if (count($transactions) === 0) { + echo "⚠️ Нет неидентифицированных пополнений\n"; + return; + } + + // Показываем список + foreach ($transactions as $i => $tx) { + echo " [$i] ID: {$tx->id}\n"; + echo " Сумма: {$tx->amount} RUB\n"; + echo " Назначение: {$tx->purpose}\n"; + echo " Плательщик: {$tx->payer?->name}\n\n"; + } + + // Берём первую для примера + $transaction = $transactions[0]; + echo "✅ Выбрана транзакция: {$transaction->id} ({$transaction->amount} RUB)\n\n"; + + // ============================================================================= + // ШАГ 3: Идентификация (привязка к бенефициару) + // ============================================================================= + + echo "=== ШАГ 3: Идентификация платежа ===\n"; + + if (empty($beneficiaryId) || $beneficiaryId === 'your-beneficiary-id-here') { + echo "⚠️ Укажите правильный beneficiaryId!\n"; + echo " Получите список бенефициаров через:\n"; + echo " >>> \$baas->send(new GetBeneficiariesRequest())->dto()\n"; + return; + } + + $identifyData = new IdentifyRequestData( + transactionId: $transaction->id, + accountNumber: $nominalAccountNumber, + amountDistribution: DataCollection::create([ + new AmountDistributionData( + beneficiaryId: $beneficiaryId, + amount: $transaction->amount + ) + ], AmountDistributionData::class) + ); + + $identifyResponse = $baas->send( + new IdentifyTransactionRequest($transaction->id, $identifyData) + ); + + $result = $identifyResponse->dto(); + echo "✅ Запрос создан!\n"; + echo " Identify ID: {$result->identifyId}\n"; + echo " Статус: {$result->status}\n\n"; + + // ============================================================================= + // ШАГ 4: Проверка статуса + // ============================================================================= + + echo "=== ШАГ 4: Проверка статуса ===\n"; + + $statusResponse = $baas->send( + new GetIdentifyStatusRequest($result->identifyId) + ); + + $status = $statusResponse->dto(); + echo "Текущий статус: {$status->status}\n\n"; + + echo "=== РЕЗУЛЬТАТ ===\n"; + echo "✅ SBP QR: {$qrData->payload}\n"; + echo "✅ Транзакция: {$transaction->id}\n"; + echo "✅ Бенефициар: $beneficiaryId\n"; + echo "✅ Сумма: {$transaction->amount} RUB\n"; + echo "✅ Статус идентификации: {$status->status}\n"; + +} catch (\Exception $e) { + echo "\n❌ Ошибка: {$e->getMessage()}\n"; + echo "Класс: " . get_class($e) . "\n"; +} + +echo "\n=== Готово ===\n"; diff --git a/src/Core/AlfaBankConnector.php b/src/Core/AlfaBankConnector.php index d3878ec..bd544bf 100644 --- a/src/Core/AlfaBankConnector.php +++ b/src/Core/AlfaBankConnector.php @@ -75,7 +75,8 @@ class AlfaBankConnector extends Connector implements HasPagination { return [ 'Accept' => 'application/json', - 'Content-Type' => 'application/json', + // Content-Type не устанавливаем здесь, каждый Request сам определяет его через трейты (HasJsonBody, HasFormBody и т.д.) + // OAuth токен запросы требуют application/x-www-form-urlencoded ]; } @@ -97,7 +98,7 @@ class AlfaBankConnector extends Connector implements HasPagination } // login() kept for ClientCredentials compatibility (legacy/simple flow) - public function login(?string $clientId = null, ?string $clientSecret = null): static + public function login(?string $clientId = null, ?string $clientSecret = null, array $scopes = []): static { $oauthConfig = $this->oauthConfig(); @@ -113,7 +114,7 @@ class AlfaBankConnector extends Connector implements HasPagination throw new \RuntimeException("Client ID/Secret not configured."); } - $request = new \Saloon\Http\OAuth2\GetClientCredentialsTokenRequest($oauthConfig); + $request = new \Saloon\Http\OAuth2\GetClientCredentialsTokenRequest($oauthConfig, $scopes); $response = $this->send($request); diff --git a/src/Sbp/Requests/GetQrCodeRequest.php b/src/Sbp/Requests/GetQrCodeRequest.php index ab0ad0d..f061e95 100644 --- a/src/Sbp/Requests/GetQrCodeRequest.php +++ b/src/Sbp/Requests/GetQrCodeRequest.php @@ -15,18 +15,19 @@ namespace Apoloz\AlfaBank\Sbp\Requests; // use Apoloz\AlfaBank\Sbp\Data\QrCodeRequestData; use Apoloz\AlfaBank\Sbp\Data\QrCodeResponseData; +use Apoloz\AlfaBank\Utils\CmsSigner; use Saloon\Contracts\Body\HasBody; use Saloon\Enums\Method; use Saloon\Http\Request; use Saloon\Http\Response; -use Saloon\Traits\Body\HasJsonBody; +use Saloon\Traits\Body\HasStringBody; // //
// class GetQrCodeRequest extends Request implements HasBody { - use HasJsonBody; + use HasStringBody; //
// @@ -44,15 +45,19 @@ class GetQrCodeRequest extends Request implements HasBody // public function resolveEndpoint(): string { - return '/qrcs'; + return '/'; } //
//
- // - protected function defaultBody(): array + // + protected function defaultBody(): string { - return $this->data->toArray(); + $bodyArray = array_merge( + ['command' => 'GetQRCd'], + $this->data->toArray() + ); + return CmsSigner::canonicalize($bodyArray); } //
diff --git a/src/Sbp/SbpConnector.php b/src/Sbp/SbpConnector.php index 73acef9..ae2e08c 100644 --- a/src/Sbp/SbpConnector.php +++ b/src/Sbp/SbpConnector.php @@ -94,14 +94,17 @@ class SbpConnector extends Connector implements HasPagination public function boot(PendingRequest $pendingRequest): void { $bodyRepo = $pendingRequest->body(); - if (!$bodyRepo || empty($bodyRepo->all())) { + if (!$bodyRepo) { return; } - $body = $bodyRepo->all(); - // SBP requires minified JSON for signing. Canonicalization (JCS) is the formal term. - $jsonBody = CmsSigner::canonicalize($body); - + // Get body as string (for HasStringBody requests) + $bodyString = $bodyRepo->all(); + if (is_array($bodyString)) { + // Fallback for array bodies (HasJsonBody) + $bodyString = CmsSigner::canonicalize($bodyString); + } + // Sign the body using the configured private key (prefer signing key, fallback to transport key). $keyPath = $this->alfaConfig->signingPrivateKeyPath ?: $this->alfaConfig->privateKeyPath; @@ -118,18 +121,12 @@ class SbpConnector extends Connector implements HasPagination } $signature = ''; - openssl_sign($jsonBody, $signature, $privateKey, OPENSSL_ALGO_SHA256); + openssl_sign($bodyString, $signature, $privateKey, OPENSSL_ALGO_SHA256); $signatureBase64 = base64_encode($signature); - // Mutate the request to add SBP-specific headers and set the exact signed body. + // Mutate the request to add SBP-specific headers. $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'); - - // Note: Saloon/Guzzle might re-serialize the body. - // For strict SBP signing, ensuring the body sent matches $jsonBody exactly is crucial. - // Saloon's HasJsonBody trait usually handles this fine as long as array order is preserved. } public function withCaBundle(string $path): static