fix gitlab tests
This commit is contained in:
@@ -16,36 +16,25 @@ class FinanceNotificationLogService
|
||||
'failed_at' => $status === 'failed' ? now() : null,
|
||||
'created_by' => $actorId,
|
||||
]));
|
||||
|
||||
return $log->toArray();
|
||||
}
|
||||
|
||||
public function list(array $filters): array
|
||||
{
|
||||
$q = FinanceNotificationLog::query();
|
||||
foreach (['status', 'notification_type', 'parent_id'] as $field) {
|
||||
if (! empty($filters[$field])) {
|
||||
$q->where($field, $filters[$field]);
|
||||
}
|
||||
}
|
||||
if (! empty($filters['date_from'])) {
|
||||
$q->whereDate('created_at', '>=', $filters['date_from']);
|
||||
}
|
||||
if (! empty($filters['date_to'])) {
|
||||
$q->whereDate('created_at', '<=', $filters['date_to']);
|
||||
}
|
||||
|
||||
return ['rows' => $q->orderByDesc('id')->paginate((int) ($filters['per_page'] ?? 25))];
|
||||
foreach (['status','notification_type','parent_id'] as $field) if (!empty($filters[$field])) $q->where($field, $filters[$field]);
|
||||
if (!empty($filters['date_from'])) $q->whereDate('created_at', '>=', $filters['date_from']);
|
||||
if (!empty($filters['date_to'])) $q->whereDate('created_at', '<=', $filters['date_to']);
|
||||
return ['rows' => $q->orderByDesc('id')->paginate((int)($filters['per_page'] ?? 25))];
|
||||
}
|
||||
|
||||
public function nextReceiptNumber(string $schoolYear): string
|
||||
{
|
||||
return DB::transaction(function () use ($schoolYear) {
|
||||
$seq = FinanceReceiptSequence::query()->lockForUpdate()->firstOrCreate(['school_year' => $schoolYear], ['prefix' => 'R', 'next_number' => 1]);
|
||||
$number = $seq->prefix.'-'.$schoolYear.'-'.str_pad((string) $seq->next_number, 6, '0', STR_PAD_LEFT);
|
||||
$number = $seq->prefix . '-' . $schoolYear . '-' . str_pad((string) $seq->next_number, 6, '0', STR_PAD_LEFT);
|
||||
$seq->next_number = $seq->next_number + 1;
|
||||
$seq->save();
|
||||
|
||||
return $number;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,9 +60,9 @@ class FinancialChartService
|
||||
private function downloadChart(array $chartData, string $filename): ?string
|
||||
{
|
||||
$baseUrl = 'https://quickchart.io/chart?c=';
|
||||
$url = $baseUrl.urlencode(json_encode($chartData));
|
||||
$url = $baseUrl . urlencode(json_encode($chartData));
|
||||
$targetDir = storage_path('app/reports');
|
||||
if (! is_dir($targetDir) && ! mkdir($targetDir, 0775, true) && ! is_dir($targetDir)) {
|
||||
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -71,9 +71,8 @@ class FinancialChartService
|
||||
if ($contents === false) {
|
||||
return null;
|
||||
}
|
||||
$path = $targetDir.DIRECTORY_SEPARATOR.$filename;
|
||||
$path = $targetDir . DIRECTORY_SEPARATOR . $filename;
|
||||
file_put_contents($path, $contents);
|
||||
|
||||
return $path;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
|
||||
@@ -50,7 +50,7 @@ class FinancialPaymentService
|
||||
}
|
||||
$method = (string) ($row['method'] ?? '');
|
||||
$amount = (float) ($row['amount'] ?? 0);
|
||||
if (! isset($breakdown[$invoiceId])) {
|
||||
if (!isset($breakdown[$invoiceId])) {
|
||||
$breakdown[$invoiceId] = [];
|
||||
}
|
||||
$breakdown[$invoiceId][$method] = $amount;
|
||||
@@ -85,13 +85,13 @@ class FinancialPaymentService
|
||||
{
|
||||
$query = DB::table('payments');
|
||||
|
||||
if (! empty($schoolYear)) {
|
||||
if (!empty($schoolYear)) {
|
||||
$query->where('school_year', $schoolYear);
|
||||
}
|
||||
if (! empty($dateFrom)) {
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(payment_date) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (! empty($dateTo)) {
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(payment_date) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,14 +4,16 @@ namespace App\Services\Finance;
|
||||
|
||||
class FinancialPdfReportService
|
||||
{
|
||||
public function __construct(private FinancialChartService $charts) {}
|
||||
public function __construct(private FinancialChartService $charts)
|
||||
{
|
||||
}
|
||||
|
||||
public function buildPdf(array $summary): string
|
||||
{
|
||||
$pdf = new \FPDF;
|
||||
$pdf = new \FPDF();
|
||||
$pdf->AddPage();
|
||||
$pdf->SetFont('Arial', 'B', 16);
|
||||
$pdf->Cell(0, 10, 'Financial Report for School Year: '.($summary['schoolYear'] ?? ''), 0, 1, 'C');
|
||||
$pdf->Cell(0, 10, 'Financial Report for School Year: ' . ($summary['schoolYear'] ?? ''), 0, 1, 'C');
|
||||
|
||||
$pdf->Ln(10);
|
||||
$pdf->SetFont('Arial', 'B', 12);
|
||||
@@ -35,12 +37,12 @@ class FinancialPdfReportService
|
||||
$pdf->SetFont('Arial', '', 11);
|
||||
foreach ($rows as $label => $amount) {
|
||||
$pdf->Cell(130, 10, $label, 1);
|
||||
$pdf->Cell(60, 10, '$'.number_format((float) $amount, 2), 1);
|
||||
$pdf->Cell(60, 10, '$' . number_format((float) $amount, 2), 1);
|
||||
$pdf->Ln();
|
||||
}
|
||||
|
||||
$barChart = $this->charts->generateBarChart($summary);
|
||||
if (! empty($barChart) && file_exists($barChart)) {
|
||||
if (!empty($barChart) && file_exists($barChart)) {
|
||||
$pdf->Ln(10);
|
||||
$pdf->SetFont('Arial', 'B', 12);
|
||||
$pdf->Cell(0, 10, 'Summary Graph', 0, 1);
|
||||
@@ -48,7 +50,7 @@ class FinancialPdfReportService
|
||||
}
|
||||
|
||||
$pieChart = $this->charts->generatePieChart($summary);
|
||||
if (! empty($pieChart) && file_exists($pieChart)) {
|
||||
if (!empty($pieChart) && file_exists($pieChart)) {
|
||||
$pdf->Ln(10);
|
||||
$pdf->SetFont('Arial', 'B', 12);
|
||||
$pdf->Cell(0, 10, 'Expense Breakdown', 0, 1);
|
||||
|
||||
@@ -9,7 +9,8 @@ class FinancialReportService
|
||||
public function __construct(
|
||||
private FinancialPaymentService $payments,
|
||||
private FinancialSchoolYearService $schoolYears
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function getReport(?string $dateFrom, ?string $dateTo, ?string $schoolYear): array
|
||||
{
|
||||
@@ -22,7 +23,7 @@ class FinancialReportService
|
||||
$expenseQuery = DB::table('expenses');
|
||||
$reimbQuery = DB::table('reimbursements');
|
||||
|
||||
if (! empty($schoolYear)) {
|
||||
if (!empty($schoolYear)) {
|
||||
$invoiceQuery->where('invoices.school_year', $schoolYear);
|
||||
$refundQuery->where('school_year', $schoolYear);
|
||||
$discountQuery->where('school_year', $schoolYear);
|
||||
@@ -30,7 +31,7 @@ class FinancialReportService
|
||||
$reimbQuery->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
if (! empty($dateFrom)) {
|
||||
if (!empty($dateFrom)) {
|
||||
$invoiceQuery->whereRaw('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >= ?', [$dateFrom]);
|
||||
$refundQuery->whereRaw('DATE(COALESCE(refunded_at, created_at)) >= ?', [$dateFrom]);
|
||||
$discountQuery->whereRaw('DATE(COALESCE(used_at, created_at)) >= ?', [$dateFrom]);
|
||||
@@ -38,7 +39,7 @@ class FinancialReportService
|
||||
$reimbQuery->whereRaw('DATE(created_at) >= ?', [$dateFrom]);
|
||||
}
|
||||
|
||||
if (! empty($dateTo)) {
|
||||
if (!empty($dateTo)) {
|
||||
$invoiceQuery->whereRaw('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <= ?', [$dateTo]);
|
||||
$refundQuery->whereRaw('DATE(COALESCE(refunded_at, created_at)) <= ?', [$dateTo]);
|
||||
$discountQuery->whereRaw('DATE(COALESCE(used_at, created_at)) <= ?', [$dateTo]);
|
||||
|
||||
@@ -19,7 +19,7 @@ class FinancialSchoolYearService
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if (empty($years) && ! empty($fallbackYear)) {
|
||||
if (empty($years) && !empty($fallbackYear)) {
|
||||
$years[] = (string) $fallbackYear;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ class FinancialSummaryService
|
||||
{
|
||||
public function __construct(
|
||||
private FinancialDonationRecipientService $donationRecipients
|
||||
) {}
|
||||
) {
|
||||
}
|
||||
|
||||
public function getSummary(?string $dateFrom, ?string $dateTo, ?string $schoolYear): array
|
||||
{
|
||||
@@ -18,10 +19,10 @@ class FinancialSummaryService
|
||||
$derivedDateFrom = null;
|
||||
$derivedDateTo = null;
|
||||
|
||||
if (! empty($schoolYear) && empty($dateFrom) && empty($dateTo)) {
|
||||
if (!empty($schoolYear) && empty($dateFrom) && empty($dateTo)) {
|
||||
if (preg_match('/^(\\d{4})[^\\d]?(\\d{4})$/', $schoolYear, $m)) {
|
||||
$derivedDateFrom = $m[1].'-01-01';
|
||||
$derivedDateTo = $m[2].'-12-31';
|
||||
$derivedDateFrom = $m[1] . '-01-01';
|
||||
$derivedDateTo = $m[2] . '-12-31';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +30,10 @@ class FinancialSummaryService
|
||||
$invoiceDateTo = $dateTo ?: $derivedDateTo;
|
||||
|
||||
$invoiceQuery = DB::table('invoices')->where('school_year', $schoolYear);
|
||||
if (! empty($invoiceDateFrom)) {
|
||||
if (!empty($invoiceDateFrom)) {
|
||||
$invoiceQuery->whereRaw('DATE(COALESCE(issue_date, created_at)) >= ?', [$invoiceDateFrom]);
|
||||
}
|
||||
if (! empty($invoiceDateTo)) {
|
||||
if (!empty($invoiceDateTo)) {
|
||||
$invoiceQuery->whereRaw('DATE(COALESCE(issue_date, created_at)) <= ?', [$invoiceDateTo]);
|
||||
}
|
||||
$invoices = $invoiceQuery->get()->map(fn ($row) => (array) $row)->all();
|
||||
@@ -40,7 +41,6 @@ class FinancialSummaryService
|
||||
|
||||
$invoiceIds = array_values(array_unique(array_filter(array_map(static function ($row) {
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
|
||||
return $id > 0 ? $id : null;
|
||||
}, $invoices))));
|
||||
|
||||
@@ -54,7 +54,7 @@ class FinancialSummaryService
|
||||
$paidByInvoice = [];
|
||||
$discountByInvoice = [];
|
||||
$refundByInvoice = [];
|
||||
if (! empty($invoiceIds)) {
|
||||
if (!empty($invoiceIds)) {
|
||||
$paidByInvoice = $this->paymentsByInvoiceIds($invoiceIds, $schoolYear, $invoiceDateFrom, $invoiceDateTo);
|
||||
$discountByInvoice = $this->discountsByInvoiceIds($invoiceIds, $dateFrom, $dateTo);
|
||||
$refundByInvoice = $this->refundsByInvoiceIds($invoiceIds, $dateFrom, $dateTo);
|
||||
@@ -99,15 +99,14 @@ class FinancialSummaryService
|
||||
->where('ac.school_year', $schoolYear)
|
||||
->where('ac.status', '!=', 'void');
|
||||
|
||||
if (! empty($dateFrom)) {
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(ac.due_date) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (! empty($dateTo)) {
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(ac.due_date) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
$row = (array) $query->first();
|
||||
|
||||
return (float) ($row['amount'] ?? 0);
|
||||
}
|
||||
|
||||
@@ -124,10 +123,10 @@ class FinancialSummaryService
|
||||
})
|
||||
->groupBy('ac.parent_id');
|
||||
|
||||
if (! empty($dateFrom)) {
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(ac.due_date) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (! empty($dateTo)) {
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(ac.due_date) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
@@ -147,10 +146,10 @@ class FinancialSummaryService
|
||||
private function sumPayments(string $schoolYear, ?string $dateFrom, ?string $dateTo): float
|
||||
{
|
||||
$query = DB::table('payments')->where('school_year', $schoolYear);
|
||||
if (! empty($dateFrom)) {
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(payment_date) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (! empty($dateTo)) {
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(payment_date) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
@@ -167,7 +166,6 @@ class FinancialSummaryService
|
||||
}
|
||||
|
||||
$row = (array) $query->selectRaw('COALESCE(SUM(paid_amount), 0) AS paid_amount')->first();
|
||||
|
||||
return (float) ($row['paid_amount'] ?? 0);
|
||||
}
|
||||
|
||||
@@ -178,10 +176,10 @@ class FinancialSummaryService
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
if (! empty($dateFrom)) {
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(payment_date) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (! empty($dateTo)) {
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(payment_date) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
@@ -205,7 +203,6 @@ class FinancialSummaryService
|
||||
$map[$invoiceId] = (float) ($row['total_paid'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
@@ -215,10 +212,10 @@ class FinancialSummaryService
|
||||
->selectRaw('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc')
|
||||
->whereIn('invoice_id', $invoiceIds);
|
||||
|
||||
if (! empty($dateFrom)) {
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(COALESCE(used_at, created_at)) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (! empty($dateTo)) {
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(COALESCE(used_at, created_at)) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
@@ -230,7 +227,6 @@ class FinancialSummaryService
|
||||
$map[$invoiceId] = (float) ($row['total_disc'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
@@ -241,10 +237,10 @@ class FinancialSummaryService
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->whereIn('status', ['Partial', 'Paid']);
|
||||
|
||||
if (! empty($dateFrom)) {
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(COALESCE(refunded_at, created_at)) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (! empty($dateTo)) {
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(COALESCE(refunded_at, created_at)) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
@@ -256,22 +252,20 @@ class FinancialSummaryService
|
||||
$map[$invoiceId] = (float) ($row['total_refund'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function sumExpenses(string $schoolYear, ?string $dateFrom, ?string $dateTo): float
|
||||
{
|
||||
$query = DB::table('expenses')->where('school_year', $schoolYear);
|
||||
if (! empty($dateFrom)) {
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(created_at) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (! empty($dateTo)) {
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(created_at) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
$row = (array) $query->selectRaw('COALESCE(SUM(amount), 0) AS amount')->first();
|
||||
|
||||
return (float) ($row['amount'] ?? 0);
|
||||
}
|
||||
|
||||
@@ -283,7 +277,7 @@ class FinancialSummaryService
|
||||
->selectRaw('COALESCE(SUM(r.amount),0) AS amount')
|
||||
->leftJoin('expenses as e', 'e.id', '=', 'r.expense_id');
|
||||
|
||||
if (! empty($schoolYear)) {
|
||||
if (!empty($schoolYear)) {
|
||||
$query->where(function ($q) use ($schoolYear, $normalizedYear) {
|
||||
$q->where(function ($q2) use ($schoolYear, $normalizedYear) {
|
||||
$q2->where('r.school_year', $schoolYear)
|
||||
@@ -304,10 +298,10 @@ class FinancialSummaryService
|
||||
});
|
||||
}
|
||||
|
||||
if (! empty($dateFrom)) {
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(COALESCE(r.created_at, e.created_at)) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (! empty($dateTo)) {
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(COALESCE(r.created_at, e.created_at)) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
@@ -323,7 +317,7 @@ class FinancialSummaryService
|
||||
->whereNull('bi.unassigned_at')
|
||||
->whereNull('r.id');
|
||||
|
||||
if (! empty($schoolYear)) {
|
||||
if (!empty($schoolYear)) {
|
||||
$batchFallback->where(function ($q) use ($schoolYear, $normalizedYear) {
|
||||
$q->where('e.school_year', $schoolYear)
|
||||
->orWhereRaw(
|
||||
@@ -333,10 +327,10 @@ class FinancialSummaryService
|
||||
});
|
||||
}
|
||||
|
||||
if (! empty($dateFrom)) {
|
||||
if (!empty($dateFrom)) {
|
||||
$batchFallback->whereRaw('DATE(COALESCE(e.created_at, b.closed_at)) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (! empty($dateTo)) {
|
||||
if (!empty($dateTo)) {
|
||||
$batchFallback->whereRaw('DATE(COALESCE(e.created_at, b.closed_at)) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
@@ -352,10 +346,10 @@ class FinancialSummaryService
|
||||
->where('school_year', $schoolYear)
|
||||
->where('category', 'Donation');
|
||||
|
||||
if (! empty($dateFrom)) {
|
||||
if (!empty($dateFrom)) {
|
||||
$expenseQuery->whereRaw('DATE(created_at) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (! empty($dateTo)) {
|
||||
if (!empty($dateTo)) {
|
||||
$expenseQuery->whereRaw('DATE(created_at) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
@@ -364,14 +358,14 @@ class FinancialSummaryService
|
||||
|
||||
$donationReimb = 0.0;
|
||||
$recipientIds = $this->donationRecipients->recipientIds();
|
||||
if (! empty($recipientIds)) {
|
||||
if (!empty($recipientIds)) {
|
||||
$normalizedYear = preg_replace('/[^0-9]/', '', (string) $schoolYear);
|
||||
$donationQuery = DB::table('reimbursements as r')
|
||||
->selectRaw('COALESCE(SUM(r.amount),0) AS amount')
|
||||
->leftJoin('expenses as e', 'e.id', '=', 'r.expense_id')
|
||||
->whereIn('r.reimbursed_to', $recipientIds);
|
||||
|
||||
if (! empty($schoolYear)) {
|
||||
if (!empty($schoolYear)) {
|
||||
$donationQuery->where(function ($q) use ($schoolYear, $normalizedYear) {
|
||||
$q->where(function ($q2) use ($schoolYear, $normalizedYear) {
|
||||
$q2->where('r.school_year', $schoolYear)
|
||||
@@ -392,10 +386,10 @@ class FinancialSummaryService
|
||||
});
|
||||
}
|
||||
|
||||
if (! empty($dateFrom)) {
|
||||
if (!empty($dateFrom)) {
|
||||
$donationQuery->whereRaw('DATE(COALESCE(r.created_at, e.created_at)) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (! empty($dateTo)) {
|
||||
if (!empty($dateTo)) {
|
||||
$donationQuery->whereRaw('DATE(COALESCE(r.created_at, e.created_at)) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
@@ -416,15 +410,14 @@ class FinancialSummaryService
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->whereNotNull('refund_paid_amount');
|
||||
|
||||
if (! empty($dateFrom)) {
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(COALESCE(refunded_at, created_at)) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (! empty($dateTo)) {
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(COALESCE(refunded_at, created_at)) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
$row = (array) $query->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS refund_paid_amount')->first();
|
||||
|
||||
return (float) ($row['refund_paid_amount'] ?? 0);
|
||||
}
|
||||
|
||||
@@ -434,15 +427,14 @@ class FinancialSummaryService
|
||||
->join('invoices as i', 'i.id', '=', 'du.invoice_id')
|
||||
->where('i.school_year', $schoolYear);
|
||||
|
||||
if (! empty($dateFrom)) {
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(COALESCE(du.used_at, du.created_at)) >= ?', [$dateFrom]);
|
||||
}
|
||||
if (! empty($dateTo)) {
|
||||
if (!empty($dateTo)) {
|
||||
$query->whereRaw('DATE(COALESCE(du.used_at, du.created_at)) <= ?', [$dateTo]);
|
||||
}
|
||||
|
||||
$row = (array) $query->selectRaw('COALESCE(SUM(du.discount_amount),0) AS discount_amount')->first();
|
||||
|
||||
return (float) ($row['discount_amount'] ?? 0);
|
||||
}
|
||||
|
||||
@@ -462,14 +454,14 @@ class FinancialSummaryService
|
||||
$ref = (float) ($refundByInvoice[$invoiceId] ?? 0);
|
||||
$balance = max(0.0, round($total - $disc - $paid - $ref, 2));
|
||||
|
||||
if (! isset($parentBalances[$parentId])) {
|
||||
if (!isset($parentBalances[$parentId])) {
|
||||
$parentBalances[$parentId] = 0.0;
|
||||
}
|
||||
$parentBalances[$parentId] += $balance;
|
||||
}
|
||||
|
||||
foreach ($extraByParent as $parentId => $extra) {
|
||||
if (! isset($parentBalances[$parentId])) {
|
||||
if (!isset($parentBalances[$parentId])) {
|
||||
$parentBalances[$parentId] = 0.0;
|
||||
}
|
||||
$parentBalances[$parentId] += (float) $extra;
|
||||
|
||||
@@ -8,7 +8,9 @@ use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class FinancialUnpaidParentsService
|
||||
{
|
||||
public function __construct(private FinancialSchoolYearService $schoolYears) {}
|
||||
public function __construct(private FinancialSchoolYearService $schoolYears)
|
||||
{
|
||||
}
|
||||
|
||||
public function getUnpaidParents(?string $schoolYear): array
|
||||
{
|
||||
@@ -74,7 +76,7 @@ class FinancialUnpaidParentsService
|
||||
$total = (float) ($row['total_amount'] ?? 0);
|
||||
$balance = max(0.0, round($total - $discSum - $paidSum - $refSum, 2));
|
||||
|
||||
if (! isset($byParent[$parentId])) {
|
||||
if (!isset($byParent[$parentId])) {
|
||||
$byParent[$parentId] = [
|
||||
'parent_id' => $parentId,
|
||||
'firstname' => (string) ($row['firstname'] ?? ''),
|
||||
@@ -94,7 +96,7 @@ class FinancialUnpaidParentsService
|
||||
}
|
||||
|
||||
$extraRows = DB::table('additional_charges as ac')
|
||||
->selectRaw('ac.parent_id, u.firstname, u.lastname, u.email, COALESCE(SUM(ac.amount), 0) AS total_extra')
|
||||
->selectRaw("ac.parent_id, u.firstname, u.lastname, u.email, COALESCE(SUM(ac.amount), 0) AS total_extra")
|
||||
->leftJoin('users as u', 'u.id', '=', 'ac.parent_id')
|
||||
->where('ac.school_year', $schoolYear)
|
||||
->where('ac.status', '!=', 'void')
|
||||
@@ -117,7 +119,7 @@ class FinancialUnpaidParentsService
|
||||
if (abs($extra) < 0.00001) {
|
||||
continue;
|
||||
}
|
||||
if (! isset($byParent[$parentId])) {
|
||||
if (!isset($byParent[$parentId])) {
|
||||
$byParent[$parentId] = [
|
||||
'parent_id' => $parentId,
|
||||
'firstname' => (string) ($row['firstname'] ?? ''),
|
||||
@@ -148,8 +150,8 @@ class FinancialUnpaidParentsService
|
||||
}
|
||||
}
|
||||
|
||||
$monthsUntil = static function (?\DateTimeImmutable $end) use ($today): int {
|
||||
if (! $end) {
|
||||
$monthsUntil = static function (? \DateTimeImmutable $end) use ($today): int {
|
||||
if (!$end) {
|
||||
return 0;
|
||||
}
|
||||
$y = (int) $end->format('Y') - (int) $today->format('Y');
|
||||
@@ -158,7 +160,6 @@ class FinancialUnpaidParentsService
|
||||
if ((int) $end->format('j') > (int) $today->format('j')) {
|
||||
$months += 1;
|
||||
}
|
||||
|
||||
return max(0, $months);
|
||||
};
|
||||
|
||||
@@ -194,7 +195,7 @@ class FinancialUnpaidParentsService
|
||||
$hasPayments = [];
|
||||
$paidTotals = [];
|
||||
$paymentCounts = [];
|
||||
if (! empty($parentIds)) {
|
||||
if (!empty($parentIds)) {
|
||||
$pRows = DB::table('payments')
|
||||
->selectRaw('parent_id, SUM(paid_amount) AS total_paid, COUNT(*) AS payment_count')
|
||||
->whereIn('parent_id', $parentIds)
|
||||
@@ -216,8 +217,7 @@ class FinancialUnpaidParentsService
|
||||
|
||||
$results = array_map(function (array $row) use ($hasPayments, $paidTotals, $paymentCounts, $nextInstallment) {
|
||||
$parentId = (int) ($row['parent_id'] ?? 0);
|
||||
$name = trim((string) ($row['firstname'] ?? '').' '.(string) ($row['lastname'] ?? ''));
|
||||
|
||||
$name = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? ''));
|
||||
return [
|
||||
'parent_id' => $parentId,
|
||||
'parent_name' => $name,
|
||||
|
||||
@@ -7,33 +7,31 @@ use App\Models\InvoiceInstallmentPlan;
|
||||
use App\Models\PaymentInstallmentAllocation;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class InstallmentPlanService
|
||||
{
|
||||
public function list(array $filters): array
|
||||
{
|
||||
$q = InvoiceInstallmentPlan::query();
|
||||
foreach (['invoice_id', 'parent_id', 'school_year', 'status'] as $field) {
|
||||
if (! empty($filters[$field])) {
|
||||
$q->where($field, $filters[$field]);
|
||||
}
|
||||
foreach (['invoice_id','parent_id','school_year','status'] as $field) {
|
||||
if (!empty($filters[$field])) $q->where($field, $filters[$field]);
|
||||
}
|
||||
|
||||
return ['rows' => $q->orderByDesc('id')->paginate((int) ($filters['per_page'] ?? 25))];
|
||||
return ['rows' => $q->orderByDesc('id')->paginate((int)($filters['per_page'] ?? 25))];
|
||||
}
|
||||
|
||||
public function createForInvoice(int $invoiceId, array $payload, ?int $actorId): array
|
||||
{
|
||||
return DB::transaction(function () use ($invoiceId, $payload, $actorId) {
|
||||
$invoice = DB::table('invoices')->where('id', $invoiceId)->first();
|
||||
abort_if(! $invoice, 404, 'Invoice not found.');
|
||||
abort_if(!$invoice, 404, 'Invoice not found.');
|
||||
$total = (float) ($payload['total_amount'] ?? ($invoice->balance ?? $invoice->total_amount ?? 0));
|
||||
$count = (int) $payload['number_of_installments'];
|
||||
$plan = InvoiceInstallmentPlan::query()->create([
|
||||
'invoice_id' => $invoiceId,
|
||||
'parent_id' => $invoice->parent_id,
|
||||
'school_year' => $invoice->school_year ?? null,
|
||||
'plan_name' => $payload['plan_name'] ?? ('Invoice #'.$invoiceId.' installment plan'),
|
||||
'plan_name' => $payload['plan_name'] ?? ('Invoice #' . $invoiceId . ' installment plan'),
|
||||
'total_amount' => $total,
|
||||
'number_of_installments' => $count,
|
||||
'status' => 'draft',
|
||||
@@ -67,7 +65,6 @@ class InstallmentPlanService
|
||||
{
|
||||
$plan = InvoiceInstallmentPlan::query()->findOrFail($id)->toArray();
|
||||
$plan['installments'] = InvoiceInstallment::query()->where('installment_plan_id', $id)->orderBy('sequence')->get()->toArray();
|
||||
|
||||
return $plan;
|
||||
}
|
||||
|
||||
@@ -76,7 +73,6 @@ class InstallmentPlanService
|
||||
$plan = InvoiceInstallmentPlan::query()->findOrFail($id);
|
||||
$plan->fill(['status' => 'active', 'approved_by' => $actorId])->save();
|
||||
InvoiceInstallment::query()->where('installment_plan_id', $id)->where('status', 'pending')->update(['status' => 'due']);
|
||||
|
||||
return $this->show($id);
|
||||
}
|
||||
|
||||
@@ -84,7 +80,6 @@ class InstallmentPlanService
|
||||
{
|
||||
InvoiceInstallmentPlan::query()->whereKey($id)->update(['status' => 'cancelled']);
|
||||
InvoiceInstallment::query()->where('installment_plan_id', $id)->whereNotIn('status', ['paid'])->update(['status' => 'cancelled']);
|
||||
|
||||
return $this->show($id);
|
||||
}
|
||||
|
||||
@@ -92,16 +87,14 @@ class InstallmentPlanService
|
||||
{
|
||||
return DB::transaction(function () use ($paymentId, $payload) {
|
||||
$payment = DB::table('payments')->where('id', $paymentId)->first();
|
||||
abort_if(! $payment, 404, 'Payment not found.');
|
||||
abort_if(!$payment, 404, 'Payment not found.');
|
||||
$allocations = $payload['installments'] ?? null;
|
||||
if (! $allocations) {
|
||||
if (!$allocations) {
|
||||
$remaining = (float) ($payment->paid_amount ?? 0);
|
||||
$installments = InvoiceInstallment::query()->where('invoice_id', $payment->invoice_id)->where('balance', '>', 0)->orderBy('due_date')->lockForUpdate()->get();
|
||||
$allocations = [];
|
||||
foreach ($installments as $inst) {
|
||||
if ($remaining <= 0) {
|
||||
break;
|
||||
}
|
||||
if ($remaining <= 0) break;
|
||||
$amount = min($remaining, (float) $inst->balance);
|
||||
$allocations[] = ['installment_id' => $inst->id, 'amount' => $amount];
|
||||
$remaining -= $amount;
|
||||
@@ -112,9 +105,7 @@ class InstallmentPlanService
|
||||
foreach ($allocations as $a) {
|
||||
$inst = InvoiceInstallment::query()->lockForUpdate()->findOrFail($a['installment_id']);
|
||||
$amount = min((float) $a['amount'], (float) $inst->balance);
|
||||
if ($amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
if ($amount <= 0) continue;
|
||||
$created[] = PaymentInstallmentAllocation::query()->create([
|
||||
'payment_id' => $paymentId,
|
||||
'invoice_id' => $inst->invoice_id,
|
||||
@@ -127,7 +118,6 @@ class InstallmentPlanService
|
||||
$inst->paid_at = $inst->balance <= 0 ? now() : null;
|
||||
$inst->save();
|
||||
}
|
||||
|
||||
return ['allocations' => $created];
|
||||
});
|
||||
}
|
||||
@@ -136,12 +126,11 @@ class InstallmentPlanService
|
||||
{
|
||||
$q = InvoiceInstallment::query()->where('balance', '>', 0);
|
||||
$overdue ? $q->whereDate('due_date', '<', now()->toDateString()) : $q->whereDate('due_date', '>=', now()->toDateString());
|
||||
|
||||
return ['rows' => $q->orderBy('due_date')->get()->toArray()];
|
||||
}
|
||||
|
||||
public function markOverdue(): int
|
||||
{
|
||||
return InvoiceInstallment::query()->where('balance', '>', 0)->whereDate('due_date', '<', now()->toDateString())->whereNotIn('status', ['paid', 'cancelled', 'waived'])->update(['status' => 'overdue']);
|
||||
return InvoiceInstallment::query()->where('balance', '>', 0)->whereDate('due_date', '<', now()->toDateString())->whereNotIn('status', ['paid','cancelled','waived'])->update(['status' => 'overdue']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Services\Finance;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\FinanceFollowUpNote;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
@@ -36,11 +35,11 @@ class ParentPaymentFollowUpService
|
||||
$discounted = (float) ($discountByInvoice[$invoiceId] ?? 0);
|
||||
$refunded = (float) ($refundByInvoice[$invoiceId] ?? 0);
|
||||
$open = max(0, $total - $paid - $discounted - $refunded);
|
||||
if (! $includePaid && $open <= 0) {
|
||||
if (!$includePaid && $open <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! isset($parents[$parentId])) {
|
||||
if (!isset($parents[$parentId])) {
|
||||
$parents[$parentId] = [
|
||||
'parent_id' => $parentId,
|
||||
'parent_name' => $invoice->parent_name ?? null,
|
||||
@@ -72,7 +71,7 @@ class ParentPaymentFollowUpService
|
||||
];
|
||||
}
|
||||
|
||||
$row = &$parents[$parentId];
|
||||
$row =& $parents[$parentId];
|
||||
$row['invoice_count']++;
|
||||
$row['total_invoiced'] += $total;
|
||||
$row['total_paid'] += $paid;
|
||||
@@ -100,7 +99,7 @@ class ParentPaymentFollowUpService
|
||||
$row['promise_to_pay_amount'] = $note->promise_to_pay_amount !== null ? (float) $note->promise_to_pay_amount : null;
|
||||
$row['escalation_level'] = (int) ($note->escalation_level ?? 0);
|
||||
}
|
||||
$row['notes_count'] = FinanceFollowUpNote::query()->where('parent_id', $parentId)->when($schoolYear, fn ($q) => $q->where('school_year', $schoolYear))->count();
|
||||
$row['notes_count'] = FinanceFollowUpNote::query()->where('parent_id', $parentId)->when($schoolYear, fn($q) => $q->where('school_year', $schoolYear))->count();
|
||||
$row['days_overdue'] = $this->daysOverdue($row['oldest_unpaid_invoice_date']);
|
||||
$row['aging_bucket'] = $this->agingBucket($row['days_overdue']);
|
||||
$row['risk_flags'] = $this->riskFlags($row);
|
||||
@@ -108,26 +107,15 @@ class ParentPaymentFollowUpService
|
||||
unset($row);
|
||||
|
||||
$rows = array_values(array_filter($parents, function (array $row) use ($filters, $minimumBalance) {
|
||||
if ($row['open_balance'] < $minimumBalance) {
|
||||
return false;
|
||||
}
|
||||
if (! empty($filters['aging_bucket']) && $row['aging_bucket'] !== $filters['aging_bucket']) {
|
||||
return false;
|
||||
}
|
||||
if (! empty($filters['follow_up_status']) && $row['follow_up_status'] !== $filters['follow_up_status']) {
|
||||
return false;
|
||||
}
|
||||
if (! empty($filters['next_follow_up_due_before']) && (empty($row['next_follow_up_date']) || strtotime($row['next_follow_up_date']) > strtotime($filters['next_follow_up_due_before']))) {
|
||||
return false;
|
||||
}
|
||||
if (! empty($filters['promise_to_pay_due_before']) && (empty($row['promise_to_pay_date']) || strtotime($row['promise_to_pay_date']) > strtotime($filters['promise_to_pay_due_before']))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($row['open_balance'] < $minimumBalance) return false;
|
||||
if (!empty($filters['aging_bucket']) && $row['aging_bucket'] !== $filters['aging_bucket']) return false;
|
||||
if (!empty($filters['follow_up_status']) && $row['follow_up_status'] !== $filters['follow_up_status']) return false;
|
||||
if (!empty($filters['next_follow_up_due_before']) && (empty($row['next_follow_up_date']) || strtotime($row['next_follow_up_date']) > strtotime($filters['next_follow_up_due_before']))) return false;
|
||||
if (!empty($filters['promise_to_pay_due_before']) && (empty($row['promise_to_pay_date']) || strtotime($row['promise_to_pay_date']) > strtotime($filters['promise_to_pay_due_before']))) return false;
|
||||
return true;
|
||||
}));
|
||||
|
||||
usort($rows, fn ($a, $b) => [$b['open_balance'], $b['days_overdue']] <=> [$a['open_balance'], $a['days_overdue']]);
|
||||
usort($rows, fn($a, $b) => [$b['open_balance'], $b['days_overdue']] <=> [$a['open_balance'], $a['days_overdue']]);
|
||||
|
||||
return ['schoolYear' => $schoolYear, 'generatedAt' => now()->toISOString(), 'results' => $rows];
|
||||
}
|
||||
@@ -153,78 +141,56 @@ class ParentPaymentFollowUpService
|
||||
|
||||
public function csvRows(array $report): array
|
||||
{
|
||||
$rows = [['Parent ID', 'Parent', 'Email', 'Phone', 'Students', 'School Year', 'Invoices', 'Open Invoices', 'Total Invoiced', 'Total Paid', 'Open Balance', 'Oldest Unpaid', 'Days Overdue', 'Aging', 'Status', 'Next Follow-Up', 'Promise Date', 'Promise Amount', 'Risk Flags']];
|
||||
$rows = [['Parent ID','Parent','Email','Phone','Students','School Year','Invoices','Open Invoices','Total Invoiced','Total Paid','Open Balance','Oldest Unpaid','Days Overdue','Aging','Status','Next Follow-Up','Promise Date','Promise Amount','Risk Flags']];
|
||||
foreach ($report['results'] as $r) {
|
||||
$rows[] = [
|
||||
$r['parent_id'], $r['parent_name'], $r['parent_email'], $r['parent_phone'], implode('; ', $r['student_names'] ?? []), $r['school_year'], $r['invoice_count'], $r['open_invoice_count'], $r['total_invoiced'], $r['total_paid'], $r['open_balance'], $r['oldest_unpaid_invoice_date'], $r['days_overdue'], $r['aging_bucket'], $r['follow_up_status'], $r['next_follow_up_date'], $r['promise_to_pay_date'], $r['promise_to_pay_amount'], implode('; ', $r['risk_flags'] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function invoiceRows(string $schoolYear, array $filters)
|
||||
{
|
||||
$q = DB::table('invoices')->where('invoices.school_year', $schoolYear);
|
||||
if (! empty($filters['parent_id'])) {
|
||||
$q->where('invoices.parent_id', $filters['parent_id']);
|
||||
}
|
||||
if (! empty($filters['date_from'])) {
|
||||
$q->whereDate('invoices.created_at', '>=', $filters['date_from']);
|
||||
}
|
||||
if (! empty($filters['date_to'])) {
|
||||
$q->whereDate('invoices.created_at', '<=', $filters['date_to']);
|
||||
}
|
||||
if (!empty($filters['parent_id'])) $q->where('invoices.parent_id', $filters['parent_id']);
|
||||
if (!empty($filters['date_from'])) $q->whereDate('invoices.created_at', '>=', $filters['date_from']);
|
||||
if (!empty($filters['date_to'])) $q->whereDate('invoices.created_at', '<=', $filters['date_to']);
|
||||
if (Schema::hasTable('users')) {
|
||||
$name = $this->nameExpression('users');
|
||||
$q->leftJoin('users', 'users.id', '=', 'invoices.parent_id')
|
||||
->addSelect('invoices.*')
|
||||
->addSelect(DB::raw($name.' as parent_name'))
|
||||
->addSelect('users.email as parent_email');
|
||||
if (Schema::hasColumn('users', 'phone')) {
|
||||
$q->addSelect('users.phone as parent_phone');
|
||||
} else {
|
||||
$q->addSelect(DB::raw('NULL as parent_phone'));
|
||||
}
|
||||
->addSelect('invoices.*')
|
||||
->addSelect(DB::raw($name . ' as parent_name'))
|
||||
->addSelect('users.email as parent_email');
|
||||
if (Schema::hasColumn('users', 'phone')) $q->addSelect('users.phone as parent_phone');
|
||||
else $q->addSelect(DB::raw('NULL as parent_phone'));
|
||||
} else {
|
||||
$q->select('invoices.*', DB::raw('NULL as parent_name'), DB::raw('NULL as parent_email'), DB::raw('NULL as parent_phone'));
|
||||
}
|
||||
|
||||
return $q->get();
|
||||
}
|
||||
|
||||
private function paymentTotalsByInvoice(string $schoolYear): array
|
||||
{
|
||||
if (! Schema::hasTable('payments')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Schema::hasTable('payments')) return [];
|
||||
return DB::table('payments')->select('invoice_id', DB::raw('COALESCE(SUM(paid_amount),0) total'), DB::raw('MAX(payment_date) last_payment_date'))
|
||||
->where('school_year', $schoolYear)->whereNotIn('status', ['void', 'voided', 'failed', 'cancelled', 'canceled', 'reversed'])
|
||||
->groupBy('invoice_id')->pluck('total', 'invoice_id')->map(fn ($v) => (float) $v)->all();
|
||||
->where('school_year', $schoolYear)->whereNotIn('status', ['void','voided','failed','cancelled','canceled','reversed'])
|
||||
->groupBy('invoice_id')->pluck('total', 'invoice_id')->map(fn($v) => (float) $v)->all();
|
||||
}
|
||||
|
||||
private function discountTotalsByInvoice(): array
|
||||
{
|
||||
if (! Schema::hasTable('discount_usages')) {
|
||||
return [];
|
||||
}
|
||||
if (!Schema::hasTable('discount_usages')) return [];
|
||||
$col = Schema::hasColumn('discount_usages', 'discount_amount') ? 'discount_amount' : 'amount';
|
||||
|
||||
return DB::table('discount_usages')->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->groupBy('invoice_id')->pluck('total', 'invoice_id')->map(fn ($v) => (float) $v)->all();
|
||||
return DB::table('discount_usages')->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all();
|
||||
}
|
||||
|
||||
private function refundTotalsByInvoice(): array
|
||||
{
|
||||
if (! Schema::hasTable('refunds')) {
|
||||
return [];
|
||||
}
|
||||
if (!Schema::hasTable('refunds')) return [];
|
||||
$col = Schema::hasColumn('refunds', 'refund_amount') ? 'refund_amount' : (Schema::hasColumn('refunds', 'amount') ? 'amount' : null);
|
||||
if (! $col) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table('refunds')->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->whereNotIn('status', ['void', 'voided', 'cancelled', 'canceled'])->groupBy('invoice_id')->pluck('total', 'invoice_id')->map(fn ($v) => (float) $v)->all();
|
||||
if (!$col) return [];
|
||||
return DB::table('refunds')->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->whereNotIn('status', ['void','voided','cancelled','canceled'])->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all();
|
||||
}
|
||||
|
||||
private function latestNotesByParent(string $schoolYear): array
|
||||
@@ -234,66 +200,40 @@ class ParentPaymentFollowUpService
|
||||
|
||||
private function studentsByParent(): array
|
||||
{
|
||||
if (! Schema::hasTable('students') || ! Schema::hasColumn('students', 'parent_id')) {
|
||||
return [];
|
||||
}
|
||||
if (!Schema::hasTable('students') || !Schema::hasColumn('students', 'parent_id')) return [];
|
||||
$name = $this->nameExpression('students');
|
||||
|
||||
return DB::table('students')->select('parent_id', DB::raw($name.' as student_name'))->whereNotNull('parent_id')->get()->groupBy('parent_id')->map(fn ($rows) => $rows->pluck('student_name')->filter()->values()->all())->all();
|
||||
return DB::table('students')->select('parent_id', DB::raw($name.' as student_name'))->whereNotNull('parent_id')->get()->groupBy('parent_id')->map(fn($rows) => $rows->pluck('student_name')->filter()->values()->all())->all();
|
||||
}
|
||||
|
||||
private function nameExpression(string $table): string
|
||||
{
|
||||
if (Schema::hasColumn($table, 'name')) {
|
||||
return $table.'.name';
|
||||
}
|
||||
if (Schema::hasColumn($table, 'full_name')) {
|
||||
return $table.'.full_name';
|
||||
}
|
||||
if (Schema::hasColumn($table, 'name')) return $table.'.name';
|
||||
if (Schema::hasColumn($table, 'full_name')) return $table.'.full_name';
|
||||
$driver = DB::connection()->getDriverName();
|
||||
$concat = $driver === 'sqlite' ? '%s || \' \' || %s' : "CONCAT(%s, ' ', %s)";
|
||||
if (Schema::hasColumn($table, 'firstname') && Schema::hasColumn($table, 'lastname')) {
|
||||
return sprintf($concat, $table.'.firstname', $table.'.lastname');
|
||||
}
|
||||
if (Schema::hasColumn($table, 'first_name') && Schema::hasColumn($table, 'last_name')) {
|
||||
return sprintf($concat, $table.'.first_name', $table.'.last_name');
|
||||
}
|
||||
|
||||
if (Schema::hasColumn($table, 'firstname') && Schema::hasColumn($table, 'lastname')) return sprintf($concat, $table.'.firstname', $table.'.lastname');
|
||||
if (Schema::hasColumn($table, 'first_name') && Schema::hasColumn($table, 'last_name')) return sprintf($concat, $table.'.first_name', $table.'.last_name');
|
||||
return "CAST($table.id AS CHAR)";
|
||||
}
|
||||
|
||||
private function daysOverdue(?string $date): int
|
||||
{
|
||||
if (! $date) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return max(0, now()->startOfDay()->diffInDays(Carbon::parse($date)->startOfDay(), false) * -1);
|
||||
if (!$date) return 0;
|
||||
return max(0, now()->startOfDay()->diffInDays(\Carbon\Carbon::parse($date)->startOfDay(), false) * -1);
|
||||
}
|
||||
|
||||
private function agingBucket(int $days): string
|
||||
{
|
||||
return match (true) {
|
||||
$days <= 0 => 'current', $days <= 30 => '1-30 days overdue', $days <= 60 => '31-60 days overdue', $days <= 90 => '61-90 days overdue', default => '90+ days overdue'
|
||||
};
|
||||
return match (true) { $days <= 0 => 'current', $days <= 30 => '1-30 days overdue', $days <= 60 => '31-60 days overdue', $days <= 90 => '61-90 days overdue', default => '90+ days overdue' };
|
||||
}
|
||||
|
||||
private function riskFlags(array $row): array
|
||||
{
|
||||
$flags = [];
|
||||
if ($row['days_overdue'] > 90) {
|
||||
$flags[] = '90+ days overdue';
|
||||
}
|
||||
if ($row['open_balance'] > 1000) {
|
||||
$flags[] = 'high balance';
|
||||
}
|
||||
if ($row['promise_to_pay_date'] && strtotime($row['promise_to_pay_date']) < strtotime(date('Y-m-d'))) {
|
||||
$flags[] = 'missed promise-to-pay';
|
||||
}
|
||||
if (! $row['parent_email'] && ! $row['parent_phone']) {
|
||||
$flags[] = 'missing contact info';
|
||||
}
|
||||
|
||||
if ($row['days_overdue'] > 90) $flags[] = '90+ days overdue';
|
||||
if ($row['open_balance'] > 1000) $flags[] = 'high balance';
|
||||
if ($row['promise_to_pay_date'] && strtotime($row['promise_to_pay_date']) < strtotime(date('Y-m-d'))) $flags[] = 'missed promise-to-pay';
|
||||
if (!$row['parent_email'] && !$row['parent_phone']) $flags[] = 'missing contact info';
|
||||
return $flags;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,20 +20,16 @@ class PriorYearBalanceCarryforwardService
|
||||
$refunds = $this->refundTotals();
|
||||
|
||||
$q = DB::table('invoices')->where('school_year', $from);
|
||||
if ($parentId) {
|
||||
$q->where('parent_id', $parentId);
|
||||
}
|
||||
if ($parentId) $q->where('parent_id', $parentId);
|
||||
$invoices = $q->get();
|
||||
|
||||
$byParent = [];
|
||||
foreach ($invoices as $invoice) {
|
||||
$invoiceId = (int) $invoice->id;
|
||||
$balance = max(0, (float) ($invoice->total_amount ?? 0) - (float) ($payments[$invoiceId] ?? 0) - (float) ($discounts[$invoiceId] ?? 0) - (float) ($refunds[$invoiceId] ?? 0));
|
||||
if ($balance <= 0) {
|
||||
continue;
|
||||
}
|
||||
if ($balance <= 0) continue;
|
||||
$pid = (int) $invoice->parent_id;
|
||||
if (! isset($byParent[$pid])) {
|
||||
if (!isset($byParent[$pid])) {
|
||||
$existing = FinanceBalanceCarryforward::query()->where('parent_id', $pid)->where('from_school_year', $from)->where('to_school_year', $to)->first();
|
||||
$byParent[$pid] = [
|
||||
'parent_id' => $pid,
|
||||
@@ -45,17 +41,15 @@ class PriorYearBalanceCarryforwardService
|
||||
'existing_carryforward_id' => $existing?->id,
|
||||
'warnings' => [],
|
||||
];
|
||||
if ($existing) {
|
||||
$byParent[$pid]['warnings'][] = 'source invoice already carried forward';
|
||||
}
|
||||
if ($existing) $byParent[$pid]['warnings'][] = 'source invoice already carried forward';
|
||||
}
|
||||
$byParent[$pid]['source_invoice_ids'][] = $invoiceId;
|
||||
$byParent[$pid]['source_balance_amount'] += $balance;
|
||||
$byParent[$pid]['proposed_carryforward_amount'] += $balance;
|
||||
}
|
||||
|
||||
$rows = array_values(array_filter($byParent, fn ($r) => $r['source_balance_amount'] >= $minimum));
|
||||
usort($rows, fn ($a, $b) => $b['source_balance_amount'] <=> $a['source_balance_amount']);
|
||||
$rows = array_values(array_filter($byParent, fn($r) => $r['source_balance_amount'] >= $minimum));
|
||||
usort($rows, fn($a, $b) => $b['source_balance_amount'] <=> $a['source_balance_amount']);
|
||||
|
||||
return ['fromSchoolYear' => $from, 'toSchoolYear' => $to, 'generatedAt' => now()->toISOString(), 'rows' => $rows];
|
||||
}
|
||||
@@ -65,9 +59,7 @@ class PriorYearBalanceCarryforwardService
|
||||
$preview = $this->preview($filters);
|
||||
$created = [];
|
||||
foreach ($preview['rows'] as $row) {
|
||||
if (! empty($row['existing_carryforward_id'])) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($row['existing_carryforward_id'])) continue;
|
||||
$created[] = FinanceBalanceCarryforward::query()->create([
|
||||
'parent_id' => $row['parent_id'],
|
||||
'from_school_year' => $row['from_school_year'],
|
||||
@@ -82,7 +74,6 @@ class PriorYearBalanceCarryforwardService
|
||||
'metadata_json' => ['warnings' => $row['warnings']],
|
||||
])->toArray();
|
||||
}
|
||||
|
||||
return ['created' => $created, 'skipped' => count($preview['rows']) - count($created)];
|
||||
}
|
||||
|
||||
@@ -90,7 +81,6 @@ class PriorYearBalanceCarryforwardService
|
||||
{
|
||||
$cf = FinanceBalanceCarryforward::query()->findOrFail($id);
|
||||
$cf->fill(['status' => 'approved', 'approved_by' => $actorId, 'approved_at' => now()])->save();
|
||||
|
||||
return $cf->fresh()->toArray();
|
||||
}
|
||||
|
||||
@@ -104,7 +94,6 @@ class PriorYearBalanceCarryforwardService
|
||||
$cf->reason = $reason ?: $cf->reason;
|
||||
$cf->approved_by = $cf->approved_by ?: $actorId;
|
||||
$cf->save();
|
||||
|
||||
return $cf->fresh()->toArray();
|
||||
}
|
||||
|
||||
@@ -115,7 +104,6 @@ class PriorYearBalanceCarryforwardService
|
||||
$cf->remaining_amount = max(0, (float) $cf->carryforward_amount - (float) $cf->waived_amount + $amount);
|
||||
$cf->reason = $reason ?: $cf->reason;
|
||||
$cf->save();
|
||||
|
||||
return $cf->fresh()->toArray();
|
||||
}
|
||||
|
||||
@@ -123,7 +111,7 @@ class PriorYearBalanceCarryforwardService
|
||||
{
|
||||
return DB::transaction(function () use ($id, $actorId) {
|
||||
$cf = FinanceBalanceCarryforward::query()->lockForUpdate()->findOrFail($id);
|
||||
if (! in_array($cf->status, ['approved', 'draft', 'pending_review'], true)) {
|
||||
if (!in_array($cf->status, ['approved','draft','pending_review'], true)) {
|
||||
return $cf->toArray() + ['warning' => 'Carryforward is not in a postable status.'];
|
||||
}
|
||||
|
||||
@@ -132,14 +120,14 @@ class PriorYearBalanceCarryforwardService
|
||||
if (Schema::hasTable('invoices')) {
|
||||
$invoiceId = DB::table('invoices')->insertGetId([
|
||||
'parent_id' => $cf->parent_id,
|
||||
'invoice_number' => 'CF-'.$cf->to_school_year.'-'.$cf->id,
|
||||
'invoice_number' => 'CF-' . $cf->to_school_year . '-' . $cf->id,
|
||||
'total_amount' => $amount,
|
||||
'balance' => $amount,
|
||||
'paid_amount' => 0,
|
||||
'school_year' => $cf->to_school_year,
|
||||
'issue_date' => now()->toDateString(),
|
||||
'status' => $amount > 0 ? 'unpaid' : 'paid',
|
||||
'description' => 'Previous year balance from '.$cf->from_school_year,
|
||||
'description' => 'Previous year balance from ' . $cf->from_school_year,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'updated_by' => $actorId,
|
||||
@@ -149,7 +137,6 @@ class PriorYearBalanceCarryforwardService
|
||||
$cf->status = 'posted_to_new_year';
|
||||
$cf->remaining_amount = $amount;
|
||||
$cf->save();
|
||||
|
||||
return $cf->fresh()->toArray();
|
||||
});
|
||||
}
|
||||
@@ -157,54 +144,37 @@ class PriorYearBalanceCarryforwardService
|
||||
public function report(array $filters): array
|
||||
{
|
||||
$q = FinanceBalanceCarryforward::query();
|
||||
foreach (['from_school_year', 'to_school_year', 'parent_id', 'status'] as $field) {
|
||||
if (! empty($filters[$field])) {
|
||||
$q->where($field, $filters[$field]);
|
||||
}
|
||||
foreach (['from_school_year','to_school_year','parent_id','status'] as $field) {
|
||||
if (!empty($filters[$field])) $q->where($field, $filters[$field]);
|
||||
}
|
||||
|
||||
return ['generatedAt' => now()->toISOString(), 'rows' => $q->orderByDesc('remaining_amount')->get()->toArray()];
|
||||
}
|
||||
|
||||
public function csvRows(array $report): array
|
||||
{
|
||||
$rows = [['ID', 'Parent ID', 'From Year', 'To Year', 'Source Invoices', 'Source Balance', 'Carryforward', 'Waived', 'Adjusted', 'Remaining', 'Status', 'Posted Invoice']];
|
||||
$rows = [['ID','Parent ID','From Year','To Year','Source Invoices','Source Balance','Carryforward','Waived','Adjusted','Remaining','Status','Posted Invoice']];
|
||||
foreach ($report['rows'] as $r) {
|
||||
$rows[] = [$r['id'], $r['parent_id'], $r['from_school_year'], $r['to_school_year'], json_encode($r['source_invoice_ids_json'] ?? []), $r['source_balance_amount'], $r['carryforward_amount'], $r['waived_amount'], $r['adjusted_amount'], $r['remaining_amount'], $r['status'], $r['posted_invoice_id'] ?? null];
|
||||
$rows[] = [$r['id'],$r['parent_id'],$r['from_school_year'],$r['to_school_year'],json_encode($r['source_invoice_ids_json'] ?? []),$r['source_balance_amount'],$r['carryforward_amount'],$r['waived_amount'],$r['adjusted_amount'],$r['remaining_amount'],$r['status'],$r['posted_invoice_id'] ?? null];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function paymentTotals(string $schoolYear): array
|
||||
{
|
||||
if (! Schema::hasTable('payments')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table('payments')->where('school_year', $schoolYear)->whereNotIn('status', ['void', 'voided', 'failed', 'cancelled', 'canceled', 'reversed'])->select('invoice_id', DB::raw('COALESCE(SUM(paid_amount),0) total'))->groupBy('invoice_id')->pluck('total', 'invoice_id')->map(fn ($v) => (float) $v)->all();
|
||||
if (!Schema::hasTable('payments')) return [];
|
||||
return DB::table('payments')->where('school_year', $schoolYear)->whereNotIn('status', ['void','voided','failed','cancelled','canceled','reversed'])->select('invoice_id', DB::raw('COALESCE(SUM(paid_amount),0) total'))->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all();
|
||||
}
|
||||
|
||||
private function discountTotals(): array
|
||||
{
|
||||
if (! Schema::hasTable('discount_usages')) {
|
||||
return [];
|
||||
}
|
||||
$col = Schema::hasColumn('discount_usages', 'discount_amount') ? 'discount_amount' : 'amount';
|
||||
|
||||
return DB::table('discount_usages')->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->groupBy('invoice_id')->pluck('total', 'invoice_id')->map(fn ($v) => (float) $v)->all();
|
||||
if (!Schema::hasTable('discount_usages')) return [];
|
||||
$col = Schema::hasColumn('discount_usages','discount_amount') ? 'discount_amount' : 'amount';
|
||||
return DB::table('discount_usages')->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all();
|
||||
}
|
||||
|
||||
private function refundTotals(): array
|
||||
{
|
||||
if (! Schema::hasTable('refunds')) {
|
||||
return [];
|
||||
}
|
||||
$col = Schema::hasColumn('refunds', 'refund_amount') ? 'refund_amount' : (Schema::hasColumn('refunds', 'amount') ? 'amount' : null);
|
||||
if (! $col) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table('refunds')->whereNotIn('status', ['void', 'voided', 'cancelled', 'canceled'])->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->groupBy('invoice_id')->pluck('total', 'invoice_id')->map(fn ($v) => (float) $v)->all();
|
||||
if (!Schema::hasTable('refunds')) return [];
|
||||
$col = Schema::hasColumn('refunds','refund_amount') ? 'refund_amount' : (Schema::hasColumn('refunds','amount') ? 'amount' : null);
|
||||
if (!$col) return [];
|
||||
return DB::table('refunds')->whereNotIn('status', ['void','voided','cancelled','canceled'])->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Services\Finance;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
@@ -22,7 +21,7 @@ class StakeholderFinancialAnalysisService
|
||||
|
||||
$current = $this->periodMetrics($schoolYear, $rangeFrom, $rangeTo);
|
||||
$comparison = null;
|
||||
if (! empty($compareSchoolYear)) {
|
||||
if (!empty($compareSchoolYear)) {
|
||||
[$compareFrom, $compareTo] = $this->resolveDateRange($compareSchoolYear, null, null);
|
||||
$comparison = $this->periodMetrics($compareSchoolYear, $compareFrom, $compareTo);
|
||||
}
|
||||
@@ -71,7 +70,6 @@ class StakeholderFinancialAnalysisService
|
||||
foreach ($value as $bucket => $amount) {
|
||||
$rows[] = ['Receivables Aging', $bucket, $amount];
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
$rows[] = ['Receivables', $key, is_scalar($value) ? $value : json_encode($value)];
|
||||
@@ -83,15 +81,14 @@ class StakeholderFinancialAnalysisService
|
||||
$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];
|
||||
$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;
|
||||
}
|
||||
|
||||
@@ -172,19 +169,18 @@ class StakeholderFinancialAnalysisService
|
||||
|
||||
private function invoiceTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
|
||||
{
|
||||
if (! Schema::hasTable('invoices')) {
|
||||
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')) {
|
||||
if (!Schema::hasTable('payments')) {
|
||||
return ['total' => 0.0, 'familyCount' => 0, 'byMethod' => []];
|
||||
}
|
||||
$base = DB::table('payments')->where('school_year', $schoolYear);
|
||||
@@ -202,13 +198,12 @@ class StakeholderFinancialAnalysisService
|
||||
$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')) {
|
||||
if (!Schema::hasTable('refunds')) {
|
||||
return ['total' => 0.0];
|
||||
}
|
||||
$q = DB::table('refunds')->where('school_year', $schoolYear);
|
||||
@@ -217,14 +212,13 @@ class StakeholderFinancialAnalysisService
|
||||
}
|
||||
$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();
|
||||
|
||||
$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')) {
|
||||
if (!Schema::hasTable('discount_usages')) {
|
||||
return ['total' => 0.0];
|
||||
}
|
||||
$q = DB::table('discount_usages as du');
|
||||
@@ -234,21 +228,19 @@ class StakeholderFinancialAnalysisService
|
||||
$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();
|
||||
|
||||
$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')) {
|
||||
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)];
|
||||
}
|
||||
|
||||
@@ -268,21 +260,20 @@ class StakeholderFinancialAnalysisService
|
||||
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();
|
||||
|
||||
$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')) {
|
||||
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'];
|
||||
$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) {
|
||||
@@ -291,13 +282,12 @@ class StakeholderFinancialAnalysisService
|
||||
$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')) {
|
||||
if (!Schema::hasTable('reimbursements')) {
|
||||
return ['total' => 0.0];
|
||||
}
|
||||
$q = DB::table('reimbursements')->where('school_year', $schoolYear);
|
||||
@@ -306,8 +296,7 @@ class StakeholderFinancialAnalysisService
|
||||
}
|
||||
$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();
|
||||
|
||||
$row = (array) $q->selectRaw('COALESCE(SUM(' . $amountColumn . '),0) as total')->first();
|
||||
return ['total' => (float) ($row['total'] ?? 0)];
|
||||
}
|
||||
|
||||
@@ -315,7 +304,7 @@ class StakeholderFinancialAnalysisService
|
||||
{
|
||||
$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')) {
|
||||
if (!Schema::hasTable('invoices')) {
|
||||
return ['total' => 0.0, 'buckets' => $buckets, 'largestOpenInvoices' => []];
|
||||
}
|
||||
$q = DB::table('invoices')->where('school_year', $schoolYear);
|
||||
@@ -338,8 +327,8 @@ class StakeholderFinancialAnalysisService
|
||||
$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));
|
||||
if (!empty($dueRaw)) {
|
||||
$days = max(0, (int) \Illuminate\Support\Carbon::parse($dueRaw)->startOfDay()->diffInDays($today, false));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$days = 0;
|
||||
@@ -355,7 +344,6 @@ class StakeholderFinancialAnalysisService
|
||||
];
|
||||
}
|
||||
usort($largest, fn ($a, $b) => (float) $b['balance'] <=> (float) $a['balance']);
|
||||
|
||||
return ['total' => $total, 'buckets' => $buckets, 'largestOpenInvoices' => array_slice($largest, 0, 10)];
|
||||
}
|
||||
|
||||
@@ -379,13 +367,12 @@ class StakeholderFinancialAnalysisService
|
||||
];
|
||||
}
|
||||
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) {
|
||||
if (!Schema::hasTable($table) || !Schema::hasColumn($table, $amountColumn) || $dateColumn === null) {
|
||||
return [];
|
||||
}
|
||||
$q = DB::table($table);
|
||||
@@ -398,18 +385,17 @@ class StakeholderFinancialAnalysisService
|
||||
$monthExpr = $driver === 'sqlite'
|
||||
? "strftime('%Y-%m', $dateColumn)"
|
||||
: "DATE_FORMAT($dateColumn, '%Y-%m')";
|
||||
$rows = $q->selectRaw($monthExpr.' as month, COALESCE(SUM('.$amountColumn.'),0) as total')
|
||||
$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'])) {
|
||||
if (!empty($arr['month'])) {
|
||||
$out[(string) $arr['month']] = (float) ($arr['total'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
@@ -424,7 +410,6 @@ class StakeholderFinancialAnalysisService
|
||||
if ($comparison) {
|
||||
$summary['comparison'] = $this->deltas($current['keyMetrics'], $comparison['keyMetrics']);
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
@@ -446,7 +431,6 @@ class StakeholderFinancialAnalysisService
|
||||
if (empty($flags)) {
|
||||
$flags[] = ['level' => 'ok', 'message' => 'No automatic risk flags were triggered for the selected period.'];
|
||||
}
|
||||
|
||||
return $flags;
|
||||
}
|
||||
|
||||
@@ -464,17 +448,15 @@ class StakeholderFinancialAnalysisService
|
||||
'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';
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -483,17 +465,17 @@ class StakeholderFinancialAnalysisService
|
||||
if ($column === null) {
|
||||
return;
|
||||
}
|
||||
if (! empty($dateFrom)) {
|
||||
$query->whereRaw('DATE('.$column.') >= ?', [$dateFrom]);
|
||||
if (!empty($dateFrom)) {
|
||||
$query->whereRaw('DATE(' . $column . ') >= ?', [$dateFrom]);
|
||||
}
|
||||
if (! empty($dateTo)) {
|
||||
$query->whereRaw('DATE('.$column.') <= ?', [$dateTo]);
|
||||
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')) {
|
||||
if (empty($statuses) || !Schema::hasColumn($table, 'status')) {
|
||||
return;
|
||||
}
|
||||
$column = $qualifiedColumn ?: 'status';
|
||||
@@ -504,7 +486,7 @@ class StakeholderFinancialAnalysisService
|
||||
|
||||
private function dateColumn(string $table, array $candidates): ?string
|
||||
{
|
||||
if (! Schema::hasTable($table)) {
|
||||
if (!Schema::hasTable($table)) {
|
||||
return null;
|
||||
}
|
||||
foreach ($candidates as $column) {
|
||||
@@ -512,7 +494,6 @@ class StakeholderFinancialAnalysisService
|
||||
return $column;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user