Skip to content

Commit fc7fb8c

Browse files
committed
feat(finance): Phase 92 — Contract Management with lifecycle and renewals
- Add party_name, party_email, contract_number, created_by, terminated_at, notes columns to contracts table - Create contract_renewals table and ContractRenewal model - Update Contract model: renew(), terminate(notes), activate(), generateContractNumber(), getDaysRemainingAttribute(), updated getIsExpiringAttribute() - Add renew action to ContractController with validation - Register ContractRenewal gate policy in FinanceServiceProvider - Add contracts.renew route; update resource to explicit only() list including destroy - Update finance.ts: add ContractRenewal interface, extend Contract with new fields - Replace ContractTest.php with Phase 92 spec tests (lifecycle: activate/terminate/renew, days_remaining, is_expiring, RBAC) https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 921746a commit fc7fb8c

9 files changed

Lines changed: 299 additions & 127 deletions

File tree

erp/app/Modules/Finance/Http/Controllers/ContractController.php

Lines changed: 52 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public function index(Request $request): Response
1717
{
1818
$this->authorize('viewAny', Contract::class);
1919

20-
$contracts = Contract::with('contact')
20+
$contracts = Contract::with('createdBy')
2121
->when($request->status, fn ($q) => $q->where('status', $request->status))
2222
->when($request->type, fn ($q) => $q->where('type', $request->type))
2323
->latest()
@@ -53,23 +53,29 @@ public function store(Request $request): RedirectResponse
5353
$this->authorize('create', Contract::class);
5454

5555
$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'],
56+
'title' => ['required', 'string', 'max:255'],
57+
'party_name' => ['required', 'string', 'max:255'],
58+
'party_email'=> ['nullable', 'email', 'max:255'],
59+
'type' => ['nullable', Rule::in(['client', 'vendor', 'employee', 'employment', 'nda', 'other'])],
60+
'status' => ['nullable', Rule::in(['draft', 'active', 'expired', 'terminated'])],
61+
'contact_id' => ['nullable', Rule::exists('contacts', 'id')],
62+
'start_date' => ['required', 'date'],
63+
'end_date' => ['required', 'date', 'after:start_date'],
64+
'value' => ['nullable', 'numeric', 'min:0'],
65+
'currency' => ['nullable', 'string', 'max:3'],
66+
'currency_code' => ['nullable', 'string', 'max:3'],
67+
'terms' => ['nullable', 'string'],
68+
'notes' => ['nullable', 'string'],
69+
'reference' => ['nullable', 'string', 'max:100'],
70+
'description'=> ['nullable', 'string'],
71+
'auto_renew' => ['boolean'],
6672
'renewal_notice_days' => ['nullable', 'integer', 'min:0'],
67-
'description' => ['nullable', 'string'],
68-
'terms' => ['nullable', 'string'],
6973
]);
7074

7175
$contract = Contract::create([
72-
'tenant_id' => auth()->user()->tenant_id,
76+
'tenant_id' => auth()->user()->tenant_id,
77+
'contract_number' => Contract::generateContractNumber(),
78+
'created_by' => auth()->id(),
7379
...$data,
7480
]);
7581

@@ -81,7 +87,7 @@ public function show(Contract $contract): Response
8187
{
8288
$this->authorize('view', $contract);
8389

84-
$contract->load('contact');
90+
$contract->load(['createdBy', 'renewals', 'contact']);
8591

8692
return Inertia::render('Finance/Contracts/Show', [
8793
'contract' => $contract,
@@ -95,7 +101,7 @@ public function show(Contract $contract): Response
95101

96102
public function edit(Contract $contract): Response
97103
{
98-
$this->authorize('create', Contract::class);
104+
$this->authorize('update', $contract);
99105

100106
return Inertia::render('Finance/Contracts/Edit', [
101107
'contract' => $contract,
@@ -111,13 +117,13 @@ public function edit(Contract $contract): Response
111117

112118
public function update(Request $request, Contract $contract): RedirectResponse
113119
{
114-
$this->authorize('create', Contract::class);
120+
$this->authorize('update', $contract);
115121

116122
$data = $request->validate([
117123
'title' => ['required', 'string', 'max:255'],
118124
'reference' => ['nullable', 'string', 'max:100'],
119125
'contact_id' => ['nullable', Rule::exists('contacts', 'id')],
120-
'type' => ['required', Rule::in(['client', 'vendor', 'employment', 'nda', 'other'])],
126+
'type' => ['required', Rule::in(['client', 'vendor', 'employee', 'employment', 'nda', 'other'])],
121127
'status' => ['nullable', Rule::in(['draft', 'active', 'expired', 'terminated'])],
122128
'value' => ['nullable', 'numeric', 'min:0'],
123129
'currency_code' => ['nullable', 'string', 'size:3'],
@@ -147,19 +153,43 @@ public function destroy(Contract $contract): RedirectResponse
147153

148154
public function activate(Contract $contract): RedirectResponse
149155
{
150-
$this->authorize('create', Contract::class);
156+
$this->authorize('update', $contract);
151157

152158
$contract->activate();
153159

154160
return back()->with('success', 'Contract activated.');
155161
}
156162

157-
public function terminate(Contract $contract): RedirectResponse
163+
public function terminate(Request $request, Contract $contract): RedirectResponse
158164
{
159-
$this->authorize('create', Contract::class);
165+
$this->authorize('update', $contract);
160166

161-
$contract->terminate();
167+
$data = $request->validate([
168+
'notes' => ['nullable', 'string'],
169+
]);
170+
171+
$contract->terminate($data['notes'] ?? '');
162172

163173
return back()->with('success', 'Contract terminated.');
164174
}
175+
176+
public function renew(Request $request, Contract $contract): RedirectResponse
177+
{
178+
$this->authorize('update', $contract);
179+
180+
$data = $request->validate([
181+
'new_end_date' => ['required', 'date', 'after:' . ($contract->end_date?->toDateString() ?? 'today')],
182+
'new_value' => ['nullable', 'numeric', 'min:0'],
183+
'notes' => ['nullable', 'string'],
184+
]);
185+
186+
$contract->renew(
187+
$data['new_end_date'],
188+
isset($data['new_value']) ? (float) $data['new_value'] : null,
189+
$data['notes'] ?? '',
190+
auth()->id()
191+
);
192+
193+
return back()->with('success', 'Contract renewed.');
194+
}
165195
}

erp/app/Modules/Finance/Models/Contract.php

Lines changed: 70 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,58 +5,110 @@
55
use App\Modules\Core\Traits\BelongsToTenant;
66
use Illuminate\Database\Eloquent\Model;
77
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
use Illuminate\Database\Eloquent\Relations\HasMany;
89
use Illuminate\Database\Eloquent\SoftDeletes;
10+
use App\Models\User;
911

1012
class Contract extends Model
1113
{
1214
use BelongsToTenant;
1315
use SoftDeletes;
1416

1517
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',
18+
'tenant_id', 'contact_id', 'contract_number', 'title', 'party_name', 'party_email',
19+
'reference', 'type', 'status', 'value', 'currency', 'currency_code',
20+
'start_date', 'end_date', 'auto_renew', 'renewal_notice_days',
21+
'description', 'terms', 'notes', 'created_by', 'signed_at', 'terminated_at',
1922
];
2023

2124
protected $casts = [
22-
'value' => 'decimal:2',
23-
'start_date' => 'date',
24-
'end_date' => 'date',
25-
'signed_at' => 'date',
26-
'auto_renew' => 'boolean',
25+
'value' => 'float',
26+
'start_date' => 'date',
27+
'end_date' => 'date',
28+
'signed_at' => 'datetime',
29+
'terminated_at' => 'datetime',
30+
'auto_renew' => 'boolean',
2731
];
2832

2933
public function contact(): BelongsTo
3034
{
3135
return $this->belongsTo(Contact::class);
3236
}
3337

34-
public function getIsExpiringAttribute(): bool
38+
public function createdBy(): BelongsTo
3539
{
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+
return $this->belongsTo(User::class, 'created_by');
4041
}
4142

42-
public function getIsExpiredAttribute(): bool
43+
public function renewals(): HasMany
4344
{
44-
return $this->end_date !== null && $this->end_date->isPast();
45+
return $this->hasMany(ContractRenewal::class);
4546
}
4647

4748
public function activate(): void
4849
{
4950
$this->status = 'active';
50-
$this->signed_at = $this->signed_at ?? today();
51+
$this->signed_at = now();
52+
$this->save();
53+
}
54+
55+
public function terminate(string $notes = ''): void
56+
{
57+
$this->status = 'terminated';
58+
$this->terminated_at = now();
59+
if ($notes !== '') {
60+
$this->notes = $notes;
61+
}
5162
$this->save();
5263
}
5364

54-
public function terminate(): void
65+
public function renew(string $newEndDate, ?float $newValue, string $notes, int $userId): void
5566
{
56-
$this->status = 'terminated';
67+
$this->end_date = $newEndDate;
68+
if ($newValue !== null) {
69+
$this->value = $newValue;
70+
}
71+
ContractRenewal::create([
72+
'tenant_id' => $this->tenant_id,
73+
'contract_id' => $this->id,
74+
'new_end_date'=> $newEndDate,
75+
'new_value' => $newValue,
76+
'notes' => $notes,
77+
'renewed_by' => $userId,
78+
]);
5779
$this->save();
5880
}
5981

82+
public static function generateContractNumber(): string
83+
{
84+
return 'CNT-' . strtoupper(uniqid());
85+
}
86+
87+
public function getIsExpiredAttribute(): bool
88+
{
89+
return $this->end_date !== null
90+
&& $this->end_date->isPast()
91+
&& $this->status !== 'terminated';
92+
}
93+
94+
public function getIsExpiringAttribute(): bool
95+
{
96+
if ($this->end_date === null || $this->status !== 'active') {
97+
return false;
98+
}
99+
$noticeDays = $this->renewal_notice_days ?? 30;
100+
return $this->end_date->isFuture()
101+
&& $this->end_date->diffInDays(now(), false) >= -$noticeDays;
102+
}
103+
104+
public function getDaysRemainingAttribute(): int
105+
{
106+
if ($this->end_date === null) {
107+
return 0;
108+
}
109+
return max(0, (int) today()->diffInDays($this->end_date, false));
110+
}
111+
60112
public function scopeExpiringSoon($query)
61113
{
62114
return $query->where('status', 'active')
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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 App\Models\User;
9+
10+
class ContractRenewal extends Model
11+
{
12+
use BelongsToTenant;
13+
14+
protected $fillable = [
15+
'tenant_id', 'contract_id', 'new_end_date', 'new_value', 'notes', 'renewed_by',
16+
];
17+
18+
protected $casts = [
19+
'new_end_date' => 'date',
20+
'new_value' => 'float',
21+
];
22+
23+
public function contract(): BelongsTo
24+
{
25+
return $this->belongsTo(Contract::class);
26+
}
27+
28+
public function renewedBy(): BelongsTo
29+
{
30+
return $this->belongsTo(User::class, 'renewed_by');
31+
}
32+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
use App\Modules\Finance\Models\Commission;
5858
use App\Modules\Finance\Models\CommissionRule;
5959
use App\Modules\Finance\Models\Contract;
60+
use App\Modules\Finance\Models\ContractRenewal;
6061
use App\Modules\Finance\Policies\ContractPolicy;
6162
use App\Modules\Finance\Policies\CommissionPolicy;
6263
use App\Modules\Finance\Policies\CommissionRulePolicy;
@@ -124,6 +125,7 @@ public function boot(): void
124125
Gate::policy(Commission::class, CommissionPolicy::class);
125126
Gate::policy(CommissionRule::class, CommissionRulePolicy::class);
126127
Gate::policy(Contract::class, ContractPolicy::class);
128+
Gate::policy(ContractRenewal::class, ContractPolicy::class);
127129

128130
Gate::policy(ReturnRequest::class, ReturnRequestPolicy::class);
129131
Gate::policy(ReturnRequestItem::class, ReturnRequestPolicy::class);

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -258,11 +258,11 @@
258258
Route::resource('commissions', CommissionController::class)->except(['edit', 'update']);
259259

260260

261-
// Contracts
261+
// Contracts — custom actions BEFORE resource
262262
Route::post('contracts/{contract}/activate', [ContractController::class, 'activate'])->name('contracts.activate');
263263
Route::post('contracts/{contract}/terminate', [ContractController::class, 'terminate'])->name('contracts.terminate');
264-
Route::resource('contracts', ContractController::class)->except(['show']);
265-
Route::get('contracts/{contract}', [ContractController::class, 'show'])->name('contracts.show');
264+
Route::post('contracts/{contract}/renew', [ContractController::class, 'renew'])->name('contracts.renew');
265+
Route::resource('contracts', ContractController::class)->only(['index', 'create', 'store', 'show', 'edit', 'update', 'destroy']);
266266

267267
// Return Requests — custom actions BEFORE resource
268268
Route::post('return-requests/{returnRequest}/approve', [ReturnRequestController::class, 'approve'])->name('return-requests.approve');
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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::table('contracts', function (Blueprint $table) {
12+
if (!Schema::hasColumn('contracts', 'contract_number')) {
13+
$table->string('contract_number')->nullable()->unique()->after('tenant_id');
14+
}
15+
if (!Schema::hasColumn('contracts', 'party_name')) {
16+
$table->string('party_name')->nullable()->after('title');
17+
}
18+
if (!Schema::hasColumn('contracts', 'party_email')) {
19+
$table->string('party_email')->nullable()->after('party_name');
20+
}
21+
if (!Schema::hasColumn('contracts', 'created_by')) {
22+
$table->unsignedBigInteger('created_by')->nullable()->after('terms');
23+
}
24+
if (!Schema::hasColumn('contracts', 'terminated_at')) {
25+
$table->timestamp('terminated_at')->nullable()->after('signed_at');
26+
}
27+
if (!Schema::hasColumn('contracts', 'notes')) {
28+
$table->text('notes')->nullable()->after('description');
29+
}
30+
});
31+
}
32+
33+
public function down(): void
34+
{
35+
Schema::table('contracts', function (Blueprint $table) {
36+
$table->dropColumn(array_filter(['contract_number', 'party_name', 'party_email', 'created_by', 'terminated_at', 'notes'], fn($col) => Schema::hasColumn('contracts', $col)));
37+
});
38+
}
39+
};
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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('contract_renewals', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->unsignedBigInteger('contract_id');
15+
$table->date('new_end_date');
16+
$table->decimal('new_value', 15, 2)->nullable();
17+
$table->text('notes')->nullable();
18+
$table->unsignedBigInteger('renewed_by');
19+
$table->timestamps();
20+
});
21+
}
22+
23+
public function down(): void
24+
{
25+
Schema::dropIfExists('contract_renewals');
26+
}
27+
};

0 commit comments

Comments
 (0)