fix financial and certificates

This commit is contained in:
root
2026-06-05 01:51:08 -04:00
parent d28d11e2e5
commit ad968eaff7
94 changed files with 9654 additions and 214 deletions
@@ -24,6 +24,9 @@ use App\Services\Promotions\PromotionEnrollmentService;
use App\Services\Promotions\PromotionQueryService;
use App\Services\Promotions\PromotionReminderService;
use App\Services\Promotions\PromotionStatusService;
use App\Services\Promotions\Placement\SectionPlacementPreviewService;
use App\Models\SectionPlacementBatch;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -42,7 +45,8 @@ class AdministratorPromotionController extends BaseApiController
private PromotionEnrollmentService $enrollmentService,
private PromotionReminderService $reminders,
private PromotionAuditService $audit,
private LevelProgressionService $progression
private LevelProgressionService $progression,
private SectionPlacementPreviewService $sectionPlacement
) {
parent::__construct();
}
@@ -133,6 +137,69 @@ class AdministratorPromotionController extends BaseApiController
]);
}
public function createPlacementPreview(Request $request): JsonResponse
{
$payload = $request->validate([
'from_school_year' => ['required', 'string', 'max:25'],
'to_school_year' => ['required', 'string', 'max:25'],
'from_grade_level_id' => ['nullable', 'integer', 'min:1'],
'to_grade_level_id' => ['required', 'integer', 'min:1'],
]);
try {
$batch = $this->sectionPlacement->createDraft(
$payload['from_school_year'],
$payload['to_school_year'],
$payload['from_grade_level_id'] ?? null,
$payload['to_grade_level_id'] ?? null,
$this->getCurrentUserId()
);
} catch (\Throwable $e) {
Log::error('Section placement preview generation failed', ['exception' => $e]);
return response()->json([
'ok' => false,
'message' => $e->getMessage(),
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
return response()->json([
'ok' => true,
'data' => $this->sectionPlacement->previewPayload($batch),
]);
}
public function showPlacementBatch(int $batchId): JsonResponse
{
$batch = SectionPlacementBatch::query()->find($batchId);
if (!$batch) {
return response()->json(['ok' => false, 'message' => 'Placement batch not found.'], Response::HTTP_NOT_FOUND);
}
return response()->json([
'ok' => true,
'data' => $this->sectionPlacement->previewPayload($batch),
]);
}
public function finalizePlacementBatch(int $batchId): JsonResponse
{
try {
$batch = $this->sectionPlacement->finalize($batchId, $this->getCurrentUserId());
} catch (\Throwable $e) {
Log::error('Section placement finalization failed', ['batch_id' => $batchId, 'exception' => $e]);
return response()->json([
'ok' => false,
'message' => $e->getMessage(),
], Response::HTTP_CONFLICT);
}
return response()->json([
'ok' => true,
'data' => $this->sectionPlacement->previewPayload($batch),
]);
}
public function updateStatus(UpdatePromotionStatusRequest $request, int $promotionId): JsonResponse
{
$record = $this->findOrFail($promotionId);
@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Api\Administrator;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Administrator\TrophyReportRequest;
use App\Services\Administrator\Trophy\TrophyReportService;
use Illuminate\Http\JsonResponse;
class TrophyController extends BaseApiController
{
public function __construct(private TrophyReportService $service)
{
parent::__construct();
}
public function index(TrophyReportRequest $request): JsonResponse
{
$payload = $request->validated();
return response()->json([
'ok' => true,
'data' => $this->service->projection(
$payload['school_year'] ?? null,
isset($payload['percentile']) ? (float) $payload['percentile'] : null
),
]);
}
public function winners(TrophyReportRequest $request): JsonResponse
{
$payload = $request->validated();
return response()->json([
'ok' => true,
'data' => $this->service->winners(
$payload['school_year'] ?? null,
isset($payload['percentile']) ? (float) $payload['percentile'] : null
),
]);
}
public function final(TrophyReportRequest $request): JsonResponse
{
$payload = $request->validated();
return response()->json([
'ok' => true,
'data' => $this->service->final(
$payload['school_year'] ?? null,
isset($payload['percentile']) ? (float) $payload['percentile'] : null
),
]);
}
}
@@ -5,149 +5,141 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\Certificates;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Models\Configuration;
use App\Services\Certificates\CertificateAdminService;
use App\Services\Certificates\CertificatePdfService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class CertificateController extends BaseApiController
{
public function __construct()
{
public function __construct(
private CertificateAdminService $service,
private CertificatePdfService $pdfService,
) {
parent::__construct();
}
/**
* GET /api/v1/administrator/certificates/form-options
*/
public function formOptions(Request $request): JsonResponse
public function dashboard(Request $request): JsonResponse
{
$schoolYearParam = $request->query('school_year');
if ($schoolYearParam !== null) {
$schoolYear = trim((string) $schoolYearParam);
} else {
try {
$schoolYear = trim((string) (Configuration::getConfig('school_year') ?? ''));
} catch (\Throwable) {
$schoolYear = '';
}
}
$classSectionId = $request->query('class_section_id');
try {
$classSections = DB::table('classSection')
->select('class_section_id', 'class_section_name')
->orderBy('class_section_name', 'ASC')
->get()
->map(fn ($r) => (array) $r)
->all();
return response()->json([
'ok' => true,
'data' => $this->service->dashboard($request->query('school_year')),
]);
} catch (\Throwable $e) {
Log::error('Certificate formOptions class query failed: ' . $e->getMessage());
return $this->error('Failed to load classes: ' . $e->getMessage(), 500);
Log::error('Certificate dashboard failed', ['exception' => $e]);
return $this->error('Failed to load certificate dashboard.', 500);
}
}
public function formOptions(Request $request): JsonResponse
{
return $this->dashboard($request);
}
public function auditLog(Request $request): JsonResponse
{
try {
return response()->json([
'ok' => true,
'data' => $this->service->auditLog($request->query('school_year')),
]);
} catch (\Throwable $e) {
Log::error('Certificate audit log failed', ['exception' => $e]);
return $this->error('Failed to load certificate audit log.', 500);
}
$students = [];
if ($classSectionId) {
try {
$studentQuery = DB::table('student_class as sc')
->select('s.id', 's.firstname', 's.lastname', 'cs.class_section_name as grade')
->join('students as s', 's.id', '=', 'sc.student_id')
->join('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->where('sc.class_section_id', (int) $classSectionId)
->where('s.is_active', 1)
->orderBy('s.lastname')
->orderBy('s.firstname');
if ($schoolYear !== '') {
$studentQuery->where('sc.school_year', $schoolYear);
}
$students = $studentQuery->get()->map(fn ($r) => (array) $r)->all();
} catch (\Throwable $e) {
Log::error('Certificate formOptions student query failed: ' . $e->getMessage());
return $this->error('Failed to load students: ' . $e->getMessage(), 500);
}
}
return $this->success([
'class_sections' => $classSections,
'students' => $students,
'school_year' => $schoolYear,
'selected_class_id' => $classSectionId,
]);
}
/**
* POST /api/v1/administrator/certificates/generate
*/
public function generate(Request $request): Response|JsonResponse
{
$studentIds = $request->input('student_ids', []);
$certDate = trim((string) ($request->input('cert_date') ?? date('m/d/Y')));
$studentIds = $request->input('student_ids', []);
$certDate = trim((string) ($request->input('cert_date') ?? date('m/d/Y')));
$classSectionId = $request->input('class_section_id');
if (empty($studentIds) || !is_array($studentIds)) {
return $this->error('Please select at least one student.', 422);
}
$studentIds = array_values(array_filter(array_map('intval', $studentIds)));
$certDate = preg_replace('/[^0-9\/\-]/', '', $certDate) ?? date('m/d/Y');
if (empty($studentIds)) {
return $this->error('Invalid student selection.', 422);
}
$students = [];
foreach ($studentIds as $id) {
$row = null;
if ($classSectionId) {
$row = DB::table('student_class as sc')
->select('s.id', 's.firstname', 's.lastname', 'cs.class_section_name as grade')
->join('students as s', 's.id', '=', 'sc.student_id')
->join('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->where('sc.class_section_id', (int) $classSectionId)
->where('s.id', $id)
->first();
}
if (!$row) {
$row = DB::table('students')
->select('id', 'firstname', 'lastname', 'registration_grade as grade')
->where('id', $id)
->first();
}
if ($row) {
$students[] = (array) $row;
}
}
if (empty($students)) {
return $this->error('No valid students found.', 422);
}
$schoolYear = $request->input('school_year');
try {
$pdfService = app(CertificatePdfService::class);
$pdfContent = $pdfService->generate($students, $certDate);
$payload = $this->service->issueCertificates(
is_array($studentIds) ? $studentIds : [],
$certDate,
$classSectionId !== null && $classSectionId !== '' ? (int) $classSectionId : null,
is_string($schoolYear) ? $schoolYear : null,
$this->getCurrentUserId()
);
$pdfContent = $this->pdfService->generate(
$payload['students'],
$payload['cert_date_display']
);
} catch (\InvalidArgumentException $e) {
return $this->error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
} catch (\Throwable $e) {
Log::error('Certificate PDF generation failed: ' . $e->getMessage(), ['exception' => $e]);
Log::error('Certificate generation failed', ['exception' => $e]);
return $this->error('Failed to generate certificates.', 500);
}
$filename = 'Certificates_' . date('Ymd_His') . '.pdf';
$filename = 'Certificates_'.date('Ymd_His').'.pdf';
return response($pdfContent, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="' . $filename . '"',
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$filename.'"',
]);
}
private function currentSchoolYear(): string
public function reprint(string $certificateNumber): Response|JsonResponse
{
return trim((string) (Configuration::getConfig('school_year') ?? ''));
try {
$payload = $this->service->reprintPayload($certificateNumber);
if ($payload === null) {
return $this->error('Certificate not found: '.$certificateNumber, 404);
}
$pdfContent = $this->pdfService->generate(
[$payload['student']],
$payload['cert_date_display']
);
} catch (\Throwable $e) {
Log::error('Certificate reprint failed', [
'certificate_number' => $certificateNumber,
'exception' => $e,
]);
return $this->error('Failed to reprint certificate.', 500);
}
$filename = 'Certificate_'.strtoupper($certificateNumber).'.pdf';
return response($pdfContent, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$filename.'"',
]);
}
public function verify(string $token): JsonResponse
{
try {
$payload = $this->service->verifyPayload($token);
} catch (\Throwable $e) {
Log::error('Certificate verify failed', ['token' => $token, 'exception' => $e]);
return $this->error('Failed to verify certificate.', 500);
}
if ($payload === null) {
return response()->json([
'ok' => false,
'message' => 'Certificate not found.',
], 404);
}
return response()->json([
'ok' => true,
'data' => $payload,
]);
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Controller;
use App\Http\Requests\Finance\CarryforwardAdjustmentRequest;
use App\Http\Requests\Finance\CarryforwardFinalizeRequest;
use App\Http\Requests\Finance\CarryforwardPreviewRequest;
use App\Services\Finance\PriorYearBalanceCarryforwardService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
class BalanceCarryforwardController extends Controller
{
public function __construct(private PriorYearBalanceCarryforwardService $service) {}
public function preview(CarryforwardPreviewRequest $request): JsonResponse
{
return response()->json(['ok' => true, 'preview' => $this->service->preview($request->validated())]);
}
public function storeDraft(CarryforwardFinalizeRequest $request): JsonResponse
{
return response()->json(['ok' => true, 'result' => $this->service->storeDrafts($request->validated(), optional($request->user())->id)]);
}
public function approve(Request $request, int $carryforward): JsonResponse
{
return response()->json(['ok' => true, 'carryforward' => $this->service->approve($carryforward, optional($request->user())->id)]);
}
public function postToNewYear(Request $request, int $carryforward): JsonResponse
{
return response()->json(['ok' => true, 'carryforward' => $this->service->postToNewYear($carryforward, optional($request->user())->id)]);
}
public function waive(CarryforwardAdjustmentRequest $request, int $carryforward): JsonResponse
{
$data = $request->validated();
return response()->json(['ok' => true, 'carryforward' => $this->service->waive($carryforward, (float) $data['amount'], $data['reason'] ?? null, optional($request->user())->id)]);
}
public function adjust(CarryforwardAdjustmentRequest $request, int $carryforward): JsonResponse
{
$data = $request->validated();
return response()->json(['ok' => true, 'carryforward' => $this->service->adjust($carryforward, (float) $data['amount'], $data['reason'] ?? null)]);
}
public function report(Request $request): JsonResponse
{
return response()->json(['ok' => true, 'report' => $this->service->report($request->query())]);
}
public function reportCsv(Request $request): StreamedResponse
{
$rows = $this->service->csvRows($this->service->report($request->query()));
return response()->streamDownload(function () use ($rows) {
$out = fopen('php://output', 'w');
foreach ($rows as $row) fputcsv($out, $row);
fclose($out);
}, 'carryforward_report_' . date('Ymd_His') . '.csv', ['Content-Type' => 'text/csv']);
}
}
@@ -0,0 +1,93 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Controller;
use App\Http\Requests\Finance\EventChargeLifecycleRequest;
use App\Models\EventCharge;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class EventChargeController extends Controller
{
public function index(Request $request): JsonResponse
{
$q = EventCharge::query();
foreach (['parent_id','student_id','event_id','school_year','semester'] as $field) {
if ($request->filled($field)) $q->where($field, $request->query($field));
}
if ($request->filled('status')) {
$status = $request->query('status');
if (Schema::hasColumn('event_charges', 'status')) $q->where('status', $status);
elseif ($status === 'paid') $q->where('event_paid', 1);
elseif ($status === 'pending') $q->where(function ($qq) { $qq->whereNull('event_paid')->orWhere('event_paid', 0); });
}
return response()->json(['ok' => true, 'event_charges' => $q->orderByDesc('id')->paginate((int) $request->query('per_page', 25))]);
}
public function store(EventChargeLifecycleRequest $request): JsonResponse
{
$data = $this->normalizePayload($request->validated());
$data['created_by'] = optional($request->user())->id;
if (Schema::hasColumn('event_charges', 'status')) $data['status'] = $data['status'] ?? 'draft';
$charge = EventCharge::query()->create($data);
return response()->json(['ok' => true, 'event_charge' => $charge], 201);
}
public function show(int $eventCharge): JsonResponse
{
return response()->json(['ok' => true, 'event_charge' => EventCharge::query()->findOrFail($eventCharge)]);
}
public function update(EventChargeLifecycleRequest $request, int $eventCharge): JsonResponse
{
$charge = EventCharge::query()->findOrFail($eventCharge);
$charge->fill($this->normalizePayload($request->validated()))->save();
return response()->json(['ok' => true, 'event_charge' => $charge->fresh()]);
}
public function destroy(int $eventCharge): JsonResponse
{
return $this->void($eventCharge);
}
public function approve(int $eventCharge): JsonResponse
{
$charge = EventCharge::query()->findOrFail($eventCharge);
if (Schema::hasColumn('event_charges', 'status')) $charge->status = 'approved';
if (Schema::hasColumn('event_charges', 'charged')) $charge->charged = 1;
$charge->save();
return response()->json(['ok' => true, 'event_charge' => $charge->fresh()]);
}
public function void(int $eventCharge): JsonResponse
{
$charge = EventCharge::query()->findOrFail($eventCharge);
if (Schema::hasColumn('event_charges', 'status')) $charge->status = 'voided';
if (Schema::hasColumn('event_charges', 'charged')) $charge->charged = 0;
$charge->save();
return response()->json(['ok' => true, 'event_charge' => $charge->fresh()]);
}
public function attachToInvoice(Request $request, int $eventCharge): JsonResponse
{
$request->validate(['invoice_id' => ['required','integer']]);
$charge = EventCharge::query()->findOrFail($eventCharge);
$invoiceId = (int) $request->input('invoice_id');
abort_if(!DB::table('invoices')->where('id', $invoiceId)->exists(), 404, 'Invoice not found.');
if (Schema::hasColumn('event_charges', 'invoice_id')) $charge->invoice_id = $invoiceId;
if (Schema::hasColumn('event_charges', 'status')) $charge->status = 'invoiced';
if (Schema::hasColumn('event_charges', 'charged')) $charge->charged = 1;
$charge->save();
return response()->json(['ok' => true, 'event_charge' => $charge->fresh(), 'warning' => Schema::hasColumn('event_charges','invoice_id') ? null : 'invoice_id column does not exist; charge marked as charged only.']);
}
private function normalizePayload(array $data): array
{
if (isset($data['title']) && !isset($data['event_name'])) $data['event_name'] = $data['title'];
unset($data['title'], $data['invoice_id']);
return array_filter($data, fn($v) => $v !== null);
}
}
@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Controller;
use App\Http\Requests\Finance\FinanceNotificationLogRequest;
use App\Services\Finance\FinanceNotificationLogService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class FinanceNotificationController extends Controller
{
public function __construct(private FinanceNotificationLogService $service) {}
public function sendPaymentReceipt(Request $request, int $payment): JsonResponse
{
$p = DB::table('payments')->where('id', $payment)->first();
abort_if(!$p, 404, 'Payment not found.');
$receipt = $this->service->nextReceiptNumber($p->school_year ?? date('Y'));
$log = $this->service->log([
'parent_id' => $p->parent_id ?? null,
'invoice_id' => $p->invoice_id ?? null,
'payment_id' => $payment,
'notification_type' => 'payment_receipt',
'channel' => 'database',
'recipient' => (string) ($p->parent_id ?? ''),
'subject' => 'Payment receipt ' . $receipt,
], optional($request->user())->id);
return response()->json(['ok' => true, 'receipt_number' => $receipt, 'notification_log' => $log]);
}
public function sendRefundReceipt(Request $request, int $refund): JsonResponse
{
$log = $this->service->log(['refund_id' => $refund, 'notification_type' => 'refund_receipt', 'channel' => 'database', 'subject' => 'Refund receipt'], optional($request->user())->id);
return response()->json(['ok' => true, 'notification_log' => $log]);
}
public function sendInvoiceStatement(Request $request, int $invoice): JsonResponse
{
$inv = DB::table('invoices')->where('id', $invoice)->first();
abort_if(!$inv, 404, 'Invoice not found.');
$log = $this->service->log(['parent_id' => $inv->parent_id ?? null, 'invoice_id' => $invoice, 'notification_type' => 'statement_available', 'channel' => 'database', 'subject' => 'Statement available'], optional($request->user())->id);
return response()->json(['ok' => true, 'notification_log' => $log]);
}
public function sendOverdueReminder(Request $request, int $parent): JsonResponse
{
$log = $this->service->log(['parent_id' => $parent, 'notification_type' => 'overdue_balance_reminder', 'channel' => 'database', 'subject' => 'Overdue balance reminder'], optional($request->user())->id);
return response()->json(['ok' => true, 'notification_log' => $log]);
}
public function sendInstallmentReminder(Request $request, int $installment): JsonResponse
{
$log = $this->service->log(['installment_id' => $installment, 'notification_type' => 'installment_reminder', 'channel' => 'database', 'subject' => 'Installment reminder'], optional($request->user())->id);
return response()->json(['ok' => true, 'notification_log' => $log]);
}
public function logs(FinanceNotificationLogRequest $request): JsonResponse
{
return response()->json(['ok' => true, 'logs' => $this->service->list($request->validated())]);
}
}
@@ -5,7 +5,10 @@ namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Api\Core\BaseApiController;
use App\Http\Requests\Finance\FinancialReportRequest;
use App\Http\Requests\Finance\FinancialSummaryRequest;
use App\Http\Requests\Finance\StakeholderFinancialAnalysisRequest;
use App\Http\Requests\Finance\FinancialUnpaidParentsRequest;
use App\Http\Requests\Finance\ParentPaymentFollowUpRequest;
use App\Http\Requests\Finance\FollowUpNoteRequest;
use App\Http\Resources\Finance\FinancialReportResource;
use App\Http\Resources\Finance\FinancialSummaryResource;
use App\Http\Resources\Finance\FinancialUnpaidParentResource;
@@ -13,7 +16,9 @@ use App\Services\Finance\FinancialPdfReportService;
use App\Services\Finance\FinancialReportService;
use App\Services\Finance\FinancialSchoolYearService;
use App\Services\Finance\FinancialSummaryService;
use App\Services\Finance\StakeholderFinancialAnalysisService;
use App\Services\Finance\FinancialUnpaidParentsService;
use App\Services\Finance\ParentPaymentFollowUpService;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -24,7 +29,9 @@ class FinancialController extends BaseApiController
private FinancialSummaryService $summaryService,
private FinancialUnpaidParentsService $unpaidParentsService,
private FinancialSchoolYearService $schoolYears,
private FinancialPdfReportService $pdfService
private FinancialPdfReportService $pdfService,
private StakeholderFinancialAnalysisService $stakeholderAnalysisService,
private ParentPaymentFollowUpService $parentPaymentFollowUpService
) {
parent::__construct();
}
@@ -75,6 +82,95 @@ class FinancialController extends BaseApiController
]);
}
public function parentPaymentFollowups(ParentPaymentFollowUpRequest $request): JsonResponse
{
return response()->json([
'ok' => true,
'followups' => $this->parentPaymentFollowUpService->report($request->validated()),
]);
}
public function parentPaymentFollowupsCsv(ParentPaymentFollowUpRequest $request): StreamedResponse
{
$report = $this->parentPaymentFollowUpService->report($request->validated());
$rows = $this->parentPaymentFollowUpService->csvRows($report);
return response()->streamDownload(function () use ($rows) {
$out = fopen('php://output', 'w');
foreach ($rows as $row) {
fputcsv($out, $row);
}
fclose($out);
}, 'parent_payment_followups_' . ($report['schoolYear'] ?? 'report') . '_' . date('Ymd_His') . '.csv', ['Content-Type' => 'text/csv']);
}
public function storeParentFollowUpNote(FollowUpNoteRequest $request, int $parent): JsonResponse
{
return response()->json([
'ok' => true,
'note' => $this->parentPaymentFollowUpService->storeNote($parent, $request->validated(), optional($request->user())->id),
], 201);
}
public function markParentContacted(FollowUpNoteRequest $request, int $parent): JsonResponse
{
$payload = array_merge($request->validated(), ['status' => $request->input('status', 'contacted')]);
return response()->json(['ok' => true, 'note' => $this->parentPaymentFollowUpService->storeNote($parent, $payload, optional($request->user())->id)], 201);
}
public function storePromiseToPay(FollowUpNoteRequest $request, int $parent): JsonResponse
{
$payload = array_merge($request->validated(), ['status' => 'promise_to_pay']);
return response()->json(['ok' => true, 'note' => $this->parentPaymentFollowUpService->storeNote($parent, $payload, optional($request->user())->id)], 201);
}
public function resolveParentFollowUp(FollowUpNoteRequest $request, int $parent): JsonResponse
{
$payload = array_merge($request->validated(), ['status' => 'resolved']);
return response()->json(['ok' => true, 'note' => $this->parentPaymentFollowUpService->storeNote($parent, $payload, optional($request->user())->id)], 201);
}
public function stakeholderAnalysis(StakeholderFinancialAnalysisRequest $request): JsonResponse
{
$payload = $request->validated();
$analysis = $this->stakeholderAnalysisService->analyze(
$payload['date_from'] ?? null,
$payload['date_to'] ?? null,
$payload['school_year'] ?? null,
$payload['compare_school_year'] ?? null
);
return response()->json([
'ok' => true,
'analysis' => $analysis,
]);
}
public function stakeholderAnalysisCsv(StakeholderFinancialAnalysisRequest $request): StreamedResponse
{
$payload = $request->validated();
$analysis = $this->stakeholderAnalysisService->analyze(
$payload['date_from'] ?? null,
$payload['date_to'] ?? null,
$payload['school_year'] ?? null,
$payload['compare_school_year'] ?? null
);
$rows = $this->stakeholderAnalysisService->csvRows($analysis);
$schoolYear = $analysis['schoolYear'] ?? 'report';
return response()->streamDownload(function () use ($rows) {
$out = fopen('php://output', 'w');
foreach ($rows as $row) {
fputcsv($out, $row);
}
fclose($out);
}, 'stakeholder_financial_analysis_' . $schoolYear . '_' . date('Ymd_His') . '.csv', ['Content-Type' => 'text/csv']);
}
public function downloadCsv(FinancialReportRequest $request): StreamedResponse
{
$payload = $request->validated();
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Api\Finance;
use App\Http\Controllers\Controller;
use App\Http\Requests\Finance\InstallmentPlanRequest;
use App\Http\Requests\Finance\PaymentAllocationRequest;
use App\Services\Finance\InstallmentPlanService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class InstallmentPlanController extends Controller
{
public function __construct(private InstallmentPlanService $service) {}
public function index(Request $request): JsonResponse { return response()->json(['ok' => true, 'plans' => $this->service->list($request->query())]); }
public function store(InstallmentPlanRequest $request, int $invoice): JsonResponse { return response()->json(['ok' => true, 'plan' => $this->service->createForInvoice($invoice, $request->validated(), optional($request->user())->id)], 201); }
public function show(int $plan): JsonResponse { return response()->json(['ok' => true, 'plan' => $this->service->show($plan)]); }
public function activate(Request $request, int $plan): JsonResponse { return response()->json(['ok' => true, 'plan' => $this->service->activate($plan, optional($request->user())->id)]); }
public function cancel(int $plan): JsonResponse { return response()->json(['ok' => true, 'plan' => $this->service->cancel($plan)]); }
public function restructure(InstallmentPlanRequest $request, int $plan): JsonResponse
{
$old = $this->service->cancel($plan);
return response()->json(['ok' => true, 'old_plan' => $old, 'message' => 'Old plan cancelled. Create a replacement plan against the invoice to preserve audit history.']);
}
public function allocatePayment(PaymentAllocationRequest $request, int $payment): JsonResponse { return response()->json(['ok' => true, 'result' => $this->service->allocatePayment($payment, $request->validated())]); }
public function due(): JsonResponse { return response()->json(['ok' => true, 'installments' => $this->service->due(false)]); }
public function overdue(): JsonResponse { $this->service->markOverdue(); return response()->json(['ok' => true, 'installments' => $this->service->due(true)]); }
}
@@ -128,6 +128,23 @@ class InvoiceController extends BaseApiController
]);
}
public function preview(int $invoiceId): JsonResponse
{
$data = $this->pdfService->previewData($invoiceId);
if (isset($data['error'])) {
return response()->json([
'ok' => false,
'message' => $data['error'],
], 404);
}
return response()->json([
'ok' => true,
...$data,
]);
}
public function pdf(int $invoiceId): StreamedResponse
{
$pdfBytes = $this->pdfService->buildPdf($invoiceId);
@@ -35,6 +35,7 @@ use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Validator;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ReimbursementController extends BaseApiController
{
@@ -287,7 +288,7 @@ class ReimbursementController extends BaseApiController
return response()->json(['ok' => true, 'message' => 'Email sent successfully.']);
}
public function export(ReimbursementExportRequest $request): Response
public function export(ReimbursementExportRequest $request): StreamedResponse
{
$payload = $request->validated();
$type = $payload['type'] ?? 'processed';
@@ -310,7 +311,7 @@ class ReimbursementController extends BaseApiController
]);
}
public function exportBatch(ReimbursementExportBatchRequest $request): Response
public function exportBatch(ReimbursementExportBatchRequest $request): StreamedResponse
{
$payload = $request->validated();
$csv = $this->exportService->buildBatchCsv((int) $payload['batch_id']);
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Administrator;
use App\Http\Requests\ApiFormRequest;
class TrophyReportRequest extends ApiFormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'school_year' => ['nullable', 'string', 'max:50'],
'percentile' => ['nullable', 'numeric', 'min:1', 'max:99'],
];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Http\Requests\Finance;
use Illuminate\Foundation\Http\FormRequest;
class CarryforwardAdjustmentRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'amount' => ['required','numeric','min:0'],
'reason' => ['nullable','string','max:255'],
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Requests\Finance;
use Illuminate\Foundation\Http\FormRequest;
class CarryforwardFinalizeRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'from_school_year' => ['required_without:id','string','max:20'],
'to_school_year' => ['required_without:id','string','max:20'],
'parent_id' => ['nullable','integer'],
'reason' => ['nullable','string','max:255'],
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Requests\Finance;
use Illuminate\Foundation\Http\FormRequest;
class CarryforwardPreviewRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'from_school_year' => ['required','string','max:20'],
'to_school_year' => ['required','string','max:20'],
'parent_id' => ['nullable','integer'],
'minimum_balance' => ['nullable','numeric','min:0'],
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Finance;
use Illuminate\Foundation\Http\FormRequest;
class EventChargeLifecycleRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'event_id' => ['nullable','integer'],
'parent_id' => ['nullable','integer'],
'student_id' => ['nullable','integer'],
'school_year' => ['nullable','string','max:20'],
'semester' => ['nullable','string','max:40'],
'event_name' => ['nullable','string','max:255'],
'title' => ['nullable','string','max:255'],
'description' => ['nullable','string'],
'amount' => ['nullable','numeric','min:0'],
'invoice_id' => ['nullable','integer'],
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Finance;
use Illuminate\Foundation\Http\FormRequest;
class FinanceNotificationLogRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'status' => ['nullable','string','max:40'],
'notification_type' => ['nullable','string','max:80'],
'parent_id' => ['nullable','integer'],
'date_from' => ['nullable','date'],
'date_to' => ['nullable','date'],
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Finance;
use Illuminate\Foundation\Http\FormRequest;
class FollowUpNoteRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'invoice_id' => ['nullable','integer'],
'school_year' => ['nullable','string','max:20'],
'status' => ['nullable','string','max:60'],
'contact_method' => ['nullable','string','max:60'],
'promise_to_pay_date' => ['nullable','date'],
'promise_to_pay_amount' => ['nullable','numeric','min:0'],
'next_follow_up_date' => ['nullable','date'],
'escalation_level' => ['nullable','integer','min:0','max:10'],
'note' => ['nullable','string','max:10000'],
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Finance;
use Illuminate\Foundation\Http\FormRequest;
class InstallmentPlanRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'plan_name' => ['nullable','string','max:120'],
'total_amount' => ['nullable','numeric','min:0'],
'number_of_installments' => ['required','integer','min:1','max:24'],
'first_due_date' => ['required','date'],
'interval_months' => ['nullable','integer','min:1','max:12'],
];
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\Finance;
use Illuminate\Foundation\Http\FormRequest;
class ParentPaymentFollowUpRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'school_year' => ['nullable','string','max:20'],
'date_from' => ['nullable','date'],
'date_to' => ['nullable','date'],
'minimum_balance' => ['nullable','numeric','min:0'],
'aging_bucket' => ['nullable','string','max:40'],
'parent_id' => ['nullable','integer'],
'student_id' => ['nullable','integer'],
'follow_up_status' => ['nullable','string','max:60'],
'last_contacted_before' => ['nullable','date'],
'next_follow_up_due_before' => ['nullable','date'],
'promise_to_pay_due_before' => ['nullable','date'],
'include_paid_invoices' => ['nullable','boolean'],
'include_inactive_students' => ['nullable','boolean'],
];
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Requests\Finance;
use Illuminate\Foundation\Http\FormRequest;
class PaymentAllocationRequest extends FormRequest
{
public function authorize(): bool { return true; }
public function rules(): array
{
return [
'installments' => ['nullable','array'],
'installments.*.installment_id' => ['required_with:installments','integer'],
'installments.*.amount' => ['required_with:installments','numeric','min:0.01'],
];
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\Http\Requests\Finance;
class StakeholderFinancialAnalysisRequest extends FinancialReportRequest
{
public function rules(): array
{
return array_merge(parent::rules(), [
'compare_school_year' => ['nullable', 'string', 'max:20'],
'include_monthly_trend' => ['nullable', 'boolean'],
'include_parent_risk' => ['nullable', 'boolean'],
]);
}
}
@@ -22,7 +22,7 @@ class GradingScoreUpdateRequest extends ApiFormRequest
'score_ids' => ['nullable', 'array'],
'scores' => ['nullable', 'array'],
'comments' => ['nullable', 'array'],
'score' => ['nullable', 'numeric'],
'score' => ['nullable', 'numeric', 'min:0', 'max:100'],
'comment' => ['nullable', 'string'],
];
}
+20 -20
View File
@@ -9,26 +9,26 @@ class ExpenseResource extends JsonResource
public function toArray($request): array
{
return [
'id' => $this['id'] ?? $this->id,
'category' => $this['category'] ?? $this->category,
'amount' => $this['amount'] ?? $this->amount,
'receipt_path' => $this['receipt_path'] ?? $this->receipt_path,
'receipt_url' => $this['receipt_url'] ?? null,
'description' => $this['description'] ?? $this->description,
'retailor' => $this['retailor'] ?? $this->retailor,
'date_of_purchase' => $this['date_of_purchase'] ?? $this->date_of_purchase,
'purchased_by' => $this['purchased_by'] ?? $this->purchased_by,
'added_by' => $this['added_by'] ?? $this->added_by,
'school_year' => $this['school_year'] ?? $this->school_year,
'semester' => $this['semester'] ?? $this->semester,
'status' => $this['status'] ?? $this->status,
'status_reason' => $this['status_reason'] ?? $this->status_reason,
'approved_by' => $this['approved_by'] ?? $this->approved_by,
'updated_by' => $this['updated_by'] ?? $this->updated_by,
'purchaser_firstname' => $this['purchaser_firstname'] ?? null,
'purchaser_lastname' => $this['purchaser_lastname'] ?? null,
'approver_firstname' => $this['approver_firstname'] ?? null,
'approver_lastname' => $this['approver_lastname'] ?? null,
'id' => data_get($this->resource, 'id'),
'category' => data_get($this->resource, 'category'),
'amount' => data_get($this->resource, 'amount'),
'receipt_path' => data_get($this->resource, 'receipt_path'),
'receipt_url' => data_get($this->resource, 'receipt_url'),
'description' => data_get($this->resource, 'description'),
'retailor' => data_get($this->resource, 'retailor'),
'date_of_purchase' => data_get($this->resource, 'date_of_purchase'),
'purchased_by' => data_get($this->resource, 'purchased_by'),
'added_by' => data_get($this->resource, 'added_by'),
'school_year' => data_get($this->resource, 'school_year'),
'semester' => data_get($this->resource, 'semester'),
'status' => data_get($this->resource, 'status'),
'status_reason' => data_get($this->resource, 'status_reason'),
'approved_by' => data_get($this->resource, 'approved_by'),
'updated_by' => data_get($this->resource, 'updated_by'),
'purchaser_firstname' => data_get($this->resource, 'purchaser_firstname'),
'purchaser_lastname' => data_get($this->resource, 'purchaser_lastname'),
'approver_firstname' => data_get($this->resource, 'approver_firstname'),
'approver_lastname' => data_get($this->resource, 'approver_lastname'),
];
}
}