validate([ 'amount_rub' => ['required', 'numeric', 'min:100', 'max:1000000', 'decimal:0,2'], ]); /** @var User $user */ $user = $request->user(); $amountRub = bcadd((string) $validated['amount_rub'], '0', 2); // Развилка: реальный шлюз (флаг ВКЛ) ИЛИ мгновенная заглушка (флаг ВЫКЛ). if (SystemSettings::bool('billing_yookassa_enabled')) { $manager = app(PaymentGatewayManager::class); $gateway = $manager->activeGateway(); if ($gateway === null) { return response()->json(['message' => 'Платёжный шлюз не настроен.'], 503); } $returnUrl = rtrim((string) config('app.url'), '/').'/billing?topup=return'; $result = app(OnlineTopupService::class)->start( (int) $user->tenant_id, $amountRub, $gateway, $returnUrl, (int) $user->id ); return response()->json(['confirmation_url' => $result->confirmationUrl], 201); } // Заглушка (текущее прод-поведение до Б-1): мгновенное зачисление. $tx = $this->topupService->topup((int) $user->tenant_id, $amountRub, (int) $user->id); return response()->json([ 'transaction' => [ 'id' => $tx->id, 'type' => $tx->type, 'amount_rub' => $tx->amount_rub, 'balance_rub_after' => $tx->balance_rub_after, 'created_at' => $tx->created_at, ], 'balance_rub' => $tx->balance_rub_after, ], 201); } /** * GET /api/billing/wallet — единый ₽-баланс + рассчитанные «≈ N лидов» + 7-ступенчатый превью. * * Billing v2 Spec A: `balance_leads` ушёл из ответа; конверсия ₽ → лиды * считается на лету через BalanceToLeadsConverter (точный расчёт по * ступеням, не «по текущей»). Тариф унифицирован до name+features. */ public function wallet(Request $request): JsonResponse { /** @var User $user */ $user = $request->user(); /** @var Tenant $tenant */ $tenant = Tenant::query()->with('tariff')->findOrFail((int) $user->tenant_id); $activeTiers = app(PricingTierRepository::class)->activeAt(Carbon::now('Europe/Moscow')); $conversion = app(BalanceToLeadsConverter::class)->convert( (string) $tenant->balance_rub, (int) ($tenant->delivered_in_month ?? 0), $activeTiers, ); $tiersPreview = $activeTiers ->sortBy('tier_no') ->values() ->map(static fn ($t) => [ 'tier_no' => (int) $t->tier_no, 'leads_in_tier' => $t->leads_in_tier === null ? null : (int) $t->leads_in_tier, 'price_rub' => bcdiv((string) $t->price_per_lead_kopecks, '100', 2), ]) ->all(); return response()->json([ 'balance_rub' => $tenant->balance_rub, 'affordable_leads' => $conversion['leads'], 'current_tier' => $conversion['current_tier'], 'next_tier' => $conversion['next_tier'], 'delivered_in_month' => (int) ($tenant->delivered_in_month ?? 0), 'runway_days' => $this->runwayDays($tenant, $conversion['leads']), 'tiers_preview' => $tiersPreview, 'tariff' => $tenant->tariff === null ? null : [ 'code' => $tenant->tariff->code, 'name' => $tenant->tariff->name, 'features' => $tenant->tariff->features ?? [], ], ]); } /** * GET /api/billing/balance-status — лёгкий статус баланса для UI префлайта * (Billing v2 Spec C §3.6). Питает глобальный баннер заморозки * (BalanceFrozenBanner: frozen_by_balance_at + дефицит) и индикатор ёмкости * (BalanceCapacityIndicator: balance / capacity / required). Грузится в * AppLayout на всех страницах, поэтому без tiers_preview и истории. */ public function balanceStatus(Request $request): JsonResponse { /** @var User $user */ $user = $request->user(); /** @var Tenant $tenant */ $tenant = Tenant::query()->findOrFail((int) $user->tenant_id); $activeTiers = app(PricingTierRepository::class)->activeAt(Carbon::now('Europe/Moscow')); $deliveredInMonth = (int) ($tenant->delivered_in_month ?? 0); $capacityLeads = (int) app(BalanceToLeadsConverter::class)->convert( (string) $tenant->balance_rub, $deliveredInMonth, $activeTiers, )['leads']; // Требуемые лиды/день — сумма лимитов активных не-заблокированных проектов // (та же выборка, что в ProjectController preflight). $requiredLeads = (int) Project::query() ->where('tenant_id', $tenant->id) ->where('is_active', true) ->whereNull('preflight_blocked_at') ->sum('daily_limit_target'); $deficitLeads = max(0, $requiredLeads - $capacityLeads); $deficitRub = '0.00'; if ($deficitLeads > 0) { $needed = $this->minBalanceForLeads($requiredLeads, $deliveredInMonth, $activeTiers); $deficitRub = bcsub($needed, (string) $tenant->balance_rub, 2); if (bccomp($deficitRub, '0', 2) < 0) { $deficitRub = '0.00'; } } return response()->json([ 'frozen_by_balance_at' => $tenant->frozen_by_balance_at?->toISOString(), 'balance_rub' => (string) $tenant->balance_rub, 'capacity_leads' => $capacityLeads, 'required_leads_per_day' => $requiredLeads, 'deficit_leads' => $deficitLeads, 'deficit_rub' => $deficitRub, ]); } /** * Минимальный баланс (₽, scale 2), чтобы позволить себе $leads лидов при уже * доставленных $deliveredInMonth в этом месяце — сумма цен ступеней по позициям * [delivered .. delivered+leads-1]. Зеркалит логику BalanceToLeadsConverter. * * @param Collection $tiers */ private function minBalanceForLeads(int $leads, int $deliveredInMonth, $tiers): string { if ($leads <= 0) { return '0.00'; } $sorted = $tiers ->filter(fn ($t) => (bool) $t->is_active) ->sortBy('tier_no') ->values(); $kopecks = '0'; $remaining = $leads; $cumulative = 0; // позиции [0..cumulative) пройдены предыдущими ступенями $position = $deliveredInMonth; foreach ($sorted as $tier) { $unlimited = $tier->leads_in_tier === null; $tierEnd = $unlimited ? PHP_INT_MAX : $cumulative + (int) $tier->leads_in_tier; $slotsInTier = max(0, $tierEnd - max($cumulative, $position)); if ($slotsInTier > 0) { $take = min($remaining, $slotsInTier); $kopecks = bcadd($kopecks, bcmul((string) (int) $tier->price_per_lead_kopecks, (string) $take, 0), 0); $remaining -= $take; $position += $take; } if ($remaining <= 0 || $unlimited) { break; } $cumulative = $tierEnd; } return bcdiv($kopecks, '100', 2); } /** * GET /api/billing/transactions?type=topup|lead_charge|migration&page=N * — пагинированная история balance_transactions тенанта (20/страница). * * Billing v2 Spec A: 'refund' убран из whitelist (возвраты не реализуются); * 'migration' добавлен (тип одноразовой конвертации balance_leads → balance_rub). * Поле display_amount_rub в каждой строке — UI-показ суммы; для исторических * prepaid lead_charge (amount_rub='0.00') возвращается '0.00' для маркера * «бесплатное списание». */ public function transactions(Request $request): JsonResponse { /** @var User $user */ $user = $request->user(); $tenantId = (int) $user->tenant_id; // Явный tenant_id фильтр — defense-in-depth поверх RLS (тесты идут // под superuser BYPASSRLS; паттерн TenantChargesController). $query = BalanceTransaction::query() ->where('tenant_id', $tenantId) ->orderBy('created_at', 'desc') ->orderBy('id', 'desc'); $type = $request->query('type'); if (is_string($type) && in_array($type, ['topup', 'lead_charge', 'migration'], true)) { $query->where('type', $type); } $page = $query->paginate(20); return response()->json([ 'data' => array_map(static function (BalanceTransaction $tx): array { // Historic prepaid rows: type=lead_charge AND amount_rub=='0.00' (deduction в leads). // display_amount_rub возвращает явное '0.00' для UI-маркера «бесплатное списание», // несмотря на то что значение совпадает с amount_rub. $displayAmountRub = (string) $tx->amount_rub; if ($tx->type === BalanceTransaction::TYPE_LEAD_CHARGE && bccomp((string) $tx->amount_rub, '0', 2) === 0) { $displayAmountRub = '0.00'; } return [ 'id' => $tx->id, 'code' => 'TX-'.$tx->id, 'type' => $tx->type, 'description' => $tx->description, 'amount_rub' => $tx->amount_rub, 'amount_leads' => $tx->amount_leads, 'balance_rub_after' => $tx->balance_rub_after, 'display_amount_rub' => $displayAmountRub, 'created_at' => $tx->created_at, ]; }, $page->items()), 'meta' => [ 'current_page' => $page->currentPage(), 'last_page' => $page->lastPage(), 'total' => $page->total(), 'per_page' => $page->perPage(), ], ]); } /** * GET /api/billing/invoices — счета тенанта (saas_invoices). * * Real-but-empty на MVP: saas_invoices.legal_entity_id NOT NULL требует * зарегистрированного юр-лица (блокируется Б-1). Read-only выборка через * DB::table — без Eloquent-модели (паттерн AdminBillingController). */ public function invoices(Request $request): JsonResponse { /** @var User $user */ $user = $request->user(); $tenantId = (int) $user->tenant_id; $rows = DB::table('saas_invoices') ->where('tenant_id', $tenantId) ->orderBy('issued_at', 'desc') ->get(['id', 'invoice_number', 'amount_total', 'status', 'issued_at', 'expires_at', 'pdf_path']); // Какие счета уже имеют закрывающий документ (акт) — для кнопки «Скачать акт». $actInvoiceIds = DB::table('saas_upd_documents') ->where('tenant_id', $tenantId) ->whereNotNull('invoice_id') ->pluck('invoice_id') ->flip(); return response()->json([ 'data' => $rows->map(static fn (\stdClass $r): array => [ 'id' => $r->id, 'invoice_number' => $r->invoice_number, 'amount_total' => $r->amount_total, 'status' => $r->status, 'issued_at' => $r->issued_at, 'expires_at' => $r->expires_at, 'has_pdf' => $r->pdf_path !== null, 'has_act' => isset($actInvoiceIds[$r->id]), 'pdf_url' => $r->pdf_path !== null ? "/api/billing/invoices/{$r->id}/pdf" : null, 'act_url' => isset($actInvoiceIds[$r->id]) ? "/api/billing/invoices/{$r->id}/act" : null, ])->all(), ]); } /** * Прогноз «на сколько дней хватит affordable_leads» — оценочный UX-показатель. * * 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 $affordableLeads): ?int { // F3 (17.06.2026): единый источник расчёта — RunwayCalculator (общий с дашбордом), // чтобы прогноз «хватит на дни» не расходился между биллингом и дашбордом. return app(RunwayCalculator::class)->daysLeft((int) $tenant->id, $affordableLeads); } }