From a4833a9ea4acf6fc4f9df39fcd8ba4ca84de8387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 23 May 2026 13:24:01 +0300 Subject: [PATCH] =?UTF-8?q?refactor(billing-v2):=20runwayDays=20=3D=20affo?= =?UTF-8?q?rdable=5Fleads=20=C3=B7=20avg-leads-per-day?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Api/BillingController.php | 38 +++++++++++-------- .../Billing/BillingOverviewControllerTest.php | 20 ++++++---- 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/app/app/Http/Controllers/Api/BillingController.php b/app/app/Http/Controllers/Api/BillingController.php index f710f0ed..866e3d89 100644 --- a/app/app/Http/Controllers/Api/BillingController.php +++ b/app/app/Http/Controllers/Api/BillingController.php @@ -101,7 +101,7 @@ class BillingController extends Controller 'current_tier' => $conversion['current_tier'], 'next_tier' => $conversion['next_tier'], 'delivered_in_month' => (int) ($tenant->delivered_in_month ?? 0), - 'runway_days' => $this->runwayDays($tenant), + 'runway_days' => $this->runwayDays($tenant, $conversion['leads']), 'tiers_preview' => $tiersPreview, 'tariff' => $tenant->tariff === null ? null : [ 'code' => $tenant->tariff->code, @@ -186,27 +186,35 @@ class BillingController extends Controller } /** - * Прогноз «на сколько дней хватит баланса» — оценочный UX-показатель. + * Прогноз «на сколько дней хватит affordable_leads» — оценочный UX-показатель. * - * = balance_rub / (рублёвые списания за 30 дней / 30). NULL, если списаний - * не было. Float здесь допустим: грубая оценка для шапки, НЕ мутация - * баланса (мутации баланса — строго bcmath, см. BillingTopupService). - * Отрицательный баланс → 0 (тенант уже в минусе, runway не может быть < 0). + * Billing v2 Spec A: считаем по affordable_leads (выход BalanceToLeadsConverter) + * делённому на среднюю скорость списания за 30 дней (count(lead_charges)/30). + * Раньше формула была balance_rub / per-day-rub-spend — после унификации + * единицы измерения «лиды» более показательны и устраняют дрейф между + * рублёвой шапкой и тарифной ступенью. + * + * - affordable_leads ≤ 0 → 0 (тенант не может купить ни одного лида). + * - leadsLast30Days = 0 → null (нет истории, не от чего считать). + * - иначе → floor(affordable_leads / (leadsLast30Days / 30)). */ - private function runwayDays(Tenant $tenant): ?int + private function runwayDays(Tenant $tenant, int $affordableLeads): ?int { - $spent = abs((float) DB::table('balance_transactions') - ->where('tenant_id', $tenant->id) - ->where('type', BalanceTransaction::TYPE_LEAD_CHARGE) - ->where('created_at', '>=', now()->subDays(30)) - ->sum('amount_rub')); + if ($affordableLeads <= 0) { + return 0; + } - if ($spent <= 0.0) { + $leadsLast30Days = (int) DB::table('lead_charges') + ->where('tenant_id', $tenant->id) + ->where('charged_at', '>=', now()->subDays(30)) + ->count(); + + if ($leadsLast30Days <= 0) { return null; } - $perDay = $spent / 30.0; + $avgPerDay = $leadsLast30Days / 30.0; - return max(0, (int) floor((float) $tenant->balance_rub / $perDay)); + return max(0, (int) floor($affordableLeads / $avgPerDay)); } } diff --git a/app/tests/Feature/Billing/BillingOverviewControllerTest.php b/app/tests/Feature/Billing/BillingOverviewControllerTest.php index 566642a4..79a8db56 100644 --- a/app/tests/Feature/Billing/BillingOverviewControllerTest.php +++ b/app/tests/Feature/Billing/BillingOverviewControllerTest.php @@ -3,15 +3,17 @@ declare(strict_types=1); use App\Models\BalanceTransaction; +use App\Models\LeadCharge; use App\Models\Tenant; use App\Models\User; +use Database\Seeders\PricingTierSeeder; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\DB; uses(DatabaseTransactions::class); beforeEach(function () { - $this->seed(\Database\Seeders\PricingTierSeeder::class); + $this->seed(PricingTierSeeder::class); $this->tenant = Tenant::factory()->create([ 'balance_rub' => '14250.00', 'balance_leads' => 285, @@ -52,16 +54,18 @@ test('GET /api/billing/wallet: runway_days = null без списаний', func ->assertJsonPath('runway_days', null); }); -test('GET /api/billing/wallet: runway_days рассчитан при наличии списаний', function () { - BalanceTransaction::factory()->create([ +test('GET /api/billing/wallet: runway_days рассчитан как affordable_leads / avg leads-per-day', function () { + // Seed 30 historical lead_charges over the last 30 days — avg 1 lead/day. + LeadCharge::factory()->count(30)->create([ 'tenant_id' => $this->tenant->id, - 'type' => 'lead_charge', - 'amount_rub' => '-3000.00', - 'created_at' => now()->subDays(10), + 'charged_at' => now()->subDays(rand(1, 30)), ]); - // 3000 ₽ / 30 дн = 100 ₽/день; баланс 14250 → floor(142.5) = 142. - expect($this->getJson('/api/billing/wallet')->json('runway_days'))->toBe(142); + // Wallet has 14250 ₽. PricingTierSeeder tier 1: 100 leads @ 500₽. + // delivered_in_month=0 → 100 slots left in tier 1. afford = bcdiv(1425000, 50000, 0) = 28 leads. + // take = min(100, 28) = 28 → affordable_leads = 28. + // avg = 30/30 = 1 lead/day. runway = floor(28 / 1) = 28. + expect($this->getJson('/api/billing/wallet')->json('runway_days'))->toBe(28); }); test('GET /api/billing/wallet: runway_days = 0 при отрицательном балансе', function () {