where($field, $filters[$field]); } return ['rows' => $q->orderByDesc('id')->paginate((int)($filters['per_page'] ?? 25))]; } public function createForInvoice(int $invoiceId, array $payload, ?int $actorId): array { return DB::transaction(function () use ($invoiceId, $payload, $actorId) { $invoice = DB::table('invoices')->where('id', $invoiceId)->first(); abort_if(!$invoice, 404, 'Invoice not found.'); $total = (float) ($payload['total_amount'] ?? ($invoice->balance ?? $invoice->total_amount ?? 0)); $count = (int) $payload['number_of_installments']; $plan = InvoiceInstallmentPlan::query()->create([ 'invoice_id' => $invoiceId, 'parent_id' => $invoice->parent_id, 'school_year' => $invoice->school_year ?? null, 'plan_name' => $payload['plan_name'] ?? ('Invoice #' . $invoiceId . ' installment plan'), 'total_amount' => $total, 'number_of_installments' => $count, 'status' => 'draft', 'created_by' => $actorId, ]); $firstDue = Carbon::parse($payload['first_due_date']); $interval = (int) ($payload['interval_months'] ?? 1); $base = floor(($total / $count) * 100) / 100; $allocated = 0.0; for ($i = 1; $i <= $count; $i++) { $amount = $i === $count ? round($total - $allocated, 2) : $base; $allocated += $amount; InvoiceInstallment::query()->create([ 'installment_plan_id' => $plan->id, 'invoice_id' => $invoiceId, 'sequence' => $i, 'due_date' => $firstDue->copy()->addMonths(($i - 1) * $interval)->toDateString(), 'amount_due' => $amount, 'amount_paid' => 0, 'balance' => $amount, 'status' => 'pending', ]); } return $this->show($plan->id); }); } public function show(int $id): array { $plan = InvoiceInstallmentPlan::query()->findOrFail($id)->toArray(); $plan['installments'] = InvoiceInstallment::query()->where('installment_plan_id', $id)->orderBy('sequence')->get()->toArray(); return $plan; } public function activate(int $id, ?int $actorId): array { $plan = InvoiceInstallmentPlan::query()->findOrFail($id); $plan->fill(['status' => 'active', 'approved_by' => $actorId])->save(); InvoiceInstallment::query()->where('installment_plan_id', $id)->where('status', 'pending')->update(['status' => 'due']); return $this->show($id); } public function cancel(int $id): array { InvoiceInstallmentPlan::query()->whereKey($id)->update(['status' => 'cancelled']); InvoiceInstallment::query()->where('installment_plan_id', $id)->whereNotIn('status', ['paid'])->update(['status' => 'cancelled']); return $this->show($id); } public function allocatePayment(int $paymentId, array $payload): array { return DB::transaction(function () use ($paymentId, $payload) { $payment = DB::table('payments')->where('id', $paymentId)->first(); abort_if(!$payment, 404, 'Payment not found.'); $allocations = $payload['installments'] ?? null; if (!$allocations) { $remaining = (float) ($payment->paid_amount ?? 0); $installments = InvoiceInstallment::query()->where('invoice_id', $payment->invoice_id)->where('balance', '>', 0)->orderBy('due_date')->lockForUpdate()->get(); $allocations = []; foreach ($installments as $inst) { if ($remaining <= 0) break; $amount = min($remaining, (float) $inst->balance); $allocations[] = ['installment_id' => $inst->id, 'amount' => $amount]; $remaining -= $amount; } } $created = []; foreach ($allocations as $a) { $inst = InvoiceInstallment::query()->lockForUpdate()->findOrFail($a['installment_id']); $amount = min((float) $a['amount'], (float) $inst->balance); if ($amount <= 0) continue; $created[] = PaymentInstallmentAllocation::query()->create([ 'payment_id' => $paymentId, 'invoice_id' => $inst->invoice_id, 'installment_id' => $inst->id, 'amount' => $amount, ])->toArray(); $inst->amount_paid = (float) $inst->amount_paid + $amount; $inst->balance = max(0, (float) $inst->amount_due - (float) $inst->amount_paid); $inst->status = $inst->balance <= 0 ? 'paid' : 'partially_paid'; $inst->paid_at = $inst->balance <= 0 ? now() : null; $inst->save(); } return ['allocations' => $created]; }); } public function due(bool $overdue = false): array { $q = InvoiceInstallment::query()->where('balance', '>', 0); $overdue ? $q->whereDate('due_date', '<', now()->toDateString()) : $q->whereDate('due_date', '>=', now()->toDateString()); return ['rows' => $q->orderBy('due_date')->get()->toArray()]; } public function markOverdue(): int { return InvoiceInstallment::query()->where('balance', '>', 0)->whereDate('due_date', '<', now()->toDateString())->whereNotIn('status', ['paid','cancelled','waived'])->update(['status' => 'overdue']); } }