65c5178c29
Code-quality review fixups: runway_days клампится в 0 при отрицательном балансе (overdrawn-тенант не должен показывать «−N дней»); (int)-каст в wallet() для консистентности; усилены assertJsonPath на type-фильтре. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
187 lines
7.2 KiB
PHP
187 lines
7.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\BalanceTransaction;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Billing\BillingTopupService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Биллинг тенанта — кошелёк, транзакции, счета, пополнение (audit E1/E3).
|
|
*
|
|
* Все эндпоинты под middleware [auth:sanctum, tenant] (RLS-контекст).
|
|
* Отдельно от TenantChargesController (lead_charges ledger) и
|
|
* AdminBillingController (SaaS-уровневые агрегаты).
|
|
*
|
|
* E1: POST /api/billing/topup — MVP-stub пополнения (без платёжного шлюза).
|
|
* E3: GET wallet/transactions/invoices — данные для BillingView Overview.
|
|
*/
|
|
class BillingController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly BillingTopupService $topupService,
|
|
) {}
|
|
|
|
/**
|
|
* POST /api/billing/topup — пополнить рублёвый баланс.
|
|
*
|
|
* MVP-stub: кредитует баланс немедленно (без ЮKassa — реальная оплата
|
|
* post-Б-1). Записывает append-only строку balance_transactions(topup).
|
|
*/
|
|
public function topup(Request $request): JsonResponse
|
|
{
|
|
$validated = $request->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));
|
|
}
|
|
}
|