Skip to content

Commit fc6ed3f

Browse files
committed
feat(finance): Phase 140 — Finance Payment Schedules
Add installment-based payment schedule lifecycle: active → completed/paused/cancelled, with per-item tracking and recalculate-on-paid logic. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 1e5311a commit fc6ed3f

13 files changed

Lines changed: 557 additions & 0 deletions

File tree

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\PaymentSchedule;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class PaymentScheduleController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$this->authorize('viewAny', PaymentSchedule::class);
17+
18+
$schedules = PaymentSchedule::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/PaymentSchedules/Index', [
25+
'paymentSchedules' => $schedules,
26+
'filters' => $request->only(['status']),
27+
]);
28+
}
29+
30+
public function create(): Response
31+
{
32+
$this->authorize('create', PaymentSchedule::class);
33+
34+
return Inertia::render('Finance/PaymentSchedules/Create');
35+
}
36+
37+
public function store(Request $request): RedirectResponse
38+
{
39+
$this->authorize('create', PaymentSchedule::class);
40+
41+
$validated = $request->validate([
42+
'name' => 'required|string|max:255',
43+
'total_amount' => 'required|numeric|min:0',
44+
'installments' => 'required|integer|min:1',
45+
'start_date' => 'required|date',
46+
'frequency' => 'required|in:monthly,quarterly,annual,custom',
47+
]);
48+
49+
PaymentSchedule::create([
50+
...$validated,
51+
'tenant_id' => app('tenant')->id,
52+
'created_by' => auth()->id(),
53+
]);
54+
55+
return redirect()->route('finance.payment-schedules.index')
56+
->with('success', 'Payment schedule created.');
57+
}
58+
59+
public function show(PaymentSchedule $paymentSchedule): Response
60+
{
61+
$this->authorize('view', $paymentSchedule);
62+
63+
return Inertia::render('Finance/PaymentSchedules/Show', [
64+
'paymentSchedule' => $paymentSchedule->load('items'),
65+
]);
66+
}
67+
68+
public function edit(PaymentSchedule $paymentSchedule): Response
69+
{
70+
$this->authorize('update', $paymentSchedule);
71+
72+
return Inertia::render('Finance/PaymentSchedules/Edit', [
73+
'paymentSchedule' => $paymentSchedule,
74+
]);
75+
}
76+
77+
public function update(Request $request, PaymentSchedule $paymentSchedule): RedirectResponse
78+
{
79+
$this->authorize('update', $paymentSchedule);
80+
81+
$validated = $request->validate([
82+
'name' => 'required|string|max:255',
83+
'total_amount' => 'required|numeric|min:0',
84+
'installments' => 'required|integer|min:1',
85+
'start_date' => 'required|date',
86+
'frequency' => 'required|in:monthly,quarterly,annual,custom',
87+
]);
88+
89+
$paymentSchedule->update($validated);
90+
91+
return redirect()->route('finance.payment-schedules.index')
92+
->with('success', 'Payment schedule updated.');
93+
}
94+
95+
public function destroy(PaymentSchedule $paymentSchedule): RedirectResponse
96+
{
97+
$this->authorize('delete', $paymentSchedule);
98+
99+
$paymentSchedule->delete();
100+
101+
return redirect()->route('finance.payment-schedules.index')
102+
->with('success', 'Payment schedule deleted.');
103+
}
104+
105+
public function pause(PaymentSchedule $paymentSchedule): RedirectResponse
106+
{
107+
$this->authorize('pause', $paymentSchedule);
108+
109+
$paymentSchedule->pause();
110+
111+
return redirect()->route('finance.payment-schedules.index')
112+
->with('success', 'Payment schedule paused.');
113+
}
114+
115+
public function resume(PaymentSchedule $paymentSchedule): RedirectResponse
116+
{
117+
$this->authorize('resume', $paymentSchedule);
118+
119+
$paymentSchedule->resume();
120+
121+
return redirect()->route('finance.payment-schedules.index')
122+
->with('success', 'Payment schedule resumed.');
123+
}
124+
125+
public function cancel(PaymentSchedule $paymentSchedule): RedirectResponse
126+
{
127+
$this->authorize('cancel', $paymentSchedule);
128+
129+
$paymentSchedule->cancel();
130+
131+
return redirect()->route('finance.payment-schedules.index')
132+
->with('success', 'Payment schedule cancelled.');
133+
}
134+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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\HasMany;
8+
use Illuminate\Database\Eloquent\SoftDeletes;
9+
10+
class PaymentSchedule extends Model
11+
{
12+
use BelongsToTenant, SoftDeletes;
13+
14+
protected $fillable = [
15+
'tenant_id',
16+
'schedule_number',
17+
'name',
18+
'reference_type',
19+
'reference_id',
20+
'total_amount',
21+
'paid_amount',
22+
'currency',
23+
'frequency',
24+
'installments',
25+
'start_date',
26+
'end_date',
27+
'status',
28+
'notes',
29+
'created_by',
30+
];
31+
32+
protected $casts = [
33+
'total_amount' => 'decimal:2',
34+
'paid_amount' => 'decimal:2',
35+
'start_date' => 'date',
36+
'end_date' => 'date',
37+
'installments' => 'integer',
38+
];
39+
40+
protected $attributes = [
41+
'status' => 'active',
42+
'currency' => 'USD',
43+
'frequency' => 'monthly',
44+
'paid_amount' => 0,
45+
'installments' => 1,
46+
];
47+
48+
// ─── Relations ────────────────────────────────────────────────────────────
49+
50+
public function items(): HasMany
51+
{
52+
return $this->hasMany(PaymentScheduleItem::class);
53+
}
54+
55+
// ─── Actions ──────────────────────────────────────────────────────────────
56+
57+
public function pause(): void
58+
{
59+
$this->status = 'paused';
60+
$this->save();
61+
}
62+
63+
public function resume(): void
64+
{
65+
$this->status = 'active';
66+
$this->save();
67+
}
68+
69+
public function cancel(): void
70+
{
71+
$this->status = 'cancelled';
72+
$this->save();
73+
}
74+
75+
public function generateScheduleNumber(): string
76+
{
77+
return 'PS-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT);
78+
}
79+
80+
public function recalculatePaidAmount(): void
81+
{
82+
$this->paid_amount = $this->items()->where('status', 'paid')->sum('amount');
83+
84+
if ((float) $this->paid_amount >= (float) $this->total_amount) {
85+
$this->status = 'completed';
86+
}
87+
88+
$this->save();
89+
}
90+
91+
// ─── Accessors ────────────────────────────────────────────────────────────
92+
93+
public function getRemainingAmountAttribute(): float
94+
{
95+
return (float) $this->total_amount - (float) $this->paid_amount;
96+
}
97+
98+
public function getCompletionPercentAttribute(): float
99+
{
100+
if ((float) $this->total_amount > 0) {
101+
return round(((float) $this->paid_amount / (float) $this->total_amount) * 100, 2);
102+
}
103+
104+
return 0;
105+
}
106+
107+
public function getIsActiveAttribute(): bool
108+
{
109+
return $this->status === 'active';
110+
}
111+
112+
public function getIsCompletedAttribute(): bool
113+
{
114+
return $this->status === 'completed';
115+
}
116+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
7+
use Illuminate\Support\Carbon;
8+
9+
class PaymentScheduleItem extends Model
10+
{
11+
protected $fillable = [
12+
'payment_schedule_id',
13+
'installment_number',
14+
'amount',
15+
'due_date',
16+
'paid_date',
17+
'status',
18+
];
19+
20+
protected $casts = [
21+
'amount' => 'decimal:2',
22+
'due_date' => 'date',
23+
'paid_date' => 'date',
24+
];
25+
26+
protected $attributes = [
27+
'status' => 'pending',
28+
];
29+
30+
// ─── Relations ────────────────────────────────────────────────────────────
31+
32+
public function schedule(): BelongsTo
33+
{
34+
return $this->belongsTo(PaymentSchedule::class);
35+
}
36+
37+
// ─── Actions ──────────────────────────────────────────────────────────────
38+
39+
public function markPaid(string $date = null): void
40+
{
41+
$this->status = 'paid';
42+
$this->paid_date = $date ?? Carbon::today()->toDateString();
43+
$this->save();
44+
}
45+
46+
public function waive(): void
47+
{
48+
$this->status = 'waived';
49+
$this->save();
50+
}
51+
52+
// ─── Accessors ────────────────────────────────────────────────────────────
53+
54+
public function getIsOverdueAttribute(): bool
55+
{
56+
return $this->status === 'pending' && $this->due_date->lt(Carbon::today());
57+
}
58+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Finance\Models\PaymentSchedule;
7+
8+
class PaymentSchedulePolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->hasPermissionTo('finance.view');
13+
}
14+
15+
public function view(User $user, PaymentSchedule $paymentSchedule): 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, PaymentSchedule $paymentSchedule): bool
26+
{
27+
return $user->hasPermissionTo('finance.create');
28+
}
29+
30+
public function pause(User $user, PaymentSchedule $paymentSchedule): bool
31+
{
32+
return $user->hasPermissionTo('finance.create');
33+
}
34+
35+
public function resume(User $user, PaymentSchedule $paymentSchedule): bool
36+
{
37+
return $user->hasPermissionTo('finance.create');
38+
}
39+
40+
public function cancel(User $user, PaymentSchedule $paymentSchedule): bool
41+
{
42+
return $user->hasPermissionTo('finance.create');
43+
}
44+
45+
public function delete(User $user, PaymentSchedule $paymentSchedule): bool
46+
{
47+
return $user->hasPermissionTo('finance.delete');
48+
}
49+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,9 @@
112112
use App\Modules\Finance\Policies\RecurringExpensePolicy;
113113
use App\Modules\Finance\Models\VendorPayment;
114114
use App\Modules\Finance\Policies\VendorPaymentPolicy;
115+
use App\Modules\Finance\Models\PaymentSchedule;
116+
use App\Modules\Finance\Models\PaymentScheduleItem;
117+
use App\Modules\Finance\Policies\PaymentSchedulePolicy;
115118
use Illuminate\Support\Facades\Gate;
116119
use Illuminate\Support\ServiceProvider;
117120

@@ -196,6 +199,8 @@ public function boot(): void
196199
Gate::policy(CashFlowForecast::class, CashFlowForecastPolicy::class);
197200
Gate::policy(RecurringExpense::class, RecurringExpensePolicy::class);
198201
Gate::policy(VendorPayment::class, VendorPaymentPolicy::class);
202+
Gate::policy(PaymentSchedule::class, PaymentSchedulePolicy::class);
203+
Gate::policy(PaymentScheduleItem::class, PaymentSchedulePolicy::class);
199204
if ($this->app->runningInConsole()) {
200205
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);
201206
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,3 +419,12 @@
419419
Route::post('vendor-payments/{vendor_payment}/cancel', [VendorPaymentController::class, 'cancel'])->name('vendor-payments.cancel');
420420
Route::resource('vendor-payments', VendorPaymentController::class);
421421
});
422+
423+
// Payment Schedules
424+
use App\Modules\Finance\Http\Controllers\PaymentScheduleController;
425+
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
426+
Route::post('payment-schedules/{payment_schedule}/pause', [PaymentScheduleController::class, 'pause'])->name('payment-schedules.pause');
427+
Route::post('payment-schedules/{payment_schedule}/resume', [PaymentScheduleController::class, 'resume'])->name('payment-schedules.resume');
428+
Route::post('payment-schedules/{payment_schedule}/cancel', [PaymentScheduleController::class, 'cancel'])->name('payment-schedules.cancel');
429+
Route::resource('payment-schedules', PaymentScheduleController::class);
430+
});

0 commit comments

Comments
 (0)