Files
portal/app/tests/Feature/Advertising/AdvertisingWalletHistoryTest.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

79 lines
3.3 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\AdWallet;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
/**
* GET /api/advertising/wallet/transactions — история операций рекламного кошелька.
*
* Приёмка 03.08.2026: на экране кошелька вместо истории стояло «появится позже», и клиент
* не мог объяснить себе ни одну заморозку. Ручки списка не было вовсе.
*
* Сторож держит два правила: клиент видит СВОИ движения человеческими словами и НЕ видит
* чужие — рекламные деньги идут по тем же путям, что и остальные, и разделение клиентов
* здесь так же обязательно.
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
it('отдаёт движения кошелька, свежие сверху', function () {
AdWallet::create(['tenant_id' => $this->tenant->id, 'balance_rub' => '0.00', 'frozen_rub' => '0.00']);
$svc = app(AdWalletService::class);
$svc->topup($this->tenant->id, '10000.00', 'yandex', 'Пополнение по счёту №12');
$svc->freeze($this->tenant->id, 'yandex', 'campaign', 6, '3333.36');
$svc->release($this->tenant->id, 'yandex', 'campaign', 6);
$response = $this->getJson('/api/advertising/wallet/transactions');
$response->assertOk();
$items = $response->json('items');
expect($items)->toHaveCount(3);
// Свежие сверху — клиент читает историю сверху вниз.
expect($items[0]['type'])->toBe('release')
->and($items[1]['type'])->toBe('freeze')
->and($items[2]['type'])->toBe('topup');
expect($items[1]['amount_rub'])->toBe('-3333.36')
->and($items[1]['description'])->toContain('6')
->and($items[0]['amount_rub'])->toBe('3333.36');
// Каждое движение подписано человеческими словами, а не кодом типа.
expect($items[1])->toHaveKey('title')
->and($items[1]['title'])->not->toBe('freeze');
});
it('чужие движения не отдаёт', function () {
$chuzhoy = Tenant::factory()->create();
AdWallet::create(['tenant_id' => $chuzhoy->id, 'balance_rub' => '0.00', 'frozen_rub' => '0.00']);
app(AdWalletService::class)->topup($chuzhoy->id, '99999.00', 'yandex', 'Чужое пополнение');
$response = $this->getJson('/api/advertising/wallet/transactions');
$response->assertOk();
expect($response->json('items'))->toBe([]);
});
it('пустая история — пустой список, а не ошибка', function () {
$this->getJson('/api/advertising/wallet/transactions')
->assertOk()
->assertJson(['items' => []]);
});
it('без входа не отдаёт ничего', function () {
auth()->logout();
$this->getJson('/api/advertising/wallet/transactions')->assertStatus(401);
});