feat(billing): YooKassaDriver — создание платежа и server-to-server сверка
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing\Gateway;
|
||||
|
||||
use App\Models\PaymentGateway;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Драйвер ЮKassa (YooKassa API v3).
|
||||
* Auth: Basic shopId:secretKey. Idempotence-Key — заголовок при создании.
|
||||
* Чек 54-ФЗ передаётся секцией receipt (включается флагом billing_receipt_enabled).
|
||||
*/
|
||||
final class YooKassaDriver implements PaymentGatewayDriver
|
||||
{
|
||||
private const BASE = 'https://api.yookassa.ru/v3';
|
||||
|
||||
public function createPayment(
|
||||
PaymentGateway $gateway,
|
||||
string $amountRub,
|
||||
string $idempotenceKey,
|
||||
string $returnUrl,
|
||||
?array $receipt,
|
||||
): CreatePaymentResult {
|
||||
[$shopId, $secret] = $this->creds($gateway);
|
||||
|
||||
$payload = [
|
||||
'amount' => ['value' => $amountRub, 'currency' => 'RUB'],
|
||||
'capture' => true,
|
||||
'confirmation' => ['type' => 'redirect', 'return_url' => $returnUrl],
|
||||
'description' => 'Пополнение баланса Лидерра',
|
||||
];
|
||||
if ($receipt !== null) {
|
||||
$payload['receipt'] = $receipt;
|
||||
}
|
||||
|
||||
$resp = Http::withBasicAuth($shopId, $secret)
|
||||
->withHeaders(['Idempotence-Key' => $idempotenceKey])
|
||||
->acceptJson()
|
||||
->post(self::BASE.'/payments', $payload);
|
||||
|
||||
if (! $resp->successful()) {
|
||||
throw new RuntimeException('YooKassa createPayment failed: HTTP '.$resp->status());
|
||||
}
|
||||
|
||||
$id = (string) $resp->json('id');
|
||||
$url = (string) $resp->json('confirmation.confirmation_url');
|
||||
if ($id === '' || $url === '') {
|
||||
throw new RuntimeException('YooKassa createPayment: пустой id/confirmation_url');
|
||||
}
|
||||
|
||||
return new CreatePaymentResult($id, $url);
|
||||
}
|
||||
|
||||
public function verifyPayment(PaymentGateway $gateway, string $gatewayPaymentId): WebhookVerifyResult
|
||||
{
|
||||
[$shopId, $secret] = $this->creds($gateway);
|
||||
|
||||
$resp = Http::withBasicAuth($shopId, $secret)
|
||||
->acceptJson()
|
||||
->get(self::BASE.'/payments/'.$gatewayPaymentId);
|
||||
|
||||
if (! $resp->successful()) {
|
||||
throw new RuntimeException('YooKassa verifyPayment failed: HTTP '.$resp->status());
|
||||
}
|
||||
|
||||
return new WebhookVerifyResult(
|
||||
gatewayPaymentId: (string) $resp->json('id'),
|
||||
status: (string) $resp->json('status'),
|
||||
amountRub: (string) $resp->json('amount.value'),
|
||||
paymentMethod: $resp->json('payment_method.type'),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:string} [shopId, secretKey] */
|
||||
private function creds(PaymentGateway $gateway): array
|
||||
{
|
||||
$c = $gateway->credentials();
|
||||
$shopId = (string) ($c['shop_id'] ?? '');
|
||||
$secret = (string) ($c['secret_key'] ?? '');
|
||||
if ($shopId === '' || $secret === '') {
|
||||
throw new RuntimeException('YooKassa: shop_id/secret_key не настроены');
|
||||
}
|
||||
|
||||
return [$shopId, $secret];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\PaymentGateway;
|
||||
use App\Services\Billing\Gateway\YooKassaDriver;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(Tests\TestCase::class);
|
||||
|
||||
function fakeGateway(): PaymentGateway
|
||||
{
|
||||
$gw = new PaymentGateway;
|
||||
$gw->code = 'yookassa';
|
||||
$gw->config = Crypt::encrypt(['shop_id' => 'shop_1', 'secret_key' => 'test_secret']);
|
||||
|
||||
return $gw;
|
||||
}
|
||||
|
||||
it('создаёт платёж и возвращает id + confirmation_url', function () {
|
||||
Http::fake([
|
||||
'api.yookassa.ru/v3/payments' => Http::response([
|
||||
'id' => '2da2b...test',
|
||||
'status' => 'pending',
|
||||
'confirmation' => ['type' => 'redirect', 'confirmation_url' => 'https://yoomoney.ru/checkout/2da2b'],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$res = (new YooKassaDriver)->createPayment(
|
||||
fakeGateway(), '500.00', 'b3f1c2d4-0000-4000-8000-000000000001', 'https://liderra.ru/billing', null
|
||||
);
|
||||
|
||||
expect($res->gatewayPaymentId)->toBe('2da2b...test')
|
||||
->and($res->confirmationUrl)->toBe('https://yoomoney.ru/checkout/2da2b');
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->hasHeader('Idempotence-Key', 'b3f1c2d4-0000-4000-8000-000000000001')
|
||||
&& $request['amount']['value'] === '500.00'
|
||||
&& $request['amount']['currency'] === 'RUB'
|
||||
&& $request['capture'] === true;
|
||||
});
|
||||
});
|
||||
|
||||
it('сверяет платёж и распознаёт succeeded', function () {
|
||||
Http::fake([
|
||||
'api.yookassa.ru/v3/payments/pay_77' => Http::response([
|
||||
'id' => 'pay_77',
|
||||
'status' => 'succeeded',
|
||||
'amount' => ['value' => '1000.00', 'currency' => 'RUB'],
|
||||
'payment_method' => ['type' => 'bank_card'],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$res = (new YooKassaDriver)->verifyPayment(fakeGateway(), 'pay_77');
|
||||
|
||||
expect($res->isSucceeded())->toBeTrue()
|
||||
->and($res->amountRub)->toBe('1000.00')
|
||||
->and($res->paymentMethod)->toBe('bank_card');
|
||||
});
|
||||
Reference in New Issue
Block a user