add refund and event logic

This commit is contained in:
root
2026-03-10 23:12:49 -04:00
parent ba1206e314
commit f6be51576c
67 changed files with 4808 additions and 1000 deletions
@@ -0,0 +1,61 @@
<?php
namespace App\Services\Refunds;
use App\Models\Refund;
class RefundDecisionService
{
public function updateDecision(int $refundId, string $status, string $reason, int $actorId): array
{
$refund = Refund::query()->find($refundId);
if (!$refund) {
return ['ok' => false, 'message' => 'Refund not found.'];
}
if (!in_array($status, [Refund::STATUS_APPROVED, Refund::STATUS_REJECTED], true)) {
return ['ok' => false, 'message' => 'Invalid refund status.'];
}
if ($status === Refund::STATUS_APPROVED && (float) ($refund->refund_amount ?? 0) <= 0) {
return ['ok' => false, 'message' => 'Cannot approve a zero refund.'];
}
$refund->update([
'status' => $status,
'reason' => $reason,
'approved_at' => utc_now(),
'approved_by' => $actorId,
'updated_at' => utc_now(),
'updated_by' => $actorId,
]);
return ['ok' => true];
}
public function approve(int $refundId, int $actorId): array
{
return $this->updateDecision($refundId, Refund::STATUS_APPROVED, 'Approved', $actorId);
}
public function reject(int $refundId, string $reason, int $actorId): array
{
return $this->updateDecision($refundId, Refund::STATUS_REJECTED, $reason, $actorId);
}
public function cancel(int $refundId, int $actorId): array
{
$refund = Refund::query()->find($refundId);
if (!$refund) {
return ['ok' => false, 'message' => 'Refund not found.'];
}
$refund->update([
'status' => Refund::STATUS_CANCELED,
'updated_at' => utc_now(),
'updated_by' => $actorId,
]);
return ['ok' => true];
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Services\Refunds;
use App\Models\AdditionalCharge;
use App\Models\Invoice;
use App\Services\Discounts\DiscountInvoiceService;
class RefundInvoiceAdjustmentService
{
public function __construct(private DiscountInvoiceService $invoiceService)
{
}
public function applyBookChargeAndUpdateInvoice(
Invoice $invoice,
int $parentId,
array $refundDetails,
string $semester,
?int $actorId
): array {
$bookChargeTitle = 'Book Fee (Non-Refundable)';
$bookChargeDesc = 'Book fee retained for withdrawals before the refund deadline.';
$bookPrice = (float) ($refundDetails['book_price'] ?? 0.0);
$eligible = !empty($refundDetails['eligible']);
$bookChargeDelta = 0.0;
$bookChargeAction = 'none';
$bookChargeId = null;
$existingCharge = AdditionalCharge::query()
->where('invoice_id', (int) $invoice->id)
->where('parent_id', (int) $parentId)
->where('school_year', (string) $invoice->school_year)
->where('title', $bookChargeTitle)
->orderByDesc('id')
->first();
if ($eligible && $bookPrice > 0) {
if ($existingCharge) {
$oldAmount = (float) ($existingCharge->amount ?? 0.0);
$bookChargeId = (int) $existingCharge->id;
if (abs($oldAmount - $bookPrice) > 0.00001 || ($existingCharge->status ?? '') !== 'applied') {
$existingCharge->update([
'charge_type' => 'add',
'title' => $bookChargeTitle,
'description' => $bookChargeDesc,
'amount' => $bookPrice,
'status' => 'applied',
]);
$bookChargeDelta = round($bookPrice - $oldAmount, 2);
$bookChargeAction = 'updated';
}
} else {
$bookChargeId = AdditionalCharge::query()->insertGetId([
'parent_id' => (int) $parentId,
'invoice_id' => (int) $invoice->id,
'school_year' => (string) $invoice->school_year,
'semester' => $semester,
'charge_type' => 'add',
'title' => $bookChargeTitle,
'description' => $bookChargeDesc,
'amount' => $bookPrice,
'status' => 'applied',
'created_by' => $actorId,
'created_at' => utc_now(),
'updated_at' => utc_now(),
]);
if ($bookChargeId) {
$bookChargeDelta = round($bookPrice, 2);
$bookChargeAction = 'inserted';
}
}
} elseif ($existingCharge) {
$oldAmount = (float) ($existingCharge->amount ?? 0.0);
$bookChargeId = (int) $existingCharge->id;
$existingCharge->delete();
$bookChargeDelta = round(-$oldAmount, 2);
$bookChargeAction = 'deleted';
}
$this->invoiceService->recalculateInvoice((int) $invoice->id, (string) $invoice->school_year);
return [
'book_charge_action' => $bookChargeAction,
'book_charge_delta' => $bookChargeDelta,
'book_charge_id' => $bookChargeId,
];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Services\Refunds;
use Illuminate\Support\Facades\Log;
class RefundNotificationService
{
public function notifyPending(int $parentId, float $amount, ?string $portalLink = null): void
{
Log::info('Refund pending notification queued.', [
'parent_id' => $parentId,
'amount' => round($amount, 2),
'portal_link' => $portalLink,
]);
}
}
@@ -0,0 +1,390 @@
<?php
namespace App\Services\Refunds;
use App\Models\Configuration;
use App\Models\Invoice;
use App\Models\Refund;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class RefundOverpaymentService
{
public function __construct(private RefundNotificationService $notifications)
{
}
public function recalculate(bool $triggerEvents = false, ?string $invoiceNumber = null, ?int $actorId = null): array
{
if ($invoiceNumber !== null && $invoiceNumber !== '') {
return $this->recalculateForInvoice($invoiceNumber, $triggerEvents, $actorId);
}
$created = [];
$updated = [];
$schoolYear = (string) (Configuration::getConfig('school_year') ?? '');
$semester = (string) (Configuration::getConfig('semester') ?? '');
$paidRows = $this->sumPaymentsByParent($schoolYear);
$invRows = $this->sumInvoicesByParent($schoolYear);
$refRows = $this->sumRefundsByParent($schoolYear);
$paidMap = $this->indexByParent($paidRows, 'total_paid');
$invMap = $this->indexByParent($invRows, 'total_invoiced');
$refMap = $this->indexByParent($refRows, 'total_refunded');
$parentIds = array_unique(array_merge(array_keys($paidMap), array_keys($invMap)));
foreach ($parentIds as $pid) {
$paid = (float) ($paidMap[$pid] ?? 0);
$invd = (float) ($invMap[$pid] ?? 0);
$rfnd = (float) ($refMap[$pid] ?? 0);
$over = round($paid - $invd - $rfnd, 2);
if ($over > 0.0001) {
$existing = Refund::query()
->where('parent_id', $pid)
->where('school_year', $schoolYear)
->where('request', 'overpayment')
->orderByDesc('id')
->first();
if ($existing && in_array($existing->status, [Refund::STATUS_PENDING, Refund::STATUS_PARTIAL], true)) {
$existing->update([
'refund_amount' => $over,
'updated_at' => utc_now(),
'updated_by' => $actorId,
]);
$updated[] = (int) $existing->id;
}
}
}
try {
$createdUpdated = $this->recalculatePerInvoice($schoolYear, $semester, $triggerEvents, $actorId);
$created = array_merge($created, $createdUpdated['created_ids'] ?? []);
$updated = array_merge($updated, $createdUpdated['updated_ids'] ?? []);
} catch (\Throwable $e) {
Log::error('recalculateOverpayments per-invoice failed', ['error' => $e->getMessage()]);
}
return ['created_ids' => $created, 'updated_ids' => $updated];
}
private function recalculateForInvoice(string $invoiceNumber, bool $triggerEvents, ?int $actorId): array
{
$invoice = Invoice::query()->where('invoice_number', $invoiceNumber)->first();
if (!$invoice) {
return ['ok' => false, 'message' => 'Invoice not found.'];
}
$iid = (int) $invoice->id;
$pid = (int) $invoice->parent_id;
$year = (string) $invoice->school_year;
$paid = (float) DB::table('payments')
->selectRaw('COALESCE(SUM(paid_amount),0) AS tot')
->where('invoice_id', $iid)
->value('tot');
$disc = (float) DB::table('discount_usages')
->selectRaw('COALESCE(SUM(discount_amount),0) AS tot')
->where('invoice_id', $iid)
->value('tot');
$rfnd = (float) DB::table('refunds')
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS tot')
->where('invoice_id', $iid)
->whereIn('status', ['Partial', 'Paid'])
->value('tot');
$total = (float) ($invoice->total_amount ?? 0);
$raw = round($total - $disc - $paid - $rfnd, 2);
if ($raw >= -0.0001) {
return ['ok' => true, 'message' => 'No overpayment detected.'];
}
$over = abs($raw);
$openRow = Refund::query()
->where('invoice_id', $iid)
->whereIn('status', [Refund::STATUS_PENDING, Refund::STATUS_APPROVED, Refund::STATUS_PARTIAL])
->orderByDesc('id')
->first();
if ($openRow) {
$openRow->update([
'refund_amount' => $over,
'request' => 'overpayment',
'updated_at' => utc_now(),
'updated_by' => $actorId,
]);
return ['ok' => true, 'message' => 'Overpayment updated for invoice.'];
}
$existing = Refund::query()
->where('invoice_id', $iid)
->where('request', 'overpayment')
->orderByDesc('id')
->first();
if ($existing) {
return ['ok' => true, 'message' => 'Overpayment already resolved for invoice.'];
}
$refund = Refund::query()->create([
'parent_id' => $pid,
'school_year' => $year,
'semester' => $invoice->semester,
'invoice_id' => $iid,
'refund_amount' => $over,
'refund_paid_amount' => 0.00,
'status' => Refund::STATUS_PENDING,
'request' => 'overpayment',
'reason' => 'Auto-detected per-invoice overpayment (manual)',
'updated_by' => $actorId,
'created_at' => utc_now(),
'updated_at' => utc_now(),
]);
if ($refund && $triggerEvents) {
$this->notifications->notifyPending($pid, $over, url('/login'));
}
return ['ok' => true, 'message' => 'Overpayment detected and refund created.'];
}
private function recalculatePerInvoice(string $schoolYear, string $semester, bool $triggerEvents, ?int $actorId): array
{
$created = [];
$updated = [];
$invRows = Invoice::query()
->select('id', 'parent_id', 'total_amount', 'school_year')
->where('school_year', $schoolYear)
->get()
->map(fn ($row) => (array) $row)
->all();
if (empty($invRows)) {
return ['created_ids' => [], 'updated_ids' => []];
}
$invoiceIds = array_map(static fn ($row) => (int) $row['id'], $invRows);
$paidByInv = $this->sumPaymentsByInvoice($invoiceIds);
$discByInv = $this->sumDiscountsByInvoice($invoiceIds);
$refByInv = $this->sumRefundsByInvoice($invoiceIds);
foreach ($invRows as $inv) {
$iid = (int) $inv['id'];
$pid = (int) $inv['parent_id'];
$total = (float) ($inv['total_amount'] ?? 0);
$paid = (float) ($paidByInv[$iid] ?? 0);
$disc = (float) ($discByInv[$iid] ?? 0);
$rfnd = (float) ($refByInv[$iid] ?? 0);
$rawBalance = round($total - $disc - $paid - $rfnd, 2);
if ($rawBalance >= -0.0001) {
continue;
}
$over = abs($rawBalance);
$openRow = Refund::query()
->where('invoice_id', $iid)
->whereIn('status', [Refund::STATUS_PENDING, Refund::STATUS_APPROVED, Refund::STATUS_PARTIAL])
->orderByDesc('id')
->first();
if ($openRow) {
$openRow->update([
'refund_amount' => $over,
'updated_at' => utc_now(),
'updated_by' => $actorId,
]);
$updated[] = (int) $openRow->id;
continue;
}
$parentLevelOpen = Refund::query()
->where('parent_id', $pid)
->where('school_year', $schoolYear)
->whereNull('invoice_id')
->where('request', 'overpayment')
->whereIn('status', [Refund::STATUS_PENDING, Refund::STATUS_APPROVED, Refund::STATUS_PARTIAL])
->orderByDesc('id')
->first();
if ($parentLevelOpen) {
$parentLevelOpen->update([
'invoice_id' => $iid,
'refund_amount' => $over,
'updated_at' => utc_now(),
'updated_by' => $actorId,
]);
$updated[] = (int) $parentLevelOpen->id;
continue;
}
$existing = Refund::query()
->where('invoice_id', $iid)
->where('request', 'overpayment')
->whereIn('status', [Refund::STATUS_PENDING, Refund::STATUS_APPROVED, Refund::STATUS_PARTIAL, Refund::STATUS_PAID])
->orderByDesc('id')
->first();
if (!$existing || $existing->status === Refund::STATUS_PAID) {
$refund = Refund::query()->create([
'parent_id' => $pid,
'school_year' => $schoolYear,
'semester' => $semester,
'invoice_id' => $iid,
'refund_amount' => $over,
'refund_paid_amount' => 0.00,
'status' => Refund::STATUS_PENDING,
'request' => 'overpayment',
'reason' => 'Auto-detected per-invoice overpayment',
'updated_by' => $actorId,
'created_at' => utc_now(),
'updated_at' => utc_now(),
]);
if ($refund) {
$created[] = (int) $refund->id;
if ($triggerEvents) {
$this->notifications->notifyPending($pid, $over, url('/login'));
}
}
} elseif (in_array($existing->status, [Refund::STATUS_PENDING, Refund::STATUS_PARTIAL], true)) {
$existing->update([
'refund_amount' => $over,
'updated_at' => utc_now(),
'updated_by' => $actorId,
]);
$updated[] = (int) $existing->id;
}
}
return ['created_ids' => $created, 'updated_ids' => $updated];
}
private function sumPaymentsByParent(string $schoolYear): array
{
$query = DB::table('payments')
->select('parent_id', DB::raw('COALESCE(SUM(paid_amount),0) AS total_paid'))
->where('school_year', $schoolYear)
->groupBy('parent_id');
if (Schema::hasColumn('payments', 'status')) {
$query->where(function ($q) {
$q->whereNotIn('status', [
'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled',
])->orWhereNull('status');
});
}
if (Schema::hasColumn('payments', 'is_void')) {
$query->where(function ($q) {
$q->where('is_void', 0)->orWhereNull('is_void');
});
}
return $query->get()->map(fn ($row) => (array) $row)->all();
}
private function sumInvoicesByParent(string $schoolYear): array
{
return DB::table('invoices')
->select('parent_id', DB::raw('COALESCE(SUM(total_amount),0) AS total_invoiced'))
->where('school_year', $schoolYear)
->groupBy('parent_id')
->get()
->map(fn ($row) => (array) $row)
->all();
}
private function sumRefundsByParent(string $schoolYear): array
{
return DB::table('refunds')
->select('parent_id', DB::raw('COALESCE(SUM(refund_paid_amount),0) AS total_refunded'))
->where('school_year', $schoolYear)
->whereIn('status', ['Partial', 'Paid'])
->groupBy('parent_id')
->get()
->map(fn ($row) => (array) $row)
->all();
}
private function sumPaymentsByInvoice(array $invoiceIds): array
{
$query = DB::table('payments')
->select('invoice_id', DB::raw('COALESCE(SUM(paid_amount),0) AS total_paid'))
->whereIn('invoice_id', $invoiceIds)
->groupBy('invoice_id');
if (Schema::hasColumn('payments', 'status')) {
$query->where(function ($q) {
$q->whereNotIn('status', [
'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled',
])->orWhereNull('status');
});
}
if (Schema::hasColumn('payments', 'is_void')) {
$query->where(function ($q) {
$q->where('is_void', 0)->orWhereNull('is_void');
});
}
$rows = $query->get();
$paidBy = [];
foreach ($rows as $row) {
$paidBy[(int) $row->invoice_id] = (float) ($row->total_paid ?? 0);
}
return $paidBy;
}
private function sumDiscountsByInvoice(array $invoiceIds): array
{
$rows = DB::table('discount_usages')
->select('invoice_id', DB::raw('COALESCE(SUM(discount_amount),0) AS total_disc'))
->whereIn('invoice_id', $invoiceIds)
->groupBy('invoice_id')
->get();
$discBy = [];
foreach ($rows as $row) {
$discBy[(int) $row->invoice_id] = (float) ($row->total_disc ?? 0);
}
return $discBy;
}
private function sumRefundsByInvoice(array $invoiceIds): array
{
$rows = DB::table('refunds')
->select('invoice_id', DB::raw('COALESCE(SUM(refund_paid_amount),0) AS total_ref'))
->whereIn('invoice_id', $invoiceIds)
->whereIn('status', ['Partial', 'Paid'])
->groupBy('invoice_id')
->get();
$refBy = [];
foreach ($rows as $row) {
$refBy[(int) $row->invoice_id] = (float) ($row->total_ref ?? 0);
}
return $refBy;
}
private function indexByParent(array $rows, string $field): array
{
$map = [];
foreach ($rows as $row) {
$pid = (int) ($row['parent_id'] ?? 0);
if ($pid <= 0) {
continue;
}
$map[$pid] = (float) ($row[$field] ?? 0);
}
return $map;
}
}
@@ -0,0 +1,177 @@
<?php
namespace App\Services\Refunds;
use App\Models\Invoice;
use App\Models\Refund;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class RefundPayoutService
{
public function recordPayment(int $refundId, array $payload, int $actorId): array
{
$refund = Refund::query()->find($refundId);
if (!$refund) {
return ['ok' => false, 'message' => 'Refund not found.'];
}
if (!in_array($refund->status, [Refund::STATUS_APPROVED, Refund::STATUS_PARTIAL], true)) {
return ['ok' => false, 'message' => 'Refund must be Approved or Partial to pay.'];
}
$newPaidAmount = (float) ($payload['paid_amount'] ?? 0);
if ($newPaidAmount <= 0) {
return ['ok' => false, 'message' => 'Valid paid amount is required.'];
}
$oldPaid = (float) ($refund->refund_paid_amount ?? 0);
$target = (float) ($refund->refund_amount ?? 0);
$total = $oldPaid + $newPaidAmount;
if ($total > $target + 0.0001) {
return ['ok' => false, 'message' => 'Total paid cannot exceed refund amount.'];
}
$refundMethod = (string) ($payload['payment_method'] ?? '');
$checkNumber = $payload['check_number'] ?? null;
$checkFileName = $refund->check_file;
if ($refundMethod === 'Check' && ($payload['check_file'] ?? null) instanceof UploadedFile) {
$file = $payload['check_file'];
if ($file->isValid()) {
$checkFileName = $file->hashName();
$file->storeAs('checks', $checkFileName);
}
}
$newStatus = ($total < $target) ? Refund::STATUS_PARTIAL : Refund::STATUS_PAID;
return DB::transaction(function () use (
$refund,
$total,
$newStatus,
$refundMethod,
$checkNumber,
$checkFileName,
$actorId
) {
$assignInvoiceId = $refund->invoice_id ?: $this->assignInvoiceForRefund($refund);
$refund->update([
'refund_paid_amount' => $total,
'status' => $newStatus,
'refunded_at' => utc_now(),
'updated_at' => utc_now(),
'updated_by' => $actorId,
'refund_method' => $refundMethod,
'check_nbr' => $checkNumber,
'check_file' => $checkFileName,
'invoice_id' => $assignInvoiceId,
]);
return ['ok' => true];
});
}
private function assignInvoiceForRefund(Refund $refund): ?int
{
$parentId = (int) ($refund->parent_id ?? 0);
$schoolYear = (string) ($refund->school_year ?? '');
if ($parentId <= 0 || $schoolYear === '') {
return null;
}
try {
$invRows = Invoice::query()
->select('id', 'total_amount')
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->get()
->map(fn ($row) => (array) $row)
->all();
if (empty($invRows)) {
return null;
}
$invoiceIds = array_map(static fn ($r) => (int) $r['id'], $invRows);
$paidBy = $this->sumPaymentsByInvoice($invoiceIds);
$discBy = $this->sumDiscountsByInvoice($invoiceIds);
$minRaw = 0.0;
$minId = null;
foreach ($invRows as $row) {
$iid = (int) $row['id'];
$raw = (float) ($row['total_amount'] ?? 0) - (float) ($discBy[$iid] ?? 0) - (float) ($paidBy[$iid] ?? 0);
if ($raw < $minRaw) {
$minRaw = $raw;
$minId = $iid;
}
}
if ($minId !== null) {
return $minId;
}
$latest = Invoice::query()
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->orderByDesc('created_at')
->first();
return $latest ? (int) $latest->id : null;
} catch (\Throwable $e) {
Log::warning('Refund invoice assignment failed.', ['error' => $e->getMessage()]);
return null;
}
}
private function sumPaymentsByInvoice(array $invoiceIds): array
{
$query = DB::table('payments')
->select('invoice_id', DB::raw('COALESCE(SUM(paid_amount),0) AS total_paid'))
->whereIn('invoice_id', $invoiceIds)
->groupBy('invoice_id');
if (Schema::hasColumn('payments', 'status')) {
$query->where(function ($q) {
$q->whereNotIn('status', [
'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled',
])->orWhereNull('status');
});
}
if (Schema::hasColumn('payments', 'is_void')) {
$query->where(function ($q) {
$q->where('is_void', 0)->orWhereNull('is_void');
});
}
$rows = $query->get();
$paidBy = [];
foreach ($rows as $row) {
$paidBy[(int) $row->invoice_id] = (float) ($row->total_paid ?? 0);
}
return $paidBy;
}
private function sumDiscountsByInvoice(array $invoiceIds): array
{
$rows = DB::table('discount_usages')
->select('invoice_id', DB::raw('COALESCE(SUM(discount_amount),0) AS total_disc'))
->whereIn('invoice_id', $invoiceIds)
->groupBy('invoice_id')
->get();
$discBy = [];
foreach ($rows as $row) {
$discBy[(int) $row->invoice_id] = (float) ($row->total_disc ?? 0);
}
return $discBy;
}
}
@@ -0,0 +1,204 @@
<?php
namespace App\Services\Refunds;
use App\Models\Configuration;
use App\Models\Enrollment;
use App\Models\Invoice;
use App\Models\Refund;
use App\Services\Fees\FeeRefundDetailService;
use Illuminate\Support\Facades\DB;
class RefundPolicyService
{
public function __construct(
private FeeRefundDetailService $refundDetails,
private RefundInvoiceAdjustmentService $invoiceAdjustments
) {
}
public function processWithdrawalRefund(
int $parentId,
?int $invoiceId = null,
?string $schoolYear = null,
?string $semester = null,
?int $actorId = null
): array {
$schoolYear = $schoolYear ?? (string) (Configuration::getConfig('school_year') ?? '');
if ($schoolYear === '') {
return ['ok' => false, 'error' => 'Missing school year for refund processing.'];
}
$semester = $semester ?? (string) (Configuration::getConfig('semester') ?? '');
$invoice = $invoiceId
? Invoice::query()->find($invoiceId)
: Invoice::query()
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->orderByDesc('created_at')
->first();
if (!$invoice) {
return ['ok' => false, 'error' => 'No invoice found for refund processing.'];
}
$students = Enrollment::query()
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->get()
->map(fn ($row) => $row->toArray())
->all();
if (empty($students)) {
return ['ok' => false, 'error' => 'No enrollments found for refund processing.'];
}
$details = $this->refundDetails->calculateRefundDetails($students, $parentId);
return DB::transaction(function () use (
$details,
$invoice,
$parentId,
$schoolYear,
$semester,
$actorId,
$students
) {
if (empty($details['ok'])) {
$reason = $details['error'] ?? 'Refund calculation failed.';
$note = json_encode([
'parent_id' => $parentId,
'invoice_id' => (int) ($invoice->id ?? 0),
'error' => $reason,
]);
$existingRefund = Refund::query()->where('invoice_id', (int) $invoice->id)->first();
if ($existingRefund) {
$existingRefund->update([
'refund_amount' => 0.0,
'status' => Refund::STATUS_REJECTED,
'reason' => $reason,
'request' => 'tuition',
'note' => $note,
'updated_at' => utc_now(),
'updated_by' => $actorId,
]);
} else {
Refund::query()->create([
'parent_id' => $parentId,
'school_year' => $schoolYear,
'semester' => $semester,
'invoice_id' => (int) $invoice->id,
'refund_amount' => 0.0,
'refund_paid_amount' => 0.00,
'status' => Refund::STATUS_REJECTED,
'reason' => $reason,
'request' => 'tuition',
'note' => $note,
'requested_at' => utc_now(),
'created_at' => utc_now(),
'updated_at' => utc_now(),
'updated_by' => $actorId,
]);
}
return ['ok' => false, 'error' => $reason];
}
$invoiceUpdate = $this->invoiceAdjustments->applyBookChargeAndUpdateInvoice(
$invoice,
$parentId,
$details,
$semester,
$actorId
);
$refundAmount = (float) ($details['refund_amount'] ?? 0.0);
$debitAmount = (float) ($details['debit_amount'] ?? 0.0);
$eligible = !empty($details['eligible']);
$status = $refundAmount > 0 ? Refund::STATUS_PENDING : Refund::STATUS_REJECTED;
$reason = null;
if ($refundAmount <= 0) {
if (!$eligible) {
$reason = 'No refund due to late withdrawal.';
} elseif (!empty($details['full_discount'])) {
$reason = 'No refund due to full discount.';
} elseif ($debitAmount > 0) {
$reason = 'No refund due; book fee balance remains.';
} else {
$reason = 'No refund due after book fee deduction.';
}
}
$auditPayload = [
'student_ids' => array_map('intval', array_column($students, 'student_id')),
'parent_id' => $parentId,
'withdraw_date' => $details['withdraw_date'] ?? null,
'refund_deadline' => $details['refund_deadline'] ?? null,
'eligible' => $details['eligible'] ?? null,
'paid_amount' => $details['paid_amount'] ?? null,
'book_price' => $details['book_price'] ?? null,
'invoice_total' => $details['invoice_total'] ?? null,
'discount_total' => $details['discount_total'] ?? null,
'full_discount' => $details['full_discount'] ?? null,
'refundable_tuition' => $details['refundable_tuition'] ?? null,
'withdrawn_eligible_count' => $details['withdrawn_eligible_count'] ?? null,
'refund_amount' => $refundAmount,
'debit_amount' => $debitAmount,
'invoice_id' => (int) ($invoice->id ?? 0),
];
$auditPayload = array_merge($auditPayload, $invoiceUpdate);
$existingRefund = Refund::query()->where('invoice_id', (int) $invoice->id)->first();
if ($existingRefund) {
$existingRefund->update([
'refund_amount' => $refundAmount,
'status' => $status,
'reason' => $reason,
'request' => 'tuition',
'note' => json_encode($auditPayload),
'updated_at' => utc_now(),
'updated_by' => $actorId,
]);
return [
'ok' => true,
'refund_id' => (int) $existingRefund->id,
'refund_amount' => $refundAmount,
'status' => $status,
];
}
$created = Refund::query()->create([
'parent_id' => $parentId,
'school_year' => $schoolYear,
'semester' => $semester,
'invoice_id' => (int) $invoice->id,
'refund_amount' => $refundAmount,
'refund_paid_amount' => 0.00,
'status' => $status,
'reason' => $reason,
'request' => 'tuition',
'note' => json_encode($auditPayload),
'requested_at' => utc_now(),
'created_at' => utc_now(),
'updated_at' => utc_now(),
'updated_by' => $actorId,
]);
if (!$created) {
return ['ok' => false, 'error' => 'Failed to create refund record.'];
}
return [
'ok' => true,
'refund_id' => (int) $created->id,
'refund_amount' => $refundAmount,
'status' => $status,
];
});
}
}
@@ -0,0 +1,83 @@
<?php
namespace App\Services\Refunds;
use App\Models\Refund;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class RefundQueryService
{
public function list(array $filters): array
{
$perPage = (int) ($filters['per_page'] ?? 25);
$page = (int) ($filters['page'] ?? 1);
$sortBy = (string) ($filters['sort_by'] ?? 'created_at');
$sortDir = strtolower((string) ($filters['sort_dir'] ?? 'desc')) === 'asc' ? 'asc' : 'desc';
$allowedSorts = ['created_at', 'updated_at', 'refund_amount', 'status', 'parent_id', 'invoice_id'];
if (!in_array($sortBy, $allowedSorts, true)) {
$sortBy = 'created_at';
}
$query = Refund::query()
->select([
'refunds.*',
DB::raw("CONCAT(COALESCE(parents.lastname, ''), ', ', COALESCE(parents.firstname, '')) AS parent_name"),
DB::raw("CONCAT(COALESCE(approvers.lastname, ''), ', ', COALESCE(approvers.firstname, '')) AS approved_by_name"),
])
->leftJoin('users as parents', 'parents.id', '=', 'refunds.parent_id')
->leftJoin('users as approvers', 'approvers.id', '=', 'refunds.approved_by');
if (!empty($filters['status'])) {
$query->where('refunds.status', $filters['status']);
}
if (!empty($filters['request'])) {
$query->where('refunds.request', $filters['request']);
}
if (!empty($filters['parent_id'])) {
$query->where('refunds.parent_id', (int) $filters['parent_id']);
}
if (!empty($filters['invoice_id'])) {
$query->where('refunds.invoice_id', (int) $filters['invoice_id']);
}
if (!empty($filters['school_year'])) {
$query->where('refunds.school_year', (string) $filters['school_year']);
}
if (!empty($filters['semester'])) {
$query->where('refunds.semester', (string) $filters['semester']);
}
if (!empty($filters['q'])) {
$term = trim((string) $filters['q']);
$query->where(function ($where) use ($term) {
$where->where('refunds.reason', 'like', "%{$term}%")
->orWhere('refunds.note', 'like', "%{$term}%")
->orWhere('parents.firstname', 'like', "%{$term}%")
->orWhere('parents.lastname', 'like', "%{$term}%");
});
}
$paginator = $query->orderBy('refunds.' . $sortBy, $sortDir)
->paginate($perPage, ['*'], 'page', $page);
return [
'refunds' => $paginator,
'pagination' => $this->paginationPayload($paginator),
];
}
public function find(int $refundId): ?Refund
{
return Refund::query()->find($refundId);
}
private function paginationPayload(LengthAwarePaginator $paginator): array
{
return [
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'total_pages' => $paginator->lastPage(),
];
}
}
@@ -0,0 +1,126 @@
<?php
namespace App\Services\Refunds;
use App\Models\Configuration;
use App\Models\Refund;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class RefundRequestService
{
public const REQUEST_TYPES = ['tuition', 'overpayment', 'duplicate', 'extra'];
public function __construct(
private RefundPolicyService $policyService,
private RefundSummaryService $summaryService,
private RefundNotificationService $notifications
) {
}
public function requestRefund(array $payload, int $actorId): array
{
$parentId = (int) ($payload['parent_id'] ?? 0);
$requestType = strtolower((string) ($payload['request_type'] ?? ''));
$amount = (float) ($payload['amount'] ?? 0);
$invoiceId = isset($payload['invoice_id']) ? (int) $payload['invoice_id'] : null;
$paymentId = isset($payload['payment_id']) ? (int) $payload['payment_id'] : null;
$reason = $payload['reason'] ?? null;
if (!in_array($requestType, self::REQUEST_TYPES, true)) {
return ['ok' => false, 'message' => 'Invalid refund request type.'];
}
$parent = User::query()->find($parentId);
if (!$parent) {
return ['ok' => false, 'message' => 'Parent not found.'];
}
$termSchoolYear = (string) (Configuration::getConfig('school_year') ?? '');
$termSemester = (string) (Configuration::getConfig('semester') ?? '');
$schoolYear = (string) ($payload['school_year'] ?? $termSchoolYear);
$semester = (string) ($payload['semester'] ?? $termSemester);
if ($requestType === 'tuition') {
$result = $this->policyService->processWithdrawalRefund($parentId, $invoiceId, $schoolYear, $semester, $actorId);
if (empty($result['ok'])) {
return ['ok' => false, 'message' => $result['error'] ?? 'Refund calculation failed.'];
}
if (($result['refund_amount'] ?? 0) > 0) {
$this->notifications->notifyPending($parentId, (float) $result['refund_amount'], url('/login'));
}
return [
'ok' => true,
'refund_id' => $result['refund_id'] ?? null,
'refund_amount' => $result['refund_amount'] ?? 0.0,
'status' => $result['status'] ?? null,
];
}
if (in_array($requestType, ['overpayment', 'extra'], true)) {
$summary = $this->summaryService->getParentFinancialSummary($parentId, $schoolYear, $semester);
if (($summary['unapplied_balance'] ?? 0) <= 0) {
return ['ok' => false, 'message' => 'No unapplied balance available.'];
}
if ($amount > (float) ($summary['unapplied_balance'] ?? 0) + 0.0001) {
return [
'ok' => false,
'message' => 'Amount exceeds available unapplied balance.',
'available' => number_format((float) ($summary['unapplied_balance'] ?? 0), 2),
];
}
}
if ($amount <= 0) {
return ['ok' => false, 'message' => 'Refund amount must be greater than zero.'];
}
return DB::transaction(function () use (
$parentId,
$schoolYear,
$semester,
$invoiceId,
$amount,
$requestType,
$reason,
$paymentId,
$actorId
) {
$refund = Refund::query()->create([
'parent_id' => $parentId,
'school_year' => $schoolYear,
'semester' => $semester,
'invoice_id' => $invoiceId,
'refund_amount' => $amount,
'refund_paid_amount' => 0.00,
'status' => Refund::STATUS_PENDING,
'reason' => $reason,
'request' => $requestType,
'note' => $paymentId ? ('duplicate-of-payment#' . $paymentId) : null,
'requested_at' => utc_now(),
'created_at' => utc_now(),
'updated_at' => utc_now(),
'updated_by' => $actorId,
]);
if (!$refund) {
Log::error('Failed to create refund request.', [
'parent_id' => $parentId,
'type' => $requestType,
]);
return ['ok' => false, 'message' => 'Failed to create refund request.'];
}
$this->notifications->notifyPending($parentId, $amount, url('/login'));
return [
'ok' => true,
'refund_id' => (int) $refund->id,
'refund_amount' => (float) $refund->refund_amount,
];
});
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Services\Refunds;
use Illuminate\Support\Facades\DB;
class RefundSummaryService
{
public function getParentFinancialSummary(int $parentId, string $schoolYear, string $semester): array
{
$invRow = (array) DB::table('invoices')
->selectRaw('COALESCE(SUM(total_amount),0) AS total_invoiced')
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->first();
$payRow = (array) DB::table('payments')
->selectRaw('COALESCE(SUM(paid_amount),0) AS paid_in')
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->first();
$refRow = (array) DB::table('refunds')
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS refunded_cash')
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->whereIn('status', ['Partial', 'Paid'])
->first();
$totalInvoiced = (float) ($invRow['total_invoiced'] ?? 0);
$paidIn = (float) ($payRow['paid_in'] ?? 0);
$refundedCash = (float) ($refRow['refunded_cash'] ?? 0);
$unapplied = $paidIn - $totalInvoiced - $refundedCash;
return [
'total_invoiced' => $totalInvoiced,
'total_paid_in' => $paidIn,
'total_refunded' => $refundedCash,
'unapplied_balance' => $unapplied,
];
}
}