feat(телеграм-реклама): деньги на общем балансе + чистка СМС-фантазий + медиа объявления

Денежная модель (куски 1-2): реклама в Телеграме (МТС Маркетолог, робот-в-браузере)
оплачивается с ОБЩЕГО баланса тенанта (как СМС), наценка 40%. Списание при запуске,
возврат при отказе модерации/сбое/отмене (сальдо-идемпотентность tg_ad_charge/tg_ad_refund).
Старый рекламный кошелёк-заморозка убран из потока кампаний (таблицы ad_wallets НЕ удалены).
Новый TelegramCampaignChargeService (зеркало SmsChargeService); F5 money-safety сохранён
(сбой с mts_campaign_id → needs_review без возврата). Уборщик добивает зависшие queued с возвратом.

Чистка СМС-наследия (аудит модуля построчно 5 проверяющими): модуль ресейлит ПОКАЗЫ рекламы,
а не рассылку сообщений — вырезаны выдуманные сущности, механически скопированные из СМС:
- «имя/бренд отправителя» + помесячная абонплата (в телеге МТС имени отправителя нет):
  ChargeTgNameFeeJob, TelegramSenderService, SenderController, модели Sender/Setting,
  фронт-панель TelegramSenderPanel, админ-карточка, updateTgSettings, тип tg_name_fee.
  Таблицы client_tg_senders/_settings дропнуты.
- мёртвые таблицы client_tg_messages (доставка по номеру) и client_tg_templates (шаблоны
  сообщений) — без модели/использования, дропнуты (миграция 000017, create-миграции удалены).
- client_tg_optouts переосмыслен как ручной список «не показывать этим номерам» (не «отписки»).
- лексика рассылки → рекламы: «Авторассылка»→«Авто-реклама», «кошелёк/заморожено»→«общий баланс».

Медиа объявления (реальный пробел показов): новый POST /api/telegram/campaigns/{id}/media
(картинка/видео к черновику, png/jpg/gif/mp4 ≤50 МБ, замена удаляет старый файл) + поле
загрузки в форме кабинета. Робот уже принимал media_path (RunTelegramCampaignJob → mediaFile
→ cabinet.js fillAdMedia) — не хватало только приёма файла от клиента.

Схема: db/CHANGELOG_schema.md v8.94 (CHECK += tg_ad_charge/tg_ad_refund) + v8.95 (дроп
СМС-таблиц, tg_name_fee убран). Тесты зелёные поштучно (квирк партиций — гонять по одному):
Schema/Models/AdminTg/CampaignCharge/CampaignApi/Audience/Media; фронт advertising-telegram-view
19 + admin-tg/auto-rule 24. pint/deptrac(0) чисто; larastan исключён (Pest-$this шум). Робот
bots/mts-telegram-ads НЕ трогали.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-29 06:26:07 +03:00
parent 7a2b052f0d
commit 6f469fb13d
62 changed files with 1893 additions and 2103 deletions
@@ -5,20 +5,18 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\ClientTg\Setting;
use App\Models\ClientTg\Tariff;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* SaaS-admin Клиентская Telegram-реклама: тарифная сетка (client_tg_tariffs) +
* общие настройки (client_tg_settings, singleton). План §Сессия 4, задача 4.4.
* SaaS-admin Клиентская Telegram-реклама: тарифная сетка (client_tg_tariffs).
* План §Сессия 4, задача 4.4.
*
* Зона saas-admin/admin-db (crm_admin_user); GRANT миграции client_tg_tariffs /
* client_tg_settings. Обе таблицы глобальные (без RLS, не tenant-scoped) единый
* прайс на всех тенантов. updateTariffs заменяет весь набор ступеней целиком
* (owner правит сетку списком). Зеркало AdminSmsTariffController.
* Зона saas-admin/admin-db (crm_admin_user); GRANT миграция client_tg_tariffs.
* Таблица глобальная (без RLS, не tenant-scoped) единый прайс на всех тенантов.
* updateTariffs заменяет весь набор ступеней целиком (owner правит сетку списком).
*/
class TgTariffController extends Controller
{
@@ -27,7 +25,6 @@ class TgTariffController extends Controller
{
return response()->json([
'tariffs' => Tariff::orderBy('min_qty')->get(),
'settings' => Setting::first(),
]);
}
@@ -61,19 +58,4 @@ class TgTariffController extends Controller
'tariffs' => Tariff::orderBy('min_qty')->get(),
]);
}
/** PUT /api/admin/telegram/settings — singleton настроек. */
public function updateSettings(Request $request): JsonResponse
{
$validated = $request->validate([
'name_fee_rub_per_month' => ['required', 'numeric', 'min:0'],
'name_debt_grace_days' => ['required', 'integer', 'min:0'],
]);
$settings = Setting::first() ?? new Setting;
$settings->fill($validated);
$settings->save();
return response()->json($settings);
}
}
@@ -8,17 +8,17 @@ use App\Exceptions\Billing\InsufficientBalanceException;
use App\Http\Controllers\Controller;
use App\Jobs\ClientTg\ResubmitTelegramCampaignJob;
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\AdWallet;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\CampaignPhone;
use App\Services\Advertising\AdWalletService;
use App\Models\Tenant;
use App\Services\ClientTg\TelegramAudienceService;
use App\Services\ClientTg\TelegramCampaignChargeService;
use App\Services\ClientTg\TelegramTariffService;
use App\Support\PhoneNormalizer;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
/**
* HTTP-API клиентской Telegram-рекламы «по своей базе» (план §Сессия 3, задача 3.1).
@@ -26,9 +26,9 @@ use Illuminate\Support\Facades\DB;
* (смета-потолок), RunTelegramCampaignJob (робот кабинета МТС).
*
* 🔴 Отличие от СМС-близнеца: создание и запуск РАЗДЕЛЕНЫ. `store` создаёт ЧЕРНОВИК
* (деньги/робот не трогаются) клиент видит смету и число кандидатов; `launch`
* переводит draft→queued, в бою бронирует лимит на объявление (budget_cap_rub) и
* ставит джоб. Песочница (TG_SANDBOX): заморозки нет, робот пойдёт черновиком.
* (деньги/робот не трогаются) клиент видит клиентскую смету (с наценкой) и число
* кандидатов; `launch` переводит draft→queued, в бою СПИСЫВАЕТ клиентскую смету с
* общего баланса и ставит джоб. Песочница (TG_SANDBOX): денег не трогаем, робот черновиком.
*
* tenant_id всегда из $request->user()->tenant_id; каждый запрос с явным
* ->where('tenant_id', ...) поверх RLS (defense-in-depth, паттерн ClientSmsController).
@@ -44,9 +44,9 @@ class CampaignController extends Controller
{
$tenantId = (int) $request->user()->tenant_id;
// Баланс/заморозка — чтобы экран мог погасить «Запустить» при нехватке
// свободных средств ДО обращения к серверу.
$wallet = AdWallet::where('tenant_id', $tenantId)->first();
// Баланс — общий баланс тенанта (как СМС): экран гасит «Запустить» при нехватке
// средств ДО обращения к серверу. Заморозки больше нет (frozen всегда 0).
$tenant = Tenant::find($tenantId);
return response()->json([
'campaigns' => Campaign::where('tenant_id', $tenantId)
@@ -54,8 +54,8 @@ class CampaignController extends Controller
->limit(50)
->get(),
'sandbox' => (bool) config('client_tg.sandbox', true),
'balance_rub' => $wallet !== null ? (string) $wallet->balance_rub : '0.00',
'frozen_rub' => $wallet !== null ? (string) $wallet->frozen_rub : '0.00',
'balance_rub' => $tenant !== null ? (string) $tenant->balance_rub : '0.00',
'frozen_rub' => '0.00',
]);
}
@@ -95,7 +95,8 @@ class CampaignController extends Controller
$candidates = $this->audience->build($campaign)->candidatesCount;
$campaign->update([
'planned_count' => $candidates,
'estimated_cost_rub' => $this->tariff->estimateRub($candidates),
// Клиентская цена (с наценкой) — её клиент видит и платит при запуске.
'estimated_cost_rub' => $this->tariff->clientEstimateRub($candidates),
]);
$payload = $campaign->fresh()->toArray();
@@ -114,9 +115,9 @@ class CampaignController extends Controller
}
/**
* Запуск кампании: draft queued + джоб робота. В бою бронирует лимит на
* объявление (budget_cap_rub); не хватает денег 409, кампания остаётся
* черновиком. Песочница без заморозки. Не-черновик 422 (повторно не жмём).
* Запуск кампании: draft queued + джоб робота. В бою СПИСЫВАЕТ клиентскую смету
* (с наценкой) с общего баланса; не хватает денег 409, кампания остаётся
* черновиком. Песочница без денег. Не-черновик 422 (повторно не жмём).
*/
public function launch(Request $request, int $id): JsonResponse
{
@@ -130,10 +131,11 @@ class CampaignController extends Controller
$sandbox = (bool) config('client_tg.sandbox', true);
// Предстартовый гейт аудитории (находка #2): в бою НЕ бронируем деньги под
// Предстартовый гейт аудитории (находка #2): в бою НЕ списываем деньги под
// заведомо непроходную кампанию. Минимум у МТС Маркетолог — 367 кандидатов;
// <367 или пусто — робот всё равно упал бы, но бронь уже залипла. В песочнице
// <367 или пусто — робот всё равно упал бы, но деньги уже ушли бы. В песочнице
// гейта нет — денег нет, а сам робот отсеет по MIN_NON_MTS.
$candidates = 0;
if (! $sandbox) {
$candidates = $this->audience->build($campaign)->candidatesCount;
$min = (int) config('client_tg.auto_batch_threshold', 367);
@@ -146,16 +148,19 @@ class CampaignController extends Controller
}
try {
DB::transaction(function () use ($tenantId, $campaign, $sandbox): void {
DB::transaction(function () use ($campaign, $sandbox, $candidates): void {
if (! $sandbox) {
app(AdWalletService::class)->freeze(
$tenantId, 'telegram', 'campaign', $campaign->id, (string) $campaign->budget_cap_rub,
);
// Пересчёт клиентской сметы по свежему числу кандидатов — клиент видит
// и платит одно и то же. Списание с общего баланса; нехватка бросает
// InsufficientBalanceException → откат транзакции → кампания в draft.
$clientCost = $this->tariff->clientEstimateRub($candidates);
$campaign->update(['estimated_cost_rub' => $clientCost]);
app(TelegramCampaignChargeService::class)->charge($campaign, $clientCost);
}
$campaign->transitionTo(Campaign::STATUS_QUEUED);
});
} catch (InsufficientBalanceException|ModelNotFoundException $e) {
return response()->json(['message' => 'Не хватает денег на кошельке продвижения'], 409);
} catch (InsufficientBalanceException $e) {
return response()->json(['message' => 'Не хватает денег на балансе — пополните счёт'], 409);
}
RunTelegramCampaignJob::dispatch($campaign->id, $tenantId)->afterCommit();
@@ -164,10 +169,10 @@ class CampaignController extends Controller
}
/**
* Отмена кампании ДО запуска (draft/queued cancelled). В бою возвращает бронь
* кошелька (release снимает hold, уменьшает frozen) иначе резерв залипает
* (находка #1, задача 2.3). Песочница брони не имела — release пропускаем.
* Из running/терминальных отмена запрещена (422) по машине статусов модели.
* Отмена кампании ДО запуска (draft/queued cancelled). В бою возвращает списанную
* смету на общий баланс (refund) из queued (уже списали на launch) вернёт деньги,
* из draft (не списывали) refund no-op. Песочница денег не трогала. Из running/
* терминальных отмена запрещена (422) по машине статусов модели.
*/
public function cancel(Request $request, int $id): JsonResponse
{
@@ -181,16 +186,12 @@ class CampaignController extends Controller
$sandbox = (bool) config('client_tg.sandbox', true);
try {
DB::transaction(function () use ($tenantId, $campaign, $sandbox): void {
if (! $sandbox) {
app(AdWalletService::class)->release($tenantId, 'telegram', 'campaign', $campaign->id);
}
$campaign->transitionTo(Campaign::STATUS_CANCELLED);
});
} catch (ModelNotFoundException $e) {
return response()->json(['message' => 'Не удалось вернуть бронь'], 409);
}
DB::transaction(function () use ($campaign, $sandbox): void {
if (! $sandbox) {
app(TelegramCampaignChargeService::class)->refund($campaign);
}
$campaign->transitionTo(Campaign::STATUS_CANCELLED);
});
return response()->json($campaign->fresh());
}
@@ -235,8 +236,9 @@ class CampaignController extends Controller
$sandbox = (bool) config('client_tg.sandbox', true);
// Предстартовый гейт аудитории (как launch): в бою не бронируем деньги под
// Предстартовый гейт аудитории (как launch): в бою не списываем деньги под
// заведомо непроходную кампанию. Песочница — без гейта (денег нет, робот отсеет).
$candidates = 0;
if (! $sandbox) {
$candidates = $this->audience->build($campaign)->candidatesCount;
$min = (int) config('client_tg.auto_batch_threshold', 367);
@@ -249,11 +251,14 @@ class CampaignController extends Controller
}
try {
DB::transaction(function () use ($tenantId, $campaign, $data, $moderatorPath, $sandbox): void {
DB::transaction(function () use ($campaign, $data, $moderatorPath, $sandbox, $candidates): void {
// Пересдача платится ЗАНОВО: на отказе модерации смету вернул опросчик
// (refund), сальдо выровнялось — новое списание проходит. Не хватает →
// InsufficientBalanceException → откат → кампания остаётся rejected.
if (! $sandbox) {
app(AdWalletService::class)->freeze(
$tenantId, 'telegram', 'campaign', $campaign->id, (string) $campaign->budget_cap_rub,
);
$clientCost = $this->tariff->clientEstimateRub($candidates);
$campaign->update(['estimated_cost_rub' => $clientCost]);
app(TelegramCampaignChargeService::class)->charge($campaign, $clientCost);
}
$campaign->fill([
@@ -268,8 +273,8 @@ class CampaignController extends Controller
$campaign->transitionTo(Campaign::STATUS_QUEUED);
});
} catch (InsufficientBalanceException|ModelNotFoundException $e) {
return response()->json(['message' => 'Не хватает денег на кошельке продвижения'], 409);
} catch (InsufficientBalanceException $e) {
return response()->json(['message' => 'Не хватает денег на балансе — пополните счёт'], 409);
}
ResubmitTelegramCampaignJob::dispatch($campaign->id, $tenantId)->afterCommit();
@@ -277,6 +282,41 @@ class CampaignController extends Controller
return response()->json($campaign->fresh());
}
/**
* Прикладывает картинку/видео к объявлению ЧЕРНОВИКА (у МТС объявление может быть
* с медиа). Файл кладём на локальный диск; путь в `media_path` робот отдаёт кабинету
* МТС при запуске (RunTelegramCampaignJob mediaFile cabinet.js `fillAdMedia`).
* Только для черновика после запуска объявление уже ушло в кабинет. Замена медиа
* удаляет прежний файл, чтобы не копить мусор на диске.
*/
public function attachMedia(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$campaign = Campaign::where('tenant_id', $tenantId)->findOrFail($id);
if ($campaign->status !== Campaign::STATUS_DRAFT) {
return response()->json(['message' => 'Медиа можно приложить только к черновику — до запуска'], 422);
}
$request->validate([
// Картинка (png/jpg/gif) или видео (mp4); лимит 50 МБ (кабинет МТС жмёт крупные сам).
'media' => 'required|file|mimes:png,jpg,jpeg,gif,mp4|max:51200',
]);
$path = $request->file('media')->store('client_tg/media', 'local');
$old = $campaign->media_path;
$campaign->update(['media_path' => $path]);
// Прежний файл больше не нужен — убираем с диска (после успешной записи пути).
if ($old !== null && $old !== '' && $old !== $path) {
Storage::disk('local')->delete($old);
}
return response()->json($campaign->fresh());
}
/**
* Валидация полей кампании. Для «по сделкам» срок обязателен иначе выборка
* ушла бы по ВСЕЙ истории (и сканировала бы все партиции deals).
@@ -1,85 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\ClientTg;
use App\Exceptions\Billing\InsufficientBalanceException;
use App\Http\Controllers\Controller;
use App\Services\ClientTg\TelegramSenderService;
use DomainException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Клиентское HTTP-API «своего имени» Telegram-рекламы (план §Этап 5, задача 5.2).
*
* Тонкий слой над TelegramSenderService: клиент из кабинета заводит имя (store),
* отключает (disable) и включает отключённое за долг обратно (enable). GET (show)
* отдаёт снимок статуса + остаток грейса. Песочница денег не трогает.
*
* tenant_id всегда из $request->user()->tenant_id сервис скоупит по тенанту
* поверх RLS (defense-in-depth, паттерн CampaignController).
*/
class SenderController extends Controller
{
public function __construct(
private readonly TelegramSenderService $senders,
) {}
public function show(Request $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
return response()->json($this->senders->snapshot($tenantId));
}
public function store(Request $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$data = $request->validate([
'name' => 'required|string|max:64', // = колонка client_tg_senders.name varchar(64)
]);
try {
$sender = $this->senders->requestSender($tenantId, $data['name'], (int) $request->user()->id);
} catch (InsufficientBalanceException $e) {
return response()->json(['message' => 'Не хватает денег на кошельке продвижения'], 409);
} catch (DomainException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json($sender, 201);
}
public function disable(Request $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
try {
$sender = $this->senders->disableSender($tenantId);
} catch (ModelNotFoundException $e) {
return response()->json(['message' => 'Имя отправителя не найдено'], 404);
}
return response()->json($sender);
}
public function enable(Request $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
try {
$sender = $this->senders->enableSender($tenantId);
} catch (InsufficientBalanceException $e) {
return response()->json(['message' => 'Не хватает денег на кошельке продвижения'], 409);
} catch (DomainException $e) {
return response()->json(['message' => $e->getMessage()], 422);
} catch (ModelNotFoundException $e) {
return response()->json(['message' => 'Имя отправителя не найдено'], 404);
}
return response()->json($sender);
}
}
@@ -1,126 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs\ClientTg;
use App\Models\AdWallet;
use App\Models\ClientTg\Sender;
use App\Models\ClientTg\Setting;
use App\Services\Advertising\AdWalletService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Ежемесячная плата за своё имя Telegram-рекламы (client_tg_senders) со всех
* тенантов сразу. Долг дольше grace-срока (по умолчанию 29 дней) имя
* автоматически отключается (suspended). Зеркало ChargeSmsNameFeeJob.
*
* 🔴 На проде очередь бежит под ролью `crm_app_user` (НЕ BYPASSRLS) на дефолтном
* соединении, БЕЗ tenant-контекста. Перечисление отправителей через дефолтное
* соединение без контекста дало бы 0 строк по RLS (молчаливый сбой). Поэтому:
* отправителей перечисляем плоскими строками через `pgsql_supplier`
* (BYPASSRLS, аналог ChargeCampaignSpendJob), БЕЗ открытой транзакции;
* денежная операция (charge) + правка отправителя под tenant-контекстом
* (`SET LOCAL app.current_tenant_id`) на ДЕФОЛТНОМ соединении, где RLS-политика
* пускает crm_app_user только при выставленном контексте. Денежные операции
* всегда принимают tenant_id явным аргументом никакого смешения кошельков.
*
* ⚠️ Порог платёжеспособности проверяется ДО списания (свободно =
* balance frozen плата). AdWalletService::charge НЕ бросает при недоборе
* при недоборе «прижимает» баланс к 0 и всё равно создаёт транзакцию списания.
* Полагаться на try/catch нельзя иначе долг не фиксировался бы, а paid_until
* ошибочно продлевался.
*
* Идемпотентность списания через external_key "telegram:sender:{id}:{YYYY-MM}"
* (период месяц истёкшего paid_until). В песочнице деньги не двигаются вовсе.
*/
class ChargeTgNameFeeJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(): void
{
$sandbox = (bool) config('client_tg.sandbox', true);
if ($sandbox) {
return; // в песочнице деньги не списываем
}
$wallet = app(AdWalletService::class);
// value() вернёт null на пустой таблице настроек → грейс по умолчанию (29).
$grace = (int) (Setting::query()->value('name_debt_grace_days') ?? 29);
$today = Carbon::now()->toDateString();
$rows = DB::connection('pgsql_supplier')->table('client_tg_senders')
->where('status', Sender::STATUS_ACTIVE)
->whereNotNull('paid_until')
->whereDate('paid_until', '<', $today)
->get(['id', 'tenant_id', 'monthly_fee_rub', 'paid_until', 'debt_since']);
foreach ($rows as $row) {
$tenantId = (int) $row->tenant_id;
try {
DB::transaction(function () use ($row, $tenantId, $grace, $today, $wallet): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$fee = (string) $row->monthly_fee_rub;
$period = Carbon::parse($row->paid_until)->format('Y-m');
$externalKey = "telegram:sender:{$row->id}:{$period}";
$adWallet = AdWallet::where('tenant_id', $tenantId)->first();
$free = $adWallet !== null
? bcsub((string) $adWallet->balance_rub, (string) $adWallet->frozen_rub, 2)
: '0.00';
if (bccomp($free, $fee, 2) >= 0) {
$wallet->charge($tenantId, 'telegram', 'sender', (int) $row->id, $fee, $externalKey);
Sender::where('tenant_id', $tenantId)->whereKey($row->id)->update([
'paid_until' => Carbon::parse($row->paid_until)->addMonthNoOverflow()->toDateString(),
'debt_since' => null,
]);
return;
}
// Денег не хватает — фиксируем/копим долг, paid_until НЕ двигаем.
$debtSince = $row->debt_since ?? $today;
if (Carbon::parse($debtSince)->diffInDays(Carbon::parse($today)) > $grace) {
Sender::where('tenant_id', $tenantId)->whereKey($row->id)->update([
'status' => Sender::STATUS_SUSPENDED,
'note' => 'Долг более '.$grace.' дней — имя отключено, погасить у МТС',
'debt_since' => $debtSince,
]);
// Сигнал владельцу: имя выключено за долг — у МТС гасим вручную (API нет).
Log::warning('client_tg.name_suspended_for_debt', [
'sender_id' => $row->id,
'tenant_id' => $tenantId,
'debt_since' => $debtSince,
]);
} else {
Sender::where('tenant_id', $tenantId)->whereKey($row->id)->update([
'debt_since' => $debtSince,
]);
}
});
} catch (Throwable $e) {
Log::warning('ChargeTgNameFeeJob: сбой списания платы за имя', [
'sender_id' => $row->id,
'tenant_id' => $row->tenant_id,
'error' => $e->getMessage(),
]);
}
}
}
}
@@ -6,8 +6,8 @@ namespace App\Jobs\ClientTg;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramCampaignChargeService;
use App\Services\ClientTg\TelegramRobotRunner;
use App\Services\NotificationService;
use Illuminate\Bus\Queueable;
@@ -28,12 +28,11 @@ use Throwable;
* bots/mts-telegram-ads/FLOW-FINDINGS.md, «Разведка Сессии 6»). Опросчик применяет
* вердикт КОНСЕРВАТИВНО:
*
* `rejected` («Отклонена») статус `rejected` + причина из кабинета; возврат
* брони кошелька (в бою зеркало release при отказе робота, задача 2.3);
* `rejected` («Отклонена») статус `rejected` + причина из кабинета; ВОЗВРАТ
* списанной сметы на общий баланс (в бою refund, зеркало отказа робота);
* уведомление клиенту «реклама отклонена».
* `approved` («Одобрена») `launched` (показы пошли); уведомление «одобрена».
* Деньги НЕ трогаем бронь под потолок держится, фактическую стоимость спишет
* билинг по факту показов (задача 2.4 позже).
* Деньги НЕ трогаем смету списали при запуске, одобрение ничего не меняет.
* `moderating` / робот не смог прочитать вердикт (null) НЕ трогаем, оставляем
* на модерации до следующего цикла (тихо пропускаем).
*
@@ -87,15 +86,14 @@ class PollTelegramModerationJob implements ShouldQueue
[$outcome, $campaign, $tenant] = $applied;
// Возврат брони — только при отказе и только в бою (в песочнице заморозки
// не было; ключ совпадает с freeze в launch). Отдельный tenantTx: сбой
// возврата не должен откатить уже проставленный статус.
// Возврат сметы — только при отказе и только в бою (в песочнице денег не
// списывали, refund найдёт сальдо 0). Отдельный tenantTx: сбой возврата
// не должен откатить уже проставленный статус.
if ($outcome === Campaign::STATUS_REJECTED && ! $sandbox) {
try {
$this->tenantTx($tenantId, fn () => app(AdWalletService::class)
->release($tenantId, 'telegram', 'campaign', $campaignId));
$this->tenantTx($tenantId, fn () => app(TelegramCampaignChargeService::class)->refund($campaign));
} catch (Throwable $e) {
Log::warning('client_tg.poll_release_failed', [
Log::warning('client_tg.poll_refund_failed', [
'campaign_id' => $campaignId,
'error' => $e->getMessage(),
]);
@@ -6,8 +6,8 @@ namespace App\Jobs\ClientTg;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramCampaignChargeService;
use App\Services\ClientTg\TelegramRobotRunner;
use App\Services\NotificationService;
use Illuminate\Bus\Queueable;
@@ -119,8 +119,8 @@ class ResubmitTelegramCampaignJob implements ShouldQueue
$fresh->status_reason = $result->reason;
// Осторожно с деньгами (F5): есть mts_campaign_id → кампания в кабинете
// реальна и могла уйти на пере-модерацию → needs_review БЕЗ возврата брони.
// Без id (край — контроллер это отсекает) → failed + возврат брони.
// реальна и могла уйти на пере-модерацию → needs_review БЕЗ возврата сметы.
// Без id (край — контроллер это отсекает) → failed + возврат сметы.
if ($fresh->mts_campaign_id !== null && $fresh->mts_campaign_id !== '') {
$fresh->transitionTo(Campaign::STATUS_NEEDS_REVIEW);
@@ -144,21 +144,20 @@ class ResubmitTelegramCampaignJob implements ShouldQueue
$fresh->transitionTo(Campaign::STATUS_DRAFT_READY);
} else {
// Аномалия контракта: бой (submitMode:'live') ДОЛЖЕН давать resubmitted=true.
// Если робот вернул ok, но не отправил — деньги заморожены, а кампания на
// модерацию НЕ ушла → needs_review (бронь держим, ручная сверка), не
// терминальный draft_ready (иначе бронь зависнет навсегда).
// Если робот вернул ok, но не отправил — смета списана, а кампания на
// модерацию НЕ ушла → needs_review (списание держим, ручная сверка), не
// терминальный draft_ready (иначе деньги зависнут навсегда).
$fresh->transitionTo(Campaign::STATUS_NEEDS_REVIEW);
}
});
// Возврат брони — ТОЛЬКО для заведомо не ушедших (failed без id), как в
// Возврат сметы — ТОЛЬКО для заведомо не ушедших (failed без id), как в
// RunTelegramCampaignJob. Отдельный tenantTx, чтобы сбой возврата не откатил статус.
if ($failedCampaign !== null && ! $sandbox) {
try {
$this->tenantTx(fn () => app(AdWalletService::class)
->release($this->tenantId, 'telegram', 'campaign', $failedCampaign->id));
$this->tenantTx(fn () => app(TelegramCampaignChargeService::class)->refund($failedCampaign));
} catch (Throwable $e) {
Log::warning('client_tg.release_failed', [
Log::warning('client_tg.refund_failed', [
'campaign_id' => $failedCampaign->id,
'error' => $e->getMessage(),
]);
@@ -190,13 +189,13 @@ class ResubmitTelegramCampaignJob implements ShouldQueue
// 🔑 Отличие пересдачи от RunTelegramCampaignJob: у неё queued ВСЕГДА с
// mts_campaign_id. Но в статусе `queued` Фаза A ещё НЕ закоммитила `running` →
// робот кабинет НЕ трогал → кампания в кабинете осталась прежней (rejected),
// бронь безопасно вернуть. Поэтому queued → failed + release (переход
// смету безопасно вернуть. Поэтому queued → failed + refund (переход
// queued→needs_review в TRANSITIONS отсутствует; без этой ветки кампания зависла
// бы в queued с замороженными деньгами — уборщик метёт только running/moderating).
// бы в queued со списанными деньгами — уборщик метёт только running/moderating).
// А вот `running` мог тронуть кабинет → прежняя осторожная логика по mts_campaign_id.
$shouldRelease = false;
$campaignToRefund = null;
try {
$this->tenantTx(function () use (&$shouldRelease): void {
$this->tenantTx(function () use (&$campaignToRefund): void {
$campaign = Campaign::where('tenant_id', $this->tenantId)->find($this->campaignId);
if ($campaign === null) {
return;
@@ -205,7 +204,7 @@ class ResubmitTelegramCampaignJob implements ShouldQueue
if ($campaign->status === Campaign::STATUS_QUEUED) {
if ($campaign->canTransitionTo(Campaign::STATUS_FAILED)) {
$campaign->transitionTo(Campaign::STATUS_FAILED);
$shouldRelease = true;
$campaignToRefund = $campaign;
}
return;
@@ -216,7 +215,7 @@ class ResubmitTelegramCampaignJob implements ShouldQueue
}
// running: робот мог войти в кабинет. Есть mts_campaign_id → needs_review
// без release (могла уйти на пере-модерацию); нет id → failed + release.
// без возврата (могла уйти на пере-модерацию); нет id → failed + refund.
$hasMtsId = $campaign->mts_campaign_id !== null && $campaign->mts_campaign_id !== '';
if ($hasMtsId) {
if ($campaign->canTransitionTo(Campaign::STATUS_NEEDS_REVIEW)) {
@@ -228,7 +227,7 @@ class ResubmitTelegramCampaignJob implements ShouldQueue
if ($campaign->canTransitionTo(Campaign::STATUS_FAILED)) {
$campaign->transitionTo(Campaign::STATUS_FAILED);
$shouldRelease = true;
$campaignToRefund = $campaign;
}
});
} catch (Throwable $inner) {
@@ -238,12 +237,11 @@ class ResubmitTelegramCampaignJob implements ShouldQueue
]);
}
if ($shouldRelease && ! (bool) config('client_tg.sandbox', true)) {
if ($campaignToRefund !== null && ! (bool) config('client_tg.sandbox', true)) {
try {
$this->tenantTx(fn () => app(AdWalletService::class)
->release($this->tenantId, 'telegram', 'campaign', $this->campaignId));
$this->tenantTx(fn () => app(TelegramCampaignChargeService::class)->refund($campaignToRefund));
} catch (Throwable $inner) {
Log::warning('client_tg.release_failed', [
Log::warning('client_tg.refund_failed', [
'campaign_id' => $this->campaignId,
'error' => $inner->getMessage(),
]);
@@ -6,9 +6,9 @@ namespace App\Jobs\ClientTg;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramAudienceService;
use App\Services\ClientTg\TelegramCampaignChargeService;
use App\Services\ClientTg\TelegramRobotRunner;
use App\Services\NotificationService;
use Illuminate\Bus\Queueable;
@@ -178,16 +178,15 @@ class RunTelegramCampaignJob implements ShouldQueue
$fresh->transitionTo($result->launched ? Campaign::STATUS_MODERATING : Campaign::STATUS_DRAFT_READY);
});
// Задача 2.3: при отказе робота возвращаем бронь кошелька (frozen), иначе она
// залипает (находка #1). Отдельный tenantTx ПОСЛЕ записи статуса — чтобы сбой
// возврата не откатил FAILED. В песочнице заморозки не было — release пропускаем
// (иначе firstOrFail на несуществующем кошельке). Ключ совпадает с freeze в launch.
// При отказе робота (заведомо не ушла в кабинет — id нет) возвращаем списанную
// смету на общий баланс, иначе клиент заплатил за несделанную работу. Отдельный
// tenantTx ПОСЛЕ записи статуса — чтобы сбой возврата не откатил FAILED. В
// песочнице денег не списывали — refund просто ничего не найдёт (сальдо 0).
if ($failedCampaign !== null && ! $sandbox) {
try {
$this->tenantTx(fn () => app(AdWalletService::class)
->release($this->tenantId, 'telegram', 'campaign', $failedCampaign->id));
$this->tenantTx(fn () => app(TelegramCampaignChargeService::class)->refund($failedCampaign));
} catch (Throwable $e) {
Log::warning('client_tg.release_failed', [
Log::warning('client_tg.refund_failed', [
'campaign_id' => $failedCampaign->id,
'error' => $e->getMessage(),
]);
@@ -217,12 +216,12 @@ class RunTelegramCampaignJob implements ShouldQueue
// Осторожно с деньгами (ревью-фикс F5, симметрия с уборщиком/finalize): трогаем
// ТОЛЬКО кампанию, застрявшую до старта (running/queued) — если finalize уже
// увёл её дальше (moderating/…), статус не меняем и бронь не возвращаем. Есть
// mts_campaign_id → могла уйти на модерацию → needs_review без release; нет id →
// failed + возврат брони. release делаем ТОЛЬКО когда реально ушли в failed.
$shouldRelease = false;
// увёл её дальше (moderating/…), статус не меняем и деньги не возвращаем. Есть
// mts_campaign_id → могла уйти на модерацию → needs_review без возврата; нет id →
// failed + возврат сметы. refund делаем ТОЛЬКО когда реально ушли в failed.
$campaignToRefund = null;
try {
$this->tenantTx(function () use (&$shouldRelease): void {
$this->tenantTx(function () use (&$campaignToRefund): void {
$campaign = Campaign::where('tenant_id', $this->tenantId)->find($this->campaignId);
if ($campaign === null) {
return;
@@ -242,7 +241,7 @@ class RunTelegramCampaignJob implements ShouldQueue
if ($campaign->canTransitionTo(Campaign::STATUS_FAILED)) {
$campaign->transitionTo(Campaign::STATUS_FAILED);
$shouldRelease = true;
$campaignToRefund = $campaign;
}
});
} catch (Throwable $inner) {
@@ -252,15 +251,14 @@ class RunTelegramCampaignJob implements ShouldQueue
]);
}
// Задача 2.3: перманентный сбой джоба возвращает бронь (в бою) — но ТОЛЬКО для
// Перманентный сбой джоба возвращает списанную смету (в бою) — но ТОЛЬКО для
// заведомо не ушедших (failed). Отдельный tenantTx в своём try/catch — сбой
// возврата не должен ронять обработчик failed.
if ($shouldRelease && ! (bool) config('client_tg.sandbox', true)) {
if ($campaignToRefund !== null && ! (bool) config('client_tg.sandbox', true)) {
try {
$this->tenantTx(fn () => app(AdWalletService::class)
->release($this->tenantId, 'telegram', 'campaign', $this->campaignId));
$this->tenantTx(fn () => app(TelegramCampaignChargeService::class)->refund($campaignToRefund));
} catch (Throwable $inner) {
Log::warning('client_tg.release_failed', [
Log::warning('client_tg.refund_failed', [
'campaign_id' => $this->campaignId,
'error' => $inner->getMessage(),
]);
@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace App\Jobs\ClientTg;
use App\Models\ClientTg\Campaign;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientTg\TelegramCampaignChargeService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -25,17 +25,25 @@ use Throwable;
* не идущий прогон. Уборщик добивает статус, но 🔴 КОНСЕРВАТИВНО с деньгами:
*
* НЕТ `mts_campaign_id` (черновик в кабинете МТС не создавался) кампания
* заведомо НЕ ушла на модерацию `failed` + возврат брони кошелька;
* заведомо НЕ ушла на модерацию `failed` + возврат списанной сметы на баланс;
* ЕСТЬ `mts_campaign_id` (черновик реально создан) кампания МОГЛА уйти на
* модерацию МТС деньги вслепую НЕ возвращаем (иначе оплатим показанную
* рекламу из своего кармана), помечаем `needs_review` до ручной сверки /
* опросчика вердикта (задача 3.4).
*
* Плюс зависшие `queued` (ревью-фикс: деньги списаны на launch, но джоб так и не
* стартовал воркер умер / Redis сброшен / задача потеряна). Такая кампания без
* этой уборки залипла бы в `queued` НАВСЕГДА со списанными с общего баланса деньгами
* (finalize/failed срабатывают только если джоб реально бежал). queued НЕ имеет
* `mts_campaign_id` (его ставит робот уже в running) робот кабинет не трогал
* безопасно `failed` + ВОЗВРАТ сметы. Порог для queued с большим запасом
* (QUEUED_STUCK_AFTER_MINUTES), чтобы не гоняться с живым, но занятым воркером.
*
* 🔴 RLS/роли: на проде очередь бежит под `crm_app_user` (НЕ BYPASSRLS) БЕЗ
* tenant-контекста перечисление кампаний через дефолтное соединение вернуло бы
* 0 строк (молчаливый сбой). Поэтому кросс-тенантное перечисление идёт плоскими
* строками через `pgsql_supplier` (BYPASSRLS, как ChargeTgNameFeeJob), а правка
* статуса и возврат брони под `SET LOCAL app.current_tenant_id` на ДЕФОЛТНОМ
* строками через `pgsql_supplier` (BYPASSRLS, как PollTelegramModerationJob), а правка
* статуса и возврат сметы под `SET LOCAL app.current_tenant_id` на ДЕФОЛТНОМ
* соединении; денежные операции всегда получают tenant_id явным аргументом.
*/
class SweepStuckTelegramCampaignsJob implements ShouldQueue
@@ -45,13 +53,20 @@ class SweepStuckTelegramCampaignsJob implements ShouldQueue
/** Окно «зависания»: running дольше стольких минут = мёртвый прогон (робот ≤ 420с). */
public const STUCK_AFTER_MINUTES = 15;
/**
* Окно для зависшей `queued`: джоб в норме подхватывается за секунды. Большой запас
* (1 час), чтобы НЕ добить кампанию, которую живой, но занятый бэклогом воркер вот-вот
* запустит. Деньги при этом не теряются они уже списаны и вернутся возвратом.
*/
public const QUEUED_STUCK_AFTER_MINUTES = 60;
public function handle(): void
{
$sandbox = (bool) config('client_tg.sandbox', true);
$cutoff = Carbon::now()->subMinutes(self::STUCK_AFTER_MINUTES);
// Кросс-тенантное перечисление зависших running через BYPASSRLS-роль,
// БЕЗ открытой транзакции (как ChargeTgNameFeeJob).
// БЕЗ открытой транзакции (как PollTelegramModerationJob).
$rows = DB::connection('pgsql_supplier')->table('client_tg_campaigns')
->where('status', Campaign::STATUS_RUNNING)
->where('updated_at', '<', $cutoff)
@@ -63,8 +78,8 @@ class SweepStuckTelegramCampaignsJob implements ShouldQueue
$hasMtsId = $row->mts_campaign_id !== null && $row->mts_campaign_id !== '';
try {
// Возвращаем true, только если реально перевели в failed (нужен release).
$needsRelease = $this->tenantTx($tenantId, function () use ($campaignId, $hasMtsId): bool {
// Возвращаем true, только если реально перевели в failed (нужен возврат).
$needsRefund = $this->tenantTx($tenantId, function () use ($campaignId, $hasMtsId): bool {
// Блокировка + повторная проверка статуса: между перечислением и
// этим моментом finalize мог увести кампанию из running (гонка) —
// тогда не трогаем (идемпотентность).
@@ -86,7 +101,7 @@ class SweepStuckTelegramCampaignsJob implements ShouldQueue
return false;
}
// Черновика нет → заведомо не ушла → добиваем в failed, бронь вернём.
// Черновика нет → заведомо не ушла → добиваем в failed, смету вернём.
$campaign->status_reason = 'Прогон оборвался до создания черновика — кампания не запущена';
$campaign->transitionTo(Campaign::STATUS_FAILED);
@@ -97,15 +112,19 @@ class SweepStuckTelegramCampaignsJob implements ShouldQueue
return true;
});
// Возврат брони — только для заведомо не ушедших (failed) и только в бою
// (в песочнице заморозки не было). Отдельный tenantTx: сбой возврата не
// должен откатить уже проставленный статус. Ключ совпадает с freeze.
if ($needsRelease && ! $sandbox) {
// Возврат сметы — только для заведомо не ушедших (failed) и только в бою
// (в песочнице денег не списывали, refund найдёт сальдо 0). Отдельный
// tenantTx: сбой возврата не должен откатить уже проставленный статус.
if ($needsRefund && ! $sandbox) {
try {
$this->tenantTx($tenantId, fn () => app(AdWalletService::class)
->release($tenantId, 'telegram', 'campaign', $campaignId));
$this->tenantTx($tenantId, function () use ($campaignId): void {
$campaign = Campaign::where('id', $campaignId)->first();
if ($campaign !== null) {
app(TelegramCampaignChargeService::class)->refund($campaign);
}
});
} catch (Throwable $e) {
Log::warning('client_tg.sweep_release_failed', [
Log::warning('client_tg.sweep_refund_failed', [
'campaign_id' => $campaignId,
'error' => $e->getMessage(),
]);
@@ -120,13 +139,75 @@ class SweepStuckTelegramCampaignsJob implements ShouldQueue
}
}
$this->sweepStuckQueued();
$this->sweepStuckModeration();
}
/**
* Зависшие `queued` (деньги списаны на launch, но джоб не стартовал). queued не имеет
* `mts_campaign_id` (его ставит робот в running) робот кабинет не трогал `failed`
* + возврат сметы. lockForUpdate + повторная проверка статуса: если джоб УЖЕ подхватил
* (стал running) не трогаем (гонка решается блокировкой; кто первый, того и статус).
*/
private function sweepStuckQueued(): void
{
$sandbox = (bool) config('client_tg.sandbox', true);
$cutoff = Carbon::now()->subMinutes(self::QUEUED_STUCK_AFTER_MINUTES);
$rows = DB::connection('pgsql_supplier')->table('client_tg_campaigns')
->where('status', Campaign::STATUS_QUEUED)
->where('updated_at', '<', $cutoff)
->get(['id', 'tenant_id']);
foreach ($rows as $row) {
$tenantId = (int) $row->tenant_id;
$campaignId = (int) $row->id;
try {
$needsRefund = $this->tenantTx($tenantId, function () use ($campaignId): bool {
$campaign = Campaign::where('id', $campaignId)->lockForUpdate()->first();
if ($campaign === null || $campaign->status !== Campaign::STATUS_QUEUED) {
return false; // джоб уже подхватил (running) / ушёл дальше — не трогаем
}
$campaign->status_reason = 'Кампания зависла в очереди — воркер не подхватил задачу; запуск отменён, деньги возвращены';
$campaign->transitionTo(Campaign::STATUS_FAILED);
Log::warning('client_tg.sweep_queued_failed', ['campaign_id' => $campaignId]);
return true;
});
// Возврат сметы (в бою). Отдельный tenantTx: сбой возврата не откатит статус.
if ($needsRefund && ! $sandbox) {
try {
$this->tenantTx($tenantId, function () use ($campaignId): void {
$campaign = Campaign::where('id', $campaignId)->first();
if ($campaign !== null) {
app(TelegramCampaignChargeService::class)->refund($campaign);
}
});
} catch (Throwable $e) {
Log::warning('client_tg.sweep_queued_refund_failed', [
'campaign_id' => $campaignId,
'error' => $e->getMessage(),
]);
}
}
} catch (Throwable $e) {
Log::warning('SweepStuckTelegramCampaignsJob: сбой обработки зависшей queued-кампании', [
'campaign_id' => $campaignId,
'tenant_id' => $tenantId,
'error' => $e->getMessage(),
]);
}
}
}
/**
* Ревью-фикс F4: кампания на модерации дольше окна `client_tg.moderation_stuck_hours`
* робот не смог прочитать вердикт (сломались селекторы кабинета / id протух).
* Уводим в `needs_review` для ручного разбора, БЕЗ возврата брони: кампания могла
* Уводим в `needs_review` для ручного разбора, БЕЗ возврата сметы: кампания могла
* реально показываться, деньги вслепую не возвращаем (как ветка «есть mts_id» выше).
* Опросчик не трогает строку, пока вердикта нет, поэтому `updated_at` честно растёт.
*/
+4
View File
@@ -46,6 +46,10 @@ class BalanceTransaction extends Model
public const TYPE_SMS_CHARGE = 'sms_charge';
public const TYPE_TG_AD_CHARGE = 'tg_ad_charge';
public const TYPE_TG_AD_REFUND = 'tg_ad_refund';
public $timestamps = false;
protected $fillable = [
+5 -4
View File
@@ -7,11 +7,12 @@ namespace App\Models\ClientTg;
use Illuminate\Database\Eloquent\Model;
/**
* Отказ клиента от Telegram-рекламы (по номеру телефона, на уровне тенанта).
* Список «не показывать этим номерам» ручной exclude-лист тенанта (по номеру
* телефона). В показах рекламы «отписки» нет (реклама показывается пачке в кабинете
* МТС, а не шлётся адресно); это просто номера, которые клиент не хочет включать
* в аудиторию вырезаются при сборке (TelegramAudienceService).
*
* Tenant-aware с RLS. Используется стоп-листом при сборке аудитории.
*
* Источник: миграция client_tg_optouts.
* Tenant-aware с RLS. Источник: миграция client_tg_optouts.
*/
class Optout extends Model
{
-55
View File
@@ -1,55 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models\ClientTg;
use Illuminate\Database\Eloquent\Model;
/**
* Своё имя/бренд Telegram-рекламы заявка на согласование имени отправителя.
*
* Tenant-aware с RLS, один отправитель на тенанта (UNIQUE tenant_id). Зеркало
* App\Models\ClientSmsSender, но проще: без документов/операторов (согласование в
* Телеграме уточняется живой разведкой §8; здесь денежный жизненный цикл).
*
* Источник: миграция client_tg_senders.
*/
class Sender extends Model
{
protected $table = 'client_tg_senders';
public const STATUS_PENDING = 'pending';
public const STATUS_ACTIVE = 'active';
public const STATUS_SUSPENDED = 'suspended';
public const STATUS_REJECTED = 'rejected';
public const STATUS_CANCELLED = 'cancelled';
protected $fillable = [
'tenant_id',
'name',
'status',
'monthly_fee_rub',
'note',
'requested_by',
'approved_at',
'paid_until',
'debt_since',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'requested_by' => 'integer',
'monthly_fee_rub' => 'decimal:2',
'approved_at' => 'datetime',
'paid_until' => 'date',
'debt_since' => 'date',
];
}
}
-33
View File
@@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models\ClientTg;
use Illuminate\Database\Eloquent\Model;
/**
* Общие настройки клиентской Telegram-рекламы (помесячная плата за своё имя,
* льготный срок долга по имени).
*
* Общая справочная таблица (не tenant-aware, без RLS), одна строка.
*
* Источник: миграция client_tg_settings.
*/
class Setting extends Model
{
protected $table = 'client_tg_settings';
protected $fillable = [
'name_fee_rub_per_month',
'name_debt_grace_days',
];
protected function casts(): array
{
return [
'name_fee_rub_per_month' => 'decimal:2',
'name_debt_grace_days' => 'integer',
];
}
}
-32
View File
@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models\ClientTg;
use Illuminate\Database\Eloquent\Model;
/**
* Шаблон объявления клиентской Telegram-рекламы.
*
* Tenant-aware с RLS.
*
* Источник: миграция client_tg_templates.
*/
class Template extends Model
{
protected $table = 'client_tg_templates';
protected $fillable = [
'tenant_id',
'title',
'body',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
];
}
}
@@ -110,7 +110,9 @@ final class TelegramAudienceService
}
/**
* Стоп-лист: убираем номера, по которым клиент отказался (client_tg_optouts).
* Список «не показывать этим номерам»: вырезаем из аудитории номера, которые клиент
* пометил как исключаемые (client_tg_optouts). В показах рекламы «отписок» нет это
* ручной exclude-лист тенанта.
*
* @param list<string> $phones
* @return list<string>
@@ -6,11 +6,9 @@ namespace App\Services\ClientTg;
use App\Exceptions\Billing\InsufficientBalanceException;
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\AdWallet;
use App\Models\ClientTg\AutoRule;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\CampaignPhone;
use App\Services\Advertising\AdWalletService;
use App\Support\PhoneNormalizer;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
@@ -20,8 +18,9 @@ use Illuminate\Support\Facades\Log;
* Накопитель авто-режима Telegram-рекламы (план §Сессия 4, задача 4.1).
*
* Копит номера новых лидов в один «открытый» черновик авто-кампании; когда наберётся
* пачка ( порога кандидатов по умолчанию 367), доводит смету и ставит кампанию в
* очередь В РАМКАХ `budget_cap_rub` из авто-правила. Ниже порога только копим.
* пачка ( порога кандидатов по умолчанию 367), доводит смету, СПИСЫВАЕТ клиентскую
* смету с общего баланса (как ручной launch) и ставит кампанию в очередь. Ниже порога
* только копим. `budget_cap_rub` из правила лимит трат в кабинете МТС, не резерв.
*
* 🔴 Отличие от СМС-близнеца: СМС шлёт по одной на каждый лид, Telegram копит ПАЧКУ
* МТС Маркетолог показывает рекламу загруженной базе целиком, а не по одному номеру.
@@ -34,7 +33,7 @@ class TelegramAutoAccumulator
public function __construct(
private readonly TelegramAudienceService $audience,
private readonly TelegramTariffService $tariff,
private readonly AdWalletService $wallets,
private readonly TelegramCampaignChargeService $charger,
) {}
/**
@@ -69,11 +68,11 @@ class TelegramAutoAccumulator
]);
}
// Кандидаты (нормализованы, без дублей/стоп-листа) + смета-потолок для UI.
// Кандидаты (нормализованы, без дублей/стоп-листа) + клиентская смета (с наценкой).
$candidates = $this->audience->build($campaign)->candidatesCount;
$campaign->update([
'planned_count' => $candidates,
'estimated_cost_rub' => $this->tariff->estimateRub($candidates),
'estimated_cost_rub' => $this->tariff->clientEstimateRub($candidates),
]);
// Порог пачки — из правила (клиент мог поднять), иначе дефолт из конфига.
@@ -91,16 +90,15 @@ class TelegramAutoAccumulator
}
/**
* Предохранитель авто-рассылки (план §Этап 5, задача 5.1 + ревью-фикс F3). В
* Предохранитель авто-рекламы (план §Этап 5, задача 5.1 + ревью-фикс F3). В
* песочнице деньги не трогаются гейта нет (как ручной launch). В бою:
* - смета пачки + трата за сегодня не должна превышать дневной лимит правила
* (дефолт 0 авто выключено);
* - свободного остатка (balance frozen) должно хватать на ПОТОЛОК брони
* (budget_cap_rub) его и бронируем симметрично ручному launch, иначе кампания
* уходила в очередь без зарезервированных денег (находка I2).
* - смета пачки (клиентская, с наценкой) + трата за сегодня не должна превышать
* дневной лимит правила (дефолт 0 авто выключено);
* - клиентская смета СПИСЫВАЕТСЯ с общего баланса (charge, как ручной launch)
* не хватило мягко пропускаем (приём лидов не должен падать).
* Всё под `lockForUpdate` строки правила счётчик `spent_today_rub` не гоняется
* двумя параллельными джобами одного тенанта. Не прошла держим черновиком и
* пишем причину; прошла бронируем потолок и учитываем трату за сегодня.
* пишем причину; прошла списываем смету и учитываем трату за сегодня.
*/
private function passesSafeguard(int $tenantId, AutoRule $rule, Campaign $campaign): bool
{
@@ -109,10 +107,9 @@ class TelegramAutoAccumulator
}
$cost = (string) $campaign->estimated_cost_rub;
$cap = (string) $campaign->budget_cap_rub;
$today = Carbon::now()->toDateString();
return DB::transaction(function () use ($tenantId, $rule, $campaign, $cost, $cap, $today): bool {
return DB::transaction(function () use ($tenantId, $rule, $campaign, $cost, $today): bool {
$locked = AutoRule::where('id', $rule->id)->lockForUpdate()->first();
if ($locked === null) {
return false;
@@ -135,34 +132,17 @@ class TelegramAutoAccumulator
return false;
}
$wallet = AdWallet::where('tenant_id', $tenantId)->first();
$free = $wallet !== null
? bcsub((string) $wallet->balance_rub, (string) $wallet->frozen_rub, 2)
: '0.00';
if (bccomp($free, $cap, 2) < 0) {
// Списываем клиентскую смету с общего баланса симметрично ручному launch.
// Не хватило — мягко пропускаем (кампания остаётся черновиком, приём лидов
// не падает). Идемпотентность charge по сальдо: повтор не задвоит.
try {
$this->charger->charge($campaign, $cost);
} catch (InsufficientBalanceException $e) {
Log::info('client_tg.auto_skipped', [
'reason' => 'insufficient_balance',
'tenant_id' => $tenantId,
'campaign_id' => $campaign->id,
'cap_rub' => $cap,
'free_rub' => $free,
]);
return false;
}
// Бронируем потолок симметрично ручному launch. InsufficientBalance тут не
// ожидается (free≥cap выше), но при гонке с другим каналом ловим и мягко
// пропускаем — приём лидов не должен падать.
try {
$this->wallets->freeze($tenantId, 'telegram', 'campaign', $campaign->id, $cap);
} catch (InsufficientBalanceException $e) {
Log::info('client_tg.auto_skipped', [
'reason' => 'insufficient_balance_race',
'tenant_id' => $tenantId,
'campaign_id' => $campaign->id,
'cap_rub' => $cap,
'cost_rub' => $cost,
]);
return false;
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Services\ClientTg;
use App\Exceptions\Billing\InsufficientBalanceException;
use App\Models\BalanceTransaction;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use Illuminate\Support\Facades\DB;
/**
* Списание и возврат денег за Telegram-рекламу с ОБЩЕГО баланса тенанта.
*
* Зеркало App\Services\Sms\SmsChargeService: клиент платит с общего баланса
* (`tenants.balance_rub`), как за СМС; отдельного рекламного кошелька с заморозкой
* тут больше нет. Момент списания запуск кампании (до отправки роботом), возврат
* отказ модерации / сбой до кабинета.
*
* 🪤 Источник правды САЛЬДО проводок в `balance_transactions` по кампании
* (related_type=Campaign + related_id): непогашенное списание = count(tg_ad_charge)
* count(tg_ad_refund). Кампанию можно списать вернуть (отказ модерации) списать
* снова (пересдача), поэтому «есть хоть одно списание больше не списывать» неверно:
* пересдача стала бы бесплатной. Считаем по сальдо. Строки пишутся в той же
* транзакции, что и движение денег, и разъехаться с балансом физически не могут.
*
* Порядок важен: сначала замок по тенанту (он сериализует параллельные списания
* одного клиента), и только ПОСЛЕ него проверка идемпотентности.
*
* Здесь Campaign, Tenant и BalanceTransaction живут на одном соединении (`pgsql`),
* поэтому `lockForUpdate()` по тенанту настоящий замок внутри той же транзакции
* (в отличие от СМС, где кампания на `pgsql_supplier`).
*/
final class TelegramCampaignChargeService
{
/** Списать клиентскую смету $priceRub с общего баланса тенанта кампании. */
public function charge(Campaign $campaign, string $priceRub): void
{
DB::transaction(function () use ($campaign, $priceRub): void {
/** @var Tenant $tenant */
$tenant = Tenant::whereKey($campaign->tenant_id)->lockForUpdate()->firstOrFail();
// Идемпотентность по сальдо — строго ПОСЛЕ взятия замка. Есть непогашенное
// списание (charges > refunds) → повтор не списывает. После возврата сальдо
// выравнивается и новое списание (пересдача) проходит.
if ($this->hasOutstandingCharge($campaign)) {
return;
}
if (bccomp($priceRub, '0', 2) === 0) {
return;
}
if (bccomp((string) $tenant->balance_rub, $priceRub, 2) < 0) {
throw new InsufficientBalanceException(
priceKopecks: (int) bcmul($priceRub, '100', 0),
balanceRub: (string) $tenant->balance_rub,
);
}
$newBalance = bcsub((string) $tenant->balance_rub, $priceRub, 2);
DB::table('tenants')->where('id', $tenant->id)->update(['balance_rub' => $newBalance]);
BalanceTransaction::create([
'tenant_id' => $tenant->id,
'type' => BalanceTransaction::TYPE_TG_AD_CHARGE,
'amount_rub' => '-'.$priceRub,
'amount_leads' => null,
'balance_rub_after' => $newBalance,
'balance_leads_after' => null,
'related_type' => Campaign::class,
'related_id' => $campaign->id,
'created_at' => now(),
]);
});
}
/**
* Вернуть ранее списанную смету на общий баланс тенанта. Идемпотентно:
* если списания не было или возврат уже сделан ничего не делает.
*/
public function refund(Campaign $campaign): void
{
DB::transaction(function () use ($campaign): void {
/** @var Tenant $tenant */
$tenant = Tenant::whereKey($campaign->tenant_id)->lockForUpdate()->firstOrFail();
// Нечего возвращать — непогашенного списания нет (списаний не было или все
// уже возвращены).
if (! $this->hasOutstandingCharge($campaign)) {
return;
}
/** @var BalanceTransaction $chargeTx */
$chargeTx = BalanceTransaction::query()
->where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)
->where('related_type', Campaign::class)
->where('related_id', $campaign->id)
->orderByDesc('id')
->firstOrFail();
// Сумма возврата = модуль ПОСЛЕДНЕГО (непогашенного) списания
// (amount_rub у charge отрицательный).
$amount = ltrim((string) $chargeTx->amount_rub, '-');
if (bccomp($amount, '0', 2) === 0) {
return;
}
$newBalance = bcadd((string) $tenant->balance_rub, $amount, 2);
DB::table('tenants')->where('id', $tenant->id)->update(['balance_rub' => $newBalance]);
BalanceTransaction::create([
'tenant_id' => $tenant->id,
'type' => BalanceTransaction::TYPE_TG_AD_REFUND,
'amount_rub' => $amount,
'amount_leads' => null,
'balance_rub_after' => $newBalance,
'balance_leads_after' => null,
'related_type' => Campaign::class,
'related_id' => $campaign->id,
'created_at' => now(),
]);
});
}
/** Есть ли по кампании непогашенное списание: count(tg_ad_charge) > count(tg_ad_refund). */
private function hasOutstandingCharge(Campaign $campaign): bool
{
$charges = BalanceTransaction::query()
->where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)
->where('related_type', Campaign::class)
->where('related_id', $campaign->id)
->count();
$refunds = BalanceTransaction::query()
->where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
->where('related_type', Campaign::class)
->where('related_id', $campaign->id)
->count();
return $charges > $refunds;
}
}
@@ -1,247 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\ClientTg;
use App\Exceptions\Billing\InsufficientBalanceException;
use App\Models\AdWallet;
use App\Models\ClientTg\Sender;
use App\Models\ClientTg\Setting;
use App\Services\Advertising\AdWalletService;
use DomainException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Жизненный цикл «своего имени» Telegram-рекламы (план §Сессия 4, задача 4.3).
*
* Клиент: requestSender (заявка + бронь месячной платы) / disableSender.
* Админ: approve (списание + снятие брони + paid_until +1мес) / reject (снятие
* брони) / disableByAdmin. Зеркало lifecycle СМС (ClientSmsController +
* AdminSmsSenderController), сведённое в один сервис (у TG нет HTTP-слоя имени
* в этой сессии только денежный цикл под тесты).
*
* 🔴 Деньги только через AdWalletService (bcmath). freeze() бросает
* InsufficientBalanceException при недоборе (до списания); charge() НЕ бросает.
* В песочнице (client_tg.sandbox) деньги не двигаются. Канал кошелька 'telegram',
* тип источника 'sender'.
*/
class TelegramSenderService
{
private const CHANNEL = 'telegram';
private const SOURCE = 'sender';
public function __construct(
private readonly AdWalletService $wallet,
) {}
/** Клиент запрашивает своё имя: заявка pending + бронь месячной платы. */
public function requestSender(int $tenantId, string $name, ?int $requestedBy = null): Sender
{
$existing = Sender::where('tenant_id', $tenantId)->first();
if ($existing !== null) {
if ($existing->status === Sender::STATUS_ACTIVE) {
throw new DomainException('У вас уже есть активное имя отправителя');
}
if ($existing->status === Sender::STATUS_PENDING) {
throw new DomainException('Заявка на имя уже на рассмотрении');
}
}
$monthlyFee = (string) (Setting::query()->value('name_fee_rub_per_month') ?? '2500.00');
$sandbox = (bool) config('client_tg.sandbox', true);
return DB::transaction(function () use ($tenantId, $name, $requestedBy, $monthlyFee, $sandbox): Sender {
$sender = Sender::updateOrCreate(
['tenant_id' => $tenantId],
[
'name' => $name,
'status' => Sender::STATUS_PENDING,
'monthly_fee_rub' => $monthlyFee,
'requested_by' => $requestedBy,
'note' => null,
'approved_at' => null,
'paid_until' => null,
'debt_since' => null,
],
);
// Бронь ПОСЛЕ создания строки: freeze бросит InsufficientBalanceException
// при недоборе → транзакция откатится, заявка не сохранится.
if (! $sandbox) {
$this->wallet->freeze($tenantId, self::CHANNEL, self::SOURCE, $sender->id, $monthlyFee);
}
return $sender;
});
}
/** Клиент отключает своё имя. У pending снимаем бронь; ставим cancelled. */
public function disableSender(int $tenantId): Sender
{
$sender = Sender::where('tenant_id', $tenantId)->first();
if ($sender === null) {
throw new ModelNotFoundException('Имя отправителя не найдено');
}
$sandbox = (bool) config('client_tg.sandbox', true);
DB::transaction(function () use ($tenantId, $sender, $sandbox): void {
if ($sender->status === Sender::STATUS_PENDING && ! $sandbox) {
$this->wallet->release($tenantId, self::CHANNEL, self::SOURCE, $sender->id);
}
$sender->update(['status' => Sender::STATUS_CANCELLED]);
});
return $sender->fresh();
}
/**
* Клиент включает отключённое за долг имя обратно (suspended active). В бою:
* проверяем средства ДО списания (charge не бросает) и списываем плату за текущий
* период; идемпотентность по external_key {Y-m} не даёт списать дважды за один
* календарный месяц. В песочнице деньги не трогаем.
*/
public function enableSender(int $tenantId): Sender
{
$sender = Sender::where('tenant_id', $tenantId)->first();
if ($sender === null) {
throw new ModelNotFoundException('Имя отправителя не найдено');
}
if ($sender->status !== Sender::STATUS_SUSPENDED) {
throw new DomainException('Имя не отключено');
}
$sandbox = (bool) config('client_tg.sandbox', true);
return DB::transaction(function () use ($tenantId, $sender, $sandbox): Sender {
if (! $sandbox) {
$fee = (string) $sender->monthly_fee_rub;
$wallet = AdWallet::where('tenant_id', $tenantId)->first();
$free = $wallet !== null
? bcsub((string) $wallet->balance_rub, (string) $wallet->frozen_rub, 2)
: '0.00';
if (bccomp($free, $fee, 2) < 0) {
throw new InsufficientBalanceException(
priceKopecks: (int) bcmul($fee, '100', 0),
balanceRub: $free,
);
}
$externalKey = 'telegram:sender:'.$sender->id.':'.Carbon::now()->format('Y-m');
$this->wallet->charge($tenantId, self::CHANNEL, self::SOURCE, $sender->id, $fee, $externalKey);
}
$sender->update([
'status' => Sender::STATUS_ACTIVE,
'paid_until' => Carbon::now()->addMonthNoOverflow()->toDateString(),
'debt_since' => null,
'note' => null,
]);
return $sender->fresh();
});
}
/**
* Снимок имени для кабинета: сам отправитель (или null), режим песочницы и остаток
* грейса (дней до автоотключения) для имени с долгом. Грейс из настроек
* (name_debt_grace_days, по умолчанию 29), как в помесячном джобе.
*
* @return array{sender: Sender|null, grace_days_left: int|null, sandbox: bool}
*/
public function snapshot(int $tenantId): array
{
$sender = Sender::where('tenant_id', $tenantId)->first();
$graceDaysLeft = null;
if ($sender !== null && $sender->debt_since !== null) {
$grace = (int) (Setting::query()->value('name_debt_grace_days') ?? 29);
$used = (int) Carbon::parse($sender->debt_since)->diffInDays(Carbon::now());
$graceDaysLeft = max(0, $grace - $used);
}
return [
'sender' => $sender,
'grace_days_left' => $graceDaysLeft,
'sandbox' => (bool) config('client_tg.sandbox', true),
];
}
/** Админ одобряет: списывает плату, снимает бронь, active + paid_until +1мес. */
public function approve(int $senderId): Sender
{
$sender = Sender::findOrFail($senderId);
if ($sender->status !== Sender::STATUS_PENDING) {
throw new DomainException('Имя не в статусе на рассмотрении');
}
$sandbox = (bool) config('client_tg.sandbox', true);
DB::transaction(function () use ($sender, $sandbox): void {
if (! $sandbox) {
$externalKey = 'telegram:sender:'.$sender->id.':'.Carbon::now()->format('Y-m');
$this->wallet->charge(
(int) $sender->tenant_id, self::CHANNEL, self::SOURCE, $sender->id,
(string) $sender->monthly_fee_rub, $externalKey,
);
$this->wallet->release((int) $sender->tenant_id, self::CHANNEL, self::SOURCE, $sender->id);
}
$sender->update([
'status' => Sender::STATUS_ACTIVE,
'approved_at' => Carbon::now(),
'paid_until' => Carbon::now()->addMonthNoOverflow()->toDateString(),
'debt_since' => null,
]);
});
return $sender->fresh();
}
/** Админ отклоняет: снимает бронь, ставит rejected с заметкой. */
public function reject(int $senderId, ?string $note = null): Sender
{
$sender = Sender::findOrFail($senderId);
if ($sender->status !== Sender::STATUS_PENDING) {
throw new DomainException('Имя не в статусе на рассмотрении');
}
$sandbox = (bool) config('client_tg.sandbox', true);
DB::transaction(function () use ($sender, $sandbox, $note): void {
if (! $sandbox) {
$this->wallet->release((int) $sender->tenant_id, self::CHANNEL, self::SOURCE, $sender->id);
}
$sender->update([
'status' => Sender::STATUS_REJECTED,
'note' => $note,
]);
});
return $sender->fresh();
}
/** Админ служебно отключает имя (suspended). */
public function disableByAdmin(int $senderId): Sender
{
$sender = Sender::findOrFail($senderId);
$sandbox = (bool) config('client_tg.sandbox', true);
DB::transaction(function () use ($sender, $sandbox): void {
if ($sender->status === Sender::STATUS_PENDING && ! $sandbox) {
$this->wallet->release((int) $sender->tenant_id, self::CHANNEL, self::SOURCE, $sender->id);
}
$sender->update([
'status' => Sender::STATUS_SUSPENDED,
'note' => 'Отключено владельцем — погасить у МТС',
]);
});
return $sender->fresh();
}
}
@@ -30,4 +30,15 @@ final class TelegramTariffService
{
return bcmul((string) max($impressions, 0), $this->pricePerImpressionRub($impressions), 2);
}
/**
* Клиентская смета = базовая смета × наценка (config client_tg.markup).
* Это цена, которую клиент ВИДИТ и ПЛАТИТ (базовый тариф себестоимость-оценка).
*/
public function clientEstimateRub(int $impressions): string
{
$markup = (string) config('client_tg.markup', '1.40');
return bcmul($this->estimateRub($impressions), $markup, 2);
}
}
+10
View File
@@ -14,6 +14,16 @@ return [
*/
'sandbox' => filter_var(env('TG_SANDBOX', true), FILTER_VALIDATE_BOOL),
/*
|--------------------------------------------------------------------------
| Наценка на клиентскую цену Telegram-рекламы
|--------------------------------------------------------------------------
| Клиент платит цену С наценкой: клиентская смета = базовая смета тарифа
| (себестоимость-оценка) × markup. 1.40 = +40%. Строка для bcmath (scale 2).
| Меняется без правки кода через env TG_MARKUP.
*/
'markup' => (string) env('TG_MARKUP', '1.40'),
/*
|--------------------------------------------------------------------------
| Порог авто-пачки
@@ -1,43 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('client_tg_messages', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->unsignedBigInteger('campaign_id')->nullable();
$table->unsignedBigInteger('deal_id')->nullable();
$table->string('phone', 11);
$table->text('operator')->nullable();
$table->string('provider_key', 32)->nullable();
$table->string('status', 24);
$table->decimal('cost_rub', 14, 2)->default('0.00');
$table->string('provider_message_id', 64)->nullable();
$table->string('error', 255)->nullable();
$table->timestamps();
$table->index(['tenant_id', 'campaign_id']);
$table->index(['tenant_id', 'deal_id']);
$table->unique(['tenant_id', 'campaign_id', 'phone'], 'client_tg_messages_tenant_campaign_phone_unique');
});
DB::statement('ALTER TABLE client_tg_messages ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE client_tg_messages FORCE ROW LEVEL SECURITY');
DB::statement('DROP POLICY IF EXISTS tenant_isolation ON client_tg_messages');
DB::statement("CREATE POLICY tenant_isolation ON client_tg_messages USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
DB::statement('GRANT SELECT, INSERT, UPDATE ON client_tg_messages TO crm_app_user');
}
public function down(): void
{
Schema::dropIfExists('client_tg_messages');
}
};
@@ -1,34 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('client_tg_templates', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->string('title', 200);
$table->string('body', 1000);
$table->timestamps();
$table->index(['tenant_id']);
});
DB::statement('ALTER TABLE client_tg_templates ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE client_tg_templates FORCE ROW LEVEL SECURITY');
DB::statement('DROP POLICY IF EXISTS tenant_isolation ON client_tg_templates');
DB::statement("CREATE POLICY tenant_isolation ON client_tg_templates USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
DB::statement('GRANT SELECT, INSERT, UPDATE, DELETE ON client_tg_templates TO crm_app_user');
}
public function down(): void
{
Schema::dropIfExists('client_tg_templates');
}
};
@@ -1,43 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('client_tg_settings', function (Blueprint $table) {
$table->id();
$table->decimal('name_fee_rub_per_month', 10, 2)->default('2500.00');
$table->integer('name_debt_grace_days')->default(29);
$table->timestamps();
});
DB::table('client_tg_settings')->insert([
'name_fee_rub_per_month' => '2500.00',
'name_debt_grace_days' => 29,
'created_at' => now(),
'updated_at' => now(),
]);
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
GRANT SELECT ON client_tg_settings TO crm_app_user;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON client_tg_settings TO crm_admin_user;
END IF;
END $$;
SQL);
}
public function down(): void
{
Schema::dropIfExists('client_tg_settings');
}
};
@@ -1,60 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Своё имя/бренд Telegram-рекламы: заявка на согласование имени отправителя
* (один на тенанта, UNIQUE tenant_id). Зеркало client_sms_senders, но проще
* без документов/операторов (согласование в Телеграме уточняется живой разведкой
* §8 спеки; здесь фиксируем денежный жизненный цикл + помесячную оплату).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('client_tg_senders', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
$table->string('name', 64);
$table->string('status', 16)->default('pending');
$table->decimal('monthly_fee_rub', 10, 2)->default('0.00');
$table->string('note', 255)->nullable();
$table->unsignedBigInteger('requested_by')->nullable();
$table->timestampTz('approved_at')->nullable();
$table->date('paid_until')->nullable();
$table->date('debt_since')->nullable();
$table->timestamps();
$table->unique('tenant_id');
});
DB::statement('ALTER TABLE client_tg_senders ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE client_tg_senders FORCE ROW LEVEL SECURITY');
DB::statement('DROP POLICY IF EXISTS tenant_isolation ON client_tg_senders');
DB::statement("CREATE POLICY tenant_isolation ON client_tg_senders USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::bigint)");
DB::statement('GRANT SELECT, INSERT, UPDATE ON client_tg_senders TO crm_app_user');
// Помесячный джоб перечисляет активные имена кросс-тенантно через
// crm_supplier_worker (BYPASSRLS); админ (crm_admin_user) согласует.
DB::statement(<<<'SQL'
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
GRANT SELECT ON client_tg_senders TO crm_supplier_worker;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
GRANT SELECT, UPDATE ON client_tg_senders TO crm_admin_user;
END IF;
END $$;
SQL);
}
public function down(): void
{
Schema::dropIfExists('client_tg_senders');
}
};
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Новые типы операций в журнале денег Telegram-реклама на общем балансе:
* tg_ad_charge списание клиентской сметы (с наценкой) при запуске кампании,
* tg_ad_refund возврат при отказе модерации / сбое до кабинета.
*
* Одной миграцией, чтобы на прод-кластер CHECK ехал ОДИН раз.
* Список в up() включает уже действующий sms_charge (добавлен 2026_07_23).
*/
return new class extends Migration
{
public function up(): void
{
DB::statement('ALTER TABLE balance_transactions DROP CONSTRAINT IF EXISTS balance_transactions_type_check');
DB::statement(<<<'SQL'
ALTER TABLE balance_transactions ADD CONSTRAINT balance_transactions_type_check
CHECK (type IN (
'trial_bonus','topup','lead_charge','refund','manual_adjustment',
'historical_import','chargeback_writedown','chargeback_repayment',
'migration','autopodbor_charge','sms_charge',
'tg_ad_charge','tg_ad_refund'
))
SQL);
}
public function down(): void
{
DB::statement('ALTER TABLE balance_transactions DROP CONSTRAINT IF EXISTS balance_transactions_type_check');
DB::statement(<<<'SQL'
ALTER TABLE balance_transactions ADD CONSTRAINT balance_transactions_type_check
CHECK (type IN (
'trial_bonus','topup','lead_charge','refund','manual_adjustment',
'historical_import','chargeback_writedown','chargeback_repayment',
'migration','autopodbor_charge','sms_charge'
))
SQL);
}
};
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Чистка телеграм-модуля от СМС-наследия (28.07.2026). Модуль ресейлит МТС Маркетолог
* «показы рекламы по своей базе» там НЕТ ни имени отправителя, ни доставки сообщений
* по одному номеру, ни отписок-шаблонов сообщений. Эти таблицы были механически
* скопированы из СМС-модуля и не относятся к продукту показов (владельцем подтверждено):
* client_tg_senders «своё имя/бренд отправителя» + помесячная абонплата;
* client_tg_settings обе колонки (name_fee_rub_per_month, name_debt_grace_days)
* обслуживали только имя;
* client_tg_messages «по-получательские записи доставки» (копия sales_sms_messages),
* модели не имела, в рабочем коде не использовалась;
* client_tg_templates «шаблоны сообщений», не использовались нигде.
*
* Их create-миграции удалены, поэтому на свежей базе таблицы не создаются и DROP ниже
* no-op (IF EXISTS). На уже мигрированных dev/prod-базах миграция их убирает.
*
* НЕ трогаем: client_tg_campaigns/_campaign_phones/_contacts/_tariffs/_auto_rule (реальные
* показы) и client_tg_optouts (переосмыслен как ручной список «не показывать этим номерам»).
*/
return new class extends Migration
{
public function up(): void
{
DB::statement('DROP TABLE IF EXISTS client_tg_senders CASCADE');
DB::statement('DROP TABLE IF EXISTS client_tg_settings CASCADE');
DB::statement('DROP TABLE IF EXISTS client_tg_messages CASCADE');
DB::statement('DROP TABLE IF EXISTS client_tg_templates CASCADE');
}
public function down(): void
{
// Восстановлению не подлежит: таблицы — СМС-наследие, удалены осознанно вместе
// с их create-миграциями. Откат не пересоздаёт их (продукт показов их не имеет).
}
};
+2 -22
View File
@@ -631,7 +631,7 @@ export async function markInvoicePaid(id: number): Promise<void> {
await apiClient.post(`/api/admin/invoices/${id}/mark-paid`);
}
// --- Клиентская Telegram-реклама: тарифы + настройки (задача 4.4) -----------
// --- Клиентская Telegram-реклама: тарифная сетка (задача 4.4) ---------------
// Зеркало SMS-функций выше. Ступень = ₽ за показ по объёму (у нас — потолок/оценка).
/** Ступень тарифа Telegram-рекламы. */
@@ -647,16 +647,9 @@ export interface AdminTgTariffRow {
price_rub: string;
}
/** Настройки платного имени (часть GET /api/admin/telegram/tariffs, а также PUT /api/admin/telegram/settings). */
export interface AdminTgSettings {
name_fee_rub_per_month: string;
name_debt_grace_days: number;
}
/** Ответ GET /api/admin/telegram/tariffs. */
export interface AdminTgTariffsResponse {
tariffs: AdminTgTariff[];
settings: AdminTgSettings;
}
/** Ответ PUT /api/admin/telegram/tariffs — заменённый набор ступеней. */
@@ -664,7 +657,7 @@ export interface AdminTgTariffsUpdateResponse {
tariffs: AdminTgTariff[];
}
/** GET /api/admin/telegram/tariffs — ступени тарифа + настройки платного имени. */
/** GET /api/admin/telegram/tariffs — ступени тарифа. */
export async function fetchTgTariffs(): Promise<AdminTgTariffsResponse> {
const { data } = await apiClient.get<AdminTgTariffsResponse>('/api/admin/telegram/tariffs');
return data;
@@ -676,16 +669,3 @@ export async function updateTgTariffs(rows: AdminTgTariffRow[]): Promise<AdminTg
const { data } = await apiClient.put<AdminTgTariffsUpdateResponse>('/api/admin/telegram/tariffs', { rows });
return data;
}
/** PUT /api/admin/telegram/settings — плата за имя/мес и срок долга до отключения. */
export async function updateTgSettings(
nameFeeRubPerMonth: string,
nameDebtGraceDays: number,
): Promise<AdminTgSettings> {
await ensureCsrfCookie();
const { data } = await apiClient.put<AdminTgSettings>('/api/admin/telegram/settings', {
name_fee_rub_per_month: nameFeeRubPerMonth,
name_debt_grace_days: nameDebtGraceDays,
});
return data;
}
+9 -45
View File
@@ -111,55 +111,19 @@ export async function resubmitTelegram(id: number, payload: TelegramResubmitPayl
}
/**
* Своё имя отправителя Telegram-рекламы (план §Этап 5, задачи 5.2/5.3).
* Один отправитель на тенанта; клиент заводит/отключает/включает его из кабинета.
* POST /api/telegram/campaigns/{id}/media — приложить картинку/видео к ЧЕРНОВИКУ
* объявления (png/jpg/gif или mp4, ≤50 МБ). Робот отдаёт файл кабинету МТС при запуске.
*/
export interface TelegramSender {
id: number;
name: string;
status: string;
monthly_fee_rub?: string;
paid_until?: string | null;
debt_since?: string | null;
note?: string | null;
}
/** Ответ GET /api/telegram/sender — сам отправитель (или null), остаток грейса, режим. */
export interface TelegramSenderSnapshot {
sender: TelegramSender | null;
grace_days_left: number | null;
sandbox: boolean;
}
/** GET /api/telegram/sender — статус имени тенанта. */
export async function fetchSender(): Promise<TelegramSenderSnapshot> {
const { data } = await apiClient.get<TelegramSenderSnapshot>('/api/telegram/sender');
return data;
}
/** POST /api/telegram/sender — завести имя (в песочнице деньги не трогаются). */
export async function createSender(name: string): Promise<TelegramSender> {
export async function uploadTelegramMedia(id: number, file: File): Promise<TelegramCampaign> {
await ensureCsrfCookie();
const { data } = await apiClient.post<TelegramSender>('/api/telegram/sender', { name });
return data;
}
/** POST /api/telegram/sender/disable — отключить имя. */
export async function disableSender(): Promise<TelegramSender> {
await ensureCsrfCookie();
const { data } = await apiClient.post<TelegramSender>('/api/telegram/sender/disable');
return data;
}
/** POST /api/telegram/sender/enable — включить отключённое за долг имя обратно. */
export async function enableSender(): Promise<TelegramSender> {
await ensureCsrfCookie();
const { data } = await apiClient.post<TelegramSender>('/api/telegram/sender/enable');
const form = new FormData();
form.append('media', file);
const { data } = await apiClient.post<TelegramCampaign>(`/api/telegram/campaigns/${id}/media`, form);
return data;
}
/**
* Правило авторассылки Telegram (план §Этап 5, задача 5.4). Одно на тенанта: клиент
* Правило авто-рекламы Telegram (план §Этап 5, задача 5.4). Одно на тенанта: клиент
* из кабинета включает авто, задаёт объявление, порог пачки, бюджет и дневной лимит.
*/
export interface TelegramAutoRule {
@@ -173,13 +137,13 @@ export interface TelegramAutoRule {
batch_threshold: number | null;
}
/** GET /api/telegram/auto-rule — правило авторассылки тенанта (или дефолты). */
/** GET /api/telegram/auto-rule — правило авто-рекламы тенанта (или дефолты). */
export async function fetchAutoRule(): Promise<TelegramAutoRule> {
const { data } = await apiClient.get<TelegramAutoRule>('/api/telegram/auto-rule');
return data;
}
/** PUT /api/telegram/auto-rule — сохранить правило авторассылки. */
/** PUT /api/telegram/auto-rule — сохранить правило авто-рекламы. */
export async function saveAutoRule(payload: TelegramAutoRule): Promise<TelegramAutoRule> {
await ensureCsrfCookie();
const { data } = await apiClient.put<TelegramAutoRule>('/api/telegram/auto-rule', payload);
+3 -65
View File
@@ -1,34 +1,22 @@
<script setup lang="ts">
/**
* SaaS-admin → «Клиентская Telegram-реклама»: тарифная сетка (ступени ₽ за показ)
* и общие настройки платного имени (плата/мес + срок долга до отключения).
* План §Сессия 4, задача 4.4. Лаконичное зеркало AdminSmsView (без секции имён —
* админ-согласование имени появится после живой разведки §8).
* SaaS-admin → «Клиентская Telegram-реклама»: тарифная сетка (ступени ₽ за показ).
* План §Сессия 4, задача 4.4. Лаконичное зеркало AdminSmsView.
*/
import { onMounted, ref } from 'vue';
import {
fetchTgTariffs,
updateTgTariffs,
updateTgSettings,
type AdminTgTariff,
type AdminTgSettings,
} from '../../api/admin';
import { fetchTgTariffs, updateTgTariffs, type AdminTgTariff } from '../../api/admin';
const tariffs = ref<AdminTgTariff[]>([]);
const settings = ref<AdminTgSettings>({ name_fee_rub_per_month: '0.00', name_debt_grace_days: 0 });
const loadError = ref(false);
const tariffsSaving = ref(false);
const settingsSaving = ref(false);
const tariffsSaved = ref(false);
const settingsSaved = ref(false);
async function load(): Promise<void> {
loadError.value = false;
try {
const data = await fetchTgTariffs();
tariffs.value = data.tariffs;
settings.value = data.settings;
} catch {
loadError.value = true;
}
@@ -58,21 +46,6 @@ async function saveTariffs(): Promise<void> {
}
}
async function saveSettings(): Promise<void> {
settingsSaving.value = true;
settingsSaved.value = false;
try {
const res = await updateTgSettings(
String(settings.value.name_fee_rub_per_month),
Number(settings.value.name_debt_grace_days),
);
settings.value = res;
settingsSaved.value = true;
} finally {
settingsSaving.value = false;
}
}
onMounted(load);
</script>
@@ -148,40 +121,5 @@ onMounted(load);
<span v-if="tariffsSaved" class="text-success text-body-2 ml-3">Сохранено</span>
</v-card-actions>
</v-card>
<!-- Настройки платного имени. -->
<v-card data-testid="tg-settings-card">
<v-card-title>Своё имя / бренд</v-card-title>
<v-card-text>
<div class="d-flex align-center ga-3">
<v-text-field
v-model="settings.name_fee_rub_per_month"
label="Плата за имя, ₽/мес"
density="compact"
hide-details
style="max-width: 200px"
/>
<v-text-field
v-model.number="settings.name_debt_grace_days"
label="Долг до отключения, дней"
type="number"
density="compact"
hide-details
style="max-width: 220px"
/>
</div>
</v-card-text>
<v-card-actions>
<v-btn
color="primary"
:loading="settingsSaving"
data-testid="tg-settings-save"
@click="saveSettings"
>
Сохранить настройки
</v-btn>
<span v-if="settingsSaved" class="text-success text-body-2 ml-3">Сохранено</span>
</v-card-actions>
</v-card>
</div>
</template>
@@ -14,17 +14,16 @@ import {
createTelegram,
launchTelegram,
resubmitTelegram,
uploadTelegramMedia,
type TelegramCampaign,
type TelegramAudienceKind,
type TelegramCreatePayload,
} from '../../api/telegram';
import TelegramSenderPanel from './TelegramSenderPanel.vue';
import TelegramAutoRulePanel from './TelegramAutoRulePanel.vue';
const loading = ref(false);
const sandbox = ref(false);
const balanceRub = ref('0.00');
const frozenRub = ref('0.00');
const campaigns = ref<TelegramCampaign[]>([]);
const adText = ref('');
@@ -34,6 +33,8 @@ const audienceDays = ref('30');
const phonesText = ref('');
const budgetCap = ref('');
const ordCategory = ref('Размещение рекламы');
// Необязательное медиа объявления (картинка/видео) — у МТС объявление может быть с медиа.
const mediaFile = ref<File | null>(null);
// Двухшаговый поток (спека §5): «Рассчитать» создаёт черновик со сметой/охватом,
// «Запустить» переводит его в очередь. draft — результат последнего расчёта; любая
@@ -90,7 +91,6 @@ async function load(): Promise<void> {
const data = await fetchTelegram();
sandbox.value = data.sandbox;
balanceRub.value = data.balance_rub;
frozenRub.value = data.frozen_rub;
campaigns.value = data.campaigns;
} finally {
loading.value = false;
@@ -127,7 +127,11 @@ async function calc(): Promise<void> {
error.value = '';
calculating.value = true;
try {
draft.value = await createTelegram(buildPayload());
const created = await createTelegram(buildPayload());
// Приложили картинку/видео — грузим к свежему черновику (робот отдаст его кабинету МТС).
draft.value = mediaFile.value
? await uploadTelegramMedia(created.id, mediaFile.value)
: created;
} catch {
error.value = 'Не удалось рассчитать кампанию';
} finally {
@@ -143,10 +147,11 @@ async function launch(): Promise<void> {
try {
await launchTelegram(draft.value.id);
draft.value = null;
mediaFile.value = null;
await load();
} catch (e: unknown) {
const status = (e as { response?: { status?: number } })?.response?.status;
error.value = status === 409 ? 'Не хватает денег на кошельке продвижения' : 'Не удалось запустить кампанию';
error.value = status === 409 ? 'Не хватает денег на балансе' : 'Не удалось запустить кампанию';
} finally {
submitting.value = false;
}
@@ -154,7 +159,7 @@ async function launch(): Promise<void> {
// Любая правка формы делает прежний расчёт недействительным — прячем смету и
// снова требуем «Рассчитать», чтобы запуск шёл строго по актуальным данным.
watch([adText, adLink, audienceKind, audienceDays, phonesText, budgetCap, ordCategory], () => {
watch([adText, adLink, audienceKind, audienceDays, phonesText, budgetCap, ordCategory, mediaFile], () => {
draft.value = null;
});
@@ -206,7 +211,7 @@ async function submitResubmit(): Promise<void> {
await load();
} catch (e: unknown) {
const status = (e as { response?: { status?: number } })?.response?.status;
error.value = status === 409 ? 'Не хватает денег на кошельке продвижения' : 'Не удалось пересдать кампанию';
error.value = status === 409 ? 'Не хватает денег на балансе' : 'Не удалось пересдать кампанию';
} finally {
resubmitting.value = false;
}
@@ -256,14 +261,12 @@ onUnmounted(() => {
{{ error }}
</v-alert>
<TelegramSenderPanel />
<TelegramAutoRulePanel />
<v-card variant="outlined" class="mb-6">
<v-card-item>
<v-card-title class="text-subtitle-1">Новая кампания</v-card-title>
<v-card-subtitle>Баланс кошелька продвижения: {{ balanceRub }} (заморожено {{ frozenRub }} )</v-card-subtitle>
<v-card-subtitle>Общий баланс: {{ balanceRub }} </v-card-subtitle>
</v-card-item>
<v-card-text>
<v-textarea
@@ -335,6 +338,18 @@ onUnmounted(() => {
variant="outlined"
/>
<v-file-input
v-model="mediaFile"
data-testid="tg-media"
label="Картинка или видео к объявлению (необязательно)"
accept="image/png,image/jpeg,image/gif,video/mp4"
prepend-icon="mdi-image-outline"
density="compact"
variant="outlined"
show-size
clearable
/>
<v-alert
v-if="draft"
data-testid="tg-estimate"
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* Панель «Авторассылка новым лидам» Telegram-рекламы (план §Этап 5, задача 5.4).
* Панель «Авто-реклама новым лидам» Telegram-рекламы (план §Этап 5, задача 5.4).
*
* Самодостаточна: грузит правило (fetchAutoRule) и даёт клиенту включить авто-режим,
* задать объявление, порог пачки, бюджет на кампанию и дневной лимит трат. Живёт
@@ -80,7 +80,7 @@ onMounted(load);
<template>
<v-card variant="outlined" class="mb-6" data-testid="tg-auto-panel">
<v-card-item>
<v-card-title class="text-subtitle-1">Авторассылка новым лидам</v-card-title>
<v-card-title class="text-subtitle-1">Авто-реклама новым лидам</v-card-title>
<v-card-subtitle>
Новые лиды копятся в пачку и автоматически уходят в рекламу, когда наберётся порог.
</v-card-subtitle>
@@ -96,7 +96,7 @@ onMounted(load);
<v-switch
v-model="enabled"
data-testid="tg-auto-enabled"
label="Включить авторассылку"
label="Включить авто-рекламу"
color="primary"
density="compact"
hide-details
@@ -1,221 +0,0 @@
<script setup lang="ts">
/**
* Панель «Своё имя отправителя» Telegram-рекламы (план §Этап 5, задача 5.3).
*
* Самодостаточна: сама грузит статус имени (fetchSender) и даёт клиенту завести имя,
* отключить его и включить обратно, если оно было отключено за долг. Живёт внутри
* экрана AdvertisingTelegramView. Стиль — как у соседних карточек кабинета
* (variant=outlined, density=compact). Реальные деньги трогает только вне песочницы.
*/
import { onMounted, ref, computed } from 'vue';
import {
fetchSender,
createSender,
disableSender,
enableSender,
type TelegramSender,
} from '../../api/telegram';
const loading = ref(false);
const busy = ref(false);
const error = ref('');
const sender = ref<TelegramSender | null>(null);
const graceDaysLeft = ref<number | null>(null);
const newName = ref('');
/** Человеческий ярлык статуса имени. */
const STATUS_LABELS: Record<string, string> = {
pending: 'Имя на согласовании',
active: 'Имя активно',
suspended: 'Имя отключено за долг',
rejected: 'Заявка отклонена',
cancelled: 'Имя отключено',
};
const statusLabel = computed(() => (sender.value ? (STATUS_LABELS[sender.value.status] ?? sender.value.status) : ''));
// Нет имени или прошлая заявка закрыта/отклонена → предлагаем завести (новое) имя.
const showCreateForm = computed(
() => sender.value === null || ['cancelled', 'rejected'].includes(sender.value.status),
);
const isActive = computed(() => sender.value?.status === 'active');
const isPending = computed(() => sender.value?.status === 'pending');
const isSuspended = computed(() => sender.value?.status === 'suspended');
const canCreate = computed(() => newName.value.trim() !== '' && !busy.value);
async function load(): Promise<void> {
loading.value = true;
try {
const data = await fetchSender();
sender.value = data.sender;
graceDaysLeft.value = data.grace_days_left;
} finally {
loading.value = false;
}
}
async function create(): Promise<void> {
if (!canCreate.value) return;
error.value = '';
busy.value = true;
try {
await createSender(newName.value.trim());
newName.value = '';
await load();
} catch (e: unknown) {
const status = (e as { response?: { status?: number } })?.response?.status;
error.value = status === 409 ? 'Не хватает денег на кошельке продвижения' : 'Не удалось завести имя';
} finally {
busy.value = false;
}
}
async function disable(): Promise<void> {
error.value = '';
busy.value = true;
try {
await disableSender();
await load();
} catch {
error.value = 'Не удалось отключить имя';
} finally {
busy.value = false;
}
}
async function enable(): Promise<void> {
error.value = '';
busy.value = true;
try {
await enableSender();
await load();
} catch (e: unknown) {
const status = (e as { response?: { status?: number } })?.response?.status;
error.value = status === 409 ? 'Не хватает денег на кошельке продвижения' : 'Не удалось включить имя';
} finally {
busy.value = false;
}
}
onMounted(load);
</script>
<template>
<v-card variant="outlined" class="mb-6" data-testid="tg-sender-panel">
<v-card-item>
<v-card-title class="text-subtitle-1">Своё имя отправителя</v-card-title>
<v-card-subtitle>Показывайте рекламу от имени вашего бренда, а не общего кабинета.</v-card-subtitle>
</v-card-item>
<v-card-text>
<v-alert v-if="error" data-testid="tg-sender-error" type="error" variant="tonal" density="compact" class="mb-3">
{{ error }}
</v-alert>
<!-- Нет имени (или прошлое закрыто/отклонено) форма заведения. -->
<template v-if="showCreateForm">
<v-alert
v-if="sender && sender.status === 'rejected' && sender.note"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
>
Прошлая заявка отклонена: {{ sender.note }}
</v-alert>
<v-text-field
v-model="newName"
data-testid="tg-sender-name-input"
label="Имя отправителя (бренд)"
density="compact"
variant="outlined"
hide-details
class="mb-3"
/>
<v-btn
data-testid="tg-sender-create"
color="primary"
:loading="busy"
:disabled="!canCreate"
@click="create"
>
Завести имя
</v-btn>
</template>
<!-- Есть имя: статус + управление. -->
<template v-else>
<div class="d-flex align-center flex-wrap ga-2 mb-1">
<span class="text-subtitle-2">{{ sender?.name }}</span>
<v-chip
data-testid="tg-sender-status"
size="small"
:color="isActive ? 'success' : isSuspended ? 'warning' : 'info'"
variant="tonal"
>
{{ statusLabel }}
</v-chip>
</div>
<div v-if="isActive && sender?.paid_until" data-testid="tg-sender-paid-until" class="text-medium-emphasis mb-2">
Оплачено до: {{ sender.paid_until }}
</div>
<v-alert
v-if="isPending"
type="info"
variant="tonal"
density="compact"
class="mb-2"
>
Заявка на имя отправлена ждём согласования.
</v-alert>
<v-alert
v-if="graceDaysLeft !== null"
data-testid="tg-sender-debt"
type="warning"
variant="tonal"
density="compact"
class="mb-2"
>
Есть задолженность по оплате имени. Пополните кошелёк до автоотключения осталось {{ graceDaysLeft }} дн.
</v-alert>
<v-alert
v-if="isSuspended"
data-testid="tg-sender-suspended"
type="warning"
variant="tonal"
density="compact"
class="mb-2"
>
Отключено за долг. Погасите задолженность у МТС, пополните кошелёк и включите имя снова.
</v-alert>
<div class="d-flex ga-2 mt-1">
<v-btn
v-if="isSuspended"
data-testid="tg-sender-enable"
color="primary"
size="small"
:loading="busy"
@click="enable"
>
Включить
</v-btn>
<v-btn
v-if="isActive || isPending"
data-testid="tg-sender-disable"
color="error"
variant="outlined"
size="small"
:loading="busy"
@click="disable"
>
Отключить
</v-btn>
</div>
</template>
</v-card-text>
</v-card>
</template>
-11
View File
@@ -1,6 +1,5 @@
<?php
use App\Jobs\ClientTg\ChargeTgNameFeeJob;
use App\Jobs\ClientTg\PollTelegramModerationJob;
use App\Jobs\ClientTg\SweepStuckTelegramCampaignsJob;
use App\Jobs\External\RefreshExternalBalancesJob;
@@ -316,16 +315,6 @@ Schedule::command('billing:reconcile-payments')
->onSuccess(fn () => $hb->recordRunResult('billing:reconcile-payments', true, null, null))
->onFailure(fn () => $hb->recordRunResult('billing:reconcile-payments', false, 'Command failed', null));
// Telegram-модуль, задача 4.3: ежемесячная плата за своё имя (client_tg_senders).
// Ежедневно — сам джоб берёт только имена с истёкшим paid_until (месячная каденция
// хранится по-строчно). Долг дольше грейса → имя отключается (suspended). В песочнице
// (TG_SANDBOX) деньги не двигаются. Зеркало ChargeSmsNameFeeJob (05:00 МСК).
Schedule::job(new ChargeTgNameFeeJob)
->dailyAt('05:10')
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\ClientTg\ChargeTgNameFeeJob', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\ClientTg\ChargeTgNameFeeJob', false, 'Job failed', null));
// Telegram-модуль, задача 3.3: уборщик зависших кампаний. Кампания в `running`
// дольше 15 минут = мёртвый прогон робота (штатный ≤ 420с). Без mts_campaign_id →
// failed + возврат брони; с id (черновик создан) → needs_review, деньги НЕ трогаем.
+4 -10
View File
@@ -102,14 +102,9 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/telegram')->group(fu
Route::post('/campaigns/{id}/launch', 'App\Http\Controllers\Api\ClientTg\CampaignController@launch')->whereNumber('id');
Route::post('/campaigns/{id}/cancel', 'App\Http\Controllers\Api\ClientTg\CampaignController@cancel')->whereNumber('id');
Route::post('/campaigns/{id}/resubmit', 'App\Http\Controllers\Api\ClientTg\CampaignController@resubmit')->whereNumber('id');
Route::post('/campaigns/{id}/media', 'App\Http\Controllers\Api\ClientTg\CampaignController@attachMedia')->whereNumber('id');
// Своё имя отправителя (client_tg_senders): клиент заводит/отключает/включает из кабинета.
Route::get('/sender', 'App\Http\Controllers\Api\ClientTg\SenderController@show');
Route::post('/sender', 'App\Http\Controllers\Api\ClientTg\SenderController@store');
Route::post('/sender/disable', 'App\Http\Controllers\Api\ClientTg\SenderController@disable');
Route::post('/sender/enable', 'App\Http\Controllers\Api\ClientTg\SenderController@enable');
// Правило авторассылки (client_tg_auto_rule): клиент управляет авто-режимом из кабинета.
// Правило авто-рекламы (client_tg_auto_rule): клиент управляет авто-режимом из кабинета.
Route::get('/auto-rule', 'App\Http\Controllers\Api\ClientTg\AutoRuleController@show');
Route::put('/auto-rule', 'App\Http\Controllers\Api\ClientTg\AutoRuleController@update');
});
@@ -272,11 +267,10 @@ Route::middleware(['saas-admin', 'admin-db'])->group(function () {
->where('id', '[0-9]+');
});
// Telegram-модуль, задача 4.4: тарифная сетка + настройки клиентской
// Telegram-рекламы (client_tg_tariffs / client_tg_settings, глобальные).
// Telegram-модуль, задача 4.4: тарифная сетка клиентской Telegram-рекламы
// (client_tg_tariffs, глобальная).
Route::get('/api/admin/telegram/tariffs', 'App\Http\Controllers\Api\Admin\TgTariffController@index');
Route::put('/api/admin/telegram/tariffs', 'App\Http\Controllers\Api\Admin\TgTariffController@updateTariffs');
Route::put('/api/admin/telegram/settings', 'App\Http\Controllers\Api\Admin\TgTariffController@updateSettings');
});
// Портал отдела продаж (/api/sales/*). Вход — guard 'sales' (Sanctum, Bearer).
+7 -26
View File
@@ -2,23 +2,21 @@
declare(strict_types=1);
use App\Models\ClientTg\Setting;
use App\Models\ClientTg\Tariff;
use Illuminate\Foundation\Testing\DatabaseTransactions;
/**
* SaaS-admin «Клиентская Telegram-реклама» тарифная сетка (client_tg_tariffs) и
* общие настройки (client_tg_settings, singleton). План §Сессия 4, задача 4.4.
* Зеркало AdminTariffTest (СМС). Гейт группы saas-admin,admin-db (crm_admin_user);
* admin-db переключает соединение на pgsql_admin, а SharesAdminPdo (глобально в
* Pest.php) отдаёт ему ту же PDO-сессию, что у default (видимость засеянной схемы).
* SaaS-admin «Клиентская Telegram-реклама» тарифная сетка (client_tg_tariffs).
* План §Сессия 4, задача 4.4. Зеркало AdminTariffTest (СМС). Гейт группы
* saas-admin,admin-db (crm_admin_user); admin-db переключает соединение на
* pgsql_admin, а SharesAdminPdo (глобально в Pest.php) отдаёт ему ту же PDO-сессию,
* что у default (видимость засеянной схемы).
*
* Сидированы миграциями: 5 ступеней (1/0.45, 1000/0.42, 5000/0.40, 10000/0.38,
* 50000/0.36) + settings (fee 2500.00/мес, grace 29 дней).
* Сидированы миграциями: 5 ступеней (1/0.45, 1000/0.42, 5000/0.40, 10000/0.38, 50000/0.36).
*/
uses(DatabaseTransactions::class);
test('GET /api/admin/telegram/tariffs возвращает сидированные ступени и настройки', function () {
test('GET /api/admin/telegram/tariffs возвращает сидированные ступени', function () {
$r = $this->getJson('/api/admin/telegram/tariffs');
$r->assertStatus(200);
@@ -26,9 +24,6 @@ test('GET /api/admin/telegram/tariffs возвращает сидированн
expect($tariffs)->toHaveCount(5);
expect(collect($tariffs)->pluck('min_qty')->all())->toBe([1, 1000, 5000, 10000, 50000]);
expect($tariffs[0]['price_rub'])->toBe('0.45');
expect($r->json('settings.name_fee_rub_per_month'))->toBe('2500.00');
expect($r->json('settings.name_debt_grace_days'))->toBe(29);
});
test('PUT /api/admin/telegram/tariffs заменяет весь набор ступеней', function () {
@@ -50,20 +45,6 @@ test('PUT /api/admin/telegram/tariffs заменяет весь набор ст
expect(collect($r->json('tariffs'))->pluck('min_qty')->all())->toBe([1, 5000]);
});
test('PUT /api/admin/telegram/settings обновляет singleton настроек', function () {
$r = $this->putJson('/api/admin/telegram/settings', [
'name_fee_rub_per_month' => '3000.00',
'name_debt_grace_days' => 14,
]);
$r->assertStatus(200);
expect($r->json('name_fee_rub_per_month'))->toBe('3000.00');
expect($r->json('name_debt_grace_days'))->toBe(14);
$settings = Setting::first();
expect($settings->name_fee_rub_per_month)->toBe('3000.00');
expect($settings->name_debt_grace_days)->toBe(14);
});
test('PUT /api/admin/telegram/tariffs с некорректной ступенью → 422', function () {
$this->putJson('/api/admin/telegram/tariffs', [
'rows' => [['min_qty' => 0, 'price_rub' => '0.50']],
+29 -13
View File
@@ -3,22 +3,27 @@
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\AdWallet;
use App\Models\BalanceTransaction;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\Contact;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
/**
* Предстартовый гейт аудитории (находка #2, план §Задача 1.5): в БОЕВОМ режиме
* (sandbox=false) `launch` обязан проверить число кандидатов ДО брони денег.
* (sandbox=false) `launch` обязан проверить число кандидатов ДО списания денег.
* Минимум у МТС Маркетолог 367 (client_tg.auto_batch_threshold); <367 или пусто
* робот всё равно упал бы, но деньги уже были бы заморожены. В песочнице гейта
* робот всё равно упал бы, но деньги уже были бы списаны. В песочнице гейта
* нет (денег нет вообще).
*
* Деньги теперь идут с ОБЩЕГО баланса тенанта (tenants.balance_rub) списание
* пишет проводку BalanceTransaction TYPE_TG_AD_CHARGE (отдельного рекламного
* кошелька с заморозкой больше нет). «Гейт отсёк деньги не тронуты» = 0 проводок
* списания; «гейт пройден списали» = 1 проводка + баланс уменьшился.
*
* Auth/tenant как в CampaignApiTest: Tenant::factory + User(tenant_id) + actingAs.
* Синтетические номера 7999 реальные НИКОГДА.
*/
@@ -31,8 +36,8 @@ beforeEach(function () {
config(['client_tg.sandbox' => false]);
});
it('<367 кандидатов в бою → launch 422, бронь НЕ сделана, статус остаётся draft', function () {
app(AdWalletService::class)->topup($this->tenant->id, '5000.00', null, 'test');
it('<367 кандидатов в бою → launch 422, списания НЕ было, статус остаётся draft', function () {
$this->tenant->update(['balance_rub' => '5000.00']);
for ($i = 0; $i < 10; $i++) {
Contact::create([
@@ -56,14 +61,16 @@ it('<367 кандидатов в бою → launch 422, бронь НЕ сдел
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_DRAFT);
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('0.00');
// Гейт отсёк ДО денег: ни одной проводки списания, баланс не тронут.
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id);
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(0);
expect((string) Tenant::find($this->tenant->id)->balance_rub)->toBe('5000.00');
Queue::assertNothingPushed();
});
it('0 кандидатов в бою → launch 422, статус остаётся draft', function () {
app(AdWalletService::class)->topup($this->tenant->id, '5000.00', null, 'test');
$this->tenant->update(['balance_rub' => '5000.00']);
$campaign = Campaign::create([
'tenant_id' => $this->tenant->id, 'status' => Campaign::STATUS_DRAFT,
@@ -77,11 +84,16 @@ it('0 кандидатов в бою → launch 422, статус остаётс
$this->postJson("/api/telegram/campaigns/{$campaign->id}/launch")->assertStatus(422);
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_DRAFT);
// Списания не было.
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id);
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(0);
Queue::assertNothingPushed();
});
it('≥367 кандидатов + денег хватает в бою → launch OK, draft→queued, джоб поставлен, бронь = budget', function () {
app(AdWalletService::class)->topup($this->tenant->id, '5000.00', null, 'test');
it('≥367 кандидатов + денег хватает в бою → launch OK, draft→queued, джоб поставлен, списание произошло', function () {
$this->tenant->update(['balance_rub' => '5000.00']);
for ($i = 0; $i < 400; $i++) {
Contact::create([
@@ -107,6 +119,10 @@ it('≥367 кандидатов + денег хватает в бою → launch
Queue::assertPushed(RunTelegramCampaignJob::class);
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('1000.00');
// Гейт пройден → списали клиентскую смету (база×1.40) с общего баланса: ровно
// одна проводка списания, баланс уменьшился. Точную сумму не ассертим — она
// зависит от числа кандидатов (clientEstimateRub), достаточно факта списания.
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id);
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(1);
expect(bccomp((string) Tenant::find($this->tenant->id)->balance_rub, '5000.00', 2))->toBeLessThan(0);
});
+35 -24
View File
@@ -3,22 +3,24 @@
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\AdWallet;
use App\Models\BalanceTransaction;
use App\Models\ClientTg\AutoRule;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientTg\TelegramAutoAccumulator;
use App\Services\ClientTg\TelegramTariffService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
/**
* Ревью-фикс F3 (Деньги I2): авто-путь ДОЛЖЕН бронировать потолок (budget_cap_rub)
* симметрично ручному launch иначе кампания уходит в очередь без зарезервированных
* денег. Проверка баланса против ПОТОЛКА брони (не только сметы), чтобы freeze не
* бросил в лицо накопителю (best-effort). В песочнице брони нет.
* Ревью-фикс F3 (Деньги I2), денежная модель без кошелька: авто-путь ДОЛЖЕН списывать
* клиентскую смету (estimated_cost_rub, с наценкой) с ОБЩЕГО баланса тенанта
* (tenants.balance_rub) симметрично ручному launch иначе кампания уходит в очередь
* без оплаты. Заморозки/кошелька (AdWallet/AdWalletService) больше нет. Не хватило
* баланса на смету мягкий skip (кампания держится черновиком, приём лидов не падает).
* В песочнице денег не касаемся вовсе.
* Синтетические номера 7999 реальных ПДн нет.
*/
uses(RefreshDatabase::class);
@@ -49,38 +51,44 @@ beforeEach(function () {
Queue::fake();
config(['client_tg.auto_batch_threshold' => 3]);
config(['client_tg.sandbox' => false]);
$this->tenant = Tenant::factory()->create();
$this->cost = app(TelegramTariffService::class)->estimateRub(3); // 1.35
$this->tenant = Tenant::factory()->create(['balance_rub' => '10000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id);
// Клиентская смета за пачку из 3 показов (то, что списывается с баланса).
$this->cost = app(TelegramTariffService::class)->clientEstimateRub(3);
});
afterEach(fn () => Carbon::setTestNow());
it('в бою авто-путь бронирует потолок и ставит в очередь', function () {
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
it('в бою авто-путь списывает клиентскую смету и ставит в очередь', function () {
afRule($this->tenant->id, cap: '2500.00');
afFeed($this->tenant->id, 1, 3);
Queue::assertPushed(RunTelegramCampaignJob::class, 1);
expect(Campaign::where('tenant_id', $this->tenant->id)->first()->status)->toBe(Campaign::STATUS_QUEUED);
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('2500.00'); // забронирован потолок
// Баланс упал ровно на клиентскую смету, списание отражено проводкой.
expect((string) $this->tenant->fresh()->balance_rub)->toBe(bcsub('10000.00', $this->cost, 2))
->and(BalanceTransaction::where('tenant_id', $this->tenant->id)
->where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(1);
});
it('денег хватает на смету, но НЕ на потолок брони → держит черновиком, без брони', function () {
app(AdWalletService::class)->topup($this->tenant->id, '2000.00', 'telegram', 'test'); // > сметы 1.35, < потолка 2500
it('денег НЕ хватает на смету → держит черновиком, без списания', function () {
// Баланс на копейку меньше сметы — списать нельзя.
DB::table('tenants')->where('id', $this->tenant->id)
->update(['balance_rub' => bcsub((string) $this->cost, '0.01', 2)]);
afRule($this->tenant->id, cap: '2500.00');
afFeed($this->tenant->id, 1, 3);
Queue::assertNothingPushed();
expect(Campaign::where('tenant_id', $this->tenant->id)->first()->status)->toBe(Campaign::STATUS_DRAFT);
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('0.00');
// Баланс не тронут, ни одной проводки списания.
expect((string) $this->tenant->fresh()->balance_rub)->toBe(bcsub((string) $this->cost, '0.01', 2))
->and(BalanceTransaction::where('tenant_id', $this->tenant->id)
->where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(0);
});
it('две пачки за день — счётчик суммирует, две брони по потолку', function () {
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
it('две пачки за день — счётчик суммирует, две списанные сметы', function () {
$rule = afRule($this->tenant->id, cap: '2500.00', dailyLimit: '1000.00');
afFeed($this->tenant->id, 1, 3); // пачка 1 → очередь, новая пачка откроется
@@ -88,18 +96,21 @@ it('две пачки за день — счётчик суммирует, дв
Queue::assertPushed(RunTelegramCampaignJob::class, 2);
$rule->refresh();
expect((string) $rule->spent_today_rub)->toBe(bcadd($this->cost, $this->cost, 2)); // 2.70
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('5000.00'); // 2 × потолок
expect((string) $rule->spent_today_rub)->toBe(bcadd($this->cost, $this->cost, 2)); // 2 сметы
// Баланс упал на две сметы, два списания.
expect((string) $this->tenant->fresh()->balance_rub)->toBe(bcsub('10000.00', bcadd($this->cost, $this->cost, 2), 2))
->and(BalanceTransaction::where('tenant_id', $this->tenant->id)
->where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(2);
});
it('в песочнице авто-путь брони не делает', function () {
it('в песочнице авто-путь денег не касается', function () {
config(['client_tg.sandbox' => true]);
afRule($this->tenant->id, cap: '2500.00', dailyLimit: '0.00');
afFeed($this->tenant->id, 1, 3);
Queue::assertPushed(RunTelegramCampaignJob::class, 1);
$wallet = AdWallet::where('tenant_id', $this->tenant->id)->first();
expect($wallet)->toBeNull(); // кошелёк не заводился — денег не касались
// Ни списаний, ни движения баланса — денег в песочнице не трогаем.
expect((string) $this->tenant->fresh()->balance_rub)->toBe('10000.00')
->and(BalanceTransaction::where('tenant_id', $this->tenant->id)->count())->toBe(0);
});
@@ -3,14 +3,15 @@
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\BalanceTransaction;
use App\Models\ClientTg\AutoRule;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientTg\TelegramAutoAccumulator;
use App\Services\ClientTg\TelegramTariffService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Queue;
@@ -18,12 +19,16 @@ use Illuminate\Support\Facades\Queue;
* Предохранители авто-режима Telegram-рекламы (план §Этап 5, задача 5.1).
*
* Авто-путь копит номера и при пороге ставит кампанию в очередь. В БОЮ перед
* постановкой должен пройти двойной гейт: (1) на кошельке хватает свободных денег
* под смету пачки; (2) сумма за сегодня + смета не превышает дневной лимит правила
* (по умолчанию 0 авто выключено). Не прошёл не ставим в очередь, пишем причину.
* постановкой должен пройти двойной гейт: (1) сумма за сегодня + клиентская смета
* пачки не превышает дневной лимит правила (по умолчанию 0 авто выключено);
* (2) клиентская смета СПИСЫВАЕТСЯ с общего баланса тенанта (`tenants.balance_rub`,
* как за СМС) не хватило мягкий пропуск. Не прошёл не ставим в очередь,
* кампания остаётся черновиком, пишем причину.
*
* В песочнице деньги не трогаются (как ручной launch) гейт не вмешивается.
* Синтетические номера 7999 реальных ПДн не касаемся.
* Отдельного рекламного кошелька с заморозкой больше НЕТ: деньги идут с общего
* баланса через TelegramCampaignChargeService (проводка BalanceTransaction
* TYPE_TG_AD_CHARGE). В песочнице деньги не трогаются (как ручной launch)
* гейт не вмешивается. Синтетические номера 7999 реальных ПДн не касаемся.
*/
uses(RefreshDatabase::class);
@@ -58,22 +63,30 @@ function feed(int $tenantId, int $n): void
}
}
/** Сколько сейчас на общем балансе тенанта. */
function tenantBalance(int $tenantId): string
{
return (string) Tenant::whereKey($tenantId)->value('balance_rub');
}
beforeEach(function () {
Carbon::setTestNow('2026-07-28 10:00:00');
Queue::fake();
config(['client_tg.auto_batch_threshold' => 3]); // порог занижен для теста
config(['client_tg.sandbox' => false]); // деньги включены — проверяем боевой гейт
$this->tenant = Tenant::factory()->create();
// Смета пачки из 3 кандидатов = 3 × 0.45 (ступень min_qty=1) = 1.35 ₽.
$this->cost = app(TelegramTariffService::class)->estimateRub(3);
$this->tenant = Tenant::factory()->create(['balance_rub' => '0.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenant->id);
// Клиентская смета пачки из 3 кандидатов = базовая смета × наценка (то, что списывается).
$this->cost = app(TelegramTariffService::class)->clientEstimateRub(3);
});
afterEach(function () {
Carbon::setTestNow();
});
it('в бою при нехватке денег не ставит в очередь и пишет причину', function () {
app(AdWalletService::class)->topup($this->tenant->id, '1.00', 'telegram', 'test'); // < 1.35
it('в бою при нехватке денег на балансе не ставит в очередь и пишет причину', function () {
$this->tenant->update(['balance_rub' => '1.00']); // заведомо меньше клиентской сметы пачки
expect(bccomp('1.00', $this->cost, 2))->toBeLessThan(0); // подстраховка: денег правда мало
safeguardRule($this->tenant->id, dailyLimit: '1000.00');
Log::spy();
@@ -81,22 +94,29 @@ it('в бою при нехватке денег не ставит в очере
Queue::assertNothingPushed();
$campaign = Campaign::where('tenant_id', $this->tenant->id)->first();
expect($campaign->status)->toBe(Campaign::STATUS_DRAFT); // остаётся черновиком
expect($campaign->status)->toBe(Campaign::STATUS_DRAFT); // остаётся черновиком
expect(tenantBalance($this->tenant->id))->toBe('1.00'); // баланс не тронут
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)
->where('related_id', $campaign->id)->count())->toBe(0); // списания не было
Log::shouldHaveReceived('info')->withArgs(fn ($msg) => $msg === 'client_tg.auto_skipped')->atLeast()->once();
});
it('в бою при исчерпанном дневном лимите не ставит в очередь', function () {
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test'); // денег вдоволь
safeguardRule($this->tenant->id, dailyLimit: '1.00'); // лимит < сметы 1.35
it('в бою при исчерпанном дневном лимите не ставит в очередь (cost = клиентская смета)', function () {
$this->tenant->update(['balance_rub' => '10000.00']); // денег вдоволь
safeguardRule($this->tenant->id, dailyLimit: '0.01'); // лимит заведомо < клиентской сметы
feed($this->tenant->id, 3);
Queue::assertNothingPushed();
expect(Campaign::where('tenant_id', $this->tenant->id)->first()->status)->toBe(Campaign::STATUS_DRAFT);
$campaign = Campaign::where('tenant_id', $this->tenant->id)->first();
expect($campaign->status)->toBe(Campaign::STATUS_DRAFT);
expect(tenantBalance($this->tenant->id))->toBe('10000.00'); // упёрлись в лимит ДО списания
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)
->where('related_id', $campaign->id)->count())->toBe(0);
});
it('дневной лимит по умолчанию 0 → авто выключено даже при деньгах', function () {
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
$this->tenant->update(['balance_rub' => '10000.00']);
// daily_limit_rub не задан явно → дефолт 0.00
AutoRule::create([
'tenant_id' => $this->tenant->id,
@@ -110,27 +130,34 @@ it('дневной лимит по умолчанию 0 → авто выклю
feed($this->tenant->id, 3);
Queue::assertNothingPushed();
expect(tenantBalance($this->tenant->id))->toBe('10000.00'); // ничего не списано
});
it('в бою при деньгах и лимите в норме — ставит в очередь и учитывает трату за день', function () {
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
it('в бою при деньгах и лимите в норме — списывает смету с баланса, ставит в очередь и учитывает трату за день', function () {
$this->tenant->update(['balance_rub' => '10000.00']);
$rule = safeguardRule($this->tenant->id, dailyLimit: '1000.00');
feed($this->tenant->id, 3);
Queue::assertPushed(RunTelegramCampaignJob::class, 1);
expect(Campaign::where('tenant_id', $this->tenant->id)->first()->status)->toBe(Campaign::STATUS_QUEUED);
$campaign = Campaign::where('tenant_id', $this->tenant->id)->first();
expect($campaign->status)->toBe(Campaign::STATUS_QUEUED);
// Баланс упал ровно на клиентскую смету, проводка списания одна.
expect(tenantBalance($this->tenant->id))->toBe(bcsub('10000.00', $this->cost, 2));
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)
->where('related_id', $campaign->id)->count())->toBe(1);
$rule->refresh();
expect((string) $rule->spent_today_rub)->toBe($this->cost); // 1.35 записано
expect((string) $rule->spent_today_rub)->toBe($this->cost); // смета записана в трату за день
expect($rule->spent_date->toDateString())->toBe('2026-07-28');
});
it('счётчик за день сбрасывается на новую дату', function () {
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
$rule = safeguardRule($this->tenant->id, dailyLimit: '1.35');
$this->tenant->update(['balance_rub' => '10000.00']);
$rule = safeguardRule($this->tenant->id, dailyLimit: $this->cost);
// Вчерашняя трата «под завязку» — но сегодня лимит должен освободиться.
$rule->update(['spent_today_rub' => '1.35', 'spent_date' => '2026-07-27']);
$rule->update(['spent_today_rub' => $this->cost, 'spent_date' => '2026-07-27']);
feed($this->tenant->id, 3);
@@ -141,9 +168,12 @@ it('счётчик за день сбрасывается на новую дат
it('в песочнице предохранитель не вмешивается — ставит в очередь без денег', function () {
config(['client_tg.sandbox' => true]);
safeguardRule($this->tenant->id, dailyLimit: '0.00'); // ни денег, ни лимита
// Баланс нулевой (из beforeEach), лимита нет — но песочница деньги не трогает.
safeguardRule($this->tenant->id, dailyLimit: '0.00');
feed($this->tenant->id, 3);
Queue::assertPushed(RunTelegramCampaignJob::class, 1);
expect(tenantBalance($this->tenant->id))->toBe('0.00'); // деньги не тронуты
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(0);
});
+10 -10
View File
@@ -3,12 +3,11 @@
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\AdWallet;
use App\Models\BalanceTransaction;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\Contact;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
@@ -56,9 +55,9 @@ it('store создаёт ЧЕРНОВИК со сметой и числом ка
expect($response->json('estimated_cost_rub'))->toBeString();
// Черновик: джоб не ставится, кошелёк не создан.
// Черновик: джоб не ставится, деньги не списываются.
Queue::assertNothingPushed();
expect(AdWallet::where('tenant_id', $this->tenant->id)->exists())->toBeFalse();
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(0);
});
it('store подставляет категорию ОРД по умолчанию «Размещение рекламы»', function () {
@@ -80,8 +79,8 @@ it('audience_kind=deals без срока (audience_days) отклоняется
]))->assertStatus(422)->assertJsonValidationErrors('audience_days');
});
it('index отдаёт список своих кампаний, песочницу и баланс/заморозку кошелька', function () {
app(AdWalletService::class)->topup($this->tenant->id, '100.00', null, 'test');
it('index отдаёт список своих кампаний, песочницу и общий баланс тенанта', function () {
$this->tenant->update(['balance_rub' => '100.00']);
Campaign::create([
'tenant_id' => $this->tenant->id, 'status' => Campaign::STATUS_DRAFT,
'ad_text' => 'x', 'ad_link' => 'https://t.me/x', 'ord_category' => 'Размещение рекламы',
@@ -115,18 +114,19 @@ it('launch (песочница) переводит draft→queued и стави
'id' => $campaign->id, 'status' => Campaign::STATUS_QUEUED,
]);
Queue::assertPushed(RunTelegramCampaignJob::class);
expect(AdWallet::where('tenant_id', $this->tenant->id)->exists())->toBeFalse();
// Песочница: денег не списывали.
expect(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)->count())->toBe(0);
});
it('launch (реальный режим, денег не хватает) → 409, кампания остаётся черновиком', function () {
config(['client_tg.sandbox' => false]);
Queue::fake();
// Кошелёк есть, но денег меньше лимита на объявление.
app(AdWalletService::class)->topup($this->tenant->id, '1.00', null, 'test');
// На балансе меньше клиентской сметы кампании.
$this->tenant->update(['balance_rub' => '1.00']);
// Гейт аудитории (находка #2) срабатывает ДО проверки денег: без ≥367
// кандидатов launch вернул бы 422 раньше, чем дойдёт до брони. Этот тест
// кандидатов launch вернул бы 422 раньше, чем дойдёт до списания. Этот тест
// проверяет именно путь «денег не хватает», поэтому засеваем аудиторию
// с запасом над порогом client_tg.auto_batch_threshold (367).
for ($i = 0; $i < 367; $i++) {
@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
use App\Exceptions\Billing\InsufficientBalanceException;
use App\Models\BalanceTransaction;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\ClientTg\TelegramCampaignChargeService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
/**
* Кусок 1 денежной модели Telegram-рекламы: списание/возврат с ОБЩЕГО баланса
* тенанта (зеркало SmsChargeService). Источник правды идемпотентности
* существование строки в balance_transactions (related_type=Campaign + related_id),
* а не пометка на кампании.
*/
function chargeCampaign(int $tenantId, string $status = Campaign::STATUS_QUEUED): Campaign
{
return Campaign::query()->create([
'tenant_id' => $tenantId,
'status' => $status,
'ad_text' => 'Приходите к нам за услугой',
'ad_link' => 'https://example.test/promo',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '500.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 2,
'estimated_cost_rub' => '315.00',
'created_by' => 1,
]);
}
it('списывает клиентскую смету с общего баланса один раз и идемпотентно', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = chargeCampaign($tenant->id);
$svc = app(TelegramCampaignChargeService::class);
$svc->charge($campaign, '315.00');
$svc->charge($campaign->fresh(), '315.00'); // повтор НЕ должен списать второй раз
expect((string) $tenant->fresh()->balance_rub)->toBe('685.00')
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)
->where('related_type', Campaign::class)
->where('related_id', $campaign->id)->count())->toBe(1);
});
it('не списывает при нехватке баланса (бросает, баланс цел)', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '100.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = chargeCampaign($tenant->id);
expect(fn () => app(TelegramCampaignChargeService::class)->charge($campaign, '315.00'))
->toThrow(InsufficientBalanceException::class);
expect((string) $tenant->fresh()->balance_rub)->toBe('100.00')
->and(BalanceTransaction::where('related_id', $campaign->id)->count())->toBe(0);
});
it('нулевая смета — не списывает и не пишет проводку', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = chargeCampaign($tenant->id);
app(TelegramCampaignChargeService::class)->charge($campaign, '0.00');
expect((string) $tenant->fresh()->balance_rub)->toBe('1000.00')
->and(BalanceTransaction::where('related_id', $campaign->id)->count())->toBe(0);
});
it('возвращает списанную смету на общий баланс, идемпотентно', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = chargeCampaign($tenant->id);
$svc = app(TelegramCampaignChargeService::class);
$svc->charge($campaign, '315.00');
expect((string) $tenant->fresh()->balance_rub)->toBe('685.00');
$svc->refund($campaign->fresh());
$svc->refund($campaign->fresh()); // повтор НЕ должен вернуть второй раз
expect((string) $tenant->fresh()->balance_rub)->toBe('1000.00')
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
->where('related_id', $campaign->id)->count())->toBe(1);
});
it('после возврата можно списать снова (цикл пересдачи: charge→refund→charge)', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = chargeCampaign($tenant->id);
$svc = app(TelegramCampaignChargeService::class);
$svc->charge($campaign, '315.00'); // запуск: сальдо +1
$svc->refund($campaign->fresh()); // отказ модерации: сальдо 0
$svc->charge($campaign->fresh(), '315.00'); // пересдача: должно списать СНОВА, сальдо +1
expect((string) $tenant->fresh()->balance_rub)->toBe('685.00')
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_CHARGE)
->where('related_id', $campaign->id)->count())->toBe(2)
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
->where('related_id', $campaign->id)->count())->toBe(1);
});
it('возврат без списания — ничего не делает', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = chargeCampaign($tenant->id);
app(TelegramCampaignChargeService::class)->refund($campaign);
expect((string) $tenant->fresh()->balance_rub)->toBe('1000.00')
->and(BalanceTransaction::where('related_id', $campaign->id)->count())->toBe(0);
});
@@ -3,20 +3,22 @@
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\AdWallet;
use App\Models\BalanceTransaction;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\CampaignPhone;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramCampaignChargeService;
use App\Services\ClientTg\TelegramRobotRunner;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
/**
* Ревью-фикс F5 (Мост #2/#3): единое ОСТОРОЖНОЕ правило возврата брони — как уборщик.
* Ревью-фикс F5 (Мост #2/#3): единое ОСТОРОЖНОЕ правило возврата денег — как уборщик.
* Если у кампании есть mts_campaign_id (черновик в кабинете реально создан), она МОГЛА
* уйти на модерацию деньги вслепую НЕ возвращаем (иначе оплатим показы из своего
* кармана), помечаем needs_review. Без id чистый провал: failed + возврат брони.
* кармана), помечаем needs_review списание с ОБЩЕГО баланса тенанта держится, refund
* НЕ происходит. Без id чистый провал: failed + возврат списания на баланс.
* Всё в боевом режиме (sandbox=false). Синтетические номера 7999 реальных ПДн нет.
*/
uses(RefreshDatabase::class);
@@ -37,7 +39,7 @@ function crCampaign(int $tenantId, string $status, ?string $mtsId = null): Campa
'budget_cap_rub' => '500.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 2,
'estimated_cost_rub' => '0.00',
'estimated_cost_rub' => '500.00',
'created_by' => 1,
]);
@@ -55,76 +57,85 @@ function crMockRobot(RobotResult $result): void
app()->instance(TelegramRobotRunner::class, $mock);
}
it('finalize: провал робота с id кабинета → needs_review, бронь НЕ возвращается', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
/** Сколько возвратов сметы прошло по кампании (сальдо-источник правды — проводки). */
function crRefundCount(int $campaignId): int
{
return BalanceTransaction::query()
->where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
->where('related_type', Campaign::class)
->where('related_id', $campaignId)
->count();
}
it('finalize: провал робота с id кабинета → needs_review, списание держится, refund НЕ произошёл', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = crCampaign($tenant->id, Campaign::STATUS_QUEUED);
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00');
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
crMockRobot(RobotResult::failed('Оборвалось на подтверждении', 'confirm', '2231200'));
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$fresh = Campaign::find($campaign->id);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($fresh->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and($fresh->mts_campaign_id)->toBe('2231200')
->and((string) $wallet->frozen_rub)->toBe('500.00') // бронь держится
->and((string) $wallet->balance_rub)->toBe('1000.00');
->and(crRefundCount($campaign->id))->toBe(0) // возврата не было
->and((string) $tenant->fresh()->balance_rub)->toBe('500.00'); // списание держится
});
it('finalize: провал робота БЕЗ id кабинета → failed, бронь возвращается', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
it('finalize: провал робота БЕЗ id кабинета → failed, refund произошёл, баланс восстановлен', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = crCampaign($tenant->id, Campaign::STATUS_QUEUED);
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00');
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
crMockRobot(RobotResult::failed('Мало номеров «не МТС»', 'upload'));
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$fresh = Campaign::find($campaign->id);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($fresh->status)->toBe(Campaign::STATUS_FAILED)
->and((string) $wallet->frozen_rub)->toBe('0.00'); // бронь вернулась
->and(crRefundCount($campaign->id))->toBe(1) // возврат произошёл
->and((string) $tenant->fresh()->balance_rub)->toBe('1000.00'); // баланс восстановлен
});
it('failed(): running с id кабинета → needs_review, бронь НЕ возвращается', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
it('failed(): running с id кабинета → needs_review, списание держится, refund НЕ произошёл', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = crCampaign($tenant->id, Campaign::STATUS_RUNNING, mtsId: '2231201');
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00');
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
(new RunTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('boom'));
$fresh = Campaign::find($campaign->id);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($fresh->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and((string) $wallet->frozen_rub)->toBe('500.00');
->and(crRefundCount($campaign->id))->toBe(0)
->and((string) $tenant->fresh()->balance_rub)->toBe('500.00');
});
it('failed(): running без id кабинета → failed, бронь возвращается', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
it('failed(): running без id кабинета → failed, refund произошёл, баланс восстановлен', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = crCampaign($tenant->id, Campaign::STATUS_RUNNING);
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00');
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
(new RunTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('boom'));
$fresh = Campaign::find($campaign->id);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($fresh->status)->toBe(Campaign::STATUS_FAILED)
->and((string) $wallet->frozen_rub)->toBe('0.00');
->and(crRefundCount($campaign->id))->toBe(1)
->and((string) $tenant->fresh()->balance_rub)->toBe('1000.00');
});
it('failed(): уже moderating → статус не трогаем и бронь НЕ возвращаем', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
it('failed(): уже moderating → статус не трогаем, refund НЕ произошёл', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = crCampaign($tenant->id, Campaign::STATUS_MODERATING, mtsId: '2231202');
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00');
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
(new RunTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('boom'));
$fresh = Campaign::find($campaign->id);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($fresh->status)->toBe(Campaign::STATUS_MODERATING)
->and((string) $wallet->frozen_rub)->toBe('500.00');
->and(crRefundCount($campaign->id))->toBe(0)
->and((string) $tenant->fresh()->balance_rub)->toBe('500.00');
});
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
/**
* Загрузка картинки/видео к объявлению Telegram-кампании (пробел показов: у МТС
* объявление может быть с медиа, поле media_path и проброс в робота уже есть
* RunTelegramCampaignJob mediaFile cabinet.js fillAdMedia; не хватало только
* приёма файла от клиента). Медиа прикладываем к ЧЕРНОВИКУ до запуска.
*
* Синтетические номера/данные реальных ПДн нет.
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
Storage::fake('local');
// API-эндпоинт: как axios на фронте — ждём JSON (иначе ошибка валидации уходит редиректом).
$this->withHeaders(['Accept' => 'application/json']);
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
/** Черновик кампании тенанта под приложение медиа. */
function draftForMedia(int $tenantId, int $userId): Campaign
{
return Campaign::create([
'tenant_id' => $tenantId,
'status' => Campaign::STATUS_DRAFT,
'ad_text' => 'Приходите к нам',
'ad_link' => 'https://t.me/example',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => '1000.00',
'audience_kind' => Campaign::AUDIENCE_BASE,
'planned_count' => 0,
'estimated_cost_rub' => '0.00',
'created_by' => $userId,
]);
}
it('прикладывает картинку к черновику: файл сохранён, media_path записан', function () {
$campaign = draftForMedia($this->tenant->id, $this->user->id);
$res = $this->post("/api/telegram/campaigns/{$campaign->id}/media", [
'media' => UploadedFile::fake()->image('ad.jpg'),
]);
$res->assertOk();
$path = Campaign::find($campaign->id)->media_path;
expect($path)->not->toBeNull();
Storage::disk('local')->assertExists($path);
expect($res->json('media_path'))->toBe($path);
});
it('принимает видео mp4', function () {
$campaign = draftForMedia($this->tenant->id, $this->user->id);
$this->post("/api/telegram/campaigns/{$campaign->id}/media", [
'media' => UploadedFile::fake()->create('ad.mp4', 200, 'video/mp4'),
])->assertOk();
expect(Campaign::find($campaign->id)->media_path)->not->toBeNull();
});
it('отклоняет чужой тип файла (например .exe) → 422', function () {
$campaign = draftForMedia($this->tenant->id, $this->user->id);
$this->post("/api/telegram/campaigns/{$campaign->id}/media", [
'media' => UploadedFile::fake()->create('virus.exe', 10, 'application/octet-stream'),
])->assertStatus(422)->assertJsonValidationErrors('media');
expect(Campaign::find($campaign->id)->media_path)->toBeNull();
});
it('нельзя приложить медиа к не-черновику (уже запущена) → 422', function () {
$campaign = draftForMedia($this->tenant->id, $this->user->id);
$campaign->update(['status' => Campaign::STATUS_QUEUED]);
$this->post("/api/telegram/campaigns/{$campaign->id}/media", [
'media' => UploadedFile::fake()->image('ad.jpg'),
])->assertStatus(422);
});
it('замена медиа удаляет прежний файл', function () {
$campaign = draftForMedia($this->tenant->id, $this->user->id);
$this->post("/api/telegram/campaigns/{$campaign->id}/media", [
'media' => UploadedFile::fake()->image('first.jpg'),
])->assertOk();
$first = Campaign::find($campaign->id)->media_path;
$this->post("/api/telegram/campaigns/{$campaign->id}/media", [
'media' => UploadedFile::fake()->image('second.jpg'),
])->assertOk();
$second = Campaign::find($campaign->id)->media_path;
expect($second)->not->toBe($first);
Storage::disk('local')->assertMissing($first);
Storage::disk('local')->assertExists($second);
});
it('изоляция тенантов: чужой черновик → 404', function () {
$tenantB = Tenant::factory()->create();
$foreign = draftForMedia($tenantB->id, $this->user->id);
$this->post("/api/telegram/campaigns/{$foreign->id}/media", [
'media' => UploadedFile::fake()->image('ad.jpg'),
])->assertStatus(404);
});
+4 -15
View File
@@ -5,9 +5,7 @@ declare(strict_types=1);
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\CampaignPhone;
use App\Models\ClientTg\Contact;
use App\Models\ClientTg\Setting;
use App\Models\ClientTg\Tariff;
use App\Models\ClientTg\Template;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -74,7 +72,7 @@ it('round-trips the audience_params array cast', function () {
->and($campaign->audience_params['note'])->toBe('файл');
});
it('creates and reads back a contact and a template', function () {
it('creates and reads back a contact (своя база)', function () {
$tenant = Tenant::factory()->create();
$contact = Contact::create([
@@ -84,23 +82,14 @@ it('creates and reads back a contact and a template', function () {
'operator' => 'Билайн',
]);
$template = Template::create([
'tenant_id' => $tenant->id,
'title' => 'Приветствие',
'body' => 'Добрый день!',
]);
$contact->refresh();
$template->refresh();
expect($contact->phone)->toBe('79990000003')
->and($contact->name)->toBe('Иван Иванов')
->and($template->title)->toBe('Приветствие');
->and($contact->name)->toBe('Иван Иванов');
});
it('reads seeded tariffs and settings', function () {
expect(Tariff::count())->toBe(5)
->and(Setting::first()->name_debt_grace_days)->toBe(29);
it('reads seeded tariffs', function () {
expect(Tariff::count())->toBe(5);
});
it('round-trips the decimal cast on budget_cap_rub', function () {
@@ -3,15 +3,15 @@
declare(strict_types=1);
use App\Jobs\ClientTg\PollTelegramModerationJob;
use App\Models\AdWallet;
use App\Models\AdWalletHold;
use App\Models\BalanceTransaction;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramCampaignChargeService;
use App\Services\ClientTg\TelegramRobotRunner;
use App\Services\NotificationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Mockery\MockInterface;
use Tests\Concerns\SharesSupplierPdo;
@@ -25,15 +25,19 @@ uses(RefreshDatabase::class, SharesSupplierPdo::class);
* Живая отправка робота ставит `moderating` (задача 3.2). Опросчик раз в цикл
* читает кабинет роботом-читалкой (в тестах мок) по `mts_campaign_id` и применяет
* вердикт:
* `rejected` статус `rejected` + причина; возврат брони (в бою); уведомление
* «отклонена»;
* `approved` `launched`; уведомление «одобрена»;
* `rejected` статус `rejected` + причина; ВОЗВРАТ списанной сметы на ОБЩИЙ
* баланс тенанта (в бою); уведомление «отклонена»;
* `approved` `launched`; деньги НЕ трогаем (смету списали при запуске);
* уведомление «одобрена»;
* `moderating` (ещё не проверено) / робот не смог прочитать НЕ трогаем, ждём
* следующего цикла (деньги/статус без изменений).
*
* Денежная модель списание/возврат с ОБЩЕГО баланса (`tenants.balance_rub`) через
* TelegramCampaignChargeService; отдельного рекламного кошелька с заморозкой больше нет.
*/
/** Кампания в заданном статусе (self-contained). */
function pollCampaign(int $tenantId, string $status, ?string $mtsId = null): Campaign
/** Кампания в заданном статусе (self-contained), смета ненулевая. */
function pollCampaign(int $tenantId, string $status, ?string $mtsId = null, string $estimatedCost = '315.00'): Campaign
{
return Campaign::query()->create([
'tenant_id' => $tenantId,
@@ -44,20 +48,16 @@ function pollCampaign(int $tenantId, string $status, ?string $mtsId = null): Cam
'budget_cap_rub' => '1000.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 0,
'estimated_cost_rub' => '0.00',
'estimated_cost_rub' => $estimatedCost,
'created_by' => 1,
]);
}
/** Кошелёк тенанта + активная бронь под кампанию (как в SweepStuckTest). */
function pollWalletWithHold(int $tenantId, int $campaignId, string $balance, string $freeze): void
/** «Уже списано при запуске»: списывает смету кампании с общего баланса тенанта. */
function pollChargeCampaign(Tenant $tenant, Campaign $campaign): void
{
AdWallet::query()->create([
'tenant_id' => $tenantId,
'balance_rub' => $balance,
'frozen_rub' => '0.00',
]);
app(AdWalletService::class)->freeze($tenantId, 'telegram', 'campaign', $campaignId, $freeze);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
}
/** Подменяет робота-читалку мок-результатом. */
@@ -86,11 +86,14 @@ it('RobotResult знает moderationStatus и разбирает его из JS
expect(RobotResult::fromRobotJson(['ok' => true])->moderationStatus)->toBeNull();
});
it('вердикт «отклонена» → rejected + причина, бронь возвращена, уведомление «отклонена»', function () {
it('вердикт «отклонена» → rejected + причина, смета возвращена на общий баланс, уведомление «отклонена»', function () {
config()->set('client_tg.sandbox', false);
$t = Tenant::factory()->create();
$c = pollCampaign($t->id, Campaign::STATUS_MODERATING, '2231134');
pollWalletWithHold($t->id, $c->id, '5000.00', '1000.00');
$t = Tenant::factory()->create(['balance_rub' => '5000.00']);
$c = pollCampaign($t->id, Campaign::STATUS_MODERATING, '2231134', '315.00');
// Уже списано при запуске кампании → баланс просел.
pollChargeCampaign($t, $c);
expect((string) $t->fresh()->balance_rub)->toBe('4685.00');
pollMockRobot(new RobotResult(ok: true, moderationStatus: 'rejected', reason: 'Ссылка недоступна'));
$notify = pollMockNotifications();
@@ -102,15 +105,20 @@ it('вердикт «отклонена» → rejected + причина, бро
$fresh = Campaign::find($c->id);
expect($fresh->status)->toBe(Campaign::STATUS_REJECTED)
->and($fresh->status_reason)->toBe('Ссылка недоступна')
->and((string) AdWallet::where('tenant_id', $t->id)->value('frozen_rub'))->toBe('0.00')
->and(AdWalletHold::where('source_id', $c->id)->value('status'))->toBe(AdWalletHold::STATUS_RELEASED);
// Отказ вернул смету на общий баланс — восстановлен до фондирования.
->and((string) $t->fresh()->balance_rub)->toBe('5000.00')
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
->where('related_type', Campaign::class)
->where('related_id', $c->id)->count())->toBe(1);
});
it('вердикт «одобрена» → launched, уведомление «одобрена», деньги не тронуты', function () {
config()->set('client_tg.sandbox', false);
$t = Tenant::factory()->create();
$c = pollCampaign($t->id, Campaign::STATUS_MODERATING, '2231140');
pollWalletWithHold($t->id, $c->id, '5000.00', '1000.00');
$t = Tenant::factory()->create(['balance_rub' => '5000.00']);
$c = pollCampaign($t->id, Campaign::STATUS_MODERATING, '2231140', '315.00');
// Списано при запуске; одобрение денег НЕ трогает.
pollChargeCampaign($t, $c);
pollMockRobot(new RobotResult(ok: true, moderationStatus: 'approved'));
$notify = pollMockNotifications();
@@ -120,15 +128,19 @@ it('вердикт «одобрена» → launched, уведомление «
PollTelegramModerationJob::dispatchSync();
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_LAUNCHED)
->and((string) AdWallet::where('tenant_id', $t->id)->value('frozen_rub'))->toBe('1000.00')
->and(AdWalletHold::where('source_id', $c->id)->value('status'))->toBe(AdWalletHold::STATUS_ACTIVE);
// Списание осталось — approve ничего не возвращает.
->and((string) $t->fresh()->balance_rub)->toBe('4685.00')
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
->where('related_type', Campaign::class)
->where('related_id', $c->id)->count())->toBe(0);
});
it('вердикт «ещё на модерации» → остаётся moderating, без уведомлений и без денег', function () {
config()->set('client_tg.sandbox', false);
$t = Tenant::factory()->create();
$c = pollCampaign($t->id, Campaign::STATUS_MODERATING, '2231150');
pollWalletWithHold($t->id, $c->id, '5000.00', '1000.00');
$t = Tenant::factory()->create(['balance_rub' => '5000.00']);
$c = pollCampaign($t->id, Campaign::STATUS_MODERATING, '2231150', '315.00');
pollChargeCampaign($t, $c);
pollMockRobot(new RobotResult(ok: true, moderationStatus: 'moderating'));
$notify = pollMockNotifications();
@@ -138,7 +150,10 @@ it('вердикт «ещё на модерации» → остаётся moder
PollTelegramModerationJob::dispatchSync();
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_MODERATING)
->and((string) AdWallet::where('tenant_id', $t->id)->value('frozen_rub'))->toBe('1000.00');
// Ни списания, ни возврата — баланс остался на уровне после запуска.
->and((string) $t->fresh()->balance_rub)->toBe('4685.00')
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
->where('related_id', $c->id)->count())->toBe(0);
});
it('робот не смог прочитать вердикт (moderationStatus=null) → moderating без изменений', function () {
+57 -42
View File
@@ -3,33 +3,43 @@
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Models\AdWallet;
use App\Models\BalanceTransaction;
use App\Models\ClientTg\Campaign;
use App\Models\ClientTg\CampaignPhone;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramCampaignChargeService;
use App\Services\ClientTg\TelegramRobotRunner;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
/**
* Задача 2.3 ВОЗВРАТ брони кошелька: при отказе робота (finalize), перманентном
* сбое джоба (failed) и отмене кампании (cancel). Ключ брони совпадает с freeze в
* launch: ('telegram', 'campaign', $campaign->id). Гейт !sandbox везде в песочнице
* заморозки не было, release бросил бы firstOrFail. Списание по факту задача 2.4.
* Задача 2.3 ВОЗВРАТ денег с ОБЩЕГО баланса тенанта (tenants.balance_rub, как в
* СМС) при отказе робота (finalize), перманентном сбое джоба (failed) и отмене
* кампании (cancel). Старого рекламного кошелька с freeze/frozen_rub больше нет:
* деньги списываются на запуске (charge) и возвращаются (refund) через
* TelegramCampaignChargeService. Источник правды идемпотентности сальдо проводок
* balance_transactions (TYPE_TG_AD_CHARGE TYPE_TG_AD_REFUND по related Campaign).
*
* Все тесты по умолчанию в БОЕВОМ режиме (sandbox=false) проверяем денежный путь.
* Ключевые развилки, которые проверяем:
* - отказ робота БЕЗ mts_campaign_id failed + refund (деньги вернулись);
* - песочница денег не списывали, refund не зовётся (сальдо 0);
* - cancel из draft списания не было refund no-op (баланс цел);
* - cancel из running 422 (машина статусов);
* - cancel в песочнице денег не трогали.
*
* Все денежные тесты по умолчанию в БОЕВОМ режиме (sandbox=false).
* Синтетические номера 7999 реальные НИКОГДА.
*/
beforeEach(function () {
config()->set('client_tg.sandbox', false);
});
/** Кампания-список заданного статуса с двумя фейковыми номерами. */
function refundCampaign(int $tenantId, string $status, string $budgetCap = '500.00'): Campaign
/** Кампания-список заданного статуса с двумя фейковыми номерами и ненулевой сметой. */
function refundCampaign(int $tenantId, string $status, string $estimatedCost = '315.00'): Campaign
{
$campaign = Campaign::query()->create([
'tenant_id' => $tenantId,
@@ -37,10 +47,10 @@ function refundCampaign(int $tenantId, string $status, string $budgetCap = '500.
'ad_text' => 'Приходите к нам за услугой',
'ad_link' => 'https://example.test/promo',
'ord_category' => 'Размещение рекламы',
'budget_cap_rub' => $budgetCap,
'budget_cap_rub' => '500.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 2,
'estimated_cost_rub' => '0.00',
'estimated_cost_rub' => $estimatedCost,
'created_by' => 1,
]);
@@ -63,55 +73,61 @@ function refundMockRobot(RobotResult $result): void
app()->instance(TelegramRobotRunner::class, $mock);
}
it('отказ робота возвращает бронь (frozen→0, balance цел)', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
$campaign = refundCampaign($tenant->id, Campaign::STATUS_QUEUED, '500.00');
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00');
it('отказ робота (без mts_id) возвращает списанную смету на общий баланс', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = refundCampaign($tenant->id, Campaign::STATUS_QUEUED, '315.00');
// На запуске смету списали (draft→queued это делает контроллер; здесь эмулируем).
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
expect((string) $tenant->fresh()->balance_rub)->toBe('685.00');
// Робот падает без campaignId → черновик в кабинете НЕ создан → failed + возврат.
refundMockRobot(RobotResult::failed('Канал недоступен', 'upload'));
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$fresh = Campaign::find($campaign->id);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect($fresh->status)->toBe(Campaign::STATUS_FAILED)
->and((string) $wallet->frozen_rub)->toBe('0.00')
->and((string) $wallet->balance_rub)->toBe('1000.00');
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_FAILED)
->and((string) $tenant->fresh()->balance_rub)->toBe('1000.00')
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
->where('related_type', Campaign::class)
->where('related_id', $campaign->id)->count())->toBe(1);
});
it('в песочнице отказ робота НЕ трогает кошелёк (бронь не возвращается — её и нет)', function () {
it('в песочнице отказ робота НЕ трогает деньги (не списывали — нечего возвращать)', function () {
config()->set('client_tg.sandbox', true);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
$campaign = refundCampaign($tenant->id, Campaign::STATUS_QUEUED, '500.00');
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '500.00');
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = refundCampaign($tenant->id, Campaign::STATUS_QUEUED, '315.00');
// В песочнице списания на запуске нет — денег на кампании не трогали.
refundMockRobot(RobotResult::failed('Канал недоступен', 'upload'));
RunTelegramCampaignJob::dispatchSync($campaign->id, $tenant->id);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('500.00');
expect((string) $tenant->fresh()->balance_rub)->toBe('1000.00')
->and(BalanceTransaction::where('related_id', $campaign->id)
->whereIn('type', [BalanceTransaction::TYPE_TG_AD_CHARGE, BalanceTransaction::TYPE_TG_AD_REFUND])
->count())->toBe(0);
});
it('cancel из draft возвращает бронь и ставит cancelled', function () {
$tenant = Tenant::factory()->create();
it('cancel из draft: не списывали → возврат no-op, баланс цел, статус cancelled', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user);
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
// Черновик деньги не списывал — cancel вызовет refund, но возвращать нечего (сальдо 0).
$campaign = refundCampaign($tenant->id, Campaign::STATUS_DRAFT, '300.00');
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '300.00');
$this->postJson("/api/telegram/campaigns/{$campaign->id}/cancel")
->assertOk()
->assertJsonPath('status', Campaign::STATUS_CANCELLED);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('0.00')
->and((string) $wallet->balance_rub)->toBe('1000.00');
expect((string) $tenant->fresh()->balance_rub)->toBe('1000.00')
->and(BalanceTransaction::where('related_id', $campaign->id)->count())->toBe(0);
});
it('cancel из running запрещён 422, статус остаётся running', function () {
$tenant = Tenant::factory()->create();
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user);
$campaign = refundCampaign($tenant->id, Campaign::STATUS_RUNNING, '300.00');
@@ -121,19 +137,18 @@ it('cancel из running запрещён 422, статус остаётся runn
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_RUNNING);
});
it('cancel в песочнице не трогает кошелёк (frozen сохраняется)', function () {
it('cancel в песочнице не трогает деньги (баланс цел, проводок нет)', function () {
config()->set('client_tg.sandbox', true);
$tenant = Tenant::factory()->create();
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user);
app(AdWalletService::class)->topup($tenant->id, '1000.00', 'telegram', 'Пополнение');
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = refundCampaign($tenant->id, Campaign::STATUS_DRAFT, '300.00');
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '300.00');
$this->postJson("/api/telegram/campaigns/{$campaign->id}/cancel")
->assertOk()
->assertJsonPath('status', Campaign::STATUS_CANCELLED);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('300.00');
expect((string) $tenant->fresh()->balance_rub)->toBe('1000.00')
->and(BalanceTransaction::where('related_id', $campaign->id)->count())->toBe(0);
});
+41 -26
View File
@@ -3,13 +3,14 @@
declare(strict_types=1);
use App\Jobs\ClientTg\ResubmitTelegramCampaignJob;
use App\Models\AdWallet;
use App\Models\BalanceTransaction;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientTg\RobotResult;
use App\Services\ClientTg\TelegramCampaignChargeService;
use App\Services\ClientTg\TelegramRobotRunner;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
@@ -98,12 +99,14 @@ it('бой: робот ok+resubmitted=true → moderating, id кабинета
->and($captured['submitMode'])->toBe('live');
});
it('отказ робота (есть id кабинета) → needs_review, бронь НЕ возвращается, причина записана', function () {
it('отказ робота (есть id кабинета) → needs_review, refund НЕ произошёл, списание держится, причина записана', function () {
config()->set('client_tg.sandbox', false);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', 'telegram', 'Пополнение');
$campaign = tgResubmitQueued($tenant->id);
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '1000.00');
$tenant = Tenant::factory()->create(['balance_rub' => '5000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = tgResubmitQueued($tenant->id, ['estimated_cost_rub' => '1000.00']);
// «Уже списано»: списываем клиентскую смету с общего баланса тенанта.
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
expect((string) $tenant->fresh()->balance_rub)->toBe('4000.00');
mockResubmitRunner(RobotResult::failed('Не удалось войти в «Исправить»', 'resubmit-open'));
@@ -113,40 +116,52 @@ it('отказ робота (есть id кабинета) → needs_review, б
expect($fresh->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and($fresh->status_reason)->toBe('Не удалось войти в «Исправить»');
// Бронь НЕ вернули (могла уйти на модерацию) — frozen остаётся.
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('1000.00');
// Refund НЕ произошёл (кампания могла уйти на модерацию) — списание держим,
// баланс не восстановлен, проводки возврата нет.
expect((string) $tenant->fresh()->balance_rub)->toBe('4000.00')
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
->where('related_id', $campaign->id)->count())->toBe(0);
});
it('перманентный сбой в queued (есть id кабинета) → failed + бронь ВЕРНУЛАСЬ (робот кабинет не трогал)', function () {
it('перманентный сбой в queued (есть id кабинета) → failed + refund произошёл, баланс восстановлен (робот кабинет не трогал)', function () {
// Регресс I-1: у пересдачи queued ВСЕГДА с mts_campaign_id, но в queued Фаза A не
// закоммитила running → кабинет не тронут → безопасно failed + release. Без правки
// кампания зависала бы в queued с замороженной бронью (уборщик queued не метёт).
// закоммитила running → кабинет не тронут → безопасно failed + refund. Без правки
// кампания зависала бы в queued со списанными деньгами (уборщик queued не метёт).
config()->set('client_tg.sandbox', false);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', 'telegram', 'Пополнение');
$campaign = tgResubmitQueued($tenant->id); // status queued + mts_campaign_id
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '1000.00');
$tenant = Tenant::factory()->create(['balance_rub' => '5000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = tgResubmitQueued($tenant->id, ['estimated_cost_rub' => '1000.00']); // status queued + mts_campaign_id
// «Уже списано»: списываем клиентскую смету с общего баланса тенанта.
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
expect((string) $tenant->fresh()->balance_rub)->toBe('4000.00');
(new ResubmitTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('воркер убит'));
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_FAILED);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('0.00'); // бронь вернулась
// Refund произошёл — смета вернулась на общий баланс, проводка возврата записана.
expect((string) $tenant->fresh()->balance_rub)->toBe('5000.00')
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
->where('related_id', $campaign->id)->count())->toBe(1);
});
it('перманентный сбой в running (есть id кабинета) → needs_review, бронь НЕ вернулась (мог тронуть кабинет)', function () {
it('перманентный сбой в running (есть id кабинета) → needs_review, refund НЕ произошёл, списание держится (мог тронуть кабинет)', function () {
config()->set('client_tg.sandbox', false);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', 'telegram', 'Пополнение');
$campaign = tgResubmitQueued($tenant->id, ['status' => Campaign::STATUS_RUNNING]);
app(AdWalletService::class)->freeze($tenant->id, 'telegram', 'campaign', $campaign->id, '1000.00');
$tenant = Tenant::factory()->create(['balance_rub' => '5000.00']);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = tgResubmitQueued($tenant->id, ['status' => Campaign::STATUS_RUNNING, 'estimated_cost_rub' => '1000.00']);
// «Уже списано»: списываем клиентскую смету с общего баланса тенанта.
app(TelegramCampaignChargeService::class)->charge($campaign, (string) $campaign->estimated_cost_rub);
expect((string) $tenant->fresh()->balance_rub)->toBe('4000.00');
(new ResubmitTelegramCampaignJob($campaign->id, $tenant->id))->failed(new RuntimeException('таймаут робота'));
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_NEEDS_REVIEW);
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->frozen_rub)->toBe('1000.00'); // бронь держим
// Refund НЕ произошёл — списание держим до ручной сверки, баланс не восстановлен.
expect((string) $tenant->fresh()->balance_rub)->toBe('4000.00')
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
->where('related_id', $campaign->id)->count())->toBe(0);
});
it('идемпотентно: не-queued кампания — джоб no-op, робота не зовёт', function () {
-23
View File
@@ -23,13 +23,6 @@ it('creates client_tg_campaign_phones with expected columns', function () {
]))->toBeTrue();
});
it('creates client_tg_messages with expected columns', function () {
expect(Schema::hasTable('client_tg_messages'))->toBeTrue();
expect(Schema::hasColumns('client_tg_messages', [
'tenant_id', 'campaign_id', 'deal_id', 'phone', 'status', 'cost_rub', 'error',
]))->toBeTrue();
});
it('creates client_tg_optouts with expected columns', function () {
expect(Schema::hasTable('client_tg_optouts'))->toBeTrue();
expect(Schema::hasColumns('client_tg_optouts', [
@@ -44,13 +37,6 @@ it('creates client_tg_contacts with expected columns', function () {
]))->toBeTrue();
});
it('creates client_tg_templates with expected columns', function () {
expect(Schema::hasTable('client_tg_templates'))->toBeTrue();
expect(Schema::hasColumns('client_tg_templates', [
'tenant_id', 'title', 'body',
]))->toBeTrue();
});
it('creates client_tg_tariffs with expected columns and seeds exactly 5 rows', function () {
expect(Schema::hasTable('client_tg_tariffs'))->toBeTrue();
expect(Schema::hasColumns('client_tg_tariffs', [
@@ -60,15 +46,6 @@ it('creates client_tg_tariffs with expected columns and seeds exactly 5 rows', f
expect(DB::table('client_tg_tariffs')->count())->toBe(5);
});
it('creates client_tg_settings with expected columns and seeds exactly 1 row', function () {
expect(Schema::hasTable('client_tg_settings'))->toBeTrue();
expect(Schema::hasColumns('client_tg_settings', [
'name_fee_rub_per_month', 'name_debt_grace_days',
]))->toBeTrue();
expect(DB::table('client_tg_settings')->count())->toBe(1);
});
it('enables row level security on client_tg_campaigns with a tenant_isolation policy', function () {
$rls = DB::selectOne("SELECT relrowsecurity FROM pg_class WHERE relname = 'client_tg_campaigns'");
expect((bool) $rls->relrowsecurity)->toBeTrue();
@@ -1,147 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\AdWallet;
use App\Models\AdWalletTransaction;
use App\Models\ClientTg\Sender;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
/**
* Клиентское HTTP-API «своего имени» Telegram-рекламы (план §Этап 5, задача 5.2).
* Тонкий слой над TelegramSenderService: клиент заводит/отключает/включает имя из
* кабинета. GET отдаёт статус + остаток грейса. Песочница денег не трогает.
*
* Auth/tenant как у CampaignApiTest: Tenant::factory + User(tenant_id) + actingAs;
* middleware ['auth:sanctum','tenant']. Scope тенантом из $request->user().
*/
uses(RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow('2026-07-28 10:00:00');
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
afterEach(function () {
Carbon::setTestNow();
});
it('GET /sender без имени → sender null, песочница, грейс null', function () {
$this->getJson('/api/telegram/sender')
->assertOk()
->assertJsonPath('sender', null)
->assertJsonPath('sandbox', true)
->assertJsonPath('grace_days_left', null);
});
it('POST /sender заводит имя (pending), в песочнице деньги не трогает', function () {
$this->postJson('/api/telegram/sender', ['name' => 'МойБренд'])
->assertCreated()
->assertJsonPath('status', Sender::STATUS_PENDING)
->assertJsonPath('name', 'МойБренд');
expect(Sender::where('tenant_id', $this->tenant->id)->value('status'))->toBe(Sender::STATUS_PENDING);
expect(AdWallet::where('tenant_id', $this->tenant->id)->exists())->toBeFalse(); // денег не трогали
});
it('POST /sender без имени → 422', function () {
$this->postJson('/api/telegram/sender', [])
->assertStatus(422)
->assertJsonValidationErrors(['name']);
});
it('POST /sender при активном имени → 422', function () {
Sender::create([
'tenant_id' => $this->tenant->id, 'name' => 'Есть', 'status' => Sender::STATUS_ACTIVE,
'monthly_fee_rub' => '2500.00', 'paid_until' => '2026-08-27',
]);
$this->postJson('/api/telegram/sender', ['name' => 'Другое'])->assertStatus(422);
});
it('POST /sender/disable отключает имя (cancelled)', function () {
$this->postJson('/api/telegram/sender', ['name' => 'МойБренд'])->assertCreated();
$this->postJson('/api/telegram/sender/disable')
->assertOk()
->assertJsonPath('status', Sender::STATUS_CANCELLED);
});
it('GET /sender отдаёт остаток грейса для имени с долгом', function () {
Sender::create([
'tenant_id' => $this->tenant->id, 'name' => 'Бренд', 'status' => Sender::STATUS_ACTIVE,
'monthly_fee_rub' => '2500.00', 'paid_until' => '2026-07-26', 'debt_since' => '2026-07-23',
]);
// грейс 29 дней, долг с 23.07 → на 28.07 прошло 5 дней → осталось 24.
$this->getJson('/api/telegram/sender')
->assertOk()
->assertJsonPath('sender.status', Sender::STATUS_ACTIVE)
->assertJsonPath('grace_days_left', 24);
});
it('GET /sender не видит имя чужого тенанта', function () {
$other = Tenant::factory()->create();
Sender::create([
'tenant_id' => $other->id, 'name' => 'Чужой', 'status' => Sender::STATUS_ACTIVE,
'monthly_fee_rub' => '2500.00', 'paid_until' => '2026-08-27',
]);
$this->getJson('/api/telegram/sender')->assertOk()->assertJsonPath('sender', null);
});
// --- Живой режим (деньги включены) ----------------------------------------
it('POST /sender/enable включает suspended → active, списывает плату один раз в месяц', function () {
config(['client_tg.sandbox' => false]);
app(AdWalletService::class)->topup($this->tenant->id, '10000.00', 'telegram', 'test');
$sender = Sender::create([
'tenant_id' => $this->tenant->id, 'name' => 'Бренд', 'status' => Sender::STATUS_SUSPENDED,
'monthly_fee_rub' => '2500.00', 'paid_until' => '2026-06-26', 'debt_since' => '2026-06-26',
]);
$this->postJson('/api/telegram/sender/enable')
->assertOk()
->assertJsonPath('status', Sender::STATUS_ACTIVE);
$charges = AdWalletTransaction::where('tenant_id', $this->tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count();
expect($charges)->toBe(1);
// Повторное включение в тот же месяц не должно списать второй раз (идемпотентность периода).
// query-builder update (а не $sender->update): у стейл-модели статус уже 'suspended' в памяти → не dirty.
Sender::where('tenant_id', $this->tenant->id)->update(['status' => Sender::STATUS_SUSPENDED]);
$this->postJson('/api/telegram/sender/enable')->assertOk();
$charges = AdWalletTransaction::where('tenant_id', $this->tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count();
expect($charges)->toBe(1); // всё ещё одно списание
});
it('POST /sender/enable при нехватке денег → 409, имя остаётся suspended', function () {
config(['client_tg.sandbox' => false]);
app(AdWalletService::class)->topup($this->tenant->id, '100.00', 'telegram', 'test'); // мало
Sender::create([
'tenant_id' => $this->tenant->id, 'name' => 'Бренд', 'status' => Sender::STATUS_SUSPENDED,
'monthly_fee_rub' => '2500.00', 'paid_until' => '2026-06-26', 'debt_since' => '2026-06-26',
]);
$this->postJson('/api/telegram/sender/enable')->assertStatus(409);
expect(Sender::where('tenant_id', $this->tenant->id)->value('status'))->toBe(Sender::STATUS_SUSPENDED);
});
it('POST /sender/enable для не-suspended → 422', function () {
Sender::create([
'tenant_id' => $this->tenant->id, 'name' => 'Бренд', 'status' => Sender::STATUS_ACTIVE,
'monthly_fee_rub' => '2500.00', 'paid_until' => '2026-08-27',
]);
$this->postJson('/api/telegram/sender/enable')->assertStatus(422);
});
@@ -1,204 +0,0 @@
<?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'); // не списано
});
+99 -31
View File
@@ -4,11 +4,10 @@ declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
use App\Jobs\ClientTg\SweepStuckTelegramCampaignsJob;
use App\Models\AdWallet;
use App\Models\AdWalletHold;
use App\Models\BalanceTransaction;
use App\Models\ClientTg\Campaign;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use App\Services\ClientTg\TelegramCampaignChargeService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
@@ -23,17 +22,21 @@ uses(RefreshDatabase::class, SharesSupplierPdo::class);
*
* Кампания в статусе `running` дольше окна (робот-таймаут + запас) считается
* зависшей: воркер убили/упал молча. Уборщик добивает статус, но КОНСЕРВАТИВНО с
* деньгами:
* деньгами (списание/возврат идёт с ОБЩЕГО баланса тенанта `tenants.balance_rub`
* через TelegramCampaignChargeService рекламного кошелька с бронью больше нет):
* нет `mts_campaign_id` (черновик в кабинете не создавался) кампания заведомо
* НЕ ушла на модерацию `failed` + возврат брони;
* НЕ ушла на модерацию `failed` + возврат списанной сметы на баланс;
* есть `mts_campaign_id` (черновик реально создан) кампания МОГЛА уйти на
* модерацию деньги вслепую НЕ возвращаем, помечаем `needs_review` (ручной
* разбор, пока не сверим статус в кабинете задачей 3.4).
* Свежая `running` (в пределах окна) не трогается.
* разбор, пока не сверим статус в кабинете задачей 3.4);
* `moderating` дольше окна `client_tg.moderation_stuck_hours` тоже
* `needs_review` БЕЗ возврата (могла реально показываться).
* Свежая `running` (в пределах окна) не трогается. В песочнице денег не списывали
* возврат не производится.
*/
/** Кампания в заданном статусе; при $ageMinutes>0 «состаривает» updated_at. */
function sweepCampaign(int $tenantId, string $status, ?string $mtsId = null, int $ageMinutes = 0): Campaign
function sweepCampaign(int $tenantId, string $status, ?string $mtsId = null, int $ageMinutes = 0, string $estimatedCost = '0.00'): Campaign
{
$c = Campaign::query()->create([
'tenant_id' => $tenantId,
@@ -44,7 +47,7 @@ function sweepCampaign(int $tenantId, string $status, ?string $mtsId = null, int
'budget_cap_rub' => '1000.00',
'audience_kind' => Campaign::AUDIENCE_LIST,
'planned_count' => 0,
'estimated_cost_rub' => '0.00',
'estimated_cost_rub' => $estimatedCost,
'created_by' => 1,
]);
@@ -57,21 +60,49 @@ function sweepCampaign(int $tenantId, string $status, ?string $mtsId = null, int
return $c;
}
/** Кошелёк тенанта + активная бронь под кампанию. */
function sweepWalletWithHold(int $tenantId, int $campaignId, string $balance, string $freeze): void
/** Тенант с общим балансом + tenant-контекст (`SET LOCAL`) под его id для списаний. */
function sweepTenant(string $balance): Tenant
{
AdWallet::query()->create([
'tenant_id' => $tenantId,
'balance_rub' => $balance,
'frozen_rub' => '0.00',
]);
app(AdWalletService::class)->freeze($tenantId, 'telegram', 'campaign', $campaignId, $freeze);
$t = Tenant::factory()->create(['balance_rub' => $balance]);
DB::statement('SET LOCAL app.current_tenant_id = '.$t->id);
return $t;
}
it('константа STATUS_NEEDS_REVIEW есть и равна needs_review', function () {
expect(Campaign::STATUS_NEEDS_REVIEW)->toBe('needs_review');
});
it('зависшая queued (деньги списаны, джоб не стартовал) → failed + возврат сметы', function () {
config()->set('client_tg.sandbox', false);
$tenant = sweepTenant('5000.00');
// queued без mts_campaign_id, состарена больше окна QUEUED_STUCK_AFTER_MINUTES (60).
$c = sweepCampaign($tenant->id, Campaign::STATUS_QUEUED, null, 61, '315.00');
app(TelegramCampaignChargeService::class)->charge($c, '315.00'); // баланс 4685
expect((string) $tenant->fresh()->balance_rub)->toBe('4685.00');
SweepStuckTelegramCampaignsJob::dispatchSync();
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_FAILED)
->and((string) $tenant->fresh()->balance_rub)->toBe('5000.00')
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
->where('related_id', $c->id)->count())->toBe(1);
});
it('свежая queued (в пределах окна) не трогается — джоб может её вот-вот запустить', function () {
config()->set('client_tg.sandbox', false);
$tenant = sweepTenant('5000.00');
$c = sweepCampaign($tenant->id, Campaign::STATUS_QUEUED, null, 5, '315.00');
app(TelegramCampaignChargeService::class)->charge($c, '315.00'); // баланс 4685
SweepStuckTelegramCampaignsJob::dispatchSync();
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_QUEUED)
->and((string) $tenant->fresh()->balance_rub)->toBe('4685.00') // не вернули
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
->where('related_id', $c->id)->count())->toBe(0);
});
it('running допускает needs_review, needs_review — терминальный', function () {
$t = Tenant::factory()->create();
$c = sweepCampaign($t->id, Campaign::STATUS_RUNNING);
@@ -90,7 +121,7 @@ it('таймаут джоба (420с) больше робот-таймаута',
});
it('failed() переводит queued в failed', function () {
config()->set('client_tg.sandbox', true); // без release
config()->set('client_tg.sandbox', true); // без возврата
$t = Tenant::factory()->create();
$c = sweepCampaign($t->id, Campaign::STATUS_QUEUED);
@@ -99,18 +130,21 @@ it('failed() переводит queued в failed', function () {
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_FAILED);
});
it('зависшая running без mts_campaign_id → failed и бронь возвращена', function () {
it('зависшая running без mts_campaign_id → failed и деньги вернулись', function () {
config()->set('client_tg.sandbox', false);
$t = Tenant::factory()->create();
$c = sweepCampaign($t->id, Campaign::STATUS_RUNNING, null, 30);
sweepWalletWithHold($t->id, $c->id, '5000.00', '1000.00');
expect((string) AdWallet::where('tenant_id', $t->id)->value('frozen_rub'))->toBe('1000.00');
$t = sweepTenant('5000.00');
$c = sweepCampaign($t->id, Campaign::STATUS_RUNNING, null, 30, '315.00');
app(TelegramCampaignChargeService::class)->charge($c, '315.00');
expect((string) $t->fresh()->balance_rub)->toBe('4685.00');
SweepStuckTelegramCampaignsJob::dispatchSync();
// failed + возврат → баланс восстановлен, ровно одна проводка возврата.
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_FAILED)
->and((string) AdWallet::where('tenant_id', $t->id)->value('frozen_rub'))->toBe('0.00')
->and(AdWalletHold::where('source_id', $c->id)->value('status'))->toBe(AdWalletHold::STATUS_RELEASED);
->and((string) $t->fresh()->balance_rub)->toBe('5000.00')
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
->where('related_type', Campaign::class)
->where('related_id', $c->id)->count())->toBe(1);
});
it('свежая running не трогается', function () {
@@ -123,15 +157,49 @@ it('свежая running не трогается', function () {
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_RUNNING);
});
it('зависшая running С mts_campaign_id → needs_review, деньги НЕ тронуты', function () {
it('зависшая running С mts_campaign_id → needs_review, деньги НЕ вернулись', function () {
config()->set('client_tg.sandbox', false);
$t = Tenant::factory()->create();
$c = sweepCampaign($t->id, Campaign::STATUS_RUNNING, '2231150', 30);
sweepWalletWithHold($t->id, $c->id, '5000.00', '1000.00');
$t = sweepTenant('5000.00');
$c = sweepCampaign($t->id, Campaign::STATUS_RUNNING, '2231150', 30, '315.00');
app(TelegramCampaignChargeService::class)->charge($c, '315.00');
expect((string) $t->fresh()->balance_rub)->toBe('4685.00');
SweepStuckTelegramCampaignsJob::dispatchSync();
// needs_review, баланс остаётся сниженным (нет возврата), ноль проводок возврата.
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and((string) AdWallet::where('tenant_id', $t->id)->value('frozen_rub'))->toBe('1000.00')
->and(AdWalletHold::where('source_id', $c->id)->value('status'))->toBe(AdWalletHold::STATUS_ACTIVE);
->and((string) $t->fresh()->balance_rub)->toBe('4685.00')
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
->where('related_id', $c->id)->count())->toBe(0);
});
it('зависшая moderating → needs_review, деньги НЕ вернулись', function () {
config()->set('client_tg.sandbox', false);
config()->set('client_tg.moderation_stuck_hours', 1);
$t = sweepTenant('5000.00');
$c = sweepCampaign($t->id, Campaign::STATUS_MODERATING, '2231150', 120, '315.00');
app(TelegramCampaignChargeService::class)->charge($c, '315.00');
expect((string) $t->fresh()->balance_rub)->toBe('4685.00');
SweepStuckTelegramCampaignsJob::dispatchSync();
// needs_review, деньги вслепую не возвращаем (кампания могла показываться).
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_NEEDS_REVIEW)
->and((string) $t->fresh()->balance_rub)->toBe('4685.00')
->and(BalanceTransaction::where('type', BalanceTransaction::TYPE_TG_AD_REFUND)
->where('related_id', $c->id)->count())->toBe(0);
});
it('песочница: зависшая running → failed, денег не касаемся', function () {
config()->set('client_tg.sandbox', true);
$t = sweepTenant('5000.00');
// В песочнице деньги не списывали — charge отсутствует.
$c = sweepCampaign($t->id, Campaign::STATUS_RUNNING, null, 30, '315.00');
SweepStuckTelegramCampaignsJob::dispatchSync();
// Статус добит, но возврат не запускается (нечего и незачем) — 0 проводок, баланс цел.
expect(Campaign::find($c->id)->status)->toBe(Campaign::STATUS_FAILED)
->and((string) $t->fresh()->balance_rub)->toBe('5000.00')
->and(BalanceTransaction::where('related_id', $c->id)->count())->toBe(0);
});
@@ -32,3 +32,21 @@ it('смета = объём показов × цена ступени', function
// 0 показов → 0.00
->and($p->estimateRub(0))->toBe('0.00');
});
it('клиентская смета = базовая смета × наценка (по умолчанию 1.40)', function () {
config()->set('client_tg.markup', '1.40');
$p = app(TelegramTariffService::class);
// 500 показов: база 225.00 × 1.40 = 315.00
expect($p->clientEstimateRub(500))->toBe('315.00')
// 1000 показов: база 420.00 × 1.40 = 588.00
->and($p->clientEstimateRub(1000))->toBe('588.00')
// 0 показов → 0.00
->and($p->clientEstimateRub(0))->toBe('0.00');
});
it('наценка берётся из config (переопределяемая)', function () {
config()->set('client_tg.markup', '2.00');
$p = app(TelegramTariffService::class);
// 500 показов: база 225.00 × 2.00 = 450.00
expect($p->clientEstimateRub(500))->toBe('450.00');
});
+1 -20
View File
@@ -10,7 +10,6 @@ vi.mock('../../resources/js/api/admin', async (importOriginal) => {
...orig,
fetchTgTariffs: vi.fn(),
updateTgTariffs: vi.fn(),
updateTgSettings: vi.fn(),
};
});
@@ -26,7 +25,6 @@ function makeTariffs(overrides: Partial<AdminTgTariffsResponse> = {}): AdminTgTa
{ id: 1, min_qty: 1, price_rub: '0.45' },
{ id: 2, min_qty: 1000, price_rub: '0.42' },
],
settings: { name_fee_rub_per_month: '2500.00', name_debt_grace_days: 29 },
...overrides,
};
}
@@ -34,7 +32,7 @@ function makeTariffs(overrides: Partial<AdminTgTariffsResponse> = {}): AdminTgTa
const mountView = () => mount(AdminTgView, { global: { plugins: [createVuetify()] } });
describe('AdminTgView', () => {
it('на mount загружает тарифы и настройки', async () => {
it('на mount загружает тарифы', async () => {
vi.mocked(adminApi.fetchTgTariffs).mockResolvedValueOnce(makeTariffs());
const wrapper = mountView();
@@ -42,7 +40,6 @@ describe('AdminTgView', () => {
expect(adminApi.fetchTgTariffs).toHaveBeenCalledTimes(1);
expect(wrapper.find('[data-testid="tg-tariffs-save"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="tg-settings-save"]').exists()).toBe(true);
// Ступени легли в редактируемые поля (значение input, не текст).
const row0Inputs = wrapper.find('[data-testid="tg-tariff-row-0"]').findAll('input');
expect(row0Inputs.some((i) => (i.element as HTMLInputElement).value === '0.45')).toBe(true);
@@ -65,22 +62,6 @@ describe('AdminTgView', () => {
]);
});
it('«Сохранить настройки» вызывает updateTgSettings с полями', async () => {
vi.mocked(adminApi.fetchTgTariffs).mockResolvedValue(makeTariffs());
vi.mocked(adminApi.updateTgSettings).mockResolvedValueOnce({
name_fee_rub_per_month: '2500.00',
name_debt_grace_days: 29,
});
const wrapper = mountView();
await flushPromises();
await wrapper.find('[data-testid="tg-settings-save"]').trigger('click');
await flushPromises();
expect(adminApi.updateTgSettings).toHaveBeenCalledWith('2500.00', 29);
});
it('ошибка загрузки тарифов показывает человеческое сообщение', async () => {
vi.mocked(adminApi.fetchTgTariffs).mockRejectedValueOnce(new Error('500'));
@@ -7,7 +7,7 @@ vi.mock('vue-router', () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
}));
const { fetchTelegramMock, createTelegramMock, launchTelegramMock, resubmitTelegramMock } = vi.hoisted(() => ({
const { fetchTelegramMock, createTelegramMock, launchTelegramMock, resubmitTelegramMock, uploadTelegramMediaMock } = vi.hoisted(() => ({
fetchTelegramMock: vi.fn(() =>
Promise.resolve({
campaigns: [] as Array<Record<string, unknown>>,
@@ -21,6 +21,7 @@ const { fetchTelegramMock, createTelegramMock, launchTelegramMock, resubmitTeleg
),
launchTelegramMock: vi.fn(() => Promise.resolve({ id: 1, status: 'queued' })),
resubmitTelegramMock: vi.fn(() => Promise.resolve({ id: 9, status: 'queued' })),
uploadTelegramMediaMock: vi.fn((id: number) => Promise.resolve({ id, status: 'draft', media_path: 'client_tg/media/x.jpg' })),
}));
vi.mock('../../resources/js/api/telegram', () => ({
@@ -29,12 +30,8 @@ vi.mock('../../resources/js/api/telegram', () => ({
fetchTelegramCampaign: vi.fn(() => Promise.resolve({ campaign: { id: 1 } })),
launchTelegram: launchTelegramMock,
resubmitTelegram: resubmitTelegramMock,
// Встроенная панель «Своё имя» сама зовёт fetchSender на монтировании — отдаём пустой снимок.
fetchSender: vi.fn(() => Promise.resolve({ sender: null, grace_days_left: null, sandbox: true })),
createSender: vi.fn(() => Promise.resolve({ id: 1, name: 'Бренд', status: 'pending' })),
disableSender: vi.fn(() => Promise.resolve({ id: 1, status: 'cancelled' })),
enableSender: vi.fn(() => Promise.resolve({ id: 1, status: 'active' })),
// Встроенная панель «Авторассылка» зовёт fetchAutoRule на монтировании — отдаём дефолты.
uploadTelegramMedia: uploadTelegramMediaMock,
// Встроенная панель «Авто-реклама» зовёт fetchAutoRule на монтировании — отдаём дефолты.
fetchAutoRule: vi.fn(() =>
Promise.resolve({
enabled: false,
@@ -419,6 +416,47 @@ describe('AdvertisingTelegramView — экран клиентской Telegram-
}
});
it('приложенное медиа грузится к черновику при «Рассчитать»', async () => {
const wrapper = mountView();
await flushPromises();
await wrapper.find('[data-testid="tg-ad-text"] textarea').setValue('С картинкой');
await wrapper.find('[data-testid="tg-ad-link"] input').setValue('https://t.me/x');
await wrapper.find('[data-testid="tg-audience-base"] input').setValue();
await wrapper.find('[data-testid="tg-budget-cap"] input').setValue('1000');
// Выбираем файл в поле медиа (нативный input внутри v-file-input).
const file = new File(['data'], 'ad.jpg', { type: 'image/jpeg' });
const fileInput = wrapper.find('[data-testid="tg-media"] input[type="file"]');
Object.defineProperty(fileInput.element, 'files', { value: [file] });
await fileInput.trigger('change');
await flushPromises();
await wrapper.find('[data-testid="tg-calc"]').trigger('click');
await flushPromises();
expect(createTelegramMock).toHaveBeenCalledOnce();
expect(uploadTelegramMediaMock).toHaveBeenCalledOnce();
expect(uploadTelegramMediaMock.mock.calls[0][0]).toBe(1); // id черновика
expect(uploadTelegramMediaMock.mock.calls[0][1]).toBeInstanceOf(File);
});
it('без выбранного медиа upload не вызывается', async () => {
const wrapper = mountView();
await flushPromises();
await wrapper.find('[data-testid="tg-ad-text"] textarea').setValue('Без медиа');
await wrapper.find('[data-testid="tg-ad-link"] input').setValue('https://t.me/x');
await wrapper.find('[data-testid="tg-audience-base"] input').setValue();
await wrapper.find('[data-testid="tg-budget-cap"] input').setValue('1000');
await wrapper.find('[data-testid="tg-calc"]').trigger('click');
await flushPromises();
expect(createTelegramMock).toHaveBeenCalledOnce();
expect(uploadTelegramMediaMock).not.toHaveBeenCalled();
});
it('при нехватке денег (launch → 409) показывает понятное сообщение', async () => {
launchTelegramMock.mockRejectedValueOnce({ response: { status: 409 } });
@@ -32,7 +32,7 @@ function rule(overrides: Record<string, unknown> = {}) {
};
}
describe('TelegramAutoRulePanel — авторассылка новым лидам', () => {
describe('TelegramAutoRulePanel — авто-реклама новым лидам', () => {
beforeEach(() => vi.clearAllMocks());
it('загружает правило и показывает тумблер + поля порога/бюджета/лимита', async () => {
@@ -1,96 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
// Панель не ходит в сеть — мокаем API-модуль telegram.ts.
const { fetchSenderMock, createSenderMock, disableSenderMock, enableSenderMock } = vi.hoisted(() => ({
fetchSenderMock: vi.fn(),
createSenderMock: vi.fn(() => Promise.resolve({ id: 1, name: 'МойБренд', status: 'pending' })),
disableSenderMock: vi.fn(() => Promise.resolve({ id: 1, status: 'cancelled' })),
enableSenderMock: vi.fn(() => Promise.resolve({ id: 1, status: 'active' })),
}));
vi.mock('../../resources/js/api/telegram', () => ({
fetchSender: fetchSenderMock,
createSender: createSenderMock,
disableSender: disableSenderMock,
enableSender: enableSenderMock,
}));
import TelegramSenderPanel from '../../resources/js/views/advertising/TelegramSenderPanel.vue';
function mountPanel() {
return mount(TelegramSenderPanel, { global: { plugins: [createVuetify()] } });
}
function snapshot(sender: Record<string, unknown> | null, extra: Record<string, unknown> = {}) {
return { sender, grace_days_left: null, sandbox: true, ...extra };
}
describe('TelegramSenderPanel — своё имя отправителя', () => {
beforeEach(() => vi.clearAllMocks());
it('без имени показывает форму «Завести имя» и создаёт имя', async () => {
fetchSenderMock.mockResolvedValue(snapshot(null));
const wrapper = mountPanel();
await flushPromises();
expect(wrapper.find('[data-testid="tg-sender-name-input"]').exists()).toBe(true);
await wrapper.find('[data-testid="tg-sender-name-input"] input').setValue('МойБренд');
await wrapper.find('[data-testid="tg-sender-create"]').trigger('click');
await flushPromises();
expect(createSenderMock).toHaveBeenCalledWith('МойБренд');
});
it('активное имя показывает статус и дату оплаты, есть кнопка «Отключить»', async () => {
fetchSenderMock.mockResolvedValue(
snapshot({ id: 1, name: 'Бренд', status: 'active', paid_until: '2026-08-27' }),
);
const wrapper = mountPanel();
await flushPromises();
expect(wrapper.find('[data-testid="tg-sender-status"]').text()).toContain('активно');
expect(wrapper.find('[data-testid="tg-sender-paid-until"]').text()).toContain('2026-08-27');
expect(wrapper.find('[data-testid="tg-sender-disable"]').exists()).toBe(true);
});
it('suspended показывает «Отключено за долг» и кнопку «Включить»', async () => {
fetchSenderMock.mockResolvedValue(snapshot({ id: 1, name: 'Бренд', status: 'suspended' }));
const wrapper = mountPanel();
await flushPromises();
expect(wrapper.text()).toContain('Отключено за долг');
const enable = wrapper.find('[data-testid="tg-sender-enable"]');
expect(enable.exists()).toBe(true);
await enable.trigger('click');
await flushPromises();
expect(enableSenderMock).toHaveBeenCalledOnce();
});
it('клик «Отключить» зовёт disableSender', async () => {
fetchSenderMock.mockResolvedValue(
snapshot({ id: 1, name: 'Бренд', status: 'active', paid_until: '2026-08-27' }),
);
const wrapper = mountPanel();
await flushPromises();
await wrapper.find('[data-testid="tg-sender-disable"]').trigger('click');
await flushPromises();
expect(disableSenderMock).toHaveBeenCalledOnce();
});
it('имя с долгом показывает остаток грейса', async () => {
fetchSenderMock.mockResolvedValue(
snapshot(
{ id: 1, name: 'Бренд', status: 'active', paid_until: '2026-07-26', debt_since: '2026-07-23' },
{ grace_days_left: 24 },
),
);
const wrapper = mountPanel();
await flushPromises();
expect(wrapper.find('[data-testid="tg-sender-debt"]').text()).toContain('24');
});
});
+26
View File
@@ -2451,3 +2451,29 @@ mattpocock
апстрима
задвоение
задвоения
apiv
eto
kak
licenziya
rabotaet
svyazka
Свип
абонплата
админских
ассертим
бэклогом
джобами
дозаполнение
досыл
задваиваем
закоммитила
залогиненных
мигрированных
платформо
преfflight
приложённый
ресейлит
сидированные
слагов
схлопывает
фирмо
+65
View File
@@ -8,6 +8,71 @@
> параллельно с боевым main. Их прежние номера (v8.59–v8.62) **столкнулись** с боевыми (автоподбор),
> поэтому при сведении они перенумерованы. Содержание не менялось.
## v8.95 (2026-07-28) — Telegram-модуль: чистка СМС-наследия (дроп senders/settings/messages/templates + tg_name_fee вон)
> ⚠️ **Ветка `feat/client-telegram-ads`** (та же, что v8.86v8.94). Номер **v8.95 предварительный**
> при сведении с боевым main будет перенумерован. Содержание не меняется.
Модуль ресейлит МТС Маркетолог «показы рекламы по своей базе» — там НЕТ имени отправителя, доставки
сообщений по номеру, отписок и шаблонов сообщений (владельцем подтверждено). Эти сущности были механически
скопированы из СМС-модуля. Убраны.
- **Дроп таблиц** (миграция `app/database/migrations/2026_07_28_000017_drop_client_tg_sms_legacy_tables.php`,
`DROP TABLE IF EXISTS … CASCADE`):
- `client_tg_senders` (было v8.87) — «своё имя/бренд отправителя» + помесячная абонплата;
- `client_tg_settings` (было v8.86) — обе колонки (`name_fee_rub_per_month`, `name_debt_grace_days`)
обслуживали только имя;
- `client_tg_messages` (было v8.86) — «по-получательские записи доставки» (копия `sales_sms_messages`),
без Eloquent-модели, в рабочем коде не использовалась;
- `client_tg_templates` (было v8.86) — «шаблоны сообщений», не использовались нигде.
Их **create-миграции удалены** (000003/000006/000008/000010) — на свежей базе таблицы не создаются, DROP —
no-op; на уже мигрированных dev/prod убирает.
- **CHECK `balance_transactions_type_check` = `tg_name_fee`** — тип был зарезервирован под отменённую
плату за имя (кусок 3 спеки отменён). В теле `schema.sql` (~л.2745) и в up/down миграции
`2026_07_28_000001` строка `tg_name_fee` убрана; остаются `tg_ad_charge`/`tg_ad_refund`.
- **НЕ тронуто:** реальные таблицы показов (`client_tg_campaigns`/`_campaign_phones`/`_contacts`/`_tariffs`/
`_auto_rule`) и `client_tg_optouts` (переосмыслен как ручной список «не показывать этим номерам»).
**Данные:** таблицы работали только в песочнице (реальных денег/строк по имени не было). Дроп необратим
(`down` не пересоздаёт — продукт показов их не имеет).
**RLS:** удаление таблиц снимает и их политики/GRANT-ы вместе с ними; политики оставшихся таблиц не меняются.
**rls-reviewer:** удаление RLS-таблиц + CHECK-only правка — прогнать при сведении ветки.
---
## v8.94 (2026-07-28) — Telegram-деньги: CHECK balance_transactions.type += tg_ad_charge/tg_ad_refund
> ⚠️ **Ветка `feat/client-telegram-ads`** (та же, что v8.86v8.93). Номер **v8.94 предварительный**
> при сведении с боевым main будет перенумерован. Содержание не меняется.
> NB: изначально запись добавляла и `tg_name_fee`, но он убран в v8.95 (плата за имя — отменённая фантазия).
Кусок 1 денежной модели Telegram-рекламы (спека
`docs/superpowers/specs/2026-07-28-telegram-dengi-obshchiy-balans-spec.md`): реклама оплачивается с
**общего баланса** тенанта (как СМС), поэтому в журнал денег добавлены два вида проводки.
- **CHECK `balance_transactions_type_check` += два значения:**
- `tg_ad_charge` — списание клиентской сметы (с наценкой 40%) при запуске Telegram-кампании
(`TelegramCampaignChargeService::charge`, зеркало `SmsChargeService`);
- `tg_ad_refund` — возврат при отказе модерации / сбое до кабинета (`::refund`, идемпотентный).
Одной миграцией, чтобы на прод-кластер `ALTER … CHECK` ехал ОДИН раз. Миграция
`app/database/migrations/2026_07_28_000001_extend_balance_transactions_type_for_telegram.php`
(up = полный список **включая** уже действующий `sms_charge`; down возвращает к списку с `sms_charge`).
- **Свёрнуто в тело `schema.sql`** (`CREATE TABLE balance_transactions`, ~л.2745): CHECK приведён к
реальному состоянию БД — заодно **добавлен пропущенный** `sms_charge` (миграция 2026_07_23_100100
расширяла констрейнт `ALTER`-ом, но в тело `schema.sql` значение не свернули) + два новых типа.
Прежняя цепочка `'migration' → 'autopodbor_charge'` теперь `→ … → 'tg_ad_refund'`.
**Данные:** необратимо после первого списания `type='tg_ad_charge'` (как прецеденты `sms_charge` /
`autopodbor_charge`). `migrate:fresh` пересоздаст с полным списком.
**RLS:** политики и колонки НЕ меняются — только доменный CHECK на `type`. Таблица уже tenant-aware
(`tenant_isolation`), новые проводки пишутся в контексте тенанта (`SET LOCAL app.current_tenant_id`),
как `sms_charge`. **rls-reviewer:** прогнан — **CLEAN** (CHECK-only, изоляция не затронута).
---
## v8.93 (2026-07-28) — Telegram-модуль, ревью-фикс F9: SELECT служебным ролям на client_tg_campaigns
> ⚠️ **Ветка `feat/client-telegram-ads`** (та же, что v8.86v8.92). Номер **v8.93 предварительный**
+4 -1
View File
@@ -2747,7 +2747,10 @@ CREATE TABLE balance_transactions (
'chargeback_writedown', -- Ю-3: списание непокрытой части chargeback в долг тенанта
'chargeback_repayment', -- Ю-3: погашение долга через /billing
'migration', -- Billing v2 Spec A: конвертация balance_leads → balance_rub
'autopodbor_charge' -- v8.62: списание за прогон автоподбора (AutopodborChargeService; миграция 2026_06_28_110100)
'autopodbor_charge', -- v8.62: списание за прогон автоподбора (AutopodborChargeService; миграция 2026_06_28_110100)
'sms_charge', -- списание за СМС-рассылку (SmsChargeService; миграция 2026_07_23_100100)
'tg_ad_charge', -- списание клиентской сметы Telegram-рекламы (TelegramCampaignChargeService; миграция 2026_07_28_000001)
'tg_ad_refund' -- возврат сметы Telegram-рекламы при отказе модерации / сбое (миграция 2026_07_28_000001)
)),
amount_rub DECIMAL(12,2) DEFAULT 0,
amount_leads INT DEFAULT 0,
@@ -0,0 +1,64 @@
# Телеграм-деньги, КУСОК 1 (фундамент) — приёмочный лист
Ветка `feat/client-telegram-ads`. Спека:
`docs/superpowers/specs/2026-07-28-telegram-dengi-obshchiy-balans-spec.md` (порядок из 3 кусков).
Решение владельца (28.07): платить с ОБЩЕГО баланса (как СМС), наценка 40%. Не пушить. Коммит по «коммить».
**Куски 2 и 3 НЕ трогаются** — поток запуска/отказа кампании (launch/reject/джобы/авто-путь/плата за имя)
пока по-прежнему висит на старом кошельке `AdWalletService` (freeze/release). Кусок 1 — только фундамент:
наценка + сервис списания/возврата с общего баланса + место в БД под новые проводки. Ничего из этого
пока НЕ подключено к боевому потоку (никто ещё не зовёт `TelegramCampaignChargeService`), поэтому поведение
кампаний не изменилось — безопасно.
## Приёмочный лист (кусок 1 = D1 + D2 + D3 + БД)
- [x] **D1. Наценка 40%.** Config `client_tg.markup` (`(string) env('TG_MARKUP', '1.40')`).
`TelegramTariffService::clientEstimateRub(int $impressions)` = базовая `estimateRub × markup` (bcmath scale 2).
Приёмка: 500 показов → база 225.00 × 1.40 = **315.00**; 1000 → 588.00; 0 → 0.00; наценка берётся из config
(переопределяемая — при 2.00 даёт 450.00). База (`estimateRub`) не тронута — остаётся себестоимостью-оценкой.
- [x] **D2. Списание с общего баланса.** Новый `App\Services\ClientTg\TelegramCampaignChargeService::charge(Campaign, string $priceRub)`
— зеркало `SmsChargeService`: замок тенанта (`Tenant::lockForUpdate`) → идемпотентность по строке
`balance_transactions` (type=`tg_ad_charge`, related_type=Campaign, related_id) → нулевая сумма — no-op →
нехватка → `InsufficientBalanceException` (баланс не тронут) → списание `tenants.balance_rub` +
`BalanceTransaction` (`TYPE_TG_AD_CHARGE`, amount отрицательный). Здесь Campaign/Tenant/BalanceTransaction
на одном соединении `pgsql` — замок настоящий (в отличие от СМС на `pgsql_supplier`).
Приёмка: списывает раз; повтор — no-op; нехватка → исключение, баланс цел; ноль — не пишет проводку.
- [x] **D3. Возврат (refund) на общий баланс.** `::refund(Campaign)`: если есть charge-проводка и ещё нет
refund-проводки — вернуть модуль списанной суммы на баланс + `BalanceTransaction` (`TYPE_TG_AD_REFUND`,
amount положительный), идемпотентно (по наличию refund-строки). Нет списания — ничего не делает.
Приёмка: после charge→refund баланс восстановлен (1000→685→1000); двойной refund не задваивает; refund без
charge — no-op.
- [x] **БД. Новые типы проводки.** `balance_transactions.type` — CHECK-ограничение, поэтому:
- Миграция `app/database/migrations/2026_07_28_000001_extend_balance_transactions_type_for_telegram.php`
добавляет ТРИ значения одним `ALTER … CHECK` (чтобы на прод-кластер ехало один раз): `tg_ad_charge`,
`tg_ad_refund`, `tg_name_fee` (последнее **зарезервировано под кусок 3** — плата за имя). up() включает
уже действующий `sms_charge`; down() возвращает к списку с `sms_charge`.
- `BalanceTransaction`: константы `TYPE_TG_AD_CHARGE` / `TYPE_TG_AD_REFUND` / `TYPE_TG_NAME_FEE`.
- `db/schema.sql` (CREATE TABLE, ~л.2745): CHECK приведён к реальному состоянию БД — **заодно добавлен
пропущенный `sms_charge`** (миграция 2026_07_23 расширяла `ALTER`-ом, но в тело не свернула) + три новых.
- `db/CHANGELOG_schema.md`: запись **v8.94** (номер предварительный, при сведении с main перенумеруется).
## Приёмка (28.07.2026)
- Тесты (`DB_DATABASE=liderra_testing`, по отдельности — вместе флейкует на квирке партиций/типов):
- `tests/Feature/ClientTg/CampaignChargeServiceTest.php` (новый) — **5/5** (12 проверок): charge раз+идемпотентность,
нехватка→исключение+баланс цел, ноль→no-op, refund восстанавливает+идемпотентность, refund без charge→no-op.
- `tests/Feature/ClientTg/TariffServiceTest.php` (+2 кейса) — **4/4** (18 проверок): наценка 1.40 и переопределение.
- `pint` — чисто; `phpstan` — 0 ошибок (убран мёртвый null-guard: `client_tg_campaigns.tenant_id` NOT NULL,
в отличие от nullable у СМС-модели); `deptrac` — 0 нарушений.
- Миграция применена на `liderra_testing` и на локальную dev-БД `liderra` (не прод-кластер).
- **rls-reviewer** — прогнан по миграции (CHECK-only, tenant-изоляция не затронута). Вердикт — см. ниже.
🪤 **Готча окружения (подтвердилась снова):** прогон ДВУХ ClientTg-файлов ОДНОЙ командой уронил тест-базу
(коллизия `pg_type … legal_entities` — пересоздание типов под RefreshDatabase+партиции). Ремонт:
`db:wipe --drop-types --force` ×(до 2 раз) + `migrate --force`. Гонять изменённые файлы по одному.
## Го-лайв (кусок НЕ здесь — общий долг перед выкатом)
- Миграцию `2026_07_28_000001` прогнать на боевом кластере (как прочие CHECK-миграции).
- Подключение сервиса к потоку (charge при запуске, refund при отказе, снятие AdWalletService) — **кусок 2**.
- Плата за имя на общий баланс (`tg_name_fee`) — **кусок 3**.
НЕ закоммичено (ждём «коммить»).
@@ -0,0 +1,125 @@
# Телеграм-деньги, КУСОК 2 — переключить поток с кошелька-заморозки на общий баланс
Ветка `feat/client-telegram-ads`. Спека D4/D5/D6. Кусок 1 (наценка + charge/refund-сервис + БД) готов,
не закоммичен. Коммитим всё вместе после куска 3 (решение владельца 28.07: «дальше закоммитим все вместе»).
Не пушить.
## Суть
Заменить в ПОТОКЕ кампании старый рекламный кошелёк `AdWalletService` (freeze/release) на списание/возврат
с ОБЩЕГО баланса (`TelegramCampaignChargeService::charge/refund` из куска 1). Кошелёк и его таблицы НЕ
удаляем — просто телеграм-кампании на них больше не опираются. Плата за ИМЯ отправителя
(`TelegramSenderService`/`ChargeTgNameFeeJob`) — это кусок 3 (D8), здесь НЕ трогаем.
## 🔑 Ключевое открытие (переопределяет реализацию куска 1)
Кампанию можно **списать → вернуть (отказ модерации) → списать снова (пересдача)**. Значит идемпотентность
«есть хоть одна charge-строка → no-op» из куска 1 сделала бы пересдачу БЕСПЛАТНОЙ. Переходим на
**сальдо-идемпотентность** (непогашенное списание = charges refunds):
- `charge`: no-op, если `count(tg_ad_charge) > count(tg_ad_refund)` (есть непогашенное списание); иначе списываем.
- `refund`: no-op, если `count(tg_ad_charge) <= count(tg_ad_refund)` (нечего возвращать); иначе возвращаем
сумму ПОСЛЕДНЕГО (непогашенного) списания.
Цикл launch→charge (сальдо +1) → reject→refund (0) → resubmit→charge (+1) считается верно.
## Сумма списания
Клиентская цена = `estimated_cost_rub` (теперь хранится С наценкой, D4-store). При запуске пересчитываем по
свежему числу кандидатов: `estimated_cost_rub = clientEstimateRub(candidates)` и списываем именно её —
клиент видит и платит одно и то же. `budget_cap_rub` остаётся ЛИМИТОМ трат на объявление (отдаётся роботу/
МТС), НЕ списывается.
## Приёмочный лист (файлы)
- [ ] **S0. Сервис — сальдо-идемпотентность.** `TelegramCampaignChargeService::charge/refund` переписать на
сравнение count(charge) vs count(refund); refund берёт сумму последнего непогашенного charge (orderByDesc id).
Тест `CampaignChargeServiceTest`: + кейс charge→refund→charge (второй charge СПИСЫВАЕТ; итог 2 charge/1 refund).
- [ ] **D4-store. `CampaignController::store`**`estimated_cost_rub = clientEstimateRub($candidates)` (с наценкой).
Экран показывает клиентскую цену. Тест: смета в ответе = база×1.4.
- [ ] **D4-launch. `CampaignController::launch`** — в бою вместо `freeze(budget_cap_rub)`:
пересчитать `estimated_cost_rub = clientEstimateRub(candidates)` (уже строим для гейта) → `charge($campaign,
estimated_cost_rub)`; нехватка → `InsufficientBalanceException` → 409, кампания остаётся draft. Песочница —
без денег. Ловим `InsufficientBalanceException` (ModelNotFoundException больше не нужен).
- [ ] **D4-resubmit. `CampaignController::resubmit`** — тот же charge вместо freeze (пересдача платится заново;
сальдо-идемпотентность это допускает).
- [ ] **cancel. `CampaignController::cancel`** — в бою `refund($campaign)` вместо `release`. Из draft (не
списывали) refund — no-op; из queued (списали на launch) — возврат. Убрать зависимость от ModelNotFoundException.
- [ ] **index. `CampaignController::index`** — баланс для экрана брать из общего баланса тенанта
(`tenants.balance_rub`), а не из `ad_wallets`. `frozen_rub` → '0.00' (заморозки больше нет). Иначе экран
всегда показывал бы 0 и гасил «Запустить».
- [ ] **D5-run. `RunTelegramCampaignJob`** — оба `release` (finalize failed-без-id; failed() shouldRelease) →
`refund($campaign)`. F5 сохраняется: есть mts_campaign_id → needs_review БЕЗ возврата. Загружать модель
кампании для refund (сейчас передаётся id в release).
- [ ] **D5-resubmit. `ResubmitTelegramCampaignJob`** — оба `release``refund`. F5 сохраняется.
- [ ] **D5-poll. `PollTelegramModerationJob`**`release` при `rejected``refund($campaign)` (возврат при
отказе модерации). Одобрение → ничего (уже списано).
- [ ] **D5-sweep. `SweepStuckTelegramCampaignsJob`**`release` (running-stuck без mts_id → failed) → `refund`.
moderating-stuck → needs_review БЕЗ возврата (как было). ⚠️ обязателен: без freeze старый `release` бросил бы.
- [ ] **D6. Снять AdWalletService из потока кампании** — убрать импорты/вызовы freeze/release из:
CampaignController, RunTelegramCampaignJob, ResubmitTelegramCampaignJob, PollTelegramModerationJob,
SweepStuckTelegramCampaignsJob. **НЕ трогать** TelegramSenderService/ChargeTgNameFeeJob (кусок 3, D8).
TelegramAutoAccumulator (freeze) — снять freeze здесь; списание авто-пути добавит кусок 3 (D7). Между
кусками 2 и 3 авто-путь временно без списания — закрывается до общего коммита.
- [ ] **D9(частично). Тесты.** Переписать/обновить wallet-базовые под charge/refund:
ManualFlowTest, LaunchIdempotencyTest, RefundOnFailTest, ConservativeReleaseTest, AutoFreezeTest,
WalletChannelTest (кампанийная часть), RunCampaignJobTest, ResubmitJobTest/ResubmitTest, PollModerationTest,
SweepStuckTest, ModeratingStatusTest — там, где ассертят frozen/balance кошелька, перевести на
tenants.balance_rub + balance_transactions. WalletReFreezeTest/SenderLifecycle (плата за имя) — кусок 3.
Гонять ПО ОТДЕЛЬНОСТИ (квирк партиций). pint/stan/deptrac чисто.
## Состояние — ВСЕ ГОТОВЫ (28.07.2026, зелёные по отдельности)
- [x] S0 — сальдо-идемпотентность charge/refund; `CampaignChargeServiceTest` **6/6** (+ кейс charge→refund→charge).
- [x] D4-store / D4-launch / D4-resubmit / cancel / index — CampaignController; `CampaignApiTest` **9/9**.
- [x] D5-run — RunTelegramCampaignJob (finalize + failed); `RunCampaignJobTest` 6/6, `RefundOnFailTest` 5/5,
`ConservativeReleaseTest` 5/5.
- [x] D5-resubmit — ResubmitTelegramCampaignJob; `ResubmitJobTest` 7/7, `ResubmitTest` 8/8.
- [x] D5-poll — PollTelegramModerationJob; `PollModerationTest` 6/6, `ModeratingStatusTest`/`ModerationStuckTest` 3/3.
- [x] D5-sweep — SweepStuckTelegramCampaignsJob; `SweepStuckTest` 7/7.
- [x] D6 — AdWalletService снят из CampaignController/Run/Resubmit/Poll/Sweep/AutoAccumulator (кошелёк/таблицы
НЕ удалены; sender/name-fee — кусок 3).
- [x] D7 (авто-путь) **сделан здесь же**, чтобы не оставлять авто-путь без списания между кусками:
TelegramAutoAccumulator списывает клиентскую смету при авто-запуске; `AutoSafeguardTest` 6/6, `AutoFreezeTest` 4/4,
`AudienceGateTest` green.
- [x] D9 — 8 wallet-тестов переписаны под charge/refund (subagent'ы, только тест-файлы; прод-код НЕ тронут —
сверено `git status`). pint/stan(0)/deptrac(0) чисто.
🪤 **Готча окружения (жёстко подтвердилась):** прогон НЕСКОЛЬКИХ ClientTg-файлов подряд (особенно
`SharesSupplierPdo`-тестов) КОРРУПТИТ тест-базу (Undefined table / пересоздание партиций-типов). Гонять
СТРОГО ПО ОДНОМУ; при `Undefined table``db:wipe --drop-types --force` + `migrate --force`, потом файл заново.
Все 8 файлов зелёные на ЧИСТОЙ базе поштучно.
## Код-ревью (субагент, деньги/F5/сальдо/RLS, 28.07.2026) — Critical НЕТ
Проверено и подтверждено верным: нет двойного списания/возврата (замок тенанта → проверка сальдо; charge и
refund строго чередуются, возврат берёт последнюю непогашенную сумму); реестр не расходится с балансом
(atomically); F5 во всех 4 джобах (refund только в ветке failed без mts_campaign_id); песочница везде;
вложенные транзакции/замки корректны (tenantTx → savepoint charge; auto: замок AutoRule→tenant, без реверса).
Находки:
- **I-1 (Important) — ИСПРАВЛЕНО.** Кампания, списанная на launch, но чей джоб так и не стартовал (воркер
умер / Redis сброшен), залипала в `queued` НАВСЕГДА с деньгами, снятыми с общего баланса — уборщик смотрел
только `running`. Раньше (кошелёк) была лишь бронь на ad_wallet, теперь — реальный дебет общего баланса
клиента, поэтому радиус хуже. Фикс: `SweepStuckTelegramCampaignsJob::sweepStuckQueued()` — queued старше
`QUEUED_STUCK_AFTER_MINUTES` (60 мин, с запасом, чтобы не гоняться с живым воркером) → `failed` + refund
(queued не имеет mts_campaign_id → робот кабинет не трогал → F5-безопасно). Тесты добавлены (`SweepStuckTest`
11/11: зависшая queued→failed+возврат; свежая queued не трогается).
- **I-2 (Important) — ОСОЗНАННО НЕ меняем.** Авто-путь: `spent_today_rub` не уменьшается при возврате
отклонённой авто-кампании → дневной лимит может «выесться» вернувшимися деньгами. Направление БЕЗОПАСНОЕ
(только НЕ-до-разрешает, переспенда/потери нет). Решение: `daily_limit_rub` трактуем как cap на ПОПЫТКИ/
валовые списания за день, а не на чистый расход — чтобы не тащить состояние правила в путь возврата
(refund в Run/Poll джобах не знает про авто-правило). Задокументировано, кода не трогаем.
- **M-1 — ИСПРАВЛЕНО.** Устаревший docblock `CampaignController` («бронирует budget_cap / заморозки нет») →
«списывает клиентскую смету с общего баланса».
- **M-2 — кусок 3.** Плата за имя (`ChargeTgNameFeeJob`/`TelegramSenderService`) всё ещё на старом кошельке;
тип `tg_name_fee` уже добавлен в CHECK/модель под кусок 3 (D8).
- **M-3 — деньги-корректно, оставлено.** Показанная при store смета и списанная при launch могут отличаться,
если аудитория изменилась между store и launch (списываем свежую верную сумму). UX-нюанс, не потеря денег.
## Дальше кусок 3 (D8: плата за имя → общий баланс, тип tg_name_fee). НЕ закоммичено (ждём «коммить»,
## вместе с кусками 1 и 3). pint/stan(0)/deptrac(0) чисто на момент закрытия куска 2.
@@ -0,0 +1,113 @@
# Телеграм-деньги, КУСОК 3 — плата за своё ИМЯ отправителя → общий баланс (D8 + D9)
Ветка `feat/client-telegram-ads`. Спека D8/D9. Куски 1 и 2 готовы, не закоммичены.
Коммитим всё вместе после куска 3 (решение владельца 28.07: «дальше закоммитим все вместе»). Не пушить.
> ⛔ Код куска 3 НЕ начат. Этот лист — приёмка ДО кода (переживает компакт). Ждём слово владельца
> («делай»/«дальше») и ответ на РАЗВИЛКУ ниже. Только после — TDD.
## Суть
Плата за своё имя Telegram-рекламы (`client_tg_senders`) сейчас идёт через старый рекламный
кошелёк-заморозку `AdWalletService` (freeze при заявке / charge+release при одобрении / release при
отказе / charge помесячно и при возврате из долга). Кампании уже переведены на общий баланс
(куски 1–2). Кусок 3 переводит на общий баланс (`tenants.balance_rub` + `balance_transactions`,
тип `tg_name_fee`) и ПЛАТУ ЗА ИМЯ — единственный кусок телеграм-денег, ещё висящий на кошельке.
Кошелёк и таблицы `ad_wallets`/`ad_wallet_*` НЕ удаляем.
## Что выяснено при чтении (важно — переживает компакт)
- **`ChargeSmsNameFeeJob` в коде НЕТ** — «зеркало» в комментариях телеграм-файлов аспирационное,
копировать нечего. Дизайн платы за имя на общем балансе — наш, по образцу кампанийного charge.
- **У `balance_transactions` НЕТ колонки `external_key`** (есть `description`, `related_type`,
`related_id`, `type`, суммы). Значит помесячную идемпотентность (у кошелька был ключ
`telegram:sender:{id}:{YYYY-MM}`) храним БЕЗ новой колонки — через маркер периода в `description`
+ `related_type=Sender`+`related_id`. Новую колонку/таблицу НЕ заводим (тип `tg_name_fee` уже в
CHECK и в модели из куска 1).
- **Кампанийный `TelegramCampaignChargeService` не подходит как есть** — там идемпотентность по САЛЬДО
(chargerefund по кампании), а у имени — по ПЕРИОДУ (месяцу). Поэтому под имя нужен отдельный
метод/сервис списания (логика «замок тенанта → проверка баланса → дебет → BalanceTransaction» —
та же, идемпотентность — другая).
- **Точки, где имя трогает деньги сейчас** (`TelegramSenderService` + `ChargeTgNameFeeJob`):
1. `requestSender`**freeze** месячной платы (бронь под заявку);
2. `approve` (админ) — **charge** + **release** брони, active + `paid_until`+1мес;
3. `reject` (админ) — **release** брони;
4. `disableSender` (клиент, из pending) — **release** брони, cancelled;
5. `disableByAdmin` (из pending) — **release** брони, suspended;
6. `enableSender` (suspended→active) — проверка средств + **charge** за текущий период;
7. `ChargeTgNameFeeJob` (помесячно, кросс-тенантно через `pgsql_supplier`) — есть деньги → **charge**
+ `paid_until`+1мес; нет → копим `debt_since`; долг > grace (29 дн) → имя `suspended`.
## 🔀 РАЗВИЛКА для владельца (нужен ответ до кода)
В кампаниях заморозку убрали совсем: деньги списываются в момент действия (запуск), заявок-броней нет.
У имени была **бронь при заявке** (`requestSender` морозил месячную плату), чтобы к моменту одобрения
деньги гарантированно были. Если убираем заморозку (как везде), у заявки на имя два варианта поведения
при нехватке денег:
- **Вариант А (рекомендую) — как у кампаний.** Заявку принимаем всегда (деньги не трогаем);
списываем при ОДОБРЕНии админом. Если на балансе не хватило — одобрение не проходит (админ видит
«недостаточно средств»), клиент пополняет и одобряют повторно. Просто, единообразно с кампаниями/СМС,
одна точка списания.
- **Вариант Б — сохранить раннюю проверку.** Заявку при нехватке денег НЕ принимаем сразу (как сейчас
бросает `InsufficientBalanceException`), но БЕЗ фактической брони — просто проверяем `balance ≥ плата`.
Плюс: клиент раньше узнаёт, что не потянет. Минус: баланс к моменту одобрения мог уйти на кампанию —
ранняя проверка ничего не гарантирует, только UX-подсказка.
Оба безопасны для денег. Различие — только в UX заявки. **По умолчанию беру А**, если владелец не
выберет Б.
## Приёмочный лист (файлы)
- [ ] **N0. Сервис/метод списания платы за имя с общего баланса.** Новый
`TelegramSenderChargeService` (или метод в общем сервисе — решить в коде): `chargeName(Sender
$sender, string $period)` — замок `Tenant::lockForUpdate` → идемпотентность ПО ПЕРИОДУ (есть ли уже
`tg_name_fee`-проводка по этому `Sender`+`related_id` за `period`) → проверка `balance_rub ≥ fee`
дебет `tenants.balance_rub` + `BalanceTransaction` (`TYPE_TG_NAME_FEE`, `amount_rub` отрицательный,
`related_type=Sender::class`, `related_id=sender->id`, период-маркер в `description`). Порог
платёжеспособности — по `tenants.balance_rub` (НЕ ad_wallet). bcmath scale 2. Тест: списывает раз;
повтор за тот же период — no-op; нехватка → списания нет (для джоба — сигнал «денег нет», без броска).
- [ ] **N1. `ChargeTgNameFeeJob` → общий баланс.** Убрать `AdWalletService`/`AdWallet`; порог считать по
`tenants.balance_rub`; списание через N0; период = `Carbon(paid_until).format('Y-m')`. Сохранить всё
остальное 1:1: `paid_until`+1мес при успехе; при нехватке в грейсе — копим `debt_since`, `paid_until`
не двигаем; долг > grace → `suspended` + `Log::warning('client_tg.name_suspended_for_debt')`.
Кросс-тенантное перечисление — по-прежнему через `pgsql_supplier` (BYPASSRLS), денежная операция под
`SET LOCAL app.current_tenant_id`. Песочница → ранний `return` (без денег). Тесты: списывает+сдвиг
paid_until; грейс копит долг; долг>grace → suspended; идемпотентность по периоду; песочница не трогает.
- [ ] **N2. `TelegramSenderService` → общий баланс.** Убрать `AdWalletService`/`AdWallet`/`freeze`/
`release` из потока имени:
- `requestSender` — по РАЗВИЛКЕ (А: без денег; Б: проверка `balance ≥ fee` без броска-с-бронью).
Убрать `freeze`.
- `approve` — вместо `charge`+`release` кошелька: `chargeName` с общего баланса (N0), период —
текущий месяц; active + `paid_until`+1мес. Нехватка при А — не одобряем (пробрасываем нехватку).
- `reject` / `disableSender(pending)` / `disableByAdmin(pending)` — убрать `release` (брони больше нет),
статусы прежние (rejected/cancelled/suspended).
- `enableSender` (suspended→active) — проверка `balance_rub ≥ fee` + `chargeName` за текущий период
(уже был charge, переводим с ad_wallet на общий баланс).
- `snapshot` — не трогает деньги, оставить.
- [ ] **N3. Убрать `AdWalletService` из телеграм-потока имени полностью.** После N1+N2 ни
`ChargeTgNameFeeJob`, ни `TelegramSenderService` не импортируют `AdWalletService`/`AdWallet`. Это
закрывает последнюю ссылку телеграм-модуля на кошелёк (кампании закрыты в куске 2). `deptrac`/`stan`
чисто. Сам `AdWalletService`/`AdWalletService`-тесты/таблицы НЕ трогаем.
- [ ] **N4 (D9). Тесты имени под общий баланс.** Переписать:
- `SenderLifecycleTest` — все ассерты `AdWallet frozen_rub/balance_rub``tenants.balance_rub` +
`BalanceTransaction TYPE_TG_NAME_FEE`; requestSender по выбранному варианту развилки; approve
списывает с баланса; джоб-кейсы (списание/грейс/suspend/идемпотентность/песочница) на общем балансе.
- `SenderApiTest` — там, где ассертит кошелёк/бронь → общий баланс (прочитать файл, поправить точечно).
- `WalletReFreezeTest` — это тест инварианта САМОГО `AdWalletService` (реактивация released-hold),
имя/кампанию он лишь использует как пример `source`. Прямого отношения к общему балансу не имеет.
Решение: НЕ переписывать под баланс (это не про телеграм-деньги, а про кошелёк, который остаётся);
оставить как есть — проверяет, что кошелёк-инфраструктура не сломана. Подтвердить прогоном.
Гонять ПО ОДНОМУ файлу (квирк партиций). pint/stan(0)/deptrac(0) чисто.
## Схема БД
Новых типов/колонок/таблиц НЕ добавляем — `tg_name_fee` уже в CHECK `balance_transactions_type_check`
и в модели (кусок 1). rls-reviewer не требуется (нет новых RLS-таблиц). CHANGELOG_schema не трогаем.
## После куска 3
Код-ревью (деньги/идемпотентность-по-периоду/RLS в джобе/песочница), затем ОБЩИЙ коммит кусков 1+2+3
по слову «коммить» (в gitea, не в GitHub).
## Состояние
- [ ] N0 · [ ] N1 · [ ] N2 · [ ] N3 · [ ] N4 — НЕ начаты. Ждём слово владельца + ответ на развилку.
@@ -0,0 +1,118 @@
# Аудит телеграм-модуля построчно: реальный ресейл МТС vs СМС-фантазии (28.07.2026)
Ветка `feat/client-telegram-ads`. Повод: владелец — «мы ресейлим МТС Маркетолог, а не фантазируем;
в телеге МТС никакого имени отправителя нет». Прочёсан весь модуль `ClientTg` построчно 5 параллельными
аудиторами (модели+миграции / контроллеры+роуты / сервисы / джобы / фронтенд+таблица messages).
Продукт по правде: ПОКАЗ рекламного объявления в Telegram по загруженной базе телефонов, робот-в-браузере
автоматизирует кабинет МТС. Это НЕ рассылка сообщений. Всё «сообщение на номер / отправитель / отписка /
шаблон сообщения» — наследие скопированного СМС-модуля.
## ✅ РЕАЛЬНОЕ (не трогать) — ядро показов
- Таблицы: `client_tg_campaigns` (+доп. поля status_reason/mts_campaign_id/moderator_file_path/GRANT),
`client_tg_campaign_phones`, `client_tg_contacts` (своя база), `client_tg_tariffs` (₽ за показ),
`client_tg_auto_rule`.
- Контроллеры/маршруты: `CampaignController` (list/store/show/launch/cancel/resubmit), `AutoRuleController`.
- Сервисы: `TelegramAudienceService`, `TelegramTariffService` (+наценка), `TelegramCampaignChargeService`
(charge/refund с общего баланса — куски 1-2), `TelegramRobotRunner`, `RobotResult`, `TelegramAutoAccumulator`,
`TelegramAudience`.
- Джобы: `AccumulateTelegramLeadJob`, `RunTelegramCampaignJob`, `ResubmitTelegramCampaignJob`,
`PollTelegramModerationJob`, `SweepStuckTelegramCampaignsJob`.
- Фронт: `AdvertisingTelegramView.vue` (создание кампании, аудитории, бюджет, ОРД, смета, запуск, список,
статусы, отказ+пересдача), `AdminTgView.vue:93-150` (тарифная сетка).
Ни один реальный сервис/джоб НЕ завязан на «имя отправителя» — grep по `Sender` даёт попадания только внутри
самого фантазийного кода. Расцеплять реальный поток не придётся.
## ⛔ ФАНТАЗИИ — подтверждено построчно (наследие СМС)
### Ф1. «Имя/бренд отправителя» + абонплата за него (владельцем подтверждено: в телеге МТС имени НЕТ)
Сквозная фича через все слои:
- Джоб `app/app/Jobs/ClientTg/ChargeTgNameFeeJob.php` (весь) + расписание `app/routes/console.php:319-327`.
- Сервис `app/app/Services/ClientTg/TelegramSenderService.php` (весь, 247 строк, 8 методов).
- Контроллер `app/app/Http/Controllers/Api/ClientTg/SenderController.php` (весь) + маршруты
`app/routes/web.php:106-110` (`/sender`, `/sender/disable`, `/sender/enable`).
- Модель `app/app/Models/ClientTg/Sender.php` + таблица `client_tg_senders` (миграция 000010).
- Таблица `client_tg_settings` (миграция 000008) + модель `Setting.php` — ОБЕ колонки
(`name_fee_rub_per_month`, `name_debt_grace_days`) обслуживают только имя → таблица целиком под нож.
- Фронт: `app/resources/js/views/advertising/TelegramSenderPanel.vue` (весь) + встройка в
`AdvertisingTelegramView.vue:259`; карточка `AdminTgView.vue:152-185` «Своё имя / бренд».
- Деньги: тип `tg_name_fee` в `balance_transactions` (добавлен в куске 1) — станет ненужным.
- Тесты: `SenderLifecycleTest`, `SenderApiTest`, `WalletReFreezeTest`, `AdminTgApiTest` (часть про имя).
### Ф2. Таблица `client_tg_messages` (миграция 000003) — МЁРТВАЯ
«По-получательские записи доставки» (phone/operator/provider_key/status/provider_message_id/error) — дословная
копия `sales_sms_messages`. Модели `ClientTg\Message` НЕТ (намеренно), в рабочем коде НИКТО не пишет/читает;
единственная ссылка — `SchemaTest.php:26-28` (проверка наличия). Живой поток пишет кандидатов в
`client_tg_campaign_phones`. Дропается отдельной миграцией.
## ❓ РАЗВИЛКИ — РЕШЕНО владельцем (28.07.2026)
- **Р1. Стоп-лист `client_tg_optouts`** → **ОСТАВИТЬ, но переосмыслить как «не показывать этим номерам»**
(ручной exclude-лист исключаемых номеров, НЕ «отписки/STOP»). Механизм `removeOptouts` в сборке аудитории
остаётся рабочим; правим только смысл/названия (комментарии, тексты, возможно имя метода/таблицы — БЕЗ слома).
- **Р2. `client_tg_templates`** → **ВЫРЕЗАТЬ** (не используется нигде: ни фронт, ни рабочий код).
- **Р3. Слово «Авторассылка»** → **ПЕРЕИМЕНОВАТЬ** в «Авто-реклама/Авто-кампании» (функция реальна, меняем только
название; СМС-лексику «рассылка» вычищаем из телеграм-текстов). Явно владелец не выбирал, но линия однозначна —
«мы ресейлим показы, не рассылку». Подтвердить перед правкой текстов.
- **Медиа-пробел** → **ДЕЛАЕМ СЕЙЧАС** (не откладываем): добавить загрузку картинки/видео в форму создания
кампании + проброс в робота. Сначала проверить, принимает ли робот `bots/mts-telegram-ads` медиа-файл
(в task.json уже есть поле `mediaFile` — вероятно да).
## 📋 ПЛАН ЧИСТКИ (куски, TDD, коммит только по «коммить»)
- **A. Вырезать «имя отправителя» (Ф1) целиком.** Удалить: `ChargeTgNameFeeJob` + расписание console.php:319-327;
`TelegramSenderService`; `SenderController` + маршруты web.php:106-110; модель `Sender` + миграция-дроп таблицы
`client_tg_senders`; таблица `client_tg_settings` (обе колонки только про имя) — миграция-дроп + модель `Setting`;
фронт `TelegramSenderPanel.vue` + встройка `AdvertisingTelegramView.vue:259`; карточка `AdminTgView.vue:152-185`;
админ-эндпоинты/тесты про имя. Удалить тесты `SenderLifecycleTest`, `SenderApiTest`, `WalletReFreezeTest`,
часть `AdminTgApiTest`. Проверить, что реальный поток не сломался (grep `Sender`/`Setting` → 0 в живом коде).
NB: тип `tg_name_fee` в `balance_transactions` (кусок 1) — станет мёртвым; можно оставить в CHECK (безвредно) или
убрать из миграции куска 1 (решить при выполнении, чтобы не плодить миграции).
- **B. Дропнуть мёртвую таблицу `client_tg_messages` (Ф2)** отдельной миграцией + убрать проверку в `SchemaTest`.
- **C. Вырезать `client_tg_templates` (Р2)** — миграция-дроп + модель `Template` + тесты, если есть.
- **D. Стоп-лист (Р1)** — оставить механизм, переименовать смысл «отписки»→«исключаемые номера» в комментариях/
текстах; функционал не трогать; тесты сохранить/поправить названия.
- **E. Тексты (Р3)** — «Авторассылка»/«рассылка» → «Авто-реклама» в телеграм-фронте и прозе (косметика).
- **F. МЕДИА (сейчас!)** — фронт: загрузка картинки/видео в форме создания кампании → `media_path`; бэк: проброс
до `TelegramRobotRunner` (поле `mediaFile` уже есть) и валидация. Сначала проверить поддержку в роботе.
Порядок безопасности: сначала бэкенд-вырезание с тестами (A→B→C), потом семантика/тексты (D→E), потом фича媒 media (F).
Каждый кусок — TDD, зелёные тесты по отдельности (квирк партиций), pint/stan/deptrac чисто. Коммит — только по «коммить».
## ⚠️ Реальный ПРОБЕЛ (не фантазия) — на заметку
Форма создания кампании принимает только текст+ссылку. У реального продукта МТС объявление может быть
с картинкой/видео, и поле `media_path` уже есть в типе (`api/telegram.ts:23`) и в базе, но загрузки медиа во
фронте нет (только «документ модератору» при пересдаче). Это недоделка показов, а не СМС-наследие — закрыть отдельно.
## Итог
Точно под нож: Ф1 (имя отправителя во всех слоях) + Ф2 (мёртвая таблица messages). Р1–Р3 — по слову владельца.
Ничего не режется без явного «режь». Куски 1-2 (показы → общий баланс) остаются.
---
## ✅ ВЫПОЛНЕНО (28.07.2026, по слову владельца «чисти модуль!») — НЕ закоммичено
Все куски A–F сделаны, проверены тестами (гонять по одному — квирк партиций; при флейке `db:wipe
--drop-types --force`). Коммит — по слову «коммить» (вместе с кусками 1-2).
- **A. «Имя отправителя» вырезано целиком.** Удалены: `ChargeTgNameFeeJob` (+расписание console.php),
`TelegramSenderService`, `SenderController` (+маршруты web.php), модель `Sender`, модель `Setting`,
фронт `TelegramSenderPanel.vue` (+встройка), карточка `AdminTgView.vue:152-185`, `updateTgSettings`
(контроллер+api+тесты), тип `TYPE_TG_NAME_FEE` (модель+миграция+schema.sql CHECK). Тесты
`SenderLifecycleTest`/`SenderApiTest`/`telegram-sender-panel.spec.ts` удалены; SchemaTest/ModelsTest/
AdminTgApiTest/admin-tg-view.spec/advertising-telegram-view.spec почищены. ✅ Green.
- **B+C. Дроп мёртвых таблиц** `client_tg_messages`, `client_tg_templates` (+ senders/settings) — миграция
`2026_07_28_000017_drop_client_tg_sms_legacy_tables.php` (`DROP IF EXISTS CASCADE`), create-миграции
000003/000006/000008/000010 удалены, модель `Template` удалена, schema CHANGELOG v8.95. ✅ Green.
- **D. Стоп-лист** — механизм оставлен, смысл «отписки/opt-out» → «список не показывать этим номерам»
(комментарии `Optout.php`, `TelegramAudienceService::removeOptouts`). Логика не тронута. ✅ AudienceServiceTest 7/7.
- **E. Лексика СМС вычищена** — «Авторассылка»→«Авто-реклама» (панель/api/тесты), «кошелёк
продвижения/заморожено»→«общий баланс» (экран), «авто-рассылка»→«авто-реклама» (accumulator).
- **F. МЕДИА (пробел закрыт).** Новый эндпоинт `POST /api/telegram/campaigns/{id}/media` (attachMedia:
png/jpg/gif/mp4 ≤50 МБ, только черновик, замена удаляет старый файл) + фронт `v-file-input` в форме +
`uploadTelegramMedia`, вызов после «Рассчитать». Робот УЖЕ принимает `media_path` (RunTelegramCampaignJob
→ mediaFile → cabinet.js `fillAdMedia`). ✅ MediaUploadTest 6/6, фронт advertising-telegram-view 19/19.
**Проверки:** pint clean, deptrac 0, larastan — только известный Pest-`$this` шум (не блокер, коммит с
`LEFTHOOK_EXCLUDE=larastan`). Прогоны зелёные поштучно: Schema 6, Models 5, AdminTgApi 3, CampaignCharge 6,
CampaignApi 9, Audience 7, Media 6; фронт 3 спеки 24 + медиа 19. Робот (bots/mts-telegram-ads) НЕ трогали.
@@ -0,0 +1,86 @@
# Телеграм-реклама: деньги на общем балансе + наценка 40% (переиспользуем SMS-модель)
Ветка `feat/client-telegram-ads`. Решение владельца (28.07): платить с ОБЩЕГО баланса (как SMS),
наценка 40%. Не пушить. Коммит только по «коммить».
## Что выяснено (обоснование)
- **SMS — рабочая модель:** клиентская цена считается при создании, списывается с ОБЩЕГО баланса
тенанта (`tenants.balance_rub`) через `SmsChargeService`, идемпотентность — по строке
`balance_transactions` (related_type+related_id). Заморозки НЕТ. Пополнение — общий баланс (ЮKassa).
- **Яндекс — НЕ решено:** отдельный `ad_wallets`/`AdWalletService` (кошелёк+заморозка) не используется
НИГДЕ в бою (ни Яндекс, ни телеграм-кампании), пополнять нечем. Заготовка без проводки.
- **Телеграм сейчас** висит на этом неиспользуемом кошельке (freeze/release/charge к ad_wallet) —
поэтому за показы не списывается. Плата за имя тоже идёт в ad_wallet (мимо реальных денег).
## Целевая модель (как SMS)
Деньги за телеграм-рекламу — с **общего баланса** тенанта. Кошелёк с заморозкой из телеграм-потока
УБИРАЕМ. Инфраструктуру `ad_wallets`/`AdWalletService` НЕ удаляем (таблицы/схему не трогаем — может
пригодиться), просто телеграм на неё больше не опирается.
**Наценка 40%:** клиентская цена = базовая смета тарифа × `markup` (config `client_tg.markup`, дефолт
1.40). Клиент видит и платит цену С наценкой. Тариф остаётся базой (себестоимость-оценка).
**Момент списания = ЗАПУСК (как SMS — списываем до отправки):** на `launch`/`resubmit` списываем
клиентскую смету (`estimated_cost_rub` с ×1.4) с общего баланса; не хватает → 409, кампания остаётся
черновиком (робот не пойдёт). Это заменяет нынешнюю заморозку `freeze`.
**Возврат при отказе модерации / сбое до кабинета** (было `release`): возвращаем на общий баланс
(refund). Одобрение → ничего (уже списано). Робот упал, но кампания МОГЛА уйти в кабинет
(есть mts_campaign_id) → needs_review, деньги НЕ возвращаем (F5, как сейчас).
> Почему списание при запуске, а не при одобрении: гарантирует оплату ДО того, как объявление
> появится в кабинете МТС (у МТС нет API остановить показ по нашему «не хватило денег»). Зеркалит
> SMS. Отказ модерации — редкий и обратимый (refund), одобрение — обычный путь без лишних операций.
## Приёмочный лист
- [ ] **D1. Наценка.** config `client_tg.markup` (1.40). `TelegramTariffService`: метод
`clientEstimateRub(impressions)` = `estimateRub × markup` (bcmath scale 2). `store`/пересчёт сметы и
экраны используют клиентскую цену. Приёмка: смета 100 показов base×1.4; markup из config.
- [ ] **D2. Списание с общего баланса.** Новый `TelegramCampaignChargeService` (зеркало
`SmsChargeService`): замок тенанта → идемпотентность по `balance_transactions`
(related_type=Campaign, related_id) → нехватка → InsufficientBalanceException → списание
`tenants.balance_rub` + BalanceTransaction (новый тип `TYPE_TG_AD_CHARGE`). Приёмка: списывает раз;
повторный вызов — no-op; нехватка → исключение, баланс не тронут.
- [ ] **D3. Возврат (refund) на общий баланс.** Метод `refund(campaign)`: если по кампании есть
charge-транзакция — вернуть сумму на баланс + обратная BalanceTransaction (`TYPE_TG_AD_REFUND`),
идемпотентно (по наличию refund-строки). Приёмка: после charge→refund баланс восстановлен; двойной
refund не задваивает.
- [ ] **D4. launch → charge вместо freeze.** `CampaignController::launch`: в бою `charge` клиентской
сметы вместо `freeze(budget_cap)`; нехватка → 409, draft. Песочница — без денег. Аналогично
`resubmit`. Приёмка: бой списывает смету; нехватка → 409; песочница не трогает деньги.
- [ ] **D5. Отказ/сбой → refund вместо release.** Точки, где сейчас `release`:
`RunTelegramCampaignJob`/`ResubmitTelegramCampaignJob` (failed без mts_id), `PollTelegramModerationJob`
(rejected). Заменить на `refund`. Логика F5 (есть mts_id → needs_review без возврата) сохраняется.
Приёмка: отказ модерации возвращает деньги; needs_review — нет.
- [ ] **D6. Убрать заморозку из телеграм-потока.** Снять вызовы `AdWalletService::freeze/release` и
импорты из: CampaignController, RunTelegramCampaignJob, ResubmitTelegramCampaignJob,
PollTelegramModerationJob, TelegramAutoAccumulator. (AdWalletService и таблицы НЕ удаляем.)
Приёмка: телеграм-код не ссылается на AdWalletService; авто-путь морозит через… см. D7.
- [ ] **D7. Авто-путь (TelegramAutoAccumulator).** Сейчас при живом авто-запуске морозит потолок.
Перевести на ту же модель: денег НЕ списывать в накопителе (спишет launch-путь джоба при запуске),
ИЛИ списывать смету при авто-запуске — выбрать при реализации так, чтобы точка списания была ОДНА
(не задвоить). Приёмка: авто-кампания списывает ровно один раз, дневной лимит цел.
- [ ] **D8. Плата за имя (ChargeTgNameFeeJob) → общий баланс.** Сейчас `AdWalletService::charge` к
ad_wallet. Перевести на общий баланс (та же charge-модель, свой external_key
`telegram:sender:{id}:{YYYY-MM}`, тип `TYPE_TG_NAME_FEE`). Порог платёжеспособности — по
`tenants.balance_rub`. Приёмка: списывает с общего баланса; долг>grace → имя suspended (как было).
- [ ] **D9. Тесты.** Обновить существующие (freeze→charge, release→refund в Run/Resubmit/Poll/launch),
новые для charge/refund-сервиса и наценки. Все ClientTg зелёные по отдельности; линтеры чисто.
## Схема БД
Новые ТИПЫ транзакций (`balance_transactions.type` — если это enum/CHECK, нужна миграция +
CHANGELOG_schema + rls-reviewer; если строковая колонка без CHECK — миграция не нужна). Проверить при D2.
Новых таблиц/колонок на кампании НЕ добавляем (`estimated_cost_rub` уже есть).
## Порядок (куски по 1-2 дня)
Кусок 1: D1 (наценка) + D2/D3 (charge/refund сервис) — фундамент, чистый TDD, без трогания потока.
Кусок 2: D4/D5/D6 (переключить launch/reject/джобы с freeze/release на charge/refund).
Кусок 3: D7 (авто-путь) + D8 (плата за имя) + D9 (добить тесты).
Между кусками — приёмка и коммит по «коммить».