validate([ 'amount_rub' => ['required', 'numeric', 'min:100', 'max:1000000', 'decimal:0,2'], ]); /** @var User $user */ $user = $request->user(); // Нормализуем в DECIMAL-строку scale 2 для bcmath (НЕ float). $amountRub = bcadd((string) $validated['amount_rub'], '0', 2); $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 — балансы тенанта + текущий тариф + runway. */ 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); return response()->json([ 'balance_rub' => $tenant->balance_rub, 'balance_leads' => $tenant->balance_leads, 'runway_days' => $this->runwayDays($tenant), 'tariff' => $tenant->tariff === null ? null : [ 'code' => $tenant->tariff->code, 'name' => $tenant->tariff->name, 'price_monthly' => $tenant->tariff->price_monthly, 'billing_model' => $tenant->tariff->billing_model, 'features' => $tenant->tariff->features ?? [], ], ]); } /** * GET /api/billing/transactions?type=topup|lead_charge|refund&page=N * — пагинированная история balance_transactions тенанта (20/страница). */ 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', 'refund'], true)) { $query->where('type', $type); } $page = $query->paginate(20); return response()->json([ 'data' => array_map(static fn (BalanceTransaction $tx): array => [ '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, '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', 'pdf_path']); 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, 'has_pdf' => $r->pdf_path !== null, ])->all(), ]); } /** * Прогноз «на сколько дней хватит баланса» — оценочный UX-показатель. * * = balance_rub / (рублёвые списания за 30 дней / 30). NULL, если списаний * не было. Float здесь допустим: грубая оценка для шапки, НЕ мутация * баланса (мутации баланса — строго bcmath, см. BillingTopupService). * Отрицательный баланс → 0 (тенант уже в минусе, runway не может быть < 0). */ private function runwayDays(Tenant $tenant): ?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 ($spent <= 0.0) { return null; } $perDay = $spent / 30.0; return max(0, (int) floor((float) $tenant->balance_rub / $perDay)); } }