Files
portal/app/tests/Feature/Advertising/CampaignSmetaNaKartochkeTest.php
T
Дмитрий 447ad6af49 fix(рекламный кошелёк): клиент видит, за что заперты его деньги
Приёмка 03.08.2026 на боевом нашла три дыры вокруг одной и той же суммы: у клиента
заморожены 3 333,36 ₽, и узнать за что было негде. Деньги не терялись — терялось
объяснение, а для клиента, у которого заперта треть кошелька, это неотличимо от пропажи.

1) Денежная плашка врала после паузы и возобновления (Я-Ф1/Я-Ф4 FAIL).
   Она грузилась ОДИН раз, при открытии страницы, и о переменах не узнавала. После
   «Возобновить» экран обещал 9 991 ₽ свободных, когда на деле оставалось 6 657,64 ₽:
   клиент считал новую кампанию на несуществующие деньги и получал отказ, которого
   не ждал. Правда появлялась только после перезагрузки.
   Стало: список кампаний сообщает «деньги подвинулись», экран перечитывает плашку.
   Сигнал шлют ровно те три действия, что двигают деньги: запуск, пауза, возобновление.
   Сторож держит не содержимое плашки (оно зелено и при поломке), а сам факт
   повторного обращения за кошельком после денежного действия.

2) Летопись не фиксировала ни постановку заморозки, ни снятие.
   Заморозка писалась суммой 0,00 ₽ — жёстко зашитой, снятие не писалось вовсе.
   Стало: заморозка пишется настоящей суммой со знаком минус (деньги ушли из
   свободных, баланс не двигается), снятие — отдельной строкой типа release.
   Подпись человеческая и с номером: «Заморозили под кампанию №6».

3) На экране кошелька вместо истории стояло «появится позже».
   Стало: живая история движений — новая ручка GET /api/advertising/wallet/transactions
   (последние 100, свежие сверху, чужие не отдаёт) и лента на экране: подпись
   человеческими словами, сумма со знаком, дата по Москве. У заморозки и её снятия
   отдельная пометка «баланс не изменился», чтобы клиент не принял резерв за списание.

4) Карточка кампании называла не ту сумму.
   Показывалось поле budget_rub — число, которое клиент когда-то вписал сам и которое
   в расчёте денег не участвует вообще: «Бюджет показов 200 ₽» при заморозке 3 333,36 ₽.
   Стало: «Смета показов» — считанная сервером (показы × цена за 1000, та же арифметика,
   что и у заморозки), плюс строка «Зарезервировано» с живой бронёй по этой кампании.

Схема не менялась — все поля летописи уже были.

Проверено: 375 сторожей рекламы, 748 по смежным кошелькам (телеграм и СМС ходят тем же
сервисом), 2042 на фронте — все зелёные. Статанализ 0 ошибок, типы Vue чисты, стиль
в изменённых файлах чист. Все четыре сторожа приняты вырезанием: на старом коде красные.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 21:30:47 +03:00

113 lines
4.7 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\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
/**
* Карточка кампании обязана называть ту сумму, которая заперта на самом деле.
*
* Приёмка 03.08.2026 на боевом: на карточке было написано «Бюджет показов 200 ₽» — это поле
* `budget_rub`, число, которое клиент когда-то вписал сам; в расчёте денег оно не участвует
* вообще. Настоящая смета той кампании — 27 778 показов × 120 ₽/1000 = 3 333,36 ₽, и именно
* она была заморожена. Клиент видел одно число, а заперты у него были другие деньги.
*
* Сторож держит правило: список кампаний отдаёт СЧИТАННУЮ смету и то, сколько по кампании
* сейчас реально зарезервировано.
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
DB::table('ad_settings')->update(['client_cpm_rub' => '120.00']);
});
it('отдаёт настоящую смету, а не число, вписанное клиентом', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'status' => AdCampaign::STATUS_RUNNING,
'name' => 'Реклама от 30.07.2026',
'audience_days' => 30,
'frequency' => 10,
'estimated_impressions' => 27778,
'budget_rub' => '200.00', // то самое число «из воздуха»
'client_cpm_rub' => '120.00',
]);
$items = $this->getJson('/api/advertising/campaigns')->assertOk()->json('data');
$row = collect($items)->firstWhere('id', $campaign->id);
// 27 778 × 120 / 1000 = 3 333,36 ₽
expect($row['smeta_rub'])->toBe('3333.36');
});
it('показывает, сколько по кампании реально зарезервировано', function () {
AdWallet::create(['tenant_id' => $this->tenant->id, 'balance_rub' => '9991.00', 'frozen_rub' => '0.00']);
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'status' => AdCampaign::STATUS_RUNNING,
'name' => 'Реклама от 30.07.2026',
'audience_days' => 30,
'frequency' => 10,
'estimated_impressions' => 27778,
'client_cpm_rub' => '120.00',
]);
app(AdWalletService::class)->freeze($this->tenant->id, 'yandex', 'campaign', (int) $campaign->id, '3333.36');
$items = $this->getJson('/api/advertising/campaigns')->assertOk()->json('data');
$row = collect($items)->firstWhere('id', $campaign->id);
expect($row['frozen_rub'])->toBe('3333.36');
});
it('у кампании без заморозки резерв — ноль, а не пусто', function () {
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'status' => AdCampaign::STATUS_DRAFT,
'name' => 'Черновик',
'audience_days' => 30,
'frequency' => 10,
'estimated_impressions' => 1000,
'client_cpm_rub' => '120.00',
]);
$items = $this->getJson('/api/advertising/campaigns')->assertOk()->json('data');
$row = collect($items)->firstWhere('id', $campaign->id);
expect($row['frozen_rub'])->toBe('0.00')
->and($row['smeta_rub'])->toBe('120.00');
});
it('снятая заморозка в резерве больше не числится', function () {
AdWallet::create(['tenant_id' => $this->tenant->id, 'balance_rub' => '9991.00', 'frozen_rub' => '0.00']);
$campaign = AdCampaign::create([
'tenant_id' => $this->tenant->id,
'status' => AdCampaign::STATUS_PAUSED,
'name' => 'На паузе',
'audience_days' => 30,
'frequency' => 10,
'estimated_impressions' => 27778,
'client_cpm_rub' => '120.00',
]);
$svc = app(AdWalletService::class);
$svc->freeze($this->tenant->id, 'yandex', 'campaign', (int) $campaign->id, '3333.36');
$svc->release($this->tenant->id, 'yandex', 'campaign', (int) $campaign->id);
$items = $this->getJson('/api/advertising/campaigns')->assertOk()->json('data');
$row = collect($items)->firstWhere('id', $campaign->id);
expect($row['frozen_rub'])->toBe('0.00');
});