add controllers, servoices

This commit is contained in:
root
2026-03-09 02:52:13 -04:00
parent c8de5f7edc
commit d76c871cb7
501 changed files with 34439 additions and 21843 deletions
@@ -0,0 +1,294 @@
<?php
namespace App\Services\Discounts;
use App\Models\DiscountVoucher;
use App\Models\Invoice;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class DiscountApplyService
{
private DiscountContextService $context;
private DiscountInvoiceService $invoiceService;
public function __construct(DiscountContextService $context, DiscountInvoiceService $invoiceService)
{
$this->context = $context;
$this->invoiceService = $invoiceService;
}
public function applyVoucher(int $voucherId, array $parentIds, bool $allowAdditional, int $actorId): array
{
$schoolYear = $this->context->getSchoolYear();
$semester = $this->context->getSemester();
$voucher = DiscountVoucher::query()->find($voucherId);
if (!$voucher) {
return ['ok' => false, 'message' => 'Voucher not found.'];
}
$voucherDescription = trim((string) ($voucher->description ?? ''));
$voucherDescription = preg_replace(
'/^Auto-generated on \\d{4}-\\d{2}-\\d{2}\\s*[—-]\\s*reason:\\s*/i',
'',
$voucherDescription
);
$maxUsesRaw = $voucher->max_uses;
$maxUses = ($maxUsesRaw === null || $maxUsesRaw === '') ? null : (int) $maxUsesRaw;
$timesUsed = (int) ($voucher->times_used ?? 0);
$remainingUses = ($maxUses === null) ? PHP_INT_MAX : ($maxUses - $timesUsed);
if ($remainingUses <= 0) {
return ['ok' => false, 'message' => 'This voucher has reached its maximum allowed uses.'];
}
if (!$allowAdditional) {
$rows = DB::table('discount_usages as du')
->select('i.parent_id')
->join('invoices as i', 'i.id', '=', 'du.invoice_id')
->whereIn('i.parent_id', $parentIds)
->where('i.school_year', $schoolYear)
->groupBy('i.parent_id')
->get()
->map(fn ($row) => (array) $row)
->all();
$blockedParentIds = array_map(static fn ($r) => (int) ($r['parent_id'] ?? 0), $rows);
if (!empty($blockedParentIds)) {
$parentIds = array_values(array_diff(array_map('intval', $parentIds), $blockedParentIds));
}
if (empty($parentIds)) {
return [
'ok' => false,
'message' => 'All selected parents already have discounts. Enable "Allow additional discounts" to apply another.',
];
}
}
$paymentDate = function_exists('utc_now') ? utc_now() : now('UTC')->toDateTimeString();
$fullyCoveredInvoiceIds = [];
$touchedInvoiceIds = [];
$appliedCount = 0;
DB::beginTransaction();
try {
foreach ($parentIds as $parentId) {
$invoices = Invoice::getInvoicesByUserId((int) $parentId, $schoolYear);
if (empty($invoices)) {
Log::warning('Discount apply: parent has no invoices', [
'parent_id' => (int) $parentId,
'voucher_id' => $voucherId,
]);
continue;
}
foreach ($invoices as $invoice) {
if ($remainingUses <= 0) {
break 2;
}
$invoiceId = (int) ($invoice['id'] ?? 0);
if ($invoiceId <= 0) {
continue;
}
$initialPreBalance = (float) $this->invoiceService->getCurrentInvoiceBalance($invoiceId, $schoolYear);
if ($initialPreBalance <= 0) {
continue;
}
if (!$allowAdditional) {
$exists = DB::table('discount_usages as du')
->join('invoices as i', 'du.invoice_id', '=', 'i.id')
->where('du.voucher_id', $voucherId)
->where('du.invoice_id', $invoiceId)
->where('i.school_year', $schoolYear)
->count();
if ($exists) {
continue;
}
}
$rawDiscount = $voucher->discount_type === 'percent'
? round(((float) ($invoice['total_amount'] ?? 0) * (float) $voucher->discount_value) / 100, 2)
: (float) $voucher->discount_value;
$discount = min($rawDiscount, $initialPreBalance);
if ($discount <= 0) {
continue;
}
$now = function_exists('utc_now') ? utc_now() : now('UTC')->toDateTimeString();
DB::table('discount_usages')->insert([
'voucher_id' => $voucherId,
'invoice_id' => $invoiceId,
'parent_id' => (int) $parentId,
'discount_amount' => $discount,
'description' => $voucherDescription,
'school_year' => $schoolYear,
'updated_by' => $actorId,
'used_at' => $now,
'created_at' => $now,
'updated_at' => $now,
'semester' => $semester,
]);
$newBalance = max(0.0, round($initialPreBalance - $discount, 2));
DB::table('invoices')
->where('id', $invoiceId)
->update([
'balance' => $newBalance,
'has_discount' => 1,
'updated_at' => $now,
]);
$postBalance = round($initialPreBalance - $discount, 2);
if ($postBalance < 0) {
$postBalance = 0.0;
}
$currentBalance = (float) $this->invoiceService->getCurrentInvoiceBalance($invoiceId, $schoolYear);
DB::table('discount_vouchers')
->where('id', $voucherId)
->update([
'times_used' => DB::raw('COALESCE(times_used,0) + 1'),
]);
$this->triggerPaymentReceived(
$invoiceId,
(string) $voucherId,
$discount,
$paymentDate,
$initialPreBalance,
$postBalance,
$schoolYear,
$semester
);
$touchedInvoiceIds[] = $invoiceId;
$appliedCount++;
$epsilon = 0.01;
$fullyCovered = (abs($discount - $initialPreBalance) <= $epsilon)
|| ($postBalance <= $epsilon)
|| ($currentBalance <= $epsilon);
if ($fullyCovered) {
$fullyCoveredInvoiceIds[] = $invoiceId;
}
$remainingUses--;
if ($remainingUses <= 0 && $maxUses !== null) {
DB::table('discount_vouchers')
->where('id', $voucherId)
->update([
'is_active' => 0,
'updated_at' => $now,
]);
break 2;
}
}
}
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
return ['ok' => false, 'message' => 'Voucher application failed. Transaction rolled back.'];
}
if ($appliedCount === 0) {
return [
'ok' => false,
'message' => 'No discounts were applied. The voucher may already be used for these invoices or balances are zero.',
];
}
if ($maxUses !== null) {
$row = (array) DB::table('discount_vouchers')
->select('times_used')
->where('id', $voucherId)
->first();
$usedNow = (int) ($row['times_used'] ?? 0);
if ($usedNow >= $maxUses) {
DB::table('discount_vouchers')
->where('id', $voucherId)
->update([
'is_active' => 0,
'updated_at' => function_exists('utc_now') ? utc_now() : now('UTC')->toDateTimeString(),
]);
}
}
foreach (array_unique($touchedInvoiceIds) as $iid) {
try {
$this->invoiceService->recalculateInvoice($iid, $schoolYear);
} catch (\Throwable $e) {
Log::error('recalculateInvoice failed for invoice {id}: {err}', [
'id' => $iid,
'err' => $e->getMessage(),
]);
}
}
$updatedTotal = 0;
foreach (array_unique($fullyCoveredInvoiceIds) as $iid) {
try {
$updatedTotal += $this->invoiceService->updateEnrollmentStatusIfPaid($iid, $schoolYear);
} catch (\Throwable $e) {
Log::error('updateEnrollmentStatusIfPaid failed for invoice {id}: {err}', [
'id' => $iid,
'err' => $e->getMessage(),
]);
}
}
return [
'ok' => true,
'message' => $updatedTotal > 0
? 'Voucher applied successfully and enrollments updated.'
: 'Voucher applied successfully.',
'appliedCount' => $appliedCount,
'updatedEnrollments' => $updatedTotal,
'touchedInvoices' => array_values(array_unique($touchedInvoiceIds)),
];
}
private function triggerPaymentReceived(
int $invoiceId,
string $voucherId,
float $amount,
string $paymentDate,
float $preBalance,
float $postBalance,
string $schoolYear,
string $semester
): void {
try {
[$eventData, $studentData] = $this->invoiceService->buildPaymentEventData(
$invoiceId,
$voucherId,
$amount,
'discount',
$paymentDate,
'',
0,
$preBalance,
$postBalance,
$schoolYear,
$semester
);
if (class_exists(\CodeIgniter\Events\Events::class)) {
\CodeIgniter\Events\Events::trigger('paymentReceived', $eventData, $studentData);
} elseif (function_exists('event')) {
event('paymentReceived', [$eventData, $studentData]);
}
} catch (\Throwable $e) {
Log::warning('paymentReceived hook failed: ' . $e->getMessage());
}
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Services\Discounts;
use App\Models\Configuration;
class DiscountContextService
{
public function getSchoolYear(): string
{
return (string) (Configuration::getConfig('school_year') ?? '');
}
public function getSemester(): string
{
return (string) (Configuration::getConfig('semester') ?? '');
}
}
@@ -0,0 +1,394 @@
<?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 Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class DiscountInvoiceService
{
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' => url('parent/invoices/' . $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);
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Services\Discounts;
use App\Models\DiscountUsage;
use App\Models\User;
class DiscountParentService
{
public function listParentsWithDiscounts(string $schoolYear): array
{
$parents = User::getParents();
foreach ($parents as &$parent) {
$total = DiscountUsage::getTotalDiscountByParentIdAndSchoolYear(
(int) ($parent['id'] ?? 0),
$schoolYear
);
$parent['total_discount'] = $total;
$parent['has_discount'] = $total > 0 ? 1 : 0;
}
unset($parent);
return $parents;
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Services\Discounts;
use App\Models\DiscountVoucher;
class DiscountVoucherService
{
public function listAll(): array
{
return DiscountVoucher::query()
->orderByDesc('id')
->get()
->all();
}
public function listActive(): array
{
return DiscountVoucher::query()
->where('is_active', 1)
->orderByDesc('id')
->get()
->all();
}
public function create(array $payload): DiscountVoucher
{
return DiscountVoucher::query()->create($payload);
}
public function update(int $id, array $payload): DiscountVoucher
{
$voucher = DiscountVoucher::query()->findOrFail($id);
$voucher->fill($payload);
$voucher->save();
return $voucher;
}
public function find(int $id): DiscountVoucher
{
return DiscountVoucher::query()->findOrFail($id);
}
public function delete(int $id): void
{
DiscountVoucher::query()->whereKey($id)->delete();
}
}