Files
portal/app/tests/Feature/Obzvon/AdWalletMinusTest.php
T
Дмитрий 42ebbe769c feat: обзвон — кошелёк уходит в минус только по разрешению канала
Разговор обзвона, начатый при пустом счёте, теперь списывается целиком, счёт
уходит в минус, и долг остаётся за клиентом — решение владельца Р38, требование
Т61а. Числа из спеки: было 12 рублей, списали 102 — на счету минус 90, а в
летописи одна строка на 102.

Разрешение выдано поимённо, белым списком: только ai_call. У яндекса, СМС и
телеграма поведение не изменилось ни на копейку — списание по-прежнему упирается
в ноль. Доказано A/B на одном дереве: 424, 425 и 330 тестов трёх каналов дали
ровно те же числа и с правкой, и без неё.

Проверка платёжеспособности научилась называть причину остановки: долг по
обзвону и непокрытые заморозки — разные вещи. Сам вердикт isSolvent не изменён
ни байтом: по решению Р56 минус гасит клиенту всю рекламу, и письмо задачи З-1.7
обязано назвать обе остановки сразу. Различать нужно, чтобы назвать, а не чтобы
смягчить приговор.

Попутно, и это касается уже работающих денег рекламы: возврат refund был
единственным из пяти методов, кто не ставил контекст клиента. Замерено запуском,
а не чтением — под боевой ролью без контекста метод падал ModelNotFoundException,
а не молчал. Живым деньгам не грозило: единственный зовущий, PollClientSmsDelivery
Command, ставит контекст сам во внешней транзакции. Теперь ставит и сам метод.

Обрезка нулём для трёх старых каналов сохранена намеренно, но перестала быть
молчаливой: на ней пишется предупреждение ad_wallet.charge_clamped_to_zero. До
сих пор съеденная разница между летописью и остатком пропадала без следа.

Сторожа: tests/Feature/Obzvon/AdWalletMinusTest.php и AdWalletMinusRaceTest.php.
Все показаны красными пятью поломками. Гонка проверена параллельным прогоном
шести и четырёх настоящих процессов, а не рассуждением: без замка по строке
кошелька теряется пять списаний из шести.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:10:37 +03:00

248 lines
13 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\AdWallet;
use App\Models\AdWalletTransaction;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletGate;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
/**
* З-1.2. Кошелёк уходит в минус — ТОЛЬКО по разрешению канала.
*
* Решение владельца Р38 / требование Т61а: разговор, начатый при пустом счёте,
* списывается ЦЕЛИКОМ, счёт уходит в минус, долг остаётся за клиентом.
* Числа из спеки: было 12 ₽, наговорили на 102 ₽ → на счету минус 90 ₽.
*
* 🔴 Файл `AdWalletService` общий на ЧЕТЫРЕ канала, и три из них — живые деньги
* живых клиентов. Для `yandex`, `sms`, `telegram` поведение обязано остаться
* прежним до копейки: упираться в ноль. Половина сторожей ниже караулит именно
* это, а не новую способность.
*/
/** @return array{0: Tenant, 1: AdWalletService} тенант с кошельком на заданную сумму */
function z12Koshelek(string $balance): array
{
$tenant = Tenant::factory()->create();
AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => $balance, 'frozen_rub' => '0.00']);
return [$tenant, app(AdWalletService::class)];
}
function z12Balance(int $tenantId): string
{
return (string) AdWallet::where('tenant_id', $tenantId)->first()->balance_rub;
}
/**
* Сумма строк летописи, которые ДВИГАЮТ баланс. Заморозка и её снятие баланс не
* трогают (они переставляют деньги между «свободно» и «заперто»), поэтому в
* сверку с остатком не входят — иначе сверка врала бы на ровном месте.
*/
function z12SummaLetopisi(int $tenantId): string
{
$rows = AdWalletTransaction::where('tenant_id', $tenantId)
->whereIn('type', [
AdWalletTransaction::TYPE_TOPUP,
AdWalletTransaction::TYPE_CHARGE,
AdWalletTransaction::TYPE_REFUND,
])->orderBy('id')->get();
$sum = '0.00';
foreach ($rows as $row) {
$sum = bcadd($sum, (string) $row->amount_rub, 2);
}
return $sum;
}
it('обзвон: было 12 ₽, списали 102 ₽ — на счету минус 90 ₽, в летописи ОДНА строка на 102 ₽', function () {
[$tenant, $svc] = z12Koshelek('12.00');
$svc->charge($tenant->id, 'ai_call', 'obzvon_call', 1, '102.00', 'ai_call:1');
expect(z12Balance($tenant->id))->toBe('-90.00');
$rows = AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->get();
expect($rows)->toHaveCount(1)
->and((string) $rows->first()->amount_rub)->toBe('-102.00')
// Строка летописи обязана показывать ТОТ ЖЕ минус, что и счёт: иначе долг
// виден на счёте, но не виден в истории — и сверка разойдётся.
->and((string) $rows->first()->balance_rub_after)->toBe('-90.00');
});
it('три живых канала при том же раскладе по-прежнему упираются в ноль', function (string $channel) {
[$tenant, $svc] = z12Koshelek('12.00');
$svc->charge($tenant->id, $channel, 'campaign', 1, '102.00', $channel.':1');
expect(z12Balance($tenant->id))->toBe('0.00');
$row = AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->first();
// Летопись у трёх каналов тоже прежняя: полная сумма и остаток «ноль».
expect((string) $row->amount_rub)->toBe('-102.00')
->and((string) $row->balance_rub_after)->toBe('0.00');
})->with(['yandex', 'sms', 'telegram']);
it('сумма строк летописи в точности равна изменению остатка — включая уход в минус', function () {
[$tenant, $svc] = z12Koshelek('0.00');
$bylo = z12Balance($tenant->id);
$svc->topup($tenant->id, '12.00', 'ai_call', 'Пополнение');
$svc->charge($tenant->id, 'ai_call', 'obzvon_call', 1, '102.00', 'ai_call:1'); // → минус 90
$svc->refund($tenant->id, 'ai_call', 'obzvon_call', 1, '2.00', 'ai_call:r:1', 'Возврат');
$svc->charge($tenant->id, 'ai_call', 'obzvon_call', 2, '0.37', 'ai_call:2');
$stalo = z12Balance($tenant->id);
expect($stalo)->toBe('-88.37')
->and(z12SummaLetopisi($tenant->id))->toBe(bcsub($stalo, $bylo, 2));
});
it('повторное списание с тем же ключом события денег не двигает — и когда счёт уже в минусе', function () {
[$tenant, $svc] = z12Koshelek('12.00');
$svc->charge($tenant->id, 'ai_call', 'obzvon_call', 1, '102.00', 'ai_call:1');
$svc->charge($tenant->id, 'ai_call', 'obzvon_call', 1, '102.00', 'ai_call:1'); // тот же ключ
expect(z12Balance($tenant->id))->toBe('-90.00')
->and(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(1);
});
it('копейки не теряются: 200 списаний по 7 копеек сходятся до копейки', function () {
[$tenant, $svc] = z12Koshelek('12.00');
for ($i = 1; $i <= 200; $i++) {
$svc->charge($tenant->id, 'ai_call', 'obzvon_call', $i, '0.07', 'ai_call:kop:'.$i);
}
// 200 × 0.07 = 14.00 ровно. В дробных числах вышло бы 14.000000000000002,
// и остаток разъехался бы на копейку — здесь обязан быть точный минус 2 ₽.
expect(z12Balance($tenant->id))->toBe('-2.00')
->and(z12SummaLetopisi($tenant->id))->toBe('-14.00');
// Цепочка остатков в летописи не рвётся ни на одном шаге.
$rows = AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->orderBy('id')->get();
$ozhidaem = '12.00';
foreach ($rows as $row) {
$ozhidaem = bcsub($ozhidaem, '0.07', 2);
expect((string) $row->balance_rub_after)->toBe($ozhidaem);
}
});
it('разрешение на минус выдано ТОЛЬКО обзвону — незнакомый канал в минус не пускают', function () {
[$tenant, $svc] = z12Koshelek('12.00');
// Канал, которого нет ни в одном списке: если разрешение когда-нибудь станет
// «по умолчанию можно», этот сторож покраснеет первым.
$svc->charge($tenant->id, 'vk', 'campaign', 1, '102.00', 'vk:1');
expect(z12Balance($tenant->id))->toBe('0.00');
});
/*
|--------------------------------------------------------------------------
| Проверка платёжеспособности умеет РАЗЛИЧАТЬ долг и непокрытые заморозки
|--------------------------------------------------------------------------
|
| 🔴 Вердикт при этом НЕ меняется: по разделу 5 плана и решению Р56 минус на
| счету гасит клиенту ВСЮ рекламу, и письмо по З-1.7 обязано назвать обе
| остановки сразу. Если бы долг перестал гасить рекламу, письмо про «обе
| остановки» стало бы враньём. Различать — значит уметь НАЗВАТЬ причину,
| а не смягчить приговор.
|
*/
it('вердикт платёжеспособности на долге не изменился — клиент по-прежнему остановлен', function () {
[$tenant, $svc] = z12Koshelek('12.00');
$gate = app(AdWalletGate::class);
expect($gate->isSolvent($tenant->id))->toBeTrue();
$svc->charge($tenant->id, 'ai_call', 'obzvon_call', 1, '102.00', 'ai_call:1');
expect($gate->isSolvent($tenant->id))->toBeFalse();
});
// Значения причин записаны здесь СТРОКАМИ, а не через константы класса: сторож
// караулит и сам набор слов тоже — переименование причины обязано быть замечено.
it('причины остановки названы поимённо и совпадают с вердиктом', function (string $balance, string $frozen, array $ozhidaem) {
$tenant = Tenant::factory()->create();
AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => $balance, 'frozen_rub' => $frozen]);
$gate = app(AdWalletGate::class);
expect($gate->prichinyOstanovki($tenant->id))->toBe($ozhidaem)
// Разбор причин обязан сходиться со старым вердиктом до последнего случая:
// иначе «различать» незаметно превратилось бы в «судить по-новому».
->and($gate->isSolvent($tenant->id))->toBe($ozhidaem === []);
})->with([
'всё хорошо' => ['1000.00', '0.00', []],
'заморозка ровно по остатку' => ['1000.00', '1000.00', []],
'долг по обзвону, заморозок нет' => ['-90.00', '0.00', ['dolg']],
'остатка не хватает на заморозки' => ['100.00', '2500.00', ['zamorozki']],
'и долг, и непокрытые заморозки' => ['-90.00', '50.00', ['dolg', 'zamorozki']],
]);
it('имена причин — те же строки, что раздаёт класс', function () {
expect(AdWalletGate::PRICHINA_DOLG)->toBe('dolg')
->and(AdWalletGate::PRICHINA_ZAMOROZKI)->toBe('zamorozki');
});
it('кошелька нет — причин остановки нет', function () {
$tenant = Tenant::factory()->create();
expect(app(AdWalletGate::class)->prichinyOstanovki($tenant->id))->toBe([])
->and(app(AdWalletGate::class)->isSolvent($tenant->id))->toBeTrue();
});
/*
|--------------------------------------------------------------------------
| Контекст клиента у возврата
|--------------------------------------------------------------------------
|
| 🔴 Замерено запуском 05.08.2026: под боевой ролью БЕЗ контекста клиента
| `refund()` падал `ModelNotFoundException`, а `charge()` в тех же условиях
| проходил — контекст он ставит сам. Живым деньгам это не грозило: единственный
| зовущий (PollClientSmsDeliveryCommand) ставит контекст сам, во ВНЕШНЕЙ
| транзакции. Но заявленное в шапке класса правило «контекст — забота этого
| сервиса, а не зовущего» у возврата нарушалось, и следующий зовущий (возврат
| по обзвону) напоролся бы первым.
|
*/
function z12PodBoevoyRolyu(callable $chto): void
{
DB::statement('SET ROLE crm_app_user');
try {
$chto();
} finally {
DB::statement('RESET ROLE');
}
}
it('возврат работает под боевой ролью БЕЗ контекста клиента — как и остальные четыре метода', function () {
$tenant = Tenant::factory()->create();
AdWallet::create(['tenant_id' => $tenant->id, 'balance_rub' => '100.00', 'frozen_rub' => '0.00']);
// Гасим контекст явно: его мог оставить любой предыдущий вызов в этой же транзакции.
DB::statement("SET LOCAL app.current_tenant_id = ''");
z12PodBoevoyRolyu(fn () => app(AdWalletService::class)->refund(
$tenant->id, 'sms', 'campaign', 1, '10.00', 'z12:refund:1', 'Возврат за недоставленное СМС',
));
// Читаем суперюзером — прибор не должен зависеть от той же защиты, что проверяем.
expect(z12Balance($tenant->id))->toBe('110.00');
});