diff --git a/app/app/Http/Controllers/Api/AdvertisingCampaignController.php b/app/app/Http/Controllers/Api/AdvertisingCampaignController.php index a8776f44..b7efb1a2 100644 --- a/app/app/Http/Controllers/Api/AdvertisingCampaignController.php +++ b/app/app/Http/Controllers/Api/AdvertisingCampaignController.php @@ -5,11 +5,14 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; use App\Exceptions\Advertising\AudienceTooSmallException; +use App\Exceptions\Billing\InsufficientBalanceException; use App\Http\Controllers\Controller; use App\Models\AdCampaign; use App\Models\AdCampaignAd; use App\Models\AdCampaignBanner; use App\Models\AdWalletTransaction; +use App\Services\Advertising\AdImpressionPricing; +use App\Services\Advertising\AdWalletService; use App\Services\Advertising\BannerSizes; use App\Services\Advertising\BannerUploadPolicy; use App\Services\Advertising\CampaignAudienceBuilder; @@ -300,6 +303,11 @@ class AdvertisingCampaignController extends Controller $campaign->update(['status' => AdCampaign::STATUS_PAUSED]); + // ВЫХОД 4 — на паузе кампания не крутится и не тратит деньги, поэтому держать + // их зарезервированными незачем: возвращаем в свободные, клиент волен пустить + // их на другую рекламу. При возобновлении остаток сметы морозится заново. + app(AdWalletService::class)->release($tenantId, 'yandex', 'campaign', (int) $campaign->id); + return response()->json(['status' => $campaign->fresh()->status]); } @@ -315,6 +323,19 @@ class AdvertisingCampaignController extends Controller ], 409); } + // ВЫХОД 4 (обратно) — резервируем неоткрученный остаток сметы ДО обращения к + // Директу: если денег не хватает, кампания не должна ожить в Яндексе. + $remaining = $this->remainingBudgetRub($campaign); + if (bccomp($remaining, '0.00', 2) > 0) { + try { + app(AdWalletService::class)->freeze($tenantId, 'yandex', 'campaign', (int) $campaign->id, $remaining); + } catch (InsufficientBalanceException) { + return response()->json([ + 'message' => 'Не хватает денег на рекламном кошельке, чтобы возобновить кампанию. Пополните кошелёк.', + ], 409); + } + } + $this->callDirect($campaign, fn (YandexDirectClient $direct, int $yandexCampaignId) => $direct->resumeCampaign($yandexCampaignId)); $campaign->update(['status' => AdCampaign::STATUS_RUNNING]); @@ -322,6 +343,23 @@ class AdvertisingCampaignController extends Controller return response()->json(['status' => $campaign->fresh()->status]); } + /** + * Неоткрученный остаток сметы в клиентских рублях: полная стоимость оплаченных + * показов минус уже списанное. Для кампаний без сметы (старые, «за клики») — 0. + */ + private function remainingBudgetRub(AdCampaign $campaign): string + { + $paid = (int) ($campaign->paid_impressions ?? 0); + if ($paid <= 0) { + return '0.00'; + } + + $total = app(AdImpressionPricing::class)->clientCostRub($paid, $campaign->effectiveCpm()); + $rest = bcsub($total, (string) ($campaign->charged_client_rub ?? '0.00'), 2); + + return bccomp($rest, '0.00', 2) > 0 ? $rest : '0.00'; + } + /** * Вызывает Директ (suspend/resume) под рубильником, если у кампании уже есть * yandex_campaign_id. Деньги не трогает. Если Директ недоступен — логируем и diff --git a/app/app/Jobs/SyncCampaignModerationJob.php b/app/app/Jobs/SyncCampaignModerationJob.php index bd831425..8bb217f6 100644 --- a/app/app/Jobs/SyncCampaignModerationJob.php +++ b/app/app/Jobs/SyncCampaignModerationJob.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Jobs; use App\Models\AdCampaign; +use App\Services\Advertising\AdWalletService; use App\Services\Advertising\YandexDirectClient; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -109,6 +110,14 @@ class SyncCampaignModerationJob implements ShouldQueue 'status' => $newStatus, 'moderation_reason' => $hasRejected ? $rejectedReason : $campaign->moderation_reason, ]); + + // ВЫХОД 2 — Яндекс отклонил объявление. Показов не было, деньги не + // тратились: заморозку возвращаем клиенту целиком, кампания мертва. + if ($newStatus === AdCampaign::STATUS_REJECTED) { + app(AdWalletService::class)->release( + (int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id, + ); + } } } } diff --git a/app/app/Listeners/PauseCampaignsOnAdStop.php b/app/app/Listeners/PauseCampaignsOnAdStop.php index d6793d34..89898e3f 100644 --- a/app/app/Listeners/PauseCampaignsOnAdStop.php +++ b/app/app/Listeners/PauseCampaignsOnAdStop.php @@ -6,12 +6,15 @@ namespace App\Listeners; use App\Events\AdvertisingStopped; use App\Models\AdCampaign; +use App\Services\Advertising\AdWalletService; use App\Services\Advertising\YandexDirectClient; use Illuminate\Support\Facades\Log; use Throwable; final class PauseCampaignsOnAdStop { + public function __construct(private readonly AdWalletService $wallet) {} + public function handle(AdvertisingStopped $event): void { // ЯВНЫЙ tenant-фильтр: слушатель бежит вне веб-запроса (очередь, BYPASSRLS). @@ -38,6 +41,11 @@ final class PauseCampaignsOnAdStop } } $campaign->update(['status' => AdCampaign::STATUS_STOPPED_NO_FUNDS]); + + // ВЫХОД 3 — реклама заглушена из-за нехватки денег. Держать заморозку на + // мёртвой кампании нельзя: она занижает свободный остаток и не даёт клиенту + // распорядиться пополнением (в т.ч. перезапустить рекламу). + $this->wallet->release((int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id); } } } diff --git a/app/app/Services/Advertising/AdWalletService.php b/app/app/Services/Advertising/AdWalletService.php index 8f32bf85..32458b15 100644 --- a/app/app/Services/Advertising/AdWalletService.php +++ b/app/app/Services/Advertising/AdWalletService.php @@ -89,7 +89,13 @@ final class AdWalletService public function release(int $tenantId, string $channel, string $sourceType, int $sourceId): void { DB::transaction(function () use ($tenantId, $channel, $sourceType, $sourceId): void { - $wallet = AdWallet::where('tenant_id', $tenantId)->lockForUpdate()->firstOrFail(); + // Снятие заморозки — идемпотентная уборка на выходах кампании (завершена / + // отклонена / остановлена без средств / поставлена на паузу). Кошелька или + // активного холда может не быть вовсе (заморозки не было) — это не ошибка. + $wallet = AdWallet::where('tenant_id', $tenantId)->lockForUpdate()->first(); + if ($wallet === null) { + return; + } $hold = AdWalletHold::where('tenant_id', $tenantId) ->where('channel', $channel)->where('source_type', $sourceType) ->where('source_id', $sourceId)->where('status', AdWalletHold::STATUS_ACTIVE)->lockForUpdate()->first(); @@ -125,7 +131,42 @@ final class AdWalletService if (bccomp($newBalance, '0', 2) < 0) { $newBalance = '0.00'; // не уходим в минус; недобор ловит AdStopAll (Task 8) } - DB::table('ad_wallets')->where('id', $wallet->id)->update(['balance_rub' => $newBalance, 'updated_at' => now()]); + $walletUpdate = ['balance_rub' => $newBalance, 'updated_at' => now()]; + + // Заморозка ТАЕТ вместе со списанием: списанные деньги ушли с баланса и + // больше не зарезервированы. Без этого одни и те же рубли считались бы + // дважды — свободный остаток (balance − frozen) уходил бы в минус, и + // AdWalletGate::isSolvent() объявил бы клиента неплатёжеспособным сразу + // после первого суточного списания (→ AdStopAll глушит все кампании). + $hold = AdWalletHold::where('tenant_id', $tenantId) + ->where('channel', $channel)->where('source_type', $relatedType) + ->where('source_id', $relatedId)->where('status', AdWalletHold::STATUS_ACTIVE) + ->lockForUpdate()->first(); + + if ($hold !== null) { + // Больше, чем зарезервировано, не размораживаем (потолок сметы уже + // держит CampaignImpressionCharger, но арифметику страхуем здесь). + $melt = bccomp($amountRub, (string) $hold->amount_rub, 2) > 0 + ? (string) $hold->amount_rub + : $amountRub; + + $newFrozen = bcsub((string) $wallet->frozen_rub, $melt, 2); + if (bccomp($newFrozen, '0', 2) < 0) { + $newFrozen = '0.00'; + } + $walletUpdate['frozen_rub'] = $newFrozen; + + $holdRest = bcsub((string) $hold->amount_rub, $melt, 2); + AdWalletHold::where('id', $hold->id)->update([ + 'amount_rub' => $holdRest, + 'status' => bccomp($holdRest, '0', 2) === 0 + ? AdWalletHold::STATUS_RELEASED + : AdWalletHold::STATUS_ACTIVE, + 'updated_at' => now(), + ]); + } + + DB::table('ad_wallets')->where('id', $wallet->id)->update($walletUpdate); AdWalletTransaction::create([ 'tenant_id' => $tenantId, 'type' => AdWalletTransaction::TYPE_CHARGE, diff --git a/app/app/Services/Advertising/CampaignImpressionCharger.php b/app/app/Services/Advertising/CampaignImpressionCharger.php index 8613d31d..482eab14 100644 --- a/app/app/Services/Advertising/CampaignImpressionCharger.php +++ b/app/app/Services/Advertising/CampaignImpressionCharger.php @@ -74,5 +74,12 @@ final class CampaignImpressionCharger } $campaign->update($update); + + // ВЫХОД 1 — смета откручена. Остаток заморозки (если смета стоила меньше, + // чем заморозили при запуске) возвращаем в свободные деньги: кампания + // больше не претендует на эти рубли. + if (($update['status'] ?? null) === AdCampaign::STATUS_COMPLETED) { + $this->wallet->release((int) $campaign->tenant_id, 'yandex', 'campaign', (int) $campaign->id); + } } } diff --git a/app/tests/Feature/Advertising/AdWalletHoldLifecycleTest.php b/app/tests/Feature/Advertising/AdWalletHoldLifecycleTest.php new file mode 100644 index 00000000..8882ae58 --- /dev/null +++ b/app/tests/Feature/Advertising/AdWalletHoldLifecycleTest.php @@ -0,0 +1,86 @@ + $tenantId, + 'name' => 'Кампания жизненного цикла заморозки', + 'status' => AdCampaign::STATUS_RUNNING, + 'audience_days' => 30, + 'use_uploaded_list' => true, + 'frequency' => 15, + 'estimated_impressions' => $paidImpressions, + 'paid_impressions' => $paidImpressions, + ]); +} + +/** Активных холдов у тенанта не осталось. */ +function activeHoldCount(int $tenantId): int +{ + return AdWalletHold::where('tenant_id', $tenantId) + ->where('status', AdWalletHold::STATUS_ACTIVE)->count(); +} + +/** + * Жизненный цикл заморозки рекламного кошелька. + * + * Заморозка ставится ОДИН раз при запуске кампании на всю смету показов, а + * дальше должна ТАЯТЬ по мере фактических списаний: иначе одни и те же деньги + * считаются дважды (ушли с баланса и остались в заморозке), свободный остаток + * уходит в минус, а AdWalletGate::isSolvent() объявляет клиента неплатёжеспособным + * после первого же суточного списания и AdStopAll глушит все его кампании. + */ +it('melts the active hold while charging so free balance is not double-counted', function () { + $tenant = Tenant::factory()->create(); + AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '1000.00', 'frozen_rub' => '0.00']); + $svc = app(AdWalletService::class); + + $svc->freeze($tenant->id, 'yandex', 'campaign', 1, '1000.00'); + $svc->charge($tenant->id, 'yandex', 'campaign', 1, '200.00', 'yandex-imp:1:100'); + + $wallet = AdWallet::where('tenant_id', $tenant->id)->first(); + + // Списанные 200 ₽ ушли с баланса и ОДНОВРЕМЕННО перестали быть зарезервированы. + expect($wallet->balance_rub)->toBe('800.00') + ->and($wallet->frozen_rub)->toBe('800.00'); + + // Свободно = 0 (вся смета ещё занята кампанией), но клиент платёжеспособен — + // кампанию не должно глушить после первого списания. + expect(app(AdWalletGate::class)->isSolvent($tenant->id))->toBeTrue(); + + // Сам холд тоже похудел — иначе финальный release вернул бы больше, чем занято. + expect(AdWalletHold::where('tenant_id', $tenant->id)->where('source_id', 1)->first()->amount_rub) + ->toBe('800.00'); +}); + +// ВЫХОД 1 — кампания открутила смету и помечена «завершена». +it('releases the remaining hold when the campaign completes', function () { + $tenant = Tenant::factory()->create(); + $svc = app(AdWalletService::class); + $svc->topup($tenant->id, '10000.00', 'yandex', 'тест'); + $campaign = makeHoldLifecycleCampaign($tenant->id, 10000); + + // Заморожено с запасом (1500 ₽) над фактической стоимостью сметы (10000 показов × 120 ₽/1000 = 1200 ₽). + $svc->freeze($tenant->id, 'yandex', 'campaign', $campaign->id, '1500.00'); + + app(CampaignImpressionCharger::class)->charge($campaign, 10000); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_COMPLETED) + ->and(AdWallet::where('tenant_id', $tenant->id)->first()->frozen_rub)->toBe('0.00') + ->and(activeHoldCount($tenant->id))->toBe(0); +}); diff --git a/app/tests/Feature/Advertising/AdvertisingCampaignPauseResumeTest.php b/app/tests/Feature/Advertising/AdvertisingCampaignPauseResumeTest.php index 79baf524..f90449a5 100644 --- a/app/tests/Feature/Advertising/AdvertisingCampaignPauseResumeTest.php +++ b/app/tests/Feature/Advertising/AdvertisingCampaignPauseResumeTest.php @@ -191,3 +191,72 @@ it('returns 404 resuming another tenant campaign', function () { $this->postJson("/api/advertising/campaigns/{$campaignB->id}/resume")->assertStatus(404); }); + +// ── ВЫХОД 4: пауза снимает заморозку, возобновление морозит остаток заново ── +// Решение владельца 27.07.2026: на паузе деньги клиента свободны — он может +// потратить их на другую рекламу; при возобновлении резервируем остаток сметы. + +it('ВЫХОД 4: пауза возвращает заморозку в свободные деньги', function () { + config(['services.yandex_direct.enabled' => false]); + $svc = app(App\Services\Advertising\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, + 'estimated_impressions' => 10000, + 'paid_impressions' => 10000, + 'charged_client_rub' => '200.00', + ]); + // Остаток сметы: 10000 показов × 120 ₽/1000 = 1200 ₽, из них 200 ₽ уже списано. + $svc->freeze($this->tenant->id, 'yandex', 'campaign', $campaign->id, '1000.00'); + + $this->postJson("/api/advertising/campaigns/{$campaign->id}/pause")->assertOk(); + + expect(App\Models\AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub)->toBe('0.00'); +}); + +it('ВЫХОД 4: возобновление снова морозит неоткрученный остаток сметы', function () { + config(['services.yandex_direct.enabled' => false]); + $svc = app(App\Services\Advertising\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_PAUSED, + 'estimated_impressions' => 10000, + 'paid_impressions' => 10000, + 'charged_client_rub' => '200.00', + ]); + + $this->postJson("/api/advertising/campaigns/{$campaign->id}/resume")->assertOk(); + + expect(App\Models\AdWallet::where('tenant_id', $this->tenant->id)->first()->frozen_rub)->toBe('1000.00'); +}); + +it('ВЫХОД 4: возобновление при нехватке денег отказывает понятно и оставляет паузу', function () { + config(['services.yandex_direct.enabled' => false]); + $svc = app(App\Services\Advertising\AdWalletService::class); + $svc->topup($this->tenant->id, '500.00', 'yandex', 'тест'); // меньше остатка 1000 ₽ + + $campaign = AdCampaign::create([ + 'tenant_id' => $this->tenant->id, + 'name' => 'Кампания без денег на возобновление', + 'audience_days' => 10, + 'status' => AdCampaign::STATUS_PAUSED, + 'estimated_impressions' => 10000, + 'paid_impressions' => 10000, + 'charged_client_rub' => '200.00', + ]); + + $this->postJson("/api/advertising/campaigns/{$campaign->id}/resume") + ->assertStatus(409) + ->assertJsonPath('message', 'Не хватает денег на рекламном кошельке, чтобы возобновить кампанию. Пополните кошелёк.'); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_PAUSED); +}); diff --git a/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php b/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php index c865ce0d..7f9b97e3 100644 --- a/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php +++ b/app/tests/Feature/Advertising/CampaignModerationAndStopTest.php @@ -6,7 +6,9 @@ use App\Events\AdvertisingStopped; use App\Jobs\SyncCampaignModerationJob; use App\Models\AdCampaign; use App\Models\AdCampaignAd; +use App\Models\AdWallet; use App\Models\Tenant; +use App\Services\Advertising\AdWalletService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Http; use Tests\Concerns\SharesSupplierPdo; @@ -175,3 +177,82 @@ it('AdvertisingStopped не трогает кампанию чужого тен expect($campaignA->status)->toBe(AdCampaign::STATUS_STOPPED_NO_FUNDS); expect($campaignB->status)->toBe(AdCampaign::STATUS_RUNNING); }); + +// ВЫХОД 2 — Яндекс отклонил объявление. Показов не было, деньги не тратились → +// заморозка возвращается клиенту полностью. +it('ВЫХОД 2: отклонение модерацией снимает заморозку кампании', function () { + config([ + 'services.yandex_direct.enabled' => true, + 'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com', + 'services.yandex_direct.token' => 'DIRTOKEN', + ]); + Http::fake([ + '*/json/v5/ads' => Http::response(['result' => ['Ads' => [ + ['Id' => 557, 'Status' => 'REJECTED', 'State' => 'OFF', 'StatusClarification' => 'Не та тематика'], + ]]], 200), + ]); + + $tenant = Tenant::factory()->create(); + $svc = app(AdWalletService::class); + $svc->topup($tenant->id, '3000.00', 'yandex', 'тест'); + + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Кампания под отказ', + 'status' => AdCampaign::STATUS_PENDING_MODERATION, + 'yandex_campaign_id' => 227, + 'weekly_budget_rub' => '500.00', + 'audience_days' => 10, + 'use_uploaded_list' => true, + ]); + AdCampaignAd::create([ + 'tenant_id' => $tenant->id, + 'campaign_id' => $campaign->id, + 'title' => 'Заголовок', + 'text' => 'Текст объявления', + 'href' => 'https://example.test', + 'yandex_ad_id' => 557, + 'moderation_status' => 'MODERATION', + ]); + $svc->freeze($tenant->id, 'yandex', 'campaign', $campaign->id, '1200.00'); + + app(SyncCampaignModerationJob::class)->handle(); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_REJECTED) + ->and(AdWallet::where('tenant_id', $tenant->id)->first()->frozen_rub)->toBe('0.00'); +}); + +// ВЫХОД 3 — деньги кончились, вся реклама тенанта заглушена. Держать заморозку +// на мёртвой кампании бессмысленно — она только занижает свободный остаток. +it('ВЫХОД 3: остановка без средств снимает заморозку кампании', function () { + config([ + 'services.yandex_direct.enabled' => true, + 'services.yandex_direct.base_url' => 'https://api-sandbox.direct.yandex.com', + 'services.yandex_direct.token' => 'DIRTOKEN', + ]); + Http::fake([ + '*/json/v5/campaigns' => Http::response(['result' => []], 200), + ]); + + $tenant = Tenant::factory()->create(); + $svc = app(AdWalletService::class); + $svc->topup($tenant->id, '3000.00', 'yandex', 'тест'); + + $campaign = AdCampaign::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Кампания без денег', + 'status' => AdCampaign::STATUS_RUNNING, + 'yandex_campaign_id' => 228, + 'weekly_budget_rub' => '500.00', + 'audience_days' => 10, + 'use_uploaded_list' => true, + ]); + $svc->freeze($tenant->id, 'yandex', 'campaign', $campaign->id, '1200.00'); + + event(new AdvertisingStopped($tenant->id)); + + $campaign->refresh(); + expect($campaign->status)->toBe(AdCampaign::STATUS_STOPPED_NO_FUNDS) + ->and(AdWallet::where('tenant_id', $tenant->id)->first()->frozen_rub)->toBe('0.00'); +}); diff --git a/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php b/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php index 589a7161..2a48da33 100644 --- a/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php +++ b/app/tests/Feature/Advertising/ChargeCampaignSpendJobTest.php @@ -96,10 +96,13 @@ it('stops all advertising when the charge leaves the tenant insolvent', function fakeYandexImpressionsReport(); $tenant = Tenant::factory()->create(); - // Малый баланс, крупная заморозка → после списания 300.00 баланс (100.00) уйдёт ниже frozen (200.00). + // Заморозка ТАЕТ вместе со списанием по своей кампании, поэтому нехватку денег + // создаёт ЧУЖОЙ резерв: у клиента запущена вторая кампания с холдом 200.00 ₽, + // а первая съедает 300.00 ₽ из 400.00 ₽ → на вторую денег уже нет (100 < 200) + // → стоп всей рекламы тенанта. app(AdWalletService::class)->topup($tenant->id, '400.00', 'yandex', 'тест'); $campaign = makeRunningCampaign($tenant->id); - app(AdWalletService::class)->freeze($tenant->id, 'yandex', 'campaign', $campaign->id, '200.00'); + app(AdWalletService::class)->freeze($tenant->id, 'yandex', 'campaign', $campaign->id + 1, '200.00'); app(ChargeCampaignSpendJob::class)->handle();