Files
portal/app/tests/Feature/ClientSms/NameFeeJobTest.php
T
Дмитрий f25c2f550d feat(смс-клиент): имя, отключённое за долг, возвращается само — за один месяц вперёд
Строка листа 4.4, Этап 4 Task 3.

Раньше ночной работник смотрел только на работающие имена, поэтому имя,
отключённое за долг, не возвращалось само никогда и ни при каких деньгах.
Теперь у него есть второй проход: как только у клиента хватило СВОБОДНЫХ денег
(с учётом замороженных под рассылки), имя включается, а плата берётся за один
месяц вперёд от сегодняшнего дня. Старый долг прощается — решение владельца
В-133: за месяцы, когда имя не работало, брать не за что. Документы заново не
запрашиваются: заявка не пересоздаётся, меняются только состояние и срок оплаты.

Сумма нигде не зашита — берётся из карточки имени. Она уже считается как
«цена за оператора × число операторов», и когда подключим остальных операторов,
вырастет сама.

Плана оказалось мало (журнал В-140). Он предлагал возвращать всё, что
«отключено и с долгом», но имя отключает и владелец руками из админки —
и такое имя погашено у МТС, оно не работает. Портал включал бы его обратно
и списывал за это деньги клиента, отменяя решение владельца. Отличать по тексту
записки нельзя: текст — не признак. Поэтому у имени появилась графа «почему
отключено» (за долг / рукой владельца); сам портал возвращает только первое.
Схема v9.18, миграция 2026_08_01_100600, прав не требует.

Второй правкой плана (В-141) переписан его тест на двойное списание: он проходил
вхолостую, потому что после первого прогона имя уже работает и второй проход его
не видит. Настоящая опасность — кнопка клиента (строка 4.5) и работник спишут за
один месяц дважды, если ключ у них разный. Тест теперь про это и краснеет от
порчи ключа.

Проверено вырезанием, четыре выреза, все вернуты:
— убрал проверку денег → покраснели четыре теста;
— убрал отбор по причине отключения → покраснел тест про имя владельца;
— испортил ключ месяца → списание прошло дважды, тест покраснел;
— убрал пометку причины из админки → покраснел тест админки.

Живой прогон под боевой ролью crm_app_user, пара «не произошло / произошло»:
на счету 50 ₽ при плате 100 — имя осталось отключённым, деньги не тронуты;
пополнил до 550 ₽ — имя работает, списано ровно 100 ₽, оплачено до 29.08,
на счету 450 ₽. В том же прогоне имя, выключенное владельцем, при 5000 ₽ на
счету осталось выключенным. Вторая пара — на пометку клиента: без неё имя не
включается вовсе, и работник в обоих случаях молчит.

Прогон вскрыл чужую мину (В-142): у боевой роли не было права на счётчик номеров
таблицы движений рекламного кошелька — под этой ролью молча не проходило ни одно
списание. Это расхождение локального стенда с эталоном db/02_grants.sql, кода не
касается; в памятку на выкат вписана читающая проверка этого права на бою.

Прогоны: клиентские СМС 262/262 (11 пачек, все с первой попытки), приём лидов
17/17, phpstan ровно 2 чужие давние, pint чисто. Фронт не трогался.
2026-07-29 11:30:41 +03:00

309 lines
14 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\ChargeSmsNameFeeJob;
use App\Models\AdWallet;
use App\Models\AdWalletTransaction;
use App\Models\ClientSmsSender;
use App\Models\ClientSmsSettings;
use App\Models\Tenant;
use App\Services\Advertising\AdWalletService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\Concerns\SharesSupplierPdo;
/**
* Task 14: ежемесячная плата за именного отправителя (client_sms_senders).
*
* Джоб перечисляет активных отправителей КРОСС-ТЕНАНТНО через pgsql_supplier
* (BYPASSRLS) — без share PDO вставленные в тест-транзакции отправители
* (default pgsql-соединение) не видны второму соединению до commit'а. Поэтому
* подключаем RefreshDatabase + SharesSupplierPdo вместе (зеркалит
* tests/Feature/Advertising/ChargeCampaignSpendJobTest).
*
* Настройки (fee 2500.00, grace 29) сидированы миграцией client_sms_settings.
*/
uses(RefreshDatabase::class);
uses(SharesSupplierPdo::class);
function makeActiveSender(int $tenantId, ?string $paidUntil, string $fee = '100.00', ?string $debtSince = null): ClientSmsSender
{
return ClientSmsSender::create([
'tenant_id' => $tenantId,
'name' => 'MyBrand',
'name_type' => ClientSmsSender::TYPE_COMPANY,
'status' => ClientSmsSender::STATUS_ACTIVE,
'operators' => ['mts'],
'monthly_fee_rub' => $fee,
'paid_until' => $paidUntil,
'debt_since' => $debtSince,
]);
}
/**
* Отключённое имя. `$reason` — ПОЧЕМУ отключено: за долг (портал вправе вернуть
* его сам) или рукой владельца (портал возвращать не смеет, журнал В-140).
*/
function makeSuspendedSender(int $tenantId, string $reason, string $fee = '100.00'): ClientSmsSender
{
return ClientSmsSender::create([
'tenant_id' => $tenantId,
'name' => 'MyBrand',
'name_type' => ClientSmsSender::TYPE_COMPANY,
'status' => ClientSmsSender::STATUS_SUSPENDED,
'operators' => ['mts'],
'monthly_fee_rub' => $fee,
'paid_until' => '2026-04-25', // не платили с апреля — три месяца долга
'debt_since' => '2026-04-26',
'suspended_reason' => $reason,
'note' => 'Долг более 29 дней — имя отключено, погасить у МТС',
]);
}
beforeEach(function () {
Carbon::setTestNow('2026-07-25 10:00:00');
config(['services.sms.sandbox' => false]);
});
afterEach(function () {
Carbon::setTestNow();
});
it('charges the monthly fee, advances paid_until ~1 month, clears debt when funded', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test');
$sender = makeActiveSender($tenant->id, '2026-07-24'); // вчера → к оплате
app(ChargeSmsNameFeeJob::class)->handle();
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->balance_rub)->toBe('4900.00'); // 5000 100
$sender->refresh();
expect($sender->status)->toBe(ClientSmsSender::STATUS_ACTIVE)
->and($sender->paid_until->toDateString())->toBe('2026-08-24')
->and($sender->debt_since)->toBeNull();
$tx = AdWalletTransaction::where('tenant_id', $tenant->id)
->where('external_key', "sms:sender:{$sender->id}:2026-07")
->first();
expect($tx)->not->toBeNull()
->and($tx->type)->toBe(AdWalletTransaction::TYPE_CHARGE)
->and($tx->amount_rub)->toBe('-100.00');
});
it('не падает целиком при пустой таблице настроек — берёт grace по умолчанию', function () {
ClientSmsSettings::query()->delete(); // настроек нет — джоб не должен упасть до обработки
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test');
$sender = makeActiveSender($tenant->id, '2026-07-24'); // к оплате
app(ChargeSmsNameFeeJob::class)->handle(); // не должно бросить
$sender->refresh();
expect($sender->status)->toBe(ClientSmsSender::STATUS_ACTIVE)
->and($sender->paid_until->toDateString())->toBe('2026-08-24')
->and((string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub)->toBe('4900.00');
});
it('sets debt_since to today and keeps the sender active when funds are short', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '50.00', null, 'test'); // < fee 100
$sender = makeActiveSender($tenant->id, '2026-07-24');
app(ChargeSmsNameFeeJob::class)->handle();
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->balance_rub)->toBe('50.00'); // не тронут
$sender->refresh();
expect($sender->status)->toBe(ClientSmsSender::STATUS_ACTIVE)
->and($sender->debt_since->toDateString())->toBe('2026-07-25')
->and($sender->paid_until->toDateString())->toBe('2026-07-24'); // НЕ продлён
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(0);
});
it('suspends the sender when the debt is older than the grace period', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '50.00', null, 'test'); // всё ещё нет денег
// grace 29; долг с 2026-06-25 = 30 дней назад → 30 > 29 → отключение.
$sender = makeActiveSender($tenant->id, '2026-07-24', '100.00', '2026-06-25');
app(ChargeSmsNameFeeJob::class)->handle();
$sender->refresh();
expect($sender->status)->toBe(ClientSmsSender::STATUS_SUSPENDED)
->and($sender->note)->toContain('Долг более 29 дней')
->and($sender->debt_since->toDateString())->toBe('2026-06-25')
->and($sender->paid_until->toDateString())->toBe('2026-07-24'); // НЕ продлён
});
it('leaves a sender untouched when paid_until is still in the future', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test');
$sender = makeActiveSender($tenant->id, '2026-07-26'); // завтра → не к оплате
app(ChargeSmsNameFeeJob::class)->handle();
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->balance_rub)->toBe('5000.00');
$sender->refresh();
expect($sender->status)->toBe(ClientSmsSender::STATUS_ACTIVE)
->and($sender->paid_until->toDateString())->toBe('2026-07-26')
->and($sender->debt_since)->toBeNull();
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(0);
});
it('does not double-charge when the whole job runs twice on the same day', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test');
$sender = makeActiveSender($tenant->id, '2026-07-24');
app(ChargeSmsNameFeeJob::class)->handle();
$balanceAfterFirst = (string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub;
app(ChargeSmsNameFeeJob::class)->handle();
$balanceAfterSecond = (string) AdWallet::where('tenant_id', $tenant->id)->first()->balance_rub;
expect($balanceAfterFirst)->toBe('4900.00')
->and($balanceAfterSecond)->toBe('4900.00'); // без повторного списания
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(1);
});
it('4.4 — отключённое за долг имя включается само, когда появились деньги, и берём за ОДИН месяц', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test');
$sender = makeSuspendedSender($tenant->id, ClientSmsSender::SUSPENDED_BY_DEBT);
app(ChargeSmsNameFeeJob::class)->handle();
$sender->refresh();
expect($sender->status)->toBe(ClientSmsSender::STATUS_ACTIVE)
->and($sender->debt_since)->toBeNull()
->and($sender->suspended_reason)->toBeNull()
->and($sender->note)->toBeNull()
// Оплачен месяц ВПЕРЁД от сегодняшнего дня, а не от протухшего paid_until.
->and($sender->paid_until->toDateString())->toBe('2026-08-25');
// Списан РОВНО один месяц, а не три: за месяцы, когда имя не работало,
// не берём (решение владельца В-133).
expect((string) AdWallet::where('tenant_id', $tenant->id)->value('balance_rub'))
->toBe('4900.00');
$tx = AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->get();
expect($tx)->toHaveCount(1)
->and($tx->first()->external_key)->toBe("sms:sender:{$sender->id}:2026-07");
});
it('4.4 — денег не хватает: имя остаётся отключённым и НИЧЕГО не списывается', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '50.00', null, 'test'); // < плата 100
$sender = makeSuspendedSender($tenant->id, ClientSmsSender::SUSPENDED_BY_DEBT);
app(ChargeSmsNameFeeJob::class)->handle();
$sender->refresh();
expect($sender->status)->toBe(ClientSmsSender::STATUS_SUSPENDED)
->and($sender->debt_since->toDateString())->toBe('2026-04-26')
->and((string) AdWallet::where('tenant_id', $tenant->id)->value('balance_rub'))->toBe('50.00');
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(0);
});
it('4.4 — замороженные под рассылку деньги свободными не считаются', function () {
$tenant = Tenant::factory()->create();
$wallet = app(AdWalletService::class);
$wallet->topup($tenant->id, '120.00', null, 'test');
$wallet->freeze($tenant->id, 'sms', 'campaign', 777, '100.00'); // свободно 20 < плата 100
$sender = makeSuspendedSender($tenant->id, ClientSmsSender::SUSPENDED_BY_DEBT);
app(ChargeSmsNameFeeJob::class)->handle();
expect($sender->fresh()->status)->toBe(ClientSmsSender::STATUS_SUSPENDED)
->and((string) AdWallet::where('tenant_id', $tenant->id)->value('balance_rub'))->toBe('120.00');
});
it('4.4 — имя, отключённое РУКОЙ ВЛАДЕЛЬЦА, работник обратно НЕ включает (В-140)', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test'); // денег полно
$sender = makeSuspendedSender($tenant->id, ClientSmsSender::SUSPENDED_BY_OWNER);
app(ChargeSmsNameFeeJob::class)->handle();
// Владелец гасил имя у МТС — портал не вправе включить его обратно, тем более за деньги клиента.
expect($sender->fresh()->status)->toBe(ClientSmsSender::STATUS_SUSPENDED)
->and((string) AdWallet::where('tenant_id', $tenant->id)->value('balance_rub'))->toBe('5000.00');
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(0);
});
it('4.4 — за текущий месяц дважды не спишем: клиент уже оплатил кнопкой (В-141)', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test');
$sender = makeSuspendedSender($tenant->id, ClientSmsSender::SUSPENDED_BY_DEBT);
// Так спишет кнопка «включить имя обратно» (Task 4) — тот же месяц, тот же ключ.
app(AdWalletService::class)->charge(
$tenant->id, 'sms', 'sender', $sender->id, '100.00', "sms:sender:{$sender->id}:2026-07",
);
app(ChargeSmsNameFeeJob::class)->handle();
expect((string) AdWallet::where('tenant_id', $tenant->id)->value('balance_rub'))->toBe('4900.00');
expect(AdWalletTransaction::where('tenant_id', $tenant->id)
->where('type', AdWalletTransaction::TYPE_CHARGE)->count())->toBe(1);
});
it('4.4 — в песочнице отключённое за долг имя не воскресает и деньги не двигаются', function () {
config(['services.sms.sandbox' => true]);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test');
$sender = makeSuspendedSender($tenant->id, ClientSmsSender::SUSPENDED_BY_DEBT);
app(ChargeSmsNameFeeJob::class)->handle();
expect($sender->fresh()->status)->toBe(ClientSmsSender::STATUS_SUSPENDED)
->and((string) AdWallet::where('tenant_id', $tenant->id)->value('balance_rub'))->toBe('5000.00');
});
it('4.4 — отключая за долг, работник записывает ПРИЧИНУ отключения', function () {
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '50.00', null, 'test');
// grace 29; долг с 2026-06-25 = 30 дней назад → отключение.
$sender = makeActiveSender($tenant->id, '2026-07-24', '100.00', '2026-06-25');
app(ChargeSmsNameFeeJob::class)->handle();
expect($sender->fresh()->suspended_reason)->toBe(ClientSmsSender::SUSPENDED_BY_DEBT);
});
it('is a no-op in sandbox mode (no money moves)', function () {
config(['services.sms.sandbox' => true]);
$tenant = Tenant::factory()->create();
app(AdWalletService::class)->topup($tenant->id, '5000.00', null, 'test');
$sender = makeActiveSender($tenant->id, '2026-07-24');
app(ChargeSmsNameFeeJob::class)->handle();
$wallet = AdWallet::where('tenant_id', $tenant->id)->first();
expect((string) $wallet->balance_rub)->toBe('5000.00');
$sender->refresh();
expect($sender->status)->toBe(ClientSmsSender::STATUS_ACTIVE)
->and($sender->paid_until->toDateString())->toBe('2026-07-24');
});