Files
alrahma_sunday_school/app/Services/WithdrawalFinancialService.php
T
root 38644b32ae
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Failing after 1m19s
fix invoice and enrollment fees
2026-08-27 18:34:36 -04:00

959 lines
47 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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 = ['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<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
{
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<array<string,mixed>> */
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<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();
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<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', '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;
}
}