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>
This commit is contained in:
@@ -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. Деньги не трогает. Если Директ недоступен — логируем и
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user