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>
134 lines
6.4 KiB
PHP
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');
|
|
});
|