Skip to content

Commit 1efca99

Browse files
committed
feat(finance): Phase 102 — Vendor Bills & Accounts Payable
Implements VendorBill and VendorBillItem models, migration, policy, controller, routes, frontend stubs, TypeScript types, sidebar link, and 10 Pest feature tests (1049 → 1059 passing). https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 6f78341 commit 1efca99

13 files changed

Lines changed: 643 additions & 0 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\VendorBill;
7+
use App\Modules\Finance\Models\VendorBillItem;
8+
use App\Modules\Inventory\Models\Supplier;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class VendorBillController extends Controller
15+
{
16+
public function index(Request $request): Response
17+
{
18+
$this->authorize('viewAny', VendorBill::class);
19+
20+
$query = VendorBill::with(['supplier']);
21+
22+
if ($request->filled('status')) {
23+
$query->where('status', $request->status);
24+
}
25+
26+
$bills = $query->latest()->paginate(20)->withQueryString();
27+
28+
return Inertia::render('Finance/VendorBills/Index', [
29+
'bills' => $bills,
30+
'filters' => $request->only('status'),
31+
]);
32+
}
33+
34+
public function create(): Response
35+
{
36+
$this->authorize('create', VendorBill::class);
37+
38+
$suppliers = Supplier::orderBy('name')->get(['id', 'name']);
39+
40+
return Inertia::render('Finance/VendorBills/Create', [
41+
'suppliers' => $suppliers,
42+
]);
43+
}
44+
45+
public function store(Request $request): RedirectResponse
46+
{
47+
$this->authorize('create', VendorBill::class);
48+
49+
$data = $request->validate([
50+
'supplier_id' => 'nullable|exists:suppliers,id',
51+
'bill_date' => 'required|date',
52+
'due_date' => 'nullable|date',
53+
'currency' => 'nullable|string|max:3',
54+
'notes' => 'nullable|string',
55+
'items' => 'required|array|min:1',
56+
'items.*.description' => 'required|string|max:255',
57+
'items.*.quantity' => 'required|numeric|min:0.01',
58+
'items.*.unit_price' => 'required|numeric|min:0',
59+
]);
60+
61+
$tenantId = app('tenant')->id;
62+
63+
$bill = VendorBill::create([
64+
'tenant_id' => $tenantId,
65+
'bill_number' => VendorBill::generateBillNumber(),
66+
'supplier_id' => $data['supplier_id'] ?? null,
67+
'bill_date' => $data['bill_date'],
68+
'due_date' => $data['due_date'] ?? null,
69+
'currency' => $data['currency'] ?? 'USD',
70+
'notes' => $data['notes'] ?? null,
71+
'status' => 'draft',
72+
'subtotal' => 0,
73+
'tax' => 0,
74+
'total' => 0,
75+
'created_by' => auth()->id(),
76+
]);
77+
78+
foreach ($data['items'] as $item) {
79+
VendorBillItem::create([
80+
'tenant_id' => $tenantId,
81+
'vendor_bill_id' => $bill->id,
82+
'product_id' => $item['product_id'] ?? null,
83+
'description' => $item['description'],
84+
'quantity' => $item['quantity'],
85+
'unit_price' => $item['unit_price'],
86+
]);
87+
}
88+
89+
$bill->recalculateTotals();
90+
91+
return redirect()->route('finance.vendor-bills.show', $bill);
92+
}
93+
94+
public function show(VendorBill $vendorBill): Response
95+
{
96+
$this->authorize('view', $vendorBill);
97+
98+
$vendorBill->load(['supplier', 'items']);
99+
100+
return Inertia::render('Finance/VendorBills/Show', [
101+
'bill' => $vendorBill,
102+
]);
103+
}
104+
105+
public function submit(VendorBill $vendorBill): RedirectResponse
106+
{
107+
$this->authorize('update', $vendorBill);
108+
109+
$vendorBill->submit();
110+
111+
return back()->with('success', 'Bill submitted.');
112+
}
113+
114+
public function approve(VendorBill $vendorBill): RedirectResponse
115+
{
116+
$this->authorize('update', $vendorBill);
117+
118+
$vendorBill->approve(auth()->id());
119+
120+
return back()->with('success', 'Bill approved.');
121+
}
122+
123+
public function pay(VendorBill $vendorBill): RedirectResponse
124+
{
125+
$this->authorize('update', $vendorBill);
126+
127+
$vendorBill->pay();
128+
129+
return back()->with('success', 'Bill marked as paid.');
130+
}
131+
132+
public function cancel(VendorBill $vendorBill): RedirectResponse
133+
{
134+
$this->authorize('update', $vendorBill);
135+
136+
$vendorBill->cancel();
137+
138+
return back()->with('success', 'Bill cancelled.');
139+
}
140+
141+
public function destroy(VendorBill $vendorBill): RedirectResponse
142+
{
143+
$this->authorize('delete', $vendorBill);
144+
145+
$vendorBill->delete();
146+
147+
return redirect()->route('finance.vendor-bills.index');
148+
}
149+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use App\Modules\Inventory\Models\Supplier;
8+
use Illuminate\Database\Eloquent\Model;
9+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
10+
use Illuminate\Database\Eloquent\Relations\HasMany;
11+
use Illuminate\Database\Eloquent\SoftDeletes;
12+
13+
class VendorBill extends Model
14+
{
15+
use BelongsToTenant;
16+
use SoftDeletes;
17+
18+
protected $fillable = [
19+
'tenant_id',
20+
'bill_number',
21+
'supplier_id',
22+
'reference',
23+
'status',
24+
'bill_date',
25+
'due_date',
26+
'currency',
27+
'subtotal',
28+
'tax',
29+
'total',
30+
'notes',
31+
'created_by',
32+
'approved_by',
33+
'approved_at',
34+
'paid_at',
35+
];
36+
37+
protected $casts = [
38+
'bill_date' => 'date',
39+
'due_date' => 'date',
40+
'approved_at' => 'datetime',
41+
'paid_at' => 'datetime',
42+
'subtotal' => 'float',
43+
'tax' => 'float',
44+
'total' => 'float',
45+
];
46+
47+
public function items(): HasMany
48+
{
49+
return $this->hasMany(VendorBillItem::class);
50+
}
51+
52+
public function supplier(): BelongsTo
53+
{
54+
return $this->belongsTo(Supplier::class);
55+
}
56+
57+
public function createdBy(): BelongsTo
58+
{
59+
return $this->belongsTo(User::class, 'created_by');
60+
}
61+
62+
public static function generateBillNumber(): string
63+
{
64+
return 'BILL-' . strtoupper(uniqid());
65+
}
66+
67+
public function recalculateTotals(): void
68+
{
69+
$this->subtotal = $this->items()->get()->sum(fn ($item) => $item->quantity * $item->unit_price);
70+
$this->total = $this->subtotal + $this->tax;
71+
$this->save();
72+
}
73+
74+
public function submit(): void
75+
{
76+
$this->status = 'pending';
77+
$this->save();
78+
}
79+
80+
public function approve(int $userId): void
81+
{
82+
$this->status = 'approved';
83+
$this->approved_by = $userId;
84+
$this->approved_at = now();
85+
$this->save();
86+
}
87+
88+
public function pay(): void
89+
{
90+
$this->status = 'paid';
91+
$this->paid_at = now();
92+
$this->save();
93+
}
94+
95+
public function cancel(): void
96+
{
97+
$this->status = 'cancelled';
98+
$this->save();
99+
}
100+
101+
public function getIsOverdueAttribute(): bool
102+
{
103+
return !in_array($this->status, ['paid', 'cancelled'])
104+
&& $this->due_date !== null
105+
&& $this->due_date->lt(now()->startOfDay());
106+
}
107+
108+
public function getIsOpenAttribute(): bool
109+
{
110+
return in_array($this->status, ['draft', 'pending', 'approved']);
111+
}
112+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Modules\Inventory\Models\Product;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
class VendorBillItem extends Model
10+
{
11+
protected $fillable = [
12+
'tenant_id',
13+
'vendor_bill_id',
14+
'product_id',
15+
'description',
16+
'quantity',
17+
'unit_price',
18+
];
19+
20+
protected $casts = [
21+
'quantity' => 'float',
22+
'unit_price' => 'float',
23+
];
24+
25+
public function vendorBill(): BelongsTo
26+
{
27+
return $this->belongsTo(VendorBill::class);
28+
}
29+
30+
public function product(): BelongsTo
31+
{
32+
return $this->belongsTo(Product::class);
33+
}
34+
35+
public function getLineTotalAttribute(): float
36+
{
37+
return $this->quantity * $this->unit_price;
38+
}
39+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Policies;
4+
5+
use App\Models\User;
6+
7+
class VendorBillPolicy
8+
{
9+
public function viewAny(User $user): bool { return $user->hasPermissionTo('finance.view'); }
10+
public function view(User $user, $model): bool { return $user->hasPermissionTo('finance.view'); }
11+
public function create(User $user): bool { return $user->hasPermissionTo('finance.create'); }
12+
public function update(User $user, $model): bool { return $user->hasPermissionTo('finance.create'); }
13+
public function delete(User $user, $model): bool { return $user->hasPermissionTo('finance.delete'); }
14+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@
8383
use App\Modules\Finance\Models\TicketComment;
8484
use App\Modules\Finance\Policies\SupportTicketPolicy;
8585
use App\Modules\Finance\Models\ExpenseClaim;
86+
use App\Modules\Finance\Models\VendorBill;
87+
use App\Modules\Finance\Models\VendorBillItem;
88+
use App\Modules\Finance\Policies\VendorBillPolicy;
8689
use App\Modules\Finance\Models\ExpenseItem;
8790
use App\Modules\Finance\Policies\ExpenseClaimPolicy;
8891
use Illuminate\Support\Facades\Gate;
@@ -153,6 +156,8 @@ public function boot(): void
153156

154157

155158
Gate::policy(ExpenseClaim::class, ExpenseClaimPolicy::class);
159+
Gate::policy(VendorBill::class, VendorBillPolicy::class);
160+
Gate::policy(VendorBillItem::class, VendorBillPolicy::class);
156161
Gate::policy(ExpenseItem::class, ExpenseClaimPolicy::class);
157162
if ($this->app->runningInConsole()) {
158163
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,3 +317,13 @@
317317
Route::post('expense-claims/{expenseClaim}/mark-paid',[ExpenseClaimController::class, 'markPaid'])->name('expense-claims.mark-paid');
318318
Route::resource('expense-claims', ExpenseClaimController::class)->only(['index', 'create', 'store', 'show', 'destroy']);
319319
});
320+
321+
// Vendor Bills — custom actions BEFORE resource
322+
use App\Modules\Finance\Http\Controllers\VendorBillController;
323+
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
324+
Route::post('vendor-bills/{vendorBill}/submit', [VendorBillController::class, 'submit'])->name('vendor-bills.submit');
325+
Route::post('vendor-bills/{vendorBill}/approve', [VendorBillController::class, 'approve'])->name('vendor-bills.approve');
326+
Route::post('vendor-bills/{vendorBill}/pay', [VendorBillController::class, 'pay'])->name('vendor-bills.pay');
327+
Route::post('vendor-bills/{vendorBill}/cancel', [VendorBillController::class, 'cancel'])->name('vendor-bills.cancel');
328+
Route::resource('vendor-bills', VendorBillController::class);
329+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
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('vendor_bills', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->string('bill_number')->unique();
15+
$table->unsignedBigInteger('supplier_id')->nullable();
16+
$table->string('reference')->nullable();
17+
$table->string('status')->default('draft'); // draft, pending, approved, paid, cancelled
18+
$table->date('bill_date');
19+
$table->date('due_date')->nullable();
20+
$table->string('currency', 3)->default('USD');
21+
$table->decimal('subtotal', 15, 2)->default(0);
22+
$table->decimal('tax', 15, 2)->default(0);
23+
$table->decimal('total', 15, 2)->default(0);
24+
$table->text('notes')->nullable();
25+
$table->unsignedBigInteger('created_by')->nullable();
26+
$table->unsignedBigInteger('approved_by')->nullable();
27+
$table->timestamp('approved_at')->nullable();
28+
$table->timestamp('paid_at')->nullable();
29+
$table->timestamps();
30+
$table->softDeletes();
31+
});
32+
}
33+
34+
public function down(): void
35+
{
36+
Schema::dropIfExists('vendor_bills');
37+
}
38+
};

0 commit comments

Comments
 (0)