resolveDateRange($schoolYear, $dateFrom, $dateTo); $current = $this->periodMetrics($schoolYear, $rangeFrom, $rangeTo); $comparison = null; if (! empty($compareSchoolYear)) { [$compareFrom, $compareTo] = $this->resolveDateRange($compareSchoolYear, null, null); $comparison = $this->periodMetrics($compareSchoolYear, $compareFrom, $compareTo); } return [ 'schoolYear' => $schoolYear, 'dateFrom' => $rangeFrom, 'dateTo' => $rangeTo, 'generatedAt' => now()->toISOString(), 'audience' => 'stakeholders', 'status' => 'read_only_report', 'executiveSummary' => $this->executiveSummary($current, $comparison), 'keyMetrics' => $current['keyMetrics'], 'cashFlow' => $current['cashFlow'], 'receivables' => $current['receivables'], 'expenseAnalysis' => $current['expenseAnalysis'], 'revenueAnalysis' => $current['revenueAnalysis'], 'paymentMethodMix' => $current['paymentMethodMix'], 'monthlyTrend' => $current['monthlyTrend'], 'riskFlags' => $current['riskFlags'], 'comparison' => $comparison ? [ 'schoolYear' => $compareSchoolYear, 'keyMetrics' => $comparison['keyMetrics'], 'deltas' => $this->deltas($current['keyMetrics'], $comparison['keyMetrics']), ] : null, 'notes' => [ 'This report is additive and read-only. It does not mutate invoices, payments, refunds, expenses, reimbursements, or legacy PayPal records.', 'Revenue is based on invoice and charge records; cash collection is based on non-voided payment records.', 'Ratios are directional management indicators, not audited financial statements.', ], ]; } public function csvRows(array $analysis): array { $rows = []; $rows[] = ['Section', 'Metric', 'Value']; foreach (($analysis['keyMetrics'] ?? []) as $key => $value) { $rows[] = ['Key Metrics', $key, is_scalar($value) ? $value : json_encode($value)]; } foreach (($analysis['cashFlow'] ?? []) as $key => $value) { $rows[] = ['Cash Flow', $key, is_scalar($value) ? $value : json_encode($value)]; } foreach (($analysis['receivables'] ?? []) as $key => $value) { if ($key === 'agingBuckets') { foreach ($value as $bucket => $amount) { $rows[] = ['Receivables Aging', $bucket, $amount]; } continue; } $rows[] = ['Receivables', $key, is_scalar($value) ? $value : json_encode($value)]; } foreach (($analysis['expenseAnalysis']['byCategory'] ?? []) as $category => $amount) { $rows[] = ['Expense By Category', $category, $amount]; } foreach (($analysis['paymentMethodMix'] ?? []) as $method => $amount) { $rows[] = ['Payment Method Mix', $method, $amount]; } foreach (($analysis['monthlyTrend'] ?? []) as $month => $data) { $rows[] = ['Monthly Trend', $month.' charges', $data['charges'] ?? 0]; $rows[] = ['Monthly Trend', $month.' collections', $data['collections'] ?? 0]; $rows[] = ['Monthly Trend', $month.' expenses', $data['expenses'] ?? 0]; $rows[] = ['Monthly Trend', $month.' netCash', $data['netCash'] ?? 0]; } foreach (($analysis['riskFlags'] ?? []) as $flag) { $rows[] = ['Risk Flag', $flag['level'] ?? '', $flag['message'] ?? '']; } return $rows; } private function periodMetrics(string $schoolYear, ?string $dateFrom, ?string $dateTo): array { $invoiceTotals = $this->invoiceTotals($schoolYear, $dateFrom, $dateTo); $payments = $this->paymentTotals($schoolYear, $dateFrom, $dateTo); $refunds = $this->refundTotals($schoolYear, $dateFrom, $dateTo); $discounts = $this->discountTotals($schoolYear, $dateFrom, $dateTo); $additionalCharges = $this->additionalChargeTotals($schoolYear, $dateFrom, $dateTo); $eventCharges = $this->eventChargeTotals($schoolYear, $dateFrom, $dateTo); $expenses = $this->expenseTotals($schoolYear, $dateFrom, $dateTo); $reimbursements = $this->reimbursementTotals($schoolYear, $dateFrom, $dateTo); $grossRevenue = $invoiceTotals['total'] + $additionalCharges['total'] + $eventCharges['total']; $netRevenue = max(0.0, $grossRevenue - $discounts['total'] - $refunds['total']); $cashCollected = $payments['total']; $cashOut = $expenses['total'] + $reimbursements['total'] + $refunds['total']; $netCash = $cashCollected - $cashOut; $receivables = max(0.0, $netRevenue - $cashCollected); $collectionRate = $netRevenue > 0 ? ($cashCollected / $netRevenue) * 100 : 0.0; $expenseRatio = $cashCollected > 0 ? (($expenses['total'] + $reimbursements['total']) / $cashCollected) * 100 : 0.0; $operatingMargin = $netRevenue > 0 ? (($netRevenue - $expenses['total'] - $reimbursements['total']) / $netRevenue) * 100 : 0.0; $aging = $this->receivableAging($schoolYear, $dateFrom, $dateTo); $keyMetrics = [ 'grossRevenue' => $this->money($grossRevenue), 'netRevenue' => $this->money($netRevenue), 'cashCollected' => $this->money($cashCollected), 'refunds' => $this->money($refunds['total']), 'discounts' => $this->money($discounts['total']), 'expenses' => $this->money($expenses['total']), 'reimbursements' => $this->money($reimbursements['total']), 'netCash' => $this->money($netCash), 'receivables' => $this->money($receivables), 'collectionRatePct' => $this->percent($collectionRate), 'expenseRatioPct' => $this->percent($expenseRatio), 'operatingMarginPct' => $this->percent($operatingMargin), 'invoiceCount' => $invoiceTotals['count'], 'payingFamilyCount' => $payments['familyCount'], ]; return [ 'keyMetrics' => $keyMetrics, 'cashFlow' => [ 'cashIn' => $this->money($cashCollected), 'cashOut' => $this->money($cashOut), 'netCash' => $this->money($netCash), 'cashOutBreakdown' => [ 'expenses' => $this->money($expenses['total']), 'reimbursements' => $this->money($reimbursements['total']), 'refunds' => $this->money($refunds['total']), ], ], 'receivables' => [ 'totalOpenReceivables' => $this->money($aging['total']), 'agingBuckets' => array_map(fn ($v) => $this->money($v), $aging['buckets']), 'largestOpenInvoices' => $aging['largestOpenInvoices'], ], 'expenseAnalysis' => [ 'totalExpenses' => $this->money($expenses['total']), 'byCategory' => array_map(fn ($v) => $this->money($v), $expenses['byCategory']), ], 'revenueAnalysis' => [ 'invoiceCharges' => $this->money($invoiceTotals['total']), 'additionalCharges' => $this->money($additionalCharges['total']), 'eventCharges' => $this->money($eventCharges['total']), 'discounts' => $this->money($discounts['total']), 'refunds' => $this->money($refunds['total']), 'netRevenue' => $this->money($netRevenue), ], 'paymentMethodMix' => array_map(fn ($v) => $this->money($v), $payments['byMethod']), 'monthlyTrend' => $this->monthlyTrend($schoolYear, $dateFrom, $dateTo), 'riskFlags' => $this->riskFlags($collectionRate, $expenseRatio, $aging['buckets'], $netCash), ]; } private function invoiceTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array { if (! Schema::hasTable('invoices')) { return ['total' => 0.0, 'count' => 0]; } $q = DB::table('invoices')->where('school_year', $schoolYear); $this->applyDateRange($q, $this->dateColumn('invoices', ['issue_date', 'created_at']), $dateFrom, $dateTo); $row = (array) $q->selectRaw('COALESCE(SUM(total_amount),0) as total, COUNT(*) as count')->first(); return ['total' => (float) ($row['total'] ?? 0), 'count' => (int) ($row['count'] ?? 0)]; } private function paymentTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array { if (! Schema::hasTable('payments')) { return ['total' => 0.0, 'familyCount' => 0, 'byMethod' => []]; } $base = DB::table('payments')->where('school_year', $schoolYear); $this->excludeStatuses($base, 'payments', $this->excludedPaymentStatuses); $this->applyDateRange($base, $this->dateColumn('payments', ['payment_date', 'created_at']), $dateFrom, $dateTo); $total = (float) ((array) (clone $base)->selectRaw('COALESCE(SUM(paid_amount),0) as total')->first())['total']; $familyCount = (int) ((array) (clone $base)->selectRaw('COUNT(DISTINCT parent_id) as count')->first())['count']; $methodRows = (clone $base) ->selectRaw("COALESCE(NULLIF(payment_method,''), 'unknown') as method, COALESCE(SUM(paid_amount),0) as total") ->groupBy('method') ->orderByDesc('total') ->get(); $byMethod = []; foreach ($methodRows as $row) { $arr = (array) $row; $byMethod[(string) ($arr['method'] ?? 'unknown')] = (float) ($arr['total'] ?? 0); } return ['total' => $total, 'familyCount' => $familyCount, 'byMethod' => $byMethod]; } private function refundTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array { if (! Schema::hasTable('refunds')) { return ['total' => 0.0]; } $q = DB::table('refunds')->where('school_year', $schoolYear); if (Schema::hasColumn('refunds', 'status')) { $q->whereIn('status', ['paid', 'partially_paid', 'approved', 'Paid', 'Partially Paid', 'Approved']); } $this->applyDateRange($q, $this->dateColumn('refunds', ['refunded_at', 'approved_at', 'created_at']), $dateFrom, $dateTo); $amountColumn = Schema::hasColumn('refunds', 'refund_paid_amount') ? 'refund_paid_amount' : 'refund_amount'; $row = (array) $q->selectRaw('COALESCE(SUM('.$amountColumn.'),0) as total')->first(); return ['total' => (float) ($row['total'] ?? 0)]; } private function discountTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array { if (! Schema::hasTable('discount_usages')) { return ['total' => 0.0]; } $q = DB::table('discount_usages as du'); if (Schema::hasTable('invoices')) { $q->leftJoin('invoices as i', 'i.id', '=', 'du.invoice_id')->where('i.school_year', $schoolYear); } $this->excludeStatuses($q, 'discount_usages', $this->excludedChargeStatuses, 'du.status'); $this->applyDateRange($q, Schema::hasColumn('discount_usages', 'created_at') ? 'du.created_at' : null, $dateFrom, $dateTo); $amountColumn = Schema::hasColumn('discount_usages', 'discount_amount') ? 'discount_amount' : 'amount'; $row = (array) $q->selectRaw('COALESCE(SUM(du.'.$amountColumn.'),0) as total')->first(); return ['total' => (float) ($row['total'] ?? 0)]; } private function additionalChargeTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array { if (! Schema::hasTable('additional_charges')) { return ['total' => 0.0]; } $q = DB::table('additional_charges')->where('school_year', $schoolYear); $this->excludeStatuses($q, 'additional_charges', $this->excludedChargeStatuses); $this->applyDateRange($q, $this->dateColumn('additional_charges', ['due_date', 'created_at']), $dateFrom, $dateTo); $row = (array) $q->selectRaw('COALESCE(SUM(amount),0) as total')->first(); return ['total' => (float) ($row['total'] ?? 0)]; } private function eventChargeTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array { $table = Schema::hasTable('event_charges') ? 'event_charges' : (Schema::hasTable('event_charge') ? 'event_charge' : null); if ($table === null) { return ['total' => 0.0]; } $q = DB::table($table); if (Schema::hasColumn($table, 'school_year')) { $q->where('school_year', $schoolYear); } $this->excludeStatuses($q, $table, $this->excludedChargeStatuses); $amountColumn = Schema::hasColumn($table, 'amount') ? 'amount' : (Schema::hasColumn($table, 'charge_amount') ? 'charge_amount' : null); if ($amountColumn === null) { return ['total' => 0.0]; } $this->applyDateRange($q, $this->dateColumn($table, ['charge_date', 'created_at']), $dateFrom, $dateTo); $row = (array) $q->selectRaw('COALESCE(SUM('.$amountColumn.'),0) as total')->first(); return ['total' => (float) ($row['total'] ?? 0)]; } private function expenseTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array { if (! Schema::hasTable('expenses')) { return ['total' => 0.0, 'byCategory' => []]; } $q = DB::table('expenses')->where('school_year', $schoolYear); $this->excludeStatuses($q, 'expenses', ['void', 'voided', 'rejected', 'cancelled', 'canceled']); $this->applyDateRange($q, $this->dateColumn('expenses', ['expense_date', 'date', 'created_at']), $dateFrom, $dateTo); $amountColumn = Schema::hasColumn('expenses', 'amount') ? 'amount' : 'total_amount'; $total = (float) ((array) (clone $q)->selectRaw('COALESCE(SUM('.$amountColumn.'),0) as total')->first())['total']; $categoryColumn = Schema::hasColumn('expenses', 'category') ? 'category' : (Schema::hasColumn('expenses', 'expense_category') ? 'expense_category' : null); $byCategory = []; if ($categoryColumn) { foreach ((clone $q)->selectRaw("COALESCE(NULLIF($categoryColumn,''), 'uncategorized') as category, COALESCE(SUM($amountColumn),0) as total")->groupBy('category')->orderByDesc('total')->get() as $row) { $arr = (array) $row; $byCategory[(string) ($arr['category'] ?? 'uncategorized')] = (float) ($arr['total'] ?? 0); } } return ['total' => $total, 'byCategory' => $byCategory]; } private function reimbursementTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array { if (! Schema::hasTable('reimbursements')) { return ['total' => 0.0]; } $q = DB::table('reimbursements')->where('school_year', $schoolYear); if (Schema::hasColumn('reimbursements', 'status')) { $q->whereNotIn('status', ['void', 'voided', 'rejected', 'cancelled', 'canceled']); } $this->applyDateRange($q, $this->dateColumn('reimbursements', ['reimbursed_at', 'created_at']), $dateFrom, $dateTo); $amountColumn = Schema::hasColumn('reimbursements', 'amount') ? 'amount' : (Schema::hasColumn('reimbursements', 'total_amount') ? 'total_amount' : 'reimbursement_amount'); $row = (array) $q->selectRaw('COALESCE(SUM('.$amountColumn.'),0) as total')->first(); return ['total' => (float) ($row['total'] ?? 0)]; } private function receivableAging(string $schoolYear, ?string $dateFrom, ?string $dateTo): array { $buckets = ['current' => 0.0, '1_30_days' => 0.0, '31_60_days' => 0.0, '61_90_days' => 0.0, 'over_90_days' => 0.0]; $largest = []; if (! Schema::hasTable('invoices')) { return ['total' => 0.0, 'buckets' => $buckets, 'largestOpenInvoices' => []]; } $q = DB::table('invoices')->where('school_year', $schoolYear); $this->applyDateRange($q, $this->dateColumn('invoices', ['issue_date', 'created_at']), $dateFrom, $dateTo); $rows = $q->select(['id', 'invoice_number', 'parent_id', 'total_amount', 'paid_amount', 'balance', 'due_date', 'created_at'])->get(); $today = now()->startOfDay(); $total = 0.0; foreach ($rows as $row) { $arr = (array) $row; $balance = (float) ($arr['balance'] ?? 0); if ($balance <= 0) { $totalAmount = (float) ($arr['total_amount'] ?? 0); $paidAmount = (float) ($arr['paid_amount'] ?? 0); $balance = max(0.0, $totalAmount - $paidAmount); } if ($balance <= 0) { continue; } $total += $balance; $dueRaw = $arr['due_date'] ?? $arr['created_at'] ?? null; $days = 0; try { if (! empty($dueRaw)) { $days = max(0, (int) Carbon::parse($dueRaw)->startOfDay()->diffInDays($today, false)); } } catch (\Throwable $e) { $days = 0; } $bucket = $days <= 0 ? 'current' : ($days <= 30 ? '1_30_days' : ($days <= 60 ? '31_60_days' : ($days <= 90 ? '61_90_days' : 'over_90_days'))); $buckets[$bucket] += $balance; $largest[] = [ 'invoiceId' => (int) ($arr['id'] ?? 0), 'invoiceNumber' => (string) ($arr['invoice_number'] ?? ''), 'parentId' => (int) ($arr['parent_id'] ?? 0), 'balance' => $this->money($balance), 'daysPastDue' => $days, ]; } usort($largest, fn ($a, $b) => (float) $b['balance'] <=> (float) $a['balance']); return ['total' => $total, 'buckets' => $buckets, 'largestOpenInvoices' => array_slice($largest, 0, 10)]; } private function monthlyTrend(string $schoolYear, ?string $dateFrom, ?string $dateTo): array { $months = []; $invoiceMonth = $this->monthlySum('invoices', 'total_amount', $schoolYear, $this->dateColumn('invoices', ['issue_date', 'created_at']), $dateFrom, $dateTo); $paymentMonth = $this->monthlySum('payments', 'paid_amount', $schoolYear, $this->dateColumn('payments', ['payment_date', 'created_at']), $dateFrom, $dateTo, $this->excludedPaymentStatuses); $expenseColumn = Schema::hasTable('expenses') && Schema::hasColumn('expenses', 'amount') ? 'amount' : 'total_amount'; $expenseMonth = $this->monthlySum('expenses', $expenseColumn, $schoolYear, $this->dateColumn('expenses', ['expense_date', 'date', 'created_at']), $dateFrom, $dateTo, ['void', 'voided', 'rejected', 'cancelled', 'canceled']); foreach (array_unique(array_merge(array_keys($invoiceMonth), array_keys($paymentMonth), array_keys($expenseMonth))) as $month) { $charges = $invoiceMonth[$month] ?? 0.0; $collections = $paymentMonth[$month] ?? 0.0; $expenses = $expenseMonth[$month] ?? 0.0; $months[$month] = [ 'charges' => $this->money($charges), 'collections' => $this->money($collections), 'expenses' => $this->money($expenses), 'netCash' => $this->money($collections - $expenses), ]; } ksort($months); return $months; } private function monthlySum(string $table, string $amountColumn, string $schoolYear, ?string $dateColumn, ?string $dateFrom, ?string $dateTo, array $excludedStatuses = []): array { if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $amountColumn) || $dateColumn === null) { return []; } $q = DB::table($table); if (Schema::hasColumn($table, 'school_year')) { $q->where('school_year', $schoolYear); } $this->excludeStatuses($q, $table, $excludedStatuses); $this->applyDateRange($q, $dateColumn, $dateFrom, $dateTo); $driver = DB::connection()->getDriverName(); $monthExpr = $driver === 'sqlite' ? "strftime('%Y-%m', $dateColumn)" : "DATE_FORMAT($dateColumn, '%Y-%m')"; $rows = $q->selectRaw($monthExpr.' as month, COALESCE(SUM('.$amountColumn.'),0) as total') ->groupBy('month') ->orderBy('month') ->get(); $out = []; foreach ($rows as $row) { $arr = (array) $row; if (! empty($arr['month'])) { $out[(string) $arr['month']] = (float) ($arr['total'] ?? 0); } } return $out; } private function executiveSummary(array $current, ?array $comparison): array { $summary = [ 'headline' => 'Stakeholder financial analysis generated from existing Laravel finance tables.', 'collectionRatePct' => $current['keyMetrics']['collectionRatePct'] ?? '0.00', 'netCash' => $current['keyMetrics']['netCash'] ?? '0.00', 'receivables' => $current['keyMetrics']['receivables'] ?? '0.00', ]; if ($comparison) { $summary['comparison'] = $this->deltas($current['keyMetrics'], $comparison['keyMetrics']); } return $summary; } private function riskFlags(float $collectionRate, float $expenseRatio, array $agingBuckets, float $netCash): array { $flags = []; if ($collectionRate < 85.0) { $flags[] = ['level' => 'warning', 'message' => 'Collection rate is below 85%. Review receivables and follow-up cadence.']; } if (($agingBuckets['over_90_days'] ?? 0.0) > 0) { $flags[] = ['level' => 'warning', 'message' => 'There are receivables more than 90 days past due.']; } if ($expenseRatio > 80.0) { $flags[] = ['level' => 'watch', 'message' => 'Expenses and reimbursements exceed 80% of collections.']; } if ($netCash < 0) { $flags[] = ['level' => 'critical', 'message' => 'Net cash for the selected period is negative.']; } if (empty($flags)) { $flags[] = ['level' => 'ok', 'message' => 'No automatic risk flags were triggered for the selected period.']; } return $flags; } private function deltas(array $current, array $previous): array { $keys = ['grossRevenue', 'netRevenue', 'cashCollected', 'expenses', 'netCash', 'receivables']; $out = []; foreach ($keys as $key) { $cur = (float) ($current[$key] ?? 0); $prev = (float) ($previous[$key] ?? 0); $out[$key] = [ 'current' => $this->money($cur), 'previous' => $this->money($prev), 'change' => $this->money($cur - $prev), 'changePct' => $prev != 0.0 ? $this->percent((($cur - $prev) / abs($prev)) * 100) : null, ]; } return $out; } private function resolveDateRange(string $schoolYear, ?string $dateFrom, ?string $dateTo): array { if ((! $dateFrom || ! $dateTo) && preg_match('/^(\d{4})\D?(\d{4})$/', $schoolYear, $m)) { $dateFrom = $dateFrom ?: $m[1].'-01-01'; $dateTo = $dateTo ?: $m[2].'-12-31'; } return [$dateFrom, $dateTo]; } private function applyDateRange($query, ?string $column, ?string $dateFrom, ?string $dateTo): void { if ($column === null) { return; } if (! empty($dateFrom)) { $query->whereRaw('DATE('.$column.') >= ?', [$dateFrom]); } if (! empty($dateTo)) { $query->whereRaw('DATE('.$column.') <= ?', [$dateTo]); } } private function excludeStatuses($query, string $table, array $statuses, ?string $qualifiedColumn = null): void { if (empty($statuses) || ! Schema::hasColumn($table, 'status')) { return; } $column = $qualifiedColumn ?: 'status'; $query->where(function ($q) use ($column, $statuses) { $q->whereNotIn($column, $statuses)->orWhereNull($column); }); } private function dateColumn(string $table, array $candidates): ?string { if (! Schema::hasTable($table)) { return null; } foreach ($candidates as $column) { if (Schema::hasColumn($table, $column)) { return $column; } } return null; } private function money(float $value): string { return number_format(round($value, 2), 2, '.', ''); } private function percent(float $value): string { return number_format(round($value, 2), 2, '.', ''); } }