Invoice models and migrations

This commit is contained in:
Matt Young 2026-01-27 23:15:09 -06:00
parent 67de82a525
commit c5485a37bb
10 changed files with 232 additions and 6 deletions

View File

@ -0,0 +1,13 @@
<?php
namespace App\Exceptions;
use Exception;
class InvoiceLockedException extends Exception
{
public function __construct()
{
parent::__construct('Cannot modify a posted, void, or paid invoice.');
}
}

View File

@ -5,6 +5,7 @@ namespace App\Models;
use App\Enums\ClientStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Client extends Model
{
@ -33,6 +34,11 @@ class Client extends Model
->withPivot('is_primary');
}
public function invoices(): HasMany
{
return $this->hasMany(Invoice::class);
}
public function secondaryContacts(): BelongsToMany
{
return $this->belongsToMany(Contact::class)

View File

@ -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'));
}
}

View File

@ -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]);
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Models;
use App\Casts\MoneyCast;
use App\Exceptions\InvoiceLockedException;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class InvoiceLine extends Model
{
protected $fillable = [
'invoice_id',
'sku',
'name',
'description',
'school_year',
'quantity',
'unit_price',
];
protected $casts = [
'unit_price' => 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);
}
}

28
app/Models/Product.php Normal file
View File

@ -0,0 +1,28 @@
<?php
namespace App\Models;
use App\Casts\MoneyCast;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Product extends Model
{
protected $fillable = [
'active',
'sku',
'name',
'description',
'price',
];
protected $casts = [
'price' => MoneyCast::class,
];
public function invoiceLines(): HasMany
{
return $this->hasMany(InvoiceLine::class);
}
}

View File

@ -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();

View File

@ -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();

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,38 @@
<?php
use App\Models\Invoice;
use App\Models\Product;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('invoice_lines', function (Blueprint $table) {
$table->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');
}
};