Files
portal/app/tests/Feature/Advertising/AdWalletHoldLifecycleTest.php
T
Дмитрий dd894a5224 fix(реклама показы): заморозка тает при списании и снимается на всех 4 выходах кампании
Заморозка ставилась один раз при запуске на всю смету показов и не снималась
нигде — 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>
2026-07-27 09:44:12 +03:00

87 lines
4.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\AdCampaign;
use App\Models\AdWallet;
use App\Models\AdWalletHold;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletGate;
use App\Services\Advertising\AdWalletService;
use App\Services\Advertising\CampaignImpressionCharger;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
/** Кампания «за показы» в статусе running с оплаченной сметой. */
function makeHoldLifecycleCampaign(int $tenantId, int $paidImpressions): AdCampaign
{
return AdCampaign::create([
'tenant_id' => $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);
});