3a2f260334
Перед каждым списанием джоб проверяет, что деньги есть. Без этого AdWalletService::charge при недоборе не бросает исключение, а прижимает баланс к нулю: часть рассылки уходила бы бесплатно, а клиент видел бы пустой кошелёк без объяснения. Доступное этой рассылке = баланс − заморожено + СВОЯ активная заморозка. Наивное «баланс − заморожено» остановило бы рассылку на первом же номере при полном кошельке — вся смета заморожена при создании (ловушка В-41, доказана вырезанием: тест-страховка краснеет). Кончились деньги — номер пишется в журнал как «не хватило денег», рассылка получает статус «остановлена» с причиной no_funds, заморозка снимается. Экран разбирает причину: «Остановлено: закончились деньги. Ушло N из M, списано X ₽». Живой прогон нашёл то, чего не видели тесты (В-48): статус в колонке рядом писал «остановлена вами» и для денежной остановки. Починено, тест расширен на всю ячейку. Строка приёмочного листа 2.4. Тесты: ClientSms 147/147, приём лидов 17/17, фронт 1648, phpstan 0, pint чисто. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
207 lines
9.2 KiB
PHP
207 lines
9.2 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
use App\Jobs\SendClientSmsCampaignJob;
|
||
use App\Models\AdWallet;
|
||
use App\Models\ClientSmsCampaign;
|
||
use App\Models\ClientSmsContact;
|
||
use App\Models\ClientSmsMessage;
|
||
use App\Models\Tenant;
|
||
use App\Services\Advertising\AdWalletService;
|
||
use App\Services\ClientSms\ClientSmsAudienceBuilder;
|
||
use App\Services\ClientSms\ClientSmsPricing;
|
||
use App\Services\ClientSms\ClientSmsRecipientSelector;
|
||
use App\Services\ClientSms\ClientSmsSnapshotReader;
|
||
use App\Services\ClientSms\ClientSmsSnapshotWriter;
|
||
use App\Services\Sms\OperatorNormalizer;
|
||
use App\Services\Sms\Providers\MtsSmsProvider;
|
||
use App\Services\Sms\SmsRouter;
|
||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Http;
|
||
|
||
/**
|
||
* Деньги кончились посреди рассылки (строка листа 2.4).
|
||
*
|
||
* Почему это вообще нужно: AdWalletService::charge при недоборе НЕ бросает
|
||
* исключение, а «прижимает» баланс к нулю. То есть без проверки рассылка молча
|
||
* доходит до конца, часть СМС уходит бесплатно, а клиент видит нулевой кошелёк
|
||
* без объяснения. Класс поломки — «молчаливый сбой» (20.07).
|
||
*
|
||
* 🪤 Ловушка В-41: вся смета ЗАМОРОЖЕНА при создании рассылки, поэтому
|
||
* «свободно = баланс − заморожено» ≈ 0 и наивная проверка остановила бы
|
||
* рассылку на первом же номере. Доступное считается как
|
||
* `баланс − заморожено + СВОЯ активная заморозка`.
|
||
*
|
||
* Номера только синтетические 7999… — в реальном режиме это боевая отправка.
|
||
*/
|
||
uses(RefreshDatabase::class);
|
||
|
||
/** Кампания в статусе 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);
|
||
$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' => 'МТС',
|
||
]);
|
||
}
|
||
|
||
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' => 'МТС',
|
||
]);
|
||
}
|
||
|
||
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();
|
||
});
|