fix invoice and enrollment fees
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Failing after 1m19s

This commit is contained in:
root
2026-08-27 18:34:36 -04:00
parent 0ae9993d82
commit 38644b32ae
29 changed files with 1836 additions and 1555 deletions
+172 -343
View File
@@ -12,7 +12,7 @@ use App\Models\DiscountUsageModel;
use App\Models\EnrollmentModel;
use App\Models\EventChargesModel;
use App\Models\InvoiceEventModel;
use App\Models\InvoiceLineModel;
use App\Models\InvoiceStudentListModel;
use App\Models\InvoiceModel;
use App\Models\PaymentModel;
use App\Models\RefundModel;
@@ -34,7 +34,7 @@ class InvoiceLedgerService
protected ClassSectionModel $classSectionModel;
protected EventChargesModel $eventChargesModel;
protected InvoiceEventModel $invoiceEventModel;
protected ?InvoiceLineModel $invoiceLineModel = null;
protected InvoiceStudentListModel $invoiceStudentListModel;
protected StudentModel $studentModel;
protected TuitionCalculatorInterface $oldCalculator;
protected TuitionCalculatorInterface $newCalculator;
@@ -53,7 +53,7 @@ class InvoiceLedgerService
$this->classSectionModel = new ClassSectionModel();
$this->eventChargesModel = new EventChargesModel();
$this->invoiceEventModel = new InvoiceEventModel();
$this->invoiceLineModel = new InvoiceLineModel();
$this->invoiceStudentListModel = new InvoiceStudentListModel();
$this->studentModel = new StudentModel();
$this->oldCalculator = new OldTuitionCalculatorService();
$this->newCalculator = new NewTuitionCalculatorService();
@@ -145,8 +145,78 @@ class InvoiceLedgerService
];
}
public function storedInvoiceLedger(int $invoiceId): array
{
$invoice = $this->loadInvoice($invoiceId);
if ($invoice === null) {
throw new \RuntimeException('Invoice not found.');
}
$totalAmountCents = $this->toCents((float) ($invoice['total_amount'] ?? 0));
$paidCents = $this->toCents((float) ($invoice['paid_amount'] ?? $this->calculateValidPayments($invoiceId)));
$balanceCents = $this->toCents((float) ($invoice['balance'] ?? 0));
$refundPaidCents = $this->toCents($this->calculatePaidRefunds($invoiceId));
$discountCents = 0;
if ($this->invoiceModel->db->fieldExists('discount', $this->invoiceModel->table)) {
$discountCents = $this->toCents((float) ($invoice['discount'] ?? 0));
}
if ($discountCents === 0) {
$discountCents = $this->toCents($this->calculateDiscounts($invoiceId));
}
$netChargeCents = max(0, $totalAmountCents - $discountCents);
$rawBalanceCents = $balanceCents;
$customerCreditCents = max(0, -1 * $balanceCents);
$balanceDueCents = max(0, $balanceCents);
$status = (string) ($invoice['status'] ?? '');
if ($status === '') {
if ($balanceDueCents === 0) {
$status = FinancialStatus::INVOICE_PAID;
} elseif ($paidCents > 0 || $discountCents > 0 || $refundPaidCents > 0) {
$status = FinancialStatus::INVOICE_PARTIALLY_PAID;
} else {
$status = FinancialStatus::INVOICE_UNPAID;
}
}
return [
'invoice_id' => $invoiceId,
'gross_charge_cents' => $totalAmountCents,
'discount_eligible_base_cents' => $totalAmountCents,
'requested_discount_cents' => $discountCents,
'applied_discount_cents' => $discountCents,
'net_charge_cents' => $netChargeCents,
'totalAmountCents' => $totalAmountCents,
'discountCents' => $discountCents,
'paidCents' => $paidCents,
'completedRefundCents' => $refundPaidCents,
'rawBalanceCents' => $rawBalanceCents,
'balanceDueCents' => $balanceDueCents,
'customerCreditCents' => $customerCreditCents,
'tuition_total' => '0.00',
'event_total' => '0.00',
'additional_total' => '0.00',
'discount_total' => $this->fromCents($discountCents),
'discount_raw_total' => $this->fromCents($discountCents),
'paid_amount' => $this->fromCents($paidCents),
'refund_paid_total' => $this->fromCents($refundPaidCents),
'total_amount' => $this->fromCents($totalAmountCents),
'customer_credit' => $this->fromCents($customerCreditCents),
'balance' => $this->fromCents($balanceDueCents),
'status' => $status,
'has_discount' => $discountCents > 0 ? 1 : (int) ($invoice['has_discount'] ?? 0),
];
}
public function recalculateInvoice(int $invoiceId): array
{
$invoice = $this->loadInvoice($invoiceId);
if ($invoice === null) {
throw new \RuntimeException('Invoice not found.');
}
$this->syncInvoiceStudentsList($invoice);
$calculation = $this->calculateInvoice($invoiceId);
$payload = [
'total_amount' => $calculation['total_amount'],
@@ -168,6 +238,99 @@ class InvoiceLedgerService
return $calculation;
}
public function syncInvoiceStudentsList(array $invoice): void
{
if (! $this->invoiceStudentListModel->db->tableExists('invoice_students_list')) {
return;
}
if ($this->isCarryForwardInvoice($invoice)) {
return;
}
$invoiceId = (int) ($invoice['id'] ?? 0);
$parentId = (int) ($invoice['parent_id'] ?? 0);
$schoolYear = trim((string) ($invoice['school_year'] ?? ''));
if ($invoiceId <= 0 || $parentId <= 0 || $schoolYear === '') {
return;
}
$hasTuitionFeeColumn = $this->invoiceStudentListModel->db->fieldExists('tuition_fee', 'invoice_students_list');
$existingRows = $this->invoiceStudentListModel
->select('student_id')
->where('invoice_id', $invoiceId)
->findAll();
$existingStudentIds = [];
foreach ($existingRows as $row) {
$studentId = (int) ($row['student_id'] ?? 0);
if ($studentId > 0) {
$existingStudentIds[$studentId] = true;
}
}
$eligibleStatuses = ['enrolled', 'payment pending', 'withdrawn', 'refund pending', 'withdraw under review'];
$enrollments = $this->enrollmentModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->findAll();
$snapshotRows = [];
$studentIds = [];
foreach ($enrollments as $enrollment) {
$status = strtolower(trim((string) ($enrollment['enrollment_status'] ?? '')));
$studentId = (int) ($enrollment['student_id'] ?? 0);
if ($studentId <= 0 || isset($existingStudentIds[$studentId]) || ! in_array($status, $eligibleStatuses, true)) {
continue;
}
if (! $this->studentClassModel->hasNonEventAssignment($studentId, $schoolYear)) {
continue;
}
$snapshotRows[] = [
'student_id' => $studentId,
'enrolled' => in_array($status, ['enrolled', 'payment pending'], true) ? 1 : 0,
];
$studentIds[] = $studentId;
$existingStudentIds[$studentId] = true;
}
$studentIds = array_values(array_unique($studentIds));
if ($studentIds === []) {
return;
}
$studentRows = $this->studentModel
->select('id, firstname, lastname, school_id')
->whereIn('id', $studentIds)
->findAll();
$studentsById = [];
foreach ($studentRows as $studentRow) {
$studentsById[(int) ($studentRow['id'] ?? 0)] = $studentRow;
}
$now = utc_now();
foreach ($snapshotRows as $snapshotRow) {
$studentId = (int) ($snapshotRow['student_id'] ?? 0);
$student = $studentsById[$studentId] ?? null;
if ($student === null) {
continue;
}
$this->invoiceStudentListModel->insert([
'invoice_id' => $invoiceId,
'student_id' => $studentId,
'student_firstname' => (string) ($student['firstname'] ?? ''),
'student_lastname' => (string) ($student['lastname'] ?? ''),
'school_id' => (int) ($student['school_id'] ?? 0),
'enrolled' => (int) ($snapshotRow['enrolled'] ?? 0),
'school_year' => $schoolYear,
'created_at' => $now,
'updated_at' => $now,
] + ($hasTuitionFeeColumn ? ['tuition_fee' => '0.00'] : []));
}
}
public function recalculate(int $invoiceId): array
{
return $this->recalculateInvoice($invoiceId);
@@ -179,68 +342,7 @@ class InvoiceLedgerService
*/
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;
return false;
}
public function getValidPaymentTotalCents(int $invoiceId): int
@@ -257,118 +359,7 @@ class InvoiceLedgerService
if ($invoiceId <= 0) {
throw new \RuntimeException('Invoice ID is required to issue invoice lines.');
}
if (!$this->invoiceLinesAvailable()) {
throw new \RuntimeException('Invoice lines table is not available.');
}
if ($this->invoiceHasLines($invoiceId)) {
return 0;
}
$now = utc_now();
$version = (string) ($metadata['calculation_version'] ?? $this->getCalculationVersion());
$rows = [];
$tuitionCents = $this->toCents($tuitionAmount);
if ($tuitionCents !== 0) {
$rows[] = $this->buildInvoiceLineRow(
$invoiceId,
'tuition',
'tuition_calculation',
null,
'Tuition charges',
$tuitionCents,
1,
$version,
$metadata,
$now
);
}
$eventCents = $this->toCents($eventAmount);
if ($eventCents !== 0) {
$rows[] = $this->buildInvoiceLineRow(
$invoiceId,
'event_fee',
'event_charges',
null,
'Event charges',
$eventCents,
0,
$version,
$metadata,
$now
);
}
foreach ($this->loadApprovedAdjustmentRowsForIssuance($invoiceId, $metadata) as $charge) {
$adjustmentCents = (string)($charge['charge_type'] ?? 'add') === 'deduct'
? -1 * $this->toCents(abs((float)($charge['amount'] ?? 0)))
: $this->toCents(abs((float)($charge['amount'] ?? 0)));
if ($adjustmentCents === 0) {
throw new \RuntimeException('Zero amount approved additional charges cannot be issued.');
}
$row = $this->buildInvoiceLineRow(
$invoiceId,
$adjustmentCents > 0 ? 'additional_charge' : 'additional_deduction',
'additional_charge',
(int)$charge['id'],
trim((string)($charge['title'] ?? 'Additional charge')) ?: 'Additional charge',
$adjustmentCents,
0,
$version,
$metadata + ['charge_type' => (string)($charge['charge_type'] ?? '')],
$now
);
$row['active_source_key'] = 'additional_charge:' . (int)$charge['id'];
$rows[] = $row;
}
if ($rows === []) {
throw new \RuntimeException('Invoice issuance requires at least one non-zero invoice line.');
}
$inserted = 0;
foreach ($rows as $row) {
$lineId = $this->invoiceLineModel()->insert($row);
if (!$lineId) {
throw new FinancialPersistenceException('INVOICE_LINE_INSERT_FAILED', $this->invoiceLineModel()->errors());
}
$inserted++;
if (($row['source_type'] ?? null) === 'additional_charge' && !empty($row['source_id'])) {
$this->additionalChargeModel->update((int)$row['source_id'], [
'invoice_id' => $invoiceId,
'status' => FinancialStatus::ADDITIONAL_CHARGE_APPLIED,
'applied_invoice_line_id' => (int)$lineId,
'applied_at' => $now,
]);
}
}
return $inserted;
}
protected function loadApprovedAdjustmentRowsForIssuance(int $invoiceId, array $metadata): array
{
$parentId = (int)($metadata['parent_id'] ?? 0);
$schoolYear = (string)($metadata['school_year'] ?? '');
$semester = (string)($metadata['semester'] ?? '');
if ($parentId <= 0 || $schoolYear === '' || $semester === '') {
return [];
}
return $this->additionalChargeModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPROVED)
->groupStart()
->where('invoice_id', $invoiceId)
->orWhere('invoice_id IS NULL', null, false)
->groupEnd()
->orderBy('id', 'ASC')
->findAll();
return 0;
}
protected function loadInvoice(int $invoiceId): ?array
@@ -378,109 +369,17 @@ class InvoiceLedgerService
protected function calculateFrozenLineTotals(int $invoiceId): ?array
{
if (!$this->invoiceLinesAvailable() || !$this->invoiceHasLines($invoiceId)) {
return null;
}
$rows = $this->invoiceLineModel()
->select('line_type, line_amount_cents, discount_eligible')
->where('invoice_id', $invoiceId)
->where('voided_at IS NULL', null, false)
->findAll();
$totals = [
'tuition_cents' => 0,
'event_cents' => 0,
'additional_cents' => 0,
'total_cents' => 0,
'discount_eligible_base_cents' => 0,
];
foreach ($rows as $row) {
$amount = (int) ($row['line_amount_cents'] ?? 0);
$type = (string) ($row['line_type'] ?? '');
$totals['total_cents'] += $amount;
if ((int) ($row['discount_eligible'] ?? 0) === 1) {
$totals['discount_eligible_base_cents'] += $amount;
}
if (str_contains($type, 'event')) {
$totals['event_cents'] += $amount;
} elseif (str_contains($type, 'additional') || str_contains($type, 'adjustment') || str_contains($type, 'charge')) {
$totals['additional_cents'] += $amount;
} else {
$totals['tuition_cents'] += $amount;
}
}
return $totals;
return null;
}
protected function invoiceHasLines(int $invoiceId): bool
{
if (!$this->invoiceLinesAvailable()) {
return false;
}
return $this->invoiceLineModel()
->where('invoice_id', $invoiceId)
->where('voided_at IS NULL', null, false)
->countAllResults() > 0;
return false;
}
protected function invoiceLinesAvailable(): bool
{
try {
return $this->invoiceLineModel()->db->tableExists('invoice_lines');
} catch (\Throwable $e) {
return false;
}
}
protected function invoiceLineModel(): InvoiceLineModel
{
if ($this->invoiceLineModel === null) {
$this->invoiceLineModel = new InvoiceLineModel();
}
return $this->invoiceLineModel;
}
protected function buildInvoiceLineRow(
int $invoiceId,
string $lineType,
?string $sourceType,
?int $sourceId,
string $description,
int $amountCents,
int $discountEligible,
string $version,
array $metadata,
string $timestamp
): array {
return [
'invoice_id' => $invoiceId,
'school_year' => (string)($metadata['school_year'] ?? ''),
'line_type' => $lineType,
'source_type' => $sourceType,
'source_id' => $sourceId,
'active_source_key' => null,
'description' => $description,
'quantity' => '1.00',
'unit_amount_cents' => $amountCents,
'line_amount_cents' => $amountCents,
'discount_eligible' => $discountEligible,
'calculation_version' => $version,
'metadata_json' => json_encode($metadata, JSON_UNESCAPED_SLASHES),
'created_at' => $timestamp,
'updated_at' => $timestamp,
'voided_at' => null,
];
}
protected function getCalculationVersion(): string
{
return 'invoice_lines_v1:' . get_class($this->resolveActiveCalculator());
return false;
}
protected function isCarryForwardInvoice(array $invoice): bool
@@ -522,77 +421,7 @@ class InvoiceLedgerService
*/
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;
return 0;
}
private function carryForwardSourceSchoolYear(array $invoice): string