205 lines
8.9 KiB
PHP
205 lines
8.9 KiB
PHP
|
|
<?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'); // не списано
|
|||
|
|
});
|