*/ 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 = ['total' => 0, 'eligible_total' => 0]; if ($this->invoiceLinesAvailable()) { $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 { if (! $this->invoiceLinesAvailable()) { return []; } $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 { if (! $this->invoiceLinesAvailable()) { return []; } $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 { if (! $this->invoiceLinesAvailable()) { return; } $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(); if ($this->invoiceLinesAvailable()) { $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', 'refunds', 'school_years'] as $table) { if (! $this->db->tableExists($table)) { throw new RuntimeException('Required table is missing: ' . $table . '. Run migrations first.'); } } } private function invoiceLinesAvailable(): bool { return false; } }