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

134 lines
6.4 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\Services\Billing\Gateway\PaymentGatewayDriver;
use App\Services\Billing\Gateway\WebhookVerifyResult;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
/**
* Сверка «зависших» онлайн-платежей (Денис, 14.07.2026): клиент создал платёж на 10 000 ₽,
* не завершил оплату, ЮKassa отменила его через час — а в кабинете он навсегда остался
* «ожидает оплаты». Команда billing:reconcile-payments спрашивает шлюз о судьбе каждого
* зависшего pending и приводит наш учёт в соответствие. Побочно закрывает потерю вебхука:
* если платёж на самом деле оплачен, а уведомление не дошло — деньги всё равно зачислятся.
*/
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',
]);
});
function seedStalePending(Tenant $tenant, PaymentGateway $gw, string $payId, int $minutesAgo): SaasTransaction
{
return SaasTransaction::create([
'tenant_id' => $tenant->id, 'type' => 'topup', 'amount_rub' => '10000.00',
'gateway_id' => $gw->id, 'gateway_code' => 'yookassa', 'gateway_payment_id' => $payId,
'status' => 'pending', 'created_at' => now()->subMinutes($minutesAgo),
]);
}
it('зависший платёж, отменённый шлюзом, помечается неуспешным с причиной', function () {
$tx = seedStalePending($this->tenant, $this->gw, 'pay_expired', 90);
$this->mock(PaymentGatewayDriver::class, function ($m) {
$m->shouldReceive('verifyPayment')->once()
->andReturn(new WebhookVerifyResult('pay_expired', 'canceled', '10000.00', 'RUB', null, 'expired_on_confirmation'));
});
$this->artisan('billing:reconcile-payments')->assertSuccessful();
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('потерянный вебхук: реально оплаченный платёж всё равно зачисляется на баланс', function () {
$tx = seedStalePending($this->tenant, $this->gw, 'pay_paid', 40);
$this->mock(PaymentGatewayDriver::class, function ($m) {
$m->shouldReceive('verifyPayment')->once()
->andReturn(new WebhookVerifyResult('pay_paid', 'succeeded', '10000.00', 'RUB', 'sbp'));
});
$this->artisan('billing:reconcile-payments')->assertSuccessful();
expect($tx->fresh()->status)->toBe('success')
->and($this->tenant->fresh()->balance_rub)->toBe('10000.00');
});
it('свежий платёж (моложе 15 минут) не трогаем — человек ещё платит', function () {
$tx = seedStalePending($this->tenant, $this->gw, 'pay_fresh', 5);
$this->mock(PaymentGatewayDriver::class, function ($m) {
$m->shouldReceive('verifyPayment')->never();
});
$this->artisan('billing:reconcile-payments')->assertSuccessful();
expect($tx->fresh()->status)->toBe('pending');
});
/**
* 🔴 Прод-грабля (поймана валидатором до выката 14.07.2026): на боевой базе включена RLS,
* и UPDATE без tenant-контекста трогает НОЛЬ строк — платёж остался бы pending, а портал
* бодро отвечал бы «canceled». В тестах этого не видно: тестовая БД идёт под postgres
* (superuser), RLS игнорируется — тот же класс, что инцидент 07.07 (автоподбор) и
* 12.07 (письма заморозки).
*
* Поэтому проверяем не результат, а ПОРЯДОК запросов: SET LOCAL tenant ДО UPDATE.
*/
it('закрывая отменённый платёж, объявляет базе тенанта ДО UPDATE (иначе RLS съест запись на проде)', function () {
$tx = seedStalePending($this->tenant, $this->gw, 'pay_rls', 60);
$this->mock(PaymentGatewayDriver::class, function ($m) {
$m->shouldReceive('verifyPayment')->once()
->andReturn(new WebhookVerifyResult('pay_rls', 'canceled', '10000.00', 'RUB', null, 'expired_on_confirmation'));
});
$queries = [];
DB::listen(function ($q) use (&$queries) {
$queries[] = $q->sql;
});
$this->artisan('billing:reconcile-payments')->assertSuccessful();
$tenantContextAt = null;
$updateAt = null;
foreach ($queries as $i => $sql) {
if ($tenantContextAt === null && str_contains($sql, 'app.current_tenant_id')) {
$tenantContextAt = $i;
}
if ($updateAt === null && str_starts_with(strtolower($sql), 'update "saas_transactions"')) {
$updateAt = $i;
}
}
expect($tenantContextAt)->not->toBeNull('tenant-контекст не выставлен — на проде RLS съест UPDATE')
->and($updateAt)->not->toBeNull()
->and($tenantContextAt)->toBeLessThan($updateAt);
});
it('платёж всё ещё ожидает оплаты у шлюза — остаётся pending', function () {
$tx = seedStalePending($this->tenant, $this->gw, 'pay_still', 20);
$this->mock(PaymentGatewayDriver::class, function ($m) {
$m->shouldReceive('verifyPayment')->once()
->andReturn(new WebhookVerifyResult('pay_still', 'pending', '10000.00', 'RUB', null));
});
$this->artisan('billing:reconcile-payments')->assertSuccessful();
expect($tx->fresh()->status)->toBe('pending');
});