Files
portal/app/tests/Feature/Billing/LastOnlinePaymentTest.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

90 lines
4.0 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\LegalEntity;
use App\Models\PaymentGateway;
use App\Models\SaasTransaction;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
/**
* GET /api/billing/last-payment — судьба последней онлайн-оплаты клиента.
*
* Зачем (14.07.2026): клиент создал платёж на 10 000 ₽, не завершил его, ЮKassa отменила.
* Кабинет при этом показывал «Платёж обрабатывается, баланс обновится автоматически» —
* то есть врал. Клиенту нужно видеть «оплата не завершена» и кнопку «Оплатить снова».
*/
beforeEach(function () {
$this->tenant = Tenant::factory()->create(['balance_rub' => '0.00']);
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$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',
]);
});
function seedPayment(Tenant $tenant, PaymentGateway $gw, string $status, string $amount, int $minutesAgo, ?string $reason = null): SaasTransaction
{
return SaasTransaction::create([
'tenant_id' => $tenant->id, 'type' => 'topup', 'amount_rub' => $amount,
'gateway_id' => $gw->id, 'gateway_code' => 'yookassa',
'gateway_payment_id' => 'pay_'.uniqid(),
'status' => $status, 'failure_reason' => $reason,
'created_at' => now()->subMinutes($minutesAgo),
]);
}
it('показывает незавершённую оплату: статус failed и сумму — чтобы кабинет предложил оплатить снова', function () {
seedPayment($this->tenant, $this->gw, 'failed', '10000.00', 30, 'expired_on_confirmation');
$this->actingAs($this->user)->getJson('/api/billing/last-payment')
->assertOk()
->assertJsonPath('data.status', 'failed')
->assertJsonPath('data.amount_rub', '10000.00')
->assertJsonPath('data.failure_reason', 'expired_on_confirmation');
});
it('показывает платёж, который ещё ждёт оплаты', function () {
seedPayment($this->tenant, $this->gw, 'pending', '5000.00', 3);
$this->actingAs($this->user)->getJson('/api/billing/last-payment')
->assertOk()
->assertJsonPath('data.status', 'pending')
->assertJsonPath('data.amount_rub', '5000.00');
});
it('старую оплату (сутки+) не показывает — баннер не должен всплывать при каждом заходе', function () {
seedPayment($this->tenant, $this->gw, 'failed', '10000.00', 60 * 30, 'expired_on_confirmation');
$this->actingAs($this->user)->getJson('/api/billing/last-payment')
->assertOk()
->assertJsonPath('data', null);
});
it('успешную оплату не показывает как проблему', function () {
seedPayment($this->tenant, $this->gw, 'success', '1000.00', 10);
$this->actingAs($this->user)->getJson('/api/billing/last-payment')
->assertOk()
->assertJsonPath('data', null);
});
it('чужой платёж не виден (изоляция клиентов)', function () {
$other = Tenant::factory()->create();
seedPayment($other, $this->gw, 'failed', '10000.00', 20, 'expired_on_confirmation');
$this->actingAs($this->user)->getJson('/api/billing/last-payment')
->assertOk()
->assertJsonPath('data', null);
});