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, $this->urls->webLoginUrl()); } 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) => method_exists($row, 'toArray') ? $row->toArray() : (array) $row) ->filter(fn (array $row) => isset($row['id'], $row['parent_id'])) ->values() ->all(); if (empty($invRows)) { return ['created_ids' => [], 'updated_ids' => []]; } $invoiceIds = array_values(array_filter(array_map(static fn ($row) => (int) ($row['id'] ?? 0), $invRows))); $paidByInv = $this->sumPaymentsByInvoice($invoiceIds); $discByInv = $this->sumDiscountsByInvoice($invoiceIds); $refByInv = $this->sumRefundsByInvoice($invoiceIds); foreach ($invRows as $inv) { $iid = (int) ($inv['id'] ?? 0); $pid = (int) ($inv['parent_id'] ?? 0); if ($iid <= 0 || $pid <= 0) { continue; } $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, $this->urls->webLoginUrl()); } } } 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; } }