$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); });