298afb835a
Сессия 1 плана docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md. Бэкенд-ядро — зеркало готового СМС-модуля. Робота ещё нет — он в Сессии 2. - 8 таблиц client_tg_* с RLS tenant_isolation и GRANT crm_app_user; справочные tariffs/settings без RLS с guarded-GRANT. rls-reviewer PASS 8 из 8. - 7 моделей ClientTg + связи campaign->phones. - Ступенчатая цена TelegramTariffService — ₽ за показ по объёму; тариф = потолок, точную стоимость считает МТС. - Сборка аудитории TelegramAudienceService — сделки за период / своя база / свой список, нормализация телефона, стоп-лист и дедуп; отдаёт кандидатов, реальный охват узнаёт робот после загрузки в МТС. - Канал кошелька telegram: перенесён общий AdWallet со свежей версии портала — модели, сервис, миграции; списание и заморозка по каналу telegram под тестами, charge не бросает и не уходит в минус, нехватка средств ловится freeze до списания. Проверки: 26 из 26 Pest зелёные, larastan 0, gitleaks чисто. db/CHANGELOG_schema.md v8.86 — номер предварительный, ветка отстаёт от main. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
73 lines
3.5 KiB
PHP
73 lines
3.5 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Exceptions\Billing\InsufficientBalanceException;
|
||
use App\Models\AdWallet;
|
||
use App\Models\AdWalletTransaction;
|
||
use App\Models\Tenant;
|
||
use App\Services\Advertising\AdWalletService;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
|
||
uses(RefreshDatabase::class);
|
||
|
||
const TG_CHANNEL = 'telegram';
|
||
|
||
it('замораживает резерв под Telegram-кампанию и учитывает его по каналу', function () {
|
||
$tenant = Tenant::factory()->create();
|
||
$svc = app(AdWalletService::class);
|
||
|
||
$svc->topup($tenant->id, '1000.00', TG_CHANNEL, 'Пополнение');
|
||
$svc->freeze($tenant->id, TG_CHANNEL, 'client_tg_campaign', 5, '420.00');
|
||
|
||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||
expect((string) $wallet->balance_rub)->toBe('1000.00')
|
||
->and((string) $wallet->frozen_rub)->toBe('420.00')
|
||
->and(AdWalletTransaction::where('tenant_id', $tenant->id)->where('channel', TG_CHANNEL)->count())->toBe(2); // topup + freeze
|
||
});
|
||
|
||
it('freeze стережёт нехватку средств и бросает ДО списания', function () {
|
||
$tenant = Tenant::factory()->create();
|
||
$svc = app(AdWalletService::class);
|
||
|
||
$svc->topup($tenant->id, '300.00', TG_CHANNEL, 'Пополнение');
|
||
|
||
// free = balance − frozen = 300 < 400 → бросок ДО каких-либо списаний
|
||
expect(fn () => $svc->freeze($tenant->id, TG_CHANNEL, 'client_tg_campaign', 1, '400.00'))
|
||
->toThrow(InsufficientBalanceException::class);
|
||
|
||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||
expect((string) $wallet->frozen_rub)->toBe('0.00'); // ничего не заморозилось
|
||
});
|
||
|
||
it('charge НЕ бросает и обнуляет баланс, не уходя в минус', function () {
|
||
$tenant = Tenant::factory()->create();
|
||
$svc = app(AdWalletService::class);
|
||
|
||
$svc->topup($tenant->id, '300.00', TG_CHANNEL, 'Пополнение');
|
||
|
||
// charge не бросает — потому проверка средств обязана быть ДО (через freeze).
|
||
$svc->charge($tenant->id, TG_CHANNEL, 'client_tg_campaign', 1, '400.00', 'tg:1:test');
|
||
|
||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||
expect((string) $wallet->balance_rub)->toBe('0.00') // пол по нулю, не минус
|
||
->and(AdWalletTransaction::where('tenant_id', $tenant->id)
|
||
->where('channel', TG_CHANNEL)->where('type', AdWalletTransaction::TYPE_CHARGE)->exists())->toBeTrue();
|
||
});
|
||
|
||
it('полный поток telegram: topup → freeze → charge, идемпотентно по external_key', function () {
|
||
$tenant = Tenant::factory()->create();
|
||
$svc = app(AdWalletService::class);
|
||
|
||
$svc->topup($tenant->id, '1000.00', TG_CHANNEL, 'Пополнение');
|
||
$svc->freeze($tenant->id, TG_CHANNEL, 'client_tg_campaign', 7, '420.00');
|
||
$svc->charge($tenant->id, TG_CHANNEL, 'client_tg_campaign', 7, '420.00', 'tg:7:2026-07-27');
|
||
// повторное списание тем же external_key — идемпотентно, ничего не меняет
|
||
$svc->charge($tenant->id, TG_CHANNEL, 'client_tg_campaign', 7, '420.00', 'tg:7:2026-07-27');
|
||
|
||
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
|
||
expect((string) $wallet->balance_rub)->toBe('580.00')
|
||
->and(AdWalletTransaction::where('tenant_id', $tenant->id)
|
||
->where('channel', TG_CHANNEL)->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(1);
|
||
});
|