Skip to content

Commit f26eae4

Browse files
committed
feat(finance): Phase 120 — Finance Debit Notes
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 14012ea commit f26eae4

11 files changed

Lines changed: 425 additions & 1 deletion

File tree

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\DebitNote;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class DebitNoteController extends Controller
13+
{
14+
public function index(): Response
15+
{
16+
$this->authorize('viewAny', DebitNote::class);
17+
$debitNotes = DebitNote::where('tenant_id', app('tenant')->id)
18+
->latest()
19+
->paginate(20);
20+
return Inertia::render('Finance/DebitNotes/Index', compact('debitNotes'));
21+
}
22+
23+
public function store(Request $request): RedirectResponse
24+
{
25+
$this->authorize('create', DebitNote::class);
26+
$validated = $request->validate([
27+
'vendor_id' => 'nullable|exists:contacts,id',
28+
'vendor_bill_id'=> 'nullable|exists:vendor_bills,id',
29+
'issue_date' => 'required|date',
30+
'currency' => 'nullable|string|max:3',
31+
'reason' => 'nullable|string',
32+
]);
33+
$validated['tenant_id'] = app('tenant')->id;
34+
$validated['created_by'] = auth()->id();
35+
DebitNote::create($validated);
36+
return back()->with('success', 'Debit note created.');
37+
}
38+
39+
public function show(DebitNote $debitNote): Response
40+
{
41+
$this->authorize('view', $debitNote);
42+
return Inertia::render('Finance/DebitNotes/Show', compact('debitNote'));
43+
}
44+
45+
public function issue(DebitNote $debitNote): RedirectResponse
46+
{
47+
$this->authorize('update', $debitNote);
48+
$debitNote->issue();
49+
return back()->with('success', 'Debit note issued.');
50+
}
51+
52+
public function apply(DebitNote $debitNote): RedirectResponse
53+
{
54+
$this->authorize('update', $debitNote);
55+
$debitNote->apply();
56+
return back()->with('success', 'Debit note applied.');
57+
}
58+
59+
public function void(DebitNote $debitNote): RedirectResponse
60+
{
61+
$this->authorize('update', $debitNote);
62+
$debitNote->void();
63+
return back()->with('success', 'Debit note voided.');
64+
}
65+
66+
public function destroy(DebitNote $debitNote): RedirectResponse
67+
{
68+
$this->authorize('delete', $debitNote);
69+
$debitNote->delete();
70+
return back()->with('success', 'Debit note deleted.');
71+
}
72+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
use Illuminate\Database\Eloquent\Relations\HasMany;
9+
use Illuminate\Database\Eloquent\SoftDeletes;
10+
11+
class DebitNote extends Model
12+
{
13+
use BelongsToTenant, SoftDeletes;
14+
15+
protected $fillable = [
16+
'tenant_id', 'debit_note_number', 'vendor_id', 'vendor_bill_id',
17+
'issue_date', 'currency', 'subtotal', 'tax', 'total',
18+
'status', 'reason', 'created_by',
19+
];
20+
21+
protected $attributes = [
22+
'status' => 'draft',
23+
];
24+
25+
protected $casts = [
26+
'issue_date' => 'date',
27+
'subtotal' => 'float',
28+
'tax' => 'float',
29+
'total' => 'float',
30+
];
31+
32+
// Relations
33+
34+
public function items(): HasMany
35+
{
36+
return $this->hasMany(DebitNoteItem::class);
37+
}
38+
39+
public function vendor(): BelongsTo
40+
{
41+
return $this->belongsTo(Contact::class, 'vendor_id');
42+
}
43+
44+
// Methods
45+
46+
public function generateDebitNoteNumber(): string
47+
{
48+
return 'DN-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT);
49+
}
50+
51+
public function recalculateTotals(): void
52+
{
53+
$items = $this->items()->get();
54+
$subtotal = $items->sum(fn ($item) => $item->quantity * $item->unit_price);
55+
$tax = $items->sum(fn ($item) => $item->quantity * $item->unit_price * $item->tax_rate / 100);
56+
$this->subtotal = $subtotal;
57+
$this->tax = $tax;
58+
$this->total = $subtotal + $tax;
59+
$this->save();
60+
}
61+
62+
public function issue(): void
63+
{
64+
$this->status = 'issued';
65+
if (is_null($this->debit_note_number)) {
66+
$this->debit_note_number = $this->generateDebitNoteNumber();
67+
}
68+
$this->save();
69+
}
70+
71+
public function apply(): void
72+
{
73+
$this->status = 'applied';
74+
$this->save();
75+
}
76+
77+
public function void(): void
78+
{
79+
$this->status = 'void';
80+
$this->save();
81+
}
82+
83+
// Accessors
84+
85+
public function getIsOpenAttribute(): bool
86+
{
87+
return in_array($this->status, ['draft', 'issued']);
88+
}
89+
90+
public function getIsAvailableAttribute(): bool
91+
{
92+
return $this->status === 'issued';
93+
}
94+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
class DebitNoteItem extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $fillable = [
14+
'tenant_id', 'debit_note_id', 'description',
15+
'quantity', 'unit_price', 'tax_rate', 'line_total',
16+
];
17+
18+
protected $casts = [
19+
'quantity' => 'float',
20+
'unit_price' => 'float',
21+
'tax_rate' => 'float',
22+
'line_total' => 'float',
23+
];
24+
25+
public function debitNote(): BelongsTo
26+
{
27+
return $this->belongsTo(DebitNote::class);
28+
}
29+
30+
public function getLineTotalAttribute(): float
31+
{
32+
return (float) $this->quantity * (float) $this->unit_price;
33+
}
34+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Finance\Models\DebitNote;
7+
8+
class DebitNotePolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->hasPermissionTo('finance.view');
13+
}
14+
15+
public function view(User $user, DebitNote $debitNote): bool
16+
{
17+
return $user->hasPermissionTo('finance.view');
18+
}
19+
20+
public function create(User $user): bool
21+
{
22+
return $user->hasPermissionTo('finance.create');
23+
}
24+
25+
public function update(User $user, DebitNote $debitNote): bool
26+
{
27+
return $user->hasPermissionTo('finance.create');
28+
}
29+
30+
public function delete(User $user, DebitNote $debitNote): bool
31+
{
32+
return $user->hasPermissionTo('finance.delete');
33+
}
34+
}

erp/app/Modules/Finance/Providers/FinanceServiceProvider.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@
9999
use App\Modules\Finance\Models\CustomerGroup;
100100
use App\Modules\Finance\Policies\AdvancePaymentPolicy;
101101
use App\Modules\Finance\Policies\CustomerGroupPolicy;
102+
use App\Modules\Finance\Models\DebitNote;
103+
use App\Modules\Finance\Models\DebitNoteItem;
104+
use App\Modules\Finance\Policies\DebitNotePolicy;
102105
use Illuminate\Support\Facades\Gate;
103106
use Illuminate\Support\ServiceProvider;
104107

@@ -175,7 +178,9 @@ public function boot(): void
175178
Gate::policy(PettyCashTransaction::class, PettyCashPolicy::class);
176179
Gate::policy(BankTransfer::class, BankTransferPolicy::class);
177180
Gate::policy(CustomerGroup::class, CustomerGroupPolicy::class);
178-
Gate::policy(AdvancePayment::class, AdvancePaymentPolicy::class);
181+
Gate::policy(AdvancePayment::class, AdvancePaymentPolicy::class);
182+
Gate::policy(DebitNote::class, DebitNotePolicy::class);
183+
Gate::policy(DebitNoteItem::class, DebitNotePolicy::class);
179184
if ($this->app->runningInConsole()) {
180185
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);
181186
}

erp/app/Modules/Finance/routes/finance.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,3 +365,13 @@
365365
Route::post('advance-payments/{advancePayment}/refund', [AdvancePaymentController::class, 'refund'])->name('advance-payments.refund');
366366
Route::resource('advance-payments', AdvancePaymentController::class)->except(['create', 'edit', 'update']);
367367
});
368+
369+
370+
// Debit Notes — custom actions BEFORE resource
371+
use App\Modules\Finance\Http\Controllers\DebitNoteController;
372+
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
373+
Route::post('debit-notes/{debitNote}/issue', [DebitNoteController::class, 'issue'])->name('debit-notes.issue');
374+
Route::post('debit-notes/{debitNote}/apply', [DebitNoteController::class, 'apply'])->name('debit-notes.apply');
375+
Route::post('debit-notes/{debitNote}/void', [DebitNoteController::class, 'void'])->name('debit-notes.void');
376+
Route::resource('debit-notes', DebitNoteController::class)->only(['index', 'store', 'show', 'destroy']);
377+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::create('debit_notes', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->string('debit_note_number')->nullable();
15+
$table->unsignedBigInteger('vendor_id')->nullable(); // references contacts
16+
$table->unsignedBigInteger('vendor_bill_id')->nullable(); // optional link to bill
17+
$table->date('issue_date');
18+
$table->string('currency')->default('USD');
19+
$table->decimal('subtotal', 15, 2)->default(0);
20+
$table->decimal('tax', 15, 2)->default(0);
21+
$table->decimal('total', 15, 2)->default(0);
22+
$table->string('status')->default('draft'); // draft/issued/applied/void
23+
$table->text('reason')->nullable();
24+
$table->unsignedBigInteger('created_by')->nullable();
25+
$table->timestamps();
26+
$table->softDeletes();
27+
});
28+
}
29+
30+
public function down(): void
31+
{
32+
Schema::dropIfExists('debit_notes');
33+
}
34+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::create('debit_note_items', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->unsignedBigInteger('debit_note_id');
15+
$table->string('description');
16+
$table->decimal('quantity', 10, 2)->default(1);
17+
$table->decimal('unit_price', 15, 2)->default(0);
18+
$table->decimal('tax_rate', 5, 2)->default(0);
19+
$table->decimal('line_total', 15, 2)->default(0);
20+
$table->timestamps();
21+
});
22+
}
23+
24+
public function down(): void
25+
{
26+
Schema::dropIfExists('debit_note_items');
27+
}
28+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import React from 'react';
2+
3+
export default function DebitNoteIndex() {
4+
return <div>Debit Notes</div>;
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import React from 'react';
2+
3+
export default function DebitNoteShow() {
4+
return <div>Debit Note</div>;
5+
}

0 commit comments

Comments
 (0)