80987996ed
Три починки, каждая сперва увидена красной. 1. Мастер обещал показы как факт: «~8 150». Живой замер 04.08 по кабинету: чужая кампания с бюджетом 13 000 руб за четверо суток набрала 457 показов и потратила 138 руб — деньги не кончились, кончились люди. Число из мастера это потолок, до которого кампания почти наверняка не дотянет. Стало «не больше 8 150» плюс объяснение: упрётся в список, а не в деньги; за несостоявшиеся показы деньги вернутся. Поправлено на шаге частоты и в сводке перед отправкой. 2. Остановка «нет денег» возвращала заморозку, НЕ заплатив за уже показанное. Списание делает часовая задача, а она берёт только кампании со статусом «крутится» — остановленную пропускала навсегда. Показы последнего часа уходили клиенту даром, а Яндексу за них платили мы. Та же дыра, что чинили в паузе, только через другую дверь. Теперь: сперва заплати, потом отпускай; не узнал число показов — не отпускай вовсе. 3. Остановка ходит в Директ по два раза на каждую кампанию, и делала это ВНУТРИ денежной транзакции — замок строки висел всё время сетевых запросов. Вынесено наружу: денежная операция закрывается, и только потом остановка. Плюс минимум площадки в мастере. Директ не берёт кампанию дешевле 300 руб за каждый календарный день и отвечает по-английски на последнем шаге, когда клиент уже пятнадцать часов собирал аудиторию. Теперь мастер предупреждает заранее и по-русски. Формула вынесена в YandexMinimumSpend и одна на портал: ею пользуются и запуск, и мастер — две копии однажды разошлись бы. Сторожа: 7 новых на бэкенде и фронте, каждый принят красным. Прогоны: реклама 458 тестов зелёные, Larastan 0, vue-tsc чисто.
101 lines
5.0 KiB
PHP
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');
|
|
});
|