fix(billing): единый источник runway — дашборд = биллинг (F3)

Дашборд считал «хватит на дни» от legacy balance_leads (≈0 для рублёвых тенантов)
и расходился с биллингом. Введён общий RunwayCalculator; оба контроллера считают
runway от affordable leads (рубли→лиды по тарифу, BalanceToLeadsConverter). Фронт
DashboardView больше не режет число дней до 7 сегментов полосы. TDD: 4 Pest нового
сервиса + обновлён DashboardSummary + 1 vitest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-17 14:07:22 +03:00
parent de56d955ae
commit 2a6b476d6d
7 changed files with 151 additions and 34 deletions
@@ -316,21 +316,8 @@ class BillingController extends Controller
*/
private function runwayDays(Tenant $tenant, int $affordableLeads): ?int
{
if ($affordableLeads <= 0) {
return 0;
}
$leadsLast30Days = (int) DB::table('lead_charges')
->where('tenant_id', $tenant->id)
->where('charged_at', '>=', now()->subDays(30))
->count();
if ($leadsLast30Days <= 0) {
return null;
}
$avgPerDay = $leadsLast30Days / 30.0;
return max(0, (int) floor($affordableLeads / $avgPerDay));
// F3 (17.06.2026): единый источник расчёта — RunwayCalculator (общий с дашбордом),
// чтобы прогноз «хватит на дни» не расходился между биллингом и дашбордом.
return app(\App\Services\Billing\RunwayCalculator::class)->daysLeft((int) $tenant->id, $affordableLeads);
}
}
@@ -103,13 +103,21 @@ class DashboardController extends Controller
->map(fn ($c) => (int) $c)
->toArray();
// --- runway ---
// runway опирается на приток за фиксированное 7-дневное окно,
// независимо от выбранного range (для today/30d $curLeads — не 7-дневный).
$leads7d = (clone $base())->whereBetween('received_at', [$now->subDays(7), $now])->count();
$avgDaily = $leads7d / 7.0;
$balanceLeads = (int) ($tenant->balance_leads ?? 0);
$runwayDays = $avgDaily > 0 ? (int) floor($balanceLeads / $avgDaily) : 0;
// --- runway (F3, 17.06.2026: единый источник с биллингом) ---
// Раньше дашборд считал от legacy `balance_leads` (после Billing v2 ≈0
// для рублёвых тенантов) → расходился с биллингом «0 дней ↔ N дней».
// Теперь — affordable leads от рублёвого баланса по тарифу
// (BalanceToLeadsConverter) + общий RunwayCalculator.
$activeTiers = app(\App\Repositories\PricingTierRepository::class)
->activeAt(\Carbon\Carbon::now('Europe/Moscow'));
$conversion = app(\App\Services\Billing\BalanceToLeadsConverter::class)->convert(
(string) $tenant->balance_rub,
(int) ($tenant->delivered_in_month ?? 0),
$activeTiers,
);
$affordableLeads = (int) $conversion['leads'];
$runwayDays = app(\App\Services\Billing\RunwayCalculator::class)
->daysLeft($tenantId, $affordableLeads) ?? 0;
return [
'range' => $range,
@@ -119,7 +127,7 @@ class DashboardController extends Controller
'balance' => [
'amount_rub' => (string) $tenant->balance_rub,
'runway_days' => $runwayDays,
'runway_leads' => $balanceLeads,
'runway_leads' => $affordableLeads,
],
'activity' => ['points' => $points, 'labels' => $labels, 'max' => $axisMax],
'funnel' => (object) $funnel,
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing;
use Illuminate\Support\Facades\DB;
/**
* Единый расчёт «на сколько дней хватит affordable_leads» (F3, 17.06.2026).
*
* Единственный источник истины для прогноза runway используется и биллингом
* (BillingController::wallet), и дашбордом (DashboardController::summary), чтобы
* исключить расхождение «0 дней (дашборд) N дней (биллинг)»: раньше дашборд
* считал от legacy `balance_leads`, а биллинг от рублёвого affordable.
*
* Billing v2 Spec A: affordable_leads выход BalanceToLeadsConverter (точная
* конверсия по ступеням), делённый на среднюю скорость списания за 30 дней
* (count(lead_charges)/30).
*
* - affordable_leads 0 0 (тенант не может купить ни одного лида).
* - leadsLast30Days = 0 null (нет истории, не от чего считать).
* - иначе floor(affordable_leads / (leadsLast30Days / 30)).
*/
class RunwayCalculator
{
public function daysLeft(int $tenantId, int $affordableLeads): ?int
{
if ($affordableLeads <= 0) {
return 0;
}
$leadsLast30Days = (int) DB::table('lead_charges')
->where('tenant_id', $tenantId)
->where('charged_at', '>=', now()->subDays(30))
->count();
if ($leadsLast30Days <= 0) {
return null;
}
$avgPerDay = $leadsLast30Days / 30.0;
return max(0, (int) floor($affordableLeads / $avgPerDay));
}
}
+3 -1
View File
@@ -74,7 +74,9 @@ function applySummary(s: DashboardSummary): void {
];
balance.value = {
amount: formatRub(s.balance.amount_rub),
runwayDays: Math.min(s.balance.runway_days, RUNWAY_MAX),
// F3: реальное число дней (полоса из RUNWAY_MAX сегментов заполняется
// естественно `i <= runwayDays`; раньше cap до 7 врал в тексте «хватит на N дней»).
runwayDays: s.balance.runway_days,
runwayMax: RUNWAY_MAX,
runwayLeads: s.balance.runway_leads,
};
+15 -9
View File
@@ -134,16 +134,22 @@ it('activity возвращает 7 точек и 7 меток', function () {
->assertJsonCount(7, 'activity.labels');
});
it('runway_days использует фикс. 7д-окно независимо от range', function () {
// balance_leads = 70; 7 сделок за последние 7 дней → avgDaily=1 → runway=70.
// Баг: range=today → $curLeads=1 → avgDaily=1/7≈0.143 → runway≈490 (неверно).
$tenant = Tenant::factory()->create(['balance_leads' => 70]);
it('runway считается от рублёвого баланса (единый источник с биллингом, F3)', function () {
// F3: runway дашборда теперь = affordable leads (рубли→лиды по тарифу) + общий
// RunwayCalculator, как в биллинге. balance_leads (285) больше НЕ используется.
// 14250₽, tier1 500₽, delivered=0 → affordable = floor(1425000/50000) = 28 лидов.
// 30 списаний за 30 дней → avg 1 лид/день → runway = floor(28/1) = 28.
// Те же числа, что в BillingOverviewControllerTest — доказывает единый источник.
$this->seed(\Database\Seeders\PricingTierSeeder::class);
$tenant = Tenant::factory()->create(['balance_rub' => '14250.00', 'balance_leads' => 285]);
actingForTenant($tenant);
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
for ($i = 0; $i <= 6; $i++) {
makeDashboardDeal($tenant, $project, 'new', now()->subDays($i));
}
\App\Models\LeadCharge::factory()->count(30)->create([
'tenant_id' => $tenant->id,
'charged_at' => now()->subDays(rand(1, 30)),
]);
$this->getJson('/api/dashboard/summary?range=today')
->assertOk()
->assertJsonPath('balance.runway_days', 70);
->assertJsonPath('balance.runway_leads', 28) // от рублей, НЕ 285 (balance_leads)
->assertJsonPath('balance.runway_days', 28);
});
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Services\Billing\RunwayCalculator;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
/**
* F3 (17.06.2026): единый расчёт runway для биллинга и дашборда.
* daysLeft(tenantId, affordableLeads):
* affordable<=0 0; нет списаний за 30 дн null;
* иначе floor(affordable / (lead_charges за 30 дн / 30)).
*/
uses(DatabaseTransactions::class);
/** Вставляет $count списаний с датой $chargedAt (deferred FK на deals — тест откатывается). */
function seedLeadCharges(int $tenantId, int $count, string $chargedAt): void
{
DB::statement('SET app.current_tenant_id = '.$tenantId);
for ($i = 0; $i < $count; $i++) {
DB::table('lead_charges')->insert([
'tenant_id' => $tenantId,
'deal_id' => 900000 + $i,
'deal_received_at' => $chargedAt,
'tier_no' => 1,
'price_per_lead_kopecks' => 50000,
'charge_source' => 'rub',
'charged_at' => $chargedAt,
'created_at' => now()->toDateTimeString(),
]);
}
}
test('daysLeft = 0 если affordableLeads <= 0', function () {
$tenant = Tenant::factory()->create();
expect(app(RunwayCalculator::class)->daysLeft($tenant->id, 0))->toBe(0);
expect(app(RunwayCalculator::class)->daysLeft($tenant->id, -5))->toBe(0);
});
test('daysLeft = null если нет списаний за 30 дней', function () {
$tenant = Tenant::factory()->create();
expect(app(RunwayCalculator::class)->daysLeft($tenant->id, 100))->toBeNull();
});
test('daysLeft = floor(affordable / средний дневной за 30 дней)', function () {
$tenant = Tenant::factory()->create();
seedLeadCharges($tenant->id, 30, now()->subDays(5)->toDateTimeString()); // 30/30 = 1 лид/день
expect(app(RunwayCalculator::class)->daysLeft($tenant->id, 90))->toBe(90);
});
test('daysLeft игнорирует списания старше 30 дней', function () {
$tenant = Tenant::factory()->create();
seedLeadCharges($tenant->id, 30, now()->subDays(40)->toDateTimeString()); // вне окна
expect(app(RunwayCalculator::class)->daysLeft($tenant->id, 90))->toBeNull();
});
+11
View File
@@ -85,4 +85,15 @@ describe('DashboardView.vue ↔ /api/dashboard/summary', () => {
await flushPromises();
expect(dashboardApi.getDashboardSummary).toHaveBeenCalledTimes(2);
});
// F3 (17.06.2026): runway_days в тексте — реальное число, не срезанное до
// RUNWAY_MAX (7 сегментов полосы). Иначе дашборд расходится с биллингом.
it('показывает реальный runway_days (не срезанный до 7 сегментов полосы)', async () => {
vi.mocked(dashboardApi.getDashboardSummary).mockResolvedValueOnce(
makeSummary({ balance: { amount_rub: '14250.00', runway_days: 28, runway_leads: 28 } }),
);
const wrapper = mountView();
await flushPromises();
expect(wrapper.text()).toContain('28 дня');
});
});