Skip to content

Commit 730ee97

Browse files
committed
feat(finance): Phase 134 — Finance Recurring Expenses
Adds full CRUD for recurring expenses with pause/resume/cancel lifecycle actions, soft deletes, and policy-based RBAC. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent f67ff91 commit 730ee97

11 files changed

Lines changed: 905 additions & 0 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\RecurringExpense;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class RecurringExpenseController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$this->authorize('viewAny', RecurringExpense::class);
17+
18+
$expenses = RecurringExpense::query()
19+
->when($request->status, fn ($q) => $q->where('status', $request->status))
20+
->latest()
21+
->paginate(25)
22+
->withQueryString();
23+
24+
return Inertia::render('Finance/RecurringExpenses/Index', [
25+
'recurringExpenses' => $expenses,
26+
'filters' => $request->only(['status']),
27+
'breadcrumbs' => [
28+
['label' => 'Finance'],
29+
['label' => 'Recurring Expenses', 'href' => route('finance.recurring-expenses.index')],
30+
],
31+
]);
32+
}
33+
34+
public function create(): Response
35+
{
36+
$this->authorize('create', RecurringExpense::class);
37+
38+
return Inertia::render('Finance/RecurringExpenses/Create', [
39+
'breadcrumbs' => [
40+
['label' => 'Finance'],
41+
['label' => 'Recurring Expenses', 'href' => route('finance.recurring-expenses.index')],
42+
['label' => 'New Recurring Expense'],
43+
],
44+
]);
45+
}
46+
47+
public function store(Request $request): RedirectResponse
48+
{
49+
$this->authorize('create', RecurringExpense::class);
50+
51+
$validated = $request->validate([
52+
'name' => 'required|string|max:255',
53+
'amount' => 'required|numeric|min:0',
54+
'frequency' => 'required|in:monthly,quarterly,annual,weekly',
55+
'start_date' => 'required|date',
56+
]);
57+
58+
RecurringExpense::create([
59+
...$validated,
60+
'tenant_id' => app('tenant')->id,
61+
'created_by' => auth()->id(),
62+
]);
63+
64+
return redirect()->route('finance.recurring-expenses.index')
65+
->with('success', 'Recurring expense created.');
66+
}
67+
68+
public function show(RecurringExpense $recurringExpense): Response
69+
{
70+
$this->authorize('view', $recurringExpense);
71+
72+
return Inertia::render('Finance/RecurringExpenses/Show', [
73+
'recurringExpense' => $recurringExpense,
74+
'breadcrumbs' => [
75+
['label' => 'Finance'],
76+
['label' => 'Recurring Expenses', 'href' => route('finance.recurring-expenses.index')],
77+
['label' => $recurringExpense->name],
78+
],
79+
]);
80+
}
81+
82+
public function edit(RecurringExpense $recurringExpense): Response
83+
{
84+
$this->authorize('update', $recurringExpense);
85+
86+
return Inertia::render('Finance/RecurringExpenses/Edit', [
87+
'recurringExpense' => $recurringExpense,
88+
'breadcrumbs' => [
89+
['label' => 'Finance'],
90+
['label' => 'Recurring Expenses', 'href' => route('finance.recurring-expenses.index')],
91+
['label' => $recurringExpense->name],
92+
['label' => 'Edit'],
93+
],
94+
]);
95+
}
96+
97+
public function update(Request $request, RecurringExpense $recurringExpense): RedirectResponse
98+
{
99+
$this->authorize('update', $recurringExpense);
100+
101+
$validated = $request->validate([
102+
'name' => 'required|string|max:255',
103+
'amount' => 'required|numeric|min:0',
104+
'frequency' => 'required|in:monthly,quarterly,annual,weekly',
105+
'start_date' => 'required|date',
106+
]);
107+
108+
$recurringExpense->update($validated);
109+
110+
return redirect()->route('finance.recurring-expenses.index')
111+
->with('success', 'Recurring expense updated.');
112+
}
113+
114+
public function destroy(RecurringExpense $recurringExpense): RedirectResponse
115+
{
116+
$this->authorize('delete', $recurringExpense);
117+
118+
$recurringExpense->delete();
119+
120+
return redirect()->route('finance.recurring-expenses.index')
121+
->with('success', 'Recurring expense deleted.');
122+
}
123+
124+
public function pause(RecurringExpense $recurringExpense): RedirectResponse
125+
{
126+
$this->authorize('pause', $recurringExpense);
127+
128+
$recurringExpense->pause();
129+
130+
return redirect()->route('finance.recurring-expenses.index')
131+
->with('success', 'Recurring expense paused.');
132+
}
133+
134+
public function resume(RecurringExpense $recurringExpense): RedirectResponse
135+
{
136+
$this->authorize('resume', $recurringExpense);
137+
138+
$recurringExpense->resume();
139+
140+
return redirect()->route('finance.recurring-expenses.index')
141+
->with('success', 'Recurring expense resumed.');
142+
}
143+
144+
public function cancel(RecurringExpense $recurringExpense): RedirectResponse
145+
{
146+
$this->authorize('cancel', $recurringExpense);
147+
148+
$recurringExpense->cancel();
149+
150+
return redirect()->route('finance.recurring-expenses.index')
151+
->with('success', 'Recurring expense cancelled.');
152+
}
153+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Carbon\Carbon;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\SoftDeletes;
9+
10+
class RecurringExpense extends Model
11+
{
12+
use BelongsToTenant;
13+
use SoftDeletes;
14+
15+
protected $fillable = [
16+
'tenant_id',
17+
'name',
18+
'expense_number',
19+
'category',
20+
'amount',
21+
'currency',
22+
'frequency',
23+
'start_date',
24+
'end_date',
25+
'next_due_date',
26+
'last_processed_date',
27+
'status',
28+
'notes',
29+
'created_by',
30+
];
31+
32+
protected $casts = [
33+
'amount' => 'decimal:2',
34+
'start_date' => 'date',
35+
'end_date' => 'date',
36+
'next_due_date' => 'date',
37+
'last_processed_date' => 'date',
38+
];
39+
40+
protected $attributes = [
41+
'status' => 'active',
42+
'currency' => 'USD',
43+
'frequency' => 'monthly',
44+
];
45+
46+
// ─── Actions ──────────────────────────────────────────────────────────────
47+
48+
public function pause(): void
49+
{
50+
$this->status = 'paused';
51+
$this->save();
52+
}
53+
54+
public function resume(): void
55+
{
56+
$this->status = 'active';
57+
$this->save();
58+
}
59+
60+
public function cancel(): void
61+
{
62+
$this->status = 'cancelled';
63+
$this->save();
64+
}
65+
66+
public function generateExpenseNumber(): string
67+
{
68+
return 'RE-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT);
69+
}
70+
71+
public function calculateNextDueDate(): void
72+
{
73+
$base = $this->last_processed_date
74+
? Carbon::parse($this->last_processed_date)
75+
: Carbon::parse($this->start_date);
76+
77+
$next = match ($this->frequency) {
78+
'monthly' => $base->copy()->addMonth(),
79+
'quarterly' => $base->copy()->addMonths(3),
80+
'annual' => $base->copy()->addYear(),
81+
'weekly' => $base->copy()->addWeek(),
82+
default => $base->copy()->addMonth(),
83+
};
84+
85+
$this->next_due_date = $next;
86+
$this->save();
87+
}
88+
89+
// ─── Accessors ────────────────────────────────────────────────────────────
90+
91+
public function getIsActiveAttribute(): bool
92+
{
93+
return $this->status === 'active';
94+
}
95+
96+
public function getIsDueAttribute(): bool
97+
{
98+
return $this->is_active
99+
&& $this->next_due_date !== null
100+
&& $this->next_due_date->lte(Carbon::today());
101+
}
102+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Policies;
4+
5+
use App\Models\User;
6+
7+
class RecurringExpensePolicy
8+
{
9+
public function viewAny(User $user): bool
10+
{
11+
return $user->hasPermissionTo('finance.view');
12+
}
13+
14+
public function view(User $user, $model): bool
15+
{
16+
return $user->hasPermissionTo('finance.view');
17+
}
18+
19+
public function create(User $user): bool
20+
{
21+
return $user->hasPermissionTo('finance.create');
22+
}
23+
24+
public function update(User $user, $model): bool
25+
{
26+
return $user->hasPermissionTo('finance.create');
27+
}
28+
29+
public function pause(User $user, $model): bool
30+
{
31+
return $user->hasPermissionTo('finance.create');
32+
}
33+
34+
public function resume(User $user, $model): bool
35+
{
36+
return $user->hasPermissionTo('finance.create');
37+
}
38+
39+
public function cancel(User $user, $model): bool
40+
{
41+
return $user->hasPermissionTo('finance.create');
42+
}
43+
44+
public function delete(User $user, $model): bool
45+
{
46+
return $user->hasPermissionTo('finance.delete');
47+
}
48+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@
108108
use App\Modules\Finance\Policies\IntercompanyPolicy;
109109
use App\Modules\Finance\Models\CashFlowForecast;
110110
use App\Modules\Finance\Policies\CashFlowForecastPolicy;
111+
use App\Modules\Finance\Models\RecurringExpense;
112+
use App\Modules\Finance\Policies\RecurringExpensePolicy;
111113
use Illuminate\Support\Facades\Gate;
112114
use Illuminate\Support\ServiceProvider;
113115

@@ -190,6 +192,7 @@ public function boot(): void
190192
Gate::policy(WriteOff::class, WriteOffPolicy::class);
191193
Gate::policy(IntercompanyTransaction::class, IntercompanyPolicy::class);
192194
Gate::policy(CashFlowForecast::class, CashFlowForecastPolicy::class);
195+
Gate::policy(RecurringExpense::class, RecurringExpensePolicy::class);
193196
if ($this->app->runningInConsole()) {
194197
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);
195198
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,3 +400,12 @@
400400
Route::post('cash-flow-forecasts/{cash_flow_forecast}/archive', [CashFlowForecastController::class, 'archive'])->name('cash-flow-forecasts.archive');
401401
Route::resource('cash-flow-forecasts', CashFlowForecastController::class);
402402
});
403+
404+
// Recurring Expenses
405+
use App\Modules\Finance\Http\Controllers\RecurringExpenseController;
406+
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
407+
Route::post('recurring-expenses/{recurring_expense}/pause', [RecurringExpenseController::class, 'pause'])->name('recurring-expenses.pause');
408+
Route::post('recurring-expenses/{recurring_expense}/resume', [RecurringExpenseController::class, 'resume'])->name('recurring-expenses.resume');
409+
Route::post('recurring-expenses/{recurring_expense}/cancel', [RecurringExpenseController::class, 'cancel'])->name('recurring-expenses.cancel');
410+
Route::resource('recurring-expenses', RecurringExpenseController::class);
411+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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::dropIfExists('recurring_expenses');
12+
Schema::create('recurring_expenses', function (Blueprint $table) {
13+
$table->id();
14+
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
15+
$table->string('name');
16+
$table->string('expense_number')->nullable();
17+
$table->string('category')->nullable();
18+
$table->decimal('amount', 15, 2);
19+
$table->string('currency')->default('USD');
20+
$table->string('frequency')->default('monthly'); // monthly/quarterly/annual/weekly
21+
$table->date('start_date');
22+
$table->date('end_date')->nullable();
23+
$table->date('next_due_date')->nullable();
24+
$table->date('last_processed_date')->nullable();
25+
$table->string('status')->default('active'); // active/paused/cancelled/expired
26+
$table->text('notes')->nullable();
27+
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
28+
$table->timestamps();
29+
$table->softDeletes();
30+
});
31+
}
32+
33+
public function down(): void
34+
{
35+
Schema::dropIfExists('recurring_expenses');
36+
}
37+
};

0 commit comments

Comments
 (0)