reconstruction of the project
This commit is contained in:
+220
-195
@@ -3,11 +3,19 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\BaseModel;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class Invoice extends BaseModel
|
||||
{
|
||||
protected $table = 'invoices';
|
||||
protected $primaryKey = 'id';
|
||||
|
||||
/**
|
||||
* CI: useTimestamps = false because DB defaults/triggers manage created_at/updated_at.
|
||||
* In Laravel, keep timestamps OFF and let DB handle them.
|
||||
*/
|
||||
public $timestamps = true;
|
||||
|
||||
protected $fillable = [
|
||||
'parent_id',
|
||||
'invoice_number',
|
||||
@@ -26,273 +34,290 @@ class Invoice extends BaseModel
|
||||
'semester',
|
||||
];
|
||||
|
||||
// Your table handles timestamps (defaults/triggers), keep CI auto off
|
||||
public $timestamps = true;
|
||||
protected $casts = [
|
||||
'parent_id' => 'integer',
|
||||
'updated_by' => 'integer',
|
||||
'has_discount' => 'boolean',
|
||||
|
||||
// --- 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]',
|
||||
];
|
||||
'total_amount' => 'decimal:2',
|
||||
'balance' => 'decimal:2',
|
||||
'paid_amount' => 'decimal:2',
|
||||
|
||||
|
||||
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.',
|
||||
],
|
||||
'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',
|
||||
];
|
||||
|
||||
// --- 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
|
||||
/* Optional relationships */
|
||||
public function parent()
|
||||
{
|
||||
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;
|
||||
return $this->belongsTo(User::class, 'parent_id');
|
||||
}
|
||||
|
||||
private function toUtcDateTimeString($raw): ?string
|
||||
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) {
|
||||
$dt = (new \DateTimeImmutable('@' . $raw->getTimestamp()))
|
||||
->setTimezone(new \DateTimeZone('UTC'));
|
||||
return $dt->format('Y-m-d H:i:s');
|
||||
return Carbon::instance($raw)->utc()->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.)
|
||||
// strict datetime
|
||||
try {
|
||||
$flex = new \DateTime($s);
|
||||
$flex->setTimezone($utc);
|
||||
return $flex->format('Y-m-d H:i:s');
|
||||
$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 -----------------------
|
||||
/* =========================
|
||||
* Queries (ported from CI)
|
||||
* ========================= */
|
||||
|
||||
public function getInvoicesByUserId($userId, $schoolYear = null)
|
||||
public static function getInvoicesByUserId(int $userId, ?string $schoolYear = null): array
|
||||
{
|
||||
$builder = $this->where('parent_id', $userId);
|
||||
$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) {
|
||||
$builder->where('school_year', $schoolYear);
|
||||
$q->where('i.school_year', $schoolYear);
|
||||
}
|
||||
return $builder->findAll();
|
||||
|
||||
return $q->get()->map(fn ($r) => (array) $r)->all();
|
||||
}
|
||||
|
||||
public function updateInvoiceStatus($invoiceId, $status)
|
||||
public static function updateInvoiceStatus(int $invoiceId, string $status): bool
|
||||
{
|
||||
return $this->update($invoiceId, ['status' => $status]);
|
||||
return DB::table('invoices')->where('id', $invoiceId)->update(['status' => $status]) >= 0;
|
||||
}
|
||||
|
||||
public function getUnpaidInvoices()
|
||||
public static function getUnpaidInvoices(): array
|
||||
{
|
||||
return $this->where('status', 'unpaid')
|
||||
return DB::table('invoices')
|
||||
->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');
|
||||
->map(fn ($r) => (array) $r)
|
||||
->all();
|
||||
}
|
||||
|
||||
public function getLatestInvoicePaidAmount($parentId)
|
||||
public static function getLatestInvoiceTotalAmount(int $parentId)
|
||||
{
|
||||
return $this->where('parent_id', $parentId)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->select('paid_amount')
|
||||
->get()
|
||||
->getRow('paid_amount');
|
||||
return DB::table('invoices')
|
||||
->where('parent_id', $parentId)
|
||||
->orderByDesc('created_at')
|
||||
->value('total_amount');
|
||||
}
|
||||
|
||||
public function getLatestInvoiceBalance($parentId)
|
||||
public static function getLatestInvoicePaidAmount(int $parentId)
|
||||
{
|
||||
$invoice = $this->where('parent_id', $parentId)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->select('balance')
|
||||
->get()
|
||||
->getRow();
|
||||
|
||||
return $invoice ? $invoice->balance : null;
|
||||
return DB::table('invoices')
|
||||
->where('parent_id', $parentId)
|
||||
->orderByDesc('created_at')
|
||||
->value('paid_amount');
|
||||
}
|
||||
|
||||
public function getInvoiceEventCharges($invoiceId)
|
||||
public static function getLatestInvoiceBalance(int $parentId)
|
||||
{
|
||||
return $this->db->table('invoice_event')
|
||||
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()
|
||||
->getResultArray();
|
||||
->map(fn ($r) => (array) $r)
|
||||
->all();
|
||||
}
|
||||
|
||||
public function getInvoicesByParentId($parentId, $schoolYear = null)
|
||||
public static function getInvoicesByParentId(int $parentId, ?string $schoolYear = null): array
|
||||
{
|
||||
$builder = $this->where('parent_id', $parentId);
|
||||
$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)) {
|
||||
$builder->where('school_year', $schoolYear);
|
||||
$q->where('i.school_year', $schoolYear);
|
||||
}
|
||||
return $builder->orderBy('created_at', 'DESC')->findAll();
|
||||
|
||||
return $q->orderByDesc('i.created_at')->get()->map(fn ($r) => (array) $r)->all();
|
||||
}
|
||||
|
||||
public function getLatestInvoiceBalanceByYear($parentId, $schoolYear)
|
||||
public static function getLatestInvoiceBalanceByYear(int $parentId, string $schoolYear): ?array
|
||||
{
|
||||
return $this->where('parent_id', $parentId)
|
||||
$row = DB::table('invoices')
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->select('total_amount, paid_amount, balance, updated_at')
|
||||
->get()
|
||||
->getRowArray();
|
||||
->orderByDesc('created_at')
|
||||
->select(['total_amount', 'paid_amount', 'balance', 'updated_at'])
|
||||
->first();
|
||||
|
||||
return $row ? (array) $row : null;
|
||||
}
|
||||
|
||||
public function getDiscountedInvoicesByParent($parentId)
|
||||
public static function getDiscountedInvoicesByParent(int $parentId): array
|
||||
{
|
||||
return $this->where('parent_id', $parentId)
|
||||
return DB::table('invoices')
|
||||
->where('parent_id', $parentId)
|
||||
->where('has_discount', 1)
|
||||
->findAll();
|
||||
->get()
|
||||
->map(fn ($r) => (array) $r)
|
||||
->all();
|
||||
}
|
||||
|
||||
/* =======================
|
||||
* Query helpers
|
||||
* ======================= */
|
||||
|
||||
|
||||
/**
|
||||
* Multi-parent variant (useful when preloading for all parents).
|
||||
* Multi-parent variant (preload invoices for multiple parents).
|
||||
*/
|
||||
public function getInvoicesByUserIds(array $parentIds, string $schoolYear): array
|
||||
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 $this->select('id,parent_id,invoice_number,status,balance,total_amount,issue_date,due_date')
|
||||
->whereIn('parent_id', array_map('intval', $parentIds))
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('issue_date', 'DESC')
|
||||
->orderBy('id', 'DESC')
|
||||
->findAll();
|
||||
|
||||
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
|
||||
* Additional charge math (atomic)
|
||||
* ======================= */
|
||||
|
||||
/**
|
||||
* 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 static function applyAdditionalCharge(int $invoiceId, float $amount): bool
|
||||
{
|
||||
$val = number_format(abs($amount), 2, '.', '');
|
||||
|
||||
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;
|
||||
}
|
||||
$affected = DB::update(
|
||||
"UPDATE invoices
|
||||
SET total_amount = total_amount + {$val},
|
||||
balance = balance + {$val}
|
||||
WHERE id = ?",
|
||||
[$invoiceId]
|
||||
);
|
||||
|
||||
return $affected > 0;
|
||||
}
|
||||
|
||||
public function getAllInvoicesByUserIds(array $parentIds, string $schoolYear): array
|
||||
{
|
||||
if (empty($parentIds)) return [];
|
||||
$parentIds = array_map('intval', $parentIds);
|
||||
return $this->select('id, parent_id, invoice_number, status, balance, issue_date')
|
||||
->whereIn('parent_id', $parentIds)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('issue_date', 'DESC')
|
||||
->orderBy('id', 'DESC')
|
||||
->findAll();
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse an extra amount from additional_charge (and totals), never below 0.
|
||||
*/
|
||||
public function reverseAdditionalCharge(int $invoiceId, float $amount): bool
|
||||
public static 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();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
$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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user