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 .= '
+
+
+
+
+
+ | Item |
+ Score |
+
+
+
+ ';
+
+ $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 .= '
+
+ | ' . esc($label) . ' |
+ ' . esc($scoreText) . ' |
+
+ ';
+
+ $hasScoreRow = true;
+ }
+
+ if (!$hasScoreRow) {
+ $html .= '
+
+ | No scores recorded. |
+
+ ';
+ }
+
+ $html .= '
+
+
+ ';
+
+ $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
+