Files
alrahma_sunday_school_api/app/Services/Refunds/RefundOverpaymentService.php
T
root e0dfc3ec82
API CI/CD / Validate (composer + pint) (push) Successful in 3m15s
API CI/CD / Test (PHPUnit) (push) Failing after 5m4s
API CI/CD / Build frontend assets (push) Successful in 1m3s
API CI/CD / Security audit (push) Failing after 49s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped
Fixed many feature failures around preferences, route coverage, administrator enrollment, assignment section names, attendance tracking controller access, finance PDF generation, and finance notification logging.
2026-07-07 21:26:47 -04:00

405 lines
14 KiB
PHP

<?php
namespace App\Services\Refunds;
use App\Models\Configuration;
use App\Models\Invoice;
use App\Models\Refund;
use App\Services\ApplicationUrlService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class RefundOverpaymentService
{
private ApplicationUrlService $urls;
public function __construct(
private RefundNotificationService $notifications,
?ApplicationUrlService $urls = null,
) {
$this->urls = $urls ?? app(ApplicationUrlService::class);
}
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, $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;
}
}