Files
portal/app/tests/Feature/Advertising/AdStopNoFundsChargesFirstTest.php
T
Дмитрий 80987996ed fix(реклама): мастер не врёт про показы, остановка без денег платит за показанное
Три починки, каждая сперва увидена красной.

1. Мастер обещал показы как факт: «~8 150». Живой замер 04.08 по кабинету:
   чужая кампания с бюджетом 13 000 руб за четверо суток набрала 457 показов
   и потратила 138 руб — деньги не кончились, кончились люди. Число из мастера
   это потолок, до которого кампания почти наверняка не дотянет. Стало
   «не больше 8 150» плюс объяснение: упрётся в список, а не в деньги;
   за несостоявшиеся показы деньги вернутся. Поправлено на шаге частоты
   и в сводке перед отправкой.

2. Остановка «нет денег» возвращала заморозку, НЕ заплатив за уже показанное.
   Списание делает часовая задача, а она берёт только кампании со статусом
   «крутится» — остановленную пропускала навсегда. Показы последнего часа
   уходили клиенту даром, а Яндексу за них платили мы. Та же дыра, что чинили
   в паузе, только через другую дверь. Теперь: сперва заплати, потом отпускай;
   не узнал число показов — не отпускай вовсе.

3. Остановка ходит в Директ по два раза на каждую кампанию, и делала это
   ВНУТРИ денежной транзакции — замок строки висел всё время сетевых запросов.
   Вынесено наружу: денежная операция закрывается, и только потом остановка.

Плюс минимум площадки в мастере. Директ не берёт кампанию дешевле 300 руб
за каждый календарный день и отвечает по-английски на последнем шаге, когда
клиент уже пятнадцать часов собирал аудиторию. Теперь мастер предупреждает
заранее и по-русски. Формула вынесена в YandexMinimumSpend и одна на портал:
ею пользуются и запуск, и мастер — две копии однажды разошлись бы.

Сторожа: 7 новых на бэкенде и фронте, каждый принят красным.
Прогоны: реклама 458 тестов зелёные, Larastan 0, vue-tsc чисто.
2026-08-05 04:26:27 +03:00

101 lines
5.0 KiB
PHP

<?php
declare(strict_types=1);
use App\Events\AdvertisingStopped;
use App\Models\AdCampaign;
use App\Models\AdWallet;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Http;
// Откат после теста. Без него записи файла остаются в базе и роняют ЧУЖИЕ тесты.
uses(DatabaseTransactions::class);
/**
* ВЫХОД 3 — «Остановлено, нет денег».
*
* 🔴 Дыра, найденная 04.08.2026 рядом с той же дырой в паузе (§6и отчёта приёмки).
* Когда у клиента кончались деньги, портал глушил рекламу в Яндексе, ставил статус
* «Остановлено, нет денег» и БЕЗУСЛОВНО возвращал заморозку — не заплатив за показы,
* которые уже случились. Списание за показы делает часовая задача, и она берёт ТОЛЬКО
* кампании со статусом «крутится»: остановленную она пропускает навсегда.
*
* Итог: показы последнего часа уходили клиенту даром, а Яндексу за них платили мы.
* Ровно то же, что чинили в паузе, только через другую дверь.
*
* Правило то же: СПЕРВА заплати за показанное, ПОТОМ отпускай деньги. Не смог узнать
* число показов — деньги не отпускай вовсе: отпустить, не заплатив, значит подарить.
*/
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
config(['services.yandex_direct.enabled' => true]);
config(['services.yandex_direct.token' => 'DIRTOKEN']);
config(['services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com']);
});
it('остановка без денег сперва платит за показанное, потом отпускает заморозку', function () {
Http::fake([
'*/json/v5/campaigns' => Http::response(['result' => ['SuspendResults' => [['Id' => 777]]]]),
'*/json/v5/reports' => Http::response('2500', 200), // накопительно за всё время
]);
$svc = app(AdWalletService::class);
$svc->topup($this->tenant->id, '3000.00', 'yandex', 'тест');
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Кончились деньги на ходу',
'audience_days' => 10,
'status' => AdCampaign::STATUS_RUNNING,
'yandex_campaign_id' => 777,
'estimated_impressions' => 10000,
'paid_impressions' => 10000,
'charged_client_rub' => '0.00',
]);
$svc->freeze($this->tenant->id, 'yandex', 'campaign', $campaign->id, '1200.00');
event(new AdvertisingStopped($this->tenant->id));
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
// 2500 показов × 120 ₽ за тысячу = 300 ₽ — они обязаны уйти с баланса.
expect((string) $campaign->fresh()->charged_client_rub)->toBe('300.00')
->and((string) $wallet->balance_rub)->toBe('2700.00')
->and((string) $wallet->frozen_rub)->toBe('0.00')
->and($campaign->fresh()->status)->toBe(AdCampaign::STATUS_STOPPED_NO_FUNDS);
});
it('не узнали число показов — рекламу глушим, но деньги НЕ отпускаем', function () {
Http::fake([
'*/json/v5/campaigns' => Http::response(['result' => ['SuspendResults' => [['Id' => 778]]]]),
'*/json/v5/reports' => Http::response(['error' => ['error_detail' => 'нет доступа']], 400),
]);
$svc = app(AdWalletService::class);
$svc->topup($this->tenant->id, '3000.00', 'yandex', 'тест');
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'name' => 'Отчёт не отдался',
'audience_days' => 10,
'status' => AdCampaign::STATUS_RUNNING,
'yandex_campaign_id' => 778,
'estimated_impressions' => 10000,
'paid_impressions' => 10000,
'charged_client_rub' => '0.00',
]);
$svc->freeze($this->tenant->id, 'yandex', 'campaign', $campaign->id, '1200.00');
event(new AdvertisingStopped($this->tenant->id));
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
// Реклама заглушена — статус сменился.
expect($campaign->fresh()->status)->toBe(AdCampaign::STATUS_STOPPED_NO_FUNDS)
// Но заморозка на месте: отпустить, не заплатив, значит подарить показы.
->and((string) $wallet->frozen_rub)->toBe('1200.00')
->and((string) $campaign->fresh()->charged_client_rub)->toBe('0.00');
});