Files
portal/app/tests/Feature/Advertising/CampaignImpressionChargerTest.php
T
Дмитрий 03db14ab4a fix(реклама показы): правки по финальному ревью — потолок сметы, монотонный биллинг, свежий отчёт
Задача 10, фиксы найденного ревьюером в денежном ядре Части 4:
- CampaignLauncher при запуске ставит paid_impressions = смета показов. Это потолок
  биллинга: счётчик показов ограничивает списание сметой и завершает кампанию по её
  достижении. Раньше поле не ставилось никогда → списание шло без потолка до
  SpendLimit ×guard и кампания не завершалась.
- CampaignImpressionCharger держит charged_client_rub монотонным high-water: при
  просадке накопительного отчёта Директа база не опускается → нет двойного списания
  при последующем росте.
- YandexDirectClient getCampaignImpressions шлёт уникальный ReportName на каждый
  вызов, иначе Яндекс отдаёт кэш ALL_TIME-отчёта и списание замирает недобор.
- effectiveCpm без float, через bcmath.

Реклама-модуль 196/196 зелёный. Заморозка при завершении не снимается это отдельная
пред-существующая дыра модели кошелька release не зовётся нигде вынесено владельцу.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 10:17:06 +03:00

185 lines
7.9 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\AdWalletTransaction;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\Advertising\CampaignImpressionCharger;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
function makePokazyCampaign(int $tenantId, ?int $paidImpressions, ?string $clientCpmRub = null): 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,
'client_cpm_rub' => $clientCpmRub,
]);
}
it('charges the client for actually delivered impressions', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
$campaign = makePokazyCampaign($tenant->id, 10000);
app(CampaignImpressionCharger::class)->charge($campaign, 2500);
$campaign->refresh();
expect($campaign->charged_client_rub)->toBe('300.00')
->and($campaign->delivered_impressions)->toBe(2500)
->and($campaign->status)->toBe(AdCampaign::STATUS_RUNNING);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($wallet->balance_rub)->toBe('9700.00');
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(1);
});
it('does not double-charge when called again with the same delivered count', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
$campaign = makePokazyCampaign($tenant->id, 10000);
$charger = app(CampaignImpressionCharger::class);
$charger->charge($campaign, 2500);
$charger->charge($campaign->refresh(), 2500);
$campaign->refresh();
expect($campaign->charged_client_rub)->toBe('300.00');
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(1);
});
it('charges only the delta on a later top-up call', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
$campaign = makePokazyCampaign($tenant->id, 10000);
$charger = app(CampaignImpressionCharger::class);
$charger->charge($campaign, 2500);
$charger->charge($campaign->refresh(), 6000);
$campaign->refresh();
expect($campaign->charged_client_rub)->toBe('720.00')
->and($campaign->delivered_impressions)->toBe(6000);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($wallet->balance_rub)->toBe('9280.00'); // 10000 720
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(2);
});
it('marks the campaign completed once delivered reaches the paid amount', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
$campaign = makePokazyCampaign($tenant->id, 5000);
app(CampaignImpressionCharger::class)->charge($campaign, 5000);
$campaign->refresh();
expect($campaign->charged_client_rub)->toBe('600.00')
->and($campaign->delivered_impressions)->toBe(5000)
->and($campaign->status)->toBe(AdCampaign::STATUS_COMPLETED);
});
it('caps billable impressions at the paid amount when delivered overshoots', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
$campaign = makePokazyCampaign($tenant->id, 5000);
app(CampaignImpressionCharger::class)->charge($campaign, 8000);
$campaign->refresh();
expect($campaign->charged_client_rub)->toBe('600.00')
->and($campaign->delivered_impressions)->toBe(8000)
->and($campaign->status)->toBe(AdCampaign::STATUS_COMPLETED);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($wallet->balance_rub)->toBe('9400.00'); // 10000 600
});
it('charges by the campaign own CPM when client_cpm_rub is set, not the global default', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
$campaign = makePokazyCampaign($tenant->id, 10000, '90.00');
app(CampaignImpressionCharger::class)->charge($campaign, 2500);
$campaign->refresh();
// 2500 показов × 90.00₽/1000 = 225.00 (не 300.00, как было бы по глобальной цене 120.00).
expect($campaign->charged_client_rub)->toBe('225.00');
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($wallet->balance_rub)->toBe('9775.00');
});
it('records our margin-adjusted yandex cost alongside the client charge (40% margin default)', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
$campaign = makePokazyCampaign($tenant->id, 10000);
app(CampaignImpressionCharger::class)->charge($campaign, 2500);
$campaign->refresh();
// charged_client_rub = 300.00 (дефолт 120.00₽/1000), margin 40% → yandex_cost_rub = 60% от 300.00 = 180.00.
expect($campaign->charged_client_rub)->toBe('300.00')
->and(DB::table('ad_campaigns')->where('id', $campaign->id)->value('yandex_cost_rub'))->toBe('180.00');
});
it('keeps charged_client_rub monotonic when a later report returns fewer impressions', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
$campaign = makePokazyCampaign($tenant->id, 10000);
$charger = app(CampaignImpressionCharger::class);
$charger->charge($campaign, 6000); // 6000 × 120/1000 = 720.00
$charger->charge($campaign->refresh(), 2500); // отчёт «просел» до 2500 — база НЕ опускается
$campaign->refresh();
// charged_client_rub держится на high-water 720.00, а не откатывается к 300.00.
expect($campaign->charged_client_rub)->toBe('720.00')
->and($campaign->delivered_impressions)->toBe(2500); // метрика показов отражает факт
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($wallet->balance_rub)->toBe('9280.00'); // 10000 − 720, повторно не списали
// Повторный рост до 6000 не даёт двойного списания (external_key уже был).
$charger->charge($campaign->refresh(), 6000);
$campaign->refresh();
expect($campaign->charged_client_rub)->toBe('720.00');
expect(AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub)->toBe('9280.00');
});
it('charges nothing for zero delivered impressions', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '10000.00', 'yandex', 'тест');
$campaign = makePokazyCampaign($tenant->id, 5000);
app(CampaignImpressionCharger::class)->charge($campaign, 0);
$campaign->refresh();
expect($campaign->charged_client_rub)->toBe('0.00')
->and($campaign->delivered_impressions)->toBe(0)
->and($campaign->status)->toBe(AdCampaign::STATUS_RUNNING);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($wallet->balance_rub)->toBe('10000.00');
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(0);
});