diff --git a/app/Commands/ReconcileLegacyBookIssue.php b/app/Commands/ReconcileLegacyBookIssue.php new file mode 100644 index 0000000..649ed52 --- /dev/null +++ b/app/Commands/ReconcileLegacyBookIssue.php @@ -0,0 +1,196 @@ + 'Filter the read-only orphan list.', + '--movement-id' => 'One legacy distribution movement to reconcile.', + '--unit-price' => 'Admin-approved historical unit charge price.', + '--reason' => 'Required audit explanation for the evidence source.', + '--actor-id' => 'Required administrator user ID.', + '--commit' => 'Actually write; without this flag the command is read-only.', + '--json' => 'Print listing or result as JSON.', + ]; + + public function run(array $params) + { + $db = \Config\Database::connect(); + foreach (['inventory_movements', 'inventory_item_years', 'student_book_issues', 'withdrawal_financial_calculations', 'refunds'] as $table) { + if (! $db->tableExists($table)) { + CLI::error('Required table is missing: ' . $table . '. Run migrations first.'); + return; + } + } + + $movementId = (int) (CLI::getOption('movement-id') ?? 0); + if ($movementId <= 0) { + $builder = $db->table('inventory_movements im') + ->select('im.id, im.item_id, im.student_id, im.class_section_id, im.qty_change, im.school_year, im.created_at, i.name AS book_name') + ->join('inventory_items i', 'i.id = im.item_id', 'inner') + ->join('student_book_issues sbi', 'sbi.distribution_movement_id = im.id', 'left') + ->where('im.movement_type', 'distribution') + ->where('im.student_id IS NOT NULL', null, false) + ->where('im.qty_change <', 0) + ->where('im.status', 'posted') + ->where('sbi.id', null) + ->where('i.type', 'book') + ->orderBy('im.id', 'ASC'); + $year = trim((string) (CLI::getOption('school-year') ?? '')); + if ($year !== '') { + $builder->where('im.school_year', $year); + } + $rows = $builder->get()->getResultArray(); + if (CLI::getOption('json') !== null) { + CLI::write(json_encode(['count' => count($rows), 'legacy_distributions' => $rows], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + return; + } + CLI::write('Legacy book distributions requiring priced issue evidence: ' . count($rows), 'yellow'); + foreach ($rows as $row) { + CLI::write(sprintf('#%d %s student=%d qty=%d year=%s', (int) $row['id'], (string) $row['book_name'], (int) $row['student_id'], abs((int) $row['qty_change']), (string) $row['school_year'])); + } + return; + } + + try { + $priceCents = $this->priceToCents((string) (CLI::getOption('unit-price') ?? '')); + $reason = trim((string) (CLI::getOption('reason') ?? '')); + $actorId = (int) (CLI::getOption('actor-id') ?? 0); + if ($reason === '' || $actorId <= 0) { + throw new InvalidArgumentException('--reason and a positive --actor-id are required.'); + } + + $db->transBegin(); + $movement = $db->query('SELECT * FROM inventory_movements WHERE id = ? FOR UPDATE', [$movementId])->getRowArray(); + if ($movement === null || ($movement['movement_type'] ?? '') !== 'distribution' || ($movement['status'] ?? '') !== 'posted' || (int) ($movement['qty_change'] ?? 0) >= 0 || (int) ($movement['student_id'] ?? 0) <= 0) { + throw new InvalidArgumentException('The movement is not a negative student distribution.'); + } + if ($db->table('student_book_issues')->where('distribution_movement_id', $movementId)->countAllResults() > 0) { + throw new InvalidArgumentException('This movement is already linked to student issue evidence.'); + } + $book = $db->table('inventory_items')->select('id, name, type')->where('id', (int) $movement['item_id'])->get(1)->getRowArray(); + if ($book === null || ($book['type'] ?? '') !== 'book') { + throw new InvalidArgumentException('The movement item is not a book.'); + } + $schoolYear = trim((string) ($movement['school_year'] ?? '')); + if ($schoolYear === '') { + throw new InvalidArgumentException('The legacy movement has no school year. Correct that evidence first.'); + } + $itemYear = $db->table('inventory_item_years') + ->where('inventory_item_id', (int) $movement['item_id']) + ->where('school_year', $schoolYear)->get(1)->getRowArray(); + if ($itemYear === null) { + throw new InvalidArgumentException('The book has no inventory-year record for ' . $schoolYear . '.'); + } + $enrollments = $db->table('enrollments')->select('id, parent_id, class_section_id') + ->where('student_id', (int) $movement['student_id'])->where('school_year', $schoolYear)->get()->getResultArray(); + if (count($enrollments) !== 1 || (int) ($enrollments[0]['parent_id'] ?? 0) <= 0) { + throw new InvalidArgumentException('Exactly one parent-linked enrollment must exist for the student and year.'); + } + + $quantity = abs((int) $movement['qty_change']); + if ($quantity <= 0 || $priceCents > intdiv(2147483647, $quantity)) { + throw new InvalidArgumentException('The quantity and approved price exceed the supported charge range.'); + } + $now = date('Y-m-d H:i:s'); + $issuedAt = trim((string) ($movement['created_at'] ?? '')) ?: $now; + $issue = [ + 'student_id' => (int) $movement['student_id'], + 'enrollment_id' => (int) $enrollments[0]['id'], + 'parent_id' => (int) $enrollments[0]['parent_id'], + 'inventory_item_id' => (int) $movement['item_id'], + 'inventory_item_year_id' => (int) $itemYear['id'], + 'school_year' => $schoolYear, + 'class_section_id' => (int) ($movement['class_section_id'] ?? $enrollments[0]['class_section_id'] ?? 0) ?: null, + 'quantity' => $quantity, + 'unit_charge_price_cents' => $priceCents, + 'total_charge_cents' => $priceCents * $quantity, + 'distribution_movement_id' => $movementId, + 'idempotency_key' => 'legacy-distribution:' . $movementId, + 'status' => 'issued', + 'issued_at' => $issuedAt, + 'issued_by' => $actorId, + 'created_at' => $now, + 'updated_at' => $now, + ]; + if (CLI::getOption('commit') === null) { + $db->transRollback(); + $result = ['mode' => 'dry-run', 'movement_id' => $movementId, 'book' => $book['name'], 'issue' => $issue, 'reason' => $reason]; + CLI::write(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + CLI::write('DRY RUN — add --commit to save.', 'yellow'); + return; + } + if (! $db->table('student_book_issues')->insert($issue)) { + throw new RuntimeException('Unable to insert issue evidence.'); + } + $issueId = (int) $db->insertID(); + $db->table('inventory_movements')->where('id', $movementId)->update([ + 'item_year_id' => (int) $itemYear['id'], + 'idempotency_key' => 'legacy-distribution-movement:' . $movementId, + 'status' => 'posted', + 'source_type' => 'student_book_issue', + 'source_id' => $issueId, + 'note' => trim((string) ($movement['note'] ?? '') . "\nLegacy evidence approved by user #{$actorId}: {$reason}"), + 'updated_at' => $now, + ]); + $affectedCalculations = $db->table('withdrawal_financial_calculations') + ->select('id') + ->where('student_id', (int) $movement['student_id']) + ->where('school_year', $schoolYear) + ->where('status', 'posted') + ->where('withdrawal_request_date >=', substr($issuedAt, 0, 10)) + ->get()->getResultArray(); + $affectedIds = array_values(array_filter(array_map(static fn (array $row): int => (int) ($row['id'] ?? 0), $affectedCalculations))); + if ($affectedIds !== []) { + $db->table('withdrawal_financial_calculations')->whereIn('id', $affectedIds)->update([ + 'status' => 'requires_review', + 'active_posted_key' => null, + 'updated_at' => $now, + ]); + $db->table('refunds')->whereIn('withdrawal_calculation_id', $affectedIds)->update([ + 'reconciliation_status' => 'requires_review', + 'reconciliation_reason' => 'Legacy book issue evidence was added after the withdrawal calculation was posted.', + 'reconciliation_required_at' => $now, + 'updated_at' => $now, + ]); + } + if (! $db->transCommit()) { + throw new RuntimeException('Unable to commit the reconciliation.'); + } + CLI::write('Reconciled movement #' . $movementId . ' as issue #' . $issueId . ' without changing stock.', 'green'); + } catch (Throwable $e) { + $db->transRollback(); + CLI::error($e->getMessage()); + } + } + + private function priceToCents(string $price): int + { + $price = trim($price); + if (! preg_match('/^\d+(?:\.\d{1,2})?$/', $price)) { + throw new InvalidArgumentException('--unit-price is required with no more than two decimal places.'); + } + [$whole, $fraction] = array_pad(explode('.', $price, 2), 2, ''); + $cents = ((int) $whole * 100) + (int) str_pad($fraction, 2, '0'); + if ($cents <= 0 || $cents > 2147483647) { + throw new InvalidArgumentException('Unit price must be greater than zero and within range.'); + } + return $cents; + } +} diff --git a/app/Commands/WithdrawalInventoryAudit.php b/app/Commands/WithdrawalInventoryAudit.php new file mode 100644 index 0000000..4a8a469 --- /dev/null +++ b/app/Commands/WithdrawalInventoryAudit.php @@ -0,0 +1,182 @@ + 'Required school year.', '--json' => 'Print JSON.']; + + public function run(array $params) + { + $db = \Config\Database::connect(); + $year = trim((string) (CLI::getOption('school-year') ?? '')); + if ($year === '') { + CLI::error('--school-year is required; the audit will not guess an active year.'); + return; + } + $checks = []; + $policy = $db->table('school_years')->where('name', $year)->get(1)->getRowArray(); + $configWeeksRow = $db->table('configuration') + ->select('config_value') + ->where('config_key', 'total_instructional_weeks') + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + $configWeeks = filter_var($configWeeksRow['config_value'] ?? null, FILTER_VALIDATE_INT); + $checks['policy'] = $policy !== null + && $configWeeks !== false + && $configWeeks > 0 + && (int) ($policy['annual_fee_includes_books'] ?? 0) === 1 + ? $this->check('pass', 'School-year refund inputs are present.') + : $this->check('blocking', 'Configuration total_instructional_weeks or book-inclusive policy is missing.'); + + $missingPrices = $db->table('inventory_item_years iy') + ->join('inventory_items i', 'i.id = iy.inventory_item_id', 'inner') + ->where('iy.school_year', $year)->where('i.type', 'book') + ->groupStart()->where('iy.charge_price_cents <=', 0)->orWhere('iy.price_confirmed !=', 1)->groupEnd() + ->countAllResults(); + $checks['book_prices'] = $missingPrices === 0 ? $this->check('pass', 'All book-year prices are confirmed.') : $this->check('blocking', $missingPrices . ' book-year price(s) are missing or unconfirmed.'); + + $catalogRows = $db->table('inventory_items')->select('id, name, isbn, edition, sku, author')->where('type', 'book')->get()->getResultArray(); + $identityCounts = []; + $skuCounts = []; + foreach ($catalogRows as $catalogRow) { + $isbn = strtoupper((string) preg_replace('/[^0-9X]/i', '', (string) ($catalogRow['isbn'] ?? ''))); + $edition = strtolower(trim((string) ($catalogRow['edition'] ?? ''))); + $sku = strtolower(trim((string) ($catalogRow['sku'] ?? ''))); + if ($isbn !== '') { + $identityCounts[$isbn . '|' . $edition] = ($identityCounts[$isbn . '|' . $edition] ?? 0) + 1; + } + if ($sku !== '') { + $skuCounts[$sku] = ($skuCounts[$sku] ?? 0) + 1; + } + } + $duplicateCount = count(array_filter($identityCounts, static fn (int $count): bool => $count > 1)) + + count(array_filter($skuCounts, static fn (int $count): bool => $count > 1)); + $checks['duplicate_catalog'] = $duplicateCount === 0 ? $this->check('pass', 'No duplicate normalized ISBN/edition or SKU keys found.') : $this->check('blocking', $duplicateCount . ' duplicate normalized ISBN/edition or SKU group(s) require reconciliation.'); + + $missingAuthorCount = count(array_filter($catalogRows, static fn (array $row): bool => trim((string) ($row['author'] ?? '')) === '')); + $checks['catalog_author'] = $missingAuthorCount === 0 + ? $this->check('pass', 'All book catalog rows have an author.') + : $this->check('warning', $missingAuthorCount . ' book catalog row(s) have no author.'); + $unassignedBooks = $db->table('inventory_items i') + ->join('inventory_book_class_assignments a', 'a.inventory_item_id = i.id AND a.school_year = ' . $db->escape($year), 'left') + ->where('i.type', 'book')->where('i.is_active', 1)->where('a.id', null)->countAllResults(); + $checks['book_class_assignments'] = $unassignedBooks === 0 + ? $this->check('pass', 'All active books have explicit year/class assignments.') + : $this->check('warning', $unassignedBooks . ' active book(s) rely on category fallback instead of explicit class assignments.'); + + $malformedYearTags = $db->table('inventory_movements')->where('school_year IS NOT NULL', null, false) + ->where("school_year NOT REGEXP '^[0-9]{4}-[0-9]{4}$'", null, false)->countAllResults(); + $checks['movement_year_tags'] = $malformedYearTags === 0 + ? $this->check('pass', 'Inventory movement year tags are well formed.') + : $this->check('warning', $malformedYearTags . ' inventory movement(s) have malformed school-year tags.'); + + $legacyDistributions = $db->table('inventory_movements im') + ->join('student_book_issues sbi', 'sbi.distribution_movement_id = im.id', 'left') + ->where('im.school_year', $year)->where('im.movement_type', 'distribution')->where('sbi.id', null) + ->countAllResults(); + $checks['legacy_distributions'] = $legacyDistributions === 0 ? $this->check('pass', 'Every distribution has a price-snapshotted issue.') : $this->check('blocking', $legacyDistributions . ' distribution movement(s) lack student issue evidence and cannot be priced automatically.'); + + $positiveStudentMovements = $db->table('inventory_movements') + ->where('school_year', $year)->where('student_id IS NOT NULL', null, false)->where('qty_change >', 0) + ->groupStart()->where('source_type !=', 'student_book_issue_reversal')->orWhere('source_type', null)->groupEnd()->countAllResults(); + $checks['possible_returns'] = $positiveStudentMovements === 0 ? $this->check('pass', 'No unclassified positive student book movements found.') : $this->check('blocking', $positiveStudentMovements . ' positive student movement(s) look like legacy returns/corrections and require manual classification.'); + + $duplicateOpenings = $db->table('inventory_movements') + ->select('item_id')->where('school_year', $year)->where('movement_type', 'initial')->groupBy('item_id')->having('COUNT(*) > 1', null, false)->countAllResults(); + $checks['duplicate_openings'] = $duplicateOpenings === 0 ? $this->check('pass', 'No duplicate legacy opening movements found.') : $this->check('blocking', $duplicateOpenings . ' book/item(s) have duplicate opening movements.'); + + $quantityMismatches = $db->query( + "SELECT COUNT(*) AS total FROM ( + SELECT i.id + FROM inventory_items i + JOIN inventory_item_years iy ON iy.inventory_item_id = i.id AND iy.school_year = ? + LEFT JOIN inventory_movements im ON im.item_year_id = iy.id AND im.status IN ('posted','reversed') + WHERE i.type = 'book' + GROUP BY i.id, i.quantity, iy.opening_quantity + HAVING i.quantity != iy.opening_quantity + COALESCE(SUM(im.qty_change), 0) + ) mismatches", + [$year] + )->getRowArray(); + $quantityMismatchCount = (int) ($quantityMismatches['total'] ?? 0); + $checks['quantity_projection'] = $quantityMismatchCount === 0 + ? $this->check('pass', 'Book catalog quantities match the year movement ledger.') + : $this->check('blocking', $quantityMismatchCount . ' book quantity projection(s) disagree with the year movement ledger.'); + + $legacyRefunds = $db->table('refunds')->where('school_year', $year) + ->whereIn('status', ['Pending', 'pending', 'requested', 'Approved', 'approved', 'Partial', 'partial', 'partially_paid']) + ->groupStart()->where('withdrawal_calculation_id', null)->orWhere('withdrawal_calculation_id', 0)->groupEnd() + ->groupStart()->where('source_type', 'tuition_withdrawal')->orLike('reason', 'Withdrawal')->groupEnd()->countAllResults(); + $checks['legacy_refunds'] = $legacyRefunds === 0 ? $this->check('pass', 'Open withdrawal refunds have calculation links.') : $this->check('blocking', $legacyRefunds . ' open withdrawal refund(s) have no versioned calculation.'); + + $comparisonRows = $db->query( + "SELECT r.id AS refund_id, r.invoice_id, r.refund_amount, + r.requested_amount_cents AS stored_requested_cents, + wfc.id AS calculation_id, wfc.new_refund_request_cents AS calculated_requested_cents, + wfc.status AS calculation_status + FROM refunds r + LEFT JOIN withdrawal_financial_calculations wfc ON wfc.id = r.withdrawal_calculation_id + WHERE r.school_year = ? + AND (r.source_type = 'tuition_withdrawal' OR r.reason LIKE '%Withdrawal%') + AND LOWER(COALESCE(r.status,'')) IN ('pending','requested','approved','partial','partially_paid') + ORDER BY r.id", + [$year] + )->getResultArray(); + $comparisonDifferences = 0; + foreach ($comparisonRows as &$comparison) { + $storedCents = (int) ($comparison['stored_requested_cents'] ?? 0); + if ($storedCents === 0) { + $storedCents = (int) round(((float) ($comparison['refund_amount'] ?? 0)) * 100, 0, PHP_ROUND_HALF_UP); + } + $comparison['stored_requested_cents'] = $storedCents; + $comparison['difference_cents'] = $comparison['calculation_id'] === null + ? null + : $storedCents - (int) ($comparison['calculated_requested_cents'] ?? 0); + if ($comparison['difference_cents'] !== null && $comparison['difference_cents'] !== 0) { + $comparisonDifferences++; + } + } + unset($comparison); + $checks['refund_comparison'] = $comparisonDifferences === 0 + ? $this->check('pass', 'Linked open refund requests match their versioned calculations.') + : $this->check('blocking', $comparisonDifferences . ' linked open refund request(s) differ from their calculation snapshot.'); + + $duplicateInvoices = $db->query( + "SELECT COUNT(*) AS total FROM ( + SELECT parent_id FROM invoices WHERE school_year = ? + AND LOWER(COALESCE(status,'')) NOT IN ('void','voided','cancelled','canceled') + GROUP BY parent_id HAVING COUNT(*) > 1 + ) duplicates", + [$year] + )->getRowArray(); + $duplicateInvoiceCount = (int) ($duplicateInvoices['total'] ?? 0); + $checks['duplicate_invoices'] = $duplicateInvoiceCount === 0 ? $this->check('pass', 'No parent has multiple active invoices.') : $this->check('blocking', $duplicateInvoiceCount . ' parent(s) have multiple active invoices and require review.'); + + $blocking = count(array_filter($checks, static fn (array $check): bool => $check['severity'] === 'blocking')); + $report = ['school_year' => $year, 'generated_at' => date('Y-m-d H:i:s'), 'ready_to_enable' => $blocking === 0, 'blocking_count' => $blocking, 'checks' => $checks, 'open_refund_comparison' => $comparisonRows]; + if (CLI::getOption('json') !== null) { + CLI::write(json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + return; + } + CLI::write('Withdrawal and inventory audit for ' . $year, 'yellow'); + foreach ($checks as $name => $check) { + $color = $check['severity'] === 'blocking' ? 'red' : ($check['severity'] === 'warning' ? 'yellow' : 'green'); + CLI::write(strtoupper($check['severity']) . ' ' . $name . ': ' . $check['message'], $color); + } + CLI::write($blocking === 0 ? 'READY TO ENABLE' : 'DO NOT ENABLE — blockers remain', $blocking === 0 ? 'green' : 'red'); + } + + private function check(string $severity, string $message): array + { + return ['severity' => $severity, 'message' => $message]; + } +} diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 71f4244..1f43fa2 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -122,6 +122,12 @@ $routes->get('administrator/financial-aid', 'Administrator\FinancialAidControlle $routes->get('administrator/financial-aid/(:num)', 'Administrator\FinancialAidController::show/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']); $routes->post('administrator/financial-aid/(:num)/approve', 'Administrator\FinancialAidController::approve/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']); $routes->post('administrator/financial-aid/(:num)/deny', 'Administrator\FinancialAidController::deny/$1', ['filter' => 'auth:admin|administrator|principal|oversee_financial_aid']); +$withdrawalFinancialFilter = 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal'; +$routes->get('administrator/withdrawals/(:num)/review', 'Administrator\WithdrawalFinancialController::review/$1', ['filter' => $withdrawalFinancialFilter]); +$routes->post('administrator/withdrawals/(:num)/recalculate', 'Administrator\WithdrawalFinancialController::recalculate/$1', ['filter' => $withdrawalFinancialFilter . ',update']); +$routes->post('administrator/withdrawal-calculations/(:num)/confirm', 'Administrator\WithdrawalFinancialController::confirm/$1', ['filter' => $withdrawalFinancialFilter . ',update']); +$routes->get('administrator/withdrawal-calculations/(:num)', 'Administrator\WithdrawalFinancialController::calculation/$1', ['filter' => $withdrawalFinancialFilter]); +$routes->get('administrator/invoices/(:num)/withdrawal-calculations', 'Administrator\WithdrawalFinancialController::invoiceSummary/$1', ['filter' => $withdrawalFinancialFilter]); // API for report card meta (students, class sections, school years) $routes->get('api/printables/report-card/meta', 'View\ReportCardsController::reportCardMeta', ['filter' => 'auth']); $routes->get('api/printables/report-card/completeness', 'View\ReportCardsController::reportCardCompleteness', ['filter' => 'auth']); @@ -944,6 +950,8 @@ $routes->group('inventory', ['filter' => 'auth:view_inventory|administrator|admi $routes->get('summary-all', 'View\InventoryController::summaryAll'); $routes->get('adjust/(:num)', 'View\InventoryController::adjustForm/$1'); $routes->post('adjust/(:num)', 'View\InventoryController::adjustStore/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']); + $routes->get('book-prices', 'View\InventoryController::bookPrices'); + $routes->post('book-prices', 'View\InventoryController::updateBookPrices', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']); $routes->get('books/distribute', 'View\InventoryController::teacherDistributeForm'); $routes->post('books/distribute', 'View\InventoryController::teacherDistributeStore', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']); $routes->get('classroom/audit/(:num)', 'View\InventoryController::auditClassroomForm/$1'); diff --git a/app/Config/Services.php b/app/Config/Services.php index ca33655..f5c4858 100644 --- a/app/Config/Services.php +++ b/app/Config/Services.php @@ -505,4 +505,21 @@ class Services extends BaseService model(\App\Models\StudentYearStatusModel::class) ); } + + public static function withdrawalFinancial(bool $getShared = true): \App\Services\WithdrawalFinancialService + { + if ($getShared) { + return static::getSharedInstance('withdrawalFinancial'); + } + + $db = \Config\Database::connect(); + + return new \App\Services\WithdrawalFinancialService( + $db, + new \App\Services\WithdrawalRefundCalculator(), + new \App\Services\StudentBookIssueService($db), + new \App\Libraries\InvoiceLedgerService(), + new \App\Libraries\RefundEligibilityService() + ); + } } diff --git a/app/Controllers/Administrator/SchoolYearController.php b/app/Controllers/Administrator/SchoolYearController.php index 97078c9..b07fab4 100644 --- a/app/Controllers/Administrator/SchoolYearController.php +++ b/app/Controllers/Administrator/SchoolYearController.php @@ -3,6 +3,7 @@ namespace App\Controllers\Administrator; use App\Controllers\BaseController; +use App\Models\ConfigurationModel; use App\Models\SchoolYearModel; use App\Support\SchoolYear\SchoolYearStatus; use Throwable; @@ -43,6 +44,8 @@ class SchoolYearController extends BaseController } } + $configurationModel = new ConfigurationModel(); + return view('school_years/index', [ 'schoolYears' => $schoolYears, 'statuses' => SchoolYearStatus::ALL, @@ -54,6 +57,7 @@ class SchoolYearController extends BaseController 'latestTransitions' => service('schoolYearManagement')->latestTransitionByYear(), 'yearVerification' => $this->verificationByYear($schoolYears), 'schoolYearNamesById' => array_column($schoolYears, 'name', 'id'), + 'totalInstructionalWeeks' => $configurationModel->getConfigValueByKey('total_instructional_weeks'), ]); } diff --git a/app/Controllers/Administrator/WithdrawalFinancialController.php b/app/Controllers/Administrator/WithdrawalFinancialController.php new file mode 100644 index 0000000..3f5ba32 --- /dev/null +++ b/app/Controllers/Administrator/WithdrawalFinancialController.php @@ -0,0 +1,126 @@ +latestForEnrollment($enrollmentId); + if ($calculation === null || ($calculation['status'] ?? '') === 'superseded') { + $calculation = service('withdrawalFinancial')->preview($enrollmentId, $this->userId()); + } elseif ($this->hasStaleInstructionalWeeksBlocker($calculation)) { + $calculation = service('withdrawalFinancial')->preview($enrollmentId, $this->userId()); + } + + return view('withdrawals/review', [ + 'calculation' => $this->withPostingGateBlockers($calculation), + ]); + } catch (Throwable $e) { + return redirect()->back()->with('error', $e->getMessage()); + } + } + + public function recalculate(int $enrollmentId) + { + try { + $calculation = service('withdrawalFinancial')->preview($enrollmentId, $this->userId(), [ + 'enrollment_date' => (string) $this->request->getPost('enrollment_date'), + 'withdrawal_request_date' => (string) $this->request->getPost('withdrawal_request_date'), + 'override_reason' => (string) $this->request->getPost('override_reason'), + 'legacy_invoice_confirmed' => (string) $this->request->getPost('legacy_invoice_confirmed') === '1', + ]); + + return redirect()->to('/administrator/withdrawals/' . $enrollmentId . '/review') + ->with('success', 'Calculation preview version ' . (int) $calculation['version'] . ' saved.'); + } catch (Throwable $e) { + return redirect()->back()->withInput()->with('error', $e->getMessage()); + } + } + + public function confirm(int $calculationId) + { + try { + $calculation = service('withdrawalFinancial')->post($calculationId, $this->userId()); + + return redirect()->to('/administrator/withdrawal-calculations/' . $calculationId) + ->with('success', ((int) ($calculation['new_refund_request_cents'] ?? 0)) > 0 + ? 'Withdrawal posted and the refund request was created.' + : 'Withdrawal posted. No refund is due; the invoice balance was recalculated.'); + } catch (Throwable $e) { + return redirect()->back()->with('error', $e->getMessage()); + } + } + + public function calculation(int $calculationId) + { + try { + $calculation = service('withdrawalFinancial')->details($calculationId); + if ($this->hasStaleInstructionalWeeksBlocker($calculation)) { + $calculation = service('withdrawalFinancial')->preview((int) $calculation['enrollment_id'], $this->userId()); + + return redirect()->to('/administrator/withdrawal-calculations/' . (int) $calculation['id']) + ->with('success', 'Calculation preview refreshed with the configured total instructional weeks.'); + } + + return view('withdrawals/calculation', [ + 'calculation' => $calculation, + ]); + } catch (Throwable $e) { + return redirect()->back()->with('error', $e->getMessage()); + } + } + + public function invoiceSummary(int $invoiceId) + { + try { + return view('withdrawals/invoice_summary', [ + 'calculations' => service('withdrawalFinancial')->calculationsForInvoice($invoiceId), + 'invoiceId' => $invoiceId, + ]); + } catch (Throwable $e) { + return redirect()->back()->with('error', $e->getMessage()); + } + } + + private function hasStaleInstructionalWeeksBlocker(array $calculation): bool + { + if (($calculation['status'] ?? '') !== 'requires_review' || ! empty($calculation['posted_at'])) { + return false; + } + + $blockers = array_map('strval', (array) ($calculation['blockers'] ?? [])); + + return in_array('Set a positive total_instructional_weeks value for this school year.', $blockers, true) + || in_array('Set a positive total_instructional_weeks value in configuration.', $blockers, true); + } + + /** @return array */ + private function withPostingGateBlockers(array $calculation): array + { + $blockers = array_values(array_filter(array_map('strval', (array) ($calculation['blockers'] ?? [])))); + foreach (service('withdrawalFinancial')->postingGateBlockers($calculation) as $blocker) { + if (! in_array($blocker, $blockers, true)) { + $blockers[] = $blocker; + } + } + + $calculation['blockers'] = $blockers; + if (isset($calculation['explanation']) && is_array($calculation['explanation'])) { + $calculation['explanation']['blockers'] = $blockers; + } + + return $calculation; + } + + private function userId(): ?int + { + $id = session()->get('user_id'); + + return $id === null || $id === '' ? null : (int) $id; + } +} diff --git a/app/Controllers/View/InventoryController.php b/app/Controllers/View/InventoryController.php index a69418a..825b56c 100644 --- a/app/Controllers/View/InventoryController.php +++ b/app/Controllers/View/InventoryController.php @@ -76,9 +76,14 @@ class InventoryController extends BaseController $selectedSem = $selectedSemRaw === null ? (string) $this->semester : trim((string) $selectedSemRaw); $builder = $this->itemModel->where('type', $type); - $this->applyInventoryMovementPeriodFilter($builder, 'inventory_items', $selectedYear, $selectedSem); + if ($type !== 'book') { + $this->applyInventoryMovementPeriodFilter($builder, 'inventory_items', $selectedYear, $selectedSem); + } $items = $builder->orderBy('name', 'ASC')->findAll(); + if ($type === 'book') { + $items = $this->attachBookYearRows($items, $selectedYear); + } $categories = $this->catModel->optionsForType($type); // NEW: build UpdatedBy name map @@ -106,6 +111,9 @@ class InventoryController extends BaseController // Load categories for THIS item type $categories = $this->catModel->optionsForType($item['type']); + if (($item['type'] ?? '') === 'book') { + $item = $this->withBookYearData($item); + } return view($this->viewForForm($item['type']), [ 'type' => $item['type'], @@ -135,6 +143,9 @@ class InventoryController extends BaseController if (!$this->itemModel->update($id, $data)) { return redirect()->back()->withInput()->with('error', implode(', ', $this->itemModel->errors())); } + if (($item['type'] ?? '') === 'book') { + $this->saveBookClassAssignments($id); + } return redirect()->to(site_url('inventory/' . $item['type']))->with('success', 'Item updated.'); } @@ -148,6 +159,15 @@ class InventoryController extends BaseController $itemId = $this->itemModel->insert($itemData, true); if ($itemId) { $initialQty = (int) ($itemData['quantity'] ?? 0); + if (($itemData['type'] ?? '') === 'book') { + $this->saveBookClassAssignments((int) $itemId); + if ($initialQty !== 0) { + $this->recordMovement($itemId, $initialQty, 'initial', 'Initial stock'); + } else { + $this->recalcQuantity($itemId); + } + return redirect()->to(site_url("inventory/{$itemData['type']}"))->with('success', 'Item added.'); + } if ($initialQty !== 0) { $this->recordMovement($itemId, $initialQty, 'initial', 'Initial stock'); } else { @@ -341,6 +361,13 @@ class InventoryController extends BaseController if ($type === 'book') { $base['isbn'] = $this->post('isbn'); $base['edition'] = $this->post('edition'); + $base['author'] = $this->post('author'); + $base['is_active'] = 1; + $isbn = preg_replace('/[^0-9X]/i', '', strtoupper((string) $base['isbn'])); + $edition = strtolower(trim((string) $base['edition'])); + $sku = strtolower(trim((string) $base['sku'])); + $base['isbn_edition_key'] = $isbn !== '' ? $isbn . '|' . $edition : null; + $base['sku_normalized'] = $sku !== '' ? $sku : null; } else { $base['isbn'] = $base['edition'] = null; } @@ -348,6 +375,191 @@ class InventoryController extends BaseController return $base; } + private function withBookYearData(array $item): array + { + $itemYear = $this->bookItemYear((int) $item['id'], false); + if ($itemYear !== null) { + $item['charge_price'] = number_format(((int) ($itemYear['charge_price_cents'] ?? 0)) / 100, 2, '.', ''); + $item['price_confirmed'] = (int) ($itemYear['price_confirmed'] ?? 0); + $item['item_year_id'] = (int) $itemYear['id']; + $item['opening_quantity'] = (int) ($itemYear['opening_quantity'] ?? 0); + } + $item['classes'] = array_map( + 'intval', + array_column( + $this->db->table('inventory_book_class_assignments') + ->select('class_number') + ->where('inventory_item_id', (int) $item['id']) + ->where('school_year', $this->schoolYear) + ->orderBy('class_number', 'ASC') + ->get()->getResultArray(), + 'class_number' + ) + ); + return $item; + } + + private function attachBookYearRows(array $items, string $schoolYear): array + { + $ids = array_values(array_filter(array_map(static fn (array $item): int => (int) ($item['id'] ?? 0), $items))); + if ($ids === []) { + return $items; + } + $rows = $this->db->table('inventory_item_years') + ->whereIn('inventory_item_id', $ids) + ->where('school_year', $schoolYear) + ->get()->getResultArray(); + $byItem = []; + foreach ($rows as $row) { + $byItem[(int) $row['inventory_item_id']] = $row; + } + foreach ($items as &$item) { + $row = $byItem[(int) ($item['id'] ?? 0)] ?? null; + if ($row !== null) { + $item['charge_price_cents'] = (int) ($row['charge_price_cents'] ?? 0); + $item['price_confirmed'] = (int) ($row['price_confirmed'] ?? 0); + $item['opening_quantity'] = (int) ($row['opening_quantity'] ?? 0); + } + } + unset($item); + return $items; + } + + public function bookPrices() + { + $books = $this->itemModel + ->where('type', 'book') + ->orderBy('name', 'ASC') + ->findAll(); + $books = $this->attachBookYearRows($books, (string) $this->schoolYear); + + return view('inventory/book/prices', [ + 'books' => $books, + 'categories' => $this->catModel->optionsForType('book'), + 'schoolYear' => $this->schoolYear, + ]); + } + + public function updateBookPrices() + { + $prices = (array) $this->request->getPost('prices'); + $confirmed = (array) $this->request->getPost('confirmed'); + $ids = array_values(array_unique(array_filter(array_map('intval', array_keys($prices))))); + + if ($ids === []) { + return redirect()->back()->with('warning', 'No book prices were submitted.'); + } + + $books = $this->itemModel + ->where('type', 'book') + ->whereIn('id', $ids) + ->findAll(); + $booksById = []; + foreach ($books as $book) { + $booksById[(int) $book['id']] = $book; + } + + $year = $this->currentSchoolYearRow(); + $updated = 0; + + try { + $this->db->transStart(); + foreach ($ids as $id) { + if (! isset($booksById[$id])) { + continue; + } + + $isConfirmed = isset($confirmed[$id]) && (string) $confirmed[$id] === '1'; + $priceCents = $this->moneyValueToCents($prices[$id] ?? ''); + if ($isConfirmed && $priceCents <= 0) { + throw new \InvalidArgumentException('Confirmed book prices must be greater than $0.00.'); + } + + $itemYear = $this->bookItemYear($id, false); + $payload = [ + 'inventory_item_id' => $id, + 'school_year_id' => (int) ($year['id'] ?? 0) ?: null, + 'school_year' => $this->schoolYear, + 'charge_price_cents' => $priceCents, + 'currency' => 'USD', + 'price_confirmed' => $isConfirmed ? 1 : 0, + 'status' => 'open', + 'updated_by' => $this->currentUserId(), + 'updated_at' => utc_now(), + ]; + + if ($itemYear === null) { + $payload['opening_quantity'] = (int) ($booksById[$id]['quantity'] ?? 0); + $payload['created_by'] = $this->currentUserId(); + $payload['created_at'] = utc_now(); + $this->db->table('inventory_item_years')->insert($payload); + } else { + $this->db->table('inventory_item_years')->where('id', (int) $itemYear['id'])->update($payload); + } + $updated++; + } + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + throw new \RuntimeException('Book prices could not be saved.'); + } + } catch (\Throwable $e) { + $this->db->transRollback(); + return redirect()->back()->withInput()->with('error', $e->getMessage()); + } + + return redirect()->to(site_url('inventory/book-prices'))->with('success', 'Saved prices for ' . $updated . ' book(s).'); + } + + private function saveBookClassAssignments(int $itemId): void + { + $classes = array_values(array_unique(array_filter( + array_map('intval', (array) $this->request->getPost('classes')), + static fn (int $class): bool => $class >= 1 && $class <= 13 + ))); + $this->db->table('inventory_book_class_assignments') + ->where('inventory_item_id', $itemId) + ->where('school_year', $this->schoolYear) + ->delete(); + foreach ($classes as $classNumber) { + $this->db->table('inventory_book_class_assignments')->insert([ + 'inventory_item_id' => $itemId, + 'school_year' => $this->schoolYear, + 'class_number' => $classNumber, + 'created_at' => utc_now(), + ]); + } + } + + private function moneyValueToCents($value): int + { + $raw = trim((string) $value); + if ($raw === '') { + return 0; + } + if (! preg_match('/^\d+(?:\.\d{1,2})?$/', $raw)) { + throw new \InvalidArgumentException('Book charge price must be a valid dollar amount with at most two decimals.'); + } + return (int) round(((float) $raw) * 100); + } + + private function currentSchoolYearRow(): ?array + { + return $this->db->table('school_years') + ->where('name', $this->schoolYear) + ->get(1) + ->getRowArray(); + } + + private function bookItemYear(int $itemId, bool $forUpdate = false): ?array + { + $sql = 'SELECT * FROM inventory_item_years WHERE inventory_item_id = ? AND school_year = ? LIMIT 1'; + if ($forUpdate) { + $sql .= ' FOR UPDATE'; + } + return $this->db->query($sql, [$itemId, $this->schoolYear])->getRowArray(); + } + public function auditClassroomForm(int $itemId) { $item = $this->itemModel->find($itemId); @@ -742,12 +954,17 @@ class InventoryController extends BaseController $db = \Config\Database::connect(); $builder = $db->table('inventory_items i') ->select('i.id,i.name,i.isbn,i.edition,i.quantity,i.category_id,i.type, + iy.id AS item_year_id, iy.charge_price_cents, iy.price_confirmed, c.name AS category_name, c.grade_min, c.grade_max') ->join('inventory_categories c', 'c.id = i.category_id', 'left') + ->join('inventory_item_years iy', 'iy.inventory_item_id = i.id AND iy.school_year = ' . $db->escape($this->schoolYear), 'left') + ->join('inventory_book_class_assignments bca', 'bca.inventory_item_id = i.id AND bca.school_year = ' . $db->escape($this->schoolYear), 'left') ->where('i.type', 'book'); if (!empty($classID)) { $builder->groupStart() + ->where('bca.class_number', (int) $classID) + ->orGroupStart() // Case A: both bounds set and classID between [min..max] ->groupStart() ->where('c.grade_min IS NOT NULL', null, false) @@ -769,10 +986,11 @@ class InventoryController extends BaseController ->where('c.grade_min', null) // IS NULL ->where('c.grade_max', (int)$classID) // = ->groupEnd() + ->groupEnd() ->groupEnd(); } - $rows = $builder->orderBy('i.name', 'ASC')->get()->getResultArray(); + $rows = $builder->groupBy('i.id')->orderBy('i.name', 'ASC')->get()->getResultArray(); // Build CATEGORY groups keyed by category_id $labelFor = static function (?array $r): string { @@ -803,6 +1021,8 @@ class InventoryController extends BaseController 'id' => (int)$r['id'], 'display' => $display, 'quantity' => (int)($r['quantity'] ?? 0), + 'charge_price_cents' => (int)($r['charge_price_cents'] ?? 0), + 'price_confirmed' => (int)($r['price_confirmed'] ?? 0), ]; } @@ -819,16 +1039,16 @@ class InventoryController extends BaseController // 7) History map: how many of the selected book each student already received this school year $already = []; if ($itemId && $classSectionId) { - $rowsHist = $this->movModel - ->select('student_id, SUM(CASE WHEN qty_change < 0 THEN -qty_change ELSE 0 END) AS qty') - ->where([ - 'item_id' => $itemId, - 'movement_type' => 'distribution', - 'class_section_id' => $classSectionId, - 'school_year' => $ctx['schoolYear'], - ]) + $itemYear = $this->bookItemYear($itemId, false); + $rowsHist = $this->db->table('student_book_issues') + ->select('student_id, SUM(quantity) AS qty') + ->where('inventory_item_id', $itemId) + ->where('inventory_item_year_id', (int) ($itemYear['id'] ?? 0)) + ->where('class_section_id', $classSectionId) + ->where('school_year', $ctx['schoolYear']) + ->where('status', 'issued') ->groupBy('student_id') - ->findAll(); + ->get()->getResultArray(); foreach ($rowsHist as $r) { $sid = (int) ($r['student_id'] ?? 0); @@ -838,11 +1058,16 @@ class InventoryController extends BaseController // 8) On-hand for the selected book (to show live available stock) $onHand = 0; + $unitChargeCents = 0; + $priceConfirmed = 0; if ($itemId) { $book = $this->itemModel->find($itemId); if ($book && ($book['type'] ?? '') === 'book') { $onHand = (int) ($book['quantity'] ?? 0); } + $itemYear = $this->bookItemYear($itemId, false); + $unitChargeCents = (int) ($itemYear['charge_price_cents'] ?? 0); + $priceConfirmed = (int) ($itemYear['price_confirmed'] ?? 0); } // 9) Render view @@ -858,6 +1083,8 @@ class InventoryController extends BaseController 'students' => $students, 'already' => $already, 'onHand' => $onHand, + 'unitChargeCents' => $unitChargeCents, + 'priceConfirmed' => $priceConfirmed, 'schoolYear' => $ctx['schoolYear'], 'semester' => $ctx['semester'], @@ -910,50 +1137,34 @@ class InventoryController extends BaseController $selectedIds = array_values(array_unique(array_map('intval', $selectedIds))); $selectedIds = array_values(array_filter($selectedIds, fn($sid) => isset($validIds[$sid]))); - // Already distributed this year for this class/book - $already = []; - $rows = $this->movModel - ->select('student_id, SUM(CASE WHEN qty_change < 0 THEN -qty_change ELSE 0 END) AS qty') - ->where([ - 'item_id' => $itemId, - 'movement_type' => 'distribution', - 'class_section_id' => $classSectionId, - 'school_year' => $ctx['schoolYear'], - ])->groupBy('student_id')->findAll(); - foreach ($rows as $r) { - $sid = (int)($r['student_id'] ?? 0); - if ($sid) $already[$sid] = (int)$r['qty']; + try { + $year = $this->currentSchoolYearRow(); + $result = (new \App\Services\StudentBookIssueService($this->db))->distributeBatch( + $itemId, + $selectedIds, + $classSectionId, + (int) ($year['id'] ?? 0), + (string) $ctx['schoolYear'], + $this->currentUserId(), + $note, + null, + (string) $this->request->getPost('idempotency_key') + ); + } catch (\Throwable $e) { + return redirect() + ->to(site_url('inventory/books/distribute?class_section_id=' . $classSectionId . '&item_id=' . $itemId)) + ->withInput() + ->with('error', $e->getMessage()); } - // Only assign to students who don't already have it - $toGive = []; - foreach ($selectedIds as $sid) { - if (($already[$sid] ?? 0) < 1) $toGive[] = $sid; - } - - // Stock check - $needed = count($toGive); - $onHand = (int)$book['quantity']; - if ($needed > $onHand) { - return redirect()->back()->withInput()->with('error', 'Not enough stock: need ' . $needed . ', on hand ' . $onHand . '.'); - } - - // Record one movement per student - $okCount = 0; - foreach ($toGive as $sid) { - $ok = $this->recordMovement($itemId, -1, 'distribution', 'Teacher distribution', $note, $classSectionId, $sid); - if ($ok) $okCount++; - } - - if ($okCount === 0) { + if ((int) ($result['issued'] ?? 0) === 0) { return redirect() ->to(site_url('inventory/books/distribute?class_section_id=' . $classSectionId . '&item_id=' . $itemId)) ->with('warning', 'No changes (everyone selected already has this book).'); } - return redirect() ->to(site_url('inventory/books/distribute?class_section_id=' . $classSectionId . '&item_id=' . $itemId)) - ->with('success', 'Distributed to ' . $okCount . ' student(s).'); + ->with('success', 'Distributed to ' . (int) $result['issued'] . ' student(s).'); } @@ -1105,7 +1316,9 @@ class InventoryController extends BaseController public function create(string $type = 'classroom') { + $type = $this->normalizeType($type); $categories = $this->catModel->optionsForType($type); + return view("inventory/{$type}/form", [ 'type' => $type, 'item' => [], // important for create @@ -1347,6 +1560,10 @@ class InventoryController extends BaseController $userId = (int) (session('user_id') ?? 0); $itemId = (int) $this->request->getPost('item_id'); $movementType = (string) $this->request->getPost('movement_type'); + if ($movementType === 'distribution') { + return redirect()->back()->withInput()->with('status', 'error') + ->with('message', 'Student book distributions must be created from the book distribution workflow.'); + } $item = $this->itemModel->find($itemId); if (!$item) { @@ -1404,6 +1621,10 @@ class InventoryController extends BaseController return redirect()->to(site_url('inventory/movements')) ->with('status', 'error')->with('message', 'Movement not found.'); } + if ($this->isProtectedMovement($movement)) { + return redirect()->to(site_url('inventory/movements')) + ->with('status', 'error')->with('message', 'Issue-backed or distribution movements are read-only. Use correction reversal instead.'); + } // enrich for display (names) $movement = $this->hydrateNames($movement); @@ -1449,8 +1670,16 @@ class InventoryController extends BaseController return redirect()->to(site_url('inventory/movements')) ->with('status', 'error')->with('message', 'Movement not found.'); } + if ($this->isProtectedMovement($existing)) { + return redirect()->to(site_url('inventory/movements')) + ->with('status', 'error')->with('message', 'Issue-backed or distribution movements are read-only. Use correction reversal instead.'); + } $movementType = (string) $this->request->getPost('movement_type'); + if ($movementType === 'distribution') { + return redirect()->back()->withInput()->with('status', 'error') + ->with('message', 'Student book distributions must be created from the book distribution workflow.'); + } $qtyChange = $this->normalizeMovementQty($movementType, (int) $this->request->getPost('qty_change')); $itemId = (int) ($existing['item_id'] ?? 0); @@ -1504,10 +1733,13 @@ class InventoryController extends BaseController { // Optional: authorization checks here log_message('info', 'Inventory: delete one attempt', ['id' => $id, 'user' => (int)(session('user_id') ?? 0)]); - $movement = $this->db->table('inventory_movements')->select('id, item_id')->where('id', $id)->get()->getRowArray(); + $movement = $this->db->table('inventory_movements')->select('*')->where('id', $id)->get()->getRowArray(); if (!$movement) { return redirect()->back()->with('status', 'error')->with('message', 'Movement not found.'); } + if ($this->isProtectedMovement($movement)) { + return redirect()->back()->with('status', 'error')->with('message', 'Issue-backed or distribution movements are read-only. Use correction reversal instead.'); + } $ok = $this->db->table('inventory_movements')->where('id', $id)->delete(); log_message('info', 'Inventory: delete one result', ['id' => $id, 'ok' => (bool)$ok, 'affected' => $this->db->affectedRows()]); @@ -1577,6 +1809,16 @@ class InventoryController extends BaseController return false; } + private function isProtectedMovement(array $movement): bool + { + $type = (string) ($movement['movement_type'] ?? ''); + $sourceType = (string) ($movement['source_type'] ?? ''); + return $type === 'distribution' + || in_array($sourceType, ['student_book_issue', 'student_book_issue_reversal'], true) + || (int) ($movement['source_id'] ?? 0) > 0 + || (int) ($movement['reversal_of_movement_id'] ?? 0) > 0; + } + /** Hydrate display names for a row (used in edit) */ private function hydrateNames(array $m): array { @@ -1682,13 +1924,18 @@ class InventoryController extends BaseController } log_message('info', 'Inventory: bulk delete attempt', ['count' => count($ids), 'ids' => $ids, 'user' => (int)(session('user_id') ?? 0)]); - $itemIds = $this->db->table('inventory_movements') - ->select('item_id') + $movements = $this->db->table('inventory_movements') + ->select('*') ->whereIn('id', $ids) ->get()->getResultArray(); + foreach ($movements as $movement) { + if ($this->isProtectedMovement($movement)) { + return redirect()->back()->with('status','error')->with('message','Bulk delete contains issue-backed or distribution movement #' . (int) $movement['id'] . '. Use correction reversal instead.'); + } + } $itemIds = array_values(array_unique(array_filter(array_map( static fn($r) => (int) ($r['item_id'] ?? 0), - $itemIds + $movements )))); $ok = $this->db->table('inventory_movements')->whereIn('id', $ids)->delete(); diff --git a/app/Controllers/View/ParentController.php b/app/Controllers/View/ParentController.php index 207c590..e4bc778 100644 --- a/app/Controllers/View/ParentController.php +++ b/app/Controllers/View/ParentController.php @@ -18,8 +18,8 @@ use \App\Models\EnrollmentModel; use \App\Models\EventChargesModel; use \App\Models\EventModel; use CodeIgniter\Events\Events; -use App\Services\SchoolIdService; use App\Services\FeeCalculationService; +use App\Services\SchoolIdService; use App\Services\PhoneFormatterService; use App\Support\Enrollment\DeliberationDecision; use App\Support\Enrollment\EnrollmentEligibility; @@ -441,8 +441,6 @@ class ParentController extends BaseController public function enrollClassesHandler() { - $refundService = new FeeCalculationService(); - // Retrieve enrollment and withdrawal data from the POST request $enroll = $this->request->getPost('enroll'); // Selected students for enrollment $withdraw = $this->request->getPost('withdraw'); // Selected students for withdrawal @@ -697,97 +695,14 @@ class ParentController extends BaseController ->getRowArray(); if ($enrollment !== null) { - // Update enrollment as withdrawn - $enrollmentStatusService = \Config\Services::enrollmentStatus(false); - $enrollmentStatusService->upsertStatus([ - 'id' => (int) $enrollment['id'], - 'student_id' => (int) $studentId, - 'parent_id' => (int) ($enrollment['parent_id'] ?? $parentId), - 'school_year' => (string) $this->schoolYear, - 'semester' => (string) ($enrollment['semester'] ?? $this->semester), - 'withdrawal_date' => local_date(utc_now(), 'Y-m-d'), - 'enrollment_status' => 'withdraw under review', // Withdrawal needs review - 'updated_at' => utc_now() - ], (int) $parentId, 'parent_withdrawal_requested'); + $withdrawalRequestDate = local_date(utc_now(), 'Y-m-d'); + service('withdrawalFinancial')->requestWithdrawal( + (int) $enrollment['id'], + $withdrawalRequestDate, + (int) $parentId + ); log_message('info', "Student ID $studentId has been withdrawn from enrollment ID {$enrollment['id']}."); $withdrawalResultMessages[] = 'Student ID ' . $studentId . ': withdraw under review'; - - // === Trigger refund process === - // Find the related invoice (you may need to adjust this based on your DB structure) - $invoice = $this->db->table('invoices') - ->where('parent_id', $parentId) - ->where('school_year', $this->schoolYear) - ->orderBy('created_at', 'DESC') - ->get() - ->getRowArray(); - - if ($invoice !== null) { - $invoiceId = $invoice['id']; - $studentsForRefund = $this->enrollmentModel - ->where('parent_id', $parentId) - ->where('school_year', $this->schoolYear) - ->findAll(); - $refundAmount = $refundService->calculateRefund($studentsForRefund, (int) $parentId); - $refundCents = max(0, (int) round($refundAmount * 100)); - - $refundTable = $this->db->table('refunds'); - - $existingRefund = $refundTable - ->where('parent_id', $parentId) - ->where('invoice_id', $invoiceId) - ->where('school_year', $this->schoolYear) - ->whereIn('status', ['Pending', 'Approved', 'Partial', 'pending', 'requested', 'approved', 'partial', 'partially_paid']) - ->get() - ->getRow(); - - if ($existingRefund) { - // Only update the fields that should change - $updateData = [ - 'refund_amount' => $refundAmount, - 'requested_amount_cents' => $refundCents, - 'currency' => 'USD', - 'reason' => 'Withdrawal under review for student ID ' . $studentId, - 'note' => null, - 'request' => 'tuition', - 'source_type' => 'tuition_withdrawal', - 'source_id' => (int) $invoiceId, - 'status' => 'Pending', - 'updated_by' => session()->get('user_id'), // optionally track updates - // Add other fields if *and only if* they must be changed - ]; - - $refundTable - ->where('id', $existingRefund->id) - ->update($this->filterPayloadByTableColumns($updateData, 'refunds')); - - log_message('info', "Refund record updated for invoice ID {$invoiceId}, student ID {$studentId}."); - } else { - // Only set these once, for new entries - $insertData = [ - 'parent_id' => $parentId, - 'invoice_id' => $invoiceId, - 'refund_amount' => $refundAmount, - 'requested_amount_cents' => $refundCents, - 'approved_amount_cents' => null, - 'currency' => 'USD', - 'requested_at' => utc_now(), - 'school_year' => $this->schoolYear, - 'status' => 'Pending', - 'reason' => 'Withdrawal under review for student ID ' . $studentId, - 'request' => 'tuition', - 'source_type' => 'tuition_withdrawal', - 'source_id' => (int) $invoiceId, - 'semester' => $this->semester, - 'refund_paid_amount' => 0.0, - ]; - - $refundTable->insert($this->filterPayloadByTableColumns($insertData, 'refunds')); - - log_message('info', "Refund record created for invoice ID {$invoiceId}, student ID {$studentId}."); - } - } else { - log_message('error', "No invoice found for parent ID {$parentId}, student ID {$studentId}."); - } } else { log_message('error', "No active enrollment found for student ID $studentId."); $withdrawalErrors[] = 'Student ID ' . $studentId . ': no active enrollment was found.'; diff --git a/app/Controllers/View/RefundController.php b/app/Controllers/View/RefundController.php index d910158..a35a989 100644 --- a/app/Controllers/View/RefundController.php +++ b/app/Controllers/View/RefundController.php @@ -16,7 +16,7 @@ use App\Models\PaymentModel; use App\Models\ConfigurationModel; use App\Models\InvoiceModel; use App\Models\EnrollmentModel; -use App\Services\FeeCalculationService; +use App\Services\EnrollmentStatusService; use CodeIgniter\Exceptions\PageNotFoundException; class RefundController extends BaseController @@ -32,7 +32,6 @@ class RefundController extends BaseController protected ParentLedgerService $parentLedgerService; protected RefundEligibilityService $refundEligibilityService; protected FinancialAttachmentService $financialAttachmentService; - protected FeeCalculationService $feeCalculationService; protected $db; // Allowed request types (mapped to your `refunds.request` column) @@ -54,7 +53,6 @@ class RefundController extends BaseController $this->parentLedgerService = new ParentLedgerService(); $this->refundEligibilityService = new RefundEligibilityService(); $this->financialAttachmentService = new FinancialAttachmentService(); - $this->feeCalculationService = new FeeCalculationService(); $this->db = \Config\Database::connect(); } @@ -723,6 +721,10 @@ class RefundController extends BaseController throw new FinancialPersistenceException('REFUND_PROJECTION_UPDATE_FAILED', $this->refundModel->errors()); } + if (!$isOnline && $newStatus === FinancialStatus::REFUND_PAID) { + $this->markWithdrawalEnrollmentComplete($lockedRefund); + } + if (!$isOnline && $affectedInvoiceId > 0) { $this->invoiceLedgerService->recalculateInvoice($affectedInvoiceId); } @@ -774,6 +776,49 @@ class RefundController extends BaseController return $this->response->setJSON($payload); } + private function markWithdrawalEnrollmentComplete(array $refund): void + { + if ((string)($refund['source_type'] ?? '') !== 'tuition_withdrawal') { + return; + } + + $calculationId = (int)($refund['withdrawal_calculation_id'] ?? 0); + if ($calculationId <= 0 || ! $this->db->tableExists('withdrawal_financial_calculations')) { + return; + } + + $calculation = $this->db->query( + 'SELECT enrollment_id FROM withdrawal_financial_calculations WHERE id = ? FOR UPDATE', + [$calculationId] + )->getRowArray(); + $enrollmentId = (int)($calculation['enrollment_id'] ?? 0); + if ($enrollmentId <= 0) { + return; + } + + $enrollment = $this->db->query( + 'SELECT * FROM enrollments WHERE id = ? FOR UPDATE', + [$enrollmentId] + )->getRowArray(); + if (! $enrollment) { + return; + } + + $statusService = new EnrollmentStatusService($this->db); + $statusService->upsertStatus([ + 'id' => (int)$enrollment['id'], + 'student_id' => (int)$enrollment['student_id'], + 'parent_id' => (int)$enrollment['parent_id'], + 'school_year' => (string)$enrollment['school_year'], + 'semester' => (string)($enrollment['semester'] ?? ''), + 'enrollment_status' => 'withdrawn', + 'admission_status' => (string)($enrollment['admission_status'] ?? 'pending'), + 'is_withdrawn' => 1, + 'withdrawal_date' => $enrollment['withdrawal_date'] ?? null, + 'updated_at' => utc_now(), + ], (int)(session()->get('user_id') ?? 0) ?: null, 'withdrawal_refund_paid'); + } + public function reversePayout(?int $routePayoutId = null) { $payoutId = (int)($routePayoutId ?: $this->request->getPost('payout_id')); @@ -933,6 +978,7 @@ class RefundController extends BaseController { // Repair legacy withdrawal placeholders only; overpayment recalculation remains explicit. $this->repairPendingWithdrawalRefunds(); + $withdrawalReviews = $this->pendingWithdrawalReviews(); // 2) List refunds with joins $refunds = $this->refundModel @@ -987,10 +1033,41 @@ class RefundController extends BaseController return view('refunds/list', [ 'refunds' => $refunds, - 'parentId' => $parentId + 'parentId' => $parentId, + 'withdrawalReviews' => $withdrawalReviews, ]); } + /** @return list> */ + private function pendingWithdrawalReviews(): array + { + try { + if (! $this->db->tableExists('withdrawal_financial_calculations')) { + return []; + } + + return $this->db->table('withdrawal_financial_calculations wfc') + ->select('wfc.*, + i.invoice_number, + u.firstname AS parent_firstname, + u.lastname AS parent_lastname, + s.firstname AS student_firstname, + s.lastname AS student_lastname') + ->join('invoices i', 'wfc.invoice_id = i.id', 'left') + ->join('users u', 'wfc.parent_id = u.id', 'left') + ->join('students s', 'wfc.student_id = s.id', 'left') + ->whereIn('wfc.status', ['preview', 'requires_review']) + ->where('wfc.posted_at', null) + ->orderBy('wfc.calculated_at', 'DESC') + ->get() + ->getResultArray(); + } catch (\Throwable $e) { + log_message('error', 'Pending withdrawal review lookup failed: ' . $e->getMessage()); + + return []; + } + } + private function repairPendingWithdrawalRefunds(): void { try { @@ -1021,24 +1098,48 @@ class RefundController extends BaseController continue; } - $students = $this->enrollmentModel - ->where('parent_id', $parentId) - ->where('school_year', $schoolYear) - ->findAll(); - $refundAmount = $this->feeCalculationService->calculateRefund($students, $parentId); - $refundCents = max(0, (int)round($refundAmount * 100)); - $this->refundModel->update((int)$refund['id'], [ 'invoice_id' => (int)$invoice['id'], - 'refund_amount' => $refundAmount, - 'requested_amount_cents' => $refundCents, 'currency' => 'USD', 'request' => 'tuition', 'source_type' => 'tuition_withdrawal', 'source_id' => (int)$invoice['id'], + 'reconciliation_status' => 'requires_review', + 'reconciliation_reason' => 'Legacy pending withdrawal refund requires recalculation through the withdrawal financial review workflow.', + 'reconciliation_required_at' => utc_now(), 'updated_at' => utc_now(), ]); } + + if ($this->db->tableExists('withdrawal_financial_calculations')) { + $postedRows = $this->db->table('refunds r') + ->select('r.id, r.refund_amount, r.requested_amount_cents, wfc.posted_at, wfc.posted_by') + ->join('withdrawal_financial_calculations wfc', 'wfc.id = r.withdrawal_calculation_id', 'inner') + ->where('r.source_type', 'tuition_withdrawal') + ->whereIn('r.status', ['Pending', 'pending', 'requested']) + ->where('wfc.status', 'posted') + ->where('wfc.posted_at IS NOT NULL', null, false) + ->get() + ->getResultArray(); + + foreach ($postedRows as $refund) { + $amountCents = (int)($refund['requested_amount_cents'] ?? 0); + if ($amountCents <= 0) { + $amountCents = (int)round(((float)($refund['refund_amount'] ?? 0)) * 100); + } + if ($amountCents <= 0) { + continue; + } + + $this->refundModel->update((int)$refund['id'], [ + 'status' => $this->refundStatusForStorage(FinancialStatus::REFUND_APPROVED), + 'approved_amount_cents' => $amountCents, + 'approved_at' => $refund['posted_at'] ?? utc_now(), + 'approved_by' => !empty($refund['posted_by']) ? (int)$refund['posted_by'] : null, + 'updated_at' => utc_now(), + ]); + } + } } catch (\Throwable $e) { log_message('error', 'Pending withdrawal refund repair failed: ' . $e->getMessage()); } diff --git a/app/Database/Migrations/2026-08-21-000100_CreateWithdrawalRefundInventoryFoundation.php b/app/Database/Migrations/2026-08-21-000100_CreateWithdrawalRefundInventoryFoundation.php new file mode 100644 index 0000000..bca93a3 --- /dev/null +++ b/app/Database/Migrations/2026-08-21-000100_CreateWithdrawalRefundInventoryFoundation.php @@ -0,0 +1,488 @@ +addSchoolYearPolicyColumns(); + $this->addInventoryCatalogColumns(); + $this->createInventoryItemYears(); + $this->extendInventoryMovements(); + $this->protectInventoryHistoryForeignKey(); + $this->createBookClassAssignments(); + $this->createStudentBookIssues(); + $this->createWithdrawalCalculations(); + $this->extendRefunds(); + $this->seedActiveYearPolicy(); + $this->seedActiveBookYears(); + } + + public function down(): void + { + $this->forge->dropTable('withdrawal_financial_calculations', true); + $this->forge->dropTable('student_book_issues', true); + $this->forge->dropTable('inventory_book_class_assignments', true); + + if ($this->db->tableExists('inventory_movements')) { + foreach ([ + 'item_year_id', 'idempotency_key', 'reversal_of_movement_id', 'status', + 'reversed_at', 'reversed_by', 'source_type', 'source_id', + ] as $column) { + if ($this->db->fieldExists($column, 'inventory_movements')) { + $this->forge->dropColumn('inventory_movements', $column); + } + } + } + + $this->forge->dropTable('inventory_item_years', true); + $this->restoreInventoryHistoryForeignKey(); + + if ($this->db->tableExists('inventory_items')) { + foreach (['author', 'is_active', 'retired_at', 'isbn_edition_key', 'sku_normalized'] as $column) { + if ($this->db->fieldExists($column, 'inventory_items')) { + $this->forge->dropColumn('inventory_items', $column); + } + } + } + + if ($this->db->tableExists('school_years')) { + foreach (['total_instructional_weeks', 'annual_fee_includes_books', 'withdrawal_policy_version'] as $column) { + if ($this->db->fieldExists($column, 'school_years')) { + $this->forge->dropColumn('school_years', $column); + } + } + } + + if ($this->db->tableExists('refunds') && $this->db->fieldExists('withdrawal_calculation_id', 'refunds')) { + $this->forge->dropColumn('refunds', 'withdrawal_calculation_id'); + } + } + + private function addSchoolYearPolicyColumns(): void + { + if (! $this->db->tableExists('school_years')) { + return; + } + + $columns = []; + if (! $this->db->fieldExists('total_instructional_weeks', 'school_years')) { + $columns['total_instructional_weeks'] = [ + 'type' => 'SMALLINT', 'constraint' => 5, 'unsigned' => true, 'null' => true, + ]; + } + if (! $this->db->fieldExists('annual_fee_includes_books', 'school_years')) { + $columns['annual_fee_includes_books'] = [ + 'type' => 'TINYINT', 'constraint' => 1, 'default' => 1, 'null' => false, + ]; + } + if (! $this->db->fieldExists('withdrawal_policy_version', 'school_years')) { + $columns['withdrawal_policy_version'] = [ + 'type' => 'VARCHAR', 'constraint' => 40, 'default' => 'studied_weeks_v1', 'null' => false, + ]; + } + if ($columns !== []) { + $this->forge->addColumn('school_years', $columns); + } + } + + private function addInventoryCatalogColumns(): void + { + if (! $this->db->tableExists('inventory_items')) { + return; + } + + $columns = []; + if (! $this->db->fieldExists('author', 'inventory_items')) { + $columns['author'] = ['type' => 'VARCHAR', 'constraint' => 190, 'null' => true]; + } + if (! $this->db->fieldExists('is_active', 'inventory_items')) { + $columns['is_active'] = ['type' => 'TINYINT', 'constraint' => 1, 'default' => 1, 'null' => false]; + } + if (! $this->db->fieldExists('retired_at', 'inventory_items')) { + $columns['retired_at'] = ['type' => 'DATETIME', 'null' => true]; + } + if (! $this->db->fieldExists('isbn_edition_key', 'inventory_items')) { + $columns['isbn_edition_key'] = ['type' => 'VARCHAR', 'constraint' => 190, 'null' => true]; + } + if (! $this->db->fieldExists('sku_normalized', 'inventory_items')) { + $columns['sku_normalized'] = ['type' => 'VARCHAR', 'constraint' => 120, 'null' => true]; + } + + if ($columns !== []) { + $this->forge->addColumn('inventory_items', $columns); + } + // Existing legacy rows remain NULL until an admin reconciles/edits them, + // so installing this migration cannot fail because of historical duplicates. + $this->addIndexIfMissing('inventory_items', 'uq_inventory_book_isbn_edition', ['isbn_edition_key'], true); + $this->addIndexIfMissing('inventory_items', 'uq_inventory_sku_normalized', ['sku_normalized'], true); + } + + private function createInventoryItemYears(): void + { + if ($this->db->tableExists('inventory_item_years')) { + return; + } + + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'inventory_item_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false], + 'school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'school_year' => ['type' => 'VARCHAR', 'constraint' => 16, 'null' => false], + 'opening_quantity' => ['type' => 'INT', 'constraint' => 11, 'default' => 0, 'null' => false], + 'charge_price_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false], + 'currency' => ['type' => 'CHAR', 'constraint' => 3, 'default' => 'USD', 'null' => false], + 'price_confirmed' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0, 'null' => false], + 'system_closing_quantity' => ['type' => 'INT', 'constraint' => 11, 'null' => true], + 'counted_closing_quantity' => ['type' => 'INT', 'constraint' => 11, 'null' => true], + 'variance_quantity' => ['type' => 'INT', 'constraint' => 11, 'null' => true], + 'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'open', 'null' => false], + 'source_item_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'closing_batch_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'created_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'updated_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey(['inventory_item_id', 'school_year'], 'uq_inventory_item_year'); + $this->forge->addKey(['school_year_id', 'status'], false, false, 'idx_inventory_item_year_status'); + $this->forge->addKey('closing_batch_id'); + $this->forge->createTable('inventory_item_years', true, ['ENGINE' => 'InnoDB']); + } + + private function extendInventoryMovements(): void + { + if (! $this->db->tableExists('inventory_movements')) { + return; + } + + $definitions = [ + 'item_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'idempotency_key' => ['type' => 'VARCHAR', 'constraint' => 120, 'null' => true], + 'reversal_of_movement_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'posted', 'null' => false], + 'reversed_at' => ['type' => 'DATETIME', 'null' => true], + 'reversed_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'source_type' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], + 'source_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + ]; + + $columns = []; + foreach ($definitions as $name => $definition) { + if (! $this->db->fieldExists($name, 'inventory_movements')) { + $columns[$name] = $definition; + } + } + if ($columns !== []) { + $this->forge->addColumn('inventory_movements', $columns); + } + $this->addIndexIfMissing('inventory_movements', 'uq_inventory_movement_idempotency', ['idempotency_key'], true); + $this->addIndexIfMissing('inventory_movements', 'idx_inventory_movement_item_year_status', ['item_year_id', 'status']); + $this->addIndexIfMissing('inventory_movements', 'idx_inventory_movement_source', ['source_type', 'source_id']); + } + + private function createBookClassAssignments(): void + { + if ($this->db->tableExists('inventory_book_class_assignments')) { + return; + } + + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'inventory_item_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false], + 'school_year' => ['type' => 'VARCHAR', 'constraint' => 16, 'null' => false], + 'class_number' => ['type' => 'SMALLINT', 'constraint' => 5, 'unsigned' => true, 'null' => false], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey(['inventory_item_id', 'school_year', 'class_number'], 'uq_book_year_class'); + $this->forge->addKey(['school_year', 'class_number'], false, false, 'idx_book_class_year'); + $this->forge->createTable('inventory_book_class_assignments', true, ['ENGINE' => 'InnoDB']); + } + + private function protectInventoryHistoryForeignKey(): void + { + if (! $this->db->tableExists('inventory_movements') || ! $this->db->tableExists('inventory_items')) { + return; + } + try { + $rows = $this->db->query( + "SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'inventory_movements' + AND COLUMN_NAME = 'item_id' AND REFERENCED_TABLE_NAME = 'inventory_items'" + )->getResultArray(); + foreach ($rows as $row) { + $name = str_replace('`', '', (string) ($row['CONSTRAINT_NAME'] ?? '')); + if ($name !== '') { + $this->db->query('ALTER TABLE inventory_movements DROP FOREIGN KEY `' . $name . '`'); + } + } + $this->db->query( + 'ALTER TABLE inventory_movements ADD CONSTRAINT fk_inventory_movements_item_restrict ' + . 'FOREIGN KEY (item_id) REFERENCES inventory_items(id) ON DELETE RESTRICT ON UPDATE CASCADE' + ); + } catch (\Throwable $e) { + log_message('warning', 'Unable to replace inventory movement cascade with RESTRICT: ' . $e->getMessage()); + } + } + + private function restoreInventoryHistoryForeignKey(): void + { + if (! $this->db->tableExists('inventory_movements') || ! $this->db->tableExists('inventory_items')) { + return; + } + try { + $rows = $this->db->query( + "SELECT CONSTRAINT_NAME FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'inventory_movements' + AND COLUMN_NAME = 'item_id' AND REFERENCED_TABLE_NAME = 'inventory_items'" + )->getResultArray(); + foreach ($rows as $row) { + $name = str_replace('`', '', (string) ($row['CONSTRAINT_NAME'] ?? '')); + if ($name !== '') { + $this->db->query('ALTER TABLE inventory_movements DROP FOREIGN KEY `' . $name . '`'); + } + } + $this->db->query( + 'ALTER TABLE inventory_movements ADD CONSTRAINT fk_inventory_movements_item_cascade ' + . 'FOREIGN KEY (item_id) REFERENCES inventory_items(id) ON DELETE CASCADE ON UPDATE CASCADE' + ); + } catch (\Throwable $e) { + log_message('warning', 'Unable to restore inventory movement cascade during rollback: ' . $e->getMessage()); + } + } + + private function createStudentBookIssues(): void + { + if ($this->db->tableExists('student_book_issues')) { + return; + } + + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'student_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false], + 'enrollment_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false], + 'parent_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false], + 'inventory_item_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false], + 'inventory_item_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false], + 'school_year' => ['type' => 'VARCHAR', 'constraint' => 16, 'null' => false], + 'class_section_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'quantity' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 1, 'null' => false], + 'unit_charge_price_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false], + 'total_charge_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false], + 'distribution_movement_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'idempotency_key' => ['type' => 'VARCHAR', 'constraint' => 160, 'null' => false], + 'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'issued', 'null' => false], + 'issued_at' => ['type' => 'DATETIME', 'null' => false], + 'issued_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'reversed_at' => ['type' => 'DATETIME', 'null' => true], + 'reversed_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'reversal_reason' => ['type' => 'TEXT', 'null' => true], + 'reversal_quantity' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'reversal_movement_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('idempotency_key', 'uq_student_book_issue_key'); + $this->forge->addKey(['student_id', 'school_year', 'status'], false, false, 'idx_student_book_issue_student'); + $this->forge->addKey(['inventory_item_year_id', 'status'], false, false, 'idx_student_book_issue_item_year'); + $this->forge->addKey('distribution_movement_id'); + $this->forge->createTable('student_book_issues', true, ['ENGINE' => 'InnoDB']); + } + + private function createWithdrawalCalculations(): void + { + if ($this->db->tableExists('withdrawal_financial_calculations')) { + return; + } + + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'version' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 1, 'null' => false], + 'enrollment_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false], + 'student_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false], + 'parent_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false], + 'invoice_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'school_year' => ['type' => 'VARCHAR', 'constraint' => 16, 'null' => false], + 'policy_version' => ['type' => 'VARCHAR', 'constraint' => 40, 'null' => false], + 'annual_fee_includes_books' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 1, 'null' => false], + 'school_year_start_date' => ['type' => 'DATE', 'null' => false], + 'enrollment_date' => ['type' => 'DATE', 'null' => false], + 'withdrawal_request_date' => ['type' => 'DATE', 'null' => false], + 'total_instructional_weeks' => ['type' => 'SMALLINT', 'constraint' => 5, 'unsigned' => true, 'null' => false], + 'total_chargeable_days' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false], + 'studied_calendar_days' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false], + 'studied_weeks' => ['type' => 'SMALLINT', 'constraint' => 5, 'unsigned' => true, 'null' => false], + 'annual_fee_allocation_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false], + 'issued_book_charge_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false], + 'annual_instruction_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false], + 'earned_tuition_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false], + 'other_charge_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false], + 'retained_charge_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false], + 'original_invoice_charge_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false], + 'invoice_adjustment_cents' => ['type' => 'INT', 'constraint' => 11, 'default' => 0, 'null' => false], + 'adjusted_invoice_charge_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false], + 'valid_payment_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false], + 'completed_payout_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false], + 'open_reservation_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false], + 'refundable_credit_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false], + 'new_refund_request_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false], + 'balance_due_cents' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'default' => 0, 'null' => false], + 'book_evidence_json' => ['type' => 'LONGTEXT', 'null' => true], + 'explanation_json' => ['type' => 'LONGTEXT', 'null' => false], + 'calculation_hash' => ['type' => 'CHAR', 'constraint' => 64, 'null' => false], + 'active_posted_key' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true], + 'books_discount_eligible' => ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0, 'null' => false], + 'status' => ['type' => 'VARCHAR', 'constraint' => 24, 'default' => 'preview', 'null' => false], + 'superseded_by_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'override_reason' => ['type' => 'TEXT', 'null' => true], + 'overridden_at' => ['type' => 'DATETIME', 'null' => true], + 'overridden_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'calculated_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'posted_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'calculated_at' => ['type' => 'DATETIME', 'null' => false], + 'posted_at' => ['type' => 'DATETIME', 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey(['enrollment_id', 'version'], 'uq_withdrawal_calc_version'); + $this->forge->addUniqueKey('active_posted_key', 'uq_withdrawal_calc_active_posted'); + $this->forge->addKey('calculation_hash'); + $this->forge->addKey(['enrollment_id', 'status'], false, false, 'idx_withdrawal_calc_status'); + $this->forge->addKey(['invoice_id', 'status'], false, false, 'idx_withdrawal_calc_invoice'); + $this->forge->createTable('withdrawal_financial_calculations', true, ['ENGINE' => 'InnoDB']); + } + + private function extendRefunds(): void + { + if (! $this->db->tableExists('refunds') || $this->db->fieldExists('withdrawal_calculation_id', 'refunds')) { + return; + } + + $this->forge->addColumn('refunds', [ + 'withdrawal_calculation_id' => [ + 'type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true, 'after' => 'invoice_id', + ], + ]); + $this->addIndexIfMissing('refunds', 'idx_refunds_withdrawal_calculation', ['withdrawal_calculation_id']); + } + + private function seedActiveYearPolicy(): void + { + if (! $this->db->tableExists('school_years') || ! $this->db->tableExists('configuration')) { + return; + } + + $row = $this->db->table('configuration') + ->select('config_value') + ->where('config_key', 'total_instructional_weeks') + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + $weeks = filter_var($row['config_value'] ?? null, FILTER_VALIDATE_INT); + if ($weeks === false || $weeks <= 0) { + return; + } + + $this->db->table('school_years') + ->where('status', 'active') + ->where('total_instructional_weeks IS NULL', null, false) + ->update([ + 'total_instructional_weeks' => $weeks, + 'annual_fee_includes_books' => 1, + 'withdrawal_policy_version' => 'studied_weeks_v1', + ]); + } + + private function seedActiveBookYears(): void + { + if (! $this->db->tableExists('inventory_item_years') || ! $this->db->tableExists('inventory_items')) { + return; + } + + $year = $this->db->table('school_years') + ->select('id, name') + ->where('status', 'active') + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + if ($year === null || trim((string) ($year['name'] ?? '')) === '') { + return; + } + + $books = $this->db->table('inventory_items') + ->select('id, quantity') + ->where('type', 'book') + ->get() + ->getResultArray(); + $now = date('Y-m-d H:i:s'); + foreach ($books as $book) { + $exists = $this->db->table('inventory_item_years') + ->where('inventory_item_id', (int) $book['id']) + ->where('school_year', (string) $year['name']) + ->countAllResults(); + if ($exists > 0) { + continue; + } + $movementRow = $this->db->table('inventory_movements') + ->selectSum('qty_change', 'year_movement_total') + ->where('item_id', (int) $book['id']) + ->where('school_year', (string) $year['name']) + ->get()->getRowArray(); + // inventory_items.quantity is the legacy current physical balance. + // Reconstruct the opening so linking existing movements to this + // item-year does not count those movements a second time. + $openingQuantity = (int) ($book['quantity'] ?? 0) - (int) ($movementRow['year_movement_total'] ?? 0); + $this->db->table('inventory_item_years')->insert([ + 'inventory_item_id' => (int) $book['id'], + 'school_year_id' => (int) $year['id'], + 'school_year' => (string) $year['name'], + 'opening_quantity' => $openingQuantity, + 'charge_price_cents' => 0, + 'price_confirmed' => 0, + 'status' => 'open', + 'created_at' => $now, + 'updated_at' => $now, + ]); + $itemYearId = (int) $this->db->insertID(); + $this->db->table('inventory_movements') + ->where('item_id', (int) $book['id']) + ->where('school_year', (string) $year['name']) + ->where('item_year_id', null) + ->update(['item_year_id' => $itemYearId, 'updated_at' => $now]); + } + } + + /** @param list $columns */ + private function addIndexIfMissing(string $table, string $index, array $columns, bool $unique = false): void + { + try { + $exists = $this->db->query( + 'SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? LIMIT 1', + [$table, $index] + )->getRowArray(); + if ($exists !== null) { + return; + } + $columnSql = implode(', ', array_map(static fn (string $column): string => '`' . str_replace('`', '', $column) . '`', $columns)); + $this->db->query(sprintf( + 'CREATE %s INDEX `%s` ON `%s` (%s)', + $unique ? 'UNIQUE' : '', + str_replace('`', '', $index), + str_replace('`', '', $table), + $columnSql + )); + } catch (\Throwable $e) { + log_message('warning', 'Unable to create inventory index {index}: {message}', [ + 'index' => $index, + 'message' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Libraries/InvoiceLedgerService.php b/app/Libraries/InvoiceLedgerService.php index c0e13a7..e11ffce 100644 --- a/app/Libraries/InvoiceLedgerService.php +++ b/app/Libraries/InvoiceLedgerService.php @@ -104,7 +104,7 @@ class InvoiceLedgerService } $netChargeCents = $totalAmountCents - $discountCents; - $rawBalanceCents = $netChargeCents - $paidCents - $refundPaidCents; + $rawBalanceCents = $netChargeCents - $paidCents + $refundPaidCents; $balanceCents = max(0, $rawBalanceCents); $customerCreditCents = max(0, $paidCents - $refundPaidCents - $netChargeCents); diff --git a/app/Libraries/RefundEligibilityService.php b/app/Libraries/RefundEligibilityService.php index 38e9f7f..c7b523d 100644 --- a/app/Libraries/RefundEligibilityService.php +++ b/app/Libraries/RefundEligibilityService.php @@ -38,7 +38,7 @@ class RefundEligibilityService $completedPayoutCents = $this->completedPayoutsAffectAvailability($sourceType) ? $this->calculateCompletedPayoutCents($sourceType, $sourceId, $excludeRefundId) : 0; - $reservedAmountCents = $this->calculateReservedAmountCents($sourceType, $sourceId, $excludeRefundId); + $reservedAmountCents = $this->calculateReservedAmountCents($sourceType, $sourceId, $excludeRefundId, $invoiceId); $availableAmountCents = max(0, $sourceCreditCents - $completedPayoutCents - $reservedAmountCents); $reasons = []; @@ -164,7 +164,7 @@ class RefundEligibilityService { return match ($sourceType) { 'invoice_overpayment' => $this->invoiceCreditCents($parentId, $invoiceId ?: $sourceId), - 'tuition_withdrawal' => $this->invoicePaidCents($parentId, $invoiceId ?: $sourceId), + 'tuition_withdrawal' => $this->invoiceCreditCents($parentId, $invoiceId ?: $sourceId), 'payment_duplicate', 'payment_correction' => $this->paymentCreditCents($parentId, $invoiceId, $sourceId), 'credit_memo', 'administrative_credit' => 0, default => 0, @@ -173,7 +173,7 @@ class RefundEligibilityService protected function completedPayoutsAffectAvailability(string $sourceType): bool { - return $sourceType !== 'invoice_overpayment'; + return ! in_array($sourceType, ['invoice_overpayment', 'tuition_withdrawal'], true); } protected function invoicePaidCents(int $parentId, int $invoiceId): int @@ -286,13 +286,21 @@ class RefundEligibilityService return (int)round(((float)($row['total_paid'] ?? 0)) * 100); } - protected function calculateReservedAmountCents(string $sourceType, int $sourceId, ?int $excludeRefundId): int + protected function calculateReservedAmountCents(string $sourceType, int $sourceId, ?int $excludeRefundId, ?int $invoiceId = null): int { $query = $this->refundModel - ->select('id, refund_amount, refund_paid_amount, approved_amount_cents') - ->where('source_type', $sourceType) - ->where('source_id', $sourceId) - ->whereIn('status', ['Approved', 'Partial', 'approved', 'partially_paid']); + ->select('id, refund_amount, requested_amount_cents, refund_paid_amount, approved_amount_cents'); + + if (in_array($sourceType, ['invoice_overpayment', 'tuition_withdrawal'], true)) { + $invoiceSourceId = $invoiceId !== null && $invoiceId > 0 ? $invoiceId : $sourceId; + $query->where('invoice_id', $invoiceSourceId) + ->whereIn('source_type', ['invoice_overpayment', 'tuition_withdrawal']); + } else { + $query->where('source_type', $sourceType) + ->where('source_id', $sourceId); + } + + $query->whereIn('status', ['Pending', 'requested', 'Approved', 'Partial', 'pending', 'approved', 'partially_paid']); if ($excludeRefundId !== null && $excludeRefundId > 0) { $query->where('id !=', $excludeRefundId); @@ -302,7 +310,7 @@ class RefundEligibilityService foreach ($query->findAll() as $refund) { $approved = isset($refund['approved_amount_cents']) && $refund['approved_amount_cents'] !== null ? (int)$refund['approved_amount_cents'] - : (int)round(((float)($refund['refund_amount'] ?? 0)) * 100); + : ((int)($refund['requested_amount_cents'] ?? 0) ?: (int)round(((float)($refund['refund_amount'] ?? 0)) * 100)); $paid = $this->getCompletedPayoutTotalCentsForRefund((int)($refund['id'] ?? 0)); $reserved += max(0, $approved - $paid); } diff --git a/app/Models/InventoryItemModel.php b/app/Models/InventoryItemModel.php index 5596425..2636722 100644 --- a/app/Models/InventoryItemModel.php +++ b/app/Models/InventoryItemModel.php @@ -30,7 +30,12 @@ protected $table = 'inventory_items'; 'condition', 'isbn', 'edition', + 'author', 'sku', + 'is_active', + 'retired_at', + 'isbn_edition_key', + 'sku_normalized', 'notes', // audit @@ -49,7 +54,6 @@ protected $table = 'inventory_items'; 'type' => 'required|in_list[classroom,book,office,kitchen]', 'name' => 'required|min_length[2]', 'quantity' => 'permit_empty|integer', - 'unit_price' => 'permit_empty|decimal', 'school_year' => 'required|string|max_length[16]', ]; } diff --git a/app/Models/InventoryItemYearModel.php b/app/Models/InventoryItemYearModel.php new file mode 100644 index 0000000..ec1028c --- /dev/null +++ b/app/Models/InventoryItemYearModel.php @@ -0,0 +1,41 @@ + 'required|integer', + 'school_year' => 'required|string|max_length[16]', + 'opening_quantity' => 'required|integer', + 'charge_price_cents' => 'required|integer|greater_than_equal_to[0]', + 'currency' => 'required|alpha|max_length[3]', + 'price_confirmed' => 'required|in_list[0,1]', + 'status' => 'required|in_list[open,reconciled,carried,closed]', + ]; +} diff --git a/app/Models/InventoryMovementModel.php b/app/Models/InventoryMovementModel.php index 2c50df4..7ae107b 100644 --- a/app/Models/InventoryMovementModel.php +++ b/app/Models/InventoryMovementModel.php @@ -15,9 +15,11 @@ class InventoryMovementModel extends Model protected $useTimestamps = true; protected $allowedFields = [ - 'item_id','qty_change','movement_type','reason','note', + 'item_id','item_year_id','qty_change','movement_type','reason','note', 'semester','school_year', 'performed_by','teacher_id','student_id','class_section_id', + 'idempotency_key','reversal_of_movement_id','status','reversed_at', + 'reversed_by','source_type','source_id', ]; protected $validationRules = [ 'school_year' => 'required|string|max_length[9]', diff --git a/app/Models/SchoolYearModel.php b/app/Models/SchoolYearModel.php index 8f7b39f..bd7b50a 100644 --- a/app/Models/SchoolYearModel.php +++ b/app/Models/SchoolYearModel.php @@ -35,6 +35,9 @@ class SchoolYearModel extends Model 'registration_launch_approved_by', 'registration_email_template_version', 'fall_makeup_exam_on', + 'total_instructional_weeks', + 'annual_fee_includes_books', + 'withdrawal_policy_version', 'previous_school_year_id', 'next_school_year_id', 'activated_at', @@ -65,6 +68,8 @@ class SchoolYearModel extends Model 'registration_launch_approved_at' => 'permit_empty|valid_date[Y-m-d H:i:s]', 'registration_launch_approved_by' => 'permit_empty|integer', 'fall_makeup_exam_on' => 'permit_empty|valid_date[Y-m-d]', + 'total_instructional_weeks' => 'permit_empty|integer|greater_than[0]', + 'annual_fee_includes_books' => 'permit_empty|in_list[0,1]', ]; public function active(): ?array diff --git a/app/Models/StudentBookIssueModel.php b/app/Models/StudentBookIssueModel.php new file mode 100644 index 0000000..ddd7fbb --- /dev/null +++ b/app/Models/StudentBookIssueModel.php @@ -0,0 +1,36 @@ + 'required|integer', + 'enrollment_id' => 'required|integer', + 'parent_id' => 'required|integer', + 'inventory_item_id' => 'required|integer', + 'inventory_item_year_id' => 'required|integer', + 'school_year' => 'required|string|max_length[16]', + 'quantity' => 'required|integer|greater_than[0]', + 'unit_charge_price_cents' => 'required|integer|greater_than[0]', + 'total_charge_cents' => 'required|integer|greater_than[0]', + 'idempotency_key' => 'required|string|max_length[160]', + 'status' => 'required|in_list[issued,reversed]', + 'issued_at' => 'required|valid_date[Y-m-d H:i:s]', + ]; +} diff --git a/app/Models/WithdrawalFinancialCalculationModel.php b/app/Models/WithdrawalFinancialCalculationModel.php new file mode 100644 index 0000000..ced87b8 --- /dev/null +++ b/app/Models/WithdrawalFinancialCalculationModel.php @@ -0,0 +1,42 @@ + 'required|integer|greater_than[0]', + 'enrollment_id' => 'required|integer', + 'student_id' => 'required|integer', + 'parent_id' => 'required|integer', + 'school_year' => 'required|string|max_length[16]', + 'annual_fee_includes_books' => 'required|in_list[1]', + 'total_instructional_weeks' => 'required|integer|greater_than[0]', + 'status' => 'required|in_list[preview,posted,superseded,requires_review]', + ]; +} diff --git a/app/Services/EnrollmentStatusService.php b/app/Services/EnrollmentStatusService.php index 7aee458..1ac9a2b 100644 --- a/app/Services/EnrollmentStatusService.php +++ b/app/Services/EnrollmentStatusService.php @@ -14,11 +14,11 @@ final class EnrollmentStatusService 'payment pending', 'enrolled', 'withdraw under review', - 'refund pending', ]; public const INACTIVE_STATUSES = [ 'denied', + 'refund pending', 'withdrawn', 'waitlist', ]; diff --git a/app/Services/EnrollmentWithdrawalService.php b/app/Services/EnrollmentWithdrawalService.php index 9804cd3..ed882e7 100644 --- a/app/Services/EnrollmentWithdrawalService.php +++ b/app/Services/EnrollmentWithdrawalService.php @@ -3,7 +3,6 @@ namespace App\Services; use App\Controllers\View\InvoiceController; -use App\Libraries\RefundEligibilityService; use App\Models\ClassSectionModel; use App\Models\EnrollmentModel; use App\Models\InvoiceModel; @@ -194,7 +193,6 @@ public function newStudents(string $schoolYear): array //update enrollment status public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, string $semester, ?int $performedBy): array { - $refundService = new FeeCalculationService(); $enrollmentStatusService = \Config\Services::enrollmentStatus(false); $performedBy = $performedBy ?: ((int) (session()->get('user_id') ?? 0) ?: null); $this->schoolYear = $schoolYear; @@ -212,8 +210,8 @@ public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, s // For batching emails: parent -> status -> [students...] $groupsByParentStatus = []; // [parent_id][status][] = ['student_id'=>, 'student_name'=>] $parentInfo = []; // [parent_id] = ['user_id','email','firstname','lastname'] - $refundParents = []; // parent_id => true (for refund calc) - $refundAmountByParent = []; // parent_id => amount + $withdrawalPreviewEnrollmentIds = []; // enrollment_id => parent_id + $refundAmountByParent = []; // parent_id => preview amount for notification context $validStatuses = EnrollmentStatusService::VALID_STATUSES; @@ -287,7 +285,7 @@ public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, s ]; if ($newEnrollmentStatus === 'refund pending') { - $refundParents[$parentId] = true; + $withdrawalPreviewEnrollmentIds[(int) $result['id']] = $parentId; } log_message('info', "Created enrollment for student ID {$studentId} with status {$newEnrollmentStatus} and admission {$admissionStatus}."); @@ -369,98 +367,25 @@ public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, s // Mark for refund calc if ($newEnrollmentStatus === 'refund pending') { - $refundParents[$parentId] = true; + $withdrawalPreviewEnrollmentIds[(int) $enrollmentRow['id']] = (int) $parentId; } } - // Compute refunds ONCE per parent needing it - foreach (array_keys($refundParents) as $pid) { - $students = $this->enrollmentModel - ->where('parent_id', $pid) - ->where('school_year', $this->schoolYear) - ->findAll(); - - if (empty($students)) { - // If a parent is marked for refund but has no enrollments, just log and continue. - log_message('info', "No enrollments found for parent ID {$pid} (for refund calc); skipping refund."); - continue; - } - - $invoice = $this->invoiceModel->where('parent_id', $pid) - ->where('school_year', $this->schoolYear) - ->orderBy('created_at', 'DESC') - ->first(); - - if (!$invoice) { - $errors[] = "No invoice found for parent ID $pid (for refund calc)."; - continue; - } - - $refundAmount = $refundService->calculateRefund($students, $pid); - $refundAmountByParent[$pid] = $refundAmount; - - $existingRefund = $this->refundModel->where('invoice_id', $invoice['id'])->first(); - - if ($existingRefund) { - $refundId = (int)$existingRefund['id']; - $status = strtolower((string)($existingRefund['status'] ?? '')); - $isApprovedState = in_array($status, ['approved', 'partial', 'paid', 'partially_paid'], true); - $calculatedCents = max(0, (int)round($refundAmount * 100)); - $paidCents = (new RefundEligibilityService())->getCompletedPayoutTotalCentsForRefund($refundId); - $targetCents = $isApprovedState ? max($calculatedCents, $paidCents) : $calculatedCents; - $update = [ - 'refund_amount' => $targetCents / 100, - 'updated_by' => session()->get('user_id') ?? null, - ]; - if ($isApprovedState) { - $update['approved_amount_cents'] = $targetCents; - } else { - $update['status'] = 'Pending'; - $update['requested_amount_cents'] = $targetCents; - } - if ($isApprovedState && $paidCents > $calculatedCents) { - $message = sprintf( - 'Completed payouts (%0.2f) exceed recalculated refundable credit (%0.2f).', - $paidCents / 100, - $calculatedCents / 100 - ); - $update['reconciliation_status'] = 'requires_review'; - $update['reconciliation_reason'] = $message; - $update['reconciliation_required_at'] = utc_now(); - log_message('critical', 'Refund reconciliation required for refund #' . $refundId . ': ' . $message); - } else { - $update['reconciliation_status'] = null; - $update['reconciliation_reason'] = null; - $update['reconciliation_required_at'] = null; - } - $this->refundModel->update($refundId, $update); - } else { - $this->refundModel->insert([ - 'parent_id' => $pid, - 'school_year' => $invoice['school_year'], - 'invoice_id' => $invoice['id'], - 'refund_amount' => $refundAmount, - 'requested_amount_cents' => (int)round($refundAmount * 100), - 'approved_amount_cents' => null, - 'currency' => 'USD', - 'refund_paid_amount' => 0.0, - 'status' => 'Pending', - 'source_type' => 'tuition_withdrawal', - 'source_id' => (int)$invoice['id'], - 'requested_at' => utc_now(), - 'updated_by' => session()->get('user_id') ?? null, - ]); - } - - log_message('info', "Refund of $refundAmount created/updated for invoice ID {$invoice['id']} (parent {$pid})."); - } - $this->db->transComplete(); if (!$this->db->transStatus()) { return ['ok' => false, 'message' => 'A database error occurred. Changes were rolled back.']; } + foreach ($withdrawalPreviewEnrollmentIds as $enrollmentId => $pid) { + try { + $calculation = service('withdrawalFinancial')->preview((int) $enrollmentId, $performedBy); + $refundAmountByParent[(int) $pid] = ((int) ($calculation['new_refund_request_cents'] ?? 0)) / 100; + } catch (\Throwable $e) { + $errors[] = 'Withdrawal calculation preview failed for enrollment #' . (int) $enrollmentId . ': ' . $e->getMessage(); + } + } + // === AFTER COMMIT: fire specific events, batched per parent/status === $eventMap = [ 'admission under review' => 'admissionUnderReview', @@ -524,7 +449,7 @@ public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, s // === Server-side safety net: generate/update invoices for parents whose statuses require it === try { - $needsInvoiceFor = ['payment pending', 'enrolled', 'withdrawn', 'refund pending']; + $needsInvoiceFor = ['payment pending', 'enrolled']; $invCtl = new InvoiceController(); foreach ($groupsByParentStatus as $pid => $byStatus) { $statuses = array_keys($byStatus); diff --git a/app/Services/FeeCalculationService.php b/app/Services/FeeCalculationService.php index 0fd04cc..ec5e6e3 100644 --- a/app/Services/FeeCalculationService.php +++ b/app/Services/FeeCalculationService.php @@ -2,8 +2,6 @@ namespace App\Services; use App\Models\ConfigurationModel; -use App\Models\PaymentModel; -use App\Models\InvoiceModel; use App\Models\ClassSectionModel; class FeeCalculationService @@ -15,112 +13,44 @@ class FeeCalculationService public function calculateRefund(array $students, int $parentId): float { - $configModel = new ConfigurationModel(); - $paymentModel = new PaymentModel(); - $invoiceModel = new InvoiceModel(); - $classSectionModel = new ClassSectionModel(); + $totalCents = 0; + $seenEnrollmentIds = []; - $schoolYear = $configModel->getConfig('school_year'); - $refundDeadline = date('Y-m-d', strtotime($configModel->getConfig('refund_deadline'))); - $weekOfStudy = (float) ($configModel->getConfig('weeks_study') ?? 8); - $schoolEndDate = date('Y-m-d', strtotime($configModel->getConfig('last_day_of_school'))); - $totalPaid = $paymentModel->getTotalPaidByParentId($parentId, $schoolYear); - - if ($totalPaid <= 0) { - log_message('info', "No payments made. Refund = 0."); - return 0; - } - - // Classify and enrich student data - $registeredStudents = []; - $withdrawnStudents = []; - - foreach ($students as &$student) { - $gradeName = $classSectionModel->getClassSectionNameBySectionId($student['class_section_id']); - $student['grade'] = strtoupper(trim($gradeName)); - - if (in_array($student['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'])) { - $withdrawnStudents[] = $student; - } elseif ( - in_array($student['enrollment_status'], ['enrolled', 'payment pending']) && - $student['admission_status'] === 'accepted' - ) { - $registeredStudents[] = $student; - } - } - unset($student); - - if (empty($withdrawnStudents)) { - log_message('info', "No withdrawn students found. Refund = 0."); - return 0; - } - - usort($withdrawnStudents, function ($a, $b) { - $leftDate = strtotime((string)($a['withdrawal_date'] ?? '')) ?: PHP_INT_MAX; - $rightDate = strtotime((string)($b['withdrawal_date'] ?? '')) ?: PHP_INT_MAX; - - if ($leftDate !== $rightDate) { - return $leftDate <=> $rightDate; - } - - return $this->compareGrades($a['grade'], $b['grade']); - }); - - // Combine all students for proper fee tiering before withdrawal. - $allStudents = array_merge($registeredStudents, $withdrawnStudents); - - // Sort all students by grade for correct tiering - usort($allStudents, function ($a, $b) { - return $this->compareGrades($a['grade'], $b['grade']); - }); - - // Retrieve fee configs - $firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 380); - $secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 280); - - $refundFeeStack = $this->reverseTuitionRefundFeeStack( - count($allStudents), - count($registeredStudents), - $firstStudentFee, - $secondStudentFee - ); - - // Calculate refund for withdrawn students - $refundAmount = 0; - $withdrawnRefundIndex = 0; - - foreach ($withdrawnStudents as $student) { - if (empty($student['withdrawal_date'])) { - log_message('warning', "Missing withdraw date for student ID: {$student['student_id']}"); + foreach ($students as $student) { + if ((int) ($student['parent_id'] ?? $parentId) !== $parentId) { continue; } - $withdrawDate = date('Y-m-d', strtotime($student['withdrawal_date'])); - if (strtotime($withdrawDate) > strtotime($refundDeadline)) { - log_message('info', "Withdraw date {$withdrawDate} is after refund deadline {$refundDeadline}. No refund for this student."); + $status = strtolower(trim((string) ($student['enrollment_status'] ?? ''))); + if (! in_array($status, ['withdrawn', 'refund pending', 'withdraw under review'], true)) { continue; } - $withdrawDateObj = new \DateTime($withdrawDate); - $schoolEndDateObj = new \DateTime($schoolEndDate); - $daysRemaining = $withdrawDateObj->diff($schoolEndDateObj)->days; - $weeksRemaining = min($weekOfStudy, max(0, ceil($daysRemaining / 7))); + $enrollmentId = (int) ($student['enrollment_id'] ?? $student['id'] ?? 0); + if ($enrollmentId <= 0 || isset($seenEnrollmentIds[$enrollmentId])) { + continue; + } + $seenEnrollmentIds[$enrollmentId] = true; - $studentFee = (float) ($refundFeeStack[$withdrawnRefundIndex] ?? 0); - $withdrawnRefundIndex++; - $proportionalRefund = ($studentFee / $weekOfStudy) * $weeksRemaining; - $refundAmount += $proportionalRefund; + $calculation = $this->latestWithdrawalCalculation($enrollmentId); + if ($calculation === null || ($calculation['status'] ?? '') === 'superseded') { + $calculation = $this->previewWithdrawalCalculation($enrollmentId); + } - log_message('info', "Student ID {$student['student_id']} refund portion: {$proportionalRefund} of {$studentFee} for {$weeksRemaining} weeks."); + $totalCents += max(0, (int) ($calculation['new_refund_request_cents'] ?? 0)); } - if ($refundAmount > $totalPaid) { - log_message('info', "Refund capped at total paid amount: {$totalPaid}"); - return $totalPaid; - } + return round($totalCents / 100, 2); + } - log_message('info', "Final calculated refund: {$refundAmount}"); - return $refundAmount; + protected function latestWithdrawalCalculation(int $enrollmentId): ?array + { + return service('withdrawalFinancial')->latestForEnrollment($enrollmentId); + } + + protected function previewWithdrawalCalculation(int $enrollmentId): array + { + return service('withdrawalFinancial')->preview($enrollmentId, (int) (session()->get('user_id') ?? 0) ?: null); } /** diff --git a/app/Services/SchoolYearClosingService.php b/app/Services/SchoolYearClosingService.php index 826bd23..da7d3c9 100644 --- a/app/Services/SchoolYearClosingService.php +++ b/app/Services/SchoolYearClosingService.php @@ -79,6 +79,11 @@ final class SchoolYearClosingService $findings[] = $this->finding('blocking', 'Invoices missing school year', 'Some invoice records are not assigned to a school year.'); } + $inventory = $this->inventoryClosingPreview($sourceName, $target !== null ? (string) ($target['name'] ?? '') : ''); + foreach ($inventory['findings'] as $finding) { + $findings[] = $finding; + } + $carryForward = $this->carryForwardFamilies($sourceName); $warnings = array_values(array_filter($findings, static fn (array $f): bool => $f['severity'] === 'warning')); $blockers = array_values(array_filter($findings, static fn (array $f): bool => $f['severity'] === 'blocking')); @@ -89,6 +94,7 @@ final class SchoolYearClosingService 'overview' => $overview, 'finance' => $finance, 'promotion' => $promotion, + 'inventory' => $inventory, 'findings' => $findings, 'blockers' => $blockers, 'warnings' => $warnings, @@ -198,6 +204,7 @@ final class SchoolYearClosingService 'error_message' => null, ]); } + $this->executeInventoryCarryForward($source, $target, (int) $batch['id'], $userId); if (($batch['status'] ?? '') !== 'completed') { $this->batchModel->update((int) $batch['id'], ['status' => 'executed']); } @@ -239,6 +246,19 @@ final class SchoolYearClosingService $this->db->transStart(); $now = date('Y-m-d H:i:s'); + if ($this->db->tableExists('inventory_item_years')) { + $this->db->table('inventory_item_years') + ->where('school_year', (string) ($target['name'] ?? '')) + ->where('source_item_year_id IS NOT NULL', null, false) + ->where('closing_batch_id', (int) $batch['id']) + ->where('status', 'carried') + ->update(['status' => 'open', 'updated_at' => $now, 'updated_by' => $userId]); + $this->db->table('inventory_item_years') + ->where('school_year', (string) ($this->requireYear($sourceYearId)['name'] ?? '')) + ->where('closing_batch_id', (int) $batch['id']) + ->where('status', 'carried') + ->update(['status' => 'closed', 'updated_at' => $now, 'updated_by' => $userId]); + } $this->batchModel->update((int) $batch['id'], [ 'status' => 'completed', 'completed_by' => $userId, @@ -459,6 +479,195 @@ final class SchoolYearClosingService ]); } + private function inventoryClosingPreview(string $sourceYear, string $targetYear): array + { + $empty = ['rows' => [], 'summary' => ['books' => 0, 'target_opening_quantity' => 0], 'findings' => []]; + if (! $this->db->tableExists('inventory_item_years') || ! $this->db->tableExists('inventory_items')) { + return $empty; + } + + $itemYears = $this->db->table('inventory_item_years iy') + ->select('iy.*, i.name AS item_name, i.isbn, i.edition') + ->join('inventory_items i', 'i.id = iy.inventory_item_id', 'inner') + ->where('iy.school_year', $sourceYear) + ->where('i.type', 'book') + ->orderBy('i.name', 'ASC') + ->get()->getResultArray(); + + if ($itemYears === []) { + return $empty; + } + + $ids = array_map(static fn (array $row): int => (int) $row['id'], $itemYears); + $movementTotals = []; + $issueCounts = []; + if ($this->db->tableExists('inventory_movements')) { + foreach ($this->db->table('inventory_movements') + ->select('item_year_id, COALESCE(SUM(qty_change), 0) AS movement_total') + ->whereIn('item_year_id', $ids) + ->whereIn('status', ['posted', 'reversed']) + ->groupBy('item_year_id') + ->get()->getResultArray() as $row) { + $movementTotals[(int) $row['item_year_id']] = (int) ($row['movement_total'] ?? 0); + } + } + if ($this->db->tableExists('student_book_issues')) { + foreach ($this->db->table('student_book_issues') + ->select('inventory_item_year_id, COUNT(*) AS issue_count') + ->whereIn('inventory_item_year_id', $ids) + ->where('school_year', $sourceYear) + ->groupBy('inventory_item_year_id') + ->get()->getResultArray() as $row) { + $issueCounts[(int) $row['inventory_item_year_id']] = (int) ($row['issue_count'] ?? 0); + } + } + + $findings = []; + $rows = []; + $targetOpeningTotal = 0; + foreach ($itemYears as $row) { + $opening = (int) ($row['opening_quantity'] ?? 0); + $system = $opening + (int) ($movementTotals[(int) $row['id']] ?? 0); + $hasIssueSnapshots = (int) ($issueCounts[(int) $row['id']] ?? 0) > 0; + $counted = $row['counted_closing_quantity']; + $countedInt = $counted === null ? ($hasIssueSnapshots ? null : $system) : (int) $counted; + $variance = $countedInt === null ? null : $countedInt - $system; + $price = (int) ($row['charge_price_cents'] ?? 0); + if ($price <= 0 || (int) ($row['price_confirmed'] ?? 0) !== 1) { + $findings[] = $this->finding( + $hasIssueSnapshots ? 'blocking' : 'warning', + 'Book price missing', + (string) $row['item_name'] . ' has no confirmed charge price for ' . $sourceYear + . ($hasIssueSnapshots ? '.' : '; this bootstrap year has no issue price snapshots, so confirm the target-year price before future distribution.') + ); + } + if ($system < 0) { + $findings[] = $this->finding('blocking', 'Negative book stock', (string) $row['item_name'] . ' calculates to negative stock.'); + } + if ($counted === null && ! $hasIssueSnapshots) { + $findings[] = $this->finding('warning', 'Physical book count defaulted', (string) $row['item_name'] . ' has no physical count in this bootstrap year; system closing quantity will be carried forward.'); + } elseif ($countedInt === null) { + $findings[] = $this->finding('blocking', 'Physical book count missing', (string) $row['item_name'] . ' needs a counted closing quantity.'); + } elseif ($variance !== 0) { + $findings[] = $this->finding('blocking', 'Unresolved book variance', (string) $row['item_name'] . ' has variance ' . $variance . '. Resolve with an audited adjustment before closing.'); + } + $targetOpening = max(0, $countedInt ?? 0); + $targetOpeningTotal += $targetOpening; + $rows[] = [ + 'item_year_id' => (int) $row['id'], + 'inventory_item_id' => (int) $row['inventory_item_id'], + 'item_name' => (string) $row['item_name'], + 'isbn' => (string) ($row['isbn'] ?? ''), + 'edition' => (string) ($row['edition'] ?? ''), + 'opening_quantity' => $opening, + 'movement_total' => (int) ($movementTotals[(int) $row['id']] ?? 0), + 'system_closing_quantity' => $system, + 'counted_closing_quantity' => $countedInt, + 'variance_quantity' => $variance, + 'charge_price_cents' => $price, + 'target_school_year' => $targetYear, + 'target_opening_quantity' => $targetOpening, + ]; + } + + foreach ($this->inventoryEvidenceFindings($sourceYear) as $finding) { + $findings[] = $finding; + } + + return [ + 'rows' => $rows, + 'summary' => ['books' => count($rows), 'target_opening_quantity' => $targetOpeningTotal], + 'findings' => $findings, + ]; + } + + private function inventoryEvidenceFindings(string $sourceYear): array + { + if (! $this->db->tableExists('inventory_movements') || ! $this->db->tableExists('student_book_issues')) { + return []; + } + $findings = []; + $hasAnyIssueSnapshots = $this->db->table('student_book_issues') + ->where('school_year', $sourceYear) + ->countAllResults() > 0; + $missingIssues = $this->db->table('inventory_movements m') + ->join('student_book_issues sbi', 'sbi.distribution_movement_id = m.id', 'left') + ->where('m.school_year', $sourceYear) + ->where('m.movement_type', 'distribution') + ->where('m.status', 'posted') + ->where('sbi.id IS NULL', null, false) + ->countAllResults(); + if ($missingIssues > 0) { + $findings[] = $this->finding( + $hasAnyIssueSnapshots ? 'blocking' : 'warning', + 'Distribution movement missing issue snapshot', + $missingIssues . ' legacy book distribution movement(s) have no linked student book issue' + . ($hasAnyIssueSnapshots ? '.' : '; this bootstrap year will not use those movements as refund price evidence.') + ); + } + + $missingMovements = $this->db->table('student_book_issues sbi') + ->join('inventory_movements m', 'm.id = sbi.distribution_movement_id', 'left') + ->where('sbi.school_year', $sourceYear) + ->where('sbi.status', 'issued') + ->where('m.id IS NULL', null, false) + ->countAllResults(); + if ($missingMovements > 0) { + $findings[] = $this->finding('blocking', 'Student issue missing stock movement', $missingMovements . ' student book issue(s) have no linked stock movement.'); + } + + return $findings; + } + + private function executeInventoryCarryForward(array $source, array $target, int $batchId, ?int $userId): void + { + if (! $this->db->tableExists('inventory_item_years')) { + return; + } + $preview = $this->inventoryClosingPreview((string) $source['name'], (string) $target['name']); + $blockers = array_values(array_filter($preview['findings'], static fn (array $finding): bool => ($finding['severity'] ?? '') === 'blocking')); + if ($blockers !== []) { + throw new InvalidArgumentException('Resolve inventory closing blockers before executing carry-forward.'); + } + $now = date('Y-m-d H:i:s'); + foreach ($preview['rows'] as $row) { + $targetOpening = (int) ($row['target_opening_quantity'] ?? 0); + if ($targetOpening <= 0) { + continue; + } + $exists = $this->db->table('inventory_item_years') + ->where('inventory_item_id', (int) $row['inventory_item_id']) + ->where('school_year', (string) $target['name']) + ->get(1)->getRowArray(); + if ($exists === null) { + $this->db->table('inventory_item_years')->insert([ + 'inventory_item_id' => (int) $row['inventory_item_id'], + 'school_year_id' => (int) $target['id'], + 'school_year' => (string) $target['name'], + 'opening_quantity' => $targetOpening, + 'charge_price_cents' => (int) $row['charge_price_cents'], + 'currency' => 'USD', + 'price_confirmed' => 0, + 'status' => 'carried', + 'source_item_year_id' => (int) $row['item_year_id'], + 'closing_batch_id' => $batchId, + 'created_by' => $userId, + 'updated_by' => $userId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + $this->db->table('inventory_item_years')->where('id', (int) $row['item_year_id'])->update([ + 'system_closing_quantity' => (int) $row['system_closing_quantity'], + 'variance_quantity' => 0, + 'status' => 'carried', + 'closing_batch_id' => $batchId, + 'updated_by' => $userId, + 'updated_at' => $now, + ]); + } + } + private function countExistingTargetInvoices(array $batch): int { $batchId = (int) ($batch['id'] ?? 0); @@ -1120,6 +1329,7 @@ final class SchoolYearClosingService 'target_id' => $preview['target']['id'] ?? null, 'finance' => $preview['finance'], 'promotion' => $preview['promotion'], + 'inventory' => $preview['inventory'] ?? [], 'carry_forward' => $preview['carry_forward'], 'blockers' => $preview['blockers'], ], JSON_UNESCAPED_SLASHES)); diff --git a/app/Services/SchoolYearManagementService.php b/app/Services/SchoolYearManagementService.php index 0f2b233..b3a17ac 100644 --- a/app/Services/SchoolYearManagementService.php +++ b/app/Services/SchoolYearManagementService.php @@ -163,6 +163,12 @@ final class SchoolYearManagementService if (! SchoolYearStatus::canTransition($from, SchoolYearStatus::ACTIVE)) { throw new InvalidArgumentException('Only draft or approved reopened school years can be activated.'); } + if ($this->configurationTotalInstructionalWeeks() <= 0) { + throw new InvalidArgumentException('Set total instructional weeks before activating this school year.'); + } + if ((int) ($year['annual_fee_includes_books'] ?? 1) !== 1) { + throw new InvalidArgumentException('The withdrawal refund policy requires annual tuition to include books.'); + } $this->db->transStart(); $activeYears = $this->schoolYearModel->where('status', SchoolYearStatus::ACTIVE)->findAll(); @@ -348,6 +354,9 @@ final class SchoolYearManagementService 'registration_starts_on' => $this->nullableDate($payload['registration_starts_on'] ?? null), 'registration_ends_on' => $this->nullableDate($payload['registration_ends_on'] ?? null), 'fall_makeup_exam_on' => $this->nullableDate($payload['fall_makeup_exam_on'] ?? null), + 'total_instructional_weeks' => $this->nullableInt($payload['total_instructional_weeks'] ?? null), + 'annual_fee_includes_books' => 1, + 'withdrawal_policy_version' => trim((string) ($payload['withdrawal_policy_version'] ?? 'studied_weeks_v1')) ?: 'studied_weeks_v1', 'previous_school_year_id' => $this->nullableInt($payload['previous_school_year_id'] ?? null), ]; } @@ -377,6 +386,7 @@ final class SchoolYearManagementService $configValues = [ 'school_year' => $name, + 'total_instructional_weeks' => (string) ($schoolYear['total_instructional_weeks'] ?? ''), 'date_age_reference' => $ageReferenceDate, 'refund_deadline' => $ageReferenceDate, 'school_year_start_date' => $yearStart, @@ -409,6 +419,13 @@ final class SchoolYearManagementService } } + private function configurationTotalInstructionalWeeks(): int + { + $weeks = filter_var($this->configurationModel->getConfig('total_instructional_weeks'), FILTER_VALIDATE_INT); + + return $weeks !== false && $weeks > 0 ? (int) $weeks : 0; + } + private function withCalendarDefaults(array $payload, string $schoolYearName): array { $calendar = $this->calendarPayloadForSchoolYear($schoolYearName); diff --git a/app/Services/SchoolYearValidationService.php b/app/Services/SchoolYearValidationService.php index e8d27b8..bee797e 100644 --- a/app/Services/SchoolYearValidationService.php +++ b/app/Services/SchoolYearValidationService.php @@ -42,6 +42,14 @@ final class SchoolYearValidationService } $this->dateOrNull($payload['fall_makeup_exam_on'] ?? null); + + $weeks = $payload['total_instructional_weeks'] ?? null; + if ($weeks !== null && trim((string) $weeks) !== '') { + $parsed = filter_var($weeks, FILTER_VALIDATE_INT); + if ($parsed === false || $parsed <= 0) { + throw new InvalidArgumentException('Total instructional weeks must be a positive whole number.'); + } + } } public function isValidYearName(string $value): bool diff --git a/app/Services/StudentBookIssueService.php b/app/Services/StudentBookIssueService.php new file mode 100644 index 0000000..d6298e8 --- /dev/null +++ b/app/Services/StudentBookIssueService.php @@ -0,0 +1,403 @@ + $studentIds + * @return array{issued:int,skipped:int,issue_ids:list,unit_charge_price_cents:int,on_hand:int} + */ + public function distributeBatch( + int $inventoryItemId, + array $studentIds, + int $classSectionId, + int $schoolYearId, + string $schoolYear, + ?int $actorId, + ?string $note = null, + ?string $issuedAt = null, + ?string $batchKey = null + ): array { + $this->assertTables(); + $schoolYear = trim($schoolYear); + if ($inventoryItemId <= 0 || $classSectionId <= 0 || $schoolYearId <= 0 || $schoolYear === '') { + throw new InvalidArgumentException('Book, class section, and school year are required.'); + } + + $studentIds = array_values(array_unique(array_filter(array_map('intval', $studentIds), static fn (int $id): bool => $id > 0))); + if ($studentIds === []) { + return ['issued' => 0, 'skipped' => 0, 'issue_ids' => [], 'unit_charge_price_cents' => 0, 'on_hand' => $this->legacyOnHand($inventoryItemId)]; + } + + $issuedAt = $issuedAt !== null ? trim($issuedAt) : date('Y-m-d H:i:s'); + if (! preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $issuedAt)) { + throw new InvalidArgumentException('Issue time must use Y-m-d H:i:s.'); + } + $batchKey = trim((string) $batchKey); + if ($batchKey === '') { + $batchKey = hash('sha256', implode('|', [ + $schoolYear, $inventoryItemId, $classSectionId, $actorId ?? 0, + $issuedAt, implode(',', $studentIds), + ])); + } + + $this->db->transBegin(); + try { + $book = $this->db->query( + 'SELECT id, type, name, is_active FROM inventory_items WHERE id = ? FOR UPDATE', + [$inventoryItemId] + )->getRowArray(); + if ($book === null || ($book['type'] ?? '') !== 'book' || (int) ($book['is_active'] ?? 1) !== 1) { + throw new InvalidArgumentException('The selected book is missing or inactive.'); + } + + $itemYear = $this->db->query( + 'SELECT * FROM inventory_item_years WHERE inventory_item_id = ? AND school_year_id = ? AND school_year = ? FOR UPDATE', + [$inventoryItemId, $schoolYearId, $schoolYear] + )->getRowArray(); + if ($itemYear === null || ($itemYear['status'] ?? '') !== 'open') { + throw new InvalidArgumentException('The selected book does not have an open inventory record for this school year.'); + } + $priceCents = (int) ($itemYear['charge_price_cents'] ?? 0); + if ($priceCents <= 0 || (int) ($itemYear['price_confirmed'] ?? 0) !== 1) { + throw new InvalidArgumentException('Enter and confirm this book’s school-year charge price before distribution.'); + } + + $enrollments = []; + $toIssue = []; + $skipped = 0; + foreach ($studentIds as $studentId) { + $existing = $this->db->table('student_book_issues') + ->select('id') + ->where('student_id', $studentId) + ->where('inventory_item_year_id', (int) $itemYear['id']) + ->where('status', 'issued') + ->get(1) + ->getRowArray(); + if ($existing !== null) { + $skipped++; + continue; + } + + $enrollment = $this->db->table('enrollments') + ->select('id, parent_id, class_section_id') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + if ($enrollment === null || (int) ($enrollment['parent_id'] ?? 0) <= 0) { + throw new InvalidArgumentException('Student #' . $studentId . ' has no valid enrollment for ' . $schoolYear . '.'); + } + if ((int) ($enrollment['class_section_id'] ?? 0) > 0 + && (int) $enrollment['class_section_id'] !== $classSectionId) { + throw new InvalidArgumentException('Student #' . $studentId . ' is not enrolled in the selected class section.'); + } + $enrollments[$studentId] = $enrollment; + $toIssue[] = $studentId; + } + + $onHand = $this->onHandForItemYear((int) $itemYear['id'], (int) $itemYear['opening_quantity']); + if (count($toIssue) > $onHand) { + throw new InvalidArgumentException('Not enough stock: need ' . count($toIssue) . ', on hand ' . $onHand . '.'); + } + + $issueIds = []; + foreach ($toIssue as $studentId) { + $enrollment = $enrollments[$studentId]; + $idempotencyKey = substr($batchKey . ':student:' . $studentId, 0, 160); + $existingKey = $this->db->table('student_book_issues') + ->select('id') + ->where('idempotency_key', $idempotencyKey) + ->get(1) + ->getRowArray(); + if ($existingKey !== null) { + $skipped++; + continue; + } + + $now = date('Y-m-d H:i:s'); + $issue = [ + 'student_id' => $studentId, + 'enrollment_id' => (int) $enrollment['id'], + 'parent_id' => (int) $enrollment['parent_id'], + 'inventory_item_id' => $inventoryItemId, + 'inventory_item_year_id' => (int) $itemYear['id'], + 'school_year' => $schoolYear, + 'class_section_id' => $classSectionId, + 'quantity' => 1, + 'unit_charge_price_cents' => $priceCents, + 'total_charge_cents' => $priceCents, + 'idempotency_key' => $idempotencyKey, + 'status' => 'issued', + 'issued_at' => $issuedAt, + 'issued_by' => $actorId, + 'created_at' => $now, + 'updated_at' => $now, + ]; + if (! $this->db->table('student_book_issues')->insert($issue)) { + throw new RuntimeException('Unable to create a student book issue.'); + } + $issueId = (int) $this->db->insertID(); + + $movement = [ + 'item_id' => $inventoryItemId, + 'item_year_id' => (int) $itemYear['id'], + 'qty_change' => -1, + 'movement_type' => 'distribution', + 'reason' => 'Student book distribution', + 'note' => $note, + 'semester' => null, + 'school_year' => $schoolYear, + 'performed_by' => $actorId, + 'student_id' => $studentId, + 'class_section_id' => $classSectionId, + 'idempotency_key' => substr($idempotencyKey . ':movement', 0, 120), + 'status' => 'posted', + 'source_type' => 'student_book_issue', + 'source_id' => $issueId, + 'created_at' => $now, + 'updated_at' => $now, + ]; + if (! $this->db->table('inventory_movements')->insert($movement)) { + throw new RuntimeException('Unable to create the issue stock movement.'); + } + $movementId = (int) $this->db->insertID(); + if (! $this->db->table('student_book_issues')->where('id', $issueId)->update([ + 'distribution_movement_id' => $movementId, + 'updated_at' => $now, + ])) { + throw new RuntimeException('Unable to link the issue to its stock movement.'); + } + $issueIds[] = $issueId; + } + + $onHand -= count($issueIds); + $this->db->table('inventory_items')->where('id', $inventoryItemId)->update([ + 'quantity' => max(0, $onHand), + 'updated_by' => $actorId, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + if (! $this->db->transCommit()) { + throw new RuntimeException('Unable to commit the book distribution.'); + } + + return [ + 'issued' => count($issueIds), + 'skipped' => $skipped, + 'issue_ids' => $issueIds, + 'unit_charge_price_cents' => $priceCents, + 'on_hand' => $onHand, + ]; + } catch (Throwable $e) { + $this->db->transRollback(); + throw $e; + } + } + + public function reverseErroneousIssue(int $issueId, string $reason, ?int $actorId): void + { + $reason = trim($reason); + if ($issueId <= 0 || $reason === '') { + throw new InvalidArgumentException('Issue and correction reason are required.'); + } + + $this->db->transBegin(); + try { + $issue = $this->db->query('SELECT * FROM student_book_issues WHERE id = ? FOR UPDATE', [$issueId])->getRowArray(); + if ($issue === null || ($issue['status'] ?? '') !== 'issued') { + throw new InvalidArgumentException('Only an active issue can be corrected.'); + } + $itemYear = $this->db->query('SELECT * FROM inventory_item_years WHERE id = ? FOR UPDATE', [ + (int) $issue['inventory_item_year_id'], + ])->getRowArray(); + if ($itemYear === null || ($itemYear['status'] ?? '') !== 'open') { + throw new InvalidArgumentException('Corrections are not allowed after the inventory year is closed.'); + } + + $now = date('Y-m-d H:i:s'); + $quantity = (int) $issue['quantity']; + $movement = [ + 'item_id' => (int) $issue['inventory_item_id'], + 'item_year_id' => (int) $issue['inventory_item_year_id'], + 'qty_change' => $quantity, + 'movement_type' => 'adjust', + 'reason' => 'Correction of erroneous book issue', + 'note' => $reason, + 'school_year' => (string) $issue['school_year'], + 'performed_by' => $actorId, + 'student_id' => (int) $issue['student_id'], + 'class_section_id' => (int) ($issue['class_section_id'] ?? 0) ?: null, + 'reversal_of_movement_id' => (int) ($issue['distribution_movement_id'] ?? 0) ?: null, + 'idempotency_key' => substr('student-book-issue-reversal:' . $issueId, 0, 120), + 'status' => 'posted', + 'source_type' => 'student_book_issue_reversal', + 'source_id' => $issueId, + 'created_at' => $now, + 'updated_at' => $now, + ]; + if (! $this->db->table('inventory_movements')->insert($movement)) { + throw new RuntimeException('Unable to create the correction movement.'); + } + $reversalMovementId = (int) $this->db->insertID(); + + $this->db->table('student_book_issues')->where('id', $issueId)->update([ + 'status' => 'reversed', + 'reversed_at' => $now, + 'reversed_by' => $actorId, + 'reversal_reason' => $reason, + 'reversal_quantity' => $quantity, + 'reversal_movement_id' => $reversalMovementId, + 'updated_at' => $now, + ]); + if ((int) ($issue['distribution_movement_id'] ?? 0) > 0) { + $this->db->table('inventory_movements') + ->where('id', (int) $issue['distribution_movement_id']) + ->update(['status' => 'reversed', 'reversed_at' => $now, 'reversed_by' => $actorId]); + } + + $this->markPostedCalculationsForReview($issueId, (int) $issue['student_id'], (string) $issue['school_year'], $now); + + $onHand = $this->onHandForItemYear((int) $itemYear['id'], (int) $itemYear['opening_quantity']); + $this->db->table('inventory_items')->where('id', (int) $issue['inventory_item_id'])->update([ + 'quantity' => max(0, $onHand), + 'updated_by' => $actorId, + 'updated_at' => $now, + ]); + + if (! $this->db->transCommit()) { + throw new RuntimeException('Unable to commit the issue correction.'); + } + } catch (Throwable $e) { + $this->db->transRollback(); + throw $e; + } + } + + /** @return list> */ + public function activeIssuesAsOf(int $studentId, string $schoolYear, string $date): array + { + $dateSql = $this->db->escape($date); + + return $this->db->table('student_book_issues sbi') + ->select('sbi.*, i.name AS book_name, i.isbn, i.edition') + ->join('inventory_items i', 'i.id = sbi.inventory_item_id', 'inner') + ->where('sbi.student_id', $studentId) + ->where('sbi.school_year', $schoolYear) + ->where('sbi.status', 'issued') + ->where('DATE(sbi.issued_at) <= ' . $dateSql, null, false) + ->orderBy('sbi.issued_at', 'ASC') + ->orderBy('sbi.id', 'ASC') + ->get() + ->getResultArray(); + } + + public function totalChargeCentsAsOf(int $studentId, string $schoolYear, string $date): int + { + $dateSql = $this->db->escape($date); + + $row = $this->db->table('student_book_issues') + ->selectSum('total_charge_cents', 'total') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->where('status', 'issued') + ->where('DATE(issued_at) <= ' . $dateSql, null, false) + ->get() + ->getRowArray(); + + return (int) ($row['total'] ?? 0); + } + + /** @return list> */ + public function issueEvidenceAsOf(int $studentId, string $schoolYear, string $date): array + { + $dateSql = $this->db->escape($date); + + return $this->db->table('student_book_issues sbi') + ->select('sbi.id, sbi.inventory_item_id, sbi.quantity, sbi.unit_charge_price_cents, sbi.total_charge_cents, sbi.status, sbi.issued_at, sbi.reversed_at, sbi.reversal_reason, sbi.reversal_quantity, sbi.reversal_movement_id, i.name AS book_name, i.isbn, i.edition') + ->join('inventory_items i', 'i.id = sbi.inventory_item_id', 'inner') + ->where('sbi.student_id', $studentId) + ->where('sbi.school_year', $schoolYear) + ->where('DATE(sbi.issued_at) <= ' . $dateSql, null, false) + ->orderBy('sbi.issued_at', 'ASC') + ->orderBy('sbi.id', 'ASC') + ->get() + ->getResultArray(); + } + + private function onHandForItemYear(int $itemYearId, int $openingQuantity): int + { + $row = $this->db->table('inventory_movements') + ->selectSum('qty_change', 'movement_total') + ->where('item_year_id', $itemYearId) + ->whereIn('status', ['posted', 'reversed']) + ->get() + ->getRowArray(); + + return $openingQuantity + (int) ($row['movement_total'] ?? 0); + } + + private function legacyOnHand(int $itemId): int + { + $row = $this->db->table('inventory_items')->select('quantity')->where('id', $itemId)->get(1)->getRowArray(); + return (int) ($row['quantity'] ?? 0); + } + + private function markPostedCalculationsForReview(int $issueId, int $studentId, string $schoolYear, string $now): void + { + if (! $this->db->tableExists('withdrawal_financial_calculations')) { + return; + } + $calculations = $this->db->table('withdrawal_financial_calculations') + ->select('id, book_evidence_json') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->where('status', 'posted') + ->get() + ->getResultArray(); + foreach ($calculations as $calculation) { + $evidence = json_decode((string) ($calculation['book_evidence_json'] ?? '[]'), true); + $ids = array_map('intval', array_column(is_array($evidence) ? $evidence : [], 'id')); + if (! in_array($issueId, $ids, true)) { + continue; + } + $calculationId = (int) $calculation['id']; + $this->db->table('withdrawal_financial_calculations')->where('id', $calculationId)->update([ + 'status' => 'requires_review', + 'active_posted_key' => null, + 'updated_at' => $now, + ]); + if ($this->db->fieldExists('withdrawal_calculation_id', 'refunds')) { + $this->db->table('refunds')->where('withdrawal_calculation_id', $calculationId)->update([ + 'reconciliation_status' => 'requires_review', + 'reconciliation_reason' => 'Book issue #' . $issueId . ' was corrected after posting.', + 'reconciliation_required_at' => $now, + 'updated_at' => $now, + ]); + } + } + } + + private function assertTables(): void + { + foreach (['inventory_item_years', 'student_book_issues', 'inventory_movements', 'enrollments'] as $table) { + if (! $this->db->tableExists($table)) { + throw new RuntimeException('Required table is missing: ' . $table . '. Run migrations first.'); + } + } + } +} diff --git a/app/Services/WithdrawalFinancialService.php b/app/Services/WithdrawalFinancialService.php new file mode 100644 index 0000000..8a6fa06 --- /dev/null +++ b/app/Services/WithdrawalFinancialService.php @@ -0,0 +1,936 @@ + */ + public function requestWithdrawal(int $enrollmentId, string $requestDate, ?int $actorId): array + { + $this->assertTables(); + $this->db->transBegin(); + try { + $enrollment = $this->lockEnrollment($enrollmentId); + $status = strtolower(trim((string) ($enrollment['enrollment_status'] ?? ''))); + if (! in_array($status, ['enrolled', 'payment pending', 'withdraw under review'], true)) { + throw new InvalidArgumentException('Only an active enrollment can request withdrawal.'); + } + $requestDate = $this->validDate($requestDate, 'Withdrawal request date'); + $storedRequestDate = trim((string) ($enrollment['withdrawal_date'] ?? '')); + if ($storedRequestDate !== '') { + $requestDate = $this->validDate($storedRequestDate, 'Existing withdrawal request date'); + } + $this->db->table('enrollments')->where('id', $enrollmentId)->update([ + 'withdrawal_date' => $requestDate, + 'is_withdrawn' => 1, + 'enrollment_status' => 'withdraw under review', + 'updated_at' => utc_now(), + ]); + + $result = $this->createPreviewLocked($enrollmentId, $actorId, []); + $this->commitOrFail('Unable to save the withdrawal request.'); + + return $result; + } catch (Throwable $e) { + $this->db->transRollback(); + throw $e; + } + } + + /** + * @param array{enrollment_date?:string,withdrawal_request_date?:string,override_reason?:string,legacy_invoice_confirmed?:bool} $overrides + * @return array + */ + public function preview(int $enrollmentId, ?int $actorId, array $overrides = []): array + { + $this->assertTables(); + $this->db->transBegin(); + try { + $result = $this->createPreviewLocked($enrollmentId, $actorId, $overrides); + $this->commitOrFail('Unable to save the withdrawal calculation preview.'); + + return $result; + } catch (Throwable $e) { + $this->db->transRollback(); + throw $e; + } + } + + /** @return array */ + public function post(int $calculationId, ?int $actorId): array + { + $this->assertTables(); + $this->db->transBegin(); + $transactionClosed = false; + try { + $calculation = $this->db->query( + 'SELECT * FROM withdrawal_financial_calculations WHERE id = ? FOR UPDATE', + [$calculationId] + )->getRowArray(); + if ($calculation === null) { + throw new InvalidArgumentException('Withdrawal calculation not found.'); + } + if (! in_array((string) ($calculation['status'] ?? ''), ['preview', 'requires_review'], true)) { + if (($calculation['status'] ?? '') === 'posted') { + $this->db->transCommit(); + $transactionClosed = true; + return $this->details($calculationId); + } + throw new InvalidArgumentException('Only the latest preview can be posted.'); + } + + $enrollment = $this->lockEnrollment((int) $calculation['enrollment_id']); + $invoiceId = (int) ($calculation['invoice_id'] ?? 0); + if ($invoiceId <= 0) { + throw new RuntimeException('Resolve the invoice blocker before confirming this withdrawal.'); + } + $invoice = $this->lockInvoice($invoiceId); + $year = $this->lockSchoolYear((string) $calculation['school_year']); + + $fresh = $this->buildSnapshot($enrollment, $actorId, [ + 'enrollment_date' => (string) $calculation['enrollment_date'], + 'withdrawal_request_date' => (string) $calculation['withdrawal_request_date'], + 'override_reason' => (string) ($calculation['override_reason'] ?? ''), + 'legacy_invoice_confirmed' => str_contains((string) ($calculation['override_reason'] ?? ''), '[legacy invoice confirmed]'), + ], $invoice); + if ($fresh['blockers'] !== []) { + $this->db->table('withdrawal_financial_calculations')->where('id', $calculationId)->update([ + 'status' => 'requires_review', + 'explanation_json' => json_encode($fresh['explanation'], JSON_UNESCAPED_SLASHES), + 'updated_at' => utc_now(), + ]); + $this->commitOrFail('Unable to mark the calculation for review.'); + $transactionClosed = true; + throw new RuntimeException('The calculation has blockers and was marked for review: ' . implode(' ', $fresh['blockers'])); + } + $gateBlockers = $this->postingGateBlockersForYear($year); + if ($gateBlockers !== []) { + throw new RuntimeException(implode(' ', $gateBlockers)); + } + if (! hash_equals((string) $calculation['calculation_hash'], (string) $fresh['row']['calculation_hash'])) { + $this->db->table('withdrawal_financial_calculations')->where('id', $calculationId)->update([ + 'status' => 'requires_review', + 'updated_at' => utc_now(), + ]); + $this->commitOrFail('Unable to mark the stale calculation for review.'); + $transactionClosed = true; + throw new RuntimeException('The source data changed after preview. Generate and review a new calculation.'); + } + + $this->lockRefundRows($invoiceId); + $previous = $this->db->query( + "SELECT * FROM withdrawal_financial_calculations + WHERE enrollment_id = ? + AND (status = 'posted' OR (status = 'requires_review' AND posted_at IS NOT NULL)) + AND id != ? FOR UPDATE", + [(int) $enrollment['id'], $calculationId] + )->getResultArray(); + foreach ($previous as $old) { + $this->supersedePostedCalculation((int) $old['id'], $calculationId); + } + + $this->appendInvoiceLines($invoice, $calculation, $fresh['books']); + $ledger = $this->invoiceLedger->recalculateInvoice($invoiceId); + $refund = $this->syncRefundRequest($calculation, $ledger, $actorId); + $creditCents = (int) ($ledger['customerCreditCents'] ?? 0); + $balanceCents = (int) ($ledger['balanceDueCents'] ?? 0); + + $this->db->table('withdrawal_financial_calculations')->where('id', $calculationId)->update([ + 'status' => 'posted', + 'active_posted_key' => 'withdrawal-enrollment:' . (int) $enrollment['id'], + 'adjusted_invoice_charge_cents' => (int) ($ledger['net_charge_cents'] ?? 0), + 'refundable_credit_cents' => $creditCents, + 'new_refund_request_cents' => (int) ($refund['requested_amount_cents'] ?? 0), + 'balance_due_cents' => $balanceCents, + 'posted_by' => $actorId, + 'posted_at' => utc_now(), + 'updated_at' => utc_now(), + ]); + $this->db->table('enrollments')->where('id', (int) $enrollment['id'])->update([ + 'enrollment_status' => $creditCents > 0 ? 'refund pending' : 'withdrawn', + 'is_withdrawn' => 1, + 'updated_at' => utc_now(), + ]); + $this->commitOrFail('Unable to post the withdrawal calculation.'); + $transactionClosed = true; + + return $this->details($calculationId); + } catch (Throwable $e) { + if (! $transactionClosed) { + $this->db->transRollback(); + } + throw $e; + } + } + + /** @return list */ + public function postingGateBlockers(array $calculation): array + { + $yearName = trim((string) ($calculation['school_year'] ?? '')); + if ($yearName === '') { + return ['School year configuration was not found.']; + } + + $year = $this->db->table('school_years')->where('name', $yearName)->get(1)->getRowArray(); + if ($year === null) { + return ['School year configuration was not found.']; + } + + return $this->postingGateBlockersForYear($year); + } + + /** @return array|null */ + public function latestForEnrollment(int $enrollmentId): ?array + { + $row = $this->db->table('withdrawal_financial_calculations') + ->where('enrollment_id', $enrollmentId) + ->orderBy('version', 'DESC') + ->get(1) + ->getRowArray(); + + return $row === null ? null : $this->decodeCalculation($row); + } + + /** @return array */ + public function details(int $calculationId): array + { + $row = $this->db->table('withdrawal_financial_calculations wfc') + ->select('wfc.*, s.firstname AS student_firstname, s.lastname AS student_lastname, i.invoice_number') + ->join('students s', 's.id = wfc.student_id', 'left') + ->join('invoices i', 'i.id = wfc.invoice_id', 'left') + ->where('wfc.id', $calculationId) + ->get(1) + ->getRowArray(); + if ($row === null) { + throw new InvalidArgumentException('Withdrawal calculation not found.'); + } + + return $this->decodeCalculation($row); + } + + /** @return list */ + private function postingGateBlockersForYear(array $year): array + { + $blockers = []; + if ($this->totalInstructionalWeeks() <= 0) { + $blockers[] = 'Set a positive total_instructional_weeks value in configuration.'; + } + if ((int) ($year['annual_fee_includes_books'] ?? 0) !== 1) { + $blockers[] = 'The annual fee must be marked as book-inclusive.'; + } + $missingBookPrices = $this->missingBookPriceCount((string) ($year['name'] ?? '')); + if ($missingBookPrices > 0) { + $blockers[] = $missingBookPrices . ' book price(s) must be confirmed before withdrawal refunds can be posted.'; + } + + return $blockers; + } + + private function missingBookPriceCount(string $schoolYear): int + { + if ($schoolYear === '' + || ! $this->db->tableExists('inventory_item_years') + || ! $this->db->tableExists('inventory_items')) { + return 0; + } + + return $this->db->table('inventory_item_years iy') + ->join('inventory_items i', 'i.id = iy.inventory_item_id', 'inner') + ->where('iy.school_year', $schoolYear) + ->where('i.type', 'book') + ->groupStart() + ->where('iy.charge_price_cents <=', 0) + ->orWhere('iy.price_confirmed !=', 1) + ->groupEnd() + ->countAllResults(); + } + + /** @return list> */ + public function calculationsForInvoice(int $invoiceId): array + { + $rows = $this->db->table('withdrawal_financial_calculations wfc') + ->select('wfc.*, s.firstname AS student_firstname, s.lastname AS student_lastname, i.invoice_number') + ->join('students s', 's.id = wfc.student_id', 'left') + ->join('invoices i', 'i.id = wfc.invoice_id', 'left') + ->where('wfc.invoice_id', $invoiceId) + ->whereIn('wfc.status', ['posted', 'requires_review']) + ->orderBy('wfc.withdrawal_request_date', 'ASC') + ->orderBy('wfc.student_id', 'ASC') + ->orderBy('wfc.version', 'DESC') + ->get()->getResultArray(); + $seen = []; + $result = []; + foreach ($rows as $row) { + $enrollmentId = (int) $row['enrollment_id']; + if (isset($seen[$enrollmentId])) { + continue; + } + $seen[$enrollmentId] = true; + $result[] = $this->decodeCalculation($row); + } + return $result; + } + + public function markCalculationsForIssueCorrection(int $studentId, string $schoolYear, int $issueId): void + { + if (! $this->db->tableExists('withdrawal_financial_calculations')) { + return; + } + $rows = $this->db->table('withdrawal_financial_calculations') + ->select('id, book_evidence_json, status') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->where('status', 'posted') + ->get() + ->getResultArray(); + foreach ($rows as $row) { + $evidence = json_decode((string) ($row['book_evidence_json'] ?? '[]'), true); + $issueIds = array_map('intval', array_column(is_array($evidence) ? $evidence : [], 'id')); + if (in_array($issueId, $issueIds, true)) { + $this->db->table('withdrawal_financial_calculations')->where('id', (int) $row['id'])->update([ + 'status' => 'requires_review', + 'active_posted_key' => null, + 'updated_at' => utc_now(), + ]); + $this->db->table('refunds')->where('withdrawal_calculation_id', (int) $row['id'])->update([ + 'reconciliation_status' => 'requires_review', + 'reconciliation_reason' => 'Book issue #' . $issueId . ' was corrected after the withdrawal calculation was posted.', + 'reconciliation_required_at' => utc_now(), + 'updated_at' => utc_now(), + ]); + } + } + } + + /** @return array */ + private function createPreviewLocked(int $enrollmentId, ?int $actorId, array $overrides): array + { + $enrollment = $this->lockEnrollment($enrollmentId); + $invoiceResolution = $this->resolveInvoice((int) $enrollment['parent_id'], (string) $enrollment['school_year']); + $invoice = $invoiceResolution['invoice']; + $snapshot = $this->buildSnapshot($enrollment, $actorId, $overrides, $invoice); + $snapshot['blockers'] = array_values(array_unique(array_merge($invoiceResolution['blockers'], $snapshot['blockers']))); + $snapshot['explanation']['blockers'] = $snapshot['blockers']; + $snapshot['row']['status'] = $snapshot['blockers'] === [] ? 'preview' : 'requires_review'; + $snapshot['row']['explanation_json'] = json_encode($snapshot['explanation'], JSON_UNESCAPED_SLASHES); + $snapshot['row']['calculation_hash'] = $this->snapshotHash($snapshot['row'], $snapshot['books'], $snapshot['explanation']); + + $latest = $this->db->query( + 'SELECT * FROM withdrawal_financial_calculations WHERE enrollment_id = ? ORDER BY version DESC LIMIT 1 FOR UPDATE', + [$enrollmentId] + )->getRowArray(); + if ($latest !== null + && in_array((string) ($latest['status'] ?? ''), ['preview', 'requires_review'], true) + && hash_equals((string) ($latest['calculation_hash'] ?? ''), (string) $snapshot['row']['calculation_hash'])) { + return $this->details((int) $latest['id']); + } + + $snapshot['row']['version'] = ((int) ($latest['version'] ?? 0)) + 1; + $this->db->table('withdrawal_financial_calculations')->insert($snapshot['row']); + $id = (int) $this->db->insertID(); + if ($id <= 0) { + throw new RuntimeException('Unable to persist the withdrawal calculation.'); + } + $this->db->table('withdrawal_financial_calculations') + ->where('enrollment_id', $enrollmentId) + ->where('id !=', $id) + ->whereIn('status', ['preview', 'requires_review']) + ->where('posted_at', null) + ->update(['status' => 'superseded', 'superseded_by_id' => $id, 'updated_at' => utc_now()]); + + return $this->details($id); + } + + /** @return array{row:array,books:list>,blockers:list,explanation:array} */ + private function buildSnapshot(array $enrollment, ?int $actorId, array $overrides, ?array $invoice): array + { + $year = $this->lockSchoolYear((string) $enrollment['school_year']); + $blockers = []; + $weeks = $this->totalInstructionalWeeks(); + if ($weeks <= 0) { + $blockers[] = 'Set a positive total_instructional_weeks value in configuration.'; + } + if ((int) ($year['annual_fee_includes_books'] ?? 0) !== 1) { + $blockers[] = 'The annual fee must be marked as book-inclusive.'; + } + + $storedEnrollmentDate = $this->validDate((string) ($enrollment['enrollment_date'] ?? ''), 'Enrollment date'); + $storedWithdrawalDate = $this->validDate((string) ($enrollment['withdrawal_date'] ?? date('Y-m-d')), 'Withdrawal request date'); + $enrollmentDate = isset($overrides['enrollment_date']) && trim((string) $overrides['enrollment_date']) !== '' + ? $this->validDate((string) $overrides['enrollment_date'], 'Corrected enrollment date') + : $storedEnrollmentDate; + $withdrawalDate = isset($overrides['withdrawal_request_date']) && trim((string) $overrides['withdrawal_request_date']) !== '' + ? $this->validDate((string) $overrides['withdrawal_request_date'], 'Corrected withdrawal date') + : $storedWithdrawalDate; + $overrideReason = trim((string) ($overrides['override_reason'] ?? '')); + $dateChanged = $enrollmentDate !== $storedEnrollmentDate || $withdrawalDate !== $storedWithdrawalDate; + $legacyConfirmed = ! empty($overrides['legacy_invoice_confirmed']); + if (($dateChanged || $legacyConfirmed) && $overrideReason === '') { + throw new InvalidArgumentException('A reason is required for date corrections or legacy invoice confirmation.'); + } + if ($legacyConfirmed && ! str_contains($overrideReason, '[legacy invoice confirmed]')) { + $overrideReason .= ($overrideReason === '' ? '' : ' ') . '[legacy invoice confirmed]'; + } + + $allocation = $this->annualAllocationForEnrollment($enrollment, $invoice); + $books = $this->bookIssues->issueEvidenceAsOf((int) $enrollment['student_id'], (string) $enrollment['school_year'], $withdrawalDate); + $activeBooks = array_values(array_filter($books, static fn (array $book): bool => ($book['status'] ?? '') === 'issued')); + $bookCharge = array_sum(array_map(static fn (array $book): int => (int) ($book['total_charge_cents'] ?? 0), $activeBooks)); + if ($bookCharge > $allocation['annual_allocation_cents']) { + $blockers[] = 'Issued-book charges exceed this student’s annual tuition allocation.'; + } + + $ledger = null; + $originalInvoiceCharge = 0; + $validPayments = 0; + $completedPayouts = 0; + $otherCharges = 0; + $baseGrossCharge = 0; + $baseDiscountEligible = 0; + $requestedDiscount = 0; + if ($invoice !== null) { + $this->lockInvoice((int) $invoice['id']); + $ledger = $this->invoiceLedger->calculateInvoice((int) $invoice['id']); + $existingTargetAdjustment = $this->db->table('invoice_lines il') + ->selectSum('il.line_amount_cents', 'total') + ->select("COALESCE(SUM(CASE WHEN il.discount_eligible = 1 THEN il.line_amount_cents ELSE 0 END), 0) AS eligible_total", false) + ->join('withdrawal_financial_calculations wfc', 'wfc.id = il.source_id AND il.source_type = \'withdrawal_calculation\'', 'inner') + ->where('il.invoice_id', (int) $invoice['id']) + ->where('wfc.enrollment_id', (int) $enrollment['id']) + ->where('il.voided_at', null) + ->get()->getRowArray(); + $baseGrossCharge = (int) ($ledger['gross_charge_cents'] ?? 0) - (int) ($existingTargetAdjustment['total'] ?? 0); + $baseDiscountEligible = max(0, (int) ($ledger['discount_eligible_base_cents'] ?? 0) - (int) ($existingTargetAdjustment['eligible_total'] ?? 0)); + $requestedDiscount = max(0, (int) ($ledger['requested_discount_cents'] ?? 0)); + $originalInvoiceCharge = max(0, $baseGrossCharge - min($requestedDiscount, $baseDiscountEligible)); + $validPayments = (int) ($ledger['paidCents'] ?? 0); + $completedPayouts = (int) ($ledger['completedRefundCents'] ?? 0); + $otherCharges = max(0, (int) ($ledger['eventCents'] ?? 0) + (int) ($ledger['additionalCents'] ?? 0)); + foreach ($this->invoiceReconstructionBlockers((int) $invoice['id'], $allocation['original_family_tuition_cents'], $allocation['original_student_count'], $legacyConfirmed) as $blocker) { + $blockers[] = $blocker; + } + } + + $schoolStart = trim((string) ($year['starts_on'] ?? '')); + if ($schoolStart === '') { + $schoolStart = $enrollmentDate; + $blockers[] = 'School-year start date is missing; the enrollment date was used only to display this blocked preview.'; + } + $baseInput = [ + 'annual_fee_allocation_cents' => $allocation['annual_allocation_cents'], + 'issued_book_charge_cents' => $bookCharge, + 'total_instructional_weeks' => max(1, $weeks), + 'school_year_start_date' => $schoolStart, + 'enrollment_date' => $enrollmentDate, + 'withdrawal_request_date' => $withdrawalDate, + 'annual_fee_includes_books' => true, + ]; + $studentCalculation = $this->calculator->calculate($baseInput); + $adjustment = (int) $studentCalculation['retained_charge_cents'] - $allocation['annual_allocation_cents']; + $newEligibleAdjustment = (int) $studentCalculation['earned_tuition_cents'] - $allocation['annual_allocation_cents']; + $adjustedGrossCharge = $baseGrossCharge + $adjustment; + $adjustedDiscountEligible = max(0, $baseDiscountEligible + $newEligibleAdjustment); + $adjustedDiscount = min($requestedDiscount, $adjustedDiscountEligible); + $adjustedInvoiceCharge = max(0, $adjustedGrossCharge - $adjustedDiscount); + $netPayments = max(0, $validPayments - $completedPayouts); + $refundableCredit = max(0, $netPayments - $adjustedInvoiceCharge); + $balanceDue = max(0, $adjustedInvoiceCharge - $netPayments); + // A withdrawal refund is one invoice-level family claim. Exclude that + // claim when replacing it for a sibling, otherwise its old reservation + // incorrectly suppresses the new family credit in the preview. + $existingWithdrawalRefundId = $invoice === null ? null : $this->existingWithdrawalRefundId((int) $invoice['id']); + $reservations = $invoice === null ? 0 : $this->openInvoiceReservations((int) $invoice['id'], $existingWithdrawalRefundId); + $newRefund = max(0, $refundableCredit - $reservations); + + $explanation = [ + 'formula' => 'books + round((annual allocation - books) * studied weeks / total instructional weeks)', + 'no_school_days_subtracted' => false, + 'books_returnable' => false, + 'books_discount_eligible' => false, + 'allocation' => $allocation, + 'discount_projection' => [ + 'requested_discount_cents' => $requestedDiscount, + 'adjusted_discount_eligible_cents' => $adjustedDiscountEligible, + 'adjusted_discount_cents' => $adjustedDiscount, + 'books_discount_eligible' => false, + ], + 'blockers' => $blockers, + ]; + $now = utc_now(); + $row = [ + 'enrollment_id' => (int) $enrollment['id'], + 'student_id' => (int) $enrollment['student_id'], + 'parent_id' => (int) $enrollment['parent_id'], + 'invoice_id' => $invoice === null ? null : (int) $invoice['id'], + 'school_year' => (string) $enrollment['school_year'], + 'policy_version' => (string) ($year['withdrawal_policy_version'] ?? 'studied_weeks_v1'), + 'annual_fee_includes_books' => 1, + 'school_year_start_date' => $studentCalculation['school_year_start_date'], + 'enrollment_date' => $studentCalculation['enrollment_date'], + 'withdrawal_request_date' => $studentCalculation['withdrawal_request_date'], + 'total_instructional_weeks' => $weeks, + 'total_chargeable_days' => (int) $studentCalculation['total_chargeable_days'], + 'studied_calendar_days' => (int) $studentCalculation['studied_calendar_days'], + 'studied_weeks' => (int) $studentCalculation['studied_weeks'], + 'annual_fee_allocation_cents' => $allocation['annual_allocation_cents'], + 'issued_book_charge_cents' => $bookCharge, + 'annual_instruction_cents' => (int) $studentCalculation['annual_instruction_cents'], + 'earned_tuition_cents' => (int) $studentCalculation['earned_tuition_cents'], + 'other_charge_cents' => $otherCharges, + 'retained_charge_cents' => (int) $studentCalculation['retained_charge_cents'], + 'original_invoice_charge_cents' => $originalInvoiceCharge, + 'invoice_adjustment_cents' => $adjustment, + 'adjusted_invoice_charge_cents' => $adjustedInvoiceCharge, + 'valid_payment_cents' => $validPayments, + 'completed_payout_cents' => $completedPayouts, + 'open_reservation_cents' => $reservations, + 'refundable_credit_cents' => $refundableCredit, + 'new_refund_request_cents' => $newRefund, + 'balance_due_cents' => $balanceDue, + 'book_evidence_json' => json_encode($books, JSON_UNESCAPED_SLASHES), + 'explanation_json' => json_encode($explanation, JSON_UNESCAPED_SLASHES), + 'calculation_hash' => '', + 'active_posted_key' => null, + 'books_discount_eligible' => 0, + 'status' => $blockers === [] ? 'preview' : 'requires_review', + 'override_reason' => $overrideReason !== '' ? $overrideReason : null, + 'overridden_at' => $dateChanged || $legacyConfirmed ? $now : null, + 'overridden_by' => $dateChanged || $legacyConfirmed ? $actorId : null, + 'calculated_by' => $actorId, + 'calculated_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]; + $row['calculation_hash'] = $this->snapshotHash($row, $books, $explanation); + + return ['row' => $row, 'books' => $books, 'blockers' => $blockers, 'explanation' => $explanation]; + } + + /** @return array */ + private function annualAllocationForEnrollment(array $target, ?array $invoice = null): array + { + $rows = $this->db->table('enrollments') + ->select('id, student_id, enrollment_status, admission_status, withdrawal_date') + ->where('parent_id', (int) $target['parent_id']) + ->where('school_year', (string) $target['school_year']) + ->whereIn('enrollment_status', ['enrolled', 'payment pending', 'withdraw under review', 'refund pending', 'withdrawn']) + ->orderBy('id', 'ASC') + ->get() + ->getResultArray(); + $snapshotDetails = $invoice === null ? [] : $this->invoiceTuitionStudentDetails((int) $invoice['id']); + $originalCount = $snapshotDetails !== [] && count($snapshotDetails) === count($rows) + ? count($snapshotDetails) + : count($rows); + $remainingCount = count(array_filter($rows, static fn (array $row): bool => in_array((string) $row['enrollment_status'], ['enrolled', 'payment pending'], true))); + if ($originalCount <= 0) { + throw new RuntimeException('Unable to reconstruct the family tuition stack.'); + } + $config = new ConfigurationModel(); + $first = $this->moneyToCents($config->getConfig('first_student_fee') ?? 380); + $additional = $this->moneyToCents($config->getConfig('second_student_fee') ?? 280); + $withdrawals = array_values(array_filter($rows, static fn (array $row): bool => ! in_array((string) $row['enrollment_status'], ['enrolled', 'payment pending'], true))); + usort($withdrawals, static fn (array $a, array $b): int => [strtotime((string) ($a['withdrawal_date'] ?? '')) ?: PHP_INT_MAX, (int) $a['student_id']] <=> [strtotime((string) ($b['withdrawal_date'] ?? '')) ?: PHP_INT_MAX, (int) $b['student_id']]); + $targetIndex = null; + foreach ($withdrawals as $index => $withdrawal) { + if ((int) $withdrawal['id'] === (int) $target['id']) { + $targetIndex = $index; + break; + } + } + if ($targetIndex === null) { + throw new RuntimeException('The enrollment is not in a withdrawal state.'); + } + $position = $originalCount - $targetIndex; + if ($snapshotDetails !== [] && count($snapshotDetails) === count($rows)) { + $allocation = (int) ($snapshotDetails[$position - 1]['annual_allocation_cents'] ?? 0); + $familyTuition = array_sum(array_map(static fn (array $detail): int => (int) ($detail['annual_allocation_cents'] ?? 0), $snapshotDetails)); + if ($allocation <= 0 || $familyTuition <= 0) { + throw new RuntimeException('The invoice student-level tuition snapshot is malformed.'); + } + } else { + $allocation = $position === 1 ? $first : $additional; + $familyTuition = $originalCount <= 0 ? 0 : $first + max(0, $originalCount - 1) * $additional; + } + + return [ + 'original_student_count' => $originalCount, + 'remaining_student_count' => $remainingCount, + 'withdrawal_stack_index' => $targetIndex, + 'annual_allocation_cents' => $allocation, + 'original_family_tuition_cents' => $familyTuition, + ]; + } + + private function totalInstructionalWeeks(): int + { + $configWeeks = filter_var((new ConfigurationModel())->getConfig('total_instructional_weeks'), FILTER_VALIDATE_INT); + + return $configWeeks !== false && $configWeeks > 0 ? (int) $configWeeks : 0; + } + + /** @return list */ + private function invoiceReconstructionBlockers(int $invoiceId, int $expectedFamilyTuition, int $expectedStudentCount, bool $legacyConfirmed): array + { + $lines = $this->db->table('invoice_lines') + ->select('line_type, source_type, line_amount_cents') + ->where('invoice_id', $invoiceId) + ->where('voided_at IS NULL', null, false) + ->get() + ->getResultArray(); + $legacy = array_filter($lines, static fn (array $line): bool => ($line['source_type'] ?? '') === 'legacy_invoice'); + if ($legacy !== [] && ! $legacyConfirmed) { + return ['This legacy aggregate invoice must be explicitly confirmed with an audit reason before withdrawal posting.']; + } + if ($legacy !== []) { + return []; + } + $details = $this->invoiceTuitionStudentDetails($invoiceId); + if ($details !== []) { + if (count($details) !== $expectedStudentCount) { + return ['The invoice student-level tuition snapshot does not match the family enrollment count.']; + } + $detailTotal = array_sum(array_map(static fn (array $detail): int => (int) ($detail['annual_allocation_cents'] ?? 0), $details)); + if ($detailTotal !== $expectedFamilyTuition) { + return ['The invoice student-level tuition snapshot does not match the reconstructed family allocation.']; + } + } + $baseTuition = 0; + foreach ($lines as $line) { + $type = (string) ($line['line_type'] ?? ''); + if (($line['source_type'] ?? '') === 'withdrawal_calculation' || str_contains($type, 'event') || str_contains($type, 'additional')) { + continue; + } + $baseTuition += (int) ($line['line_amount_cents'] ?? 0); + } + return $baseTuition !== $expectedFamilyTuition + ? ['Frozen tuition lines do not match the reconstructed family tuition stack; reconcile the invoice before posting.'] + : []; + } + + /** @return list> */ + private function invoiceTuitionStudentDetails(int $invoiceId): array + { + $line = $this->db->table('invoice_lines') + ->select('metadata_json') + ->where('invoice_id', $invoiceId) + ->where('line_type', 'tuition') + ->where('voided_at', null) + ->orderBy('id', 'ASC') + ->get(1)->getRowArray(); + $metadata = json_decode((string) ($line['metadata_json'] ?? ''), true); + $details = is_array($metadata) ? ($metadata['tuition_student_details'] ?? []) : []; + if (! is_array($details)) { + return []; + } + return array_values(array_filter($details, static fn ($detail): bool => is_array($detail) + && (int) ($detail['student_id'] ?? 0) > 0 + && (int) ($detail['annual_allocation_cents'] ?? 0) > 0)); + } + + private function appendInvoiceLines(array $invoice, array $calculation, array $books): void + { + $invoiceId = (int) $invoice['id']; + $calculationId = (int) $calculation['id']; + $enrollmentId = (int) $calculation['enrollment_id']; + $timestamp = utc_now(); + $base = [ + 'invoice_id' => $invoiceId, + 'school_year' => (string) $calculation['school_year'], + 'source_type' => 'withdrawal_calculation', + 'source_id' => $calculationId, + 'quantity' => '1.00', + 'calculation_version' => (string) $calculation['policy_version'] . ':v' . (int) $calculation['version'], + 'created_at' => $timestamp, + 'updated_at' => $timestamp, + 'voided_at' => null, + ]; + $lines = [ + $base + [ + 'line_type' => 'withdrawal_tuition_reversal', + 'active_source_key' => 'withdrawal:' . $enrollmentId . ':tuition-reversal', + 'description' => 'Withdrawal annual tuition allocation reversal', + 'unit_amount_cents' => -1 * (int) $calculation['annual_fee_allocation_cents'], + 'line_amount_cents' => -1 * (int) $calculation['annual_fee_allocation_cents'], + 'discount_eligible' => 1, + 'metadata_json' => json_encode(['calculation_id' => $calculationId, 'enrollment_id' => $enrollmentId], JSON_UNESCAPED_SLASHES), + ], + $base + [ + 'line_type' => 'withdrawal_earned_tuition', + 'active_source_key' => 'withdrawal:' . $enrollmentId . ':earned-tuition', + 'description' => 'Withdrawal earned tuition for ' . (int) $calculation['studied_weeks'] . ' studied week(s)', + 'unit_amount_cents' => (int) $calculation['earned_tuition_cents'], + 'line_amount_cents' => (int) $calculation['earned_tuition_cents'], + 'discount_eligible' => 1, + 'metadata_json' => json_encode(['calculation_id' => $calculationId, 'enrollment_id' => $enrollmentId, 'studied_weeks' => (int) $calculation['studied_weeks']], JSON_UNESCAPED_SLASHES), + ], + ]; + foreach ($books as $book) { + if (($book['status'] ?? '') !== 'issued') { + continue; + } + $issueId = (int) $book['id']; + $amount = (int) $book['total_charge_cents']; + $lines[] = $base + [ + 'line_type' => 'withdrawal_retained_book', + 'active_source_key' => 'withdrawal:' . $enrollmentId . ':book-issue:' . $issueId, + 'description' => 'Retained issued book - ' . (string) ($book['book_name'] ?? ('Issue #' . $issueId)), + 'quantity' => number_format((int) ($book['quantity'] ?? 1), 2, '.', ''), + 'unit_amount_cents' => (int) $book['unit_charge_price_cents'], + 'line_amount_cents' => $amount, + 'discount_eligible' => 0, + 'metadata_json' => json_encode(['calculation_id' => $calculationId, 'enrollment_id' => $enrollmentId, 'book_issue_id' => $issueId, 'issued_at' => $book['issued_at'] ?? null], JSON_UNESCAPED_SLASHES), + ]; + } + foreach ($lines as $line) { + $existing = $this->db->table('invoice_lines') + ->select('id') + ->where('active_source_key', (string) $line['active_source_key']) + ->where('voided_at', null) + ->get(1) + ->getRowArray(); + if ($existing !== null) { + continue; + } + if (! $this->db->table('invoice_lines')->insert($line)) { + throw new RuntimeException('Unable to append a withdrawal invoice line.'); + } + } + } + + /** @return array */ + private function syncRefundRequest(array $calculation, array $ledger, ?int $actorId): array + { + $invoiceId = (int) $calculation['invoice_id']; + $existing = $this->db->table('refunds') + ->where('invoice_id', $invoiceId) + ->where('source_type', 'tuition_withdrawal') + ->whereIn('status', ['Pending', 'pending', 'requested', 'Approved', 'approved', 'Partial', 'partial', 'partially_paid', 'Paid', 'paid']) + ->orderBy('id', 'DESC') + ->get(1) + ->getRowArray(); + $existingId = (int) ($existing['id'] ?? 0); + $reserved = $this->openInvoiceReservations($invoiceId, $existingId > 0 ? $existingId : null); + $credit = max(0, (int) ($ledger['customerCreditCents'] ?? 0) - $reserved); + $paid = $existingId > 0 ? $this->refundEligibility->getCompletedPayoutTotalCentsForRefund($existingId) : 0; + $target = max($credit, $paid); + if ($target <= 0 && $existingId <= 0) { + return ['requested_amount_cents' => 0]; + } + $payload = [ + 'parent_id' => (int) $calculation['parent_id'], + 'school_year' => (string) $calculation['school_year'], + 'invoice_id' => $invoiceId, + 'withdrawal_calculation_id' => (int) $calculation['id'], + 'refund_amount' => $target / 100, + 'requested_amount_cents' => $target, + 'currency' => 'USD', + 'refund_paid_amount' => $paid / 100, + 'request' => 'tuition', + 'source_type' => 'tuition_withdrawal', + 'source_id' => $invoiceId, + 'reason' => 'Posted withdrawal calculation #' . (int) $calculation['id'], + 'reconciliation_status' => null, + 'reconciliation_reason' => null, + 'reconciliation_required_at' => null, + 'updated_by' => $actorId, + 'updated_at' => utc_now(), + ]; + if ($existingId > 0) { + $status = FinancialStatus::normalizeRefundStatus($existing['status'] ?? null); + if (in_array($status, [FinancialStatus::REFUND_APPROVED, FinancialStatus::REFUND_PARTIALLY_PAID, FinancialStatus::REFUND_PAID], true)) { + $payload['approved_amount_cents'] = $target; + if ($paid > $credit) { + $payload['reconciliation_status'] = 'requires_review'; + $payload['reconciliation_reason'] = 'Completed payouts exceed the current adjusted invoice credit.'; + $payload['reconciliation_required_at'] = utc_now(); + } + } else { + $payload['status'] = 'Approved'; + $payload['approved_amount_cents'] = $target; + $payload['approved_at'] = utc_now(); + $payload['approved_by'] = $actorId; + } + $this->db->table('refunds')->where('id', $existingId)->update($payload); + return $payload + ['id' => $existingId]; + } + $payload['status'] = 'Approved'; + $payload['requested_at'] = utc_now(); + $payload['approved_amount_cents'] = $target; + $payload['approved_at'] = utc_now(); + $payload['approved_by'] = $actorId; + $this->db->table('refunds')->insert($payload); + return $payload + ['id' => (int) $this->db->insertID()]; + } + + private function supersedePostedCalculation(int $oldId, int $newId): void + { + $now = utc_now(); + $this->db->table('invoice_lines')->where('source_type', 'withdrawal_calculation')->where('source_id', $oldId)->where('voided_at', null)->update([ + 'active_source_key' => null, + 'voided_at' => $now, + 'updated_at' => $now, + ]); + $this->db->table('withdrawal_financial_calculations')->where('id', $oldId)->update([ + 'status' => 'superseded', + 'active_posted_key' => null, + 'superseded_by_id' => $newId, + 'updated_at' => $now, + ]); + } + + /** @return array{invoice:?array,blockers:list} */ + private function resolveInvoice(int $parentId, string $schoolYear): array + { + $rows = $this->db->table('invoices i') + ->select('i.*') + ->where('i.parent_id', $parentId) + ->where('i.school_year', $schoolYear) + ->where("LOWER(COALESCE(i.status,'')) NOT IN ('void','voided','cancelled','canceled')", null, false) + ->orderBy('i.id', 'ASC') + ->get() + ->getResultArray(); + if (count($rows) === 1) { + return ['invoice' => $rows[0], 'blockers' => []]; + } + if ($rows === []) { + return ['invoice' => null, 'blockers' => ['No active invoice exists for this parent and school year.']]; + } + return ['invoice' => null, 'blockers' => ['Multiple active invoices exist. Reconcile duplicates before calculating a withdrawal; the system will never guess which invoice to use.']]; + } + + private function openInvoiceReservations(int $invoiceId, ?int $excludeRefundId): int + { + $builder = $this->db->table('refunds') + ->select('id, refund_amount, requested_amount_cents, approved_amount_cents, refund_paid_amount') + ->where('invoice_id', $invoiceId) + ->whereIn('status', ['Pending', 'pending', 'requested', 'Approved', 'approved', 'Partial', 'partial', 'partially_paid']); + if ($excludeRefundId !== null) { + $builder->where('id !=', $excludeRefundId); + } + $reserved = 0; + foreach ($builder->get()->getResultArray() as $refund) { + $amount = $refund['approved_amount_cents'] !== null + ? (int) $refund['approved_amount_cents'] + : ((int) ($refund['requested_amount_cents'] ?? 0) ?: $this->moneyToCents($refund['refund_amount'] ?? 0)); + $paid = $this->refundEligibility->getCompletedPayoutTotalCentsForRefund((int) $refund['id']); + $reserved += max(0, $amount - $paid); + } + return $reserved; + } + + private function existingWithdrawalRefundId(int $invoiceId): ?int + { + $row = $this->db->table('refunds r') + ->select('r.id') + ->where('r.invoice_id', $invoiceId) + ->where('r.source_type', 'tuition_withdrawal') + ->orderBy('r.id', 'DESC') + ->get(1)->getRowArray(); + return $row === null ? null : (int) $row['id']; + } + + private function snapshotHash(array $row, array $books, array $explanation): string + { + foreach (['version', 'status', 'calculation_hash', 'active_posted_key', 'calculated_by', 'calculated_at', 'created_at', 'updated_at', 'posted_by', 'posted_at', 'overridden_at', 'overridden_by'] as $key) { + unset($row[$key]); + } + return hash('sha256', json_encode([$row, $books, $explanation], JSON_UNESCAPED_SLASHES)); + } + + /** @return array */ + private function decodeCalculation(array $row): array + { + $row['books'] = json_decode((string) ($row['book_evidence_json'] ?? '[]'), true) ?: []; + $row['explanation'] = json_decode((string) ($row['explanation_json'] ?? '{}'), true) ?: []; + $row['blockers'] = $row['explanation']['blockers'] ?? []; + return $row; + } + + private function lockEnrollment(int $id): array + { + $row = $this->db->query('SELECT * FROM enrollments WHERE id = ? FOR UPDATE', [$id])->getRowArray(); + if ($row === null) { + throw new InvalidArgumentException('Enrollment not found.'); + } + return $row; + } + + private function lockInvoice(int $id): array + { + $row = $this->db->query('SELECT * FROM invoices WHERE id = ? FOR UPDATE', [$id])->getRowArray(); + if ($row === null) { + throw new InvalidArgumentException('Invoice not found.'); + } + return $row; + } + + private function lockSchoolYear(string $name): array + { + $row = $this->db->query('SELECT * FROM school_years WHERE name = ? FOR UPDATE', [$name])->getRowArray(); + if ($row === null) { + throw new RuntimeException('School-year policy record not found.'); + } + return $row; + } + + private function lockRefundRows(int $invoiceId): void + { + $this->db->query('SELECT id FROM refunds WHERE invoice_id = ? FOR UPDATE', [$invoiceId]); + } + + private function validDate(string $value, string $label): string + { + $date = \DateTimeImmutable::createFromFormat('!Y-m-d', trim($value)); + $errors = \DateTimeImmutable::getLastErrors(); + if ($date === false || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) { + throw new InvalidArgumentException($label . ' must be a valid Y-m-d date.'); + } + return $date->format('Y-m-d'); + } + + private function moneyToCents(mixed $value): int + { + return (int) round(((float) $value) * 100); + } + + private function commitOrFail(string $message): void + { + if (! $this->db->transCommit()) { + throw new RuntimeException($message); + } + } + + private function assertTables(): void + { + foreach (['withdrawal_financial_calculations', 'student_book_issues', 'invoice_lines', 'refunds', 'school_years'] as $table) { + if (! $this->db->tableExists($table)) { + throw new RuntimeException('Required table is missing: ' . $table . '. Run migrations first.'); + } + } + } +} diff --git a/app/Services/WithdrawalRefundCalculator.php b/app/Services/WithdrawalRefundCalculator.php new file mode 100644 index 0000000..a274285 --- /dev/null +++ b/app/Services/WithdrawalRefundCalculator.php @@ -0,0 +1,149 @@ + + */ + public function calculate(array $input): array + { + $annualFee = $this->nonNegativeInt($input, 'annual_fee_allocation_cents'); + $bookCharge = $this->nonNegativeInt($input, 'issued_book_charge_cents'); + $totalWeeks = $this->positiveInt($input, 'total_instructional_weeks'); + $validPayments = $this->optionalNonNegativeInt($input, 'valid_payment_cents'); + $completedPayouts = $this->optionalNonNegativeInt($input, 'completed_payout_cents'); + $openReservations = $this->optionalNonNegativeInt($input, 'open_reservation_cents'); + $otherCharges = $this->optionalNonNegativeInt($input, 'other_charge_cents'); + $includesBooks = (bool) ($input['annual_fee_includes_books'] ?? true); + + if (! $includesBooks) { + throw new InvalidArgumentException('The active withdrawal policy requires annual tuition to include books.'); + } + if ($bookCharge > $annualFee) { + throw new InvalidArgumentException('Issued-book charges exceed the student annual tuition allocation.'); + } + + $schoolStart = $this->date($input, 'school_year_start_date'); + $enrollmentDate = $this->date($input, 'enrollment_date'); + $withdrawalDate = $this->date($input, 'withdrawal_request_date'); + $chargeStart = $enrollmentDate > $schoolStart ? $enrollmentDate : $schoolStart; + + $totalChargeableDays = $totalWeeks * 7; + $studiedDays = 0; + if ($withdrawalDate >= $chargeStart) { + $studiedDays = ((int) $chargeStart->diff($withdrawalDate)->format('%a')) + 1; + } + $studiedDays = min($totalChargeableDays, max(0, $studiedDays)); + $studiedWeeks = $studiedDays === 0 + ? 0 + : min($totalWeeks, intdiv($studiedDays + 6, 7)); + + $annualInstruction = $annualFee - $bookCharge; + $earnedTuition = $this->roundRatio($annualInstruction * $studiedWeeks, $totalWeeks); + $retainedCharge = $bookCharge + $earnedTuition + $otherCharges; + + $netPayments = max(0, $validPayments - $completedPayouts); + $refundableCredit = max(0, $netPayments - $retainedCharge); + $balanceDue = max(0, $retainedCharge - $netPayments); + $newRefundRequest = max(0, $refundableCredit - $openReservations); + + return [ + 'annual_fee_includes_books' => true, + 'school_year_start_date' => $schoolStart->format('Y-m-d'), + 'enrollment_date' => $enrollmentDate->format('Y-m-d'), + 'withdrawal_request_date' => $withdrawalDate->format('Y-m-d'), + 'charge_start_date' => $chargeStart->format('Y-m-d'), + 'total_instructional_weeks' => $totalWeeks, + 'total_chargeable_days' => $totalChargeableDays, + 'studied_calendar_days' => $studiedDays, + 'studied_weeks' => $studiedWeeks, + 'annual_fee_allocation_cents' => $annualFee, + 'issued_book_charge_cents' => $bookCharge, + 'annual_instruction_cents' => $annualInstruction, + 'earned_tuition_cents' => $earnedTuition, + 'other_charge_cents' => $otherCharges, + 'retained_charge_cents' => $retainedCharge, + 'valid_payment_cents' => $validPayments, + 'completed_payout_cents' => $completedPayouts, + 'net_payment_cents' => $netPayments, + 'open_reservation_cents' => $openReservations, + 'refundable_credit_cents' => $refundableCredit, + 'new_refund_request_cents' => $newRefundRequest, + 'balance_due_cents' => $balanceDue, + ]; + } + + private function date(array $input, string $key): DateTimeImmutable + { + $raw = trim((string) ($input[$key] ?? '')); + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $raw); + $errors = DateTimeImmutable::getLastErrors(); + if ($date === false || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) { + throw new InvalidArgumentException($key . ' must be a valid Y-m-d date.'); + } + + return $date; + } + + private function positiveInt(array $input, string $key): int + { + $value = filter_var($input[$key] ?? null, FILTER_VALIDATE_INT); + if ($value === false || $value <= 0) { + throw new InvalidArgumentException($key . ' must be a positive integer.'); + } + + return $value; + } + + private function nonNegativeInt(array $input, string $key): int + { + $value = filter_var($input[$key] ?? null, FILTER_VALIDATE_INT); + if ($value === false || $value < 0) { + throw new InvalidArgumentException($key . ' must be a non-negative integer.'); + } + + return $value; + } + + private function optionalNonNegativeInt(array $input, string $key): int + { + if (! array_key_exists($key, $input) || $input[$key] === null || $input[$key] === '') { + return 0; + } + + return $this->nonNegativeInt($input, $key); + } + + private function roundRatio(int $numerator, int $denominator): int + { + if ($denominator <= 0) { + throw new InvalidArgumentException('The calculation denominator must be positive.'); + } + + return intdiv($numerator + intdiv($denominator, 2), $denominator); + } +} diff --git a/app/Views/index.php b/app/Views/index.php index dda62ae..e5afd35 100644 --- a/app/Views/index.php +++ b/app/Views/index.php @@ -474,7 +474,7 @@ Faith and Knowledge Hand in Hand @@ -662,8 +662,8 @@

Our Curriculum and Grade Structure

Our program spans nine structured grade levels, each building upon the previous year's knowledge to ensure a solid foundation in faith, character, and Islamic learning.

-

Upon completing the 9th grade, students transition into our three-year Youth Program, which emphasizes deeper community engagement, personal development and practical application of Islamic principles. Participation in the Youth Program requires students to be at least 15 years old, ensuring they are mature enough to benefit from its advanced content.

-

Starting this academic year, children must be at least 6 years old by 09-01-2025 to enroll in Grade 1. For younger learners, we are pleased to offer our newly established Kindergarten class, welcoming children who are at least 5 years old by 12-31-2025. This early education program is designed to introduce young minds to the basics of Islamic teachings in a warm, age-appropriate environment.

+

Upon completing the 10th grade, students transition into our two-year Youth Program, which emphasizes deeper community engagement, personal development and practical application of Islamic principles. Participation in the Youth Program requires students to be at least 16 years old, ensuring they are mature enough to benefit from its advanced content.

+

Starting this academic year, children must be at least 6 years old by 09-01-2026 to enroll in Grade 1. For younger learners, we are pleased to offer our newly established Kindergarten class, welcoming children who are at least 5 years old by 12-31-2026. This early education program is designed to introduce young minds to the basics of Islamic teachings in a warm, age-appropriate environment.

Get Started Now
diff --git a/app/Views/inventory/book/form.php b/app/Views/inventory/book/form.php index 268de4f..80fcaea 100644 --- a/app/Views/inventory/book/form.php +++ b/app/Views/inventory/book/form.php @@ -121,7 +121,6 @@ $selectedClasses = array_values(array_intersect(range(1,13), $selectedClasses)); -
diff --git a/app/Views/inventory/book/index.php b/app/Views/inventory/book/index.php index 914cbb0..db3a931 100644 --- a/app/Views/inventory/book/index.php +++ b/app/Views/inventory/book/index.php @@ -5,13 +5,13 @@

Books

+ Book Prices Add Book
-
include('partials/academic_filter') ?>
Grade Range QtyUnit Updated ByUpdated Date - SKUActions + SKUActions @@ -72,7 +72,7 @@ - Edit + Edit Book
diff --git a/app/Views/inventory/book/prices.php b/app/Views/inventory/book/prices.php new file mode 100644 index 0000000..0aa8209 --- /dev/null +++ b/app/Views/inventory/book/prices.php @@ -0,0 +1,151 @@ +extend('layout/management_layout') ?> +section('content') ?> + + + +
+
+
+

Book Prices

+
School year:
+
+ Back to Books +
+ + + + + + +
+
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + 0 && (int) ($book['price_confirmed'] ?? 0) === 1; + $search = strtolower(trim(implode(' ', [ + $book['name'] ?? '', + $book['isbn'] ?? '', + $book['edition'] ?? '', + $book['sku'] ?? '', + $category['name'] ?? '', + ]))); + ?> + + + + + + + + + + + + +
BookISBN / Edition / SKUCategoryGrade RangeQtyPriceConfirmedStatus
+
+
ID #
+
+
+
+ / +
+
+
+ $ + +
+
+
+ > + +
+
+ + Ready + + Needs price + +
+
+ +
+ +
+ + + +endSection() ?> diff --git a/app/Views/inventory/student_book_history.php b/app/Views/inventory/student_book_history.php new file mode 100644 index 0000000..0e708cb --- /dev/null +++ b/app/Views/inventory/student_book_history.php @@ -0,0 +1,11 @@ +extend('layout/management_layout') ?> +section('content') ?> +
+

Book Issue History

·
Back
+
Issued books are not returnable. A reversed row means an administrator corrected an issue that was entered in error; it is not a parent return.
+
+ + +
YearBookIssuedQtyStatusIssued byCorrection audit
by
No book issues found.
+
+endSection() ?> diff --git a/app/Views/inventory/teacher_distribute.php b/app/Views/inventory/teacher_distribute.php index 8929142..ca7be9b 100644 --- a/app/Views/inventory/teacher_distribute.php +++ b/app/Views/inventory/teacher_distribute.php @@ -47,8 +47,13 @@ School Year: , Semester:
-
On Hand:
+
+ On Hand: +
+ +
This book cannot be distributed until its active-year charge price is confirmed.
+ @@ -57,6 +62,7 @@ +
@@ -96,7 +102,7 @@ class="form-check-input student-box" name="student_ids[]" value="" - > + > @@ -123,7 +129,7 @@
- +
@@ -148,34 +154,18 @@ document.getElementById('checkAllBtn')?.addEventListener('click', () => { if (distributeReadonly) return; - document.querySelectorAll('.student-box').forEach(cb => cb.checked = true); - }); - document.getElementById('uncheckAllBtn')?.addEventListener('click', () => { - if (distributeReadonly) return; - document.querySelectorAll('.student-box').forEach(cb => cb.checked = false); - }); - - - diff --git a/app/Views/refunds/list.php b/app/Views/refunds/list.php index 1a64441..4b8fd1d 100644 --- a/app/Views/refunds/list.php +++ b/app/Views/refunds/list.php @@ -3,6 +3,41 @@

Refunds

+ '$' . number_format(((int) $cents) / 100, 2); + $dateOnly = static function($dt) { + if (empty($dt) || $dt === '0000-00-00 00:00:00') return '-'; + return local_date($dt, 'm-d-Y'); + }; + $normalizeRefundStatus = static function($status): string { + $key = strtolower(str_replace(' ', '_', trim((string) $status))); + if ($key === 'requested') return 'pending'; + if ($key === 'partially_paid') return 'partial'; + return $key !== '' ? $key : 'pending'; + }; + $summary = [ + 'reviews' => count($withdrawalReviews), + 'approval' => 0, + 'payment' => 0, + 'complete' => 0, + ]; + foreach ($refunds as $refund) { + $key = $normalizeRefundStatus($refund['status'] ?? ''); + $isWithdrawalRefund = (string)($refund['source_type'] ?? '') === 'tuition_withdrawal'; + $amount = (float) ($refund['refund_amount'] ?? 0); + $paid = (float) ($refund['refund_paid_amount'] ?? 0); + $remaining = max(0, $amount - $paid); + if ($key === 'pending' && !$isWithdrawalRefund) { + $summary['approval']++; + } elseif (in_array($key, ['approved', 'partial'], true) && $remaining > 0) { + $summary['payment']++; + } elseif (in_array($key, ['paid', 'rejected'], true)) { + $summary['complete']++; + } + } + ?> getFlashdata('success')): ?> @@ -15,34 +50,78 @@
getFlashdata('info') ?>
- -
- - - - - -
No eligible parents for refund calculation.
- -
+
+
Withdrawal reviews
+
Need approval
+
Ready to pay
+
Closed
+
+ + +
+
+ Withdrawal Review Queue + pending +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParentStudentInvoice #RequestedExpected RefundBalance DueStatusActions
+
+
Calculated
+
+ +
+
+
+
+ + +
+

Refund Queue

+
- +
- + - + @@ -56,12 +135,7 @@ return local_date($dt, 'm-d-Y'); }; $statusRaw = (string)($r['status'] ?? ''); - $statusKey = strtolower(str_replace(' ', '_', trim($statusRaw))); - if ($statusKey === 'requested') { - $statusKey = 'pending'; - } elseif ($statusKey === 'partially_paid') { - $statusKey = 'partial'; - } + $statusKey = $normalizeRefundStatus($statusRaw); $statusLabel = [ 'pending' => 'Pending', 'approved' => 'Approved', @@ -80,9 +154,20 @@ $paidAmount = (float)($r['refund_paid_amount'] ?? 0); $remainingAmount = max(0, $refundAmount - $paidAmount); $hasSource = !empty($r['source_type']) && !empty($r['source_id']); - $canApprove = $statusKey === 'pending' && $refundAmount > 0 && $hasSource; - $canReject = $statusKey === 'pending'; + $isWithdrawalRefund = (string)($r['source_type'] ?? '') === 'tuition_withdrawal'; + $canApprove = !$isWithdrawalRefund && $statusKey === 'pending' && $refundAmount > 0 && $hasSource; + $canReject = !$isWithdrawalRefund && $statusKey === 'pending'; $canRecord = in_array($statusKey, ['approved', 'partial'], true) && $remainingAmount > 0; + $nextStep = 'Closed'; + if ($statusKey === 'pending') { + $nextStep = $isWithdrawalRefund ? 'Approved by withdrawal review' : ($canApprove ? 'Needs approval' : 'Needs review'); + } elseif ($canRecord) { + $nextStep = 'Ready to pay'; + } elseif ($statusKey === 'paid') { + $nextStep = 'Paid'; + } elseif ($statusKey === 'rejected') { + $nextStep = 'Rejected'; + } ?> @@ -93,6 +178,7 @@ @@ -226,6 +310,7 @@ $(function () { order: [[2, 'desc']], // order by Requested desc scrollX: true, autoWidth: false, + fixedHeader: false, }); // Status form (approve/reject) diff --git a/app/Views/school_years/closing_preview.php b/app/Views/school_years/closing_preview.php index f59ddc8..b315594 100644 --- a/app/Views/school_years/closing_preview.php +++ b/app/Views/school_years/closing_preview.php @@ -13,6 +13,8 @@ $blockers = $preview['blockers'] ?? []; $warnings = $preview['warnings'] ?? []; $carryForward = $preview['carry_forward'] ?? []; + $inventory = $preview['inventory'] ?? ['rows' => [], 'summary' => []]; + $inventoryRows = $inventory['rows'] ?? []; $carryForwardTotal = array_reduce( $carryForward, static fn (float $total, array $row): float => $total + (float) ($row['carry_forward_amount'] ?? 0), @@ -344,6 +346,44 @@
+
Book Inventory Reconciliation
+
+
Parent Invoice #RequestedAmount StatusRefund DetailsPayment Actions
+
@@ -103,6 +189,8 @@
+
Paid: $
+
Remaining: $
Check #:
Check File: @@ -112,25 +200,21 @@ -
-
Paid: $
- - - + + + + + + + + + + + No action +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
BookOpeningNet MovementsSystem ClosingPhysical CountVarianceCharge PriceTarget Opening
+
+
+
$
No book inventory item-year rows found for this source year.
+
+
Carry-Forward Families
diff --git a/app/Views/school_years/index.php b/app/Views/school_years/index.php index b56a4b0..d114558 100644 --- a/app/Views/school_years/index.php +++ b/app/Views/school_years/index.php @@ -93,6 +93,12 @@ required > +
+ + + + +
@@ -320,6 +326,12 @@
+
+ + + + +
diff --git a/app/Views/withdrawals/calculation.php b/app/Views/withdrawals/calculation.php new file mode 100644 index 0000000..c98ba80 --- /dev/null +++ b/app/Views/withdrawals/calculation.php @@ -0,0 +1,16 @@ +extend('layout/management_layout') ?> +section('content') ?> + '$' . number_format(((int) $v) / 100, 2); ?> +
+

Withdrawal Calculation #

Version · ·
Review
+ getFlashdata('success')): ?>
getFlashdata('success')) ?>
+
+
+ 'annual_fee_allocation_cents','Issued books'=>'issued_book_charge_cents','Instruction component'=>'annual_instruction_cents','Earned tuition'=>'earned_tuition_cents','Retained charge'=>'retained_charge_cents','Original invoice charge'=>'original_invoice_charge_cents','Invoice adjustment'=>'invoice_adjustment_cents','Adjusted invoice charge'=>'adjusted_invoice_charge_cents','Valid payments'=>'valid_payment_cents','Completed payouts'=>'completed_payout_cents','Open reservations'=>'open_reservation_cents','Refundable credit'=>'refundable_credit_cents','New refund request'=>'new_refund_request_cents','Balance due'=>'balance_due_cents' + ] as $label=>$field): ?>
+
+
Policy snapshot
Dates
/ /
Studied
calendar day(s), rounded to week(s), of
Policy
; annual tuition includes books; no no-school subtraction; no book returns
Override reason
+
Book price snapshots
IssueBookDateStatusPriceTotal
#
+ +endSection() ?> diff --git a/app/Views/withdrawals/invoice_summary.php b/app/Views/withdrawals/invoice_summary.php new file mode 100644 index 0000000..54afee0 --- /dev/null +++ b/app/Views/withdrawals/invoice_summary.php @@ -0,0 +1,11 @@ +extend('layout/management_layout') ?> +section('content') ?> + '$' . number_format(((int) $v) / 100, 2); ?> +
+

Invoice # Withdrawal Breakdown

Back to refunds
+ +
Studied: week(s)
Books:
Earned tuition:
Retained:
+ +
No posted withdrawal calculations were found for this invoice.
+
+endSection() ?> diff --git a/app/Views/withdrawals/review.php b/app/Views/withdrawals/review.php new file mode 100644 index 0000000..d37fcd9 --- /dev/null +++ b/app/Views/withdrawals/review.php @@ -0,0 +1,82 @@ +extend('layout/management_layout') ?> +section('content') ?> + '$' . number_format(((int) $cents) / 100, 2); +$blockers = (array) ($c['blockers'] ?? []); +$discountProjection = (array) (($c['explanation']['discount_projection'] ?? [])); +?> +
+
+
+

Withdrawal Refund Review

+
+ + · · calculation v +
+
+ Back +
+ + getFlashdata('success')): ?>
getFlashdata('success')) ?>
+ getFlashdata('error')): ?>
getFlashdata('error')) ?>
+ +
+ Cannot post this refund yet. +
+
+ + +
+
Studied period
day(s) / week(s)
of instructional weeks
+
Annual allocation
Includes books
+
Retained charge
Books + earned tuition
+
0 ? 'Expected refund' : 'Expected balance' ?>
0 ? ($c['new_refund_request_cents'] ?? 0) : ($c['balance_due_cents'] ?? 0)) ?>
Payments:
+
+ +
+
Books Kept by Student
+
+ + + + +
BookIssuedStatusQtySnapshot priceTotalCorrection
No book issues were recorded by the withdrawal date.
+
+ +
+
Refund Calculation
+
+
Issued books:
+
Instruction component:
+
Earned tuition:
+
Invoice adjustment:
+
Adjusted invoice charge:
+
Open reservations:
+
Completed prior payouts:
+
Projected applied discount:
+
Refundable invoice credit:
+
+
+ +
+ +
Review Inputs
+
+
+
+
+
+
+ +
+ +
+ Detailed Breakdown +
+ + +
+
+
+endSection() ?> diff --git a/docs/withdrawal_refund_calculation_plan.md b/docs/withdrawal_refund_calculation_plan.md new file mode 100644 index 0000000..ee5a2cb --- /dev/null +++ b/docs/withdrawal_refund_calculation_plan.md @@ -0,0 +1,578 @@ +# Withdrawal Refund and Book Inventory Implementation Plan + +## 1. Objective + +Replace the current “unused weeks remaining” refund calculation with an earned-charge calculation that: + +1. Retains the snapshotted price of the actual books issued to the student and the tuition earned for the number of chargeable weeks elapsed before withdrawal. +2. Refunds only money actually paid beyond the parent’s recalculated obligations. +3. Leaves an outstanding balance when the amount paid is less than the earned charge. +4. Works correctly for siblings, discounts, other invoice charges, partial refunds, and prior payouts. +5. Preserves an auditable snapshot so a later configuration, date, inventory, or price change cannot silently alter an approved refund. +6. Carries verified leftover book stock into the next school year without duplicating physical quantity. + +This plan is adapted to the current CodeIgniter project, especially: + +- `app/Services/FeeCalculationService.php` +- `app/Services/EnrollmentWithdrawalService.php` +- `app/Controllers/View/ParentController.php` +- `app/Controllers/View/RefundController.php` +- `app/Controllers/View/InventoryController.php` +- `app/Libraries/InvoiceLedgerService.php` +- `app/Libraries/RefundEligibilityService.php` +- `app/Libraries/Tuition/OldTuitionCalculatorService.php` +- `app/Libraries/Tuition/NewTuitionCalculatorService.php` +- `app/Models/EnrollmentModel.php` +- `app/Models/RefundModel.php` +- `app/Models/InventoryItemModel.php` +- `app/Models/InventoryMovementModel.php` +- `app/Services/SchoolYearClosingService.php` +- `app/Views/refunds/list.php` +- `app/Views/enroll_withdraw/enrollment_withdrawal.php` +- `app/Views/inventory/book/form.php` +- `app/Views/inventory/book/index.php` +- `app/Views/inventory/teacher_distribute.php` + +## 2. Locked annual-fee policy + +Annual tuition includes books. The refund must use the prices snapshotted when books were actually issued to the student, not a flat fee and not the book's current editable price. Dividing the full annual fee by the number of weeks and then adding issued-book prices would count books twice. + +Locked rule: + +- Require a school-year-specific student charge price for every distributable book. +- Snapshot that price on every student book issue. +- Sum the active issue snapshots for the withdrawing student. +- Treat the existing annual student fee as inclusive of books. +- Subtract issued-book charges from the annual allocation before prorating the instruction component: + +```text +issued_book_charge_cents = sum(active student book issue price snapshots) +annual_instruction_cents = annual_student_fee_cents - issued_book_charge_cents +earned_tuition_cents = round(annual_instruction_cents * studied_weeks / total_instructional_weeks) +retained_charge_cents = issued_book_charge_cents + earned_tuition_cents +``` + +Do not implement `full annual fee / total weeks + issued book prices`; it would make the full-year retained charge greater than the annual fee. If issued-book prices exceed the student’s annual allocation, block automatic calculation and require financial review rather than producing a negative instruction component. + +## 3. Locked calculation rules + +### 3.1 Money representation + +Perform all calculations in integer cents. Do not calculate refunds with floats. Round once when calculating the prorated earned tuition: + +```text +earned_tuition_cents = round(annual_instruction_cents * studied_weeks / total_instructional_weeks) +``` + +Do not round a displayed one-week fee and multiply it, because that can make all weeks add up to more or less than the annual fee. + +### 3.2 Studied weeks and days + +Treat the school-year value identified by the key `total_instructional_weeks` as authoritative. It already reflects the school’s instructional-year calculation, including its treatment of `no_school` dates. The refund calculator must not query calendar events or subtract `no_school` dates again. + +Derive the chargeable duration and studied weeks as follows: + +```text +total_chargeable_days = total_instructional_weeks * 7 +charge_start_date = max(school_year_start_date, student_enrollment_date) +studied_calendar_days = inclusive_days(charge_start_date, withdrawal_request_date) +studied_calendar_days = clamp(studied_calendar_days, 0, total_chargeable_days) +studied_weeks = min(total_instructional_weeks, ceil(studied_calendar_days / 7)) +``` + +- Use the immutable withdrawal request date, not the later admin approval date. +- Count elapsed calendar days; do not inspect attendance or `calendar_events.no_school`. +- Any started seven-day period is charged as one studied week. This keeps a student who starts and withdraws during the first week at a one-week charge. +- Cap the result at `total_instructional_weeks`, even if the withdrawal date is later than the derived chargeable duration. +- Reject a missing, non-integer, or non-positive `total_instructional_weeks`; do not fall back to `weeks_study`. +- Preserve an admin override only for incorrect enrollment or withdrawal dates, with a required reason and audit user. The override must not change `total_instructional_weeks` for an already posted calculation. + +The current `weeks_study` value, which defaults to `8`, must not be used by the refund calculator. Add or confirm the clearly named, school-year-scoped `total_instructional_weeks` value and snapshot both it and the derived `total_chargeable_days` in every calculation. + +### 3.3 Book charge + +Use actual student book issues rather than a configured flat fee: + +```text +issued_book_charge_cents = + sum(non-reversed issue quantity * issue unit charge price snapshot) +``` + +- Include only issues for the same student and school year that occurred on or before the effective withdrawal date. +- An inventory movement alone is insufficient financial evidence because it has no price snapshot. Every distribution must create an immutable `student_book_issue` record linked to its stock movement. +- Price changes affect future issues only. They must never alter a prior issue or refund calculation. +- Books are not returnable. Once a book is issued, its full snapshotted price remains chargeable and the copy never re-enters available stock. +- Allow only a controlled admin correction when an issue record was created in error. Record a linked reversal with the reason and audit user; never delete the original issue. This correction restores stock and removes the mistaken issue from the charge, but it is not a book return or parent refund credit. +- If an issue correction occurs after a withdrawal calculation is posted, mark the calculation `requires_review`; never silently change an approved or paid refund. +- Prevent the same issue from being included twice after refund recalculation or repeated enrollment status changes. + +### 3.4 Family tuition tier + +The project charges the first student one amount and additional students another amount. A withdrawal cannot be calculated safely from the identity of the student alone. + +Continue the existing reverse-stack principle: + +```text +withdrawn annual allocation = original family tuition - full-year tuition for remaining active siblings +``` + +Examples with first/additional fees of `$380/$280`: + +- Two students, one withdraws: the withdrawal allocation is `$280`; the remaining student still carries the `$380` tier. +- Three students, one withdraws: the first withdrawal allocation is `$280`. +- When the final remaining student withdraws: that allocation is `$380`. + +For multiple withdrawals submitted together, order by withdrawal request date and then student ID, and save the resulting annual allocation. Never recalculate a posted student allocation merely because another sibling withdraws later. + +### 3.5 Parent-level refund and balance + +Payments are invoice-level, not student-level. Do not invent a student payment allocation. First adjust the invoice to the correct charge, then let the invoice ledger determine whether the parent has a credit or a balance. + +```text +net_payment_cents = valid_payment_cents - completed_refund_payout_cents +refundable_credit_cents = max(0, net_payment_cents - adjusted_net_charge_cents) +balance_due_cents = max(0, adjusted_net_charge_cents - net_payment_cents) +new_refund_request_cents = max(0, refundable_credit_cents - open_refund_reservations_cents) +``` + +This implements the requested behavior: + +- Fully paid: refund the amount paid beyond books plus studied weeks and all other valid obligations. +- Partially paid above the recalculated charge: refund only the excess actually paid. +- Partially paid below the recalculated charge: refund `$0` and retain the remaining balance. +- Never refund more than valid payments net of earlier completed payouts. + +Event fees and approved additional charges remain payable unless a separate policy explicitly reverses them. They must not disappear just because tuition is adjusted. + +### 3.6 Worked example + +Assume the annual `$380` includes books, the student's actual issue records contain two books priced at `$30` and `$20`, and there are 30 instructional weeks: + +```text +total chargeable days = 30 * 7 = 210 +issued book charge = $30 + $20 = $50 +instruction component = $380 - $50 = $330 +one-week earned tuition = $330 / 30 = $11 +one-week retained charge = $50 + $11 = $61 +``` + +Elapsed days 1–7 produce one studied week; day 8 starts the second studied week. A `no_school` event inside that elapsed period does not change either value. + +| Valid payments | Adjusted charge | Refund | Balance due | +| ---: | ---: | ---: | ---: | +| `$380` | `$61` | `$319` | `$0` | +| `$100` | `$61` | `$39` | `$0` | +| `$50` | `$61` | `$0` | `$11` | + +If the student received only the `$30` book before withdrawal, the book charge is `$30`, not `$50`, and the instruction component is `$380 - $30 = $350`. + +## 4. Required design + +### 4.1 Add school-year refund policy fields + +Use the existing configuration key name `total_instructional_weeks`. Do not introduce a second independently editable setting with a different name. Because the current `configuration` lookup follows the active year and is unsafe for historical calculations, create a migration adding the following durable policy snapshots to `school_years`: + +- `total_instructional_weeks` unsigned small integer, required before activation. +- `annual_fee_includes_books` boolean, required to be `1` for this policy and snapshotted for audit; do not expose an unsupported “books excluded” calculation mode. +- `withdrawal_policy_version` short string, for example `studied_weeks_v1`. + +Update `SchoolYearModel`, `SchoolYearManagementService`, the administrator school-year form, activation validation, and close/carry-forward logic. Draft years may be edited; active-year policy changes require a reason and must not rewrite existing posted calculations. + +During migration, seed `school_years.total_instructional_weeks` from `ConfigurationModel::getConfig('total_instructional_weeks')` for the active year after validating it. Thereafter, the configuration UI key must edit the selected school year's field rather than maintain a separate value. Posted withdrawal calculations use their own stored snapshot. Do not read `weeks_study`, derive the value from calendar events, or query `no_school` during refund calculation. + +### 4.2 Repair the book inventory data model + +The current inventory code mixes three different concepts in `inventory_items`: a global book catalog, current physical on-hand quantity, and a school-year record. Separate them. + +#### Global book catalog + +Keep `inventory_items` as the physical/catalog identity and add or correct: + +- `author`, because the current form asks for it but the schema/model discard it; +- `isbn`, `edition`, `sku`, category, and description; +- `is_active`/`retired_at` so used items are retired instead of deleted; +- no financial dependence on the item's mutable current price. + +Do not allow hard deletion of a book with movements or student issues. The current item foreign key cascades movement deletion, which would destroy refund evidence. + +#### School-year book stock and price + +Add `inventory_item_years` with: + +- `id`, `inventory_item_id`, `school_year` and a unique constraint on item/year; +- `opening_quantity`, `charge_price_cents`, `currency`; +- `system_closing_quantity`, `counted_closing_quantity`, `variance_quantity`; +- `status`: `open`, `reconciled`, `carried`, or `closed`; +- `source_item_year_id`, `closing_batch_id`, lock timestamps and audit users. + +`charge_price_cents` is the amount retained from the parent when that book is issued. It is not necessarily the school's purchase cost. Require it to be greater than zero before a book can be distributed. Admins must enter it when adding a book and confirm or update it for each new school year. + +The current `InventoryItemModel` validates `unit_price`, but the migration, `allowedFields`, controller payload, and form do not persist that field. Replace this dead validation with the cents-based year price. + +#### Operational movements + +Keep inventory movements as immutable physical events and add: + +- `inventory_item_year_id`; +- `idempotency_key` and a unique index; +- `reversal_of_movement_id`, `status`, and `reversed_at/by`; +- a source type/id link for a student issue, correction reversal, receipt, close adjustment, or other operation. + +Do not edit or delete posted distribution movements through the generic movement screen. Correct them with linked reversals. Generic movement updates/deletes currently make financial history mutable. + +Calculate year on-hand as: + +```text +opening quantity + net stock-affecting movements for that item-year +``` + +Do not sum `initial` movements from every school year. The current `ensureInitialMovementForYear()` plus the all-years `recalcQuantity()` can duplicate stock when a new opening movement is inserted. Migrate one valid opening value into `inventory_item_years`, stop generating quantity-affecting yearly opening movements, and use the latest open item-year as the current quantity source. + +#### Immutable student book issues + +Add `student_book_issues` with: + +- `id`, `student_id`, `enrollment_id`, `parent_id`, `inventory_item_id`, `inventory_item_year_id`; +- `school_year`, `class_section_id`, `quantity`; +- `unit_charge_price_cents` and `total_charge_cents` snapshots; +- `distribution_movement_id`, issue date/user, idempotency key; +- `status`: `issued` or `reversed`; +- correction reversal quantity, timestamps, user and required reason. + +Use a companion `student_book_issue_events` table, or immutable linked issue rows, for correction reversals. Refund calculations query non-reversed issue value through a dedicated service, not raw movement sums. Do not add a return transaction, return credit, or returned-to-stock workflow. + +The issue price must be copied from `inventory_item_years.charge_price_cents` inside the distribution transaction. Never look up `inventory_items` for a historical refund price. + +#### Book/class assignment + +The current book form displays class checkboxes, but `filterItemData()` silently discards them. Either remove that UI or, preferably, add `inventory_book_class_assignments` keyed by book, school year, and class/grade. Use those assignments for distribution filtering; retain category grade ranges as a broader fallback. + +### 4.3 Fix book creation and distribution workflow + +In `InventoryController`, the book form, and book list: + +- add a required price input rendered as dollars but converted and stored in cents; +- reject negative, zero, malformed, or over-precision prices; +- display the active school-year charge price; +- persist author and class assignments or remove fields that are not supported; +- validate that the selected category belongs to type `book`; +- prevent duplicate catalog entries using ISBN/edition when present, otherwise an explicit SKU/manual duplicate review; +- remove semester filtering from physical book availability; inventory is year-scoped, not reset each semester. + +Fix the teacher distribution flow: + +- change the current `Deduct & Save` button from `type="button"` to a real submit button; +- remove duplicated JavaScript handlers; +- show issued rows as read-only history rather than editable checked boxes; +- show the book price and exact parent charge before submission; +- wrap the whole batch in one database transaction; +- lock the item-year stock row before validating on-hand quantity; +- insert one idempotent issue and one linked stock movement per student; +- roll back the entire batch if any student issue fails; +- prevent issue dates in a closed/read-only school year; +- support a restricted correction-reversal operation for erroneous distributions instead of using checkbox changes; do not provide a book-return operation. + +Add `StudentBookIssueService` with methods to distribute, reverse an erroneous issue, list non-reversed issues for a student/year/date, and total snapshotted charge cents. This service is the only inventory entry point the refund calculator should use. + +### 4.4 Persist an auditable withdrawal calculation + +Add a `withdrawal_financial_calculations` table with at least: + +- identifiers: `id`, `enrollment_id`, `student_id`, `parent_id`, `invoice_id`, `school_year`; +- policy snapshot: version, school-year start date, enrollment date, withdrawal request date, `total_instructional_weeks`, derived total chargeable days, studied calendar days and studied weeks; +- book evidence: issue IDs, item IDs, quantities, issue dates, unit-price snapshots, any correction-reversal IDs and total issued-book charge; +- money snapshot: annual fee allocation, instruction component, earned tuition, retained issued-book charge, total retained charge, invoice adjustment, invoice paid, prior payouts, resulting credit and resulting balance; +- workflow: `preview`, `posted`, `superseded`, or `requires_review`; +- audit: calculated/posted/overridden timestamps, user IDs, override reason, and a JSON explanation payload. + +Enforce one active posted calculation per enrollment. A recalculation creates a new version and supersedes the old one; it must not edit history in place. + +### 4.5 Introduce focused refund services + +Create: + +1. `WithdrawalRefundCalculator`: a pure cents-based calculator. It accepts the snapshotted `total_instructional_weeks`, school-year/enrollment/withdrawal dates, student allocation, issued-book snapshots, payments, and existing payout/reservation inputs. It derives total days and studied weeks internally and must not read session state, attendance, calendar events, or active-year configuration. +2. `WithdrawalFinancialService`: transaction orchestration. It locks the enrollment, invoice, withdrawal calculation, relevant book issue rows, and refunds; posts invoice lines; recalculates the ledger; and creates or updates the refund request. +3. `StudentBookIssueService`: the authoritative bridge between inventory and refunds. It returns immutable net issue values as of the withdrawal date. + +Keep `FeeCalculationService::calculateRefund()` temporarily as a compatibility adapter. Replace its `getConfig('weeks_study')` lookup with the validated `total_instructional_weeks` policy resolver and remove its remaining-weeks calculation. Then remove the adapter after all three callers have moved to the new service. + +### 4.6 Post the withdrawal adjustment to the invoice + +Calculating a refund without changing the invoice charge leaves partially paid parents with a false overdue balance. Post idempotent invoice lines for each approved withdrawal: + +- reverse the withdrawn student’s annual allocation; +- add earned studied-week tuition; +- add/retain one itemized line per net active book issue using its price snapshot; +- link all lines to the withdrawal calculation and enrollment; +- use deterministic `active_source_key` values so retries cannot duplicate them. + +Because books are included in annual tuition, the reversal and earned tuition are discount-eligible, while itemized book lines should follow the school’s discount policy. Recommended: books are not discount-eligible; this must be confirmed before implementation because it changes the retained amount for discounted families. + +Do not mutate or delete the original frozen invoice line. Append versioned adjustment lines so the ledger remains auditable. + +Existing aggregate tuition invoice lines do not preserve per-student allocations. For new invoices, save the tuition calculator’s student-level detail in invoice metadata or separate student tuition lines. For existing invoices, reconstruct the reverse-stack allocation and require manual review if reconstructed totals do not match the frozen tuition line. + +### 4.7 Correct the ledger and refund eligibility + +In `InvoiceLedgerService`: + +- Correct the balance sign: + +```text +raw balance = net charge - paid + completed refunds +``` + +The current code uses `net charge - paid - completed refunds`, which can hide an over-refund. + +- Keep customer credit as: + +```text +customer credit = max(0, paid - completed refunds - net charge) +``` + +- Recalculate invoice status after the withdrawal adjustment and after every payout/reversal. + +In `RefundEligibilityService`: + +- Change `tuition_withdrawal` source credit from all invoice payments to the adjusted invoice’s `customerCreditCents`. +- Do not subtract completed payouts a second time, because `customerCreditCents` already includes them. +- Subtract open reservations across all invoice-backed refund source types, not only refunds with the same `source_type`; otherwise an overpayment refund and withdrawal refund can reserve the same credit twice. +- Enforce a database/transaction rule preventing two open refund claims against the same invoice credit. + +### 4.8 Centralize the withdrawal workflow + +The project currently triggers calculation from both `ParentController` and `EnrollmentWithdrawalService`, and `RefundController` contains a repair recalculation. Replace those duplicated paths: + +1. Parent requests withdrawal: + - capture immutable request date; + - set `withdraw under review`; + - create a calculation preview only; + - do not create or approve a monetary refund yet. +2. Admin reviews: + - show studied weeks, annual allocation, every issued book and snapshotted price, earned tuition, adjusted invoice charge, valid payments, expected refund, and expected remaining balance; + - permit an enrollment-date or withdrawal-date correction only with a reason; recalculate studied days/weeks from those corrected dates. +3. Admin confirms: + - call `WithdrawalFinancialService` inside one transaction; + - post invoice adjustments and recalculate ledger; + - if credit is positive, create/update one Pending `tuition_withdrawal` refund and set `refund pending`; + - if credit is zero, do not create a zero-dollar refund; complete the withdrawal and show either `$0 due` or the remaining balance. +4. Existing refund approval and idempotent payout flow remains, but approval rechecks the adjusted invoice credit under row locks. + +Remove the post-commit `new InvoiceController()->generateInvoice()` fallback from the withdrawal path. Financial effects must succeed or roll back in the same transaction as the approved withdrawal. + +Never select “the latest invoice” when multiple active invoices exist for the parent/year. Stop with `requires_review` until legacy duplicates are reconciled. + +### 4.9 Carry leftover books through school-year closing + +Extend `SchoolYearClosingService` rather than relying on lazy opening movements. + +#### Closing preview and blockers + +Add an inventory section to the closing preview with each book's: + +- source opening quantity; +- receipts, distributions, correction reversals, losses and adjustments; +- calculated ending quantity; +- physical counted quantity and variance; +- current and proposed next-year charge prices; +- target-year opening quantity. + +Block closing when: + +- a book with stock or student issues has a missing/zero price snapshot; +- calculated stock is negative; +- physical count is missing; +- a variance is unresolved; +- a distribution has no linked student issue or price snapshot; +- a student issue has no matching stock movement; +- mutable/duplicate yearly opening movements make the balance ambiguous. + +Admin must resolve variance with a dated, reasoned adjustment before carry-forward. Do not silently force system quantity to match the count. + +#### Closing execution + +During the existing closing execution transaction: + +1. Lock source item-year rows and relevant movements. +2. Recheck the preview hash, inventory count and prices. +3. Create one target `inventory_item_years` row per carried item with `opening_quantity = verified source closing quantity`. +4. Copy the source charge price as the proposed target-year price; require admin confirmation before the first target-year distribution. +5. Link target and source rows plus the closing batch for idempotency. +6. Mark the source item-year `carried`, then `closed` only when target rows and quantities verify. + +Do not insert a positive physical inventory movement for the carried amount. Carry-forward changes the reporting period, not the number of physical books. Only books physically left on hand carry forward; books issued to students do not become target-year stock. + +Add inventory results to the closing hash, audit log and UI. `complete()` must refuse to close/activate years until financial carry-forward and inventory carry-forward are both complete. + +## 5. UI and reporting changes + +### Enrollment/withdrawal admin page + +Add a review modal containing: + +- withdrawal request/effective date; +- total instructional weeks, derived chargeable days, elapsed studied days and calculated studied weeks; +- annual student allocation; +- every issued book, issue date, quantity and snapshotted unit price; +- permanently retained issued-book charge and any documented erroneous-issue corrections; +- earned tuition and retained charge; +- invoice payments, earlier refunds, open reservations; +- expected refund or remaining balance; +- any blockers, such as multiple invoices or missing policy values. + +### Refund list + +Add a calculation-breakdown action and show: + +- source student(s); +- issued book evidence and price snapshots; +- adjusted charge; +- paid-to-date; +- available credit; +- requested, approved, paid, and remaining refund; +- reconciliation warning when payouts exceed the recalculated entitlement. + +Approval must be disabled when the calculation is missing, stale, or marked `requires_review`. + +### Invoice/PDF + +Show withdrawal lines explicitly: annual tuition reversal, earned-week tuition, and itemized retained books. The ending invoice balance must equal the ledger calculation. + +### Book inventory + +- Book create/edit: require active-year charge price and show whether it is already used by issue snapshots. +- Book list: show active-year opening, issued, correction-reversed, adjusted and current on-hand quantities plus charge price. +- Student distribution: show price, total charge, stock, and immutable prior issue status. +- Student history: list all book issues and correction reversals by year, with the price snapshot used for refunds and the required correction reason. +- Closing preview: add an inventory reconciliation and carry-forward table with blockers and variance actions. + +## 6. Migration and reconciliation strategy + +1. Deploy the inventory catalog, item-year, immutable issue, and refund snapshot schema with the new policy disabled. +2. Audit existing book items for duplicates, missing author/class data, malformed year tags and missing prices. +3. Reconstruct one opening/ending balance per item/year from existing movements. Flag any item where multiple `initial` rows or global quantity disagree; do not guess silently. +4. Convert valid student `distribution` movements into `student_book_issues`. Because old movements have no historical price, require an admin-approved price snapshot before those issues can affect refunds. +5. Flag legacy positive student book movements for manual review; do not convert them into return credits or available stock automatically. Disable edit/delete for migrated issue movements and replace proven data-entry errors with audited correction reversals. +6. Populate `total_instructional_weeks` and `annual_fee_includes_books` for the active year. +7. Run a read-only audit of all Pending/Approved/Partial tuition-withdrawal refunds using the migrated book issue snapshots. +8. For each open refund, calculate the old and new amounts side by side. +9. Pending refunds may be superseded and regenerated after admin review. +10. Approved or partially paid refunds must never be silently reduced below completed payouts. Mark them `requires_review`, preserve payout history, and use the existing reversal workflow if correction is authorized. +11. Do not automatically rewrite Paid refunds. +12. Activate the new policy only after inventory variances and refund ledger differences are fully explained. + +## 7. Test plan + +### Pure calculation tests + +- zero, one, several, and all instructional weeks; +- enrollment before school starts and late enrollment; +- withdrawal on day 1, day 7, day 8, and after the derived final chargeable day; +- `no_school` calendar dates do not alter the calculation because they are already reflected in `total_instructional_weeks`; +- missing/zero total weeks rejected; +- zero, one, and multiple actual book issues; +- issues after the withdrawal date excluded; +- issue price remains stable after current-year price changes; +- issued-book charges are never credited because a book was handed back; +- an audited reversal of an issue entered in error is excluded from the charge; +- book issue total exceeding an inclusive annual allocation blocks automation; +- annual fee is always book-inclusive and a false/missing policy snapshot blocks calculation; +- cents rounding across all weeks equals the annual instruction component; +- first/additional sibling reverse-stack allocation; +- simultaneous and sequential sibling withdrawals. + +### Payment/refund tests + +- no payment: refund `$0`, retained balance remains; +- payment below, equal to, and above retained charge; +- fully paid invoice; +- discount present; +- event and additional charges present; +- prior completed refund, reversal, and partial payout; +- open overpayment reservation prevents duplicate credit use; +- payment void/chargeback is excluded; +- payout cannot exceed adjusted credit under concurrent requests. + +### Workflow/integration tests + +- parent request creates preview but no payout entitlement; +- admin confirmation posts each invoice line exactly once; +- retry is idempotent; +- status and financial changes roll back together on failure; +- multiple active invoices block automatic processing; +- closed school year is read-only; +- historical calculation is unchanged after policy, date, or calendar-event edits; +- invoice PDF and refund UI match the ledger; +- ledger balance increases correctly when a refund is paid or a payout is reversed. + +### Inventory tests + +- book creation rejects missing/zero/malformed charge price; +- author and class assignments persist; +- the distribution button submits the form; +- a distribution atomically creates one issue plus one negative movement per student; +- concurrent distributions cannot drive stock negative; +- retrying the same batch is idempotent; +- issued students cannot be charged twice for the same active issue; +- correction reversals restore stock without deleting issue history; +- no return action or return credit exists, and an issued copy cannot be added back to stock as a parent return; +- generic movement edit/delete cannot alter issue-backed movements; +- current on-hand uses one item-year opening and does not double-count earlier yearly openings; +- semester changes do not hide physical stock; +- historical refunds use issue price snapshots, not current book prices. + +### School-year closing inventory tests + +- missing physical count, missing price, negative stock, orphan issue, or unresolved variance blocks closing; +- verified leftover stock becomes the target-year opening exactly once; +- distributed books are not carried as on-hand stock; +- carry-forward creates no stock-affecting positive movement; +- retrying close execution creates no duplicate item-year row or quantity; +- target price is copied for review but remains unconfirmed until authorized; +- closing and target activation roll back if inventory carry-forward fails; +- source and target totals plus issue history remain reproducible after closing. + +## 8. Acceptance criteria + +The work is complete only when: + +1. A one-week withdrawal retains exactly the snapshotted prices of books actually issued by the withdrawal date plus one prorated instructional-week charge. +2. Refund amount is never greater than net valid payments or adjusted available invoice credit. +3. A partially paid parent below the retained charge receives no refund and sees the correct remaining balance. +4. A partially paid parent above the retained charge receives only the excess. +5. Sibling tuition tiers remain correct after one or more withdrawals. +6. Existing refunds cannot reserve or pay the same invoice credit twice. +7. Every amount shown in the withdrawal preview, refund screen, invoice, and ledger agrees to the cent. +8. Every approved calculation can be reproduced from its stored dates and `total_instructional_weeks` snapshot without querying today’s configuration or calendar events. +9. Changing a book price never changes a historical issue or refund. +10. Every issued book has one price-snapshotted issue record and one linked stock movement. +11. A new school year opens with exactly the verified leftover stock from the closed year, without duplicate opening movements. +12. Inventory discrepancies block school-year closing instead of being silently carried forward. +13. Handing a book back never reduces the retained charge or increases on-hand stock; only an audited correction of an issue entered in error can do so. + +## 9. Recommended implementation order + +1. Record the locked policies: annual tuition includes books and issued books are not returnable; separately confirm whether book charges are discount-eligible. +2. Add catalog fixes, item-year balances/prices, immutable student issue tables and constraints. +3. Migrate and reconcile existing stock movements before enabling any price-based refund. +4. Fix book create/list/distribution/correction-reversal workflows and add inventory tests. +5. Integrate verified inventory carry-forward into school-year closing. +6. Add school-year refund policy fields and the withdrawal calculation audit table. +7. Add `StudentBookIssueService`, pure refund calculator and unit tests. +8. Correct invoice ledger refund sign and add regression tests. +9. Add idempotent withdrawal invoice adjustments with itemized book lines. +10. Change refund eligibility to adjusted invoice credit and cross-source reservations. +11. Centralize parent/admin withdrawal paths. +12. Add preview/breakdown UI, inventory closing UI and invoice PDF lines. +13. Run the refund/inventory legacy audit and controlled rollout. + +## 10. Implementation status — 2026-08-21 + +Phase 1 is implemented in the accompanying project package: + +- completed: schema foundation, school-year policy input, book price/class persistence, immutable price-snapshotted issue records, idempotent distribution, audited error correction, movement protections, pure calculator, invoice-scoped payment calculation, ledger refund-sign correction, physical count blockers, and exact year-end stock carry-forward; +- tests added: pure calculator unit scenarios; +- pending: execution of migrations/PHPUnit in a PHP environment, legacy inventory/issue reconciliation, persisted calculation posting and itemized withdrawal invoice lines, full preview/history UI, and integration/concurrency coverage. + +The new flow must remain in staging until legacy book prices and stock are reconciled. The migration intentionally refuses to invent missing historical prices. diff --git a/tests/WithdrawalRefundCalculatorTest.php b/tests/WithdrawalRefundCalculatorTest.php new file mode 100644 index 0000000..16b95b2 --- /dev/null +++ b/tests/WithdrawalRefundCalculatorTest.php @@ -0,0 +1,251 @@ +calculator = new WithdrawalRefundCalculator(); + } + + public function testOneWeekRetainsIssuedBooksAndOneInstructionalWeek(): void + { + $result = $this->calculator->calculate($this->baseInput([ + 'valid_payment_cents' => 38000, + ])); + + self::assertSame(210, $result['total_chargeable_days']); + self::assertSame(7, $result['studied_calendar_days']); + self::assertSame(1, $result['studied_weeks']); + self::assertSame(5000, $result['issued_book_charge_cents']); + self::assertSame(33000, $result['annual_instruction_cents']); + self::assertSame(1100, $result['earned_tuition_cents']); + self::assertSame(6100, $result['retained_charge_cents']); + self::assertSame(31900, $result['new_refund_request_cents']); + self::assertSame(0, $result['balance_due_cents']); + } + + public function testPartialPaymentRefundsOnlyTheAmountAboveRetainedCharge(): void + { + $result = $this->calculator->calculate($this->baseInput([ + 'valid_payment_cents' => 10000, + ])); + + self::assertSame(3900, $result['new_refund_request_cents']); + self::assertSame(0, $result['balance_due_cents']); + } + + public function testPartialPaymentBelowRetainedChargeProducesBalanceAndNoRefund(): void + { + $result = $this->calculator->calculate($this->baseInput([ + 'valid_payment_cents' => 5000, + ])); + + self::assertSame(0, $result['new_refund_request_cents']); + self::assertSame(1100, $result['balance_due_cents']); + } + + public function testStartedSecondSevenDayPeriodChargesTwoWeeks(): void + { + $result = $this->calculator->calculate($this->baseInput([ + 'withdrawal_request_date' => '2026-08-08', + ])); + + self::assertSame(8, $result['studied_calendar_days']); + self::assertSame(2, $result['studied_weeks']); + self::assertSame(2200, $result['earned_tuition_cents']); + self::assertSame(7200, $result['retained_charge_cents']); + } + + public function testCompletedPayoutAndReservationCannotBeRefundedAgain(): void + { + $result = $this->calculator->calculate($this->baseInput([ + 'valid_payment_cents' => 38000, + 'completed_payout_cents' => 10000, + 'open_reservation_cents' => 5000, + ])); + + self::assertSame(21900, $result['refundable_credit_cents']); + self::assertSame(16900, $result['new_refund_request_cents']); + } + + public function testIssuedBooksCannotExceedInclusiveAnnualAllocation(): void + { + $this->expectException(InvalidArgumentException::class); + $this->calculator->calculate($this->baseInput([ + 'issued_book_charge_cents' => 38001, + ])); + } + + public function testBooksExcludedModeIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->calculator->calculate($this->baseInput([ + 'annual_fee_includes_books' => false, + ])); + } + + public function testDayOneChargesOneStartedWeek(): void + { + $result = $this->calculator->calculate($this->baseInput([ + 'withdrawal_request_date' => '2026-08-01', + ])); + self::assertSame(1, $result['studied_calendar_days']); + self::assertSame(1, $result['studied_weeks']); + } + + public function testWithdrawalBeforeChargeStartChargesNoTuitionButRetainsBooks(): void + { + $result = $this->calculator->calculate($this->baseInput([ + 'enrollment_date' => '2026-08-10', + 'withdrawal_request_date' => '2026-08-09', + ])); + self::assertSame(0, $result['studied_calendar_days']); + self::assertSame(0, $result['studied_weeks']); + self::assertSame(5000, $result['retained_charge_cents']); + } + + public function testLateEnrollmentBecomesChargeStart(): void + { + $result = $this->calculator->calculate($this->baseInput([ + 'enrollment_date' => '2026-08-20', + 'withdrawal_request_date' => '2026-08-26', + ])); + self::assertSame('2026-08-20', $result['charge_start_date']); + self::assertSame(7, $result['studied_calendar_days']); + self::assertSame(1, $result['studied_weeks']); + } + + public function testEnrollmentBeforeSchoolStartsUsesSchoolStart(): void + { + $result = $this->calculator->calculate($this->baseInput([ + 'enrollment_date' => '2026-07-01', + 'withdrawal_request_date' => '2026-08-07', + ])); + self::assertSame('2026-08-01', $result['charge_start_date']); + self::assertSame(1, $result['studied_weeks']); + } + + public function testDurationCapsAtTotalInstructionalWeeks(): void + { + $result = $this->calculator->calculate($this->baseInput([ + 'withdrawal_request_date' => '2027-12-31', + 'valid_payment_cents' => 38000, + ])); + self::assertSame(210, $result['studied_calendar_days']); + self::assertSame(30, $result['studied_weeks']); + self::assertSame(38000, $result['retained_charge_cents']); + self::assertSame(0, $result['new_refund_request_cents']); + } + + public function testOtherChargesRemainPayable(): void + { + $result = $this->calculator->calculate($this->baseInput([ + 'valid_payment_cents' => 10000, + 'other_charge_cents' => 2000, + ])); + self::assertSame(8100, $result['retained_charge_cents']); + self::assertSame(1900, $result['new_refund_request_cents']); + } + + public function testProrationRoundsOnceInCents(): void + { + $result = $this->calculator->calculate($this->baseInput([ + 'annual_fee_allocation_cents' => 10000, + 'issued_book_charge_cents' => 0, + 'total_instructional_weeks' => 3, + 'withdrawal_request_date' => '2026-08-07', + ])); + self::assertSame(3333, $result['earned_tuition_cents']); + } + + public function testZeroTotalInstructionalWeeksIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->calculator->calculate($this->baseInput(['total_instructional_weeks' => 0])); + } + + public function testMalformedDateIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->calculator->calculate($this->baseInput(['withdrawal_request_date' => '2026-02-31'])); + } + + public function testNoSchoolCalendarDataCannotAffectPureCalculation(): void + { + $first = $this->calculator->calculate($this->baseInput()); + $second = $this->calculator->calculate($this->baseInput(['unrelated_no_school_dates' => ['2026-08-03']])); + self::assertSame($first, $second); + } + + public function testNoPaymentProducesNoRefundAndFullRetainedBalance(): void + { + $result = $this->calculator->calculate($this->baseInput()); + self::assertSame(0, $result['new_refund_request_cents']); + self::assertSame(6100, $result['balance_due_cents']); + } + + public function testPaymentEqualToRetainedChargeProducesNeitherRefundNorBalance(): void + { + $result = $this->calculator->calculate($this->baseInput(['valid_payment_cents' => 6100])); + self::assertSame(0, $result['new_refund_request_cents']); + self::assertSame(0, $result['balance_due_cents']); + } + + public function testZeroIssuedBooksProratesTheEntireAnnualAllocation(): void + { + $result = $this->calculator->calculate($this->baseInput([ + 'issued_book_charge_cents' => 0, + 'valid_payment_cents' => 38000, + ])); + self::assertSame(0, $result['issued_book_charge_cents']); + self::assertSame(1267, $result['earned_tuition_cents']); + self::assertSame(36733, $result['new_refund_request_cents']); + } + + public function testFinalWeekAlwaysClosesAtTheExactAnnualAllocation(): void + { + $result = $this->calculator->calculate($this->baseInput([ + 'withdrawal_request_date' => '2027-02-26', + 'valid_payment_cents' => 38000, + ])); + self::assertSame(30, $result['studied_weeks']); + self::assertSame(33000, $result['earned_tuition_cents']); + self::assertSame(38000, $result['retained_charge_cents']); + } + + public function testOpenReservationCannotMakeARefundNegative(): void + { + $result = $this->calculator->calculate($this->baseInput([ + 'valid_payment_cents' => 10000, + 'open_reservation_cents' => 999999, + ])); + self::assertSame(3900, $result['refundable_credit_cents']); + self::assertSame(0, $result['new_refund_request_cents']); + } + + /** @param array $overrides */ + private function baseInput(array $overrides = []): array + { + return array_replace([ + 'annual_fee_allocation_cents' => 38000, + 'issued_book_charge_cents' => 5000, + 'total_instructional_weeks' => 30, + 'school_year_start_date' => '2026-08-01', + 'enrollment_date' => '2026-08-01', + 'withdrawal_request_date' => '2026-08-07', + 'valid_payment_cents' => 0, + 'completed_payout_cents' => 0, + 'open_reservation_cents' => 0, + 'other_charge_cents' => 0, + 'annual_fee_includes_books' => true, + ], $overrides); + } +} diff --git a/tests/app/Libraries/InvoiceLedgerServiceTest.php b/tests/app/Libraries/InvoiceLedgerServiceTest.php index 51b3ad7..b6885a5 100644 --- a/tests/app/Libraries/InvoiceLedgerServiceTest.php +++ b/tests/app/Libraries/InvoiceLedgerServiceTest.php @@ -161,7 +161,7 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase $this->assertSame('0.00', $calculation['balance']); $this->assertSame('15.00', $calculation['customer_credit']); - $this->assertSame(-3500, $calculation['rawBalanceCents']); + $this->assertSame(-1500, $calculation['rawBalanceCents']); } public function testFullyRefundedOverpaymentClearsCustomerCredit(): void @@ -177,7 +177,7 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase $this->assertSame('0.00', $calculation['balance']); $this->assertSame('0.00', $calculation['customer_credit']); - $this->assertSame(-5000, $calculation['rawBalanceCents']); + $this->assertSame(0, $calculation['rawBalanceCents']); } public function testPaymentAfterRefundCanRestorePaidStatus(): void @@ -392,7 +392,7 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase $this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']); } - public function testCashRefundDoesNotCreateBalanceAfterOverpaymentIsReturned(): void + public function testCashRefundAboveOverpaymentCreatesBalanceDue(): void { $service = new InvoiceLedgerServiceHarness([ 'id' => 12, @@ -405,8 +405,8 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase $calculation = $service->calculateInvoice(12); $this->assertSame('0.00', $calculation['customer_credit']); - $this->assertSame('0.00', $calculation['balance']); - $this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']); + $this->assertSame('5.00', $calculation['balance']); + $this->assertSame(FinancialStatus::INVOICE_PARTIALLY_PAID, $calculation['status']); } public function testIssuedInvoiceUsesPaidRefundsAsCashOutInsteadOfAdditionalCredit(): void @@ -429,7 +429,7 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase $this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']); } - public function testRefundedWithdrawalInvoiceHasNoBalanceDue(): void + public function testRefundedWithdrawalInvoiceShowsBalanceWhenRefundsExceedPaymentAgainstCharge(): void { $service = new InvoiceLedgerServiceHarness([ 'id' => 14, @@ -446,7 +446,7 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase $this->assertSame('178.00', $calculation['paid_amount']); $this->assertSame('178.00', $calculation['refund_paid_total']); $this->assertSame('0.00', $calculation['customer_credit']); - $this->assertSame('0.00', $calculation['balance']); - $this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']); + $this->assertSame('280.00', $calculation['balance']); + $this->assertSame(FinancialStatus::INVOICE_PARTIALLY_PAID, $calculation['status']); } } diff --git a/tests/app/Libraries/RefundEligibilityServiceTest.php b/tests/app/Libraries/RefundEligibilityServiceTest.php index c55008e..716e73d 100644 --- a/tests/app/Libraries/RefundEligibilityServiceTest.php +++ b/tests/app/Libraries/RefundEligibilityServiceTest.php @@ -24,7 +24,7 @@ class RefundEligibilityServiceHarness extends RefundEligibilityService return $this->completedPayoutCents; } - protected function calculateReservedAmountCents(string $sourceType, int $sourceId, ?int $excludeRefundId): int + protected function calculateReservedAmountCents(string $sourceType, int $sourceId, ?int $excludeRefundId, ?int $invoiceId = null): int { return $this->reservedAmountCents; } @@ -60,17 +60,17 @@ class RefundEligibilityServiceTest extends CIUnitTestCase $this->assertContains('HAS_APPROVED_RESERVATIONS', $result->reasonCodes); } - public function testTuitionWithdrawalAvailableCreditSubtractsCompletedPayoutsAndReservations(): void + public function testTuitionWithdrawalAvailableCreditUsesAdjustedInvoiceCreditWithoutDoubleSubtractingPayouts(): void { $service = new RefundEligibilityServiceHarness(20000, 2500, 1500); $result = $service->calculateAvailableCredit(10, 20, 'tuition_withdrawal', 20); $this->assertSame(20000, $result->sourceCreditCents); - $this->assertSame(2500, $result->completedPayoutCents); + $this->assertSame(0, $result->completedPayoutCents); $this->assertSame(1500, $result->reservedAmountCents); - $this->assertSame(16000, $result->availableAmountCents); - $this->assertContains('HAS_COMPLETED_PAYOUTS', $result->reasonCodes); + $this->assertSame(18500, $result->availableAmountCents); + $this->assertNotContains('HAS_COMPLETED_PAYOUTS', $result->reasonCodes); $this->assertContains('HAS_APPROVED_RESERVATIONS', $result->reasonCodes); } diff --git a/tests/app/Services/EnrollmentStatusServiceTest.php b/tests/app/Services/EnrollmentStatusServiceTest.php index a5508c3..7429818 100644 --- a/tests/app/Services/EnrollmentStatusServiceTest.php +++ b/tests/app/Services/EnrollmentStatusServiceTest.php @@ -27,7 +27,7 @@ final class EnrollmentStatusServiceTest extends TestCase ['payment pending', 1], ['enrolled', 1], ['withdraw under review', 1], - ['refund pending', 1], + ['refund pending', 0], ['denied', 0], ['withdrawn', 0], ['waitlist', 0], diff --git a/tests/app/Services/FeeCalculationServiceTest.php b/tests/app/Services/FeeCalculationServiceTest.php index ef3bc32..497a896 100644 --- a/tests/app/Services/FeeCalculationServiceTest.php +++ b/tests/app/Services/FeeCalculationServiceTest.php @@ -5,8 +5,51 @@ namespace Tests\App\Services; use App\Services\FeeCalculationService; use CodeIgniter\Test\CIUnitTestCase; +class FeeCalculationServiceRefundAdapterHarness extends FeeCalculationService +{ + public array $previewedEnrollmentIds = []; + + public function __construct(private array $latestByEnrollment, private array $previewByEnrollment) + { + } + + protected function latestWithdrawalCalculation(int $enrollmentId): ?array + { + return $this->latestByEnrollment[$enrollmentId] ?? null; + } + + protected function previewWithdrawalCalculation(int $enrollmentId): array + { + $this->previewedEnrollmentIds[] = $enrollmentId; + return $this->previewByEnrollment[$enrollmentId] ?? ['new_refund_request_cents' => 0]; + } +} + class FeeCalculationServiceTest extends CIUnitTestCase { + public function testRefundAdapterUsesWithdrawalFinancialPreviewAmounts(): void + { + $service = new FeeCalculationServiceRefundAdapterHarness( + latestByEnrollment: [ + 10 => ['status' => 'preview', 'new_refund_request_cents' => 12345], + 11 => ['status' => 'superseded', 'new_refund_request_cents' => 99999], + ], + previewByEnrollment: [ + 11 => ['status' => 'preview', 'new_refund_request_cents' => 500], + ] + ); + + $refund = $service->calculateRefund([ + ['id' => 10, 'parent_id' => 8, 'enrollment_status' => 'withdraw under review'], + ['id' => 11, 'parent_id' => 8, 'enrollment_status' => 'refund pending'], + ['id' => 12, 'parent_id' => 8, 'enrollment_status' => 'enrolled'], + ['id' => 13, 'parent_id' => 9, 'enrollment_status' => 'refund pending'], + ], 8); + + $this->assertSame(128.45, $refund); + $this->assertSame([11], $service->previewedEnrollmentIds); + } + public function testRefundReversesLatestTuitionTierFirst(): void { $fees = $this->refundFeeStack(3, 2, 380.0, 280.0);