403 lines
14 KiB
PHP
403 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Discounts;
|
|
|
|
use App\Models\AdditionalCharge;
|
|
use App\Models\ClassSection;
|
|
use App\Models\Configuration;
|
|
use App\Models\Enrollment;
|
|
use App\Models\EventCharges;
|
|
use App\Models\Invoice;
|
|
use App\Models\Payment;
|
|
use App\Services\ApplicationUrlService;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
|
|
class DiscountInvoiceService
|
|
{
|
|
public function __construct(
|
|
private ApplicationUrlService $urls,
|
|
) {}
|
|
|
|
public function getCurrentInvoiceBalance(int $invoiceId, string $schoolYear): float
|
|
{
|
|
$invoice = Invoice::query()->find($invoiceId);
|
|
if (! $invoice) {
|
|
return 0.0;
|
|
}
|
|
|
|
$paymentQuery = Payment::query()
|
|
->selectRaw('COALESCE(SUM(paid_amount),0) AS total_paid')
|
|
->where('invoice_id', $invoiceId)
|
|
->where('school_year', $schoolYear);
|
|
|
|
if (Schema::hasColumn('payments', 'status')) {
|
|
$paymentQuery->where(function ($q) {
|
|
$q->whereNotIn('status', [
|
|
'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled',
|
|
])->orWhereNull('status');
|
|
});
|
|
}
|
|
|
|
if (Schema::hasColumn('payments', 'is_void')) {
|
|
$paymentQuery->where(function ($q) {
|
|
$q->where('is_void', 0)->orWhereNull('is_void');
|
|
});
|
|
}
|
|
|
|
$rowPaid = (array) $paymentQuery->first();
|
|
$totalPaid = (float) ($rowPaid['total_paid'] ?? 0);
|
|
|
|
$rowDisc = (array) DB::table('discount_usages as du')
|
|
->join('invoices as i', 'i.id', '=', 'du.invoice_id')
|
|
->where('du.invoice_id', $invoiceId)
|
|
->where('i.school_year', $schoolYear)
|
|
->selectRaw('COALESCE(SUM(du.discount_amount),0) AS total_disc')
|
|
->first();
|
|
$totalDisc = (float) ($rowDisc['total_disc'] ?? 0);
|
|
|
|
$rowRefund = (array) DB::table('refunds')
|
|
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
|
->where('invoice_id', $invoiceId)
|
|
->where('school_year', $schoolYear)
|
|
->whereIn('status', ['Partial', 'Paid'])
|
|
->first();
|
|
$totalRefundPaid = (float) ($rowRefund['total_refund_paid'] ?? 0);
|
|
|
|
$total = (float) ($invoice->total_amount ?? 0);
|
|
|
|
return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2));
|
|
}
|
|
|
|
public function recalculateInvoice(int $invoiceId, string $schoolYear): void
|
|
{
|
|
$invoice = Invoice::query()->find($invoiceId);
|
|
if (! $invoice) {
|
|
return;
|
|
}
|
|
|
|
$parentId = (int) ($invoice->parent_id ?? 0);
|
|
if ($parentId <= 0) {
|
|
return;
|
|
}
|
|
|
|
$enrollments = Enrollment::query()
|
|
->where('parent_id', $parentId)
|
|
->where('school_year', $schoolYear)
|
|
->get()
|
|
->map(fn ($row) => (array) $row)
|
|
->all();
|
|
|
|
$registered = [];
|
|
$withdrawn = [];
|
|
foreach ($enrollments as $e) {
|
|
$row = [
|
|
'student_id' => (int) ($e['student_id'] ?? 0),
|
|
'class_section_id' => $e['class_section_id'] ?? null,
|
|
'enrollment_status' => (string) ($e['enrollment_status'] ?? ''),
|
|
];
|
|
if (in_array($row['enrollment_status'], ['enrolled', 'payment pending'], true)) {
|
|
$registered[] = $row;
|
|
} elseif (in_array($row['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'], true)) {
|
|
$withdrawn[] = $row;
|
|
}
|
|
}
|
|
|
|
$refundDeadline = (string) (Configuration::getConfig('refund_deadline') ?? '');
|
|
$refundAllowed = true;
|
|
if ($refundDeadline !== '') {
|
|
try {
|
|
$tzName = function_exists('user_timezone') ? (string) user_timezone() : 'UTC';
|
|
$tz = new \DateTimeZone($tzName);
|
|
$today = new \DateTimeImmutable('today', $tz);
|
|
$deadline = new \DateTimeImmutable($refundDeadline, $tz);
|
|
$refundAllowed = $today <= $deadline;
|
|
} catch (\Throwable $e) {
|
|
$refundAllowed = true;
|
|
}
|
|
}
|
|
|
|
$tuitionStudents = $registered;
|
|
if (! $refundAllowed) {
|
|
$tuitionStudents = array_merge($tuitionStudents, $withdrawn);
|
|
}
|
|
|
|
$gradeFee = (int) (Configuration::getConfig('grade_fee') ?? 9);
|
|
$firstStudentFee = (float) (Configuration::getConfig('first_student_fee') ?? 350);
|
|
$secondStudentFee = (float) (Configuration::getConfig('second_student_fee') ?? 200);
|
|
$youthFee = (float) (Configuration::getConfig('youth_fee') ?? 180);
|
|
|
|
foreach ($tuitionStudents as &$s) {
|
|
$name = null;
|
|
if (! empty($s['class_section_id'])) {
|
|
$name = ClassSection::getClassSectionNameBySectionId($s['class_section_id']);
|
|
}
|
|
$s['grade_name'] = is_string($name) ? strtoupper(trim($name)) : 'N/A';
|
|
}
|
|
unset($s);
|
|
|
|
$regularCount = 0;
|
|
$youthCount = 0;
|
|
foreach ($tuitionStudents as $s) {
|
|
$lvl = $this->parseGradeLevel((string) ($s['grade_name'] ?? ''), $gradeFee);
|
|
if ($lvl > $gradeFee) {
|
|
$youthCount++;
|
|
} else {
|
|
$regularCount++;
|
|
}
|
|
}
|
|
|
|
$tuitionSubtotal = 0.0;
|
|
$tuitionSubtotal += $youthCount * $youthFee;
|
|
if ($regularCount >= 2) {
|
|
$tuitionSubtotal += $firstStudentFee + ($regularCount - 1) * $secondStudentFee;
|
|
} elseif ($regularCount === 1) {
|
|
$tuitionSubtotal += $firstStudentFee;
|
|
}
|
|
|
|
$eventSubtotal = 0.0;
|
|
$events = EventCharges::getChargesWithEventInfo($parentId, $schoolYear) ?? [];
|
|
foreach ($events as $ev) {
|
|
$eventSubtotal += (float) ($ev['charged'] ?? 0.0);
|
|
}
|
|
|
|
$additionalSubtotal = 0.0;
|
|
$rows = AdditionalCharge::query()
|
|
->select('charge_type', 'amount')
|
|
->where('invoice_id', $invoiceId)
|
|
->where('status', 'applied')
|
|
->get()
|
|
->map(fn ($row) => (array) $row)
|
|
->all();
|
|
|
|
foreach ($rows as $r) {
|
|
$amt = (float) ($r['amount'] ?? 0);
|
|
$typ = strtolower((string) ($r['charge_type'] ?? 'add'));
|
|
$amt = $typ === 'deduct' ? -abs($amt) : abs($amt);
|
|
$additionalSubtotal += $amt;
|
|
}
|
|
|
|
$newTotal = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 2);
|
|
|
|
$paymentQuery = Payment::query()
|
|
->where('invoice_id', $invoiceId)
|
|
->where('school_year', $schoolYear);
|
|
|
|
if (Schema::hasColumn('payments', 'status')) {
|
|
$paymentQuery->where(function ($q) {
|
|
$q->whereNotIn('status', [
|
|
'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled',
|
|
])->orWhereNull('status');
|
|
});
|
|
}
|
|
if (Schema::hasColumn('payments', 'is_void')) {
|
|
$paymentQuery->where(function ($q) {
|
|
$q->where('is_void', 0)->orWhereNull('is_void');
|
|
});
|
|
}
|
|
|
|
$payments = $paymentQuery->get()->map(fn ($row) => (array) $row)->all();
|
|
$totalPaid = 0.0;
|
|
foreach ($payments as $p) {
|
|
$totalPaid += (float) ($p['paid_amount'] ?? 0);
|
|
}
|
|
|
|
$discRow = (array) DB::table('discount_usages')
|
|
->selectRaw('COALESCE(SUM(discount_amount),0) AS total_disc')
|
|
->where('invoice_id', $invoiceId)
|
|
->first();
|
|
$totalDisc = (float) ($discRow['total_disc'] ?? 0);
|
|
|
|
$refundRow = (array) DB::table('refunds')
|
|
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
|
->where('invoice_id', $invoiceId)
|
|
->whereIn('status', ['Partial', 'Paid'])
|
|
->first();
|
|
$totalRefundPaid = (float) ($refundRow['total_refund_paid'] ?? 0);
|
|
|
|
$newBalance = max(0.0, $newTotal - $totalDisc - $totalPaid - $totalRefundPaid);
|
|
$newStatus = $newBalance <= 0.00001 ? 'Paid' : ($totalPaid > 0 ? 'Partially Paid' : 'Unpaid');
|
|
|
|
$updateData = [
|
|
'total_amount' => $newTotal,
|
|
'paid_amount' => $totalPaid,
|
|
'balance' => $newBalance,
|
|
'status' => $newStatus,
|
|
'has_discount' => $totalDisc > 0.0 ? 1 : 0,
|
|
];
|
|
|
|
if (Schema::hasColumn('invoices', 'discount')) {
|
|
$updateData['discount'] = $totalDisc;
|
|
}
|
|
|
|
Invoice::query()->whereKey($invoiceId)->update($updateData);
|
|
}
|
|
|
|
public function updateEnrollmentStatusIfPaid(int $invoiceId, string $schoolYear): int
|
|
{
|
|
$invoice = Invoice::query()->find($invoiceId);
|
|
if (! $invoice) {
|
|
return 0;
|
|
}
|
|
|
|
$total = (float) ($invoice->total_amount ?? 0);
|
|
$balance = (float) ($invoice->balance ?? 0);
|
|
if (! ($total > 0 && $balance < $total)) {
|
|
return 0;
|
|
}
|
|
|
|
$parentId = (int) ($invoice->parent_id ?? 0);
|
|
if ($parentId <= 0 || $schoolYear === '') {
|
|
return 0;
|
|
}
|
|
|
|
$paidEnrollmentIds = [];
|
|
if (Schema::hasTable('invoice_items') && Schema::hasColumn('invoice_items', 'enrollment_id')) {
|
|
$rows = DB::table('invoice_items')
|
|
->select('enrollment_id')
|
|
->where('invoice_id', $invoiceId)
|
|
->whereNotNull('enrollment_id')
|
|
->get()
|
|
->map(fn ($row) => (array) $row)
|
|
->all();
|
|
|
|
foreach ($rows as $r) {
|
|
$eid = (int) ($r['enrollment_id'] ?? 0);
|
|
if ($eid > 0) {
|
|
$paidEnrollmentIds[] = $eid;
|
|
}
|
|
}
|
|
}
|
|
|
|
$query = Enrollment::query()
|
|
->select('id')
|
|
->where('parent_id', $parentId)
|
|
->where('school_year', $schoolYear)
|
|
->where(function ($q) {
|
|
$q->where('enrollment_status', 'payment pending')
|
|
->orWhere('enrollment_status', 'Payment pending')
|
|
->orWhere('enrollment_status', 'Payment Pending')
|
|
->orWhere('enrollment_status', 'PAYMENT PENDING')
|
|
->orWhereRaw("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'");
|
|
});
|
|
|
|
if (! empty($paidEnrollmentIds)) {
|
|
$query->whereIn('id', array_values(array_unique($paidEnrollmentIds)));
|
|
}
|
|
|
|
$toUpdateIds = $query->get()->pluck('id')->map(fn ($id) => (int) $id)->all();
|
|
if (empty($toUpdateIds)) {
|
|
return 0;
|
|
}
|
|
|
|
$affected = Enrollment::query()
|
|
->whereIn('id', $toUpdateIds)
|
|
->update([
|
|
'enrollment_status' => 'enrolled',
|
|
'enrollment_date' => $this->formatLocalDate('Y-m-d'),
|
|
]);
|
|
|
|
return (int) $affected;
|
|
}
|
|
|
|
public function buildPaymentEventData(
|
|
int $invoiceId,
|
|
string $transactionIdOrRef,
|
|
float $amount,
|
|
string $paymentMethod,
|
|
string $paymentDate,
|
|
?string $checkNumber,
|
|
int $installmentSeq,
|
|
float $preBalance,
|
|
float $postBalance,
|
|
string $schoolYear,
|
|
string $semester
|
|
): array {
|
|
$invoice = Invoice::query()->find($invoiceId);
|
|
if (! $invoice) {
|
|
throw new \RuntimeException("Invoice {$invoiceId} not found");
|
|
}
|
|
|
|
$parentRow = (array) DB::table('users')
|
|
->select('id', 'firstname', 'lastname', 'email')
|
|
->where('id', $invoice->parent_id)
|
|
->first();
|
|
|
|
if (empty($parentRow)) {
|
|
throw new \RuntimeException("Parent not found for invoice {$invoiceId}");
|
|
}
|
|
|
|
$studentRows = DB::table('students as s')
|
|
->select('s.id', 's.firstname', 's.lastname', 'sc.class_section_id')
|
|
->leftJoin('student_class as sc', 'sc.student_id', '=', 's.id')
|
|
->where('s.parent_id', $invoice->parent_id)
|
|
->where('sc.school_year', $schoolYear)
|
|
->get()
|
|
->map(fn ($row) => (array) $row)
|
|
->all();
|
|
|
|
$data = [
|
|
'user_id' => (int) $parentRow['id'],
|
|
'email' => $parentRow['email'],
|
|
'firstname' => $parentRow['firstname'],
|
|
'lastname' => $parentRow['lastname'],
|
|
'school_year' => $schoolYear,
|
|
'semester' => $semester,
|
|
'invoice_id' => $invoiceId,
|
|
'transaction_id' => $transactionIdOrRef,
|
|
'payment_date' => $paymentDate,
|
|
'amount' => $amount,
|
|
'method' => $paymentMethod,
|
|
'check_number' => $checkNumber,
|
|
'installment_seq' => $installmentSeq,
|
|
'invoice_total' => (float) $invoice->total_amount,
|
|
'pre_balance' => $preBalance,
|
|
'post_balance' => $postBalance,
|
|
'portalLink' => $this->urls->spaParentInvoiceDetailUrl($invoiceId),
|
|
];
|
|
|
|
return [$data, $studentRows];
|
|
}
|
|
|
|
private function parseGradeLevel(string $grade, int $gradeFee): int
|
|
{
|
|
if (is_numeric($grade)) {
|
|
return (int) $grade;
|
|
}
|
|
$g = strtoupper(trim($grade));
|
|
$g = preg_replace('/\\s+/', ' ', $g);
|
|
$g = str_replace(['.', '_', '-'], ['', '', ' '], $g);
|
|
|
|
$kg = ['K', 'KG', 'K G', 'KINDER', 'KINDERGARTEN'];
|
|
if (in_array($g, $kg, true)) {
|
|
return 1;
|
|
}
|
|
|
|
$pk = ['PK', 'P K', 'PREK', 'PRE K', 'PRE KINDER', 'PREKINDER'];
|
|
if (in_array($g, $pk, true)) {
|
|
return -1;
|
|
}
|
|
|
|
if (preg_match('/^Y(?:OUTH)?\\s*(\\d+)?$/', $g, $m)) {
|
|
$n = isset($m[1]) && $m[1] !== '' ? max(1, (int) $m[1]) : 1;
|
|
|
|
return $gradeFee + $n;
|
|
}
|
|
|
|
if (preg_match('/^(?:GR?ADE\\s*)?(\\d{1,2})\\s*([A-Z]*)$/', $g, $m)) {
|
|
return (int) $m[1];
|
|
}
|
|
|
|
return 999;
|
|
}
|
|
|
|
private function formatLocalDate(string $format): string
|
|
{
|
|
if (function_exists('local_date') && function_exists('utc_now')) {
|
|
return (string) local_date(utc_now(), $format);
|
|
}
|
|
|
|
return now()->format($format);
|
|
}
|
|
}
|