188 lines
8.0 KiB
PHP
188 lines
8.0 KiB
PHP
<?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));
|
||
}
|
||
}
|
||
}
|