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