Files
portal/app/app/Http/Controllers/Api/TenantChargesController.php
T
Дмитрий 174dbae808 feat(billing): Plan 4 Task 11 — TenantChargesController + ChargesTab + CSV export
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>
2026-05-11 11:51:13 +03:00

155 lines
6.2 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\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));
}
}
}