add refund logic and fix books inventory logic
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Failing after 1m20s

This commit is contained in:
root
2026-08-22 13:44:25 -04:00
parent 23d1cbb64c
commit d906a915d6
45 changed files with 4711 additions and 442 deletions
+936
View File
@@ -0,0 +1,936 @@
<?php
namespace App\Services;
use App\Libraries\FinancialStatus;
use App\Libraries\InvoiceLedgerService;
use App\Libraries\RefundEligibilityService;
use App\Models\ConfigurationModel;
use CodeIgniter\Database\BaseConnection;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
/**
* Authoritative withdrawal financial workflow.
*
* Preview writes immutable versioned evidence but no invoice/refund entitlement.
* Post locks the source rows, appends idempotent invoice adjustments, refreshes
* the ledger, and creates at most one invoice-backed withdrawal refund claim.
*/
final class WithdrawalFinancialService
{
public function __construct(
private readonly BaseConnection $db,
private readonly WithdrawalRefundCalculator $calculator,
private readonly StudentBookIssueService $bookIssues,
private readonly InvoiceLedgerService $invoiceLedger,
private readonly RefundEligibilityService $refundEligibility
) {
}
/** @return array<string,mixed> */
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<string,mixed>
*/
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<string,mixed> */
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<string> */
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<string,mixed>|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<string,mixed> */
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<string> */
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<array<string,mixed>> */
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<string,mixed> */
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<string,mixed>,books:list<array<string,mixed>>,blockers:list<string>,explanation:array<string,mixed>} */
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 students 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<string,int> */
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<string> */
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<array<string,mixed>> */
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<string,mixed> */
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<string>} */
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<string,mixed> */
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.');
}
}
}
}