c0487ddaa5
Разбор живого клиента (стоматология, Красноярск, 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>
90 lines
4.0 KiB
PHP
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);
|
|
});
|