Show total payments and balance

This commit is contained in:
Matt Young 2026-01-28 22:09:27 -06:00
parent 51ef80b0ba
commit 28288ee722
6 changed files with 89 additions and 8 deletions

View File

@ -14,11 +14,12 @@ use Illuminate\Support\Str;
class Invoice extends Model class Invoice extends Model
{ {
use HasFactory; use HasFactory;
public static function booted(): void public static function booted(): void
{ {
static::creating(function (Invoice $invoice) { static::creating(function (Invoice $invoice) {
$invoice->invoice_number ??= static::generateInvoiceNumber(); $invoice->invoice_number ??= static::generateInvoiceNumber();
$invoice->uuid = (string) Str::uuid(); $invoice->uuid = (string) Str::uuid();
}); });
} }
@ -45,11 +46,13 @@ class Invoice extends Model
]; ];
protected $casts = [ protected $casts = [
'total' => MoneyCast::class, 'total' => MoneyCast::class,
'status' => InvoiceStatus::class, 'total_payments' => MoneyCast::class,
'invoice_date' => 'date', 'balance_due' => MoneyCast::class,
'due_date' => 'date', 'status' => InvoiceStatus::class,
'sent_at' => 'date', 'invoice_date' => 'date',
'due_date' => 'date',
'sent_at' => 'date',
]; ];
/** /**
@ -84,6 +87,22 @@ class Invoice extends Model
$this->saveQuietly(); $this->saveQuietly();
} }
public function recalculateTotalPayments(): void
{
$this->attributes['total_payments'] = $this->payments()->sum('amount');
$this->saveQuietly();
$this->refresh();
if ($this->status === InvoiceStatus::POSTED && $this->balance_due == 0) {
$this->status = InvoiceStatus::PAID;
$this->saveQuietly();
} elseif ($this->status === InvoiceStatus::PAID && $this->balance_due != 0) {
$this->status = InvoiceStatus::POSTED;
$this->saveQuietly();
}
}
public function isLocked(): bool public function isLocked(): bool
{ {
return in_array($this->status, [InvoiceStatus::POSTED, InvoiceStatus::PAID, InvoiceStatus::VOID]); return in_array($this->status, [InvoiceStatus::POSTED, InvoiceStatus::PAID, InvoiceStatus::VOID]);

View File

@ -33,6 +33,10 @@ class InvoiceLine extends Model
throw new InvoiceLockedException; throw new InvoiceLockedException;
} }
if ($line->exists && $line->isDirty('invoice_id')) {
throw new \RuntimeException('Cannot move invoice line to another invoice');
}
$line->amount = $line->unit_price * $line->quantity; $line->amount = $line->unit_price * $line->quantity;
}); });

View File

@ -31,6 +31,25 @@ class Payment extends Model
'payment_method' => PaymentMethod::class, 'payment_method' => PaymentMethod::class,
]; ];
public static function booted(): void
{
static::saved(function (Payment $payment) {
// If invoice_id changed, recalculate the old invoice too
if ($payment->wasChanged('invoice_id')) {
$originalInvoiceId = $payment->getOriginal('invoice_id');
if ($originalInvoiceId) {
Invoice::find($originalInvoiceId)?->recalculateTotalPayments();
}
}
$payment->invoice->recalculateTotalPayments();
});
static::deleted(function (Payment $payment) {
$payment->invoice->recalculateTotalPayments();
});
}
public function invoice(): BelongsTo public function invoice(): BelongsTo
{ {
return $this->belongsTo(Invoice::class); return $this->belongsTo(Invoice::class);

View File

@ -0,0 +1,29 @@
<?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::table('invoices', function (Blueprint $table) {
$table->bigInteger('total_payments')->default(0)->after('total');
$table->bigInteger('balance_due')->storedAs('total - total_payments')->after('total_payments');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('invoices', function (Blueprint $table) {
$table->dropColumn(['balance_due', 'total_payments']);
});
}
};

View File

@ -104,7 +104,7 @@ new class extends Component {
<option value="">Select an invoice...</option> <option value="">Select an invoice...</option>
@foreach ($this->invoices as $invoice) @foreach ($this->invoices as $invoice)
<option value="{{ $invoice->id }}">{{ $invoice->client->abbreviation }} <option value="{{ $invoice->id }}">{{ $invoice->client->abbreviation }}
- {{ $invoice->invoice_number }}</option> - {{ $invoice->invoice_number }} - Balance: {{ formatMoney($invoice->balance_due) }}</option>
@endforeach @endforeach
</flux:select> </flux:select>

View File

@ -70,7 +70,15 @@ new class extends Component {
</flux:table.column> </flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'total'" :direction="$sortDirection" <flux:table.column sortable :sorted="$sortBy === 'total'" :direction="$sortDirection"
wire:click="sort('total')"> wire:click="sort('total')">
Total Invoice Total
</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'total_payments'" :direction="$sortDirection"
wire:click="sort('total_payments')">
Total Payments
</flux:table.column>
<flux:table.column sortable :sorted="$sortBy === 'balance_due'" :direction="$sortDirection"
wire:click="sort('balance_due')">
Balance Due
</flux:table.column> </flux:table.column>
<flux:table.column></flux:table.column> <flux:table.column></flux:table.column>
</flux:table.columns> </flux:table.columns>
@ -104,6 +112,8 @@ new class extends Component {
@endif @endif
</flux:table.cell> </flux:table.cell>
<flux:table.cell>{{ formatMoney($invoice->total) }}</flux:table.cell> <flux:table.cell>{{ formatMoney($invoice->total) }}</flux:table.cell>
<flux:table.cell>{{ formatMoney($invoice->total_payments) }}</flux:table.cell>
<flux:table.cell>{{ formatMoney($invoice->balance_due) }}</flux:table.cell>
<flux:table.cell> <flux:table.cell>
<flux:dropdown position="bottom" align="start"> <flux:dropdown position="bottom" align="start">
<flux:button variant="ghost" size="sm" icon="ellipsis-horizontal" inset="top bottom"></flux:button> <flux:button variant="ghost" size="sm" icon="ellipsis-horizontal" inset="top bottom"></flux:button>