diff --git a/app/app/Http/Controllers/Api/AdvertisingCampaignController.php b/app/app/Http/Controllers/Api/AdvertisingCampaignController.php
index 959c7d49..96a4a17d 100644
--- a/app/app/Http/Controllers/Api/AdvertisingCampaignController.php
+++ b/app/app/Http/Controllers/Api/AdvertisingCampaignController.php
@@ -26,6 +26,7 @@ use App\Services\Advertising\CreativeJobService;
use App\Services\Advertising\CreativeValidator;
use App\Services\Advertising\ModerationReason;
use App\Services\Advertising\YandexDirectClient;
+use App\Services\Advertising\YandexMinimumSpend;
use App\Support\PhoneNormalizer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -409,6 +410,14 @@ class AdvertisingCampaignController extends Controller
$payload['impressions'] = $est['impressions'];
$payload['cpm_rub'] = $est['cpm_rub'];
$payload['cost_rub'] = $est['cost_rub'];
+
+ // Минимум площадки — сюда же, чтобы клиент узнал о нём В МАСТЕРЕ, а не
+ // английским отказом Директа после пятнадцати часов сбора аудитории.
+ // Считает сервер: в расчёте участвует наша наценка, и наружу из неё не уходит
+ // ничего, кроме показов и клиентских рублей.
+ $minimum = app(YandexMinimumSpend::class)->minimum((int) ($campaign->run_days ?: 30), $cpm);
+ $payload['min_impressions'] = $minimum['impressions'];
+ $payload['min_cost_rub'] = $minimum['cost_rub'];
}
return response()->json($payload);
diff --git a/app/app/Jobs/ChargeCampaignSpendJob.php b/app/app/Jobs/ChargeCampaignSpendJob.php
index c572f0e7..6d403857 100644
--- a/app/app/Jobs/ChargeCampaignSpendJob.php
+++ b/app/app/Jobs/ChargeCampaignSpendJob.php
@@ -73,12 +73,25 @@ class ChargeCampaignSpendJob implements ShouldQueue
->whereNotNull('yandex_campaign_id')
->get(['id', 'tenant_id', 'yandex_campaign_id']);
+ // Кого останавливать — копим здесь и глушим ПОСЛЕ денежных транзакций.
+ // 🔴 Раньше stopAll() звался прямо внутри транзакции, а он ходит в Яндекс: глушит
+ // каждую кампанию тенанта и спрашивает у неё число показов, чтобы заплатить за них
+ // перед возвратом заморозки. Замок строки кампании висел всё это время — по два
+ // сетевых запроса на каждую кампанию. Один медленный ответ Директа запирал деньги
+ // клиента на минуты.
+ //
+ // Цена выноса: если процесс умрёт между списанием и остановкой, реклама доживёт до
+ // следующего часового обхода — он снова увидит нехватку денег и заглушит. Это
+ // несравнимо дешевле, чем держать денежный замок на сетевом запросе.
+ $ostanovit = [];
+
foreach ($rows as $row) {
try {
$delivered = $direct->getCampaignImpressions((int) $row->yandex_campaign_id);
$tenantId = (int) $row->tenant_id;
+ $neplatyozhesposoben = false;
- DB::transaction(function () use ($row, $delivered, $tenantId, $charger, $gate, $stopAll): void {
+ DB::transaction(function () use ($row, $delivered, $tenantId, $charger, $gate, &$neplatyozhesposoben): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// Замок строки обязателен. Идемпотентность списания держится на ключе
@@ -91,10 +104,12 @@ class ChargeCampaignSpendJob implements ShouldQueue
$campaign = AdCampaign::where('id', $row->id)->lockForUpdate()->firstOrFail();
$charger->charge($campaign, $delivered);
- if (! $gate->isSolvent($tenantId)) {
- $stopAll->stopAll($tenantId);
- }
+ $neplatyozhesposoben = ! $gate->isSolvent($tenantId);
});
+
+ if ($neplatyozhesposoben) {
+ $ostanovit[$tenantId] = true;
+ }
} catch (Throwable $e) {
Log::warning('ChargeCampaignSpendJob: сбой списания по кампании', [
'campaign_id' => $row->id,
@@ -103,6 +118,22 @@ class ChargeCampaignSpendJob implements ShouldQueue
]);
}
}
+
+ foreach (array_keys($ostanovit) as $tenantId) {
+ try {
+ // Контекст тенанта нужен и здесь: на проде очередь бежит под crm_app_user,
+ // и без него RLS показал бы слушателю НОЛЬ кампаний — реклама осталась бы
+ // крутиться при пустом кошельке, молча. Ставим сессионно (третий аргумент
+ // false), а не SET LOCAL: своей транзакции тут намеренно нет.
+ DB::statement("SELECT set_config('app.current_tenant_id', ?, false)", [(string) $tenantId]);
+ $stopAll->stopAll($tenantId);
+ } catch (Throwable $e) {
+ Log::warning('ChargeCampaignSpendJob: сбой остановки рекламы тенанта', [
+ 'tenant_id' => $tenantId,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
}
/** Достаёт строковое значение из config() без cast'а mixed (phpstan level=max). */
diff --git a/app/app/Listeners/PauseCampaignsOnAdStop.php b/app/app/Listeners/PauseCampaignsOnAdStop.php
index 89898e3f..399b4604 100644
--- a/app/app/Listeners/PauseCampaignsOnAdStop.php
+++ b/app/app/Listeners/PauseCampaignsOnAdStop.php
@@ -7,13 +7,17 @@ namespace App\Listeners;
use App\Events\AdvertisingStopped;
use App\Models\AdCampaign;
use App\Services\Advertising\AdWalletService;
+use App\Services\Advertising\CampaignImpressionCharger;
use App\Services\Advertising\YandexDirectClient;
use Illuminate\Support\Facades\Log;
use Throwable;
final class PauseCampaignsOnAdStop
{
- public function __construct(private readonly AdWalletService $wallet) {}
+ public function __construct(
+ private readonly AdWalletService $wallet,
+ private readonly CampaignImpressionCharger $charger,
+ ) {}
public function handle(AdvertisingStopped $event): void
{
@@ -40,8 +44,43 @@ final class PauseCampaignsOnAdStop
Log::warning('PauseCampaignsOnAdStop suspend failed: '.$e->getMessage(), ['campaign' => $campaign->id]);
}
}
+
+ // 🔴 Сперва платим за уже показанное, и только потом отпускаем деньги.
+ //
+ // Списание за показы делает часовая задача, и она берёт ТОЛЬКО кампании со
+ // статусом «крутится». Остановленную она пропускает НАВСЕГДА: до часа показов
+ // уходило клиенту даром, а Яндексу за них платили мы. Та же дыра, что чинили
+ // в паузе (§6и отчёта приёмки 04.08.2026), только через другую дверь.
+ //
+ // Лишнего обращения в Директ это не стоит: мы и так только что ходили туда
+ // глушить рекламу.
+ $uznaliPokazy = true;
+ if ($direct !== null && $campaign->yandex_campaign_id !== null) {
+ try {
+ $this->charger->charge($campaign, $direct->getCampaignImpressions((int) $campaign->yandex_campaign_id));
+ } catch (Throwable $e) {
+ $uznaliPokazy = false;
+ Log::warning('PauseCampaignsOnAdStop charge failed: '.$e->getMessage(), ['campaign' => $campaign->id]);
+ }
+ }
+
+ $campaign->refresh();
+
+ // Списание могло само закрыть кампанию — смета откручена. Тогда она уже в
+ // «Показы откручены», остаток заморозки вернулся (ВЫХОД 1), и статуса
+ // «нет денег» поверх не ставим: пятого выхода снятия заморозки не появляется.
+ if ($campaign->status === AdCampaign::STATUS_COMPLETED) {
+ continue;
+ }
+
$campaign->update(['status' => AdCampaign::STATUS_STOPPED_NO_FUNDS]);
+ // Не узнали число показов — рекламу заглушили, но деньги НЕ отпускаем:
+ // отпустить их, не заплатив за показы, значит подарить их.
+ if (! $uznaliPokazy) {
+ continue;
+ }
+
// ВЫХОД 3 — реклама заглушена из-за нехватки денег. Держать заморозку на
// мёртвой кампании нельзя: она занижает свободный остаток и не даёт клиенту
// распорядиться пополнением (в т.ч. перезапустить рекламу).
diff --git a/app/app/Services/Advertising/CampaignLauncher.php b/app/app/Services/Advertising/CampaignLauncher.php
index 83a44fef..eafd4be1 100644
--- a/app/app/Services/Advertising/CampaignLauncher.php
+++ b/app/app/Services/Advertising/CampaignLauncher.php
@@ -36,6 +36,7 @@ final class CampaignLauncher
private readonly CampaignAudienceBuilder $audience,
private readonly AdWalletService $wallet,
private readonly AdImpressionPricing $pricing,
+ private readonly YandexMinimumSpend $minimum,
) {}
/**
@@ -263,22 +264,15 @@ final class CampaignLauncher
//
// Проверяем ЗДЕСЬ — до первого обращения к Яндексу: в кабинете ничего не создаётся,
// деньги не морозятся, кампания остаётся черновиком.
- $dneyVPeriode = CarbonImmutable::parse($startDate)->diffInDays(CarbonImmutable::parse($endDate)) + 1;
- $minSpendRub = bcmul(
- (string) config('services.yandex_direct.min_spend_limit_rub_per_day'),
- (string) $dneyVPeriode,
- 2,
- );
+ //
+ // 🔑 Счёт минимума живёт в YandexMinimumSpend и ОДИН на портал: этим же счётчиком
+ // мастер предупреждает клиента заранее. Две копии одного расчёта однажды разойдутся,
+ // и клиент увидит в мастере одно число, а в отказе Директа — другое.
+ $dneyVPeriode = (int) CarbonImmutable::parse($startDate)->diffInDays(CarbonImmutable::parse($endDate)) + 1;
+ $minimum = $this->minimum->minimumForDays($dneyVPeriode, $clientCpm);
$spendLimitRub = bcdiv((string) $spendLimitMicros, '1000000', 2);
- if (bccomp($spendLimitRub, $minSpendRub, 2) < 0) {
- // Обратный счёт: сколько показов нужно, чтобы дотянуть до минимума.
- $rubZaPokaz = bcdiv(bcmul($yandexCpm, $guard, 6), '1000', 8);
- $minImpressions = (int) ceil((float) bcdiv($minSpendRub, $rubZaPokaz, 6));
-
- throw new BudgetBelowYandexMinimumException(
- $minImpressions,
- $this->pricing->clientCostRub($minImpressions, $clientCpm),
- );
+ if (bccomp($spendLimitRub, $minimum['spend_rub'], 2) < 0) {
+ throw new BudgetBelowYandexMinimumException($minimum['impressions'], $minimum['cost_rub']);
}
$freqImpr = (int) ($campaign->frequency ?? 1);
diff --git a/app/app/Services/Advertising/YandexMinimumSpend.php b/app/app/Services/Advertising/YandexMinimumSpend.php
new file mode 100644
index 00000000..d2bdc7bc
--- /dev/null
+++ b/app/app/Services/Advertising/YandexMinimumSpend.php
@@ -0,0 +1,87 @@
+minimumForDays($this->dneyVPeriode($runDays), $clientCpmRub);
+ }
+
+ /**
+ * То же, но когда длина периода уже известна точно (запуск считает её по датам,
+ * которые уходят в Директ, и переиспользует сохранённую дату конца).
+ *
+ * @return array{impressions:int, cost_rub:string, days:int, spend_rub:string}
+ */
+ public function minimumForDays(int $dney, string $clientCpmRub): array
+ {
+ $dney = max(1, $dney);
+
+ $margin = (string) (DB::table('ad_settings')->value('ad_margin_percent') ?? '40.00');
+ $share = bcdiv(bcsub('100', $margin, 4), '100', 6); // доля, уходящая в Директ
+ $yandexCpm = bcmul($clientCpmRub, $share, 2); // ₽ за 1000 для Яндекса
+ $guard = (string) config('services.yandex_direct.spend_limit_guard_multiplier');
+
+ $minSpendRub = bcmul(
+ (string) config('services.yandex_direct.min_spend_limit_rub_per_day'),
+ (string) $dney,
+ 2,
+ );
+
+ // Обратный счёт: сколько показов нужно, чтобы дотянуть до минимума.
+ $rubZaPokaz = bcdiv(bcmul($yandexCpm, $guard, 6), '1000', 8);
+ $impressions = (int) ceil((float) bcdiv($minSpendRub, $rubZaPokaz, 6));
+
+ return [
+ 'impressions' => $impressions,
+ 'cost_rub' => $this->pricing->clientCostRub($impressions, $clientCpmRub),
+ 'days' => $dney,
+ 'spend_rub' => $minSpendRub,
+ ];
+ }
+
+ /**
+ * Длина периода в календарных днях — ровно так, как её считает запуск: от старта до
+ * конца ВКЛЮЧИТЕЛЬНО. Срок в один день означает период «сегодня…завтра», то есть два
+ * дня по календарю Яндекса, а значит и минимум двойной.
+ */
+ public function dneyVPeriode(int $runDays): int
+ {
+ $start = CarbonImmutable::now()->startOfDay();
+ $end = $start->addDays(max($runDays - 1, 1));
+
+ return (int) $start->diffInDays($end) + 1;
+ }
+}
diff --git a/app/resources/js/api/advertising.ts b/app/resources/js/api/advertising.ts
index 6f7f4367..aa7e8e3e 100644
--- a/app/resources/js/api/advertising.ts
+++ b/app/resources/js/api/advertising.ts
@@ -106,6 +106,14 @@ export interface AudienceSize {
impressions?: number;
cpm_rub?: string;
cost_rub?: string;
+ /**
+ * Минимум площадки: Директ не берёт кампанию, у которой расход за период ниже его
+ * минимума (300 ₽ за каждый календарный день). Считает сервер — в расчёте участвует
+ * наша наценка, и клиенту её видеть не положено; сюда приходят только показы и
+ * клиентские рубли. Живой отказ 30.07.2026 клиент видел по-английски и на последнем шаге.
+ */
+ min_impressions?: number;
+ min_cost_rub?: string;
}
/** Тело POST/PATCH /api/advertising/campaigns (создание/правка черновика кампании «за показы»). */
diff --git a/app/resources/js/components/advertising/CampaignWizard.vue b/app/resources/js/components/advertising/CampaignWizard.vue
index 23f13673..e6f5a572 100644
--- a/app/resources/js/components/advertising/CampaignWizard.vue
+++ b/app/resources/js/components/advertising/CampaignWizard.vue
@@ -98,6 +98,22 @@ const canSubmit = computed(() => audienceSize.value?.enough ?? false);
/** Клиентская цена за 1000 показов — берём из ответа сервера (cpm_rub), НЕ хардкодим. */
const cpmLabel = computed(() => `${formatPlain(Number(audienceSize.value?.cpm_rub ?? 120))} за 1000 показов`);
+/**
+ * Смета не дотягивает до нижнего порога площадки.
+ *
+ * 🔴 Директ отказывает по-английски и на последнем шаге — после того как клиент собрал
+ * аудиторию (пятнадцать часов) и залил пятнадцать картинок. Порог считает сервер (в
+ * расчёте наша наценка), сюда приходят только показы и клиентские рубли.
+ */
+const nizheMinimuma = computed(() => {
+ const est = audienceSize.value;
+ if (audienceLoading.value || !est || est.impressions == null || est.min_impressions == null) {
+ return false;
+ }
+
+ return est.impressions < est.min_impressions;
+});
+
/** Человекочитаемая подпись режима сбора аудитории — для сводки шага 4. */
const modeLabel = computed(() => (form.mode === 'auto' ? 'Крутить постоянно' : 'Разовый запуск'));
@@ -854,9 +870,16 @@ defineExpose({
Показов
-
+
+
- ~{{ audienceSize?.impressions ?? 0 }}
+ не больше {{ audienceSize?.impressions ?? 0 }}
@@ -871,6 +894,31 @@ defineExpose({
+
+ Это потолок, а не обещание. Реклама упрётся не в деньги, а в ваш
+ список: Яндекс покажет её ровно тем людям, кого сумеет узнать в
+ интернете, и только когда они туда зайдут. За показы, которых не
+ случится, деньги вернутся на счёт.
+
+
+
+ Столько показов Яндекс не возьмёт: у него есть нижний порог на такой
+ срок. Самая маленькая кампания здесь — {{ audienceSize?.min_impressions }}
+ показов за {{ formatPlain(Number(audienceSize?.min_cost_rub ?? 0)) }}.
+ Поднимите частоту показов или укоротите срок.
+
+
{{ frequencyError }}
@@ -1047,7 +1095,10 @@ defineExpose({
Показов
- ~{{ audienceSize?.impressions ?? 0 }}
+
+
+ не больше {{ audienceSize?.impressions ?? 0 }}
+
Цена
diff --git a/app/tests/Feature/Advertising/AdStopNoFundsChargesFirstTest.php b/app/tests/Feature/Advertising/AdStopNoFundsChargesFirstTest.php
new file mode 100644
index 00000000..6dfd191b
--- /dev/null
+++ b/app/tests/Feature/Advertising/AdStopNoFundsChargesFirstTest.php
@@ -0,0 +1,100 @@
+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');
+});
diff --git a/app/tests/Feature/Advertising/AdvertisingMinimumSpendTest.php b/app/tests/Feature/Advertising/AdvertisingMinimumSpendTest.php
new file mode 100644
index 00000000..fbd96413
--- /dev/null
+++ b/app/tests/Feature/Advertising/AdvertisingMinimumSpendTest.php
@@ -0,0 +1,84 @@
+tenant = Tenant::factory()->create();
+ $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
+ $this->actingAs($this->user);
+
+ config(['services.yandex_direct.min_spend_limit_rub_per_day' => 300]);
+ config(['services.yandex_direct.spend_limit_guard_multiplier' => '1.2']);
+ DB::table('ad_settings')->update(['ad_margin_percent' => '40.00']);
+});
+
+it('мастер получает минимум площадки в показах и рублях, наценку не раскрывая', function () {
+ $campaign = AdCampaign::create([
+ 'tenant_id' => $this->tenant->id,
+ 'name' => 'Черновик',
+ 'status' => AdCampaign::STATUS_DRAFT,
+ 'audience_days' => 10,
+ 'mode' => AdCampaign::MODE_AUTO,
+ 'run_days' => 1,
+ 'client_cpm_rub' => '500.00',
+ ]);
+
+ $otvet = $this->getJson("/api/advertising/campaigns/{$campaign->id}/audience-size?frequency=5&days=10")
+ ->assertOk();
+
+ // Срок 1 день → период сегодня…завтра = 2 календарных дня → минимум Яндексу 600 ₽.
+ // Яндексу уходит 60 % от 500 ₽ = 300 ₽ за тысячу; с запасом 1,2 это 0,36 ₽ за показ.
+ // 600 / 0,36 = 1 666,67 → 1 667 показов. Клиенту это 1667 × 500 / 1000 = 833,50 ₽.
+ $otvet->assertJsonPath('min_impressions', 1667)
+ ->assertJsonPath('min_cost_rub', '833.50');
+
+ // Наценка, доля Яндекса и его ставка клиенту не видны ни в каком виде.
+ $telo = $otvet->getContent();
+ expect($telo)->not->toContain('margin')
+ ->and($telo)->not->toContain('yandex_cpm')
+ ->and($telo)->not->toContain('300.00');
+});
+
+it('запуск и мастер считают минимум ОДНИМ счётчиком', function () {
+ $schetchik = app(\App\Services\Advertising\YandexMinimumSpend::class);
+
+ $itog = $schetchik->minimum(1, '500.00');
+
+ expect($itog['impressions'])->toBe(1667)
+ ->and($itog['cost_rub'])->toBe('833.50');
+});
+
+it('длиннее срок — выше минимум: неделя стоит семь дней по 300 ₽', function () {
+ $schetchik = app(\App\Services\Advertising\YandexMinimumSpend::class);
+
+ // Срок 7 дней → период сегодня…+6 = 7 календарных дней → 2100 ₽ Яндексу.
+ // 2100 / 0,36 = 5 833,33 → 5 834 показа → 2 917,00 ₽ клиенту.
+ $itog = $schetchik->minimum(7, '500.00');
+
+ expect($itog['impressions'])->toBe(5834)
+ ->and($itog['cost_rub'])->toBe('2917.00');
+});
diff --git a/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php b/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php
index 9d01ae10..aab8d213 100644
--- a/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php
+++ b/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php
@@ -244,6 +244,11 @@ it('ВЫХОД 3: остановка без средств снимает зам
]);
Http::fake([
'*/json/v5/campaigns' => Http::response(['result' => []], 200),
+ // 🔴 С 04.08.2026 остановка сперва платит за уже показанное и только потом
+ // отпускает заморозку (AdStopNoFundsChargesFirstTest). Значит миру теста нужен
+ // ответ Директа про показы: без него портал НАМЕРЕННО не отпустит деньги —
+ // отпустить, не заплатив, значит подарить показы. Ноль показов = платить не за что.
+ '*/json/v5/reports' => Http::response('0', 200),
]);
$tenant = Tenant::factory()->create();
diff --git a/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php b/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php
index e6f2c231..9fb813e4 100644
--- a/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php
+++ b/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php
@@ -145,6 +145,42 @@ it('stops all advertising when the charge leaves the tenant insolvent', function
Event::assertDispatched(AdvertisingStopped::class, fn ($e) => $e->tenantId === $tenant->id);
});
+/**
+ * 🔴 Остановка «нет денег» ходит в Яндекс: глушит каждую кампанию и спрашивает у неё
+ * число показов, чтобы заплатить за них перед возвратом заморозки. Пока это делалось
+ * ВНУТРИ денежной транзакции, замок строки кампании держался всё время сетевых
+ * запросов — по два на каждую кампанию тенанта. Один зависший запрос к Директу
+ * запирал деньги клиента на минуты и тормозил всё, что к ним обращается.
+ *
+ * Сторож смотрит на глубину транзакции в момент остановки: она обязана совпадать с
+ * той, что была до запуска задачи, — то есть своей транзакции задача здесь не держит.
+ */
+it('останавливает рекламу ВНЕ денежной транзакции — замок не висит на сетевом запросе', function () {
+ configureYandexDirectForSpend();
+ Http::fake([
+ '*/json/v5/reports' => Http::response('2500', 200),
+ '*/json/v5/campaigns' => Http::response(['result' => ['SuspendResults' => [['Id' => 222]]]]),
+ ]);
+
+ $tenant = Tenant::factory()->create();
+ // 400 ₽ на счету, 200 ₽ заперты под ЧУЖУЮ кампанию; списание 300 ₽ уводит
+ // свободный остаток в минус → стоп всей рекламы тенанта.
+ app(AdWalletService::class)->topup($tenant->id, '400.00', 'yandex', 'тест');
+ $campaign = makeRunningCampaign($tenant->id);
+ app(AdWalletService::class)->freeze($tenant->id, 'yandex', 'campaign', $campaign->id + 1, '200.00');
+
+ $urovenVMomentOstanovki = null;
+ Event::listen(AdvertisingStopped::class, function () use (&$urovenVMomentOstanovki): void {
+ $urovenVMomentOstanovki = DB::transactionLevel();
+ });
+
+ $urovenDo = DB::transactionLevel();
+ app(ChargeCampaignSpendJob::class)->handle();
+
+ expect($urovenVMomentOstanovki)->not->toBeNull()
+ ->and($urovenVMomentOstanovki)->toBe($urovenDo);
+});
+
it('does nothing and sends no HTTP when the yandex_direct switch is off', function () {
config(['services.yandex_direct.enabled' => false]);
fakeYandexImpressionsReport();
diff --git a/app/tests/Frontend/advertising-campaign-wizard-chestnoe-obeshchanie.spec.ts b/app/tests/Frontend/advertising-campaign-wizard-chestnoe-obeshchanie.spec.ts
new file mode 100644
index 00000000..8f5f5742
--- /dev/null
+++ b/app/tests/Frontend/advertising-campaign-wizard-chestnoe-obeshchanie.spec.ts
@@ -0,0 +1,256 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { mount } from '@vue/test-utils';
+import { createVuetify } from 'vuetify';
+
+/**
+ * Мастер обещал показы как ФАКТ: «~8 150». Клиент читал это как «столько будет».
+ *
+ * 🔴 Живой замер 04.08.2026 по кабинету Директа: чужая медийная кампания с бюджетом
+ * 13 000 ₽ за четверо суток набрала 457 показов и потратила 138 ₽. Деньги не кончились —
+ * кончились ЛЮДИ: Яндекс показывает ровно стольким, скольких из загруженного списка он
+ * сумел найти в интернете, и не чаще, чем эти люди туда заходят.
+ *
+ * То есть число из мастера — это ПОТОЛОК, до которого кампания почти наверняка не
+ * дотянет, а не обещание. Клиент узнавал правду не в мастере, а через сутки после
+ * запуска, когда деньги уже заморожены.
+ *
+ * Отсюда два сторожа: число подаётся словами «не больше», и рядом сказано, обо что
+ * упрётся и что будет с неизрасходованными деньгами.
+ */
+
+const {
+ createCampaignMock,
+ fetchAudienceSizeMock,
+ patchCampaignMock,
+ fetchCampaignMock,
+ fetchCampaignsMock,
+ uploadCampaignPhonesMock,
+ fetchBannersMock,
+ uploadBannerMock,
+ toggleBannerIncludedMock,
+ deleteBannerMock,
+ approveBannersMock,
+ submitCampaignMock,
+} = vi.hoisted(() => ({
+ createCampaignMock: vi.fn(),
+ fetchAudienceSizeMock: vi.fn(),
+ patchCampaignMock: vi.fn(),
+ fetchCampaignMock: vi.fn(),
+ fetchCampaignsMock: vi.fn(),
+ uploadCampaignPhonesMock: vi.fn(),
+ fetchBannersMock: vi.fn(),
+ uploadBannerMock: vi.fn(),
+ toggleBannerIncludedMock: vi.fn(),
+ deleteBannerMock: vi.fn(),
+ approveBannersMock: vi.fn(),
+ submitCampaignMock: vi.fn(),
+}));
+
+vi.mock('../../resources/js/api/advertising', () => ({
+ createCampaign: createCampaignMock,
+ fetchAudienceSize: fetchAudienceSizeMock,
+ patchCampaign: patchCampaignMock,
+ fetchCampaign: fetchCampaignMock,
+ fetchCampaigns: fetchCampaignsMock,
+ uploadCampaignPhones: uploadCampaignPhonesMock,
+ fetchBanners: fetchBannersMock,
+ uploadBanner: uploadBannerMock,
+ toggleBannerIncluded: toggleBannerIncludedMock,
+ deleteBanner: deleteBannerMock,
+ approveBanners: approveBannersMock,
+ submitCampaign: submitCampaignMock,
+}));
+
+import CampaignWizard from '../../resources/js/components/advertising/CampaignWizard.vue';
+import type { BannerSlot } from '../../resources/js/api/advertising';
+
+const vuetify = createVuetify();
+
+async function flush(): Promise {
+ await new Promise((r) => setTimeout(r, 0));
+}
+
+const DRAFT_CAMPAIGN = {
+ id: 99,
+ name: 'Реклама от 04.08.2026',
+ status: 'draft' as const,
+ audience_days: 10,
+ launched_at: null,
+};
+
+const ESTIMATE = {
+ size: 1630,
+ min: 100,
+ enough: true,
+ hint: null,
+ frequency: 5,
+ impressions: 8150,
+ cpm_rub: '500.00',
+ cost_rub: '4075.00',
+};
+
+const BANNER_SIZES: Array<[number, number]> = [
+ [160, 600],
+ [240, 400],
+ [240, 600],
+ [300, 250],
+ [300, 300],
+ [300, 500],
+ [300, 600],
+ [320, 480],
+ [336, 280],
+ [480, 320],
+ [728, 90],
+ [970, 250],
+ [320, 50],
+ [320, 100],
+ [1000, 120],
+];
+
+function emptySlot(w: number, h: number): BannerSlot {
+ return { width: w, height: h, uploaded: false, banner_id: null, bytes: null, included: false, preview_url: null };
+}
+
+const BANNERS_EMPTY = {
+ approved_at: null,
+ max_bytes: 153600,
+ formats: ['jpg', 'jpeg', 'png', 'gif'],
+ slots: BANNER_SIZES.map(([w, h]) => emptySlot(w, h)),
+};
+
+async function gotoStep2(w: ReturnType): Promise {
+ await w.find('[data-testid="wizard-next"]').trigger('click');
+ await flush();
+ await w.vm.$nextTick();
+ expect(w.find('[data-testid="wizard-step-2"]').exists()).toBe(true);
+}
+
+describe('CampaignWizard.vue — честное обещание показов', () => {
+ beforeEach(() => {
+ createCampaignMock.mockReset();
+ fetchAudienceSizeMock.mockReset();
+ patchCampaignMock.mockReset();
+ fetchCampaignsMock.mockReset();
+ fetchCampaignsMock.mockResolvedValue([]);
+ createCampaignMock.mockResolvedValue(DRAFT_CAMPAIGN);
+ fetchAudienceSizeMock.mockResolvedValue(ESTIMATE);
+ fetchBannersMock.mockReset();
+ fetchBannersMock.mockResolvedValue(BANNERS_EMPTY);
+ });
+
+ it('число показов подаётся как потолок «не больше», а не как обещание', async () => {
+ const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+ await gotoStep2(w);
+
+ const stroka = w.find('[data-testid="pokazy-potolok"]');
+ expect(stroka.exists()).toBe(true);
+ expect(stroka.text()).toContain('не больше');
+ expect(stroka.text()).toContain('8150');
+ });
+
+ it('рядом сказано, обо что упрётся и что будет с неизрасходованными деньгами', async () => {
+ const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+ await gotoStep2(w);
+
+ const poyasnenie = w.find('[data-testid="pokazy-potolok-poyasnenie"]');
+ expect(poyasnenie.exists()).toBe(true);
+
+ const tekst = poyasnenie.text();
+ // Обо что упрётся — про список людей, а не про деньги.
+ expect(tekst).toContain('список');
+ // Что с деньгами за непоказанное.
+ expect(tekst).toContain('вернутся');
+ });
+
+ it('в сводке перед отправкой число показов тоже названо потолком', async () => {
+ const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+ await gotoStep2(w);
+
+ // Шаг 3 (баннеры) в этой проверке не участвует — доводим до сводки напрямую.
+ await w.find('[data-testid="wizard-next"]').trigger('click');
+ await flush();
+ await w.vm.$nextTick();
+ (w.vm as unknown as { bannersApprovedAt: string | null }).bannersApprovedAt = '2026-08-04T00:00:00Z';
+ (w.vm as unknown as { form: { landing_url: string | null } }).form.landing_url = 'https://liderra.ru/';
+ await w.vm.$nextTick();
+ await w.find('[data-testid="wizard-next"]').trigger('click');
+ await flush();
+ await w.vm.$nextTick();
+
+ const svodka = w.find('[data-testid="svodka-pokazy"]');
+ expect(svodka.exists()).toBe(true);
+ expect(svodka.text()).toContain('не больше');
+ expect(svodka.text()).toContain('8150');
+ });
+
+ /**
+ * 🔴 Живой отказ 30.07.2026: Директ не берёт кампанию дешевле своего минимума и пишет
+ * об этом по-английски — «Budget for this period cannot be less than 600 rub.» —
+ * на последнем шаге, когда клиент уже пятнадцать часов собирал аудиторию и залил
+ * пятнадцать картинок. Предупредить надо В МАСТЕРЕ и человеческим языком.
+ */
+ it('смета ниже минимума площадки — мастер предупреждает и говорит, что делать', async () => {
+ fetchAudienceSizeMock.mockResolvedValue({
+ ...ESTIMATE,
+ frequency: 1,
+ impressions: 500,
+ cost_rub: '250.00',
+ min_impressions: 1667,
+ min_cost_rub: '833.50',
+ });
+
+ const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+ await gotoStep2(w);
+
+ const preduprezhdenie = w.find('[data-testid="minimum-ploshchadki"]');
+ expect(preduprezhdenie.exists()).toBe(true);
+
+ const tekst = preduprezhdenie.text();
+ expect(tekst).toContain('1667');
+ expect(tekst).toContain('833');
+ // Английского отказа Директа клиент видеть не должен.
+ expect(w.text()).not.toContain('Budget for this period');
+ });
+
+ it('смета выше минимума — предупреждения нет', async () => {
+ fetchAudienceSizeMock.mockResolvedValue({
+ ...ESTIMATE,
+ min_impressions: 1667,
+ min_cost_rub: '833.50',
+ });
+
+ const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+ await gotoStep2(w);
+
+ expect(w.find('[data-testid="minimum-ploshchadki"]').exists()).toBe(false);
+ });
+
+ it('слово «потолок» не появляется, пока смета ещё считается', async () => {
+ let otdat: (v: unknown) => void = () => {};
+ fetchAudienceSizeMock.mockReturnValue(new Promise((r) => { otdat = r; }));
+
+ const w = mount(CampaignWizard, { global: { plugins: [vuetify] } });
+ await flush();
+ await w.vm.$nextTick();
+ await w.find('[data-testid="wizard-next"]').trigger('click');
+ await w.vm.$nextTick();
+
+ expect(w.find('[data-testid="pokazy-potolok-poyasnenie"]').exists()).toBe(false);
+
+ otdat(ESTIMATE);
+ await flush();
+ await w.vm.$nextTick();
+
+ expect(w.find('[data-testid="pokazy-potolok-poyasnenie"]').exists()).toBe(true);
+ });
+});