add refund and event logic
This commit is contained in:
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user