447ad6af49
Приёмка 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>
79 lines
3.3 KiB
PHP
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);
|
|
});
|