$tenant->id, 'type' => 'topup', 'amount_rub' => '500.00', 'gateway_id' => $gw->id, 'gateway_code' => 'yookassa', 'gateway_payment_id' => $payId, 'status' => 'pending', 'created_at' => now(), ]); } 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', ]); }); it('зачисляет баланс при succeeded и помечает tx success', function () { $tx = seedPendingTx($this->tenant, $this->gw, 'pay_ok'); $this->mock(PaymentGatewayDriver::class, function ($m) { $m->shouldReceive('verifyPayment')->once() ->andReturn(new WebhookVerifyResult('pay_ok', 'succeeded', '500.00', 'RUB', 'bank_card')); }); $resp = $this->postJson('/api/webhook/payment', [ 'event' => 'payment.succeeded', 'object' => ['id' => 'pay_ok'], ]); $resp->assertOk(); $ledgerId = BalanceTransaction::where('tenant_id', $this->tenant->id) ->where('type', 'topup')->latest('id')->value('id'); expect($this->tenant->fresh()->balance_rub)->toBe('500.00') ->and($tx->fresh()->status)->toBe('success') ->and($tx->fresh()->balance_rub_after)->toBe('500.00') ->and($tx->fresh()->balance_transaction_id)->toBe($ledgerId); // provenance-связка }); it('идемпотентен — повторный webhook не зачисляет дважды', function () { seedPendingTx($this->tenant, $this->gw, 'pay_dup'); $this->mock(PaymentGatewayDriver::class, function ($m) { $m->shouldReceive('verifyPayment')->twice() ->andReturn(new WebhookVerifyResult('pay_dup', 'succeeded', '500.00', 'RUB', 'bank_card')); }); $payload = ['event' => 'payment.succeeded', 'object' => ['id' => 'pay_dup']]; $this->postJson('/api/webhook/payment', $payload)->assertOk(); $this->postJson('/api/webhook/payment', $payload)->assertOk(); expect($this->tenant->fresh()->balance_rub)->toBe('500.00'); // не 1000 }); it('не зачисляет если статус не succeeded', function () { $tx = seedPendingTx($this->tenant, $this->gw, 'pay_pending'); $this->mock(PaymentGatewayDriver::class, function ($m) { $m->shouldReceive('verifyPayment')->once() ->andReturn(new WebhookVerifyResult('pay_pending', 'pending', '500.00', 'RUB', null)); }); $this->postJson('/api/webhook/payment', [ 'event' => 'payment.waiting_for_capture', 'object' => ['id' => 'pay_pending'], ])->assertOk(); expect($this->tenant->fresh()->balance_rub)->toBe('0.00') ->and($tx->fresh()->status)->toBe('pending'); }); it('возвращает 200 на неизвестный платёж не падая', function () { $this->mock(PaymentGatewayDriver::class, function ($m) { $m->shouldReceive('verifyPayment')->never(); }); $this->postJson('/api/webhook/payment', [ 'event' => 'payment.succeeded', 'object' => ['id' => 'unknown_pay'], ])->assertOk(); }); it('не зачисляет при чужой валюте (currency != RUB)', function () { $tx = seedPendingTx($this->tenant, $this->gw, 'pay_usd'); $this->mock(PaymentGatewayDriver::class, function ($m) { $m->shouldReceive('verifyPayment')->once() ->andReturn(new WebhookVerifyResult('pay_usd', 'succeeded', '500.00', 'USD', 'bank_card')); }); $this->postJson('/api/webhook/payment', ['object' => ['id' => 'pay_usd']]) ->assertOk()->assertJson(['status' => 'currency_mismatch']); expect($this->tenant->fresh()->balance_rub)->toBe('0.00') ->and($tx->fresh()->status)->toBe('pending'); }); it('не зачисляет при несовпадении id сверенного платежа (confused-deputy)', function () { $tx = seedPendingTx($this->tenant, $this->gw, 'pay_x'); $this->mock(PaymentGatewayDriver::class, function ($m) { // Шлюз вернул ИНОЙ id — зачислять нельзя. $m->shouldReceive('verifyPayment')->once() ->andReturn(new WebhookVerifyResult('pay_other', 'succeeded', '500.00', 'RUB', 'bank_card')); }); $this->postJson('/api/webhook/payment', ['object' => ['id' => 'pay_x']]) ->assertOk()->assertJson(['status' => 'ignored']); expect($this->tenant->fresh()->balance_rub)->toBe('0.00') ->and($tx->fresh()->status)->toBe('pending'); }); it('IP-allowlist: запрос вне списка отбивается без сверки', function () { config(['services.yookassa.webhook_ip_allowlist' => ['10.0.0.0/8']]); // тест-IP 127.0.0.1 вне списка seedPendingTx($this->tenant, $this->gw, 'pay_ip'); $this->mock(PaymentGatewayDriver::class, function ($m) { $m->shouldReceive('verifyPayment')->never(); // до сверки не доходит }); $this->postJson('/api/webhook/payment', ['object' => ['id' => 'pay_ip']]) ->assertOk()->assertJson(['status' => 'ignored']); expect($this->tenant->fresh()->balance_rub)->toBe('0.00'); });