Files
portal/app/app/Http/Controllers/Api/TenantChargesController.php
T

155 lines
6.2 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\LeadCharge;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Tenant-scoped доступ к lead_charges (read-only ledger) — Plan 4 Task 11.
*
* RLS защищает изоляцию через SetTenantContext middleware
* (auth:sanctum + tenant). См. spec §6.3:
* docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md.
*
* Эндпоинты:
* GET /api/billing/charges?page=N&period=current_month|last_month|90d&charge_source=prepaid|rub
* POST /api/billing/charges/export — CSV через StreamedResponse + chunkById(500).
*
* RLS-контекст устанавливается на уровне middleware `tenant` (SetTenantContext),
* который оборачивает HTTP-запрос в транзакцию с `SET LOCAL app.current_tenant_id`.
* Для StreamedResponse callback'а транзакция middleware'а уже закрыта на момент
* вызова — поэтому export открывает свою транзакцию внутри callback'а с явным
* SET LOCAL (паттерн из DealExportController).
*/
class TenantChargesController extends Controller
{
public function index(Request $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
// Explicit tenant_id фильтр — defense-in-depth поверх RLS. В тестах PG
// superuser BYPASSRLS, и без явного where() запрос вернул бы строки
// других тенантов (см. InAppNotificationController паттерн).
$query = LeadCharge::query()
->where('tenant_id', $tenantId)
->orderBy('charged_at', 'desc');
$this->applyFilters($query, $request);
$page = $query->paginate(20);
return response()->json([
'data' => $page->items(),
'meta' => [
'current_page' => $page->currentPage(),
'last_page' => $page->lastPage(),
'total' => $page->total(),
'per_page' => $page->perPage(),
],
]);
}
public function export(Request $request): StreamedResponse
{
$tenantId = (int) $request->user()->tenant_id;
$period = $request->input('period');
$source = $request->input('charge_source');
$filename = 'charges_'.now()->format('Y-m-d_His').'.csv';
return new StreamedResponse(function () use ($tenantId, $period, $source) {
// RLS-контекст должен быть установлен внутри транзакции на момент
// фактического SELECT. StreamedResponse callback вызывается уже после
// закрытия middleware-транзакции, поэтому открываем свою явную.
// Паттерн из DealExportController.
DB::transaction(function () use ($tenantId, $period, $source) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$out = fopen('php://output', 'w');
if ($out === false) {
return;
}
// BOM для Excel (CSV в UTF-8).
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['charged_at', 'deal_id', 'tier_no', 'charge_source', 'price_rub', 'balance_rub_after']);
// Explicit tenant_id фильтр — defense-in-depth поверх RLS
// (см. комментарий в index()).
$query = LeadCharge::query()
->where('tenant_id', $tenantId)
->orderBy('charged_at', 'desc');
$this->applyPeriodFilter($query, $period);
if ($source !== null && $source !== '') {
$query->where('charge_source', $source);
}
$query->chunkById(500, function ($charges) use ($out) {
foreach ($charges as $c) {
/** @var LeadCharge $c */
fputcsv($out, [
$c->charged_at->toIso8601String(),
(string) $c->deal_id,
(string) $c->tier_no,
(string) $c->getAttribute('charge_source'),
number_format($c->price_per_lead_kopecks / 100, 2, '.', ''),
// balance_rub_after — нет в lead_charges (доступно через
// balance_transactions). MVP оставляем пустым.
'',
]);
}
});
fclose($out);
});
}, 200, [
'Content-Type' => 'text/csv; charset=UTF-8',
'Content-Disposition' => "attachment; filename=\"{$filename}\"",
]);
}
/**
* @param Builder<LeadCharge> $query
*/
private function applyFilters($query, Request $request): void
{
$this->applyPeriodFilter($query, $request->query('period'));
$source = $request->query('charge_source');
if (is_string($source) && $source !== '') {
$query->where('charge_source', $source);
}
}
/**
* @param Builder<LeadCharge> $query
*/
private function applyPeriodFilter($query, mixed $period): void
{
if (! is_string($period) || $period === '') {
return;
}
$now = Carbon::now('Europe/Moscow');
if ($period === 'current_month') {
$query->where('charged_at', '>=', $now->copy()->startOfMonth());
} elseif ($period === 'last_month') {
$query->whereBetween('charged_at', [
$now->copy()->subMonth()->startOfMonth(),
$now->copy()->subMonth()->endOfMonth(),
]);
} elseif ($period === '90d') {
$query->where('charged_at', '>=', $now->copy()->subDays(90));
}
}
}