1670 lines
62 KiB
PHP
1670 lines
62 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Livewire;
|
||
|
||
use App\Models\Transaction;
|
||
use App\Services\Stats\StatsFilter;
|
||
use App\Services\StatsService;
|
||
use App\Traits\AccountInfoTrait;
|
||
use Barryvdh\DomPDF\Facade\Pdf;
|
||
use Carbon\Carbon;
|
||
use Illuminate\Support\Facades\Log;
|
||
use Livewire\Component;
|
||
|
||
class SingleReport extends Component
|
||
{
|
||
use AccountInfoTrait;
|
||
|
||
/**
|
||
* Minimum trading partners before a per-partner breakdown is shown.
|
||
*
|
||
* Two things depend on it, for related reasons. The balance distribution
|
||
* chart: with only a handful of profiles per band, a reader who knows who
|
||
* the organization deals with could read an individual's balance off it.
|
||
* The median reciprocity rate: at two partners it is arithmetically the
|
||
* mean of the only two values, and it reads as a finding when it is not.
|
||
*/
|
||
private const MIN_PARTNERS_FOR_BREAKDOWN = 20;
|
||
|
||
public $reportData;
|
||
public $fromAccountId;
|
||
public $fromDate;
|
||
public $toDate;
|
||
public $isOrganization = false;
|
||
public $decimalFormat = false;
|
||
|
||
// --- Trading network graph (banks only; see networkScope()) ---
|
||
|
||
/**
|
||
* Closed by default: the graph costs an extra aggregation and ~570 kB of
|
||
* lazily loaded JavaScript, and most visits to a report are about the
|
||
* figures above it.
|
||
*/
|
||
public bool $showNetwork = false;
|
||
|
||
/**
|
||
* Replace profile names with stable numbers in the network graph.
|
||
*
|
||
* Same labels as the admin stats page (StatsService::pseudonym), so a
|
||
* profile is "User 12" in both. On by default because this report is made
|
||
* to be exported and shared: names are revealed deliberately rather than
|
||
* by omission.
|
||
*/
|
||
public bool $anonymizeProfiles = true;
|
||
|
||
/**
|
||
* Include profiles closed by their owner, and highlight those flagged
|
||
* inactive. Mirrors the admin stats page; see Admin\Stats for why each
|
||
* default is what it is.
|
||
*/
|
||
public bool $networkIncludeRemoved = true;
|
||
|
||
public bool $networkMarkInactive = true;
|
||
|
||
/** Months after which a profile counts as having stopped passing hours on. */
|
||
public int $networkStoppedAfterMonths = 25;
|
||
|
||
/**
|
||
* Zero, matching the admin stats page: a threshold silently dropped
|
||
* profiles the figures above still counted, and nothing on screen
|
||
* explained the difference. The slider raises the floor when a dense
|
||
* graph matters more than completeness.
|
||
*/
|
||
public int $networkMinDegree = 0;
|
||
public int $networkMaxDegree = StatsService::DEGREE_CEILING;
|
||
/**
|
||
* Off, matching the admin stats page: show the whole network unless the
|
||
* user asks otherwise. A bank's own graph is scoped to its clients, so the
|
||
* hub is less dominant here than on the platform-wide view.
|
||
*/
|
||
public bool $networkHideHub = false;
|
||
|
||
/**
|
||
* Include trade with profiles outside the bank's client circle.
|
||
*
|
||
* On by default here, unlike the platform statistics. A bank's clients
|
||
* trade mostly outside the circle: on dev, bank 5 shows 2 nodes with
|
||
* internal trade only and 55 once edge traffic is included. Outsiders
|
||
* always appear without a name, so the privacy boundary is unchanged.
|
||
*/
|
||
public bool $networkEdge = true;
|
||
|
||
public string $networkView = 'volume';
|
||
public string $networkLayout = 'force';
|
||
public array $networkTypes = ['User', 'Organization'];
|
||
|
||
/**
|
||
* Period for the graph alone.
|
||
*
|
||
* Defaults to all time rather than the report's own period: scoped to one
|
||
* bank's clients, a single year leaves most banks with an empty graph
|
||
* (bank 8 on dev: 0 nodes over the last year, 47 over all time).
|
||
*/
|
||
public string $networkPeriod = 'all_time';
|
||
|
||
protected $listeners = [
|
||
'fromAccountId',
|
||
'periodSelected' => '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<string, array{given: int, received: int}> $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<array{label:string,value:string}>
|
||
*/
|
||
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);
|
||
}
|
||
} |