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>
87 lines
4.2 KiB
PHP
87 lines
4.2 KiB
PHP
<?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);
|
||
});
|