Skip to content

Commit 3d00f1d

Browse files
committed
feat(finance): Phase 62 — Contract Management
1 parent e0c4a0f commit 3d00f1d

13 files changed

Lines changed: 1313 additions & 0 deletions

File tree

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\Contact;
7+
use App\Modules\Finance\Models\Contract;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Validation\Rule;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class ContractController extends Controller
15+
{
16+
public function index(Request $request): Response
17+
{
18+
$this->authorize('viewAny', Contract::class);
19+
20+
$contracts = Contract::with('contact')
21+
->when($request->status, fn ($q) => $q->where('status', $request->status))
22+
->when($request->type, fn ($q) => $q->where('type', $request->type))
23+
->latest()
24+
->paginate(15)
25+
->withQueryString();
26+
27+
return Inertia::render('Finance/Contracts/Index', [
28+
'contracts' => $contracts,
29+
'filters' => $request->only(['status', 'type']),
30+
'breadcrumbs' => [
31+
['label' => 'Finance'],
32+
['label' => 'Contracts', 'href' => route('finance.contracts.index')],
33+
],
34+
]);
35+
}
36+
37+
public function create(): Response
38+
{
39+
$this->authorize('create', Contract::class);
40+
41+
return Inertia::render('Finance/Contracts/Create', [
42+
'contacts' => Contact::orderBy('name')->get(['id', 'name']),
43+
'breadcrumbs' => [
44+
['label' => 'Finance'],
45+
['label' => 'Contracts', 'href' => route('finance.contracts.index')],
46+
['label' => 'New Contract'],
47+
],
48+
]);
49+
}
50+
51+
public function store(Request $request): RedirectResponse
52+
{
53+
$this->authorize('create', Contract::class);
54+
55+
$data = $request->validate([
56+
'title' => ['required', 'string', 'max:255'],
57+
'reference' => ['nullable', 'string', 'max:100'],
58+
'contact_id' => ['nullable', Rule::exists('contacts', 'id')],
59+
'type' => ['required', Rule::in(['client', 'vendor', 'employment', 'nda', 'other'])],
60+
'status' => ['nullable', Rule::in(['draft', 'active', 'expired', 'terminated'])],
61+
'value' => ['nullable', 'numeric', 'min:0'],
62+
'currency_code' => ['nullable', 'string', 'size:3'],
63+
'start_date' => ['nullable', 'date'],
64+
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
65+
'auto_renew' => ['boolean'],
66+
'renewal_notice_days' => ['nullable', 'integer', 'min:0'],
67+
'description' => ['nullable', 'string'],
68+
'terms' => ['nullable', 'string'],
69+
]);
70+
71+
$contract = Contract::create([
72+
'tenant_id' => auth()->user()->tenant_id,
73+
...$data,
74+
]);
75+
76+
return redirect()->route('finance.contracts.show', $contract)
77+
->with('success', 'Contract created.');
78+
}
79+
80+
public function show(Contract $contract): Response
81+
{
82+
$this->authorize('view', $contract);
83+
84+
$contract->load('contact');
85+
86+
return Inertia::render('Finance/Contracts/Show', [
87+
'contract' => $contract,
88+
'breadcrumbs' => [
89+
['label' => 'Finance'],
90+
['label' => 'Contracts', 'href' => route('finance.contracts.index')],
91+
['label' => $contract->title],
92+
],
93+
]);
94+
}
95+
96+
public function edit(Contract $contract): Response
97+
{
98+
$this->authorize('create', Contract::class);
99+
100+
return Inertia::render('Finance/Contracts/Edit', [
101+
'contract' => $contract,
102+
'contacts' => Contact::orderBy('name')->get(['id', 'name']),
103+
'breadcrumbs' => [
104+
['label' => 'Finance'],
105+
['label' => 'Contracts', 'href' => route('finance.contracts.index')],
106+
['label' => $contract->title, 'href' => route('finance.contracts.show', $contract)],
107+
['label' => 'Edit'],
108+
],
109+
]);
110+
}
111+
112+
public function update(Request $request, Contract $contract): RedirectResponse
113+
{
114+
$this->authorize('create', Contract::class);
115+
116+
$data = $request->validate([
117+
'title' => ['required', 'string', 'max:255'],
118+
'reference' => ['nullable', 'string', 'max:100'],
119+
'contact_id' => ['nullable', Rule::exists('contacts', 'id')],
120+
'type' => ['required', Rule::in(['client', 'vendor', 'employment', 'nda', 'other'])],
121+
'status' => ['nullable', Rule::in(['draft', 'active', 'expired', 'terminated'])],
122+
'value' => ['nullable', 'numeric', 'min:0'],
123+
'currency_code' => ['nullable', 'string', 'size:3'],
124+
'start_date' => ['nullable', 'date'],
125+
'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
126+
'auto_renew' => ['boolean'],
127+
'renewal_notice_days' => ['nullable', 'integer', 'min:0'],
128+
'description' => ['nullable', 'string'],
129+
'terms' => ['nullable', 'string'],
130+
]);
131+
132+
$contract->update($data);
133+
134+
return redirect()->route('finance.contracts.show', $contract)
135+
->with('success', 'Contract updated.');
136+
}
137+
138+
public function destroy(Contract $contract): RedirectResponse
139+
{
140+
$this->authorize('delete', $contract);
141+
142+
$contract->delete();
143+
144+
return redirect()->route('finance.contracts.index')
145+
->with('success', 'Contract deleted.');
146+
}
147+
148+
public function activate(Contract $contract): RedirectResponse
149+
{
150+
$this->authorize('create', Contract::class);
151+
152+
$contract->activate();
153+
154+
return back()->with('success', 'Contract activated.');
155+
}
156+
157+
public function terminate(Contract $contract): RedirectResponse
158+
{
159+
$this->authorize('create', Contract::class);
160+
161+
$contract->terminate();
162+
163+
return back()->with('success', 'Contract terminated.');
164+
}
165+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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\SoftDeletes;
9+
10+
class Contract extends Model
11+
{
12+
use BelongsToTenant;
13+
use SoftDeletes;
14+
15+
protected $fillable = [
16+
'tenant_id', 'contact_id', 'title', 'reference', 'type', 'status',
17+
'value', 'currency_code', 'start_date', 'end_date', 'auto_renew',
18+
'renewal_notice_days', 'description', 'terms', 'signed_at',
19+
];
20+
21+
protected $casts = [
22+
'value' => 'decimal:2',
23+
'start_date' => 'date',
24+
'end_date' => 'date',
25+
'signed_at' => 'date',
26+
'auto_renew' => 'boolean',
27+
];
28+
29+
public function contact(): BelongsTo
30+
{
31+
return $this->belongsTo(Contact::class);
32+
}
33+
34+
public function getIsExpiringAttribute(): bool
35+
{
36+
return $this->status === 'active'
37+
&& $this->end_date !== null
38+
&& $this->end_date->diffInDays(now(), false) >= -$this->renewal_notice_days
39+
&& $this->end_date->isFuture();
40+
}
41+
42+
public function getIsExpiredAttribute(): bool
43+
{
44+
return $this->end_date !== null && $this->end_date->isPast();
45+
}
46+
47+
public function activate(): void
48+
{
49+
$this->status = 'active';
50+
$this->signed_at = $this->signed_at ?? today();
51+
$this->save();
52+
}
53+
54+
public function terminate(): void
55+
{
56+
$this->status = 'terminated';
57+
$this->save();
58+
}
59+
60+
public function scopeExpiringSoon($query)
61+
{
62+
return $query->where('status', 'active')
63+
->whereBetween('end_date', [now(), now()->addDays(30)]);
64+
}
65+
66+
public function scopeActive($query)
67+
{
68+
return $query->where('status', 'active');
69+
}
70+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Finance\Models\Contract;
7+
8+
class ContractPolicy
9+
{
10+
public function viewAny(User $user): bool { return $user->can('finance.view'); }
11+
public function view(User $user, Contract $contract): bool { return $user->can('finance.view'); }
12+
public function create(User $user): bool { return $user->can('finance.create'); }
13+
public function update(User $user, Contract $contract): bool { return $user->can('finance.create'); }
14+
public function delete(User $user, Contract $contract): bool { return $user->can('finance.delete'); }
15+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
use App\Modules\Finance\Policies\SubscriptionPolicy;
5050
use App\Modules\Finance\Models\Commission;
5151
use App\Modules\Finance\Models\CommissionRule;
52+
use App\Modules\Finance\Models\Contract;
53+
use App\Modules\Finance\Policies\ContractPolicy;
5254
use App\Modules\Finance\Policies\CommissionPolicy;
5355
use App\Modules\Finance\Policies\CommissionRulePolicy;
5456
use Illuminate\Support\Facades\Gate;
@@ -88,6 +90,7 @@ public function boot(): void
8890
Gate::policy(Subscription::class, SubscriptionPolicy::class);
8991
Gate::policy(Commission::class, CommissionPolicy::class);
9092
Gate::policy(CommissionRule::class, CommissionRulePolicy::class);
93+
Gate::policy(Contract::class, ContractPolicy::class);
9194

9295
if ($this->app->runningInConsole()) {
9396
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
use App\Modules\Finance\Http\Controllers\SubscriptionPlanController;
3131
use App\Modules\Finance\Http\Controllers\CommissionController;
3232
use App\Modules\Finance\Http\Controllers\CommissionRuleController;
33+
use App\Modules\Finance\Http\Controllers\ContractController;
3334

3435
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
3536

@@ -223,4 +224,11 @@
223224
Route::post('commissions/generate', [CommissionController::class, 'generate'])->name('commissions.generate');
224225
Route::resource('commissions', CommissionController::class)->except(['edit', 'update']);
225226

227+
228+
// Contracts
229+
Route::post('contracts/{contract}/activate', [ContractController::class, 'activate'])->name('contracts.activate');
230+
Route::post('contracts/{contract}/terminate', [ContractController::class, 'terminate'])->name('contracts.terminate');
231+
Route::resource('contracts', ContractController::class)->except(['show']);
232+
Route::get('contracts/{contract}', [ContractController::class, 'show'])->name('contracts.show');
233+
226234
});
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::create('contracts', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->foreignId('contact_id')->nullable()->constrained('contacts')->nullOnDelete();
15+
$table->string('title');
16+
$table->string('reference')->nullable();
17+
$table->enum('type', ['client', 'vendor', 'employment', 'nda', 'other'])->default('client');
18+
$table->enum('status', ['draft', 'active', 'expired', 'terminated'])->default('draft');
19+
$table->decimal('value', 14, 2)->nullable();
20+
$table->string('currency_code', 3)->nullable();
21+
$table->date('start_date')->nullable();
22+
$table->date('end_date')->nullable();
23+
$table->boolean('auto_renew')->default(false);
24+
$table->unsignedInteger('renewal_notice_days')->default(30);
25+
$table->text('description')->nullable();
26+
$table->text('terms')->nullable();
27+
$table->date('signed_at')->nullable();
28+
$table->softDeletes();
29+
$table->timestamps();
30+
});
31+
}
32+
33+
public function down(): void
34+
{
35+
Schema::dropIfExists('contracts');
36+
}
37+
};

erp/resources/js/Components/Layout/Sidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ const navItems: NavItem[] = [
115115
{ label: 'Subscriptions', href: '/finance/subscriptions', icon: <span /> },
116116
{ label: 'Sub Plans', href: '/finance/subscription-plans', icon: <span /> },
117117
{ label: 'Commissions', href: '/finance/commissions', icon: <span /> },
118+
{ label: 'Contracts', href: '/finance/contracts', icon: <span /> },
118119
],
119120
},
120121
{

0 commit comments

Comments
 (0)