'integer', 'updated_by' => 'integer', 'has_discount' => 'boolean', 'total_amount' => 'decimal:2', 'balance' => 'decimal:2', 'paid_amount' => 'decimal:2', 'issue_date' => 'datetime', 'due_date' => 'datetime', // created_at/updated_at exist but are DB-managed; you can still cast if you like: 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; /* Optional relationships */ public function parent() { return $this->belongsTo(User::class, 'parent_id'); } public function events() { return $this->hasMany(InvoiceEvent::class, 'invoice_id'); } public function discountUsages() { return $this->hasMany(DiscountUsage::class, 'invoice_id'); } /* ========================= * CI beforeInsert/beforeUpdate equivalents (normalize to UTC) * ========================= */ protected static function booted(): void { static::creating(function (self $m) { $m->normalizeDateTimes(); }); static::updating(function (self $m) { $m->normalizeDateTimes(); }); } private function normalizeDateTimes(): void { foreach (['issue_date', 'due_date', 'created_at', 'updated_at'] as $field) { if (!$this->isDirty($field)) continue; $val = $this->{$field}; if ($val === null || $val === '' || $val === '0000-00-00' || $val === '0000-00-00 00:00:00') { // keep NULL for due_date; keep other values as-is (DB defaults/triggers) if ($field === 'due_date') $this->{$field} = null; continue; } $normalized = static::toUtcDateTimeString($val); if ($normalized !== null) { $this->{$field} = $normalized; } } } private static function toUtcDateTimeString($raw): ?string { if ($raw instanceof \DateTimeInterface) { return Carbon::instance($raw)->utc()->format('Y-m-d H:i:s'); } $s = trim((string) $raw); if ($s === '') return null; // strict datetime try { $dt = Carbon::createFromFormat('Y-m-d H:i:s', $s, 'UTC'); return $dt->utc()->format('Y-m-d H:i:s'); } catch (\Throwable $e) { // ignore } // date-only sneaks in => store at 12:00:00 try { $d = Carbon::createFromFormat('Y-m-d', $s, 'UTC')->setTime(12, 0, 0); return $d->utc()->format('Y-m-d H:i:s'); } catch (\Throwable $e) { // ignore } // fallback parser (ISO8601 etc.) try { return Carbon::parse($s)->utc()->format('Y-m-d H:i:s'); } catch (\Throwable $e) { return null; } } /* ========================= * Queries (ported from CI) * ========================= */ public static function getInvoicesByUserId(int $userId, ?string $schoolYear = null): array { $q = DB::table('invoices as i') ->select('i.*') ->selectRaw('(SELECT COALESCE(SUM(du.discount_amount),0) FROM discount_usages du WHERE du.invoice_id = i.id) AS discount') ->where('i.parent_id', $userId); if ($schoolYear !== null) { $q->where('i.school_year', $schoolYear); } return $q->get()->map(fn ($r) => (array) $r)->all(); } public static function updateInvoiceStatus(int $invoiceId, string $status): bool { return DB::table('invoices')->where('id', $invoiceId)->update(['status' => $status]) >= 0; } public static function getUnpaidInvoices(): array { return DB::table('invoices') ->whereIn('status', ['Unpaid', 'Partially Paid', 'unpaid', 'partially paid']) ->orderBy('due_date', 'ASC') ->get() ->map(fn ($r) => (array) $r) ->all(); } public static function getLatestInvoiceTotalAmount(int $parentId) { return DB::table('invoices') ->where('parent_id', $parentId) ->orderByDesc('created_at') ->value('total_amount'); } public static function getLatestInvoicePaidAmount(int $parentId) { return DB::table('invoices') ->where('parent_id', $parentId) ->orderByDesc('created_at') ->value('paid_amount'); } public static function getLatestInvoiceBalance(int $parentId) { return DB::table('invoices') ->where('parent_id', $parentId) ->orderByDesc('created_at') ->value('balance'); } public static function getInvoiceEventCharges(int $invoiceId): array { return DB::table('invoice_event') ->where('invoice_id', $invoiceId) ->get() ->map(fn ($r) => (array) $r) ->all(); } public static function getInvoicesByParentId(int $parentId, ?string $schoolYear = null): array { $q = DB::table('invoices as i') ->select('i.*') ->selectRaw('(SELECT COALESCE(SUM(du.discount_amount),0) FROM discount_usages du WHERE du.invoice_id = i.id) AS discount') ->where('i.parent_id', $parentId); if (!empty($schoolYear)) { $q->where('i.school_year', $schoolYear); } return $q->orderByDesc('i.created_at')->get()->map(fn ($r) => (array) $r)->all(); } public static function getLatestInvoiceBalanceByYear(int $parentId, string $schoolYear): ?array { $row = DB::table('invoices') ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->orderByDesc('created_at') ->select(['total_amount', 'paid_amount', 'balance', 'updated_at']) ->first(); return $row ? (array) $row : null; } public static function getDiscountedInvoicesByParent(int $parentId): array { return DB::table('invoices') ->where('parent_id', $parentId) ->where('has_discount', 1) ->get() ->map(fn ($r) => (array) $r) ->all(); } /** * Multi-parent variant (preload invoices for multiple parents). */ public static function getInvoicesByUserIds(array $parentIds, string $schoolYear): array { $parentIds = array_values(array_unique(array_map('intval', array_filter($parentIds)))); if (empty($parentIds)) return []; return DB::table('invoices as i') ->select(['i.id','i.parent_id','i.invoice_number','i.status','i.balance','i.total_amount','i.issue_date','i.due_date']) ->selectRaw('(SELECT COALESCE(SUM(du.discount_amount),0) FROM discount_usages du WHERE du.invoice_id = i.id) AS discount') ->whereIn('i.parent_id', $parentIds) ->where('i.school_year', $schoolYear) ->orderByDesc('i.issue_date') ->orderByDesc('i.id') ->get() ->map(fn ($r) => (array) $r) ->all(); } public static function getAllInvoicesByUserIds(array $parentIds, string $schoolYear): array { $parentIds = array_values(array_unique(array_map('intval', array_filter($parentIds)))); if (empty($parentIds)) return []; return DB::table('invoices as i') ->select(['i.id','i.parent_id','i.invoice_number','i.status','i.balance','i.issue_date']) ->selectRaw('(SELECT COALESCE(SUM(du.discount_amount),0) FROM discount_usages du WHERE du.invoice_id = i.id) AS discount') ->whereIn('i.parent_id', $parentIds) ->where('i.school_year', $schoolYear) ->orderByDesc('i.issue_date') ->orderByDesc('i.id') ->get() ->map(fn ($r) => (array) $r) ->all(); } /* ======================= * Additional charge math (atomic) * ======================= */ public static function applyAdditionalCharge(int $invoiceId, float $amount): bool { $val = number_format(abs($amount), 2, '.', ''); $affected = DB::update( "UPDATE invoices SET total_amount = total_amount + {$val}, balance = balance + {$val} WHERE id = ?", [$invoiceId] ); return $affected > 0; } public static function deductAdditionalCharge(int $invoiceId, float $amount): bool { $val = number_format(abs($amount), 2, '.', ''); $affected = DB::update( "UPDATE invoices SET total_amount = GREATEST(total_amount - {$val}, 0), balance = GREATEST(balance - {$val}, 0) WHERE id = ?", [$invoiceId] ); return $affected > 0; } public static function reverseAdditionalCharge(int $invoiceId, float $amount): bool { $amount = round($amount, 2); $affected = DB::table('invoices') ->where('id', $invoiceId) ->update([ 'total_amount' => DB::raw('GREATEST(total_amount - ' . $amount . ', 0)'), 'balance' => DB::raw('GREATEST(balance - ' . $amount . ', 0)'), ]); return $affected >= 0; } }