'required|is_unique[invoices.invoice_number,id,{id}]', 'total_amount' => 'required|decimal', 'balance' => 'permit_empty|decimal', 'issue_date' => 'required|valid_date[Y-m-d H:i:s]', 'due_date' => 'permit_empty|valid_date[Y-m-d H:i:s]', 'status' => 'required|string|max_length[50]', ]; protected $validationMessages = [ 'invoice_number' => [ 'required' => 'The invoice number is required.', 'is_unique' => 'The invoice number must be unique.', ], 'total_amount' => [ 'required' => 'The total amount is required.', 'decimal' => 'The total amount must be a valid decimal.', ], 'balance' => [ 'decimal' => 'The balance must be a valid decimal.', ], 'issue_date' => [ 'required' => 'The issue date is required.', 'valid_date' => 'The issue date must be a valid datetime (YYYY-MM-DD HH:MM:SS).', ], 'due_date' => [ 'valid_date' => 'The due date must be a valid datetime (YYYY-MM-DD HH:MM:SS).', ], 'status' => [ 'required' => 'The status is required.', 'max_length' => 'The status must not exceed 50 characters.', ], ]; // --- Normalize any incoming date/datetime into UTC 'Y-m-d H:i:s' --- protected $beforeInsert = ['normalizeDateTimes']; protected $beforeUpdate = ['normalizeDateTimes']; protected function normalizeDateTimes(array $data): array { foreach (['issue_date', 'due_date', 'created_at', 'updated_at'] as $field) { if (array_key_exists($field, $data['data'])) { $val = $data['data'][$field]; if ($val === null || $val === '' || $val === '0000-00-00' || $val === '0000-00-00 00:00:00') { // keep NULL/empty for nullable fields $data['data'][$field] = ($field === 'due_date') ? null : $val; continue; } $normalized = $this->toUtcDateTimeString($val); if ($normalized !== null) { $data['data'][$field] = $normalized; } // else leave as-is; validation will catch invalid format } } return $data; } private function toUtcDateTimeString($raw): ?string { if ($raw instanceof \DateTimeInterface) { $dt = (new \DateTimeImmutable('@' . $raw->getTimestamp())) ->setTimezone(new \DateTimeZone('UTC')); return $dt->format('Y-m-d H:i:s'); } $s = trim((string) $raw); if ($s === '') return null; // Try strict formats first $utc = new \DateTimeZone('UTC'); $dt = \DateTime::createFromFormat('Y-m-d H:i:s', $s, $utc); if ($dt !== false) { $dt->setTimezone($utc); return $dt->format('Y-m-d H:i:s'); } $d = \DateTime::createFromFormat('Y-m-d', $s, $utc); if ($d !== false) { // If date-only sneaks in, store at 12:00:00 to avoid boundary confusion $d->setTime(12, 0, 0); $d->setTimezone($utc); return $d->format('Y-m-d H:i:s'); } // Fallback parser (may accept ISO8601, etc.) try { $flex = new \DateTime($s); $flex->setTimezone($utc); return $flex->format('Y-m-d H:i:s'); } catch (\Throwable $e) { return null; } } // ----------------------- Queries ----------------------- public function getInvoicesByUserId($userId, $schoolYear = null) { $builder = $this->db->table($this->table . ' i') ->select('i.*') ->select('(SELECT COALESCE(SUM(du.discount_amount),0) FROM discount_usages du WHERE du.invoice_id = i.id) AS discount', false) ->where('i.parent_id', $userId); if ($schoolYear !== null) { $builder->where('i.school_year', $schoolYear); } return $builder->get()->getResultArray(); } public function updateInvoiceStatus($invoiceId, $status) { return $this->update($invoiceId, ['status' => $status]); } public function getUnpaidInvoices() { return $this->whereIn('status', ['Unpaid', 'Partially Paid', 'unpaid', 'partially paid']) ->orderBy('due_date', 'ASC') ->findAll(); } public function getLatestInvoiceTotalAmount($parentId) { return $this->where('parent_id', $parentId) ->orderBy('created_at', 'DESC') ->select('total_amount') ->get() ->getRow('total_amount'); } public function getLatestInvoicePaidAmount($parentId) { return $this->where('parent_id', $parentId) ->orderBy('created_at', 'DESC') ->select('paid_amount') ->get() ->getRow('paid_amount'); } public function getLatestInvoiceBalance($parentId) { $invoice = $this->where('parent_id', $parentId) ->orderBy('created_at', 'DESC') ->select('balance') ->get() ->getRow(); return $invoice ? $invoice->balance : null; } public function getInvoiceEventCharges($invoiceId) { return $this->db->table('invoice_event') ->where('invoice_id', $invoiceId) ->get() ->getResultArray(); } public function getInvoicesByParentId($parentId, $schoolYear = null) { $builder = $this->db->table($this->table . ' i') ->select('i.*') ->select('(SELECT COALESCE(SUM(du.discount_amount),0) FROM discount_usages du WHERE du.invoice_id = i.id) AS discount', false) ->where('i.parent_id', $parentId); if (!empty($schoolYear)) { $builder->where('i.school_year', $schoolYear); } return $builder->orderBy('i.created_at', 'DESC')->get()->getResultArray(); } public function getLatestInvoiceBalanceByYear($parentId, $schoolYear) { return $this->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->orderBy('created_at', 'DESC') ->select('total_amount, paid_amount, balance, updated_at') ->get() ->getRowArray(); } public function getDiscountedInvoicesByParent($parentId) { return $this->where('parent_id', $parentId) ->where('has_discount', 1) ->findAll(); } /* ======================= * Query helpers * ======================= */ /** * Multi-parent variant (useful when preloading for all parents). */ public function getInvoicesByUserIds(array $parentIds, string $schoolYear): array { if (empty($parentIds)) return []; $builder = $this->db->table($this->table . ' i') ->select('i.id,i.parent_id,i.invoice_number,i.status,i.balance,i.total_amount,i.issue_date,i.due_date') ->select('(SELECT COALESCE(SUM(du.discount_amount),0) FROM discount_usages du WHERE du.invoice_id = i.id) AS discount', false) ->whereIn('i.parent_id', array_map('intval', $parentIds)) ->where('i.school_year', $schoolYear) ->orderBy('i.issue_date', 'DESC') ->orderBy('i.id', 'DESC'); return $builder->get()->getResultArray(); } /* ======================= * Additional charge math * ======================= */ /** * Atomically add an extra amount to additional_charge (and optionally totals). * Adjust total_amount and balance too (comment out if you don't want that). */ public function applyAdditionalCharge(int $invoiceId, float $amount): bool { $val = number_format(abs($amount), 2, '.', ''); $ok = $this->db->query( "UPDATE {$this->table} SET total_amount = total_amount + {$val}, balance = balance + {$val} WHERE id = ?", [$invoiceId] ); return $ok && $this->db->affectedRows() > 0; } public function deductAdditionalCharge(int $invoiceId, float $amount): bool { $val = number_format(abs($amount), 2, '.', ''); $ok = $this->db->query( "UPDATE {$this->table} SET total_amount = GREATEST(total_amount - {$val}, 0), balance = GREATEST(balance - {$val}, 0) WHERE id = ?", [$invoiceId] ); return $ok && $this->db->affectedRows() > 0; } public function getAllInvoicesByUserIds(array $parentIds, string $schoolYear): array { if (empty($parentIds)) return []; $parentIds = array_map('intval', $parentIds); $builder = $this->db->table($this->table . ' i') ->select('i.id, i.parent_id, i.invoice_number, i.status, i.balance, i.issue_date') ->select('(SELECT COALESCE(SUM(du.discount_amount),0) FROM discount_usages du WHERE du.invoice_id = i.id) AS discount', false) ->whereIn('i.parent_id', $parentIds) ->where('i.school_year', $schoolYear) ->orderBy('i.issue_date', 'DESC') ->orderBy('i.id', 'DESC'); return $builder->get()->getResultArray(); } /** * Reverse an extra amount from additional_charge (and totals), never below 0. */ public function reverseAdditionalCharge(int $invoiceId, float $amount): bool { $amount = round($amount, 2); $tb = $this->db->table($this->table); $val = $this->db->escape($amount); return $tb->set('total_amount', "GREATEST(total_amount - $val, 0)", false) ->set('balance', "GREATEST(balance - $val, 0)", false) ->where('id', $invoiceId) ->update(); } }