239 lines
10 KiB
PHP
239 lines
10 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\Repositories\PricingTierRepository;
|
|
use App\Services\Billing\BalanceToLeadsConverter;
|
|
use App\Services\Billing\BillingTopupService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Carbon;
|
|
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 — единый ₽-баланс + рассчитанные «≈ 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/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', '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(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Прогноз «на сколько дней хватит 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
|
|
{
|
|
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));
|
|
}
|
|
}
|