'periodSelectedDispatched', 'periodCleared' => 'periodClearedDispatched', 'export-pdf-with-charts' => 'handleExportPdfWithCharts', ]; public function mount($reportData = null) { $profile = getActiveProfile(); if (!$profile) { abort(403, 'No active profile'); } // CRITICAL SECURITY: Validate user has ownership/access to this profile // This prevents unauthorized access to financial report data via session manipulation \App\Helpers\ProfileAuthorizationHelper::authorize($profile); $this->reportData = $reportData; $this->isOrganization = $profile instanceof \App\Models\Organization; // Default to previous year on load (matches SelectPeriod default) $this->fromDate = Carbon::now()->subYear()->startOfYear()->format('Y-m-d'); $this->toDate = Carbon::now()->subYear()->endOfYear()->format('Y-m-d'); } public function updatedDecimalFormat() { // Trigger a re-render with updated format — no additional action needed, // render() picks up $decimalFormat automatically. } public function formatAmount($minutes) { if ($this->decimalFormat) { $isNegative = $minutes < 0; $decimal = number_format(abs($minutes) / 60, 2, ',', '.'); return ($isNegative ? '-' : '') . $decimal . ' ' . __('h.'); } return tbFormat($minutes); } public function fromAccountId($selectedAccount) { $this->fromAccountId = $selectedAccount['id']; } public function periodSelectedDispatched($periodData) { $fromDate = $periodData['fromDate']; $toDate = $periodData['toDate']; // Validate date range - ensure fromDate is not after toDate if ($fromDate && $toDate && Carbon::parse($fromDate)->gt(Carbon::parse($toDate))) { // Invalid date range - swap the dates or use a default range Log::warning('Invalid date range detected', [ 'fromDate' => $fromDate, 'toDate' => $toDate, 'action' => 'swapping_dates' ]); // Swap the dates to fix the invalid range $this->fromDate = $toDate; $this->toDate = $fromDate; // Dispatch a warning to the frontend $this->dispatch('show-notification', [ 'type' => 'warning', 'message' => __('Date range was invalid (start date after end date). Dates have been corrected.') ]); } else { $this->fromDate = $fromDate; $this->toDate = $toDate; } // Generate and dispatch the title $this->generateAndDispatchTitle($periodData['period'] ?? 'custom'); } /** * Handle when period is cleared/deselected */ public function periodClearedDispatched() { $this->fromDate = null; $this->toDate = null; // Clear the title when period is cleared $this->dispatch('tableTitle', ['header' => '', 'sub' => '']); } /** * Generate period-aware title and dispatch to table-title component */ private function generateAndDispatchTitle($periodType) { if (!$this->fromDate || !$this->toDate) { // Clear title when no period is selected $this->dispatch('tableTitle', ['header' => '', 'sub' => '']); return; } // Check if there's actual data to show $accountsData = $this->getAccountsWithPeriodBalances(); if (!$accountsData || $accountsData->count() === 0) { // Clear title when no data is available $this->dispatch('tableTitle', ['header' => '', 'sub' => '']); return; } $fromDate = Carbon::parse($this->fromDate); $toDate = Carbon::parse($this->toDate); // Generate main title based on period type $mainTitle = $this->generatePeriodTitle($periodType, $fromDate, $toDate); // Generate date range subtitle $dateRange = $fromDate->format('d-m-Y') . ' - ' . $toDate->format('d-m-Y'); $title = [ 'header' => $mainTitle, 'sub' => $dateRange ]; $this->dispatch('tableTitle', $title); } /** * Generate appropriate title based on period type and dates */ private function generatePeriodTitle($periodType, $fromDate, $toDate) { switch ($periodType) { // A rolling twelve months spans two calendar years, so naming one // of them would be wrong; the date-range subtitle carries the // detail instead. case 'since_one_year_ago': return __('Financial Overview') . ' - ' . __('Since one year ago'); case 'previous_year': return __('Financial Overview') . ' ' . $fromDate->year; case 'previous_quarter': $quarter = $this->getQuarterName($fromDate); return __('Financial Overview') . ' ' . $quarter . ' ' . $fromDate->year; case 'previous_month': return __('Financial Overview') . ' ' . $fromDate->format('F Y'); case 'custom': default: // For custom periods, just show "Financial Overview" without date details return __('Financial Overview'); } } /** * Get quarter name from date */ private function getQuarterName($date) { $quarter = $date->quarter; switch ($quarter) { case 1: return __('1st Quarter'); case 2: return __('2nd Quarter'); case 3: return __('3rd Quarter'); case 4: return __('4th Quarter'); default: return __('Quarter') . ' ' . $quarter; } } /** * Calculate balance for an account BEFORE any transactions on the start date * This gives the balance at the end of the previous day (start of the period) */ private function calculateStartBalance($accountId, $date) { if (!$date) { return 0; } $query = Transaction::where(function($q) use ($accountId) { $q->where('from_account_id', $accountId) ->orWhere('to_account_id', $accountId); }) ->whereDate('created_at', '<', $date); $balance = $query->selectRaw('SUM(CASE WHEN to_account_id = ? THEN amount ELSE -amount END) as balance', [$accountId]) ->value('balance'); return $balance ?? 0; } /** * Calculate balance for an account AFTER all transactions on the end date * This gives the balance at the end of the target day */ private function calculateEndBalance($accountId, $date) { if (!$date) { return 0; } $query = Transaction::where(function($q) use ($accountId) { $q->where('from_account_id', $accountId) ->orWhere('to_account_id', $accountId); }) ->whereDate('created_at', '<=', $date); $balance = $query->selectRaw('SUM(CASE WHEN to_account_id = ? THEN amount ELSE -amount END) as balance', [$accountId]) ->value('balance'); return $balance ?? 0; } /** * Get all accounts for active profile with period balances */ public function getAccountsWithPeriodBalances() { $activeProfile = getActiveProfile(); if (!$activeProfile || !method_exists($activeProfile, 'accounts')) { return collect(); } // Validate date range before processing if ($this->fromDate && $this->toDate && Carbon::parse($this->fromDate)->gt(Carbon::parse($this->toDate))) { Log::warning('Invalid date range in balance calculation', [ 'fromDate' => $this->fromDate, 'toDate' => $this->toDate ]); return collect(); } $accounts = $activeProfile->accounts()->notRemoved()->get(); return $accounts->map(function ($account) { $startBalance = $this->fromDate ? $this->calculateStartBalance($account->id, $this->fromDate) : 0; $endBalance = $this->toDate ? $this->calculateEndBalance($account->id, $this->toDate) : $this->getBalance($account->id); $difference = $endBalance - $startBalance; return [ 'id' => $account->id, 'name' => __('messages.' . $account->name . '_account'), 'start_balance' => $startBalance, 'end_balance' => $endBalance, 'difference' => $difference, 'limit_max' => $account->limit_max, 'start_balance_formatted' => $this->formatAmount($startBalance), 'end_balance_formatted' => $this->formatAmount($endBalance), 'difference_formatted' => $this->formatAmount($difference), ]; }); } /** * Calculate transaction type totals for the period */ public function calculateTransactionTypeTotals() { if (!$this->fromDate || !$this->toDate) { return collect(); } // Validate date range before processing if (Carbon::parse($this->fromDate)->gt(Carbon::parse($this->toDate))) { Log::warning('Invalid date range in transaction types calculation', [ 'fromDate' => $this->fromDate, 'toDate' => $this->toDate ]); return collect(); } $activeProfile = getActiveProfile(); if (!$activeProfile || !method_exists($activeProfile, 'accounts')) { return collect(); } $accountIds = $activeProfile->accounts()->notRemoved()->pluck('id'); $transactionsQuery = Transaction::with('transactionType') ->where(function ($query) use ($accountIds) { $query->whereIn('from_account_id', $accountIds) ->orWhereIn('to_account_id', $accountIds); }) ->whereBetween('created_at', [$this->fromDate, $this->toDate]); $transactions = $transactionsQuery->get(); // Group transactions by type, separating known and unknown types $knownTypes = collect(); $unknownTransactions = collect(); $groupedTransactions = $transactions->groupBy('transaction_type_id'); foreach ($groupedTransactions as $typeId => $typeTransactions) { $typeName = $typeTransactions->first()->transactionType->name ?? null; if ($typeName && $typeName !== 'Unknown') { // Known transaction type $incoming = $typeTransactions->whereIn('to_account_id', $accountIds)->sum('amount'); $outgoing = $typeTransactions->whereIn('from_account_id', $accountIds)->sum('amount'); $net = $incoming - $outgoing; $knownTypes->put($typeId, [ 'type_name' => __(ucfirst(strtolower($typeName))), 'incoming' => $incoming, 'outgoing' => $outgoing, 'net' => $net, 'incoming_formatted' => $this->formatAmount($incoming), 'outgoing_formatted' => $this->formatAmount($outgoing), 'net_formatted' => $this->formatAmount($net), ]); } else { // Unknown transaction type - add to collection for grouping $unknownTransactions = $unknownTransactions->concat($typeTransactions); } } // If we have unknown transactions, group them into a single "Unknown" entry if ($unknownTransactions->count() > 0) { $incoming = $unknownTransactions->whereIn('to_account_id', $accountIds)->sum('amount'); $outgoing = $unknownTransactions->whereIn('from_account_id', $accountIds)->sum('amount'); $net = $incoming - $outgoing; $knownTypes->put('unknown', [ 'type_name' => __('Unknown'), 'incoming' => $incoming, 'outgoing' => $outgoing, 'net' => $net, 'incoming_formatted' => $this->formatAmount($incoming), 'outgoing_formatted' => $this->formatAmount($outgoing), 'net_formatted' => $this->formatAmount($net), ]); } // Only hide the breakdown if there are only unknown types (not if there are no transactions) if ($knownTypes->count() === 1 && $knownTypes->has('unknown')) { return collect(); } return $knownTypes; } /** * Calculate return ratio data for chart visualization over time periods */ public function calculateReturnRatioTimeline() { if (!$this->fromDate || !$this->toDate) { return []; } $activeProfile = getActiveProfile(); if (!$activeProfile || !method_exists($activeProfile, 'accounts')) { return []; } $accountIds = $activeProfile->accounts()->notRemoved()->pluck('id'); $fromDate = Carbon::parse($this->fromDate); $toDate = Carbon::parse($this->toDate); // Always use monthly intervals for consistent chart display $timelineData = []; $currentDate = $fromDate->copy()->startOfMonth(); while ($currentDate->lte($toDate)) { $periodStart = $currentDate->copy(); $periodEnd = $currentDate->copy()->endOfMonth(); // Don't go beyond the selected end date if ($periodEnd->gt($toDate)) { $periodEnd = $toDate->copy()->endOfDay(); } // Don't start before the selected start date if ($periodStart->lt($fromDate)) { $periodStart = $fromDate->copy(); } // Calculate return ratio for this period $periodRatio = $this->calculateReturnRatioForPeriod($accountIds, $periodStart, $periodEnd); // Also calculate account balances for this period $accounts = $activeProfile->accounts()->notRemoved()->get(); $accountBalances = []; foreach ($accounts as $account) { $balance = $this->calculateEndBalance($account->id, $periodEnd->format('Y-m-d')); $accountBalances[] = [ 'account_id' => $account->id, 'account_name' => __('messages.' . $account->name . '_account'), 'balance' => $balance, // Keep raw value for chart calculations 'balance_formatted' => tbFormat($balance) // Add formatted version ]; } $timelineData[] = [ 'label' => $periodStart->format('M Y'), 'date' => $periodStart->format('Y-m-d'), 'return_ratio' => $periodRatio, 'accounts' => $accountBalances, // Add account balances data 'translations' => [ 'period' => __('Period'), 'return_ratio' => __('Reciprocity Rate') ] ]; // Move to next month $currentDate->addMonth(); } // Calculate trend line using linear regression $trendData = $this->calculateTrendLine($timelineData); return [ 'timeline' => $timelineData, 'trend' => $trendData ]; } /** * Calculate trend line using 4 key data points for better trend analysis */ private function calculateTrendLine($timelineData) { if (count($timelineData) < 4) { return []; } $n = count($timelineData); // Use 4 key points distributed across the timeline for better trend analysis $keyPoints = [ ['index' => 0, 'value' => $timelineData[0]['return_ratio']], ['index' => floor($n / 3), 'value' => $timelineData[floor($n / 3)]['return_ratio']], ['index' => floor(2 * $n / 3), 'value' => $timelineData[floor(2 * $n / 3)]['return_ratio']], ['index' => $n - 1, 'value' => $timelineData[$n - 1]['return_ratio']] ]; // Calculate linear regression using these 4 points $sumX = 0; $sumY = 0; $sumXY = 0; $sumXX = 0; $pointCount = 4; foreach ($keyPoints as $point) { $x = $point['index']; $y = $point['value']; $sumX += $x; $sumY += $y; $sumXY += $x * $y; $sumXX += $x * $x; } // Calculate slope (m) and y-intercept (b) for y = mx + b $denominator = ($pointCount * $sumXX) - ($sumX * $sumX); if ($denominator == 0) { // Avoid division by zero - return flat line at average $average = $sumY / $pointCount; $trendData = []; foreach ($timelineData as $point) { $trendData[] = [ 'label' => $point['label'], 'date' => $point['date'], 'trend_value' => $average ]; } return $trendData; } $slope = (($pointCount * $sumXY) - ($sumX * $sumY)) / $denominator; $intercept = ($sumY - ($slope * $sumX)) / $pointCount; // Generate trend line points for all data points $trendData = []; foreach ($timelineData as $index => $point) { $trendValue = ($slope * $index) + $intercept; $trendData[] = [ 'label' => $point['label'], 'date' => $point['date'], 'trend_value' => $trendValue ]; } return $trendData; } /** * Calculate account balances timeline for chart visualization */ public function calculateAccountBalancesTimeline() { if (!$this->fromDate || !$this->toDate) { return []; } $activeProfile = getActiveProfile(); if (!$activeProfile || !method_exists($activeProfile, 'accounts')) { return []; } $accounts = $activeProfile->accounts()->notRemoved()->get(); $fromDate = Carbon::parse($this->fromDate); $toDate = Carbon::parse($this->toDate); // Always use monthly intervals for consistent chart display $timelineData = []; $currentDate = $fromDate->copy()->startOfMonth(); while ($currentDate->lte($toDate)) { $periodStart = $currentDate->copy(); $periodEnd = $currentDate->copy()->endOfMonth(); // Don't go beyond the selected end date if ($periodEnd->gt($toDate)) { $periodEnd = $toDate->copy()->endOfDay(); } // Don't start before the selected start date if ($periodStart->lt($fromDate)) { $periodStart = $fromDate->copy(); } $monthData = [ 'label' => $periodStart->format('M Y'), 'date' => $periodStart->format('Y-m-d'), 'accounts' => [], 'translations' => [ 'period' => __('Period'), 'balance' => __('Balance') ] ]; // Calculate balance for each account at the end of this period foreach ($accounts as $account) { $balance = $this->calculateEndBalance($account->id, $periodEnd->format('Y-m-d')); $monthData['accounts'][] = [ 'account_id' => $account->id, 'account_name' => __('messages.' . $account->name . '_account'), 'balance' => $balance ]; } $timelineData[] = $monthData; // Move to next month $currentDate->addMonth(); } return $timelineData; } /** * Calculate return ratio for a specific period */ private function calculateReturnRatioForPeriod($accountIds, $startDate, $endDate) { $activeProfile = getActiveProfile(); $activeProfileKey = get_class($activeProfile) . ':' . $activeProfile->id; $accountIdsArray = $accountIds->toArray(); $transactions = Transaction::with(['accountFrom.accountable', 'accountTo.accountable']) ->where(function ($query) use ($accountIds) { $query->whereIn('from_account_id', $accountIds) ->orWhereIn('to_account_id', $accountIds); }) ->whereBetween('created_at', [$startDate, $endDate]) ->get(); $profileRatios = []; foreach ($transactions as $transaction) { $fromAccountId = $transaction->from_account_id; $toAccountId = $transaction->to_account_id; $amount = $transaction->amount; $fromProfileKey = null; $toProfileKey = null; if ($transaction->accountFrom && $transaction->accountFrom->accountable) { $fromProfileKey = $transaction->accountFrom->accountable_type . ':' . $transaction->accountFrom->accountable_id; } if ($transaction->accountTo && $transaction->accountTo->accountable) { $toProfileKey = $transaction->accountTo->accountable_type . ':' . $transaction->accountTo->accountable_id; } if (!$fromProfileKey || !$toProfileKey) { continue; } // Bank flow is administrative, not reciprocal — see // isReciprocityPartner(). if (!$this->isReciprocityPartner($fromProfileKey) || !$this->isReciprocityPartner($toProfileKey)) { continue; } // When the active profile sends money to someone else (helping someone) if (in_array($fromAccountId, $accountIdsArray) && $toProfileKey !== $activeProfileKey) { if (!isset($profileRatios[$toProfileKey])) { $profileRatios[$toProfileKey] = ['given' => 0, 'received' => 0]; } $profileRatios[$toProfileKey]['given'] += $amount; } // When the active profile receives money from someone else (being helped back) elseif (in_array($toAccountId, $accountIdsArray) && $fromProfileKey !== $activeProfileKey) { if (!isset($profileRatios[$fromProfileKey])) { $profileRatios[$fromProfileKey] = ['given' => 0, 'received' => 0]; } $profileRatios[$fromProfileKey]['received'] += $amount; } } return $this->summariseReturnRatios($profileRatios)['weighted']; } /** * Is this profile a reciprocity partner, or administrative flow? * * Banks are excluded: reciprocity asks "when I help people, do they help me * back", and a bank issuing currency, reversing a payment or migrating a * legacy balance is not reciprocating. Counting one made an organization * with a single real partner at 43,5% report 104,2%. */ private function isReciprocityPartner(?string $profileKey): bool { return $profileKey !== null && !str_starts_with($profileKey, \App\Models\Bank::class . ':'); } /** * Reduce per-partner given/received totals to a weighted rate and a median. * * Weighted, not a mean of per-partner percentages: a partner given 6 minutes * who returned 1.941 scores 32.350%, and an unweighted mean lets that one * row outvote partners representing hundreds of hours. That is how this * report came to show 414,5% for an organization whose real return was 93%. * * The weighted figure answers the question the chart is asking — of all the * time given, how much came back — and the median says whether a typical * partnership looks like that or whether the total is carried by a few. * * Partners who were never given anything are excluded: with nothing given, * there is no return to measure, and they would divide by zero. * * @param array $profileRatios * @return array{weighted: float, median: float|null, partner_count: int} */ private function summariseReturnRatios(array $profileRatios): array { $withGiven = array_filter($profileRatios, fn($a) => ($a['given'] ?? 0) > 0); if (empty($withGiven)) { return ['weighted' => 0.0, 'median' => null, 'partner_count' => 0]; } $totalGiven = array_sum(array_column($withGiven, 'given')); $totalReceived = array_sum(array_column($withGiven, 'received')); $percentages = array_map( fn($a) => ($a['received'] / $a['given']) * 100, array_values($withGiven) ); sort($percentages); $count = count($percentages); $middle = intdiv($count, 2); $median = $count % 2 ? $percentages[$middle] : ($percentages[$middle - 1] + $percentages[$middle]) / 2; return [ 'weighted' => $totalGiven > 0 ? ($totalReceived / $totalGiven) * 100 : 0.0, 'median' => $count >= self::MIN_PARTNERS_FOR_BREAKDOWN ? $median : null, 'partner_count' => $count, ]; } /** * Calculate period statistics */ public function calculatePeriodStatistics() { if (!$this->fromDate || !$this->toDate) { return [ 'transaction_count' => 0, 'unique_profiles' => 0, 'profiles_by_type' => collect(), 'average_return_ratio' => 0, 'median_return_ratio' => null, ]; } // Validate date range before processing if (Carbon::parse($this->fromDate)->gt(Carbon::parse($this->toDate))) { Log::warning('Invalid date range in period statistics calculation', [ 'fromDate' => $this->fromDate, 'toDate' => $this->toDate ]); return [ 'transaction_count' => 0, 'unique_profiles' => 0, 'profiles_by_type' => collect(), 'average_return_ratio' => 0, 'median_return_ratio' => null, ]; } $activeProfile = getActiveProfile(); if (!$activeProfile || !method_exists($activeProfile, 'accounts')) { return [ 'transaction_count' => 0, 'unique_profiles' => 0, 'profiles_by_type' => collect(), 'average_return_ratio' => 0, 'median_return_ratio' => null, ]; } $accountIds = $activeProfile->accounts()->notRemoved()->pluck('id'); $transactionsQuery = Transaction::where(function ($query) use ($accountIds) { $query->whereIn('from_account_id', $accountIds) ->orWhereIn('to_account_id', $accountIds); }) ->whereBetween('created_at', [$this->fromDate, $this->toDate]); $transactionCount = $transactionsQuery->count(); // Get unique profiles involved in transactions (excluding the active profile) $transactions = $transactionsQuery->with(['accountFrom.accountable', 'accountTo.accountable'])->get(); $activeProfileKey = get_class($activeProfile) . ':' . $activeProfile->id; $uniqueProfiles = collect(); foreach ($transactions as $transaction) { if ($transaction->accountFrom && $transaction->accountFrom->accountable) { $profileKey = $transaction->accountFrom->accountable_type . ':' . $transaction->accountFrom->accountable_id; if ($profileKey !== $activeProfileKey) { $uniqueProfiles->push([ 'key' => $profileKey, 'type' => $transaction->accountFrom->accountable_type ]); } } if ($transaction->accountTo && $transaction->accountTo->accountable) { $profileKey = $transaction->accountTo->accountable_type . ':' . $transaction->accountTo->accountable_id; if ($profileKey !== $activeProfileKey) { $uniqueProfiles->push([ 'key' => $profileKey, 'type' => $transaction->accountTo->accountable_type ]); } } } // Get unique profiles and group by type $uniqueProfilesData = $uniqueProfiles->unique('key'); $profilesByType = $uniqueProfilesData->groupBy('type')->map(function ($profiles) { return $profiles->count(); }); // Calculate return ratio: what percentage of timebank hours you get back from people you've helped // Example: You give 10 hours to someone, they give you 4 hours back = 40% return ratio // This is averaged across all people you've transacted with during the selected period $profileRatios = []; $accountIdsArray = $accountIds->toArray(); // Process each transaction to track giving/receiving amounts per profile foreach ($transactions as $transaction) { $fromAccountId = $transaction->from_account_id; $toAccountId = $transaction->to_account_id; $amount = $transaction->amount; // Get unique profile identifiers for both sender and receiver $fromProfileKey = null; $toProfileKey = null; if ($transaction->accountFrom && $transaction->accountFrom->accountable) { $fromProfileKey = $transaction->accountFrom->accountable_type . ':' . $transaction->accountFrom->accountable_id; } if ($transaction->accountTo && $transaction->accountTo->accountable) { $toProfileKey = $transaction->accountTo->accountable_type . ':' . $transaction->accountTo->accountable_id; } // Skip transactions where we can't identify both profiles if (!$fromProfileKey || !$toProfileKey) { continue; } if (!$this->isReciprocityPartner($fromProfileKey) || !$this->isReciprocityPartner($toProfileKey)) { continue; } // Track transactions between active profile and other profiles if (in_array($fromAccountId, $accountIdsArray) && $toProfileKey !== $activeProfileKey) { // Active profile gave hours to another profile - track as "given" if (!isset($profileRatios[$toProfileKey])) { $profileRatios[$toProfileKey] = ['given' => 0, 'received' => 0]; } $profileRatios[$toProfileKey]['given'] += $amount; } elseif (in_array($toAccountId, $accountIdsArray) && $fromProfileKey !== $activeProfileKey) { // Active profile received hours from another profile - track as "received" if (!isset($profileRatios[$fromProfileKey])) { $profileRatios[$fromProfileKey] = ['given' => 0, 'received' => 0]; } $profileRatios[$fromProfileKey]['received'] += $amount; } } // Weighted by volume — see summariseReturnRatios() for why a mean of // per-partner percentages is not usable here. $summary = $this->summariseReturnRatios($profileRatios); return [ 'transaction_count' => $transactionCount, 'unique_profiles' => $uniqueProfilesData->count(), 'profiles_by_type' => $profilesByType, 'average_return_ratio' => $summary['weighted'], 'median_return_ratio' => $summary['median'], ]; } /** * Export report as PDF */ public function exportPdf() { $profile = getActiveProfile(); if (!$profile) { abort(403, 'No active profile'); } // CRITICAL SECURITY: Validate authorization before export \App\Helpers\ProfileAuthorizationHelper::authorize($profile); // Build URL with current report parameters $params = [ 'fromDate' => $this->fromDate, 'toDate' => $this->toDate, 'decimal' => $this->decimalFormat ? 1 : 0, ]; $url = route('reports.pdf', $params); // Use JavaScript to open PDF in new window/tab $this->dispatch('openPdf', $url); } /** * Handle export PDF with charts request * This method is triggered by the export button and dispatches a browser event * to trigger the JavaScript chart export function */ public function handleExportPdfWithCharts() { $profile = getActiveProfile(); if (!$profile) { abort(403, 'No active profile'); } // CRITICAL SECURITY: Validate authorization before export \App\Helpers\ProfileAuthorizationHelper::authorize($profile); // Check if any charts will be rendered (need more than 4 data points) $returnRatioData = $this->calculateReturnRatioTimeline(); $returnRatioTimelineData = $returnRatioData['timeline'] ?? []; $hasReturnRatioChart = $this->isOrganization && count($returnRatioTimelineData) > 4; $hasBalancesChart = count($returnRatioTimelineData) > 4; // account balances chart uses same timeline data Log::info('handleExportPdfWithCharts', [ 'isOrganization' => $this->isOrganization, 'timelineDataCount' => count($returnRatioTimelineData), 'hasReturnRatioChart' => $hasReturnRatioChart, 'hasBalancesChart' => $hasBalancesChart, 'fromDate' => $this->fromDate, 'toDate' => $this->toDate, ]); if ($hasReturnRatioChart || $hasBalancesChart) { // Dispatch browser event to trigger JavaScript chart export Log::info('Dispatching trigger-chart-export browser event'); $this->dispatch('trigger-chart-export'); } else { // No charts available, export regular PDF directly Log::info('No charts available, calling exportPdf directly'); $this->exportPdf(); } } /** * Export report as PDF with chart image */ public function exportPdfWithChart($chartImage, $fromDate = null, $toDate = null) { $profile = getActiveProfile(); if (!$profile) { abort(403, 'No active profile'); } // CRITICAL SECURITY: Validate authorization before export \App\Helpers\ProfileAuthorizationHelper::authorize($profile); // Use passed dates if provided, otherwise use component dates $actualFromDate = $fromDate ?? $this->fromDate; $actualToDate = $toDate ?? $this->toDate; // Generate unique filename for temporary chart image $chartImageId = uniqid('chart_', true); $tempImagePath = storage_path('app/temp/' . $chartImageId . '.png'); // Ensure temp directory exists if (!file_exists(dirname($tempImagePath))) { mkdir(dirname($tempImagePath), 0755, true); } // Extract base64 data and save to temporary file $base64Data = explode(',', $chartImage)[1] ?? $chartImage; $imageData = base64_decode($base64Data); file_put_contents($tempImagePath, $imageData); // Store only the chart image ID in session (much smaller) session(['chart_image_id' => $chartImageId]); // Build URL with current report parameters $params = [ 'fromDate' => $actualFromDate, 'toDate' => $actualToDate, 'with_chart' => 1, 'decimal' => $this->decimalFormat ? 1 : 0, ]; $url = route('reports.pdf', $params); // Use JavaScript to open PDF in new window/tab $this->dispatch('openPdf', $url); } /** * Clean UTF-8 encoding for strings */ private function cleanUtf8String($string) { if (!is_string($string)) { return $string; } // Remove or replace problematic characters $string = mb_convert_encoding($string, 'UTF-8', 'UTF-8'); $string = preg_replace('/[\x00-\x1F\x7F-\x9F]/u', '', $string); return $string; } /** * Clean UTF-8 encoding for arrays/collections */ private function cleanUtf8Data($data) { if (is_string($data)) { return $this->cleanUtf8String($data); } if (is_array($data) || $data instanceof \Illuminate\Support\Collection) { $cleaned = []; foreach ($data as $key => $value) { $cleanedKey = $this->cleanUtf8String($key); $cleanedValue = $this->cleanUtf8Data($value); $cleaned[$cleanedKey] = $cleanedValue; } return $data instanceof \Illuminate\Support\Collection ? collect($cleaned) : $cleaned; } return $data; } public function render() { $profile = getActiveProfile(); if (!$profile) { abort(403, 'No active profile'); } // CRITICAL SECURITY: Re-validate authorization on every render // This prevents data exposure after session manipulation between interactions \App\Helpers\ProfileAuthorizationHelper::authorize($profile); // Update title based on current data state $this->generateAndDispatchTitle('custom'); $returnRatioData = $this->calculateReturnRatioTimeline(); $returnRatioTimelineData = $returnRatioData['timeline'] ?? []; $returnRatioTrendData = $returnRatioData['trend'] ?? []; $accountBalancesTimelineData = $returnRatioTimelineData; // Use same data as return ratio chart return view('livewire.single-report', [ 'accountsData' => $this->getAccountsWithPeriodBalances(), 'transactionTypesData' => $this->calculateTransactionTypeTotals(), 'statisticsData' => $this->calculatePeriodStatistics(), 'returnRatioTimelineData' => $returnRatioTimelineData, 'returnRatioTrendData' => $returnRatioTrendData, 'accountBalancesTimelineData' => $accountBalancesTimelineData, 'balanceDistribution' => $this->balanceDistribution($profile), ]); } /** * How a profile's trading partners are spread across balance bands. * * Organizations and banks, and only above a threshold of partners: below * it the bands hold so few profiles that a reader could match a bar to a * person they know traded with the profile. The chart carries counts per band * and no identity of any kind — the underlying profiles are never named, * listed or exposed, only tallied. * * @return array{withheld: bool, bands?: array, series?: array, profiles_counted: int, minimum?: int}|null * null when the profile type has no such section; withheld => true * when it has one but too few partners to show it. */ // ------------------------------------------------------- trading network /** * Whether this report may show a network graph. * * Banks only. An organization or user report would show the trade of other * members between themselves, which is not theirs to see; a bank already * administers its clients' accounts. */ public function showsNetwork(): bool { return getActiveProfile() instanceof \App\Models\Bank; } public function toggleNetwork(): void { if (!$this->showsNetwork()) { return; } $this->showNetwork = !$this->showNetwork; } public function setNetworkView(string $view): void { // Allow-list: the value drives styling in JS, so never take it blindly. if (in_array($view, ['flow', 'volume', 'cluster', 'region', 'circulation'], true)) { $this->networkView = $view; } } public function setNetworkLayout(string $layout): void { if (in_array($layout, ['force', 'circle', 'concentric', 'tree', 'grid'], true)) { $this->networkLayout = $layout; } } public function toggleNetworkType(string $type): void { if (!in_array($type, ['User', 'Organization', 'Bank'], true)) { return; } if (in_array($type, $this->networkTypes, true)) { $this->networkTypes = array_values(array_diff($this->networkTypes, [$type])); } else { $this->networkTypes[] = $type; } } /** * Clamp the degree sliders; they must not cross, or the graph goes empty * without the user seeing why. */ public function updatedNetworkMinDegree($value): void { $this->networkMinDegree = max(0, min(StatsService::DEGREE_CEILING, (int) $value)); if ($this->networkMinDegree > $this->networkMaxDegree) { $this->networkMaxDegree = $this->networkMinDegree; } } public function updatedNetworkMaxDegree($value): void { $max = max(0, min(StatsService::DEGREE_CEILING, (int) $value)); if ($max < $this->networkMinDegree) { $this->networkMinDegree = $max; } $this->networkMaxDegree = $max; } /** * The bank's own clients, as profile keys. * * Determined server-side and never settable from the view: this is the * boundary that stops a bank seeing names of non-client members. Note that * bank_user holds the MANAGERS; the clients live in bank_clients. */ private function networkScope(): array { $profile = getActiveProfile(); if (!$profile instanceof \App\Models\Bank) { return []; } return \DB::table('bank_clients') ->where('bank_id', $profile->id) ->get(['client_type', 'client_id']) ->map(fn($row) => $row->client_type . ':' . $row->client_id) ->all(); } /** Periods the graph can show, independently of the report's period. */ private function networkFilter(): StatsFilter { $from = null; $till = null; switch ($this->networkPeriod) { case 'inherit': $from = $this->fromDate ? Carbon::parse($this->fromDate) : null; $till = $this->toDate ? Carbon::parse($this->toDate) : null; break; case 'since_one_year_ago': $from = Carbon::now()->subYear(); $till = Carbon::now(); break; case 'previous_year': $from = Carbon::now()->subYear()->startOfYear(); $till = Carbon::now()->subYear()->endOfYear(); break; case 'previous_quarter': $from = Carbon::now()->subQuarter()->startOfQuarter(); $till = Carbon::now()->subQuarter()->endOfQuarter(); break; case 'previous_month': $from = Carbon::now()->subMonth()->startOfMonth(); $till = Carbon::now()->subMonth()->endOfMonth(); break; case 'all_time': default: break; } return new StatsFilter( periodFrom: $from, periodTill: $till, allTime: $this->networkPeriod === 'all_time', includeRemoved: $this->networkIncludeRemoved, markInactive: $this->networkMarkInactive, ); } /** * The graph's settings in words, for the PDF. * * @return array */ public function networkSettingsSummary(): array { $views = \App\Http\Livewire\Admin\Stats::networkViewOptions(); $layouts = [ 'force' => __('Force-directed'), 'circle' => __('Circle'), 'concentric' => __('Rings by connections'), 'tree' => __('Tree'), 'grid' => __('Grid'), ]; $ceiling = StatsService::DEGREE_CEILING; $types = array_map( fn($t) => ['User' => __('Users'), 'Organization' => __('Organizations'), 'Bank' => __('Banks')][$t] ?? $t, $this->networkTypes ); $summary = [ ['label' => __('View'), 'value' => $views[$this->networkView] ?? $this->networkView], ['label' => __('Arrangement'), 'value' => $layouts[$this->networkLayout] ?? $this->networkLayout], ['label' => __('Trading partners'), 'value' => $this->networkMinDegree . ' - ' . ($this->networkMaxDegree >= $ceiling ? '>' . $ceiling : $this->networkMaxDegree)], ['label' => __('Show'), 'value' => implode(', ', $types) ?: __('None')], ['label' => __('Period'), 'value' => $this->networkPeriodLabel()], ]; if ($this->networkEdge) { $summary[] = ['label' => __('Incl. network outside current bank'), 'value' => __('Yes')]; } if ($this->networkView === 'circulation') { $summary[] = [ 'label' => __('Define \'stopped\' at'), 'value' => ($this->networkStoppedAfterMonths >= \App\Http\Livewire\Admin\Stats::STOPPED_AFTER_CEILING ? '>' . \App\Http\Livewire\Admin\Stats::STOPPED_AFTER_CEILING : $this->networkStoppedAfterMonths) . ' ' . __('months'), ]; } if ($this->networkHideHub) { $summary[] = ['label' => __('Hide the largest hub'), 'value' => __('Yes')]; } if ($this->networkIncludeRemoved) { $summary[] = ['label' => __('Include removed profiles'), 'value' => __('Yes')]; } if ($this->networkMarkInactive) { $summary[] = ['label' => __('Highlight inactive profiles'), 'value' => __('Yes')]; } // Stated explicitly: a reader of a shared export should not have to // guess whether "User 12" is a pseudonym or someone's actual name. if ($this->anonymizeProfiles) { $summary[] = ['label' => __('Anonymise profile names'), 'value' => __('Yes')]; } return $summary; } /** Readable name of the graph's own period, shown under the heading. */ /** * The period the graph is actually drawn over, in words. * * networkPeriodLabel() only describes the graph's own override and returns * an empty string on 'inherit'. This resolves that case to the report's own * period, so an exported figure always states what it covers. */ public function effectiveNetworkPeriodLabel(): string { if ($this->networkPeriod !== 'inherit') { return $this->networkPeriodLabel(); } if ($this->fromDate && $this->toDate) { return Carbon::parse($this->fromDate)->format('d-m-Y') . ' – ' . Carbon::parse($this->toDate)->format('d-m-Y'); } return __('All time'); } public function networkPeriodLabel(): string { return [ 'inherit' => __('Same period as the report'), 'since_one_year_ago' => __('Since one year ago'), 'previous_year' => __('Previous year'), 'previous_quarter' => __('Previous quarter'), 'previous_month' => __('Previous month'), 'all_time' => __('All time'), ][$this->networkPeriod] ?? ''; } /** The graph itself, or null when this report does not show one. */ public function networkData(): ?array { if (!$this->showNetwork || !$this->showsNetwork()) { return null; } $scope = $this->networkScope(); if (empty($scope)) { return null; } return (new StatsService($this->networkFilter()))->network( $this->networkMinDegree, $scope, $this->networkEdge, $this->networkTypes, $this->networkMaxDegree, $this->anonymizeProfiles ); } /** * Legend for the graph: what colour, size and line width mean. * * Mirrors Admin\Stats::networkLegend(); the colour codes match * TYPE_COLORS / REGION_COLORS in stats-network.js. */ public function networkLegend(array $network): array { $typeColors = [ 'User' => '#14b8a6', 'Organization' => '#d946ef', 'Bank' => '#f59e0b', ]; $regionColors = ['#3b82f6', '#f97316', '#14b8a6', '#d946ef', '#84cc16', '#8b5cf6', '#ec4899', '#22c55e', '#0ea5e9', '#eab308']; $nodes = $network['nodes'] ?? []; if ($this->networkView === 'region') { $seen = []; foreach ($nodes as $node) { $region = $node['region'] ?? null; if ($region && !in_array($region, $seen, true)) { $seen[] = $region; } } $colors = []; foreach ($seen as $i => $region) { $colors[] = ['color' => $regionColors[$i % count($regionColors)], 'label' => $region]; } $colorMeaning = __('Region'); } elseif ($this->networkView === 'circulation') { // Classified in the browser on the visible set; this only explains // the palette. Keep in step with FLOW_COLORS in stats-network.js. // The same three states, labels and colours as the stats page: // both draw with FLOW_COLORS. An earlier legend here showed a // four-colour recency ramp from a circulationColor() that the graph // script never had, so it described colours that were never drawn. $colors = [ ['color' => '#3b82f6', 'label' => __('Passing hours on')], ['color' => '#9333ea', 'label' => __('Stalled: hours are sitting still here')], ['color' => '#22d3ee', 'label' => __('Never passed hours on')], ]; $colorMeaning = __('What the profile does with the hours it sees'); } elseif ($this->networkView === 'cluster') { $colors = []; $colorMeaning = __('Cluster of profiles that mainly trade with each other'); } else { $present = array_unique(array_column($nodes, 'type')); $labels = ['User' => __('Users'), 'Organization' => __('Organizations'), 'Bank' => __('Banks')]; $colors = []; foreach ($typeColors as $type => $hex) { if (in_array($type, $present, true)) { $colors[] = ['color' => $hex, 'label' => $labels[$type] ?? $type]; } } $colorMeaning = __('Profile type'); } // Node style, as a separate legend block: colour alone does not explain // the dashed outline that marks a profile from another bank's network. $styles = [ ['color' => $typeColors['User'], 'dashed' => false, 'label' => __('Your own clients')], ]; if (collect($nodes)->contains(fn($n) => $n['outside'] ?? false)) { $styles[] = ['color' => '#94a3b8', 'dashed' => true, 'label' => __('Another bank network')]; } return [ 'view_label' => \App\Http\Livewire\Admin\Stats::networkViewOptions()[$this->networkView] ?? $this->networkView, 'view_caption' => \App\Http\Livewire\Admin\Stats::networkViewCaption($this->networkView), 'view_hint' => \App\Http\Livewire\Admin\Stats::networkViewHint($this->networkView), 'color_meaning' => $colorMeaning, 'colors' => $colors, 'styles' => $styles, 'size_meaning' => $this->networkView === 'volume' ? __('Total time traded by this profile') : __('Number of trading partners'), 'width_meaning' => $this->networkView === 'flow' ? __('Time traded between the two profiles') : __('Number of transactions between the two profiles'), // One entry per transaction type actually present in this // graph: each arrow is a single type carrying that type's volume. // Keep the colours in step with TYPE_EDGE_COLORS in // stats-network.js. 'directions' => $this->networkView === 'flow' ? collect($network['edges'] ?? []) ->pluck('type_name', 'type_id') ->sortKeys() ->map(fn($name, $id) => [ 'color' => [ 1 => '#1e293b', 2 => '#4338ca', 3 => '#16a34a', 4 => '#eab308', 5 => '#ff3b00', 6 => '#0891b2', ][$id] ?? '#64748b', 'label' => $name, ]) ->values() ->all() : [], ]; } public function balanceDistribution($profile): ?array { // Organizations and banks: both hold many counterparties, so the bands // describe a real spread. A user's partner list is small enough that // the threshold below would hide it anyway. if (!$profile instanceof \App\Models\Organization && !$profile instanceof \App\Models\Bank) { return null; } $service = new \App\Services\StatsService(new \App\Services\Stats\StatsFilter( periodFrom: $this->fromDate ? Carbon::parse($this->fromDate) : null, periodTill: $this->toDate ? Carbon::parse($this->toDate) : null, counterparties: [[ 'type' => get_class($profile), 'id' => $profile->id, ]], )); $distribution = $service->balanceDistribution(); $partnerCount = $distribution['profiles_counted'] ?? 0; // Withheld rather than absent: the view says so, because a section that // simply vanishes reads as a bug. Null stays reserved for "this profile // type has no such section at all". if ($partnerCount < self::MIN_PARTNERS_FOR_BREAKDOWN) { return [ 'withheld' => true, 'profiles_counted' => $partnerCount, 'minimum' => self::MIN_PARTNERS_FOR_BREAKDOWN, ]; } // Counts only. Nothing that could name or order an individual partner // leaves this method. return [ 'withheld' => false, 'bands' => $distribution['bands'], 'series' => $distribution['series'], 'profiles_counted' => $partnerCount, ]; } /** * Export report as PDF with both chart images */ /** * Validate a data-URL chart image and store it under a fresh temp id. * * Validation mirrors Reports::decodeChartImage(): strict base64 plus a * PNG/JPEG magic-byte check, so a crafted payload cannot be written to disk * and handed to the PDF renderer. The three charts shared two copies of an * unvalidated version of this before. * * @return string|null the temp id, or null when no image was supplied */ private function storeChartImage(?string $chartImage, string $prefix): ?string { if (!$chartImage) { return null; } $base64Data = explode(',', $chartImage)[1] ?? $chartImage; $imageData = base64_decode($base64Data, strict: true); if ($imageData === false) { abort(422, 'Invalid chart image data'); } $isPng = str_starts_with($imageData, "\x89PNG"); $isJpeg = str_starts_with($imageData, "\xFF\xD8\xFF"); if (!$isPng && !$isJpeg) { Log::warning('SingleReport: rejected chart image with invalid magic bytes', [ 'profileType' => session('activeProfileType'), 'profileId' => session('activeProfileId'), ]); abort(422, 'Chart image must be a PNG or JPEG'); } $imageId = uniqid($prefix, true); $tempImagePath = storage_path('app/temp/' . $imageId . '.png'); if (!file_exists(dirname($tempImagePath))) { mkdir(dirname($tempImagePath), 0755, true); } file_put_contents($tempImagePath, $imageData); return $imageId; } public function exportPdfWithCharts($returnRatioChartImage = null, $accountBalancesChartImage = null, $fromDate = null, $toDate = null, $decimalFormat = null, $balanceDistributionChartImage = null, $balanceDistributionOrgChartImage = null, $networkChartImage = null) { $profile = getActiveProfile(); if (!$profile) { abort(403, 'No active profile'); } // CRITICAL SECURITY: Validate authorization before export \App\Helpers\ProfileAuthorizationHelper::authorize($profile); // Use passed dates if provided, otherwise use component dates $actualFromDate = $fromDate ?? $this->fromDate; $actualToDate = $toDate ?? $this->toDate; $chartImageIds = array_filter([ 'return_ratio_chart_id' => $this->storeChartImage($returnRatioChartImage, 'return_ratio_chart_'), 'account_balances_chart_id' => $this->storeChartImage($accountBalancesChartImage, 'account_balances_chart_'), 'balance_distribution_chart_id' => $this->storeChartImage($balanceDistributionChartImage, 'balance_distribution_chart_'), 'balance_distribution_org_chart_id' => $this->storeChartImage($balanceDistributionOrgChartImage, 'balance_distribution_org_chart_'), 'network_chart_id' => $this->storeChartImage($networkChartImage, 'network_chart_'), ]); // Store chart image IDs in session session(['chart_image_ids' => $chartImageIds]); // The graph's settings travel with it: a printed graph has no controls, // so the PDF has to say which view produced it. session(['report_network_settings' => isset($chartImageIds['network_chart_id']) ? $this->networkSettingsSummary() : null]); // The legend belongs with the image: without it the colours and sizes // in a printed graph mean nothing. $graph = isset($chartImageIds['network_chart_id']) ? $this->networkData() : null; session(['report_network_legend' => $graph ? $this->networkLegend($graph) : null]); // Build URL with current report parameters // Use explicitly passed decimalFormat if provided, otherwise fall back to component state $useDecimal = $decimalFormat !== null ? (bool) $decimalFormat : $this->decimalFormat; $params = [ 'fromDate' => $actualFromDate, 'toDate' => $actualToDate, 'with_charts' => 1, 'decimal' => $useDecimal ? 1 : 0, ]; $url = route('reports.pdf', $params); // Use JavaScript to open PDF in new window/tab $this->dispatch('openPdf', $url); } }