Files
portal/app/tests/Feature/Billing/PaymentWebhookTest.php
T
Дмитрий c0487ddaa5 fix(billing,ux): портал видит отмену платежа и перестаёт молчать в формах
Разбор живого клиента (стоматология, Красноярск, 14.07): 2 часа настраивал 26
проектов, получил 14 отказов в трёх формах и ушёл, не заплатив.

Деньги:
- отменённый шлюзом платёж больше не висит «ожидает» вечно: закрываем как failed
  с причиной (PaymentSettlementService — общий путь для webhook и крона);
- billing:reconcile-payments каждые 5 минут сам спрашивает шлюз про зависшие
  pending. Побочно страхует от ПОТЕРИ ДЕНЕГ: если webhook не дойдёт, оплаченный
  платёж всё равно зачислится;
- кабинет говорит правду: «Оплата не завершена» + «Оплатить снова» вместо
  «баланс обновится автоматически» (GET /api/billing/last-payment).

🔴 RLS-мина (поймана валидатором ДО выката): UPDATE при отмене шёл без
tenant-контекста → на проде тронул бы 0 строк, а портал рапортовал бы «отменено».
Тесты слепы (тестовая БД под postgres). Регресс-тест проверяет ПОРЯДОК:
SET LOCAL tenant ДО UPDATE. Тот же класс, что инциденты 07.07 и 12.07.

Формы (клиент бился и уходил):
- удаление проекта со сделками: причина показывается на месте + кнопка
  «Поставить на паузу» (раньше 422 улетал в никуда — 4 попытки впустую);
- создание проекта: ошибка по дням недели больше не молчит (у поля не было
  места для показа — 2 немых отказа);
- автоподбор «Добавить вручную»: показываем причину от сервера (был голый
  catch {}), длинные ссылки 2ГИС/Яндекс.Карт принимаются — трекинг-хвост срезаем
  сами. Воспроизведено тестом: именно длинная ссылка давала 3 отказа подряд.

Наблюдаемость: причины отказов пишутся в журнал (маршрут, tenant, ИМЕНА полей;
значений нет — 152-ФЗ). Уровень warning: на проде LOG_LEVEL=warning, info в
журнал не попадает вовсе. Робот-сверщик добавлен в реестр пульса.

Тесты: Pest 2475/2475, Vitest 1215/1215.
Выкачено на боевой 14.07.2026 ~13:00 МСК; сверка сразу закрыла 3 мёртвых платежа
(10 000 ₽, 5 000 ₽, 1 000 ₽).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:14:49 +03:00

171 lines
7.5 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\BalanceTransaction;
use App\Models\LegalEntity;
use App\Models\PaymentGateway;
use App\Models\SaasTransaction;
use App\Models\Tenant;
use App\Services\Billing\Gateway\PaymentGatewayDriver;
use App\Services\Billing\Gateway\WebhookVerifyResult;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
function seedPendingTx(Tenant $tenant, PaymentGateway $gw, string $payId): SaasTransaction
{
return SaasTransaction::create([
'tenant_id' => $tenant->id, 'type' => 'topup', 'amount_rub' => '500.00',
'gateway_id' => $gw->id, 'gateway_code' => 'yookassa', 'gateway_payment_id' => $payId,
'status' => 'pending', 'created_at' => now(),
]);
}
beforeEach(function () {
$this->tenant = Tenant::factory()->create(['balance_rub' => '0.00']);
$legalEntity = LegalEntity::create([
'code' => 'test_le_'.uniqid(), 'name' => 'ООО Тест', 'legal_form' => 'OOO', 'inn' => '7700000000',
]);
$this->gw = PaymentGateway::create([
'code' => 'yookassa', 'name' => 'ЮKassa', 'driver' => 'yookassa',
'legal_entity_id' => $legalEntity->id, 'config' => '', 'is_active' => true,
'accepts_methods' => ['card'], 'min_amount_rub' => '100.00',
]);
});
it('зачисляет баланс при succeeded и помечает tx success', function () {
$tx = seedPendingTx($this->tenant, $this->gw, 'pay_ok');
$this->mock(PaymentGatewayDriver::class, function ($m) {
$m->shouldReceive('verifyPayment')->once()
->andReturn(new WebhookVerifyResult('pay_ok', 'succeeded', '500.00', 'RUB', 'bank_card'));
});
$resp = $this->postJson('/api/webhook/payment', [
'event' => 'payment.succeeded',
'object' => ['id' => 'pay_ok'],
]);
$resp->assertOk();
$ledgerId = BalanceTransaction::where('tenant_id', $this->tenant->id)
->where('type', 'topup')->latest('id')->value('id');
expect($this->tenant->fresh()->balance_rub)->toBe('500.00')
->and($tx->fresh()->status)->toBe('success')
->and($tx->fresh()->balance_rub_after)->toBe('500.00')
->and($tx->fresh()->balance_transaction_id)->toBe($ledgerId); // provenance-связка
});
it('идемпотентен — повторный webhook не зачисляет дважды', function () {
seedPendingTx($this->tenant, $this->gw, 'pay_dup');
$this->mock(PaymentGatewayDriver::class, function ($m) {
$m->shouldReceive('verifyPayment')->twice()
->andReturn(new WebhookVerifyResult('pay_dup', 'succeeded', '500.00', 'RUB', 'bank_card'));
});
$payload = ['event' => 'payment.succeeded', 'object' => ['id' => 'pay_dup']];
$this->postJson('/api/webhook/payment', $payload)->assertOk();
$this->postJson('/api/webhook/payment', $payload)->assertOk();
expect($this->tenant->fresh()->balance_rub)->toBe('500.00'); // не 1000
});
it('не зачисляет если статус не succeeded', function () {
$tx = seedPendingTx($this->tenant, $this->gw, 'pay_pending');
$this->mock(PaymentGatewayDriver::class, function ($m) {
$m->shouldReceive('verifyPayment')->once()
->andReturn(new WebhookVerifyResult('pay_pending', 'pending', '500.00', 'RUB', null));
});
$this->postJson('/api/webhook/payment', [
'event' => 'payment.waiting_for_capture',
'object' => ['id' => 'pay_pending'],
])->assertOk();
expect($this->tenant->fresh()->balance_rub)->toBe('0.00')
->and($tx->fresh()->status)->toBe('pending');
});
it('возвращает 200 на неизвестный платёж не падая', function () {
$this->mock(PaymentGatewayDriver::class, function ($m) {
$m->shouldReceive('verifyPayment')->never();
});
$this->postJson('/api/webhook/payment', [
'event' => 'payment.succeeded', 'object' => ['id' => 'unknown_pay'],
])->assertOk();
});
it('не зачисляет при чужой валюте (currency != RUB)', function () {
$tx = seedPendingTx($this->tenant, $this->gw, 'pay_usd');
$this->mock(PaymentGatewayDriver::class, function ($m) {
$m->shouldReceive('verifyPayment')->once()
->andReturn(new WebhookVerifyResult('pay_usd', 'succeeded', '500.00', 'USD', 'bank_card'));
});
$this->postJson('/api/webhook/payment', ['object' => ['id' => 'pay_usd']])
->assertOk()->assertJson(['status' => 'currency_mismatch']);
expect($this->tenant->fresh()->balance_rub)->toBe('0.00')
->and($tx->fresh()->status)->toBe('pending');
});
it('не зачисляет при несовпадении id сверенного платежа (confused-deputy)', function () {
$tx = seedPendingTx($this->tenant, $this->gw, 'pay_x');
$this->mock(PaymentGatewayDriver::class, function ($m) {
// Шлюз вернул ИНОЙ id — зачислять нельзя.
$m->shouldReceive('verifyPayment')->once()
->andReturn(new WebhookVerifyResult('pay_other', 'succeeded', '500.00', 'RUB', 'bank_card'));
});
$this->postJson('/api/webhook/payment', ['object' => ['id' => 'pay_x']])
->assertOk()->assertJson(['status' => 'ignored']);
expect($this->tenant->fresh()->balance_rub)->toBe('0.00')
->and($tx->fresh()->status)->toBe('pending');
});
it('отменённый шлюзом платёж помечается неуспешным с причиной — не висит pending вечно', function () {
$tx = seedPendingTx($this->tenant, $this->gw, 'pay_cancel');
$this->mock(PaymentGatewayDriver::class, function ($m) {
$m->shouldReceive('verifyPayment')->once()
->andReturn(new WebhookVerifyResult('pay_cancel', 'canceled', '500.00', 'RUB', null, 'expired_on_confirmation'));
});
$this->postJson('/api/webhook/payment', [
'event' => 'payment.canceled',
'object' => ['id' => 'pay_cancel'],
])->assertOk()->assertJson(['status' => 'canceled']);
expect($tx->fresh()->status)->toBe('failed')
->and($tx->fresh()->failure_reason)->toBe('expired_on_confirmation')
->and($this->tenant->fresh()->balance_rub)->toBe('0.00'); // денег не начисляли
});
it('отмена не трогает уже зачисленный платёж (success остаётся success)', function () {
$tx = seedPendingTx($this->tenant, $this->gw, 'pay_done');
$tx->update(['status' => 'success']);
$this->mock(PaymentGatewayDriver::class, function ($m) {
$m->shouldReceive('verifyPayment')->once()
->andReturn(new WebhookVerifyResult('pay_done', 'canceled', '500.00', 'RUB', null, 'expired_on_confirmation'));
});
$this->postJson('/api/webhook/payment', ['object' => ['id' => 'pay_done']])->assertOk();
expect($tx->fresh()->status)->toBe('success');
});
it('IP-allowlist: запрос вне списка отбивается без сверки', function () {
config(['services.yookassa.webhook_ip_allowlist' => ['10.0.0.0/8']]); // тест-IP 127.0.0.1 вне списка
seedPendingTx($this->tenant, $this->gw, 'pay_ip');
$this->mock(PaymentGatewayDriver::class, function ($m) {
$m->shouldReceive('verifyPayment')->never(); // до сверки не доходит
});
$this->postJson('/api/webhook/payment', ['object' => ['id' => 'pay_ip']])
->assertOk()->assertJson(['status' => 'ignored']);
expect($this->tenant->fresh()->balance_rub)->toBe('0.00');
});