5b04d751c0
Сессия 4 плана docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md. Всё в песочнице (TG_SANDBOX): деньги/живой запуск выключены; робот в тестах замокан. 4.1 Авто-режим (пачка): - Таблица client_tg_auto_rule (правило: объявление + budget_cap на пачку, одно на тенанта, RLS). - TelegramAutoAccumulator: копит номера новых лидов в один открытый авто-черновик (audience_kind=list, created_by IS NULL); при наборе пачки (порог client_tg.auto_batch_threshold, по умолчанию 367 кандидатов) доводит смету, ставит кампанию в очередь В РАМКАХ budget_cap_rub и ставит RunTelegramCampaignJob. Ниже порога — только копим. Дедуп номеров на входе. Отличие от СМС-близнеца: СМС шлёт по одной на лид, Telegram копит ПАЧКУ (МТС показывает базе). 4.2 Защитный observer: - DealTelegramObserver на created: freshness-guard (сутки) → ставит AccumulateTelegramLeadJob. 🔴 try/catch(Throwable) НИКОГДА не роняет приём лида (регресс DealCreateTest 10/10 зелёный). - Приёма-джоб уводит работу с роли приёмщика (crm_supplier_worker, без GRANT на auto_rule) на очередь (crm_app_user) с tenant-контекстом — иначе на проде был бы «тихий ноль». ПДн: телефон в payload не кладём, перечитываем по deal_id. Зеркало DealSmsObserver + SendAutoSmsForDealJob. 4.3 Своё имя/бренд + помесячная оплата: - Таблица client_tg_senders (одно имя на тенанта, RLS; app_user S/I/U, supplier S, admin S/U). - TelegramSenderService — жизненный цикл: клиент requestSender (заявка+бронь месячной платы) / disableSender; админ approve (списание+снятие брони+paid_until +1мес) / reject / disableByAdmin. - ChargeTgNameFeeJob (ежедневно 05:10 МСК, routes/console.php): помесячное списание, проверка free≥cost ДО списания, идемпотентно по периоду (external_key), долг > grace (29д) → suspended, кросс-тенантно через pgsql_supplier, деньги под SET LOCAL. В песочнице деньги не двигаются. Лёгкое зеркало client_sms_senders + ChargeSmsNameFeeJob (без документов/операторов — согласование в Телеграме уточняется живой разведкой §8 спеки; здесь фиксируем денежный цикл). 4.4 Админ-тарифы/настройки: - Api\Admin\TgTariffController (GET/PUT /api/admin/telegram/tariffs|settings) под saas-admin,admin-db. - Фронт: api/admin.ts (fetchTgTariffs/updateTgTariffs/updateTgSettings), AdminTgView.vue (правка ступеней + плата за имя/грейс), маршрут /admin/telegram. Зеркало AdminSmsView (без секции имён). Проверки: 89/89 Pest ClientTg зелёные (+37 новых), регресс DealCreateTest 10/10, Vitest зелёный (+4 admin-tg-view), phpstan 0 по своему коду (level 5), pint чисто, deptrac 0 нарушений. rls-reviewer: PASS по обеим новым таблицам (client_tg_auto_rule, client_tg_senders). CHANGELOG_schema v8.87 + напоминание ПЕРЕзапустить db/03_service_bypass_policies.sql на кластере (Сессия 6). Робот в тестах замокан (кабинет МТС не тронут). Синтетические номера 7999… — реальных нет. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
205 lines
8.9 KiB
PHP
205 lines
8.9 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Exceptions\Billing\InsufficientBalanceException;
|
||
use App\Jobs\ClientTg\ChargeTgNameFeeJob;
|
||
use App\Models\AdWallet;
|
||
use App\Models\AdWalletTransaction;
|
||
use App\Models\ClientTg\Sender;
|
||
use App\Models\Tenant;
|
||
use App\Services\Advertising\AdWalletService;
|
||
use App\Services\ClientTg\TelegramSenderService;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Illuminate\Support\Carbon;
|
||
use Tests\Concerns\SharesSupplierPdo;
|
||
|
||
/**
|
||
* Своё имя/бренд Telegram-рекламы + помесячная оплата (план §Сессия 4, задача 4.3).
|
||
* Зеркало client_sms_senders + ChargeSmsNameFeeJob, но проще (без документов/операторов
|
||
* — согласование в Телеграме уточняется живой разведкой §8; здесь — денежный цикл).
|
||
*
|
||
* Помесячный джоб перечисляет активных отправителей КРОСС-ТЕНАНТНО через pgsql_supplier
|
||
* (BYPASSRLS) — без share PDO вставленные в тест-транзакции строки не видны второму
|
||
* соединению до commit'а (зеркалит NameFeeJobTest/ChargeCampaignSpendJobTest).
|
||
*/
|
||
uses(RefreshDatabase::class);
|
||
uses(SharesSupplierPdo::class);
|
||
|
||
function tgSenderService(): TelegramSenderService
|
||
{
|
||
return app(TelegramSenderService::class);
|
||
}
|
||
|
||
function makeActiveTgSender(int $tenantId, ?string $paidUntil, string $fee = '2500.00', ?string $debtSince = null): Sender
|
||
{
|
||
return Sender::create([
|
||
'tenant_id' => $tenantId,
|
||
'name' => 'МойБренд',
|
||
'status' => Sender::STATUS_ACTIVE,
|
||
'monthly_fee_rub' => $fee,
|
||
'paid_until' => $paidUntil,
|
||
'debt_since' => $debtSince,
|
||
]);
|
||
}
|
||
|
||
beforeEach(function () {
|
||
Carbon::setTestNow('2026-07-27 10:00:00');
|
||
config(['client_tg.sandbox' => false]); // деньги включены — проверяем реальный цикл
|
||
$this->tenant = Tenant::factory()->create();
|
||
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
|
||
});
|
||
|
||
afterEach(function () {
|
||
Carbon::setTestNow();
|
||
});
|
||
|
||
// --- Жизненный цикл заявки -------------------------------------------------
|
||
|
||
it('requestSender создаёт заявку pending и бронирует месячную плату', function () {
|
||
$sender = tgSenderService()->requestSender($this->tenant->id, 'МойБренд', requestedBy: 1);
|
||
|
||
expect($sender->status)->toBe(Sender::STATUS_PENDING);
|
||
expect((string) $sender->monthly_fee_rub)->toBe('2500.00'); // из client_tg_settings
|
||
|
||
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
||
expect((string) $wallet->frozen_rub)->toBe('2500.00'); // бронь под имя
|
||
});
|
||
|
||
it('requestSender при активном имени — отказ', function () {
|
||
makeActiveTgSender($this->tenant->id, '2026-08-27');
|
||
|
||
expect(fn () => tgSenderService()->requestSender($this->tenant->id, 'Другое'))
|
||
->toThrow(DomainException::class);
|
||
});
|
||
|
||
it('requestSender при нехватке денег — бросает ДО списания (заявки нет)', function () {
|
||
$poor = Tenant::factory()->create();
|
||
app(AdWalletService::class)->topup($poor->id, '100.00', 'telegram', 'test'); // кошелёк есть, но мало
|
||
|
||
expect(fn () => tgSenderService()->requestSender($poor->id, 'МойБренд'))
|
||
->toThrow(InsufficientBalanceException::class);
|
||
|
||
expect(Sender::where('tenant_id', $poor->id)->exists())->toBeFalse();
|
||
});
|
||
|
||
it('disableSender у pending снимает бронь и ставит cancelled', function () {
|
||
$sender = tgSenderService()->requestSender($this->tenant->id, 'МойБренд');
|
||
|
||
$out = tgSenderService()->disableSender($this->tenant->id);
|
||
|
||
expect($out->status)->toBe(Sender::STATUS_CANCELLED);
|
||
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
||
expect((string) $wallet->frozen_rub)->toBe('0.00'); // бронь снята
|
||
});
|
||
|
||
// --- Админ ----------------------------------------------------------------
|
||
|
||
it('approve списывает плату, снимает бронь, ставит active + paid_until +1мес', function () {
|
||
$sender = tgSenderService()->requestSender($this->tenant->id, 'МойБренд');
|
||
|
||
$out = tgSenderService()->approve($sender->id);
|
||
|
||
expect($out->status)->toBe(Sender::STATUS_ACTIVE);
|
||
expect($out->paid_until->toDateString())->toBe('2026-08-27');
|
||
expect($out->debt_since)->toBeNull();
|
||
|
||
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
||
expect((string) $wallet->frozen_rub)->toBe('0.00'); // бронь снята
|
||
expect((string) $wallet->balance_rub)->toBe('7500.00'); // 10000 − 2500
|
||
});
|
||
|
||
it('approve не-pending — отказ', function () {
|
||
$sender = makeActiveTgSender($this->tenant->id, '2026-08-27');
|
||
|
||
expect(fn () => tgSenderService()->approve($sender->id))
|
||
->toThrow(DomainException::class);
|
||
});
|
||
|
||
it('reject снимает бронь и ставит rejected с заметкой', function () {
|
||
$sender = tgSenderService()->requestSender($this->tenant->id, 'МойБренд');
|
||
|
||
$out = tgSenderService()->reject($sender->id, 'Название занято');
|
||
|
||
expect($out->status)->toBe(Sender::STATUS_REJECTED);
|
||
expect($out->note)->toBe('Название занято');
|
||
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
||
expect((string) $wallet->frozen_rub)->toBe('0.00');
|
||
});
|
||
|
||
it('disableByAdmin ставит suspended', function () {
|
||
$sender = makeActiveTgSender($this->tenant->id, '2026-08-27');
|
||
|
||
$out = tgSenderService()->disableByAdmin($sender->id);
|
||
|
||
expect($out->status)->toBe(Sender::STATUS_SUSPENDED);
|
||
});
|
||
|
||
// --- Помесячный джоб -------------------------------------------------------
|
||
|
||
it('джоб списывает плату, двигает paid_until на месяц, гасит долг когда денег хватает', function () {
|
||
$sender = makeActiveTgSender($this->tenant->id, '2026-07-26'); // вчера → к оплате
|
||
|
||
app(ChargeTgNameFeeJob::class)->handle();
|
||
|
||
$sender->refresh();
|
||
expect($sender->paid_until->toDateString())->toBe('2026-08-26');
|
||
expect($sender->debt_since)->toBeNull();
|
||
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
||
expect((string) $wallet->balance_rub)->toBe('7500.00'); // 10000 − 2500
|
||
});
|
||
|
||
it('джоб при нехватке денег в грейсе — фиксирует долг, paid_until не двигает', function () {
|
||
$poor = Tenant::factory()->create();
|
||
app(AdWalletService::class)->topup($poor->id, '100.00', 'telegram', 'test'); // мало
|
||
$sender = makeActiveTgSender($poor->id, '2026-07-26');
|
||
|
||
app(ChargeTgNameFeeJob::class)->handle();
|
||
|
||
$sender->refresh();
|
||
expect($sender->status)->toBe(Sender::STATUS_ACTIVE); // ещё в грейсе
|
||
expect($sender->paid_until->toDateString())->toBe('2026-07-26'); // не сдвинут
|
||
expect($sender->debt_since)->not->toBeNull();
|
||
});
|
||
|
||
it('джоб при долге дольше грейса — отключает имя (suspended)', function () {
|
||
$poor = Tenant::factory()->create();
|
||
app(AdWalletService::class)->topup($poor->id, '100.00', 'telegram', 'test');
|
||
// долг с 30 дней назад (>29 grace)
|
||
$sender = makeActiveTgSender($poor->id, '2026-06-26', debtSince: '2026-06-26');
|
||
|
||
app(ChargeTgNameFeeJob::class)->handle();
|
||
|
||
$sender->refresh();
|
||
expect($sender->status)->toBe(Sender::STATUS_SUSPENDED);
|
||
});
|
||
|
||
it('джоб идемпотентен по периоду — второй прогон не списывает дважды', function () {
|
||
$sender = makeActiveTgSender($this->tenant->id, '2026-07-26');
|
||
|
||
app(ChargeTgNameFeeJob::class)->handle();
|
||
$balanceAfterFirst = (string) AdWallet::where('tenant_id', $this->tenant->id)->first()->balance_rub;
|
||
|
||
// Возвращаем paid_until назад — но external_key периода тот же → повторно не спишет.
|
||
$sender->update(['paid_until' => '2026-07-26']);
|
||
app(ChargeTgNameFeeJob::class)->handle();
|
||
|
||
$chargeCount = AdWalletTransaction::where('tenant_id', $this->tenant->id)
|
||
->where('channel', 'telegram')
|
||
->where('type', AdWalletTransaction::TYPE_CHARGE)
|
||
->count();
|
||
expect($chargeCount)->toBe(1); // одно списание за период
|
||
});
|
||
|
||
it('джоб в песочнице деньги не двигает', function () {
|
||
config(['client_tg.sandbox' => true]);
|
||
$sender = makeActiveTgSender($this->tenant->id, '2026-07-26');
|
||
|
||
app(ChargeTgNameFeeJob::class)->handle();
|
||
|
||
$sender->refresh();
|
||
expect($sender->paid_until->toDateString())->toBe('2026-07-26'); // не тронут
|
||
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
|
||
expect((string) $wallet->balance_rub)->toBe('10000.00'); // не списано
|
||
});
|