394 lines
14 KiB
PHP
394 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;
|
|
}
|
|
}
|