3b142f9375
Webhook (PaymentWebhookController): строгий матч gatewayPaymentId===paymentId (confused-deputy), проверка валюты RUB (WebhookVerifyResult.currency), IP-allowlist services.yookassa.webhook_ip_allowlist (fail-open при пустом). web.php: убраны устаревшие «MVP без auth» комментарии — saas-admin зона fail-closed (nginx-basic + M-1 REMOTE_USER allowlist, проверено на проде). +3 теста, 11/11 зелёные. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
120 lines
5.5 KiB
PHP
120 lines
5.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\PaymentGateway;
|
|
use App\Models\SaasTransaction;
|
|
use App\Services\Billing\BillingTopupService;
|
|
use App\Services\Billing\Gateway\PaymentGatewayDriver;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Symfony\Component\HttpFoundation\IpUtils;
|
|
|
|
/**
|
|
* Приём webhook от платёжного шлюза (ЮKassa). Публичный роут (без auth/tenant),
|
|
* URL под маской api/webhook/* → CSRF-exempt (bootstrap/app.php).
|
|
*
|
|
* Подлинность: НЕ доверяем телу webhook — по object.id делаем server-to-server
|
|
* сверку через драйвер (GET /payments/{id}) и верим статусу из ответа шлюза.
|
|
*
|
|
* RLS: webhook вне tenant-сессии. Поиск платежа cross-tenant — через
|
|
* BYPASSRLS-соединение pgsql_supplier (как джобы, Plan 3/4). Зачисление —
|
|
* под app.current_tenant_id (SET LOCAL внутри транзакции, как BalancePreflightSweepJob).
|
|
*
|
|
* Идемпотентность: атомарный claim pending→success (UPDATE ... WHERE status='pending').
|
|
* Повторный webhook → claimed=0 → no-op, 200 OK без двойного зачисления.
|
|
*
|
|
* Зачисление денег делегируется BillingTopupService (lockForUpdate + append-only ledger).
|
|
*/
|
|
class PaymentWebhookController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly PaymentGatewayDriver $driver,
|
|
private readonly BillingTopupService $topupService,
|
|
) {}
|
|
|
|
public function receive(Request $request): JsonResponse
|
|
{
|
|
// Defense-in-depth: IP-allowlist ЮKassa. Fail-open при пустом списке —
|
|
// не ломаем легитимный поток; на проде заполнить YOOKASSA_WEBHOOK_IPS
|
|
// опубликованными ЮKassa подсетями, чтобы аноним не дёргал endpoint.
|
|
$allowlist = array_values(array_filter((array) config('services.yookassa.webhook_ip_allowlist', [])));
|
|
if ($allowlist !== [] && ! IpUtils::checkIp((string) $request->ip(), $allowlist)) {
|
|
return response()->json(['status' => 'ignored'], 200);
|
|
}
|
|
|
|
$paymentId = (string) $request->input('object.id', '');
|
|
if ($paymentId === '') {
|
|
return response()->json(['status' => 'ignored'], 200);
|
|
}
|
|
|
|
// Cross-tenant поиск платежа под BYPASSRLS-ролью (tenant ещё неизвестен).
|
|
$tx = DB::connection('pgsql_supplier')->table('saas_transactions')
|
|
->where('gateway_payment_id', $paymentId)
|
|
->first();
|
|
if ($tx === null) {
|
|
return response()->json(['status' => 'unknown'], 200);
|
|
}
|
|
|
|
$gateway = $this->gatewayFor($tx);
|
|
|
|
// Server-to-server сверка — источник правды о статусе.
|
|
$verify = $this->driver->verifyPayment($gateway, $paymentId);
|
|
if (! $verify->isSucceeded()) {
|
|
return response()->json(['status' => 'not_paid'], 200);
|
|
}
|
|
|
|
// Confused-deputy: сверенный платёж должен быть РОВНО тем, что в webhook.
|
|
if ($verify->gatewayPaymentId !== $paymentId) {
|
|
return response()->json(['status' => 'ignored'], 200);
|
|
}
|
|
|
|
// Защита от чужой валюты с тем же числом — зачисляем только рубли.
|
|
if ($verify->currency !== 'RUB') {
|
|
return response()->json(['status' => 'currency_mismatch'], 200);
|
|
}
|
|
|
|
// Защита: оплаченная сумма должна совпасть с запрошенной (scale 2).
|
|
if (bccomp((string) $verify->amountRub, (string) $tx->amount_rub, 2) !== 0) {
|
|
return response()->json(['status' => 'amount_mismatch'], 200);
|
|
}
|
|
|
|
DB::transaction(function () use ($tx, $verify) {
|
|
// RLS-контекст для этой транзакции (PgBouncer-safe SET LOCAL).
|
|
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tx->tenant_id);
|
|
|
|
// Атомарно занимаем pending→success; 0 строк = уже зачислено (дубль/гонка).
|
|
$claimed = SaasTransaction::where('id', $tx->id)
|
|
->where('status', SaasTransaction::STATUS_PENDING)
|
|
->update(['status' => SaasTransaction::STATUS_SUCCESS, 'completed_at' => now()]);
|
|
|
|
if ($claimed === 0) {
|
|
return; // идемпотентный no-op
|
|
}
|
|
|
|
$balanceTx = $this->topupService->topup(
|
|
(int) $tx->tenant_id, (string) $tx->amount_rub, null
|
|
);
|
|
|
|
SaasTransaction::where('id', $tx->id)->update([
|
|
'balance_rub_after' => $balanceTx->balance_rub_after,
|
|
'payment_method' => $verify->paymentMethod,
|
|
'balance_transaction_id' => $balanceTx->id, // provenance: оплата → строка журнала
|
|
]);
|
|
});
|
|
|
|
return response()->json(['status' => 'ok'], 200);
|
|
}
|
|
|
|
private function gatewayFor(object $tx): PaymentGateway
|
|
{
|
|
return $tx->gateway_id !== null
|
|
? PaymentGateway::findOrFail($tx->gateway_id)
|
|
: PaymentGateway::where('code', $tx->gateway_code)->firstOrFail();
|
|
}
|
|
}
|