155 lines
6.2 KiB
PHP
155 lines
6.2 KiB
PHP
|
|
<?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));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|