fix financial issues
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Interfaces\TuitionCalculatorInterface;
|
||||
use App\Libraries\Tuition\NewTuitionCalculatorService;
|
||||
use App\Libraries\Tuition\OldTuitionCalculatorService;
|
||||
use App\Models\AdditionalChargeModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\DiscountUsageModel;
|
||||
use App\Models\EnrollmentModel;
|
||||
use App\Models\EventChargesModel;
|
||||
use App\Models\InvoiceEventModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\RefundModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\StudentModel;
|
||||
|
||||
class InvoiceLedgerService
|
||||
{
|
||||
protected InvoiceModel $invoiceModel;
|
||||
protected PaymentModel $paymentModel;
|
||||
protected RefundModel $refundModel;
|
||||
protected DiscountUsageModel $discountUsageModel;
|
||||
protected AdditionalChargeModel $additionalChargeModel;
|
||||
protected ConfigurationModel $configurationModel;
|
||||
protected EnrollmentModel $enrollmentModel;
|
||||
protected StudentClassModel $studentClassModel;
|
||||
protected ClassSectionModel $classSectionModel;
|
||||
protected EventChargesModel $eventChargesModel;
|
||||
protected InvoiceEventModel $invoiceEventModel;
|
||||
protected StudentModel $studentModel;
|
||||
protected TuitionCalculatorInterface $oldCalculator;
|
||||
protected TuitionCalculatorInterface $newCalculator;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
$this->paymentModel = new PaymentModel();
|
||||
$this->refundModel = new RefundModel();
|
||||
$this->discountUsageModel = new DiscountUsageModel();
|
||||
$this->additionalChargeModel = new AdditionalChargeModel();
|
||||
$this->configurationModel = new ConfigurationModel();
|
||||
$this->enrollmentModel = new EnrollmentModel();
|
||||
$this->studentClassModel = new StudentClassModel();
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
$this->eventChargesModel = new EventChargesModel();
|
||||
$this->invoiceEventModel = new InvoiceEventModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->oldCalculator = new OldTuitionCalculatorService();
|
||||
$this->newCalculator = new NewTuitionCalculatorService();
|
||||
}
|
||||
|
||||
public function calculateInvoice(int $invoiceId): array
|
||||
{
|
||||
$invoice = $this->loadInvoice($invoiceId);
|
||||
if ($invoice === null) {
|
||||
throw new \RuntimeException('Invoice not found.');
|
||||
}
|
||||
|
||||
$tuitionTotal = $this->calculateTuitionTotal($invoice);
|
||||
$eventTotal = $this->calculateEventTotal($invoice);
|
||||
$additionalTotal = $this->calculateAdditionalCharges($invoiceId);
|
||||
$discountRawTotal = $this->calculateDiscounts($invoiceId);
|
||||
$paidTotal = $this->calculateValidPayments($invoiceId);
|
||||
$refundPaidTotal = $this->calculatePaidRefunds($invoiceId);
|
||||
|
||||
$tuitionCents = $this->toCents($tuitionTotal);
|
||||
$eventCents = $this->toCents($eventTotal);
|
||||
$additionalCents = $this->toCents($additionalTotal);
|
||||
$discountRawCents = $this->toCents($discountRawTotal);
|
||||
$paidCents = $this->toCents($paidTotal);
|
||||
$refundPaidCents = $this->toCents($refundPaidTotal);
|
||||
|
||||
$discountBaseCents = max(0, $tuitionCents + $additionalCents);
|
||||
$discountCents = min($discountRawCents, $discountBaseCents);
|
||||
$totalAmountCents = $tuitionCents + $eventCents + $additionalCents;
|
||||
$balanceCents = max(0, $totalAmountCents - $discountCents - $paidCents - $refundPaidCents);
|
||||
|
||||
if ($balanceCents === 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,
|
||||
'tuition_total' => $this->fromCents($tuitionCents),
|
||||
'event_total' => $this->fromCents($eventCents),
|
||||
'additional_total' => $this->fromCents($additionalCents),
|
||||
'discount_total' => $this->fromCents($discountCents),
|
||||
'discount_raw_total' => $this->fromCents($discountRawCents),
|
||||
'paid_amount' => $this->fromCents($paidCents),
|
||||
'refund_paid_total' => $this->fromCents($refundPaidCents),
|
||||
'total_amount' => $this->fromCents($totalAmountCents),
|
||||
'balance' => $this->fromCents($balanceCents),
|
||||
'status' => $status,
|
||||
'has_discount' => $discountCents > 0 ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function recalculateInvoice(int $invoiceId): array
|
||||
{
|
||||
$calculation = $this->calculateInvoice($invoiceId);
|
||||
$payload = [
|
||||
'total_amount' => $calculation['total_amount'],
|
||||
'paid_amount' => $calculation['paid_amount'],
|
||||
'balance' => $calculation['balance'],
|
||||
'status' => $calculation['status'],
|
||||
'has_discount' => $calculation['has_discount'],
|
||||
'updated_at' => utc_now(),
|
||||
];
|
||||
|
||||
if ($this->invoiceModel->db->fieldExists('discount', $this->invoiceModel->table)) {
|
||||
$payload['discount'] = $calculation['discount_total'];
|
||||
}
|
||||
|
||||
$this->invoiceModel->update($invoiceId, $payload);
|
||||
|
||||
return $calculation;
|
||||
}
|
||||
|
||||
protected function loadInvoice(int $invoiceId): ?array
|
||||
{
|
||||
return $this->invoiceModel->find($invoiceId);
|
||||
}
|
||||
|
||||
protected function calculateTuitionTotal(array $invoice): float
|
||||
{
|
||||
$parentId = (int) ($invoice['parent_id'] ?? 0);
|
||||
$schoolYear = (string) ($invoice['school_year'] ?? '');
|
||||
if ($parentId <= 0 || $schoolYear === '') {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$students = $this->loadTuitionStudents($parentId, $schoolYear);
|
||||
$config = $this->getTuitionConfig();
|
||||
$calculator = $this->resolveActiveCalculator();
|
||||
|
||||
return (float) ($calculator->calculateFamilyTuition($students, $config)['total'] ?? 0.0);
|
||||
}
|
||||
|
||||
protected function calculateEventTotal(array $invoice): float
|
||||
{
|
||||
$invoiceId = (int) ($invoice['id'] ?? 0);
|
||||
$invoiceEventRows = $this->invoiceEventModel
|
||||
->select('COALESCE(SUM(amount),0) AS total_amount')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->findAll();
|
||||
|
||||
if (!empty($invoiceEventRows)) {
|
||||
$total = (float) ($invoiceEventRows[0]['total_amount'] ?? 0);
|
||||
if ($total > 0) {
|
||||
return $total;
|
||||
}
|
||||
}
|
||||
|
||||
$rows = $this->eventChargesModel
|
||||
->select('COALESCE(SUM(charged),0) AS total_amount')
|
||||
->where('parent_id', (int) ($invoice['parent_id'] ?? 0))
|
||||
->where('school_year', (string) ($invoice['school_year'] ?? ''))
|
||||
->where('semester', (string) ($invoice['semester'] ?? ''))
|
||||
->findAll();
|
||||
|
||||
return (float) ($rows[0]['total_amount'] ?? 0);
|
||||
}
|
||||
|
||||
protected function calculateAdditionalCharges(int $invoiceId): float
|
||||
{
|
||||
$rows = $this->additionalChargeModel
|
||||
->select('COALESCE(SUM(amount),0) AS total_amount')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->whereNotIn('status', ['void', FinancialStatus::ADDITIONAL_CHARGE_VOIDED, 'cancelled', 'canceled'])
|
||||
->findAll();
|
||||
|
||||
return (float) ($rows[0]['total_amount'] ?? 0);
|
||||
}
|
||||
|
||||
protected function calculateDiscounts(int $invoiceId): float
|
||||
{
|
||||
$row = $this->discountUsageModel
|
||||
->select('COALESCE(SUM(discount_amount),0) AS total_amount')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->first();
|
||||
|
||||
return (float) ($row['total_amount'] ?? 0);
|
||||
}
|
||||
|
||||
protected function calculateValidPayments(int $invoiceId): float
|
||||
{
|
||||
$query = $this->paymentModel
|
||||
->select('COALESCE(SUM(paid_amount),0) AS total_amount')
|
||||
->where('invoice_id', $invoiceId);
|
||||
|
||||
if ($this->paymentModel->db->fieldExists('status', $this->paymentModel->table)) {
|
||||
$query->groupStart()
|
||||
->whereNotIn('status', FinancialStatus::EXCLUDED_PAYMENT_STATUSES)
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
if ($this->paymentModel->db->fieldExists('is_void', $this->paymentModel->table)) {
|
||||
$query->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
$row = $query->first();
|
||||
|
||||
return (float) ($row['total_amount'] ?? 0);
|
||||
}
|
||||
|
||||
protected function calculatePaidRefunds(int $invoiceId): float
|
||||
{
|
||||
$row = $this->refundModel
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS total_amount')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->whereIn('status', FinancialStatus::REFUND_REDUCES_INVOICE_STATUSES)
|
||||
->first();
|
||||
|
||||
return (float) ($row['total_amount'] ?? 0);
|
||||
}
|
||||
|
||||
protected function loadTuitionStudents(int $parentId, string $schoolYear): array
|
||||
{
|
||||
$enrollments = $this->enrollmentModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
if (empty($enrollments)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$refundDeadline = (string) ($this->configurationModel->getConfig('refund_deadline') ?? '');
|
||||
$includeWithdrawn = !$this->isWithinRefundWindow($refundDeadline);
|
||||
$eligibleStatuses = ['enrolled', 'payment pending'];
|
||||
|
||||
if ($includeWithdrawn) {
|
||||
array_push($eligibleStatuses, 'withdrawn', 'refund pending', 'withdraw under review');
|
||||
}
|
||||
|
||||
$students = [];
|
||||
foreach ($enrollments as $enrollment) {
|
||||
$status = strtolower(trim((string) ($enrollment['enrollment_status'] ?? '')));
|
||||
$studentId = (int) ($enrollment['student_id'] ?? 0);
|
||||
if ($studentId <= 0 || !in_array($status, $eligibleStatuses, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$this->studentClassModel->hasNonEventAssignment($studentId, $schoolYear)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$gradeName = $this->resolveGradeName($studentId, $schoolYear, $enrollment['class_section_id'] ?? null);
|
||||
$student = $this->studentModel->find($studentId) ?? [];
|
||||
|
||||
$students[] = [
|
||||
'student_id' => $studentId,
|
||||
'student_name' => trim(((string) ($student['firstname'] ?? '')) . ' ' . ((string) ($student['lastname'] ?? ''))),
|
||||
'grade_level' => $gradeName,
|
||||
];
|
||||
}
|
||||
|
||||
return $students;
|
||||
}
|
||||
|
||||
protected function resolveGradeName(int $studentId, string $schoolYear, $classSectionId = null): string
|
||||
{
|
||||
$sectionId = $classSectionId;
|
||||
if (empty($sectionId)) {
|
||||
$row = $this->studentClassModel
|
||||
->select('class_section_id')
|
||||
->where('student_id', $studentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('updated_at', 'DESC')
|
||||
->first();
|
||||
$sectionId = $row['class_section_id'] ?? null;
|
||||
}
|
||||
|
||||
if (empty($sectionId)) {
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
$name = $this->classSectionModel->getClassSectionNameBySectionId($sectionId);
|
||||
|
||||
return is_string($name) && $name !== '' ? strtoupper(trim($name)) : 'N/A';
|
||||
}
|
||||
|
||||
protected function resolveActiveCalculator(): TuitionCalculatorInterface
|
||||
{
|
||||
$version = strtolower(trim((string) ($this->configurationModel->getConfig('tuition_calculator_version') ?? 'old')));
|
||||
|
||||
return $version === 'new' ? $this->newCalculator : $this->oldCalculator;
|
||||
}
|
||||
|
||||
protected function getTuitionConfig(): array
|
||||
{
|
||||
return [
|
||||
'grade_fee' => $this->configurationModel->getConfig('grade_fee'),
|
||||
'first_student_fee' => $this->configurationModel->getConfig('first_student_fee'),
|
||||
'second_student_fee' => $this->configurationModel->getConfig('second_student_fee'),
|
||||
'youth_fee' => $this->configurationModel->getConfig('youth_fee'),
|
||||
'new_tuition_full_amount' => $this->configurationModel->getConfig('new_tuition_full_amount'),
|
||||
'new_tuition_second_student_discount' => $this->configurationModel->getConfig('new_tuition_second_student_discount'),
|
||||
'new_tuition_third_student_discount' => $this->configurationModel->getConfig('new_tuition_third_student_discount'),
|
||||
'new_tuition_fourth_plus_discount' => $this->configurationModel->getConfig('new_tuition_fourth_plus_discount'),
|
||||
];
|
||||
}
|
||||
|
||||
protected function isWithinRefundWindow(string $refundDeadline): bool
|
||||
{
|
||||
if ($refundDeadline === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$timeZone = new \DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone()));
|
||||
$today = new \DateTimeImmutable('today', $timeZone);
|
||||
$deadline = new \DateTimeImmutable($refundDeadline, $timeZone);
|
||||
|
||||
return $today <= $deadline;
|
||||
} catch (\Throwable $e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
protected function toCents($amount): int
|
||||
{
|
||||
return (int) round(((float) $amount) * 100);
|
||||
}
|
||||
|
||||
protected function fromCents(int $cents): string
|
||||
{
|
||||
return number_format($cents / 100, 2, '.', '');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user