Files
alrahma_sunday_school_api/app/Services/Finance/StakeholderFinancialAnalysisService.php
T
2026-06-11 11:46:12 -04:00

529 lines
25 KiB
PHP

<?php
namespace App\Services\Finance;
use App\Models\Configuration;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class StakeholderFinancialAnalysisService
{
private array $excludedPaymentStatuses = [
'void', 'voided', 'failed', 'refunded', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled',
];
private array $excludedChargeStatuses = ['void', 'voided', 'cancelled', 'canceled'];
public function analyze(?string $dateFrom, ?string $dateTo, ?string $schoolYear, ?string $compareSchoolYear = null): array
{
$schoolYear = $schoolYear ?: (Configuration::getConfig('school_year') ?? date('Y'));
[$rangeFrom, $rangeTo] = $this->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, '.', '');
}
}