2026-07-25 23:20:52 +03:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
use App\Jobs\ChargeSmsNameFeeJob;
|
|
|
|
|
|
use App\Models\AdWallet;
|
|
|
|
|
|
use App\Models\AdWalletTransaction;
|
|
|
|
|
|
use App\Models\ClientSmsSender;
|
2026-07-26 10:27:14 +03:00
|
|
|
|
use App\Models\ClientSmsSettings;
|
2026-07-25 23:20:52 +03:00
|
|
|
|
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,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-29 11:30:41 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Отключённое имя. `$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 дней — имя отключено, погасить у МТС',
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-25 23:20:52 +03:00
|
|
|
|
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');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-26 10:27:14 +03: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');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-25 23:20:52 +03: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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-29 11:30:41 +03:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-25 23:20:52 +03:00
|
|
|
|
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');
|
|
|
|
|
|
});
|