fix enrollment, invoice, payment and financila aid
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Failing after 1m19s

This commit is contained in:
root
2026-08-24 21:20:58 -04:00
parent 576da3bd78
commit 608aca79b8
30 changed files with 3327 additions and 774 deletions
+190
View File
@@ -173,6 +173,76 @@ class InvoiceLedgerService
return $this->recalculateInvoice($invoiceId);
}
/**
* Refresh frozen tuition invoice lines to match the current enrollment/class assignment state.
* Event and additional charge lines are left unchanged.
*/
public function syncTuitionLines(int $invoiceId): bool
{
$invoice = $this->loadInvoice($invoiceId);
if ($invoice === null || $this->isCarryForwardInvoice($invoice) || ! $this->invoiceLinesAvailable()) {
return false;
}
if (! $this->invoiceHasLines($invoiceId)) {
return false;
}
$currentTuitionCents = $this->toCents($this->calculateTuitionTotal($invoice));
$frozen = $this->calculateFrozenLineTotals($invoiceId);
$frozenTuitionCents = (int) ($frozen['tuition_cents'] ?? 0);
if ($currentTuitionCents === $frozenTuitionCents) {
return false;
}
$now = utc_now();
$this->invoiceLineModel()
->where('invoice_id', $invoiceId)
->where('voided_at IS NULL', null, false)
->groupStart()
->where('line_type', 'tuition')
->orWhere('source_type', 'tuition_calculation')
->groupEnd()
->set([
'voided_at' => $now,
'updated_at' => $now,
])
->update();
if ($currentTuitionCents === 0) {
return true;
}
$metadata = [
'parent_id' => (int) ($invoice['parent_id'] ?? 0),
'school_year' => (string) ($invoice['school_year'] ?? ''),
'semester' => (string) ($invoice['semester'] ?? ''),
'synced_at' => $now,
];
$lineId = $this->invoiceLineModel()->insert(
$this->buildInvoiceLineRow(
$invoiceId,
'tuition',
'tuition_calculation',
null,
'Tuition charges',
$currentTuitionCents,
1,
$this->getCalculationVersion(),
$metadata,
$now
)
);
if (! $lineId) {
throw new FinancialPersistenceException('INVOICE_TUITION_SYNC_FAILED', $this->invoiceLineModel()->errors());
}
return true;
}
public function getValidPaymentTotalCents(int $invoiceId): int
{
return $this->toCents($this->calculateValidPayments($invoiceId));
@@ -428,9 +498,129 @@ class InvoiceLedgerService
return str_contains($description, 'carried over')
|| str_contains($description, 'carry-forward')
|| str_contains($description, 'carry over')
|| str_contains($description, 'previous school year');
}
public function invoiceIsCarryForward(array $invoice): bool
{
return $this->isCarryForwardInvoice($invoice);
}
public function carryForwardDisplayDescription(array $invoice): string
{
$sourceYear = $this->carryForwardSourceSchoolYear($invoice);
if ($sourceYear !== '') {
return 'Carry over balance from last year ' . $sourceYear;
}
return 'Carry over balance from last year';
}
/**
* Issue a single carry-over line on an opening-balance invoice. No student names are used.
*/
public function issueCarryForwardInvoiceLine(int $invoiceId, float $amount, string $description): int
{
if ($invoiceId <= 0 || ! $this->invoiceLinesAvailable()) {
return 0;
}
$invoice = $this->loadInvoice($invoiceId);
if ($invoice === null || ! $this->isCarryForwardInvoice($invoice)) {
return 0;
}
$amountCents = $this->toCents($amount);
if ($amountCents === 0) {
return 0;
}
$description = trim($description);
if ($description === '') {
$description = $this->carryForwardDisplayDescription($invoice);
}
$existingLine = $this->invoiceLineModel()
->where('invoice_id', $invoiceId)
->where('source_type', 'carry_forward_opening_balance')
->where('voided_at IS NULL', null, false)
->first();
$now = utc_now();
$this->invoiceLineModel()
->where('invoice_id', $invoiceId)
->where('voided_at IS NULL', null, false)
->groupStart()
->where('line_type', 'tuition')
->orWhere('source_type', 'tuition_calculation')
->groupEnd()
->set([
'voided_at' => $now,
'updated_at' => $now,
])
->update();
if ($existingLine !== null) {
$this->invoiceLineModel()->update((int) $existingLine['id'], [
'description' => $description,
'unit_amount_cents' => $amountCents,
'line_amount_cents' => $amountCents,
'updated_at' => $now,
]);
return (int) $existingLine['id'];
}
$lineId = $this->invoiceLineModel()->insert(
$this->buildInvoiceLineRow(
$invoiceId,
'additional_charge',
'carry_forward_opening_balance',
$invoiceId,
$description,
$amountCents,
0,
$this->getCalculationVersion(),
['carry_forward' => true],
$now
)
);
if (! $lineId) {
throw new FinancialPersistenceException('INVOICE_CARRY_FORWARD_LINE_FAILED', $this->invoiceLineModel()->errors());
}
return (int) $lineId;
}
private function carryForwardSourceSchoolYear(array $invoice): string
{
$description = (string) ($invoice['description'] ?? '');
if (preg_match('/last year\s+(\d{4}-\d{4})/i', $description, $matches) === 1) {
return $matches[1];
}
if (preg_match('/previous school year\s+(\d{4}-\d{4})/i', $description, $matches) === 1) {
return $matches[1];
}
$invoiceNumber = (string) ($invoice['invoice_number'] ?? '');
if (preg_match('/^CF-(\d{8})-/i', $invoiceNumber, $matches) === 1) {
$compact = $matches[1];
if (strlen($compact) === 8) {
return substr($compact, 0, 4) . '-' . substr($compact, 4, 4);
}
}
$targetYear = trim((string) ($invoice['school_year'] ?? ''));
if (preg_match('/^(\d{4})-(\d{4})$/', $targetYear, $matches) === 1) {
return ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1);
}
return '';
}
protected function calculateTuitionTotal(array $invoice): float
{
$parentId = (int) ($invoice['parent_id'] ?? 0);