Files
alrahma_sunday_school/app/Models/InvoiceModel.php
T
2026-02-10 22:11:06 -05:00

311 lines
10 KiB
PHP

<?php
namespace App\Models;
use CodeIgniter\Model;
class InvoiceModel extends Model
{
protected $table = 'invoices';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $allowedFields = [
'parent_id',
'invoice_number',
'total_amount',
'school_year',
'semester',
'balance',
'paid_amount',
'has_discount',
'issue_date', // DATETIME
'due_date', // DATETIME (nullable)
'status',
'description',
'created_at', // DATETIME (DB default/trigger)
'updated_at', // DATETIME (DB default/trigger)
'updated_by',
];
// Your table handles timestamps (defaults/triggers), keep CI auto off
protected $useTimestamps = false;
// --- Validate as DATETIME strings like "YYYY-MM-DD HH:MM:SS" ---
protected $validationRules = [
// allow update without tripping uniqueness
'invoice_number' => '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();
}
}