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'); });