Files
alrahma_sunday_school_api/app/Services/Refunds/RefundOverpaymentService.php
T
2026-06-09 01:25:14 -04:00

396 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
{
public function __construct(
private RefundNotificationService $notifications,
private ApplicationUrlService $urls,
) {}
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) => (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, $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;
}
}