add test batches

This commit is contained in:
root
2026-06-08 23:45:55 -04:00
parent c792b8be05
commit 8d4d610b82
1480 changed files with 22587 additions and 10762 deletions
@@ -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,
+11 -22
View File
@@ -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;
}