From c5485a37bbf0a1fad2d619bc7fc77c97aac456a9 Mon Sep 17 00:00:00 2001 From: Matt Young Date: Tue, 27 Jan 2026 23:15:09 -0600 Subject: [PATCH] Invoice models and migrations --- app/Exceptions/InvoiceLockedException.php | 13 ++++ app/Models/Client.php | 6 ++ app/Models/Contact.php | 8 +++ app/Models/Invoice.php | 36 +++++++++- app/Models/InvoiceLine.php | 67 +++++++++++++++++++ app/Models/Product.php | 28 ++++++++ ..._28_023011_create_client_contact_table.php | 4 +- ...026_01_28_024527_create_invoices_table.php | 6 +- ...026_01_28_040010_create_products_table.php | 32 +++++++++ ...1_28_040017_create_invoice_lines_table.php | 38 +++++++++++ 10 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 app/Exceptions/InvoiceLockedException.php create mode 100644 app/Models/InvoiceLine.php create mode 100644 app/Models/Product.php create mode 100644 database/migrations/2026_01_28_040010_create_products_table.php create mode 100644 database/migrations/2026_01_28_040017_create_invoice_lines_table.php diff --git a/app/Exceptions/InvoiceLockedException.php b/app/Exceptions/InvoiceLockedException.php new file mode 100644 index 0000000..df18cd7 --- /dev/null +++ b/app/Exceptions/InvoiceLockedException.php @@ -0,0 +1,13 @@ +withPivot('is_primary'); } + public function invoices(): HasMany + { + return $this->hasMany(Invoice::class); + } + public function secondaryContacts(): BelongsToMany { return $this->belongsToMany(Contact::class) diff --git a/app/Models/Contact.php b/app/Models/Contact.php index 7aede8e..42d1fc6 100644 --- a/app/Models/Contact.php +++ b/app/Models/Contact.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -14,4 +15,11 @@ class Contact extends Model { return $this->belongsToMany(Client::class); } + + public function invoices(): Builder + { + return Invoice::whereIn('client_id', $this->clients()->pluck('clients.id')); + } + + } diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index 5984941..4d584b0 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -5,19 +5,53 @@ namespace App\Models; use App\Casts\MoneyCast; use App\Enums\InvoiceStatus; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class Invoice extends Model { + protected $fillable = [ + 'invoice_number', + 'client_id', + 'status', + 'invoice_date', + 'sent_at', + 'due_date', + 'notes', + 'internal_notes', + ]; + protected $casts = [ 'total' => MoneyCast::class, 'status' => InvoiceStatus::class, 'invoice_date' => 'date', 'due_date' => 'date', - 'date_sent' => 'date', + 'sent_at' => 'date', ]; + public function client(): BelongsTo + { + return $this->belongsTo(Client::class); + } + + public function lines(): HasMany + { + return $this->hasMany(InvoiceLine::class); + } + public function formattedTotal(): string { return '$'.number_format($this->total, 2); } + + public function recalculateTotal(): void + { + $this->total = $this->lines()->sum('amount'); + $this->saveQuietly(); + } + + public function isLocked(): bool + { + return in_array($this->status, [InvoiceStatus::POSTED, InvoiceStatus::PAID, InvoiceStatus::VOID]); + } } diff --git a/app/Models/InvoiceLine.php b/app/Models/InvoiceLine.php new file mode 100644 index 0000000..caf7a23 --- /dev/null +++ b/app/Models/InvoiceLine.php @@ -0,0 +1,67 @@ + MoneyCast::class, + 'amount' => MoneyCast::class, + ]; + + public static function booted(): void + { + static::saving(function (InvoiceLine $line) { + if ($line->invoice->isLocked()) { + throw new InvoiceLockedException; + } + + $line->amount = $line->unit_price * $line->quantity; + }); + + static::saved(function (InvoiceLine $line) { + $line->invoice->recalculateTotal(); + }); + + static::deleting(function (InvoiceLine $line) { + if ($line->invoice->isLocked()) { + throw new InvoiceLockedException; + } + }); + + static::deleted(function (InvoiceLine $line) { + $line->invoice->recalculateTotal(); + }); + } + + public function invoice(): BelongsTo + { + return $this->belongsTo(Invoice::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + protected function schoolYearFormatted(): Attribute + { + return Attribute::get(fn () => ($this->school_year - 1).'-'.$this->school_year); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 0000000..36b37fe --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,28 @@ + MoneyCast::class, + ]; + + public function invoiceLines(): HasMany + { + return $this->hasMany(InvoiceLine::class); + } + +} diff --git a/database/migrations/2026_01_28_023011_create_client_contact_table.php b/database/migrations/2026_01_28_023011_create_client_contact_table.php index af74b7f..b5c8932 100644 --- a/database/migrations/2026_01_28_023011_create_client_contact_table.php +++ b/database/migrations/2026_01_28_023011_create_client_contact_table.php @@ -15,8 +15,8 @@ return new class extends Migration { Schema::create('client_contact', function (Blueprint $table) { $table->id(); - $table->foreignIdFor(Client::class); - $table->foreignIdFor(Contact::class); + $table->foreignIdFor(Client::class)->constrained(); + $table->foreignIdFor(Contact::class)->constrained(); $table->boolean('is_primary')->default(false); $table->timestamps(); diff --git a/database/migrations/2026_01_28_024527_create_invoices_table.php b/database/migrations/2026_01_28_024527_create_invoices_table.php index 30c80f1..ca76ef1 100644 --- a/database/migrations/2026_01_28_024527_create_invoices_table.php +++ b/database/migrations/2026_01_28_024527_create_invoices_table.php @@ -15,12 +15,12 @@ return new class extends Migration Schema::create('invoices', function (Blueprint $table) { $table->id(); $table->string('invoice_number')->unique(); - $table->foreignIdFor(Client::class); + $table->foreignIdFor(Client::class)->constrained(); $table->string('status')->default('draft'); $table->date('invoice_date')->nullable(); - $table->date('date_sent')->nullable(); + $table->date('sent_at')->nullable(); $table->date('due_date')->nullable(); - $table->integer('total'); + $table->unsignedBigInteger('total')->default(0); $table->text('notes')->nullable(); $table->text('internal_notes')->nullable(); $table->timestamps(); diff --git a/database/migrations/2026_01_28_040010_create_products_table.php b/database/migrations/2026_01_28_040010_create_products_table.php new file mode 100644 index 0000000..b66bed1 --- /dev/null +++ b/database/migrations/2026_01_28_040010_create_products_table.php @@ -0,0 +1,32 @@ +id(); + $table->boolean('active')->default(true); + $table->string('sku')->unique(); + $table->string('name'); + $table->string('description')->nullable(); + $table->unsignedBigInteger('price'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('products'); + } +}; diff --git a/database/migrations/2026_01_28_040017_create_invoice_lines_table.php b/database/migrations/2026_01_28_040017_create_invoice_lines_table.php new file mode 100644 index 0000000..68499bd --- /dev/null +++ b/database/migrations/2026_01_28_040017_create_invoice_lines_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignIdFor(Invoice::class)->constrained(); + $table->foreignIdFor(Product::class)->nullable()->constrained(); + $table->string('sku')->nullable(); + $table->string('name'); + $table->string('description')->nullable(); + $table->unsignedInteger('school_year')->nullable(); + $table->decimal('quantity', 10, 2)->default(1); + $table->unsignedBigInteger('unit_price'); + $table->unsignedBigInteger('amount'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('invoice_lines'); + } +};