$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' => ClientSmsSender::NOTE_DEBT_SUSPENDED, ]); } 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(); // 🔴 08.08.2026: раньше здесь сверялся текст записки («Долг более 29 дней»), но // записку ВИДИТ КЛИЕНТ, и служебные подробности из неё убраны. Признак отключения // за долг живёт в `suspended_reason` — именно по нему код и решает, вправе ли портал // вернуть имя сам (В-140). Сторожим признак, а не формулировку: охрана та же, а // текст на экране клиента теперь можно менять, не ломая тест. expect($sender->status)->toBe(ClientSmsSender::STATUS_SUSPENDED) ->and($sender->suspended_reason)->toBe(ClientSmsSender::SUSPENDED_BY_DEBT) ->and($sender->note)->toBe(ClientSmsSender::NOTE_DEBT_SUSPENDED) ->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'); });