Files
alrahma_sunday_school_api/app/Services/Invoices/InvoiceGenerationService.php
T
2026-06-08 23:45:55 -04:00

265 lines
9.6 KiB
PHP

<?php
namespace App\Services\Invoices;
use App\Models\Enrollment;
use App\Models\EventCharges;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\Refund;
use App\Models\StudentClass;
use App\Models\User;
use DateTime;
use DateTimeZone;
use Illuminate\Support\Facades\DB;
class InvoiceGenerationService
{
public function __construct(
private InvoiceConfigService $config,
private InvoiceTuitionService $tuitionService
) {
}
public function generateInvoice(int $parentId, ?string $schoolYear = null): array
{
$schoolYear = $schoolYear ?: $this->config->getSchoolYear();
$semester = $this->config->getSemester();
$enrollments = Enrollment::query()
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->get()
->map(fn ($r) => $r->toArray())
->all();
if (empty($enrollments)) {
return [
'ok' => false,
'message' => 'No enrollment records found.',
];
}
$registeredKids = [];
$withdrawnKids = [];
foreach ($enrollments as $enrollment) {
$student = [
'student_id' => $enrollment['student_id'],
'parent_id' => $enrollment['parent_id'],
'class_section_id' => $enrollment['class_section_id'],
'enrollment_status' => $enrollment['enrollment_status'],
'school_year' => $enrollment['school_year'],
'semester' => $enrollment['semester'],
'admission_status' => $enrollment['admission_status'],
'is_withdrawn' => $enrollment['is_withdrawn'],
];
if (in_array($enrollment['enrollment_status'], ['enrolled', 'payment pending'], true)) {
$registeredKids[] = $student;
} elseif (in_array($enrollment['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'], true)) {
$withdrawnKids[] = $student;
}
}
$registeredKids = array_values(array_filter($registeredKids, function ($student) use ($schoolYear) {
$sid = (int) ($student['student_id'] ?? 0);
return $sid > 0 && StudentClass::hasNonEventAssignment($sid, $schoolYear);
}));
$withdrawnKids = array_values(array_filter($withdrawnKids, function ($student) use ($schoolYear) {
$sid = (int) ($student['student_id'] ?? 0);
return $sid > 0 && StudentClass::hasNonEventAssignment($sid, $schoolYear);
}));
$tuitionFee = $this->tuitionService->calculateTuitionFee(
$registeredKids,
$withdrawnKids,
$this->config->getRefundDeadline()
);
$eventsList = EventCharges::getChargesWithEventInfo($parentId, $schoolYear);
$eventChargeTotal = array_sum(array_column($eventsList, 'charged'));
$this->recalculateAndUpdateDiscount($parentId, $schoolYear, $tuitionFee, $enrollments);
$refundPaid = (new Refund())->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $schoolYear);
$totalPaid = Payment::getTotalPaidByParentId($parentId, $schoolYear);
$totalAmount = max(0.0, (float) $tuitionFee) + (float) $eventChargeTotal;
$existingInvoices = Invoice::getInvoicesByParentId($parentId, $schoolYear);
$updated = false;
$updatedIds = [];
$insertId = null;
if (!empty($existingInvoices)) {
foreach ($existingInvoices as $invoice) {
if (!isset($invoice['id'])) {
continue;
}
$extrasSum = $this->sumInvoiceAdditionalCharges((int) $invoice['id'], $schoolYear);
$newTotal = round($totalAmount + $extrasSum, 2);
$invDiscount = $this->sumInvoiceDiscounts((int) $invoice['id']);
$invRefunds = $this->sumInvoiceRefunds((int) $invoice['id'], $schoolYear);
$paidOnInv = (float) ($invoice['paid_amount'] ?? 0.0);
$newBalance = $newTotal - $invDiscount - $invRefunds - $paidOnInv;
$newStatus = $newBalance <= 0.00001
? 'Paid'
: ($paidOnInv > 0 ? 'Partially Paid' : 'Unpaid');
Invoice::query()
->whereKey($invoice['id'])
->update([
'total_amount' => $newTotal,
'paid_amount' => $paidOnInv,
'balance' => $newBalance,
'status' => $newStatus,
'updated_at' => utc_now(),
]);
$updatedIds[] = (int) $invoice['id'];
$updated = true;
}
} else {
$schoolId = User::getSchoolIdByUserId($parentId);
$invoiceNumber = $schoolId
? ('INV-' . $schoolId . '-' . uniqid())
: uniqid('INV-');
$issueUtc = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');
$dueUtc = null;
$dueDate = $this->config->getDueDate();
if (!empty($dueDate)) {
$dueLocal = new DateTime($dueDate . ' 19:59:59', new DateTimeZone($this->config->getTimezone()));
$dueLocal->setTimezone(new DateTimeZone('UTC'));
$dueUtc = $dueLocal->format('Y-m-d H:i:s');
}
$insertId = Invoice::query()->insertGetId([
'parent_id' => $parentId,
'invoice_number' => $invoiceNumber,
'total_amount' => $totalAmount,
'paid_amount' => 0,
'balance' => $totalAmount,
'status' => 'Unpaid',
'school_year' => $schoolYear,
'semester' => $semester,
'issue_date' => $issueUtc,
'due_date' => $dueUtc,
'created_at' => utc_now(),
'updated_at' => utc_now(),
]);
}
return [
'ok' => true,
'updated' => $updated,
'updated_ids' => $updatedIds,
'insert_id' => $insertId ? (int) $insertId : null,
'total_paid' => $totalPaid,
'refund_paid' => $refundPaid,
];
}
private function recalculateAndUpdateDiscount(int $parentId, string $schoolYear, float $tuitionFee, array $enrollments): void
{
$eventChargeTotal = 0.0;
try {
$eventsList = EventCharges::getChargesWithEventInfo($parentId, $schoolYear);
$eventChargeTotal = array_sum(array_column($eventsList, 'charged'));
} catch (\Throwable $e) {
}
$invoices = Invoice::getInvoicesByParentId($parentId, $schoolYear);
if (empty($invoices)) {
return;
}
foreach ($invoices as $invoice) {
if (!isset($invoice['id'])) {
continue;
}
$invoiceId = (int) $invoice['id'];
$discountUsage = DB::table('discount_usages as du')
->select('du.id', 'dv.id as voucher_id', 'dv.discount_type', 'dv.discount_value')
->join('discount_vouchers as dv', 'du.voucher_id', '=', 'dv.id')
->join('invoices as i', 'du.invoice_id', '=', 'i.id')
->where('du.invoice_id', $invoiceId)
->where('i.school_year', $schoolYear)
->first();
if (!$discountUsage) {
continue;
}
$extrasSum = $this->sumInvoiceAdditionalCharges($invoiceId, $schoolYear);
$baseTotal = round($tuitionFee + $eventChargeTotal + $extrasSum, 2);
if (($discountUsage->discount_type ?? '') === 'percent') {
$discountAmount = round(($baseTotal * (float) $discountUsage->discount_value) / 100, 2);
} else {
$discountAmount = min((float) $discountUsage->discount_value, $baseTotal);
}
DB::table('discount_usages')
->where('id', $discountUsage->id)
->update([
'discount_amount' => $discountAmount,
'updated_at' => utc_now(),
'updated_by' => auth()->id(),
]);
}
}
private function sumInvoiceAdditionalCharges(int $invoiceId, string $schoolYear): float
{
$extrasSum = 0.0;
$rows = DB::table('additional_charges')
->select('charge_type', 'amount')
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear)
->where('status', 'applied')
->get();
foreach ($rows as $row) {
$amount = (float) ($row->amount ?? 0);
$type = strtolower((string) ($row->charge_type ?? 'add'));
if ($type === 'deduct') {
$amount = -abs($amount);
} else {
$amount = abs($amount);
}
$extrasSum += $amount;
}
return $extrasSum;
}
private function sumInvoiceDiscounts(int $invoiceId): float
{
$row = DB::table('discount_usages')
->selectRaw('COALESCE(SUM(discount_amount),0) AS tot')
->where('invoice_id', $invoiceId)
->first();
return (float) ($row->tot ?? 0.0);
}
private function sumInvoiceRefunds(int $invoiceId, string $schoolYear): float
{
$row = DB::table('refunds')
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS tot')
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear)
->whereIn('status', ['Partial', 'Paid'])
->first();
return (float) ($row->tot ?? 0.0);
}
}