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'); });