travelTo(CarbonImmutable::parse('2026-08-03 09:00', 'UTC')); }); /** Кампания в статусе queued со снимком цены (8.50 за СМС), как её создаёт контроллер. */ function noFundsCampaign(int $tenantId, int $totalSms): ClientSmsCampaign { return ClientSmsCampaign::create([ 'tenant_id' => $tenantId, 'title' => 'Рассылка', 'body' => 'Здравствуйте!', 'sender_name' => 'liderra.ru', 'source' => ClientSmsCampaign::SOURCE_BASE, 'audience_days' => null, 'status' => ClientSmsCampaign::STATUS_QUEUED, 'segments' => 1, 'planned_count' => $totalSms, 'sent_count' => 0, 'total_sms' => $totalSms, 'price_rub_per_sms' => '8.50', 'estimated_cost_rub' => bcmul('8.50', (string) $totalSms, 2), 'actual_cost_rub' => null, 'created_by' => null, ]); } /** * 🪤 В-46: канал выбирается ДВАЖДЫ — отборщиком при создании рассылки и читателем * снимка при отправке. Читатель берёт роутер из контейнера, поэтому подменять надо * роутер: иначе отправится ноль, а денежный тест останется зелёным, сравнивая * ноль с нулём. */ function noFundsBindRoute(): void { app()->instance(SmsRouter::class, new SmsRouter([new MtsSmsProvider('x', ['mts'], ['*' => 0])])); } /** Снимок получателей — то, что в бою делает контроллер (строки листа 2.1–2.2). */ function noFundsSnapshot(ClientSmsCampaign $campaign): void { $selector = new ClientSmsRecipientSelector(app(SmsRouter::class), new OperatorNormalizer, new AllowedSmsOperators(new OperatorNormalizer)); $recipients = app(ClientSmsAudienceBuilder::class)->build($campaign); app(ClientSmsSnapshotWriter::class)->write( (int) $campaign->tenant_id, (int) $campaign->id, $selector->build($recipients, (int) $campaign->tenant_id), ); } /** * Деньги «ушли на сторону» между созданием рассылки и отправкой — ровно так же, * как если бы их потратил другой рекламный канал того же клиента. */ function noFundsDrainWallet(int $tenantId, string $leave): void { DB::table('ad_wallets')->where('tenant_id', $tenantId)->update(['balance_rub' => $leave]); } /** Готовая к отправке рассылка на 3 номера с замороженной сметой 25.50. */ function noFundsPrepare(): array { config(['services.sms.sandbox' => false]); Http::fake(['api.mts.ru/*' => Http::response([ 'code' => 0, 'data' => ['submitResults' => [['msid' => '79990000001', 'messageID' => 'MSG-OK-1', 'code' => 'OK']]], ], 200)]); $tenant = Tenant::factory()->create(); $wallet = app(AdWalletService::class); $wallet->topup($tenant->id, '1000.00', null, 'test'); foreach (['79995550001', '79995550002', '79995550003'] as $i => $phone) { ClientSmsContact::create([ 'tenant_id' => $tenant->id, 'phone' => $phone, 'name' => 'К'.$i, 'operator' => 'МТС', // Этап 3: без известного региона номер не уходит вовсе (В-85). 'tz_offset_minutes' => 180, ]); } noFundsBindRoute(); $campaign = noFundsCampaign($tenant->id, 3); noFundsSnapshot($campaign); $wallet->freeze($tenant->id, 'sms', 'campaign', $campaign->id, '25.50'); // 8.50 × 3 // Осталось 12.00 — хватает ровно на одно СМС, на второе уже нет. noFundsDrainWallet($tenant->id, '12.00'); return [$tenant, $campaign, $wallet]; } function noFundsRunJob(int $campaignId, int $tenantId, AdWalletService $wallet): void { (new SendClientSmsCampaignJob($campaignId, $tenantId))->handle( app(ClientSmsSnapshotReader::class), app(ClientSmsPricing::class), $wallet, ); } it('останавливается честно, когда деньги кончились посреди рассылки', function () { [$tenant, $campaign, $wallet] = noFundsPrepare(); noFundsRunJob($campaign->id, $tenant->id, $wallet); $campaign->refresh(); expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_CANCELLED) ->and($campaign->stop_reason)->toBe(ClientSmsCampaign::STOP_NO_FUNDS) ->and($campaign->sent_count)->toBe(1) // ушло меньше, чем планировали ->and((string) $campaign->actual_cost_rub)->toBe('8.50'); // В журнале видно, на каком номере встали — а не «просто кончилось». expect(ClientSmsMessage::where('campaign_id', $campaign->id) ->where('status', ClientSmsMessage::SKIP_NO_FUNDS)->count())->toBe(1); }); it('не обнуляет баланс молча: списано ровно за отправленное', function () { [$tenant, $campaign, $wallet] = noFundsPrepare(); noFundsRunJob($campaign->id, $tenant->id, $wallet); $after = AdWallet::where('tenant_id', $tenant->id)->first(); // 12.00 − 8.50 за единственное ушедшее СМС. Не «0.00», к которому прижимает charge. expect((string) $after->balance_rub)->toBe('3.50') ->and((string) $after->balance_rub)->toBe( bcsub('12.00', (string) $campaign->fresh()->actual_cost_rub, 2) ) ->and((string) $after->frozen_rub)->toBe('0.00'); // заморозка снята, деньги не заперты }); it('своя заморозка не мешает: денег хватает — уходят все, остановки нет', function () { // Обратная сторона ловушки В-41: если бы «доступно» считалось как // баланс − заморожено, эта рассылка встала бы на первом же номере при полном кошельке. config(['services.sms.sandbox' => false]); Http::fake(['api.mts.ru/*' => Http::response([ 'code' => 0, 'data' => ['submitResults' => [['msid' => '79990000001', 'messageID' => 'MSG-OK-1', 'code' => 'OK']]], ], 200)]); $tenant = Tenant::factory()->create(); $wallet = app(AdWalletService::class); $wallet->topup($tenant->id, '25.50', null, 'test'); // ровно на смету, ни рублём больше foreach (['79995550001', '79995550002', '79995550003'] as $i => $phone) { ClientSmsContact::create([ 'tenant_id' => $tenant->id, 'phone' => $phone, 'name' => 'К'.$i, 'operator' => 'МТС', // Этап 3: без известного региона номер не уходит вовсе (В-85). 'tz_offset_minutes' => 180, ]); } noFundsBindRoute(); $campaign = noFundsCampaign($tenant->id, 3); noFundsSnapshot($campaign); $wallet->freeze($tenant->id, 'sms', 'campaign', $campaign->id, '25.50'); noFundsRunJob($campaign->id, $tenant->id, $wallet); $campaign->refresh(); expect($campaign->status)->toBe(ClientSmsCampaign::STATUS_DONE) ->and($campaign->stop_reason)->toBeNull() ->and($campaign->sent_count)->toBe(3) ->and((string) $campaign->actual_cost_rub)->toBe('25.50'); expect(ClientSmsMessage::where('campaign_id', $campaign->id) ->where('status', ClientSmsMessage::SKIP_NO_FUNDS)->exists())->toBeFalse(); });