2026-06-22 21:43:01 +03:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
2026-06-22 22:30:36 +03:00
|
|
|
use App\Models\BalanceTransaction;
|
2026-06-22 21:43:01 +03:00
|
|
|
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 Tests\Concerns\SharesSupplierPdo;
|
|
|
|
|
|
|
|
|
|
uses(DatabaseTransactions::class);
|
|
|
|
|
uses(SharesSupplierPdo::class);
|
|
|
|
|
|
|
|
|
|
function seedPendingTx(Tenant $tenant, PaymentGateway $gw, string $payId): SaasTransaction
|
|
|
|
|
{
|
|
|
|
|
return SaasTransaction::create([
|
|
|
|
|
'tenant_id' => $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()
|
2026-06-23 04:15:48 +03:00
|
|
|
->andReturn(new WebhookVerifyResult('pay_ok', 'succeeded', '500.00', 'RUB', 'bank_card'));
|
2026-06-22 21:43:01 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$resp = $this->postJson('/api/webhook/payment', [
|
|
|
|
|
'event' => 'payment.succeeded',
|
|
|
|
|
'object' => ['id' => 'pay_ok'],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$resp->assertOk();
|
2026-06-22 22:30:36 +03:00
|
|
|
$ledgerId = BalanceTransaction::where('tenant_id', $this->tenant->id)
|
|
|
|
|
->where('type', 'topup')->latest('id')->value('id');
|
2026-06-22 21:43:01 +03:00
|
|
|
expect($this->tenant->fresh()->balance_rub)->toBe('500.00')
|
|
|
|
|
->and($tx->fresh()->status)->toBe('success')
|
2026-06-22 22:30:36 +03:00
|
|
|
->and($tx->fresh()->balance_rub_after)->toBe('500.00')
|
|
|
|
|
->and($tx->fresh()->balance_transaction_id)->toBe($ledgerId); // provenance-связка
|
2026-06-22 21:43:01 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('идемпотентен — повторный webhook не зачисляет дважды', function () {
|
|
|
|
|
seedPendingTx($this->tenant, $this->gw, 'pay_dup');
|
|
|
|
|
$this->mock(PaymentGatewayDriver::class, function ($m) {
|
|
|
|
|
$m->shouldReceive('verifyPayment')->twice()
|
2026-06-23 04:15:48 +03:00
|
|
|
->andReturn(new WebhookVerifyResult('pay_dup', 'succeeded', '500.00', 'RUB', 'bank_card'));
|
2026-06-22 21:43:01 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$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()
|
2026-06-23 04:15:48 +03:00
|
|
|
->andReturn(new WebhookVerifyResult('pay_pending', 'pending', '500.00', 'RUB', null));
|
2026-06-22 21:43:01 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$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();
|
|
|
|
|
});
|
2026-06-23 04:15:48 +03:00
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
});
|