context->getSchoolYear()); $title = trim((string) ($payload['title'] ?? '')); $chargeType = (string) ($payload['charge_type'] ?? 'add'); $amountAbs = round(abs((float) ($payload['amount'] ?? 0)), 2); if ($parentId <= 0 || $title === '' || $amountAbs <= 0) { return ['ok' => false, 'message' => 'Invalid extra charge payload.']; } if ($this->findDuplicateExtraCharge($parentId, $schoolYear, $title, $amountAbs, $chargeType)) { Log::warning('Duplicate extra charge blocked', [ 'parent_id' => $parentId, 'title' => $title, 'school_year' => $schoolYear, ]); return [ 'ok' => false, 'message' => 'A matching extra charge already exists for this parent.', 'error' => 'duplicate_charge', ]; } $result = $this->extraCharges->createCharge(array_merge($payload, [ 'school_year' => $schoolYear, 'semester' => $payload['semester'] ?? $this->context->getSemester(), ])); if (! ($result['ok'] ?? false)) { return $result; } return [ 'ok' => true, 'charge_type' => 'extra', 'id' => (int) ($result['id'] ?? 0), 'parent_id' => $parentId, 'invoice_id' => $result['invoice_id'] ?? null, 'status' => ! empty($payload['invoice_id']) ? 'applied' : 'pending', ]; } public function cancelExtraCharge(int $chargeId): array { $charge = AdditionalCharge::query()->find($chargeId); if (! $charge) { return ['ok' => false, 'message' => 'Extra charge not found.']; } if ((string) $charge->status === 'void') { return ['ok' => false, 'message' => 'Charge is already void.', 'error' => 'already_canceled']; } $ok = $this->extraCharges->voidCharge($charge); return [ 'ok' => $ok, 'charge_type' => 'extra', 'id' => $chargeId, 'status' => $ok ? 'void' : (string) $charge->status, 'message' => $ok ? null : 'Unable to void extra charge.', ]; } public function applyExtraCharge(int $chargeId, int $invoiceId): array { $charge = AdditionalCharge::query()->find($chargeId); if (! $charge) { return ['ok' => false, 'message' => 'Extra charge not found.']; } if ((string) $charge->status === 'void') { return ['ok' => false, 'message' => 'Cannot apply a void charge.']; } if ((string) $charge->status === 'applied' && (int) $charge->invoice_id === $invoiceId) { return [ 'ok' => true, 'charge_type' => 'extra', 'id' => $chargeId, 'status' => 'applied', 'invoice_id' => $invoiceId, ]; } if ((string) $charge->status === 'applied') { return ['ok' => false, 'message' => 'Charge is already applied to another invoice.']; } $chargeType = (string) ($charge->charge_type ?? 'add'); $amountAbs = round(abs((float) $charge->amount), 2); DB::beginTransaction(); try { $this->invoices->applyToInvoice($invoiceId, $chargeType, $amountAbs); $charge->invoice_id = $invoiceId; $charge->status = 'applied'; $charge->save(); DB::commit(); } catch (\Throwable $e) { DB::rollBack(); Log::error('Apply extra charge failed', ['charge_id' => $chargeId, 'error' => $e->getMessage()]); return ['ok' => false, 'message' => 'Unable to apply charge to invoice.']; } return [ 'ok' => true, 'charge_type' => 'extra', 'id' => $chargeId, 'status' => 'applied', 'invoice_id' => $invoiceId, ]; } public function createEventCharge(array $payload): array { $parentId = (int) ($payload['parent_id'] ?? 0); $studentId = (int) ($payload['student_id'] ?? 0); $schoolYear = (string) ($payload['school_year'] ?? $this->context->getSchoolYear()); $semester = (string) ($payload['semester'] ?? $this->context->getSemester()); $eventId = isset($payload['event_id']) ? (int) $payload['event_id'] : null; $eventName = trim((string) ($payload['event_name'] ?? '')); $actorId = (int) ($payload['created_by'] ?? $payload['updated_by'] ?? 0); if ($parentId <= 0 || $studentId <= 0) { return ['ok' => false, 'message' => 'Parent and student are required.']; } $amount = isset($payload['amount']) ? (float) $payload['amount'] : null; if ($eventId > 0) { $event = Event::getEvent($eventId, $schoolYear); if (! $event) { return ['ok' => false, 'message' => 'Event not found.']; } $eventName = (string) ($event->event_name ?? $eventName); $amount = $amount ?? (float) ($event->amount ?? 0); } if ($eventName === '' || $amount === null || $amount < 0) { return ['ok' => false, 'message' => 'Event name and amount are required.']; } if ($this->findDuplicateEventCharge($parentId, $studentId, $schoolYear, $semester, $eventId, $eventName)) { Log::warning('Duplicate event charge blocked', [ 'parent_id' => $parentId, 'student_id' => $studentId, 'event_id' => $eventId, 'event_name' => $eventName, ]); return [ 'ok' => false, 'message' => 'This student already has an active charge for this event.', 'error' => 'duplicate_charge', ]; } $data = [ 'event_id' => $eventId > 0 ? $eventId : null, 'event_name' => $eventId > 0 ? null : $eventName, 'parent_id' => $parentId, 'student_id' => $studentId, 'participation' => 'yes', 'charged' => round($amount, 2), 'amount' => round($amount, 2), 'school_year' => $schoolYear, 'semester' => $semester, ]; if (Schema::hasColumn('event_charges', 'created_by')) { $data['created_by'] = $actorId ?: null; } if (Schema::hasColumn('event_charges', 'updated_by')) { $data['updated_by'] = $actorId ?: null; } $charge = EventCharges::query()->create($data); $this->invoiceGeneration->generateInvoice($parentId, $schoolYear); return [ 'ok' => true, 'charge_type' => 'event', 'id' => (int) $charge->id, 'parent_id' => $parentId, 'student_id' => $studentId, 'event_id' => $eventId > 0 ? $eventId : null, 'event_name' => $eventName, 'amount' => round($amount, 2), 'participation_status' => 'yes', 'payment_status' => 'unpaid', 'cancellation_status' => 'active', ]; } public function cancelEventCharge(int $chargeId, bool $issueCredit = true): array { $charge = EventCharges::query()->find($chargeId); if (! $charge) { return ['ok' => false, 'message' => 'Event charge not found.']; } if ($this->isEventChargeCanceled($charge)) { return ['ok' => false, 'message' => 'Event charge is already canceled.', 'error' => 'already_canceled']; } $amount = round(abs((float) ($charge->charged ?? $charge->amount ?? 0)), 2); $eventName = $this->resolveEventName($charge); $parentId = (int) $charge->parent_id; $schoolYear = (string) $charge->school_year; DB::beginTransaction(); try { $charge->participation = 'no'; $charge->charged = 0; if (Schema::hasColumn('event_charges', 'amount')) { $charge->amount = 0; } $charge->save(); $creditId = null; if ($issueCredit && $amount > 0 && $parentId > 0) { $credit = $this->createExtraCharge([ 'parent_id' => $parentId, 'title' => 'Event credit: '.$eventName, 'description' => 'Account credit for canceled event charge #'.$chargeId, 'amount' => $amount, 'charge_type' => 'deduct', 'school_year' => $schoolYear, 'semester' => (string) ($charge->semester ?? $this->context->getSemester()), 'created_by' => (int) ($charge->updated_by ?? 0), ]); if ($credit['ok'] ?? false) { $creditId = (int) ($credit['id'] ?? 0); } } $this->invoiceGeneration->generateInvoice($parentId, $schoolYear); DB::commit(); } catch (\Throwable $e) { DB::rollBack(); Log::error('Cancel event charge failed', ['charge_id' => $chargeId, 'error' => $e->getMessage()]); return ['ok' => false, 'message' => 'Unable to cancel event charge.']; } return [ 'ok' => true, 'charge_type' => 'event', 'id' => $chargeId, 'cancellation_status' => 'canceled', 'participation_status' => 'no', 'account_credit_charge_id' => $creditId, ]; } public function markEventChargePaid(int $chargeId, bool $paid, ?int $paymentId = null): array { $charge = EventCharges::query()->find($chargeId); if (! $charge) { return ['ok' => false, 'message' => 'Event charge not found.']; } if ($this->isEventChargeCanceled($charge)) { return ['ok' => false, 'message' => 'Cannot update payment on a canceled charge.']; } if (Schema::hasColumn('event_charges', 'event_paid')) { $charge->event_paid = $paid ? 1 : 0; } if (Schema::hasColumn('event_charges', 'event_payment_id') && $paymentId !== null) { $charge->event_payment_id = $paymentId; } $charge->save(); return [ 'ok' => true, 'charge_type' => 'event', 'id' => $chargeId, 'payment_status' => $paid ? 'paid' : 'unpaid', ]; } /** * @return array{extra_charges: array>, event_charges: array>} */ public function listForParent(int $parentId, ?string $schoolYear = null): array { $schoolYear = $schoolYear ?? $this->context->getSchoolYear(); $extraRows = AdditionalCharge::query() ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->orderByDesc('id') ->get(); $eventRows = EventCharges::getChargesWithEventInfo($parentId, $schoolYear); return [ 'parent_id' => $parentId, 'school_year' => $schoolYear, 'extra_charges' => $extraRows->map(fn (AdditionalCharge $row) => $this->formatExtraCharge($row))->all(), 'event_charges' => array_map(fn (array $row) => $this->formatEventCharge($row), $eventRows), ]; } private function findDuplicateExtraCharge( int $parentId, string $schoolYear, string $title, float $amountAbs, string $chargeType ): bool { $normalizedTitle = strtolower($title); $signedAmount = $chargeType === 'deduct' ? -$amountAbs : $amountAbs; return AdditionalCharge::query() ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->whereIn('status', ['pending', 'applied']) ->whereRaw('LOWER(title) = ?', [$normalizedTitle]) ->where('amount', $signedAmount) ->exists(); } private function findDuplicateEventCharge( int $parentId, int $studentId, string $schoolYear, string $semester, ?int $eventId, string $eventName ): bool { $query = EventCharges::query() ->where('parent_id', $parentId) ->where('student_id', $studentId) ->where('school_year', $schoolYear) ->where('semester', $semester) ->where('participation', 'yes') ->where('charged', '>', 0); if ($eventId > 0) { $query->where('event_id', $eventId); } else { $query->where('event_name', $eventName); } return $query->exists(); } private function isEventChargeCanceled(EventCharges $charge): bool { if (strtolower((string) $charge->participation) === 'no') { return true; } return (float) ($charge->charged ?? 0) <= 0; } private function resolveEventName(EventCharges $charge): string { if (! empty($charge->event_name)) { return (string) $charge->event_name; } if ($charge->event_id) { $event = Event::query()->find($charge->event_id); if ($event) { return (string) $event->event_name; } } return 'Event'; } private function formatExtraCharge(AdditionalCharge $row): array { return [ 'id' => (int) $row->id, 'charge_type' => 'extra', 'parent_id' => (int) $row->parent_id, 'invoice_id' => $row->invoice_id ? (int) $row->invoice_id : null, 'school_year' => $row->school_year, 'semester' => $row->semester, 'charge_name' => $row->title, 'charge_description' => $row->description, 'amount' => round(abs((float) $row->amount), 2), 'signed_amount' => round((float) $row->amount, 2), 'fee_type' => $row->charge_type, 'due_date' => $row->due_date, 'status' => $row->status, 'approval_status' => $row->status, 'created_by' => $row->created_by, 'created_at' => $row->created_at, ]; } /** * @param array $row */ private function formatEventCharge(array $row): array { $charged = (float) ($row['charged'] ?? 0); $canceled = strtolower((string) ($row['participation'] ?? '')) === 'no' || $charged <= 0; $paid = false; if (isset($row['event_paid'])) { $paid = (bool) $row['event_paid']; } return [ 'id' => (int) ($row['id'] ?? 0), 'charge_type' => 'event', 'event_id' => isset($row['event_id']) ? (int) $row['event_id'] : null, 'event_name' => $row['event_name'] ?? null, 'parent_id' => isset($row['parent_id']) ? (int) $row['parent_id'] : null, 'student_id' => isset($row['student_id']) ? (int) $row['student_id'] : null, 'school_year' => $row['school_year'] ?? null, 'semester' => $row['semester'] ?? null, 'participation_status' => $row['participation'] ?? null, 'charge_amount' => $charged, 'payment_status' => $paid ? 'paid' : ($canceled ? 'canceled' : 'unpaid'), 'cancellation_status' => $canceled ? 'canceled' : 'active', 'event_date' => $row['updated_at'] ?? $row['created_at'] ?? null, ]; } }