174dbae808
Backend TenantChargesController: - GET /api/billing/charges — paginated list, filters period (current_month / last_month / 90d) + charge_source. - POST /api/billing/charges/export — StreamedResponse CSV (BOM + UTF-8) с chunkById(500). - auth:sanctum + tenant middleware — RLS изолирует tenant_id. - 6 Pest integration tests (RLS isolation + filters + pagination + CSV export). Frontend ChargesTab.vue: - v-data-table-server с paginated load + period/charge_source filters. - CSV-download через blob → createObjectURL. - Forest-palette + JetBrains Mono tnum. BillingView.vue — добавлен tab «Списания» с импортом ChargesTab. ChargesTab.story.vue + 4 Vitest tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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));
|
||
}
|
||
}
|
||
}
|