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

188 lines
8.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\BalanceTransaction;
use App\Models\Deal;
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()).
// LEFT JOIN balance_transactions для заполнения balance_rub_after.
// Условие type='lead_charge' исключает topup/refund которые тоже
// могут ссылаться на deal через related_id.
$query = DB::table('lead_charges as lc')
->select([
'lc.id',
'lc.charged_at',
'lc.deal_id',
'lc.tier_no',
'lc.charge_source',
'lc.price_per_lead_kopecks',
'bt.balance_rub_after',
])
->leftJoin('balance_transactions as bt', function ($j) use ($tenantId) {
$j->on('bt.related_id', '=', 'lc.deal_id')
->where('bt.related_type', '=', Deal::class)
->where('bt.type', '=', BalanceTransaction::TYPE_LEAD_CHARGE)
->where('bt.tenant_id', '=', $tenantId);
})
->where('lc.tenant_id', $tenantId)
->orderBy('lc.charged_at', 'desc')
->orderBy('lc.id', 'desc');
if (is_string($period) && $period !== '') {
$now = Carbon::now('Europe/Moscow');
if ($period === 'current_month') {
$query->where('lc.charged_at', '>=', $now->copy()->startOfMonth());
} elseif ($period === 'last_month') {
$query->whereBetween('lc.charged_at', [
$now->copy()->subMonth()->startOfMonth(),
$now->copy()->subMonth()->endOfMonth(),
]);
} elseif ($period === '90d') {
$query->where('lc.charged_at', '>=', $now->copy()->subDays(90));
}
}
if ($source !== null && $source !== '') {
$query->where('lc.charge_source', $source);
}
// chunk() вместо chunkById() — chunkById несовместим с JOIN-запросами
// (ломает пагинацию при неуникальном id в select).
$query->chunk(500, function ($rows) use ($out) {
foreach ($rows as $r) {
fputcsv($out, [
Carbon::parse($r->charged_at)->toIso8601String(),
(string) $r->deal_id,
(string) $r->tier_no,
(string) $r->charge_source,
number_format($r->price_per_lead_kopecks / 100, 2, '.', ''),
$r->balance_rub_after ?? '',
]);
}
});
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));
}
}
}