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
+1 -1
View File
@@ -14,11 +14,11 @@ final class EnrollmentStatusService
'payment pending',
'enrolled',
'withdraw under review',
'refund pending',
];
public const INACTIVE_STATUSES = [
'denied',
'refund pending',
'withdrawn',
'waitlist',
];
+14 -89
View File
@@ -3,7 +3,6 @@
namespace App\Services;
use App\Controllers\View\InvoiceController;
use App\Libraries\RefundEligibilityService;
use App\Models\ClassSectionModel;
use App\Models\EnrollmentModel;
use App\Models\InvoiceModel;
@@ -194,7 +193,6 @@ public function newStudents(string $schoolYear): array
//update enrollment status
public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, string $semester, ?int $performedBy): array
{
$refundService = new FeeCalculationService();
$enrollmentStatusService = \Config\Services::enrollmentStatus(false);
$performedBy = $performedBy ?: ((int) (session()->get('user_id') ?? 0) ?: null);
$this->schoolYear = $schoolYear;
@@ -212,8 +210,8 @@ public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, s
// For batching emails: parent -> status -> [students...]
$groupsByParentStatus = []; // [parent_id][status][] = ['student_id'=>, 'student_name'=>]
$parentInfo = []; // [parent_id] = ['user_id','email','firstname','lastname']
$refundParents = []; // parent_id => true (for refund calc)
$refundAmountByParent = []; // parent_id => amount
$withdrawalPreviewEnrollmentIds = []; // enrollment_id => parent_id
$refundAmountByParent = []; // parent_id => preview amount for notification context
$validStatuses = EnrollmentStatusService::VALID_STATUSES;
@@ -287,7 +285,7 @@ public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, s
];
if ($newEnrollmentStatus === 'refund pending') {
$refundParents[$parentId] = true;
$withdrawalPreviewEnrollmentIds[(int) $result['id']] = $parentId;
}
log_message('info', "Created enrollment for student ID {$studentId} with status {$newEnrollmentStatus} and admission {$admissionStatus}.");
@@ -369,98 +367,25 @@ public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, s
// Mark for refund calc
if ($newEnrollmentStatus === 'refund pending') {
$refundParents[$parentId] = true;
$withdrawalPreviewEnrollmentIds[(int) $enrollmentRow['id']] = (int) $parentId;
}
}
// Compute refunds ONCE per parent needing it
foreach (array_keys($refundParents) as $pid) {
$students = $this->enrollmentModel
->where('parent_id', $pid)
->where('school_year', $this->schoolYear)
->findAll();
if (empty($students)) {
// If a parent is marked for refund but has no enrollments, just log and continue.
log_message('info', "No enrollments found for parent ID {$pid} (for refund calc); skipping refund.");
continue;
}
$invoice = $this->invoiceModel->where('parent_id', $pid)
->where('school_year', $this->schoolYear)
->orderBy('created_at', 'DESC')
->first();
if (!$invoice) {
$errors[] = "No invoice found for parent ID $pid (for refund calc).";
continue;
}
$refundAmount = $refundService->calculateRefund($students, $pid);
$refundAmountByParent[$pid] = $refundAmount;
$existingRefund = $this->refundModel->where('invoice_id', $invoice['id'])->first();
if ($existingRefund) {
$refundId = (int)$existingRefund['id'];
$status = strtolower((string)($existingRefund['status'] ?? ''));
$isApprovedState = in_array($status, ['approved', 'partial', 'paid', 'partially_paid'], true);
$calculatedCents = max(0, (int)round($refundAmount * 100));
$paidCents = (new RefundEligibilityService())->getCompletedPayoutTotalCentsForRefund($refundId);
$targetCents = $isApprovedState ? max($calculatedCents, $paidCents) : $calculatedCents;
$update = [
'refund_amount' => $targetCents / 100,
'updated_by' => session()->get('user_id') ?? null,
];
if ($isApprovedState) {
$update['approved_amount_cents'] = $targetCents;
} else {
$update['status'] = 'Pending';
$update['requested_amount_cents'] = $targetCents;
}
if ($isApprovedState && $paidCents > $calculatedCents) {
$message = sprintf(
'Completed payouts (%0.2f) exceed recalculated refundable credit (%0.2f).',
$paidCents / 100,
$calculatedCents / 100
);
$update['reconciliation_status'] = 'requires_review';
$update['reconciliation_reason'] = $message;
$update['reconciliation_required_at'] = utc_now();
log_message('critical', 'Refund reconciliation required for refund #' . $refundId . ': ' . $message);
} else {
$update['reconciliation_status'] = null;
$update['reconciliation_reason'] = null;
$update['reconciliation_required_at'] = null;
}
$this->refundModel->update($refundId, $update);
} else {
$this->refundModel->insert([
'parent_id' => $pid,
'school_year' => $invoice['school_year'],
'invoice_id' => $invoice['id'],
'refund_amount' => $refundAmount,
'requested_amount_cents' => (int)round($refundAmount * 100),
'approved_amount_cents' => null,
'currency' => 'USD',
'refund_paid_amount' => 0.0,
'status' => 'Pending',
'source_type' => 'tuition_withdrawal',
'source_id' => (int)$invoice['id'],
'requested_at' => utc_now(),
'updated_by' => session()->get('user_id') ?? null,
]);
}
log_message('info', "Refund of $refundAmount created/updated for invoice ID {$invoice['id']} (parent {$pid}).");
}
$this->db->transComplete();
if (!$this->db->transStatus()) {
return ['ok' => false, 'message' => 'A database error occurred. Changes were rolled back.'];
}
foreach ($withdrawalPreviewEnrollmentIds as $enrollmentId => $pid) {
try {
$calculation = service('withdrawalFinancial')->preview((int) $enrollmentId, $performedBy);
$refundAmountByParent[(int) $pid] = ((int) ($calculation['new_refund_request_cents'] ?? 0)) / 100;
} catch (\Throwable $e) {
$errors[] = 'Withdrawal calculation preview failed for enrollment #' . (int) $enrollmentId . ': ' . $e->getMessage();
}
}
// === AFTER COMMIT: fire specific events, batched per parent/status ===
$eventMap = [
'admission under review' => 'admissionUnderReview',
@@ -524,7 +449,7 @@ public function updateStatuses(?array $enrollmentStatuses, string $schoolYear, s
// === Server-side safety net: generate/update invoices for parents whose statuses require it ===
try {
$needsInvoiceFor = ['payment pending', 'enrolled', 'withdrawn', 'refund pending'];
$needsInvoiceFor = ['payment pending', 'enrolled'];
$invCtl = new InvoiceController();
foreach ($groupsByParentStatus as $pid => $byStatus) {
$statuses = array_keys($byStatus);
+26 -96
View File
@@ -2,8 +2,6 @@
namespace App\Services;
use App\Models\ConfigurationModel;
use App\Models\PaymentModel;
use App\Models\InvoiceModel;
use App\Models\ClassSectionModel;
class FeeCalculationService
@@ -15,112 +13,44 @@ class FeeCalculationService
public function calculateRefund(array $students, int $parentId): float
{
$configModel = new ConfigurationModel();
$paymentModel = new PaymentModel();
$invoiceModel = new InvoiceModel();
$classSectionModel = new ClassSectionModel();
$totalCents = 0;
$seenEnrollmentIds = [];
$schoolYear = $configModel->getConfig('school_year');
$refundDeadline = date('Y-m-d', strtotime($configModel->getConfig('refund_deadline')));
$weekOfStudy = (float) ($configModel->getConfig('weeks_study') ?? 8);
$schoolEndDate = date('Y-m-d', strtotime($configModel->getConfig('last_day_of_school')));
$totalPaid = $paymentModel->getTotalPaidByParentId($parentId, $schoolYear);
if ($totalPaid <= 0) {
log_message('info', "No payments made. Refund = 0.");
return 0;
}
// Classify and enrich student data
$registeredStudents = [];
$withdrawnStudents = [];
foreach ($students as &$student) {
$gradeName = $classSectionModel->getClassSectionNameBySectionId($student['class_section_id']);
$student['grade'] = strtoupper(trim($gradeName));
if (in_array($student['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'])) {
$withdrawnStudents[] = $student;
} elseif (
in_array($student['enrollment_status'], ['enrolled', 'payment pending']) &&
$student['admission_status'] === 'accepted'
) {
$registeredStudents[] = $student;
}
}
unset($student);
if (empty($withdrawnStudents)) {
log_message('info', "No withdrawn students found. Refund = 0.");
return 0;
}
usort($withdrawnStudents, function ($a, $b) {
$leftDate = strtotime((string)($a['withdrawal_date'] ?? '')) ?: PHP_INT_MAX;
$rightDate = strtotime((string)($b['withdrawal_date'] ?? '')) ?: PHP_INT_MAX;
if ($leftDate !== $rightDate) {
return $leftDate <=> $rightDate;
}
return $this->compareGrades($a['grade'], $b['grade']);
});
// Combine all students for proper fee tiering before withdrawal.
$allStudents = array_merge($registeredStudents, $withdrawnStudents);
// Sort all students by grade for correct tiering
usort($allStudents, function ($a, $b) {
return $this->compareGrades($a['grade'], $b['grade']);
});
// Retrieve fee configs
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 380);
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 280);
$refundFeeStack = $this->reverseTuitionRefundFeeStack(
count($allStudents),
count($registeredStudents),
$firstStudentFee,
$secondStudentFee
);
// Calculate refund for withdrawn students
$refundAmount = 0;
$withdrawnRefundIndex = 0;
foreach ($withdrawnStudents as $student) {
if (empty($student['withdrawal_date'])) {
log_message('warning', "Missing withdraw date for student ID: {$student['student_id']}");
foreach ($students as $student) {
if ((int) ($student['parent_id'] ?? $parentId) !== $parentId) {
continue;
}
$withdrawDate = date('Y-m-d', strtotime($student['withdrawal_date']));
if (strtotime($withdrawDate) > strtotime($refundDeadline)) {
log_message('info', "Withdraw date {$withdrawDate} is after refund deadline {$refundDeadline}. No refund for this student.");
$status = strtolower(trim((string) ($student['enrollment_status'] ?? '')));
if (! in_array($status, ['withdrawn', 'refund pending', 'withdraw under review'], true)) {
continue;
}
$withdrawDateObj = new \DateTime($withdrawDate);
$schoolEndDateObj = new \DateTime($schoolEndDate);
$daysRemaining = $withdrawDateObj->diff($schoolEndDateObj)->days;
$weeksRemaining = min($weekOfStudy, max(0, ceil($daysRemaining / 7)));
$enrollmentId = (int) ($student['enrollment_id'] ?? $student['id'] ?? 0);
if ($enrollmentId <= 0 || isset($seenEnrollmentIds[$enrollmentId])) {
continue;
}
$seenEnrollmentIds[$enrollmentId] = true;
$studentFee = (float) ($refundFeeStack[$withdrawnRefundIndex] ?? 0);
$withdrawnRefundIndex++;
$proportionalRefund = ($studentFee / $weekOfStudy) * $weeksRemaining;
$refundAmount += $proportionalRefund;
$calculation = $this->latestWithdrawalCalculation($enrollmentId);
if ($calculation === null || ($calculation['status'] ?? '') === 'superseded') {
$calculation = $this->previewWithdrawalCalculation($enrollmentId);
}
log_message('info', "Student ID {$student['student_id']} refund portion: {$proportionalRefund} of {$studentFee} for {$weeksRemaining} weeks.");
$totalCents += max(0, (int) ($calculation['new_refund_request_cents'] ?? 0));
}
if ($refundAmount > $totalPaid) {
log_message('info', "Refund capped at total paid amount: {$totalPaid}");
return $totalPaid;
}
return round($totalCents / 100, 2);
}
log_message('info', "Final calculated refund: {$refundAmount}");
return $refundAmount;
protected function latestWithdrawalCalculation(int $enrollmentId): ?array
{
return service('withdrawalFinancial')->latestForEnrollment($enrollmentId);
}
protected function previewWithdrawalCalculation(int $enrollmentId): array
{
return service('withdrawalFinancial')->preview($enrollmentId, (int) (session()->get('user_id') ?? 0) ?: null);
}
/**
+210
View File
@@ -79,6 +79,11 @@ final class SchoolYearClosingService
$findings[] = $this->finding('blocking', 'Invoices missing school year', 'Some invoice records are not assigned to a school year.');
}
$inventory = $this->inventoryClosingPreview($sourceName, $target !== null ? (string) ($target['name'] ?? '') : '');
foreach ($inventory['findings'] as $finding) {
$findings[] = $finding;
}
$carryForward = $this->carryForwardFamilies($sourceName);
$warnings = array_values(array_filter($findings, static fn (array $f): bool => $f['severity'] === 'warning'));
$blockers = array_values(array_filter($findings, static fn (array $f): bool => $f['severity'] === 'blocking'));
@@ -89,6 +94,7 @@ final class SchoolYearClosingService
'overview' => $overview,
'finance' => $finance,
'promotion' => $promotion,
'inventory' => $inventory,
'findings' => $findings,
'blockers' => $blockers,
'warnings' => $warnings,
@@ -198,6 +204,7 @@ final class SchoolYearClosingService
'error_message' => null,
]);
}
$this->executeInventoryCarryForward($source, $target, (int) $batch['id'], $userId);
if (($batch['status'] ?? '') !== 'completed') {
$this->batchModel->update((int) $batch['id'], ['status' => 'executed']);
}
@@ -239,6 +246,19 @@ final class SchoolYearClosingService
$this->db->transStart();
$now = date('Y-m-d H:i:s');
if ($this->db->tableExists('inventory_item_years')) {
$this->db->table('inventory_item_years')
->where('school_year', (string) ($target['name'] ?? ''))
->where('source_item_year_id IS NOT NULL', null, false)
->where('closing_batch_id', (int) $batch['id'])
->where('status', 'carried')
->update(['status' => 'open', 'updated_at' => $now, 'updated_by' => $userId]);
$this->db->table('inventory_item_years')
->where('school_year', (string) ($this->requireYear($sourceYearId)['name'] ?? ''))
->where('closing_batch_id', (int) $batch['id'])
->where('status', 'carried')
->update(['status' => 'closed', 'updated_at' => $now, 'updated_by' => $userId]);
}
$this->batchModel->update((int) $batch['id'], [
'status' => 'completed',
'completed_by' => $userId,
@@ -459,6 +479,195 @@ final class SchoolYearClosingService
]);
}
private function inventoryClosingPreview(string $sourceYear, string $targetYear): array
{
$empty = ['rows' => [], 'summary' => ['books' => 0, 'target_opening_quantity' => 0], 'findings' => []];
if (! $this->db->tableExists('inventory_item_years') || ! $this->db->tableExists('inventory_items')) {
return $empty;
}
$itemYears = $this->db->table('inventory_item_years iy')
->select('iy.*, i.name AS item_name, i.isbn, i.edition')
->join('inventory_items i', 'i.id = iy.inventory_item_id', 'inner')
->where('iy.school_year', $sourceYear)
->where('i.type', 'book')
->orderBy('i.name', 'ASC')
->get()->getResultArray();
if ($itemYears === []) {
return $empty;
}
$ids = array_map(static fn (array $row): int => (int) $row['id'], $itemYears);
$movementTotals = [];
$issueCounts = [];
if ($this->db->tableExists('inventory_movements')) {
foreach ($this->db->table('inventory_movements')
->select('item_year_id, COALESCE(SUM(qty_change), 0) AS movement_total')
->whereIn('item_year_id', $ids)
->whereIn('status', ['posted', 'reversed'])
->groupBy('item_year_id')
->get()->getResultArray() as $row) {
$movementTotals[(int) $row['item_year_id']] = (int) ($row['movement_total'] ?? 0);
}
}
if ($this->db->tableExists('student_book_issues')) {
foreach ($this->db->table('student_book_issues')
->select('inventory_item_year_id, COUNT(*) AS issue_count')
->whereIn('inventory_item_year_id', $ids)
->where('school_year', $sourceYear)
->groupBy('inventory_item_year_id')
->get()->getResultArray() as $row) {
$issueCounts[(int) $row['inventory_item_year_id']] = (int) ($row['issue_count'] ?? 0);
}
}
$findings = [];
$rows = [];
$targetOpeningTotal = 0;
foreach ($itemYears as $row) {
$opening = (int) ($row['opening_quantity'] ?? 0);
$system = $opening + (int) ($movementTotals[(int) $row['id']] ?? 0);
$hasIssueSnapshots = (int) ($issueCounts[(int) $row['id']] ?? 0) > 0;
$counted = $row['counted_closing_quantity'];
$countedInt = $counted === null ? ($hasIssueSnapshots ? null : $system) : (int) $counted;
$variance = $countedInt === null ? null : $countedInt - $system;
$price = (int) ($row['charge_price_cents'] ?? 0);
if ($price <= 0 || (int) ($row['price_confirmed'] ?? 0) !== 1) {
$findings[] = $this->finding(
$hasIssueSnapshots ? 'blocking' : 'warning',
'Book price missing',
(string) $row['item_name'] . ' has no confirmed charge price for ' . $sourceYear
. ($hasIssueSnapshots ? '.' : '; this bootstrap year has no issue price snapshots, so confirm the target-year price before future distribution.')
);
}
if ($system < 0) {
$findings[] = $this->finding('blocking', 'Negative book stock', (string) $row['item_name'] . ' calculates to negative stock.');
}
if ($counted === null && ! $hasIssueSnapshots) {
$findings[] = $this->finding('warning', 'Physical book count defaulted', (string) $row['item_name'] . ' has no physical count in this bootstrap year; system closing quantity will be carried forward.');
} elseif ($countedInt === null) {
$findings[] = $this->finding('blocking', 'Physical book count missing', (string) $row['item_name'] . ' needs a counted closing quantity.');
} elseif ($variance !== 0) {
$findings[] = $this->finding('blocking', 'Unresolved book variance', (string) $row['item_name'] . ' has variance ' . $variance . '. Resolve with an audited adjustment before closing.');
}
$targetOpening = max(0, $countedInt ?? 0);
$targetOpeningTotal += $targetOpening;
$rows[] = [
'item_year_id' => (int) $row['id'],
'inventory_item_id' => (int) $row['inventory_item_id'],
'item_name' => (string) $row['item_name'],
'isbn' => (string) ($row['isbn'] ?? ''),
'edition' => (string) ($row['edition'] ?? ''),
'opening_quantity' => $opening,
'movement_total' => (int) ($movementTotals[(int) $row['id']] ?? 0),
'system_closing_quantity' => $system,
'counted_closing_quantity' => $countedInt,
'variance_quantity' => $variance,
'charge_price_cents' => $price,
'target_school_year' => $targetYear,
'target_opening_quantity' => $targetOpening,
];
}
foreach ($this->inventoryEvidenceFindings($sourceYear) as $finding) {
$findings[] = $finding;
}
return [
'rows' => $rows,
'summary' => ['books' => count($rows), 'target_opening_quantity' => $targetOpeningTotal],
'findings' => $findings,
];
}
private function inventoryEvidenceFindings(string $sourceYear): array
{
if (! $this->db->tableExists('inventory_movements') || ! $this->db->tableExists('student_book_issues')) {
return [];
}
$findings = [];
$hasAnyIssueSnapshots = $this->db->table('student_book_issues')
->where('school_year', $sourceYear)
->countAllResults() > 0;
$missingIssues = $this->db->table('inventory_movements m')
->join('student_book_issues sbi', 'sbi.distribution_movement_id = m.id', 'left')
->where('m.school_year', $sourceYear)
->where('m.movement_type', 'distribution')
->where('m.status', 'posted')
->where('sbi.id IS NULL', null, false)
->countAllResults();
if ($missingIssues > 0) {
$findings[] = $this->finding(
$hasAnyIssueSnapshots ? 'blocking' : 'warning',
'Distribution movement missing issue snapshot',
$missingIssues . ' legacy book distribution movement(s) have no linked student book issue'
. ($hasAnyIssueSnapshots ? '.' : '; this bootstrap year will not use those movements as refund price evidence.')
);
}
$missingMovements = $this->db->table('student_book_issues sbi')
->join('inventory_movements m', 'm.id = sbi.distribution_movement_id', 'left')
->where('sbi.school_year', $sourceYear)
->where('sbi.status', 'issued')
->where('m.id IS NULL', null, false)
->countAllResults();
if ($missingMovements > 0) {
$findings[] = $this->finding('blocking', 'Student issue missing stock movement', $missingMovements . ' student book issue(s) have no linked stock movement.');
}
return $findings;
}
private function executeInventoryCarryForward(array $source, array $target, int $batchId, ?int $userId): void
{
if (! $this->db->tableExists('inventory_item_years')) {
return;
}
$preview = $this->inventoryClosingPreview((string) $source['name'], (string) $target['name']);
$blockers = array_values(array_filter($preview['findings'], static fn (array $finding): bool => ($finding['severity'] ?? '') === 'blocking'));
if ($blockers !== []) {
throw new InvalidArgumentException('Resolve inventory closing blockers before executing carry-forward.');
}
$now = date('Y-m-d H:i:s');
foreach ($preview['rows'] as $row) {
$targetOpening = (int) ($row['target_opening_quantity'] ?? 0);
if ($targetOpening <= 0) {
continue;
}
$exists = $this->db->table('inventory_item_years')
->where('inventory_item_id', (int) $row['inventory_item_id'])
->where('school_year', (string) $target['name'])
->get(1)->getRowArray();
if ($exists === null) {
$this->db->table('inventory_item_years')->insert([
'inventory_item_id' => (int) $row['inventory_item_id'],
'school_year_id' => (int) $target['id'],
'school_year' => (string) $target['name'],
'opening_quantity' => $targetOpening,
'charge_price_cents' => (int) $row['charge_price_cents'],
'currency' => 'USD',
'price_confirmed' => 0,
'status' => 'carried',
'source_item_year_id' => (int) $row['item_year_id'],
'closing_batch_id' => $batchId,
'created_by' => $userId,
'updated_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
]);
}
$this->db->table('inventory_item_years')->where('id', (int) $row['item_year_id'])->update([
'system_closing_quantity' => (int) $row['system_closing_quantity'],
'variance_quantity' => 0,
'status' => 'carried',
'closing_batch_id' => $batchId,
'updated_by' => $userId,
'updated_at' => $now,
]);
}
}
private function countExistingTargetInvoices(array $batch): int
{
$batchId = (int) ($batch['id'] ?? 0);
@@ -1120,6 +1329,7 @@ final class SchoolYearClosingService
'target_id' => $preview['target']['id'] ?? null,
'finance' => $preview['finance'],
'promotion' => $preview['promotion'],
'inventory' => $preview['inventory'] ?? [],
'carry_forward' => $preview['carry_forward'],
'blockers' => $preview['blockers'],
], JSON_UNESCAPED_SLASHES));
@@ -163,6 +163,12 @@ final class SchoolYearManagementService
if (! SchoolYearStatus::canTransition($from, SchoolYearStatus::ACTIVE)) {
throw new InvalidArgumentException('Only draft or approved reopened school years can be activated.');
}
if ($this->configurationTotalInstructionalWeeks() <= 0) {
throw new InvalidArgumentException('Set total instructional weeks before activating this school year.');
}
if ((int) ($year['annual_fee_includes_books'] ?? 1) !== 1) {
throw new InvalidArgumentException('The withdrawal refund policy requires annual tuition to include books.');
}
$this->db->transStart();
$activeYears = $this->schoolYearModel->where('status', SchoolYearStatus::ACTIVE)->findAll();
@@ -348,6 +354,9 @@ final class SchoolYearManagementService
'registration_starts_on' => $this->nullableDate($payload['registration_starts_on'] ?? null),
'registration_ends_on' => $this->nullableDate($payload['registration_ends_on'] ?? null),
'fall_makeup_exam_on' => $this->nullableDate($payload['fall_makeup_exam_on'] ?? null),
'total_instructional_weeks' => $this->nullableInt($payload['total_instructional_weeks'] ?? null),
'annual_fee_includes_books' => 1,
'withdrawal_policy_version' => trim((string) ($payload['withdrawal_policy_version'] ?? 'studied_weeks_v1')) ?: 'studied_weeks_v1',
'previous_school_year_id' => $this->nullableInt($payload['previous_school_year_id'] ?? null),
];
}
@@ -377,6 +386,7 @@ final class SchoolYearManagementService
$configValues = [
'school_year' => $name,
'total_instructional_weeks' => (string) ($schoolYear['total_instructional_weeks'] ?? ''),
'date_age_reference' => $ageReferenceDate,
'refund_deadline' => $ageReferenceDate,
'school_year_start_date' => $yearStart,
@@ -409,6 +419,13 @@ final class SchoolYearManagementService
}
}
private function configurationTotalInstructionalWeeks(): int
{
$weeks = filter_var($this->configurationModel->getConfig('total_instructional_weeks'), FILTER_VALIDATE_INT);
return $weeks !== false && $weeks > 0 ? (int) $weeks : 0;
}
private function withCalendarDefaults(array $payload, string $schoolYearName): array
{
$calendar = $this->calendarPayloadForSchoolYear($schoolYearName);
@@ -42,6 +42,14 @@ final class SchoolYearValidationService
}
$this->dateOrNull($payload['fall_makeup_exam_on'] ?? null);
$weeks = $payload['total_instructional_weeks'] ?? null;
if ($weeks !== null && trim((string) $weeks) !== '') {
$parsed = filter_var($weeks, FILTER_VALIDATE_INT);
if ($parsed === false || $parsed <= 0) {
throw new InvalidArgumentException('Total instructional weeks must be a positive whole number.');
}
}
}
public function isValidYearName(string $value): bool
+403
View File
@@ -0,0 +1,403 @@
<?php
namespace App\Services;
use CodeIgniter\Database\BaseConnection;
use InvalidArgumentException;
use RuntimeException;
use Throwable;
final class StudentBookIssueService
{
public function __construct(private readonly BaseConnection $db)
{
}
/**
* Atomically distributes one copy of a book to each selected student.
* Existing active issues are skipped; every new issue snapshots the price.
*
* @param list<int> $studentIds
* @return array{issued:int,skipped:int,issue_ids:list<int>,unit_charge_price_cents:int,on_hand:int}
*/
public function distributeBatch(
int $inventoryItemId,
array $studentIds,
int $classSectionId,
int $schoolYearId,
string $schoolYear,
?int $actorId,
?string $note = null,
?string $issuedAt = null,
?string $batchKey = null
): array {
$this->assertTables();
$schoolYear = trim($schoolYear);
if ($inventoryItemId <= 0 || $classSectionId <= 0 || $schoolYearId <= 0 || $schoolYear === '') {
throw new InvalidArgumentException('Book, class section, and school year are required.');
}
$studentIds = array_values(array_unique(array_filter(array_map('intval', $studentIds), static fn (int $id): bool => $id > 0)));
if ($studentIds === []) {
return ['issued' => 0, 'skipped' => 0, 'issue_ids' => [], 'unit_charge_price_cents' => 0, 'on_hand' => $this->legacyOnHand($inventoryItemId)];
}
$issuedAt = $issuedAt !== null ? trim($issuedAt) : date('Y-m-d H:i:s');
if (! preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $issuedAt)) {
throw new InvalidArgumentException('Issue time must use Y-m-d H:i:s.');
}
$batchKey = trim((string) $batchKey);
if ($batchKey === '') {
$batchKey = hash('sha256', implode('|', [
$schoolYear, $inventoryItemId, $classSectionId, $actorId ?? 0,
$issuedAt, implode(',', $studentIds),
]));
}
$this->db->transBegin();
try {
$book = $this->db->query(
'SELECT id, type, name, is_active FROM inventory_items WHERE id = ? FOR UPDATE',
[$inventoryItemId]
)->getRowArray();
if ($book === null || ($book['type'] ?? '') !== 'book' || (int) ($book['is_active'] ?? 1) !== 1) {
throw new InvalidArgumentException('The selected book is missing or inactive.');
}
$itemYear = $this->db->query(
'SELECT * FROM inventory_item_years WHERE inventory_item_id = ? AND school_year_id = ? AND school_year = ? FOR UPDATE',
[$inventoryItemId, $schoolYearId, $schoolYear]
)->getRowArray();
if ($itemYear === null || ($itemYear['status'] ?? '') !== 'open') {
throw new InvalidArgumentException('The selected book does not have an open inventory record for this school year.');
}
$priceCents = (int) ($itemYear['charge_price_cents'] ?? 0);
if ($priceCents <= 0 || (int) ($itemYear['price_confirmed'] ?? 0) !== 1) {
throw new InvalidArgumentException('Enter and confirm this books school-year charge price before distribution.');
}
$enrollments = [];
$toIssue = [];
$skipped = 0;
foreach ($studentIds as $studentId) {
$existing = $this->db->table('student_book_issues')
->select('id')
->where('student_id', $studentId)
->where('inventory_item_year_id', (int) $itemYear['id'])
->where('status', 'issued')
->get(1)
->getRowArray();
if ($existing !== null) {
$skipped++;
continue;
}
$enrollment = $this->db->table('enrollments')
->select('id, parent_id, class_section_id')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->orderBy('id', 'DESC')
->get(1)
->getRowArray();
if ($enrollment === null || (int) ($enrollment['parent_id'] ?? 0) <= 0) {
throw new InvalidArgumentException('Student #' . $studentId . ' has no valid enrollment for ' . $schoolYear . '.');
}
if ((int) ($enrollment['class_section_id'] ?? 0) > 0
&& (int) $enrollment['class_section_id'] !== $classSectionId) {
throw new InvalidArgumentException('Student #' . $studentId . ' is not enrolled in the selected class section.');
}
$enrollments[$studentId] = $enrollment;
$toIssue[] = $studentId;
}
$onHand = $this->onHandForItemYear((int) $itemYear['id'], (int) $itemYear['opening_quantity']);
if (count($toIssue) > $onHand) {
throw new InvalidArgumentException('Not enough stock: need ' . count($toIssue) . ', on hand ' . $onHand . '.');
}
$issueIds = [];
foreach ($toIssue as $studentId) {
$enrollment = $enrollments[$studentId];
$idempotencyKey = substr($batchKey . ':student:' . $studentId, 0, 160);
$existingKey = $this->db->table('student_book_issues')
->select('id')
->where('idempotency_key', $idempotencyKey)
->get(1)
->getRowArray();
if ($existingKey !== null) {
$skipped++;
continue;
}
$now = date('Y-m-d H:i:s');
$issue = [
'student_id' => $studentId,
'enrollment_id' => (int) $enrollment['id'],
'parent_id' => (int) $enrollment['parent_id'],
'inventory_item_id' => $inventoryItemId,
'inventory_item_year_id' => (int) $itemYear['id'],
'school_year' => $schoolYear,
'class_section_id' => $classSectionId,
'quantity' => 1,
'unit_charge_price_cents' => $priceCents,
'total_charge_cents' => $priceCents,
'idempotency_key' => $idempotencyKey,
'status' => 'issued',
'issued_at' => $issuedAt,
'issued_by' => $actorId,
'created_at' => $now,
'updated_at' => $now,
];
if (! $this->db->table('student_book_issues')->insert($issue)) {
throw new RuntimeException('Unable to create a student book issue.');
}
$issueId = (int) $this->db->insertID();
$movement = [
'item_id' => $inventoryItemId,
'item_year_id' => (int) $itemYear['id'],
'qty_change' => -1,
'movement_type' => 'distribution',
'reason' => 'Student book distribution',
'note' => $note,
'semester' => null,
'school_year' => $schoolYear,
'performed_by' => $actorId,
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'idempotency_key' => substr($idempotencyKey . ':movement', 0, 120),
'status' => 'posted',
'source_type' => 'student_book_issue',
'source_id' => $issueId,
'created_at' => $now,
'updated_at' => $now,
];
if (! $this->db->table('inventory_movements')->insert($movement)) {
throw new RuntimeException('Unable to create the issue stock movement.');
}
$movementId = (int) $this->db->insertID();
if (! $this->db->table('student_book_issues')->where('id', $issueId)->update([
'distribution_movement_id' => $movementId,
'updated_at' => $now,
])) {
throw new RuntimeException('Unable to link the issue to its stock movement.');
}
$issueIds[] = $issueId;
}
$onHand -= count($issueIds);
$this->db->table('inventory_items')->where('id', $inventoryItemId)->update([
'quantity' => max(0, $onHand),
'updated_by' => $actorId,
'updated_at' => date('Y-m-d H:i:s'),
]);
if (! $this->db->transCommit()) {
throw new RuntimeException('Unable to commit the book distribution.');
}
return [
'issued' => count($issueIds),
'skipped' => $skipped,
'issue_ids' => $issueIds,
'unit_charge_price_cents' => $priceCents,
'on_hand' => $onHand,
];
} catch (Throwable $e) {
$this->db->transRollback();
throw $e;
}
}
public function reverseErroneousIssue(int $issueId, string $reason, ?int $actorId): void
{
$reason = trim($reason);
if ($issueId <= 0 || $reason === '') {
throw new InvalidArgumentException('Issue and correction reason are required.');
}
$this->db->transBegin();
try {
$issue = $this->db->query('SELECT * FROM student_book_issues WHERE id = ? FOR UPDATE', [$issueId])->getRowArray();
if ($issue === null || ($issue['status'] ?? '') !== 'issued') {
throw new InvalidArgumentException('Only an active issue can be corrected.');
}
$itemYear = $this->db->query('SELECT * FROM inventory_item_years WHERE id = ? FOR UPDATE', [
(int) $issue['inventory_item_year_id'],
])->getRowArray();
if ($itemYear === null || ($itemYear['status'] ?? '') !== 'open') {
throw new InvalidArgumentException('Corrections are not allowed after the inventory year is closed.');
}
$now = date('Y-m-d H:i:s');
$quantity = (int) $issue['quantity'];
$movement = [
'item_id' => (int) $issue['inventory_item_id'],
'item_year_id' => (int) $issue['inventory_item_year_id'],
'qty_change' => $quantity,
'movement_type' => 'adjust',
'reason' => 'Correction of erroneous book issue',
'note' => $reason,
'school_year' => (string) $issue['school_year'],
'performed_by' => $actorId,
'student_id' => (int) $issue['student_id'],
'class_section_id' => (int) ($issue['class_section_id'] ?? 0) ?: null,
'reversal_of_movement_id' => (int) ($issue['distribution_movement_id'] ?? 0) ?: null,
'idempotency_key' => substr('student-book-issue-reversal:' . $issueId, 0, 120),
'status' => 'posted',
'source_type' => 'student_book_issue_reversal',
'source_id' => $issueId,
'created_at' => $now,
'updated_at' => $now,
];
if (! $this->db->table('inventory_movements')->insert($movement)) {
throw new RuntimeException('Unable to create the correction movement.');
}
$reversalMovementId = (int) $this->db->insertID();
$this->db->table('student_book_issues')->where('id', $issueId)->update([
'status' => 'reversed',
'reversed_at' => $now,
'reversed_by' => $actorId,
'reversal_reason' => $reason,
'reversal_quantity' => $quantity,
'reversal_movement_id' => $reversalMovementId,
'updated_at' => $now,
]);
if ((int) ($issue['distribution_movement_id'] ?? 0) > 0) {
$this->db->table('inventory_movements')
->where('id', (int) $issue['distribution_movement_id'])
->update(['status' => 'reversed', 'reversed_at' => $now, 'reversed_by' => $actorId]);
}
$this->markPostedCalculationsForReview($issueId, (int) $issue['student_id'], (string) $issue['school_year'], $now);
$onHand = $this->onHandForItemYear((int) $itemYear['id'], (int) $itemYear['opening_quantity']);
$this->db->table('inventory_items')->where('id', (int) $issue['inventory_item_id'])->update([
'quantity' => max(0, $onHand),
'updated_by' => $actorId,
'updated_at' => $now,
]);
if (! $this->db->transCommit()) {
throw new RuntimeException('Unable to commit the issue correction.');
}
} catch (Throwable $e) {
$this->db->transRollback();
throw $e;
}
}
/** @return list<array<string,mixed>> */
public function activeIssuesAsOf(int $studentId, string $schoolYear, string $date): array
{
$dateSql = $this->db->escape($date);
return $this->db->table('student_book_issues sbi')
->select('sbi.*, i.name AS book_name, i.isbn, i.edition')
->join('inventory_items i', 'i.id = sbi.inventory_item_id', 'inner')
->where('sbi.student_id', $studentId)
->where('sbi.school_year', $schoolYear)
->where('sbi.status', 'issued')
->where('DATE(sbi.issued_at) <= ' . $dateSql, null, false)
->orderBy('sbi.issued_at', 'ASC')
->orderBy('sbi.id', 'ASC')
->get()
->getResultArray();
}
public function totalChargeCentsAsOf(int $studentId, string $schoolYear, string $date): int
{
$dateSql = $this->db->escape($date);
$row = $this->db->table('student_book_issues')
->selectSum('total_charge_cents', 'total')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('status', 'issued')
->where('DATE(issued_at) <= ' . $dateSql, null, false)
->get()
->getRowArray();
return (int) ($row['total'] ?? 0);
}
/** @return list<array<string,mixed>> */
public function issueEvidenceAsOf(int $studentId, string $schoolYear, string $date): array
{
$dateSql = $this->db->escape($date);
return $this->db->table('student_book_issues sbi')
->select('sbi.id, sbi.inventory_item_id, sbi.quantity, sbi.unit_charge_price_cents, sbi.total_charge_cents, sbi.status, sbi.issued_at, sbi.reversed_at, sbi.reversal_reason, sbi.reversal_quantity, sbi.reversal_movement_id, i.name AS book_name, i.isbn, i.edition')
->join('inventory_items i', 'i.id = sbi.inventory_item_id', 'inner')
->where('sbi.student_id', $studentId)
->where('sbi.school_year', $schoolYear)
->where('DATE(sbi.issued_at) <= ' . $dateSql, null, false)
->orderBy('sbi.issued_at', 'ASC')
->orderBy('sbi.id', 'ASC')
->get()
->getResultArray();
}
private function onHandForItemYear(int $itemYearId, int $openingQuantity): int
{
$row = $this->db->table('inventory_movements')
->selectSum('qty_change', 'movement_total')
->where('item_year_id', $itemYearId)
->whereIn('status', ['posted', 'reversed'])
->get()
->getRowArray();
return $openingQuantity + (int) ($row['movement_total'] ?? 0);
}
private function legacyOnHand(int $itemId): int
{
$row = $this->db->table('inventory_items')->select('quantity')->where('id', $itemId)->get(1)->getRowArray();
return (int) ($row['quantity'] ?? 0);
}
private function markPostedCalculationsForReview(int $issueId, int $studentId, string $schoolYear, string $now): void
{
if (! $this->db->tableExists('withdrawal_financial_calculations')) {
return;
}
$calculations = $this->db->table('withdrawal_financial_calculations')
->select('id, book_evidence_json')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('status', 'posted')
->get()
->getResultArray();
foreach ($calculations as $calculation) {
$evidence = json_decode((string) ($calculation['book_evidence_json'] ?? '[]'), true);
$ids = array_map('intval', array_column(is_array($evidence) ? $evidence : [], 'id'));
if (! in_array($issueId, $ids, true)) {
continue;
}
$calculationId = (int) $calculation['id'];
$this->db->table('withdrawal_financial_calculations')->where('id', $calculationId)->update([
'status' => 'requires_review',
'active_posted_key' => null,
'updated_at' => $now,
]);
if ($this->db->fieldExists('withdrawal_calculation_id', 'refunds')) {
$this->db->table('refunds')->where('withdrawal_calculation_id', $calculationId)->update([
'reconciliation_status' => 'requires_review',
'reconciliation_reason' => 'Book issue #' . $issueId . ' was corrected after posting.',
'reconciliation_required_at' => $now,
'updated_at' => $now,
]);
}
}
}
private function assertTables(): void
{
foreach (['inventory_item_years', 'student_book_issues', 'inventory_movements', 'enrollments'] as $table) {
if (! $this->db->tableExists($table)) {
throw new RuntimeException('Required table is missing: ' . $table . '. Run migrations first.');
}
}
}
}
+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.');
}
}
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
namespace App\Services;
use DateTimeImmutable;
use InvalidArgumentException;
/**
* Pure withdrawal calculation. All monetary values are integer cents.
*
* This class deliberately has no database, session, calendar, attendance, or
* active-school-year dependencies. Callers must pass snapshotted inputs.
*/
final class WithdrawalRefundCalculator
{
/**
* @param array{
* annual_fee_allocation_cents:int,
* issued_book_charge_cents:int,
* total_instructional_weeks:int,
* school_year_start_date:string,
* enrollment_date:string,
* withdrawal_request_date:string,
* valid_payment_cents?:int,
* completed_payout_cents?:int,
* open_reservation_cents?:int,
* other_charge_cents?:int,
* annual_fee_includes_books?:bool
* } $input
* @return array<string,int|bool|string>
*/
public function calculate(array $input): array
{
$annualFee = $this->nonNegativeInt($input, 'annual_fee_allocation_cents');
$bookCharge = $this->nonNegativeInt($input, 'issued_book_charge_cents');
$totalWeeks = $this->positiveInt($input, 'total_instructional_weeks');
$validPayments = $this->optionalNonNegativeInt($input, 'valid_payment_cents');
$completedPayouts = $this->optionalNonNegativeInt($input, 'completed_payout_cents');
$openReservations = $this->optionalNonNegativeInt($input, 'open_reservation_cents');
$otherCharges = $this->optionalNonNegativeInt($input, 'other_charge_cents');
$includesBooks = (bool) ($input['annual_fee_includes_books'] ?? true);
if (! $includesBooks) {
throw new InvalidArgumentException('The active withdrawal policy requires annual tuition to include books.');
}
if ($bookCharge > $annualFee) {
throw new InvalidArgumentException('Issued-book charges exceed the student annual tuition allocation.');
}
$schoolStart = $this->date($input, 'school_year_start_date');
$enrollmentDate = $this->date($input, 'enrollment_date');
$withdrawalDate = $this->date($input, 'withdrawal_request_date');
$chargeStart = $enrollmentDate > $schoolStart ? $enrollmentDate : $schoolStart;
$totalChargeableDays = $totalWeeks * 7;
$studiedDays = 0;
if ($withdrawalDate >= $chargeStart) {
$studiedDays = ((int) $chargeStart->diff($withdrawalDate)->format('%a')) + 1;
}
$studiedDays = min($totalChargeableDays, max(0, $studiedDays));
$studiedWeeks = $studiedDays === 0
? 0
: min($totalWeeks, intdiv($studiedDays + 6, 7));
$annualInstruction = $annualFee - $bookCharge;
$earnedTuition = $this->roundRatio($annualInstruction * $studiedWeeks, $totalWeeks);
$retainedCharge = $bookCharge + $earnedTuition + $otherCharges;
$netPayments = max(0, $validPayments - $completedPayouts);
$refundableCredit = max(0, $netPayments - $retainedCharge);
$balanceDue = max(0, $retainedCharge - $netPayments);
$newRefundRequest = max(0, $refundableCredit - $openReservations);
return [
'annual_fee_includes_books' => true,
'school_year_start_date' => $schoolStart->format('Y-m-d'),
'enrollment_date' => $enrollmentDate->format('Y-m-d'),
'withdrawal_request_date' => $withdrawalDate->format('Y-m-d'),
'charge_start_date' => $chargeStart->format('Y-m-d'),
'total_instructional_weeks' => $totalWeeks,
'total_chargeable_days' => $totalChargeableDays,
'studied_calendar_days' => $studiedDays,
'studied_weeks' => $studiedWeeks,
'annual_fee_allocation_cents' => $annualFee,
'issued_book_charge_cents' => $bookCharge,
'annual_instruction_cents' => $annualInstruction,
'earned_tuition_cents' => $earnedTuition,
'other_charge_cents' => $otherCharges,
'retained_charge_cents' => $retainedCharge,
'valid_payment_cents' => $validPayments,
'completed_payout_cents' => $completedPayouts,
'net_payment_cents' => $netPayments,
'open_reservation_cents' => $openReservations,
'refundable_credit_cents' => $refundableCredit,
'new_refund_request_cents' => $newRefundRequest,
'balance_due_cents' => $balanceDue,
];
}
private function date(array $input, string $key): DateTimeImmutable
{
$raw = trim((string) ($input[$key] ?? ''));
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $raw);
$errors = DateTimeImmutable::getLastErrors();
if ($date === false || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) {
throw new InvalidArgumentException($key . ' must be a valid Y-m-d date.');
}
return $date;
}
private function positiveInt(array $input, string $key): int
{
$value = filter_var($input[$key] ?? null, FILTER_VALIDATE_INT);
if ($value === false || $value <= 0) {
throw new InvalidArgumentException($key . ' must be a positive integer.');
}
return $value;
}
private function nonNegativeInt(array $input, string $key): int
{
$value = filter_var($input[$key] ?? null, FILTER_VALIDATE_INT);
if ($value === false || $value < 0) {
throw new InvalidArgumentException($key . ' must be a non-negative integer.');
}
return $value;
}
private function optionalNonNegativeInt(array $input, string $key): int
{
if (! array_key_exists($key, $input) || $input[$key] === null || $input[$key] === '') {
return 0;
}
return $this->nonNegativeInt($input, $key);
}
private function roundRatio(int $numerator, int $denominator): int
{
if ($denominator <= 0) {
throw new InvalidArgumentException('The calculation denominator must be positive.');
}
return intdiv($numerator + intdiv($denominator, 2), $denominator);
}
}