dd894a5224
Заморозка ставилась один раз при запуске на всю смету показов и не снималась нигде — 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>