From 95bcefc3a90d519d8beabe4b1b0afaf34b329947 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 30 May 2026 16:55:20 -0400 Subject: [PATCH] fix decision email --- app/Config/Routes.php | 13 +- app/Controllers/View/GradingController.php | 644 ++++++- ...ix_plan_no_paypal_with_tuition_forecast.md | 1658 +++++++++++++++++ 3 files changed, 2270 insertions(+), 45 deletions(-) create mode 100644 financial_system_fix_plan_no_paypal_with_tuition_forecast.md diff --git a/app/Config/Routes.php b/app/Config/Routes.php index b02827a..a77493a 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -528,10 +528,17 @@ $routes->post('grading/decisions/generate', 'View\GradingController::generateAll $routes->get('grading/below-60/decisions', 'View\GradingController::belowSixtyDecisions', ['filter' => 'auth:read']); $routes->post('grading/below-60/decisions/save', 'View\GradingController::saveBelowSixtyDecision', ['filter' => 'auth:read']); $routes->get('grading/below-60/decisions/student-details', 'View\GradingController::studentDecisionDetails', ['filter' => 'auth:read']); -$routes->get('grading/below-60/decisions/email/preview', 'View\GradingController::previewDecisionEmail', ['filter' => 'auth:read']); -$routes->get('grading/below-60/decisions/email/edit', 'View\GradingController::editDecisionEmail', ['filter' => 'auth:read']); -$routes->post('grading/below-60/decisions/email', 'View\GradingController::sendDecisionEmail', ['filter' => 'auth:read']); +$routes->get( + 'grading/below-60/decisions/email/preview', + 'View\GradingController::previewBelowSixtyDecisionEmail', + ['filter' => 'auth:read'] +); +$routes->post( + 'grading/below-60/decisions/email', + 'View\GradingController::sendBelowSixtyDecisionEmail', + ['filter' => 'auth:read'] +); // Final part $routes->get('grading/(:segment)/(:num)/(:num)', 'View\GradingController::show/$1/$2/$3'); diff --git a/app/Controllers/View/GradingController.php b/app/Controllers/View/GradingController.php index 730ffa9..b54462b 100644 --- a/app/Controllers/View/GradingController.php +++ b/app/Controllers/View/GradingController.php @@ -2543,51 +2543,148 @@ public function belowSixty() ]); } - public function saveBelowSixtyDecision() - { - $studentId = (int)$this->request->getPost('student_id'); - $semester = trim((string)$this->request->getPost('semester')); - $schoolYear = trim((string)$this->request->getPost('school_year')); - $decision = trim((string)$this->request->getPost('decision')); - $notes = trim((string)$this->request->getPost('notes')); +public function saveBelowSixtyDecision() +{ + $studentId = (int)($this->request->getPost('student_id') ?? 0); + $semester = strtolower(trim((string)($this->request->getPost('semester') ?? 'year'))); + $schoolYear = trim((string)($this->request->getPost('school_year') ?? '')); + $decision = trim((string)($this->request->getPost('decision') ?? '')); + $notes = trim((string)($this->request->getPost('notes') ?? '')); - if ($studentId <= 0 || $semester === '' || $schoolYear === '') { - return redirect()->back()->with('error', 'Missing required data.'); - } - - $allowed = ['', 'Pass', 'Repeat Class', 'Make-up exam in fall', 'Deferred decision', 'Expel', 'Withdrawn']; - if (!in_array($decision, $allowed, true)) { - return redirect()->back()->with('error', 'Invalid decision value.'); - } - - $decisionModel = new BelowSixtyDecisionModel(); - $existing = $decisionModel - ->where('student_id', $studentId) - ->where('semester', $semester) - ->where('school_year', $schoolYear) - ->first(); - - $userId = (int)(session()->get('user_id') ?? 0) ?: null; - $payload = [ - 'decision' => $decision !== '' ? $decision : null, - 'notes' => $notes !== '' ? $notes : null, - 'decided_by' => $userId, - ]; - - if ($existing) { - $decisionModel->update((int)$existing['id'], $payload); - } else { - $payload['student_id'] = $studentId; - $payload['semester'] = $semester; - $payload['school_year'] = $schoolYear; - $decisionModel->insert($payload); - } - - $query = http_build_query(['semester' => $semester, 'school_year' => $schoolYear]); - return redirect()->to(base_url('grading/below-60/decisions') . ($query ? '?' . $query : '')) - ->with('status', 'Decision saved.'); + if ($studentId <= 0 || $schoolYear === '') { + return redirect()->back()->with('error', 'Missing student or school year.'); } + // This decision page should feed certificate decisions as whole-year decisions. + // Force year mode here so certificate logic receives final year decision. + $semester = 'year'; + + $db = \Config\Database::connect(); + + /* + * 1. Save/update the manual decision in below_sixty_decisions. + * This keeps your below-60 page history working. + */ + $belowModel = new \App\Models\BelowSixtyDecisionModel(); + + $existingBelow = $belowModel + ->where('student_id', $studentId) + ->where('semester', $semester) + ->where('school_year', $schoolYear) + ->first(); + + $belowPayload = [ + 'student_id' => $studentId, + 'semester' => $semester, + 'school_year' => $schoolYear, + 'decision' => $decision, + 'notes' => $notes, + ]; + + if ($existingBelow) { + $belowModel->update((int)$existingBelow['id'], $belowPayload); + } else { + $belowModel->insert($belowPayload); + } + + /* + * 2. Calculate the student's whole-year score. + * Certificate page uses student_decisions.year_score. + */ + $scoreRows = $db->table('semester_scores ss') + ->select([ + 'LOWER(TRIM(ss.semester)) AS sem_key', + 'ss.semester_score', + 'cs.class_section_name', + ]) + ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left') + ->where('ss.student_id', $studentId) + ->where('ss.school_year', $schoolYear) + ->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring']) + ->where('ss.semester_score IS NOT NULL', null, false) + ->orderBy('ss.updated_at', 'DESC') + ->orderBy('ss.id', 'DESC') + ->get() + ->getResultArray(); + + $fallScore = null; + $springScore = null; + $classSectionName = null; + + foreach ($scoreRows as $sr) { + $semKey = strtolower(trim((string)($sr['sem_key'] ?? ''))); + $score = is_numeric($sr['semester_score'] ?? null) ? (float)$sr['semester_score'] : null; + + if ($classSectionName === null && !empty($sr['class_section_name'])) { + $classSectionName = (string)$sr['class_section_name']; + } + + if ($semKey === 'fall' && $fallScore === null) { + $fallScore = $score; + } + + if ($semKey === 'spring' && $springScore === null) { + $springScore = $score; + } + } + + if ($fallScore !== null && $springScore !== null) { + $yearScore = round(($fallScore + $springScore) / 2, 2); + } elseif ($fallScore !== null) { + $yearScore = round($fallScore, 2); + } elseif ($springScore !== null) { + $yearScore = round($springScore, 2); + } else { + $yearScore = null; + } + + /* + * 3. Sync into student_decisions. + * This is the part your certificate page needs. + * + * student_decisions is now year-based: + * - no semester + * - no semester_score + * - uses year_score + */ + $source = $decision === '' ? 'pending' : 'manual'; + + $studentDecisionPayload = [ + 'student_id' => $studentId, + 'school_year' => $schoolYear, + 'class_section_name' => $classSectionName, + 'year_score' => $yearScore, + 'decision' => $decision !== '' ? $decision : null, + 'source' => $source, + 'notes' => $notes !== '' ? $notes : null, + 'generated_by' => (int)(session()->get('user_id') ?? 0) ?: null, + ]; + + $existingStudentDecision = $db->table('student_decisions') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->get() + ->getRowArray(); + + if ($existingStudentDecision) { + $db->table('student_decisions') + ->where('id', (int)$existingStudentDecision['id']) + ->update($studentDecisionPayload); + } else { + $db->table('student_decisions') + ->insert($studentDecisionPayload); + } + + $query = http_build_query([ + 'semester' => 'year', + 'school_year' => $schoolYear, + ]); + + return redirect() + ->to(base_url('grading/below-60/decisions') . '?' . $query) + ->with('status', 'Decision saved and certificate decision updated.'); +} + public function studentDecisionDetails() { $studentId = (int)$this->request->getGet('student_id'); @@ -2602,6 +2699,469 @@ public function belowSixty() ]); } +public function previewBelowSixtyDecisionEmail() +{ + $studentId = (int)($this->request->getGet('student_id') ?? 0); + $schoolYear = trim((string)($this->request->getGet('school_year') ?? '')); + + if ($studentId <= 0 || $schoolYear === '') { + return $this->response->setJSON([ + 'error' => 'Missing student or school year.', + ]); + } + + /* + * Whole-year decision email. + * Do NOT query semester_scores.semester = "year". + * The Details button uses fetchAllSemestersForStudent(), so this email does too. + */ + $context = $this->getBelowSixtyDecisionEmailContext($studentId, $schoolYear); + + if (empty($context['student'])) { + return $this->response->setJSON([ + 'error' => 'Student not found.', + ]); + } + + if (empty($context['decision_row']) || trim((string)($context['decision_row']['decision'] ?? '')) === '') { + return $this->response->setJSON([ + 'error' => 'No saved decision found for this student. Save a decision first.', + ]); + } + + $studentName = (string)($context['student_name'] ?? 'Student'); + $classSectionName = (string)($context['class_section_name'] ?? ''); + $decisionRow = $context['decision_row']; + + $decision = trim((string)($decisionRow['decision'] ?? '')); + $notes = trim((string)($decisionRow['notes'] ?? '')); + + $fallScore = $context['fall_score'] ?? null; + $springScore = $context['spring_score'] ?? null; + $yearScore = $context['year_score'] ?? null; + + /* + * Same data used by the Details modal. + */ + $allSemesters = $context['all_semesters'] ?? []; + + if (empty($allSemesters)) { + $allSemesters = $this->fetchAllSemestersForStudent($studentId, $schoolYear); + } + + $fallText = $fallScore !== null + ? number_format((float)$fallScore, 2) + : 'N/A'; + + $springText = $springScore !== null + ? number_format((float)$springScore, 2) + : 'N/A'; + + $yearText = $yearScore !== null + ? number_format((float)$yearScore, 2) + : 'N/A'; + + $subject = 'Whole Year Academic Decision — ' . $studentName . ' (' . $schoolYear . ')'; + + $html = ' +

Dear Parent/Guardian,

+ +

+ This message is regarding ' . esc($studentName) . ' + for the ' . esc($schoolYear) . ' school year. +

+ +

+ Class: ' . esc($classSectionName !== '' ? $classSectionName : 'N/A') . '
+ Fall Score: ' . esc($fallText) . '
+ Spring Score: ' . esc($springText) . '
+ Whole-Year Score: ' . esc($yearText) . '
+ Decision: ' . esc($decision) . ' +

+ '; + + if ($notes !== '') { + $html .= ' +

+ Decision Notes:
+ ' . nl2br(esc($notes)) . ' +

+ '; + } + + /* + * Add the exact Details-button score/comment content into the email. + */ + $html .= $this->buildDecisionEmailDetailsHtml($allSemesters); + + $html .= ' +

+ Please contact the school administration if you have any questions. +

+ +

+ Regards,
+ Al Rahma Sunday School +

+ '; + + return $this->response->setJSON([ + 'ok' => true, + 'subject' => $subject, + 'html' => $html, + 'decision' => $decision, + 'score' => $yearScore, + ]); +} + +private function buildDecisionEmailDetailsHtml(array $semesters): string +{ + if (empty($semesters)) { + return ' +

Detailed Scores and Comments

+

No detailed score data found.

+ '; + } + + $scoreLabels = [ + 'homework_avg' => 'Homework Avg', + 'project_avg' => 'Project Avg', + 'participation_score' => 'Participation', + 'test_avg' => 'Test Avg', + 'ptap_score' => 'PTAP Score', + 'attendance_score' => 'Attendance', + 'midterm_exam_score' => 'Midterm Score', + 'final_exam_score' => 'Final Exam', + 'semester_score' => 'Semester Score', + ]; + + $commentTypeLabels = [ + 'general' => 'General', + 'attendance' => 'Attendance', + 'attendance_comment' => 'Attendance', + 'midterm' => 'Midterm', + 'final' => 'Final Exam', + 'ptap' => 'PTAP', + ]; + + $html = ' +
+

Detailed Scores and Comments

+ '; + + foreach ($semesters as $sem) { + $semesterName = trim((string)($sem['semester'] ?? '')); + + if ($semesterName === '') { + $semesterName = 'Semester'; + } + + $classSectionName = trim((string)($sem['class_section_name'] ?? '')); + + $html .= ' +

+ ' . esc($semesterName) . ' Semester'; + + if ($classSectionName !== '') { + $html .= ' — ' . esc($classSectionName); + } + + $html .= ' +

+ + + + + + + + + + '; + + $hasScoreRow = false; + + foreach ($scoreLabels as $key => $label) { + if (!array_key_exists($key, $sem)) { + continue; + } + + $value = $sem[$key]; + + if ($value === null || $value === '') { + continue; + } + + $scoreText = is_numeric($value) + ? number_format((float)$value, 2) + : (string)$value; + + $fontWeight = $key === 'semester_score' ? 'font-weight:bold;' : ''; + + $html .= ' + + + + + '; + + $hasScoreRow = true; + } + + if (!$hasScoreRow) { + $html .= ' + + + + '; + } + + $html .= ' + +
ItemScore
' . esc($label) . '' . esc($scoreText) . '
No scores recorded.
+ '; + + $comments = $sem['comments'] ?? []; + + if (is_array($comments) && !empty($comments)) { + $deduped = []; + $seen = []; + + foreach ($comments as $type => $text) { + $text = trim((string)$text); + + if ($text === '') { + continue; + } + + $label = $commentTypeLabels[$type] ?? (string)$type; + $key = $label . '|' . $text; + + if (isset($seen[$key])) { + continue; + } + + $seen[$key] = true; + $deduped[] = [ + 'label' => $label, + 'text' => $text, + ]; + } + + if (!empty($deduped)) { + $html .= ' +

Comments

+ '; + + foreach ($deduped as $comment) { + $html .= ' +

+ ' . esc($comment['label']) . ':
+ ' . nl2br(esc($comment['text'])) . ' +

+ '; + } + } + } + } + + return $html; +} + +public function sendBelowSixtyDecisionEmail() +{ + $studentId = (int)($this->request->getPost('student_id') ?? 0); + $schoolYear = trim((string)($this->request->getPost('school_year') ?? '')); + $subjectInput = trim((string)($this->request->getPost('subject') ?? '')); + $htmlInput = (string)($this->request->getPost('html') ?? ''); + + if ($studentId <= 0 || $schoolYear === '') { + return redirect()->back()->with('error', 'Missing student or school year.'); + } + + /* + * Whole-year decision email. + * Do NOT use sendBelowSixtyEmail(), because that one is semester-score based. + */ + $semester = 'year'; + + $context = $this->getBelowSixtyDecisionEmailContext($studentId, $schoolYear); + + if (empty($context['student'])) { + return redirect()->back()->with('error', 'Student not found.'); + } + + if (empty($context['decision_row']) || trim((string)($context['decision_row']['decision'] ?? '')) === '') { + return redirect()->back()->with('error', 'No saved decision found for this student.'); + } + + $studentName = $context['student_name']; + $classSectionName = $context['class_section_name']; + $decisionRow = $context['decision_row']; + + $subject = $subjectInput !== '' + ? $subjectInput + : 'Whole Year Academic Decision — ' . $studentName . ' (' . $schoolYear . ')'; + + $payload = [ + 'student_id' => $studentId, + 'student_name' => $studentName, + 'class_section_name' => $classSectionName, + 'semester' => 'year', + 'school_year' => $schoolYear, + 'decision' => (string)($decisionRow['decision'] ?? ''), + 'notes' => (string)($decisionRow['notes'] ?? ''), + 'subject' => $subject, + 'scores' => [ + 'fall_score' => $context['fall_score'], + 'spring_score' => $context['spring_score'], + 'year_score' => $context['year_score'], + ], + 'all_semesters' => $context['all_semesters'], + ]; + + if (trim($htmlInput) !== '') { + $payload['html'] = $htmlInput; + } + + Events::trigger('below60.decision_email', $payload); + + $query = http_build_query([ + 'semester' => 'year', + 'school_year' => $schoolYear, + ]); + + return redirect() + ->to(base_url('grading/below-60/decisions') . '?' . $query) + ->with('status', 'Decision email sent to parent(s).'); +} + + +private function getBelowSixtyDecisionEmailContext(int $studentId, string $schoolYear): array +{ + $student = $this->db->table('students s') + ->select([ + 's.id', + 's.firstname', + 's.lastname', + 's.is_active', + ]) + ->where('s.id', $studentId) + ->get() + ->getRowArray(); + + /* + * Do not require is_active = 1 here. + * You were getting "Student not found" even though the student ID exists. + * If the record exists, let the email preview work. + */ + if (!$student) { + return [ + 'student' => null, + 'decision_row' => null, + 'student_name' => '', + 'class_section_name' => '', + 'fall_score' => null, + 'spring_score' => null, + 'year_score' => null, + 'all_semesters' => [], + ]; + } + + $studentName = trim((string)($student['firstname'] ?? '') . ' ' . (string)($student['lastname'] ?? '')); + + if ($studentName === '') { + $studentName = 'Student'; + } + + $decisionRow = $this->db->table('below_sixty_decisions') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->where('semester', 'year') + ->get() + ->getRowArray(); + + $scoreRows = $this->db->table('semester_scores ss') + ->select([ + 'LOWER(TRIM(ss.semester)) AS sem_key', + 'ss.semester', + 'ss.semester_score', + 'ss.class_section_id', + 'cs.class_section_name', + ]) + ->join('classSection cs', 'cs.class_section_id = ss.class_section_id', 'left') + ->where('ss.student_id', $studentId) + ->where('ss.school_year', $schoolYear) + ->whereIn('LOWER(TRIM(ss.semester))', ['fall', 'spring']) + ->where('ss.semester_score IS NOT NULL', null, false) + ->orderBy('ss.updated_at', 'DESC') + ->orderBy('ss.id', 'DESC') + ->get() + ->getResultArray(); + + $fallScore = null; + $springScore = null; + $classSectionName = ''; + + foreach ($scoreRows as $sr) { + $semKey = strtolower(trim((string)($sr['sem_key'] ?? ''))); + $score = is_numeric($sr['semester_score'] ?? null) ? (float)$sr['semester_score'] : null; + + if ($classSectionName === '' && !empty($sr['class_section_name'])) { + $classSectionName = (string)$sr['class_section_name']; + } + + if ($score === null) { + continue; + } + + if ($semKey === 'fall' && $fallScore === null) { + $fallScore = $score; + } + + if ($semKey === 'spring' && $springScore === null) { + $springScore = $score; + } + } + + if ($classSectionName === '') { + $enrollment = $this->db->table('student_class sc') + ->select('cs.class_section_name') + ->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left') + ->where('sc.student_id', $studentId) + ->where('sc.school_year', $schoolYear) + ->orderBy('sc.id', 'DESC') + ->get() + ->getRowArray(); + + $classSectionName = (string)($enrollment['class_section_name'] ?? ''); + } + + if ($fallScore !== null && $springScore !== null) { + $yearScore = round(($fallScore + $springScore) / 2, 2); + } elseif ($fallScore !== null) { + $yearScore = round($fallScore, 2); + } elseif ($springScore !== null) { + $yearScore = round($springScore, 2); + } else { + $yearScore = null; + } + + return [ + 'student' => $student, + 'decision_row' => $decisionRow, + 'student_name' => $studentName, + 'class_section_name' => $classSectionName, + 'fall_score' => $fallScore, + 'spring_score' => $springScore, + 'year_score' => $yearScore, + 'all_semesters' => $this->fetchAllSemestersForStudent($studentId, $schoolYear), + ]; +} + public function previewDecisionEmail() { $studentId = (int)$this->request->getGet('student_id'); diff --git a/financial_system_fix_plan_no_paypal_with_tuition_forecast.md b/financial_system_fix_plan_no_paypal_with_tuition_forecast.md new file mode 100644 index 0000000..9afa25f --- /dev/null +++ b/financial_system_fix_plan_no_paypal_with_tuition_forecast.md @@ -0,0 +1,1658 @@ +# Financial System Fix Plan, PayPal Removed + +## 1. Purpose + +This document defines the final remediation plan for the project financial system after removing PayPal from the payment flow. + +The goal is to make the financial system consistent, secure, auditable, and easier to maintain. The current system has several risky patterns: duplicated balance calculations, direct invoice mutations from multiple controllers, inconsistent statuses, incomplete authorization on financial routes, inconsistent upload validation, and stale invoice balances after some payment/refund actions. + +PayPal should be removed entirely rather than partially repaired. A half-integrated payment gateway is not a feature. It is a liability with a button. + +--- + +## 2. Scope + +This plan covers: + +- invoice balances +- manual payments +- discounts +- refunds +- additional charges +- event charges +- expenses +- reimbursements +- financial uploads +- route protection +- database cleanup +- ledger recalculation +- old tuition calculator preservation +- new tuition calculation rules +- projected collection reporting UI +- financial tests +- PayPal removal + +This plan does not include adding a replacement online payment provider. After this work, the system should support internal/manual payment recording only unless a new provider is designed separately. + +--- + +## 3. Target Outcome + +The fixed system must satisfy these conditions: + +1. Invoice balances are calculated by one centralized ledger service. +2. Controllers do not manually calculate invoice totals, paid amounts, or balances. +3. All financial writes happen inside database transactions. +4. Financial routes require explicit authentication and permissions. +5. Users cannot access another user's invoices, payments, or financial files. +6. Upload validation is consistent across payments, refunds, expenses, and reimbursements. +7. PayPal routes, controllers, models, views, configs, commands, menus, and webhook exclusions are removed. +8. Status values are normalized and reused through constants. +9. A dry-run recalculation command can audit all invoice balances before changing production data. +10. Tests cover the main ledger scenarios. +11. The old tuition calculator remains available for comparison and collection forecasting. +12. The new tuition calculator supports family-based discounts: first student full amount, second and third student receive $50 discount each, fourth and later students receive $100 discount each. +13. A UI page can estimate expected collectible tuition using old and new tuition rules before invoices are generated or repaired. + +--- + +## 4. Remove PayPal Completely + +### 4.1 Remove PayPal routes + +Delete these route groups and route entries from `app/Config/Routes.php`: + +```php +$routes->get('payments/createPaypalPayment/(:num)', 'View\PaymentController::createPaypalPayment/$1'); +$routes->get('payments/executePaypalPayment', 'View\PaymentController::executePaypalPayment'); +$routes->get('payments/cancelPaypalPayment', 'View\PaymentController::cancelPaypalPayment'); +$routes->post('payments/createPaypalPayment/(:num)', 'View\PaymentController::createPaypalPayment/$1'); +$routes->post('api/paypal-webhook', 'Api\PaypalWebhook::handle'); +$routes->get('administrator/paypal_transactions', 'View\PaypalTransactionsController::index'); +$routes->get('administrator/paypal_transactions/export', 'View\PaypalTransactionsController::exportCsv'); +$routes->get('admin/paypal-transactions', 'View\PaypalTransactionsController::index'); +$routes->get('admin/paypal-transactions/export', 'View\PaypalTransactionsController::exportCsv'); +$routes->get('/payment/paypal', 'View\PaymentController::paypal'); +``` + +Also remove any user/admin/support routes related to PayPal transactions, including: + +```php +$routes->get('paypal-transactions', ...); +$routes->get('paypal-transactions/(:num)', ...); +$routes->get('paypal-transactions/transaction/(:segment)', ...); +``` + +### 4.2 Remove PayPal methods from `PaymentController` + +In `app/Controllers/View/PaymentController.php`, remove: + +```php +createPaypalPayment() +executePaypalPayment() +cancelPaypalPayment() +paypal() +``` + +Also remove these imports: + +```php +use Config\PaypalConfig; +use PayPal\Api\Amount; +use PayPal\Api\Payment; +use PayPal\Api\PaymentExecution; +use PayPal\Api\Payer; +use PayPal\Api\Transaction; +use PayPal\Rest\ApiContext; +use PayPal\Auth\OAuthTokenCredential; +``` + +Remove these properties and constructor setup: + +```php +protected $paypalConfig; +protected $apiContext; +``` + +Remove any code that initializes PayPal API context. + +### 4.3 Delete PayPal-only files + +Delete these files if they are not used by non-PayPal features: + +```text +app/Config/PaypalConfig.php +app/Commands/SyncPaypalPayments.php +app/Controllers/View/PaypalTransactionsController.php +app/Models/PayPalPaymentModel.php +app/Models/PaypalTransactionModel.php +app/Views/administrator/paypal_transactions.php +app/Views/payment/payment_redirect.php +``` + +If `app/Controllers/Api/PaypalWebhook.php` exists, delete it too. + +### 4.4 Remove PayPal command registration + +In `app/Config/Commands.php`, remove: + +```php +\App\Commands\SyncPaypalPayments::class, +``` + +### 4.5 Remove PayPal CSRF/webhook exclusions + +In `app/Config/Filters.php`, remove PayPal webhook exceptions such as: + +```php +'api/paypal-webhook', +'index.php/api/paypal-webhook', +``` + +The webhook will no longer exist. Keeping an unnecessary CSRF exception is security clutter, which is just technical debt with a fake mustache. + +### 4.6 Remove PayPal navigation/menu entries + +Remove PayPal menu entries from: + +```text +app/Database/Seeds/NavSeeder.php +app/Views/partials/navbar_back.php +``` + +Remove labels such as: + +```text +PaypalTransactions +PayPal Transactions +``` + +### 4.7 Remove PayPal buttons from parent/payment views + +In `app/Views/parent/payment_view.php`, remove the PayPal form: + +```php +
+ +
+``` + +Replace it with a neutral message or manual-payment instructions if parents need guidance: + +```php +

+ Online payment is currently unavailable. Please contact the school office to complete payment. +

+``` + +### 4.8 Remove PayPal JavaScript SDK usage + +Delete any references like: + +```html +