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>
110 lines
4.9 KiB
PHP
110 lines
4.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\AdWallet;
|
|
use App\Models\AdWalletTransaction;
|
|
use App\Models\Tenant;
|
|
use App\Services\Advertising\AdWalletService;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
|
|
|
/**
|
|
* Летопись рекламного кошелька должна ОБЪЯСНЯТЬ клиенту каждое движение денег.
|
|
*
|
|
* Приёмка 03.08.2026 на боевом: у клиента заморожено 3 333,36 ₽, и узнать за что — негде.
|
|
* Заморозка писалась в летопись суммой 0,00 ₽ (жёстко зашитой), снятие заморозки не писалось
|
|
* вовсе. Деньги при этом не терялись — терялось объяснение, а для клиента, у которого заперта
|
|
* треть кошелька, это неотличимо от пропажи.
|
|
*
|
|
* Сторож держит правило: подвинулась заморозка — в летописи появилась строка с НАСТОЯЩЕЙ
|
|
* суммой и указанием, под какую кампанию.
|
|
*/
|
|
uses(DatabaseTransactions::class);
|
|
|
|
/** @return array{0: Tenant, 1: AdWalletService} */
|
|
function tenantSKoshelkom(string $balance = '10000.00'): array
|
|
{
|
|
$tenant = Tenant::factory()->create();
|
|
AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => $balance, 'frozen_rub' => '0.00']);
|
|
|
|
return [$tenant, app(AdWalletService::class)];
|
|
}
|
|
|
|
/** @return Collection<int, AdWalletTransaction> */
|
|
function letopis(int $tenantId, string $type)
|
|
{
|
|
return AdWalletTransaction::where('tenant_id', $tenantId)->where('type', $type)->get();
|
|
}
|
|
|
|
it('заморозка записана настоящей суммой, а не нулём', function () {
|
|
[$tenant, $svc] = tenantSKoshelkom();
|
|
|
|
$svc->freeze($tenant->id, 'yandex', 'campaign', 6, '3333.36');
|
|
|
|
$rows = letopis($tenant->id, AdWalletTransaction::TYPE_FREEZE);
|
|
expect($rows)->toHaveCount(1);
|
|
|
|
$row = $rows->first();
|
|
// Знак как у списания: деньги ушли из свободных. Сам баланс заморозка не двигает.
|
|
expect((string) $row->amount_rub)->toBe('-3333.36')
|
|
->and((string) $row->balance_rub_after)->toBe('10000.00')
|
|
->and($row->channel)->toBe('yandex')
|
|
->and($row->related_type)->toBe('campaign')
|
|
->and($row->related_id)->toBe(6)
|
|
// Клиент должен понять, ПОД ЧТО заперты деньги, не выходя из кошелька.
|
|
->and($row->description)->toContain('6');
|
|
});
|
|
|
|
it('снятие заморозки записано отдельной строкой с суммой', function () {
|
|
[$tenant, $svc] = tenantSKoshelkom();
|
|
$svc->freeze($tenant->id, 'yandex', 'campaign', 6, '3333.36');
|
|
|
|
$svc->release($tenant->id, 'yandex', 'campaign', 6);
|
|
|
|
$rows = letopis($tenant->id, AdWalletTransaction::TYPE_RELEASE);
|
|
expect($rows)->toHaveCount(1);
|
|
|
|
$row = $rows->first();
|
|
expect((string) $row->amount_rub)->toBe('3333.36')
|
|
->and((string) $row->balance_rub_after)->toBe('10000.00')
|
|
->and($row->related_id)->toBe(6)
|
|
->and($row->description)->toContain('6');
|
|
});
|
|
|
|
it('повторная заморозка той же кампании не плодит вторую строку', function () {
|
|
[$tenant, $svc] = tenantSKoshelkom();
|
|
|
|
$svc->freeze($tenant->id, 'yandex', 'campaign', 6, '3333.36');
|
|
$svc->freeze($tenant->id, 'yandex', 'campaign', 6, '3333.36'); // идемпотентность
|
|
|
|
expect(letopis($tenant->id, AdWalletTransaction::TYPE_FREEZE))->toHaveCount(1);
|
|
});
|
|
|
|
it('снятие несуществующей заморозки летопись не трогает', function () {
|
|
[$tenant, $svc] = tenantSKoshelkom();
|
|
|
|
$svc->release($tenant->id, 'yandex', 'campaign', 777);
|
|
|
|
expect(letopis($tenant->id, AdWalletTransaction::TYPE_RELEASE))->toHaveCount(0);
|
|
});
|
|
|
|
it('пауза и возобновление оставляют читаемую пару строк', function () {
|
|
[$tenant, $svc] = tenantSKoshelkom();
|
|
|
|
$svc->freeze($tenant->id, 'yandex', 'campaign', 6, '3333.36');
|
|
$svc->release($tenant->id, 'yandex', 'campaign', 6); // пауза
|
|
$svc->freeze($tenant->id, 'yandex', 'campaign', 6, '3333.36'); // возобновление
|
|
|
|
// Три события — три строки: заморозили, сняли, заморозили заново.
|
|
$vse = AdWalletTransaction::where('tenant_id', $tenant->id)
|
|
->whereIn('type', [AdWalletTransaction::TYPE_FREEZE, AdWalletTransaction::TYPE_RELEASE])
|
|
->orderBy('id')->get();
|
|
|
|
expect($vse->pluck('type')->all())->toBe([
|
|
AdWalletTransaction::TYPE_FREEZE,
|
|
AdWalletTransaction::TYPE_RELEASE,
|
|
AdWalletTransaction::TYPE_FREEZE,
|
|
]);
|
|
});
|