Files
portal/app/tests/Feature/ClientTg/RefundOnFailTest.php
T
Дмитрий 6f469fb13d 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>
2026-07-29 06:26:07 +03:00

155 lines
7.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Jobs\ClientTg\RunTelegramCampaignJob;
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\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 — ВОЗВРАТ денег с ОБЩЕГО баланса тенанта (tenants.balance_rub, как в
* СМС) при отказе робота (finalize), перманентном сбое джоба (failed) и отмене
* кампании (cancel). Старого рекламного кошелька с freeze/frozen_rub больше нет:
* деньги списываются на запуске (charge) и возвращаются (refund) через
* TelegramCampaignChargeService. Источник правды идемпотентности — сальдо проводок
* balance_transactions (TYPE_TG_AD_CHARGE TYPE_TG_AD_REFUND по related Campaign).
*
* Ключевые развилки, которые проверяем:
* - отказ робота БЕЗ 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 $estimatedCost = '315.00'): Campaign
{
$campaign = 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' => $estimatedCost,
'created_by' => 1,
]);
foreach (['79990000001', '79990000002'] as $phone) {
CampaignPhone::query()->create([
'tenant_id' => $tenantId,
'campaign_id' => $campaign->id,
'phone' => $phone,
]);
}
return $campaign;
}
/** Подменяет раннер моком, возвращающим $result. */
function refundMockRobot(RobotResult $result): void
{
$mock = Mockery::mock(TelegramRobotRunner::class);
$mock->shouldReceive('run')->andReturn($result);
app()->instance(TelegramRobotRunner::class, $mock);
}
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);
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 () {
config()->set('client_tg.sandbox', true);
$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);
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: не списывали → возврат no-op, баланс цел, статус cancelled', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
// Черновик деньги не списывал — cancel вызовет refund, но возвращать нечего (сальдо 0).
$campaign = refundCampaign($tenant->id, Campaign::STATUS_DRAFT, '300.00');
$this->postJson("/api/telegram/campaigns/{$campaign->id}/cancel")
->assertOk()
->assertJsonPath('status', Campaign::STATUS_CANCELLED);
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(['balance_rub' => '1000.00']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user);
$campaign = refundCampaign($tenant->id, Campaign::STATUS_RUNNING, '300.00');
$this->postJson("/api/telegram/campaigns/{$campaign->id}/cancel")->assertStatus(422);
expect(Campaign::find($campaign->id)->status)->toBe(Campaign::STATUS_RUNNING);
});
it('cancel в песочнице не трогает деньги (баланс цел, проводок нет)', function () {
config()->set('client_tg.sandbox', true);
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$this->actingAs($user);
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$campaign = refundCampaign($tenant->id, Campaign::STATUS_DRAFT, '300.00');
$this->postJson("/api/telegram/campaigns/{$campaign->id}/cancel")
->assertOk()
->assertJsonPath('status', Campaign::STATUS_CANCELLED);
expect((string) $tenant->fresh()->balance_rub)->toBe('1000.00')
->and(BalanceTransaction::where('related_id', $campaign->id)->count())->toBe(0);
});