paymentTotals($from); $discounts = $this->discountTotals(); $refunds = $this->refundTotals(); $q = DB::table('invoices')->where('school_year', $from); if ($parentId) { $q->where('parent_id', $parentId); } $invoices = $q->get(); $byParent = []; foreach ($invoices as $invoice) { $invoiceId = (int) $invoice->id; $balance = max(0, (float) ($invoice->total_amount ?? 0) - (float) ($payments[$invoiceId] ?? 0) - (float) ($discounts[$invoiceId] ?? 0) - (float) ($refunds[$invoiceId] ?? 0)); if ($balance <= 0) { continue; } $pid = (int) $invoice->parent_id; if (! isset($byParent[$pid])) { $existing = FinanceBalanceCarryforward::query()->where('parent_id', $pid)->where('from_school_year', $from)->where('to_school_year', $to)->first(); $byParent[$pid] = [ 'parent_id' => $pid, 'from_school_year' => $from, 'to_school_year' => $to, 'source_invoice_ids' => [], 'source_balance_amount' => 0.0, 'proposed_carryforward_amount' => 0.0, 'existing_carryforward_id' => $existing?->id, 'warnings' => [], ]; if ($existing) { $byParent[$pid]['warnings'][] = 'source invoice already carried forward'; } } $byParent[$pid]['source_invoice_ids'][] = $invoiceId; $byParent[$pid]['source_balance_amount'] += $balance; $byParent[$pid]['proposed_carryforward_amount'] += $balance; } $rows = array_values(array_filter($byParent, fn ($r) => $r['source_balance_amount'] >= $minimum)); usort($rows, fn ($a, $b) => $b['source_balance_amount'] <=> $a['source_balance_amount']); return ['fromSchoolYear' => $from, 'toSchoolYear' => $to, 'generatedAt' => now()->toISOString(), 'rows' => $rows]; } public function storeDrafts(array $filters, ?int $actorId): array { $preview = $this->preview($filters); $created = []; foreach ($preview['rows'] as $row) { if (! empty($row['existing_carryforward_id'])) { continue; } $created[] = FinanceBalanceCarryforward::query()->create([ 'parent_id' => $row['parent_id'], 'from_school_year' => $row['from_school_year'], 'to_school_year' => $row['to_school_year'], 'source_invoice_ids_json' => $row['source_invoice_ids'], 'source_balance_amount' => $row['source_balance_amount'], 'carryforward_amount' => $row['proposed_carryforward_amount'], 'remaining_amount' => $row['proposed_carryforward_amount'], 'status' => 'draft', 'reason' => $filters['reason'] ?? null, 'created_by' => $actorId, 'metadata_json' => ['warnings' => $row['warnings']], ])->toArray(); } return ['created' => $created, 'skipped' => count($preview['rows']) - count($created)]; } public function approve(int $id, ?int $actorId): array { $cf = FinanceBalanceCarryforward::query()->findOrFail($id); $cf->fill(['status' => 'approved', 'approved_by' => $actorId, 'approved_at' => now()])->save(); return $cf->fresh()->toArray(); } public function waive(int $id, float $amount, ?string $reason, ?int $actorId): array { $cf = FinanceBalanceCarryforward::query()->findOrFail($id); $waived = min((float) $cf->remaining_amount, $amount); $cf->waived_amount = (float) $cf->waived_amount + $waived; $cf->remaining_amount = max(0, (float) $cf->remaining_amount - $waived); $cf->status = $cf->remaining_amount <= 0 ? 'waived' : $cf->status; $cf->reason = $reason ?: $cf->reason; $cf->approved_by = $cf->approved_by ?: $actorId; $cf->save(); return $cf->fresh()->toArray(); } public function adjust(int $id, float $amount, ?string $reason): array { $cf = FinanceBalanceCarryforward::query()->findOrFail($id); $cf->adjusted_amount = $amount; $cf->remaining_amount = max(0, (float) $cf->carryforward_amount - (float) $cf->waived_amount + $amount); $cf->reason = $reason ?: $cf->reason; $cf->save(); return $cf->fresh()->toArray(); } public function postToNewYear(int $id, ?int $actorId): array { return DB::transaction(function () use ($id, $actorId) { $cf = FinanceBalanceCarryforward::query()->lockForUpdate()->findOrFail($id); if (! in_array($cf->status, ['approved', 'draft', 'pending_review'], true)) { return $cf->toArray() + ['warning' => 'Carryforward is not in a postable status.']; } $amount = max(0, (float) $cf->carryforward_amount - (float) $cf->waived_amount + (float) $cf->adjusted_amount); $invoiceId = null; if (Schema::hasTable('invoices')) { $invoiceId = DB::table('invoices')->insertGetId([ 'parent_id' => $cf->parent_id, 'invoice_number' => 'CF-'.$cf->to_school_year.'-'.$cf->id, 'total_amount' => $amount, 'balance' => $amount, 'paid_amount' => 0, 'school_year' => $cf->to_school_year, 'issue_date' => now()->toDateString(), 'status' => $amount > 0 ? 'unpaid' : 'paid', 'description' => 'Previous year balance from '.$cf->from_school_year, 'created_at' => now(), 'updated_at' => now(), 'updated_by' => $actorId, ]); } $cf->posted_invoice_id = $invoiceId; $cf->status = 'posted_to_new_year'; $cf->remaining_amount = $amount; $cf->save(); return $cf->fresh()->toArray(); }); } public function report(array $filters): array { $q = FinanceBalanceCarryforward::query(); foreach (['from_school_year', 'to_school_year', 'parent_id', 'status'] as $field) { if (! empty($filters[$field])) { $q->where($field, $filters[$field]); } } return ['generatedAt' => now()->toISOString(), 'rows' => $q->orderByDesc('remaining_amount')->get()->toArray()]; } public function csvRows(array $report): array { $rows = [['ID', 'Parent ID', 'From Year', 'To Year', 'Source Invoices', 'Source Balance', 'Carryforward', 'Waived', 'Adjusted', 'Remaining', 'Status', 'Posted Invoice']]; foreach ($report['rows'] as $r) { $rows[] = [$r['id'], $r['parent_id'], $r['from_school_year'], $r['to_school_year'], json_encode($r['source_invoice_ids_json'] ?? []), $r['source_balance_amount'], $r['carryforward_amount'], $r['waived_amount'], $r['adjusted_amount'], $r['remaining_amount'], $r['status'], $r['posted_invoice_id'] ?? null]; } return $rows; } private function paymentTotals(string $schoolYear): array { if (! Schema::hasTable('payments')) { return []; } return DB::table('payments')->where('school_year', $schoolYear)->whereNotIn('status', ['void', 'voided', 'failed', 'cancelled', 'canceled', 'reversed'])->select('invoice_id', DB::raw('COALESCE(SUM(paid_amount),0) total'))->groupBy('invoice_id')->pluck('total', 'invoice_id')->map(fn ($v) => (float) $v)->all(); } private function discountTotals(): array { if (! Schema::hasTable('discount_usages')) { return []; } $col = Schema::hasColumn('discount_usages', 'discount_amount') ? 'discount_amount' : 'amount'; return DB::table('discount_usages')->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->groupBy('invoice_id')->pluck('total', 'invoice_id')->map(fn ($v) => (float) $v)->all(); } private function refundTotals(): array { if (! Schema::hasTable('refunds')) { return []; } $col = Schema::hasColumn('refunds', 'refund_amount') ? 'refund_amount' : (Schema::hasColumn('refunds', 'amount') ? 'amount' : null); if (! $col) { return []; } return DB::table('refunds')->whereNotIn('status', ['void', 'voided', 'cancelled', 'canceled'])->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->groupBy('invoice_id')->pluck('total', 'invoice_id')->map(fn ($v) => (float) $v)->all(); } }