Три починки, каждая сперва увидена красной.
1. Мастер обещал показы как факт: «~8 150». Живой замер 04.08 по кабинету:
чужая кампания с бюджетом 13 000 руб за четверо суток набрала 457 показов
и потратила 138 руб — деньги не кончились, кончились люди. Число из мастера
это потолок, до которого кампания почти наверняка не дотянет. Стало
«не больше 8 150» плюс объяснение: упрётся в список, а не в деньги;
за несостоявшиеся показы деньги вернутся. Поправлено на шаге частоты
и в сводке перед отправкой.
2. Остановка «нет денег» возвращала заморозку, НЕ заплатив за уже показанное.
Списание делает часовая задача, а она берёт только кампании со статусом
«крутится» — остановленную пропускала навсегда. Показы последнего часа
уходили клиенту даром, а Яндексу за них платили мы. Та же дыра, что чинили
в паузе, только через другую дверь. Теперь: сперва заплати, потом отпускай;
не узнал число показов — не отпускай вовсе.
3. Остановка ходит в Директ по два раза на каждую кампанию, и делала это
ВНУТРИ денежной транзакции — замок строки висел всё время сетевых запросов.
Вынесено наружу: денежная операция закрывается, и только потом остановка.
Плюс минимум площадки в мастере. Директ не берёт кампанию дешевле 300 руб
за каждый календарный день и отвечает по-английски на последнем шаге, когда
клиент уже пятнадцать часов собирал аудиторию. Теперь мастер предупреждает
заранее и по-русски. Формула вынесена в YandexMinimumSpend и одна на портал:
ею пользуются и запуск, и мастер — две копии однажды разошлись бы.
Сторожа: 7 новых на бэкенде и фронте, каждый принят красным.
Прогоны: реклама 458 тестов зелёные, Larastan 0, vue-tsc чисто.
Конструктор креативов Яндекса закрыт 01.06.2026 — адаптивного креатива на все
размеры не существует. Медийная кампания состоит из объявления на каждый размер
блока со своим креативом, поэтому строка баннера стала единицей
размер плюс файл плюс креатив плюс объявление.
Что сделано:
- у баннера появились номер креатива, номер объявления и статус модерации
- кап веса баннера поднят со 150 КБ до предела Яндекса 512 КБ
- слепок картиночных креативов аккаунта через creatives.get
- опознание своих креативов разницей слепков до и после загрузки по размеру
- запуск заводит объявление на каждый включённый баннер
- модерация считается по каждому объявлению: кампания работает, если принято
хотя бы одно, а деньги возвращаются только когда отклонены все
Заморозка и возврат денег не тронуты, денежных выходов по-прежнему четыре.
После выката на прод ПЕРЕзапустить db/03_service_bypass_policies.sql, иначе
джоб модерации молча увидит ноль баннеров.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Заморозка ставилась один раз при запуске на всю смету показов и не снималась
нигде — AdWalletService::release не вызывался ни одной строкой приложения.
Главное следствие было блокирующим: charge уменьшал balance_rub, но не трогал
frozen_rub, поэтому одни и те же рубли считались дважды. Свободный остаток
balance − frozen уходил в минус, а AdWalletGate::isSolvent вызывается сразу
после списания в ChargeCampaignSpendJob — клиент объявлялся неплатёжеспособным
после первого же суточного списания, и AdStopAll глушил все его кампании.
Кампания умерла бы после первого дня показов даже при полном кошельке.
Что сделано:
- charge уменьшает активный холд на списанную сумму, холд закрывается при нуле;
- release стал идемпотентным — отсутствие кошелька или холда больше не ошибка;
- выход 1 completed — CampaignImpressionCharger возвращает остаток резерва;
- выход 2 rejected — SyncCampaignModerationJob возвращает резерв целиком;
- выход 3 stopped_no_funds — PauseCampaignsOnAdStop снимает резерв;
- выход 4 paused — контроллер снимает резерв, resume морозит остаток сметы
до обращения к Директу и отдаёт 409 с понятным текстом при нехватке денег.
Удаление кампании выходом не является — destroy разрешён только для черновика,
а черновик ещё не заморожен.
Решение по паузе согласовано с владельцем 27.07.2026: на паузе деньги свободны.
Тесты: рекламный модуль 156/156. Переписан сценарий одного существующего теста
ChargeCampaignSpendJobTest — нехватку денег теперь создаёт резерв ВТОРОЙ
кампании, так как прежняя постановка опиралась на двойной счёт и стала
недостижимой; проверяемое требование сохранено.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>