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>
73 lines
3.4 KiB
PHP
73 lines
3.4 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
uses(DatabaseTransactions::class);
|
|
|
|
/**
|
|
* Инцидент 14.07.2026: клиент получил 14 отказов подряд (формы «Добавить конкурента»,
|
|
* «Создать проект», «Удалить»), и в логах не осталось НИ СЛОВА о том, что именно порталу
|
|
* не понравилось. Причину пришлось час восстанавливать по размеру HTTP-ответа в nginx.
|
|
*
|
|
* Теперь каждый отказ по проверке полей пишется в журнал: маршрут, клиент и ИМЕНА полей.
|
|
* Уровень — warning: на проде LOG_LEVEL=warning, info в журнал не попадает вовсе.
|
|
* ЗНАЧЕНИЯ полей НЕ пишем — там персональные данные (телефоны, имена, ИНН), 152-ФЗ.
|
|
*/
|
|
it('пишет в журнал причину отказа: маршрут, клиента и имена полей', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
|
|
Log::shouldReceive('warning')
|
|
->once()
|
|
->withArgs(function (string $event, array $ctx) use ($tenant) {
|
|
return $event === 'validation.rejected'
|
|
&& $ctx['path'] === 'api/projects'
|
|
&& $ctx['tenant_id'] === $tenant->id
|
|
&& in_array('name', $ctx['fields'], true);
|
|
});
|
|
Log::shouldReceive('info')->zeroOrMoreTimes();
|
|
Log::shouldReceive('error')->zeroOrMoreTimes();
|
|
Log::shouldReceive('debug')->zeroOrMoreTimes();
|
|
|
|
$this->actingAs($user)->postJson('/api/projects', [
|
|
'name' => '', // пусто → отказ
|
|
'signal_type' => 'call',
|
|
'signal_identifier' => '79991234567',
|
|
'daily_limit_target' => 10,
|
|
'regions' => [],
|
|
'delivery_days_mask' => 127,
|
|
])->assertStatus(422);
|
|
});
|
|
|
|
it('НЕ пишет в журнал значения полей — там персональные данные', function () {
|
|
$tenant = Tenant::factory()->create();
|
|
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
|
|
|
$logged = [];
|
|
Log::shouldReceive('warning')->andReturnUsing(function (string $event, array $ctx) use (&$logged) {
|
|
$logged[] = $ctx;
|
|
});
|
|
Log::shouldReceive('info')->zeroOrMoreTimes();
|
|
Log::shouldReceive('error')->zeroOrMoreTimes();
|
|
Log::shouldReceive('debug')->zeroOrMoreTimes();
|
|
|
|
$this->actingAs($user)->postJson('/api/projects', [
|
|
'name' => 'Стоматология Дениса',
|
|
'signal_type' => 'call',
|
|
'signal_identifier' => '79221364286', // личный телефон клиента
|
|
'daily_limit_target' => 10,
|
|
'regions' => [],
|
|
'delivery_days_mask' => 0, // отказ по дням недели
|
|
])->assertStatus(422);
|
|
|
|
$flat = json_encode($logged, JSON_UNESCAPED_UNICODE);
|
|
expect($flat)->toContain('delivery_days_mask') // имя поля — да
|
|
->and($flat)->not->toContain('79221364286') // телефон — ни в коем случае
|
|
->and($flat)->not->toContain('Стоматология Дениса');
|
|
});
|