42ebbe769c
Разговор обзвона, начатый при пустом счёте, теперь списывается целиком, счёт уходит в минус, и долг остаётся за клиентом — решение владельца Р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>
248 lines
13 KiB
PHP
248 lines
13 KiB
PHP
<?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');
|
||
});
|