add refund logic and fix books inventory logic
This commit is contained in:
@@ -14,11 +14,11 @@ final class EnrollmentStatusService
|
||||
'payment pending',
|
||||
'enrolled',
|
||||
'withdraw under review',
|
||||
'refund pending',
|
||||
];
|
||||
|
||||
public const INACTIVE_STATUSES = [
|
||||
'denied',
|
||||
'refund pending',
|
||||
'withdrawn',
|
||||
'waitlist',
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 book’s school-year charge price before distribution.');
|
||||
}
|
||||
|
||||
$enrollments = [];
|
||||
$toIssue = [];
|
||||
$skipped = 0;
|
||||
foreach ($studentIds as $studentId) {
|
||||
$existing = $this->db->table('student_book_issues')
|
||||
->select('id')
|
||||
->where('student_id', $studentId)
|
||||
->where('inventory_item_year_id', (int) $itemYear['id'])
|
||||
->where('status', 'issued')
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
if ($existing !== null) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$enrollment = $this->db->table('enrollments')
|
||||
->select('id, parent_id, class_section_id')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('id', 'DESC')
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
if ($enrollment === null || (int) ($enrollment['parent_id'] ?? 0) <= 0) {
|
||||
throw new InvalidArgumentException('Student #' . $studentId . ' has no valid enrollment for ' . $schoolYear . '.');
|
||||
}
|
||||
if ((int) ($enrollment['class_section_id'] ?? 0) > 0
|
||||
&& (int) $enrollment['class_section_id'] !== $classSectionId) {
|
||||
throw new InvalidArgumentException('Student #' . $studentId . ' is not enrolled in the selected class section.');
|
||||
}
|
||||
$enrollments[$studentId] = $enrollment;
|
||||
$toIssue[] = $studentId;
|
||||
}
|
||||
|
||||
$onHand = $this->onHandForItemYear((int) $itemYear['id'], (int) $itemYear['opening_quantity']);
|
||||
if (count($toIssue) > $onHand) {
|
||||
throw new InvalidArgumentException('Not enough stock: need ' . count($toIssue) . ', on hand ' . $onHand . '.');
|
||||
}
|
||||
|
||||
$issueIds = [];
|
||||
foreach ($toIssue as $studentId) {
|
||||
$enrollment = $enrollments[$studentId];
|
||||
$idempotencyKey = substr($batchKey . ':student:' . $studentId, 0, 160);
|
||||
$existingKey = $this->db->table('student_book_issues')
|
||||
->select('id')
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->get(1)
|
||||
->getRowArray();
|
||||
if ($existingKey !== null) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$issue = [
|
||||
'student_id' => $studentId,
|
||||
'enrollment_id' => (int) $enrollment['id'],
|
||||
'parent_id' => (int) $enrollment['parent_id'],
|
||||
'inventory_item_id' => $inventoryItemId,
|
||||
'inventory_item_year_id' => (int) $itemYear['id'],
|
||||
'school_year' => $schoolYear,
|
||||
'class_section_id' => $classSectionId,
|
||||
'quantity' => 1,
|
||||
'unit_charge_price_cents' => $priceCents,
|
||||
'total_charge_cents' => $priceCents,
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'status' => 'issued',
|
||||
'issued_at' => $issuedAt,
|
||||
'issued_by' => $actorId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if (! $this->db->table('student_book_issues')->insert($issue)) {
|
||||
throw new RuntimeException('Unable to create a student book issue.');
|
||||
}
|
||||
$issueId = (int) $this->db->insertID();
|
||||
|
||||
$movement = [
|
||||
'item_id' => $inventoryItemId,
|
||||
'item_year_id' => (int) $itemYear['id'],
|
||||
'qty_change' => -1,
|
||||
'movement_type' => 'distribution',
|
||||
'reason' => 'Student book distribution',
|
||||
'note' => $note,
|
||||
'semester' => null,
|
||||
'school_year' => $schoolYear,
|
||||
'performed_by' => $actorId,
|
||||
'student_id' => $studentId,
|
||||
'class_section_id' => $classSectionId,
|
||||
'idempotency_key' => substr($idempotencyKey . ':movement', 0, 120),
|
||||
'status' => 'posted',
|
||||
'source_type' => 'student_book_issue',
|
||||
'source_id' => $issueId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if (! $this->db->table('inventory_movements')->insert($movement)) {
|
||||
throw new RuntimeException('Unable to create the issue stock movement.');
|
||||
}
|
||||
$movementId = (int) $this->db->insertID();
|
||||
if (! $this->db->table('student_book_issues')->where('id', $issueId)->update([
|
||||
'distribution_movement_id' => $movementId,
|
||||
'updated_at' => $now,
|
||||
])) {
|
||||
throw new RuntimeException('Unable to link the issue to its stock movement.');
|
||||
}
|
||||
$issueIds[] = $issueId;
|
||||
}
|
||||
|
||||
$onHand -= count($issueIds);
|
||||
$this->db->table('inventory_items')->where('id', $inventoryItemId)->update([
|
||||
'quantity' => max(0, $onHand),
|
||||
'updated_by' => $actorId,
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
if (! $this->db->transCommit()) {
|
||||
throw new RuntimeException('Unable to commit the book distribution.');
|
||||
}
|
||||
|
||||
return [
|
||||
'issued' => count($issueIds),
|
||||
'skipped' => $skipped,
|
||||
'issue_ids' => $issueIds,
|
||||
'unit_charge_price_cents' => $priceCents,
|
||||
'on_hand' => $onHand,
|
||||
];
|
||||
} catch (Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function reverseErroneousIssue(int $issueId, string $reason, ?int $actorId): void
|
||||
{
|
||||
$reason = trim($reason);
|
||||
if ($issueId <= 0 || $reason === '') {
|
||||
throw new InvalidArgumentException('Issue and correction reason are required.');
|
||||
}
|
||||
|
||||
$this->db->transBegin();
|
||||
try {
|
||||
$issue = $this->db->query('SELECT * FROM student_book_issues WHERE id = ? FOR UPDATE', [$issueId])->getRowArray();
|
||||
if ($issue === null || ($issue['status'] ?? '') !== 'issued') {
|
||||
throw new InvalidArgumentException('Only an active issue can be corrected.');
|
||||
}
|
||||
$itemYear = $this->db->query('SELECT * FROM inventory_item_years WHERE id = ? FOR UPDATE', [
|
||||
(int) $issue['inventory_item_year_id'],
|
||||
])->getRowArray();
|
||||
if ($itemYear === null || ($itemYear['status'] ?? '') !== 'open') {
|
||||
throw new InvalidArgumentException('Corrections are not allowed after the inventory year is closed.');
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$quantity = (int) $issue['quantity'];
|
||||
$movement = [
|
||||
'item_id' => (int) $issue['inventory_item_id'],
|
||||
'item_year_id' => (int) $issue['inventory_item_year_id'],
|
||||
'qty_change' => $quantity,
|
||||
'movement_type' => 'adjust',
|
||||
'reason' => 'Correction of erroneous book issue',
|
||||
'note' => $reason,
|
||||
'school_year' => (string) $issue['school_year'],
|
||||
'performed_by' => $actorId,
|
||||
'student_id' => (int) $issue['student_id'],
|
||||
'class_section_id' => (int) ($issue['class_section_id'] ?? 0) ?: null,
|
||||
'reversal_of_movement_id' => (int) ($issue['distribution_movement_id'] ?? 0) ?: null,
|
||||
'idempotency_key' => substr('student-book-issue-reversal:' . $issueId, 0, 120),
|
||||
'status' => 'posted',
|
||||
'source_type' => 'student_book_issue_reversal',
|
||||
'source_id' => $issueId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
if (! $this->db->table('inventory_movements')->insert($movement)) {
|
||||
throw new RuntimeException('Unable to create the correction movement.');
|
||||
}
|
||||
$reversalMovementId = (int) $this->db->insertID();
|
||||
|
||||
$this->db->table('student_book_issues')->where('id', $issueId)->update([
|
||||
'status' => 'reversed',
|
||||
'reversed_at' => $now,
|
||||
'reversed_by' => $actorId,
|
||||
'reversal_reason' => $reason,
|
||||
'reversal_quantity' => $quantity,
|
||||
'reversal_movement_id' => $reversalMovementId,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
if ((int) ($issue['distribution_movement_id'] ?? 0) > 0) {
|
||||
$this->db->table('inventory_movements')
|
||||
->where('id', (int) $issue['distribution_movement_id'])
|
||||
->update(['status' => 'reversed', 'reversed_at' => $now, 'reversed_by' => $actorId]);
|
||||
}
|
||||
|
||||
$this->markPostedCalculationsForReview($issueId, (int) $issue['student_id'], (string) $issue['school_year'], $now);
|
||||
|
||||
$onHand = $this->onHandForItemYear((int) $itemYear['id'], (int) $itemYear['opening_quantity']);
|
||||
$this->db->table('inventory_items')->where('id', (int) $issue['inventory_item_id'])->update([
|
||||
'quantity' => max(0, $onHand),
|
||||
'updated_by' => $actorId,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if (! $this->db->transCommit()) {
|
||||
throw new RuntimeException('Unable to commit the issue correction.');
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/** @return list<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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 student’s annual tuition allocation.';
|
||||
}
|
||||
|
||||
$ledger = null;
|
||||
$originalInvoiceCharge = 0;
|
||||
$validPayments = 0;
|
||||
$completedPayouts = 0;
|
||||
$otherCharges = 0;
|
||||
$baseGrossCharge = 0;
|
||||
$baseDiscountEligible = 0;
|
||||
$requestedDiscount = 0;
|
||||
if ($invoice !== null) {
|
||||
$this->lockInvoice((int) $invoice['id']);
|
||||
$ledger = $this->invoiceLedger->calculateInvoice((int) $invoice['id']);
|
||||
$existingTargetAdjustment = $this->db->table('invoice_lines il')
|
||||
->selectSum('il.line_amount_cents', 'total')
|
||||
->select("COALESCE(SUM(CASE WHEN il.discount_eligible = 1 THEN il.line_amount_cents ELSE 0 END), 0) AS eligible_total", false)
|
||||
->join('withdrawal_financial_calculations wfc', 'wfc.id = il.source_id AND il.source_type = \'withdrawal_calculation\'', 'inner')
|
||||
->where('il.invoice_id', (int) $invoice['id'])
|
||||
->where('wfc.enrollment_id', (int) $enrollment['id'])
|
||||
->where('il.voided_at', null)
|
||||
->get()->getRowArray();
|
||||
$baseGrossCharge = (int) ($ledger['gross_charge_cents'] ?? 0) - (int) ($existingTargetAdjustment['total'] ?? 0);
|
||||
$baseDiscountEligible = max(0, (int) ($ledger['discount_eligible_base_cents'] ?? 0) - (int) ($existingTargetAdjustment['eligible_total'] ?? 0));
|
||||
$requestedDiscount = max(0, (int) ($ledger['requested_discount_cents'] ?? 0));
|
||||
$originalInvoiceCharge = max(0, $baseGrossCharge - min($requestedDiscount, $baseDiscountEligible));
|
||||
$validPayments = (int) ($ledger['paidCents'] ?? 0);
|
||||
$completedPayouts = (int) ($ledger['completedRefundCents'] ?? 0);
|
||||
$otherCharges = max(0, (int) ($ledger['eventCents'] ?? 0) + (int) ($ledger['additionalCents'] ?? 0));
|
||||
foreach ($this->invoiceReconstructionBlockers((int) $invoice['id'], $allocation['original_family_tuition_cents'], $allocation['original_student_count'], $legacyConfirmed) as $blocker) {
|
||||
$blockers[] = $blocker;
|
||||
}
|
||||
}
|
||||
|
||||
$schoolStart = trim((string) ($year['starts_on'] ?? ''));
|
||||
if ($schoolStart === '') {
|
||||
$schoolStart = $enrollmentDate;
|
||||
$blockers[] = 'School-year start date is missing; the enrollment date was used only to display this blocked preview.';
|
||||
}
|
||||
$baseInput = [
|
||||
'annual_fee_allocation_cents' => $allocation['annual_allocation_cents'],
|
||||
'issued_book_charge_cents' => $bookCharge,
|
||||
'total_instructional_weeks' => max(1, $weeks),
|
||||
'school_year_start_date' => $schoolStart,
|
||||
'enrollment_date' => $enrollmentDate,
|
||||
'withdrawal_request_date' => $withdrawalDate,
|
||||
'annual_fee_includes_books' => true,
|
||||
];
|
||||
$studentCalculation = $this->calculator->calculate($baseInput);
|
||||
$adjustment = (int) $studentCalculation['retained_charge_cents'] - $allocation['annual_allocation_cents'];
|
||||
$newEligibleAdjustment = (int) $studentCalculation['earned_tuition_cents'] - $allocation['annual_allocation_cents'];
|
||||
$adjustedGrossCharge = $baseGrossCharge + $adjustment;
|
||||
$adjustedDiscountEligible = max(0, $baseDiscountEligible + $newEligibleAdjustment);
|
||||
$adjustedDiscount = min($requestedDiscount, $adjustedDiscountEligible);
|
||||
$adjustedInvoiceCharge = max(0, $adjustedGrossCharge - $adjustedDiscount);
|
||||
$netPayments = max(0, $validPayments - $completedPayouts);
|
||||
$refundableCredit = max(0, $netPayments - $adjustedInvoiceCharge);
|
||||
$balanceDue = max(0, $adjustedInvoiceCharge - $netPayments);
|
||||
// A withdrawal refund is one invoice-level family claim. Exclude that
|
||||
// claim when replacing it for a sibling, otherwise its old reservation
|
||||
// incorrectly suppresses the new family credit in the preview.
|
||||
$existingWithdrawalRefundId = $invoice === null ? null : $this->existingWithdrawalRefundId((int) $invoice['id']);
|
||||
$reservations = $invoice === null ? 0 : $this->openInvoiceReservations((int) $invoice['id'], $existingWithdrawalRefundId);
|
||||
$newRefund = max(0, $refundableCredit - $reservations);
|
||||
|
||||
$explanation = [
|
||||
'formula' => 'books + round((annual allocation - books) * studied weeks / total instructional weeks)',
|
||||
'no_school_days_subtracted' => false,
|
||||
'books_returnable' => false,
|
||||
'books_discount_eligible' => false,
|
||||
'allocation' => $allocation,
|
||||
'discount_projection' => [
|
||||
'requested_discount_cents' => $requestedDiscount,
|
||||
'adjusted_discount_eligible_cents' => $adjustedDiscountEligible,
|
||||
'adjusted_discount_cents' => $adjustedDiscount,
|
||||
'books_discount_eligible' => false,
|
||||
],
|
||||
'blockers' => $blockers,
|
||||
];
|
||||
$now = utc_now();
|
||||
$row = [
|
||||
'enrollment_id' => (int) $enrollment['id'],
|
||||
'student_id' => (int) $enrollment['student_id'],
|
||||
'parent_id' => (int) $enrollment['parent_id'],
|
||||
'invoice_id' => $invoice === null ? null : (int) $invoice['id'],
|
||||
'school_year' => (string) $enrollment['school_year'],
|
||||
'policy_version' => (string) ($year['withdrawal_policy_version'] ?? 'studied_weeks_v1'),
|
||||
'annual_fee_includes_books' => 1,
|
||||
'school_year_start_date' => $studentCalculation['school_year_start_date'],
|
||||
'enrollment_date' => $studentCalculation['enrollment_date'],
|
||||
'withdrawal_request_date' => $studentCalculation['withdrawal_request_date'],
|
||||
'total_instructional_weeks' => $weeks,
|
||||
'total_chargeable_days' => (int) $studentCalculation['total_chargeable_days'],
|
||||
'studied_calendar_days' => (int) $studentCalculation['studied_calendar_days'],
|
||||
'studied_weeks' => (int) $studentCalculation['studied_weeks'],
|
||||
'annual_fee_allocation_cents' => $allocation['annual_allocation_cents'],
|
||||
'issued_book_charge_cents' => $bookCharge,
|
||||
'annual_instruction_cents' => (int) $studentCalculation['annual_instruction_cents'],
|
||||
'earned_tuition_cents' => (int) $studentCalculation['earned_tuition_cents'],
|
||||
'other_charge_cents' => $otherCharges,
|
||||
'retained_charge_cents' => (int) $studentCalculation['retained_charge_cents'],
|
||||
'original_invoice_charge_cents' => $originalInvoiceCharge,
|
||||
'invoice_adjustment_cents' => $adjustment,
|
||||
'adjusted_invoice_charge_cents' => $adjustedInvoiceCharge,
|
||||
'valid_payment_cents' => $validPayments,
|
||||
'completed_payout_cents' => $completedPayouts,
|
||||
'open_reservation_cents' => $reservations,
|
||||
'refundable_credit_cents' => $refundableCredit,
|
||||
'new_refund_request_cents' => $newRefund,
|
||||
'balance_due_cents' => $balanceDue,
|
||||
'book_evidence_json' => json_encode($books, JSON_UNESCAPED_SLASHES),
|
||||
'explanation_json' => json_encode($explanation, JSON_UNESCAPED_SLASHES),
|
||||
'calculation_hash' => '',
|
||||
'active_posted_key' => null,
|
||||
'books_discount_eligible' => 0,
|
||||
'status' => $blockers === [] ? 'preview' : 'requires_review',
|
||||
'override_reason' => $overrideReason !== '' ? $overrideReason : null,
|
||||
'overridden_at' => $dateChanged || $legacyConfirmed ? $now : null,
|
||||
'overridden_by' => $dateChanged || $legacyConfirmed ? $actorId : null,
|
||||
'calculated_by' => $actorId,
|
||||
'calculated_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
$row['calculation_hash'] = $this->snapshotHash($row, $books, $explanation);
|
||||
|
||||
return ['row' => $row, 'books' => $books, 'blockers' => $blockers, 'explanation' => $explanation];
|
||||
}
|
||||
|
||||
/** @return array<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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user