Skip to content

Commit 00df34d

Browse files
committed
feat(finance): Phase 74 — Service Agreements & Maintenance Contracts
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 6c5562d commit 00df34d

16 files changed

Lines changed: 1297 additions & 0 deletions
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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\MaintenanceLog;
8+
use App\Modules\Finance\Models\ServiceAgreement;
9+
use App\Modules\Finance\Models\ServiceAgreementItem;
10+
use Illuminate\Http\RedirectResponse;
11+
use Illuminate\Http\Request;
12+
use Inertia\Inertia;
13+
use Inertia\Response;
14+
15+
class ServiceAgreementController extends Controller
16+
{
17+
public function index(Request $request): Response
18+
{
19+
$this->authorize('viewAny', ServiceAgreement::class);
20+
21+
$query = ServiceAgreement::with('contact')->orderByDesc('created_at');
22+
23+
if ($request->filled('status')) {
24+
$query->where('status', $request->status);
25+
}
26+
27+
$agreements = $query->paginate(15)->withQueryString();
28+
29+
return Inertia::render('Finance/ServiceAgreements/Index', [
30+
'agreements' => $agreements,
31+
'filters' => $request->only('status'),
32+
]);
33+
}
34+
35+
public function create(): Response
36+
{
37+
$this->authorize('create', ServiceAgreement::class);
38+
39+
$contacts = Contact::orderBy('name')->get(['id', 'name', 'email']);
40+
41+
return Inertia::render('Finance/ServiceAgreements/Create', [
42+
'contacts' => $contacts,
43+
]);
44+
}
45+
46+
public function store(Request $request): RedirectResponse
47+
{
48+
$this->authorize('create', ServiceAgreement::class);
49+
50+
$validated = $request->validate([
51+
'title' => ['required', 'string', 'max:255'],
52+
'contact_id' => ['nullable', 'exists:contacts,id'],
53+
'agreement_type' => ['required', 'in:maintenance,support,sla,retainer'],
54+
'billing_cycle' => ['required', 'in:monthly,quarterly,annually,one_time'],
55+
'start_date' => ['nullable', 'date'],
56+
'end_date' => ['nullable', 'date'],
57+
'value' => ['nullable', 'numeric', 'min:0'],
58+
'auto_renew' => ['boolean'],
59+
'description' => ['nullable', 'string'],
60+
'terms' => ['nullable', 'string'],
61+
]);
62+
63+
$agreement = ServiceAgreement::create(array_merge($validated, [
64+
'tenant_id' => $request->user()->tenant_id,
65+
'status' => 'draft',
66+
]));
67+
68+
return redirect()->route('finance.service-agreements.show', $agreement);
69+
}
70+
71+
public function show(ServiceAgreement $serviceAgreement): Response
72+
{
73+
$this->authorize('view', $serviceAgreement);
74+
75+
$serviceAgreement->load(['serviceItems', 'maintenanceLogs.technician', 'contact']);
76+
77+
$data = $serviceAgreement->toArray();
78+
$data['is_expired'] = $serviceAgreement->is_expired;
79+
$data['is_expiring'] = $serviceAgreement->is_expiring;
80+
$data['days_remaining'] = $serviceAgreement->days_remaining;
81+
82+
return Inertia::render('Finance/ServiceAgreements/Show', [
83+
'agreement' => $data,
84+
]);
85+
}
86+
87+
public function destroy(ServiceAgreement $serviceAgreement): RedirectResponse
88+
{
89+
$this->authorize('delete', $serviceAgreement);
90+
91+
$serviceAgreement->delete();
92+
93+
return redirect()->route('finance.service-agreements.index');
94+
}
95+
96+
public function activate(Request $request, ServiceAgreement $serviceAgreement): RedirectResponse
97+
{
98+
$this->authorize('update', $serviceAgreement);
99+
100+
$serviceAgreement->activate();
101+
102+
return redirect()->back()->with('success', 'Agreement activated.');
103+
}
104+
105+
public function terminate(Request $request, ServiceAgreement $serviceAgreement): RedirectResponse
106+
{
107+
$this->authorize('update', $serviceAgreement);
108+
109+
$serviceAgreement->terminate();
110+
111+
return redirect()->back()->with('success', 'Agreement terminated.');
112+
}
113+
114+
public function addItem(Request $request, ServiceAgreement $serviceAgreement): RedirectResponse
115+
{
116+
$this->authorize('create', ServiceAgreement::class);
117+
118+
$validated = $request->validate([
119+
'description' => ['required', 'string'],
120+
'quantity' => ['required', 'integer', 'min:1'],
121+
'unit_price' => ['required', 'numeric', 'min:0'],
122+
]);
123+
124+
$item = ServiceAgreementItem::create(array_merge($validated, [
125+
'service_agreement_id' => $serviceAgreement->id,
126+
'tenant_id' => $serviceAgreement->tenant_id,
127+
'total_price' => 0,
128+
]));
129+
130+
$item->calculateTotal();
131+
132+
return redirect()->back()->with('success', 'Item added.');
133+
}
134+
135+
public function addLog(Request $request, ServiceAgreement $serviceAgreement): RedirectResponse
136+
{
137+
$this->authorize('create', ServiceAgreement::class);
138+
139+
$validated = $request->validate([
140+
'log_date' => ['required', 'date'],
141+
'description' => ['required', 'string'],
142+
'status' => ['nullable', 'in:scheduled,completed,cancelled'],
143+
'hours_spent' => ['nullable', 'numeric'],
144+
'next_service_date' => ['nullable', 'date'],
145+
'technician_id' => ['nullable', 'exists:users,id'],
146+
]);
147+
148+
$validated['status'] = $validated['status'] ?? 'scheduled';
149+
150+
MaintenanceLog::create(array_merge($validated, [
151+
'service_agreement_id' => $serviceAgreement->id,
152+
'tenant_id' => $serviceAgreement->tenant_id,
153+
]));
154+
155+
return redirect()->back()->with('success', 'Log added.');
156+
}
157+
158+
public function completeLog(Request $request, ServiceAgreement $serviceAgreement, MaintenanceLog $log): RedirectResponse
159+
{
160+
$this->authorize('create', ServiceAgreement::class);
161+
162+
$validated = $request->validate([
163+
'resolution' => ['required', 'string'],
164+
]);
165+
166+
$log->complete($validated['resolution']);
167+
168+
return redirect()->back()->with('success', 'Log completed.');
169+
}
170+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
10+
class MaintenanceLog extends Model
11+
{
12+
use BelongsToTenant;
13+
14+
protected $table = 'maintenance_logs';
15+
16+
protected $fillable = [
17+
'tenant_id', 'service_agreement_id', 'technician_id', 'log_date',
18+
'description', 'status', 'resolution', 'hours_spent', 'next_service_date',
19+
];
20+
21+
protected $casts = [
22+
'log_date' => 'date',
23+
'next_service_date' => 'date',
24+
'hours_spent' => 'decimal:2',
25+
];
26+
27+
public function agreement(): BelongsTo
28+
{
29+
return $this->belongsTo(ServiceAgreement::class, 'service_agreement_id');
30+
}
31+
32+
public function technician(): BelongsTo
33+
{
34+
return $this->belongsTo(User::class, 'technician_id');
35+
}
36+
37+
public function complete(string $resolution): void
38+
{
39+
$this->status = 'completed';
40+
$this->resolution = $resolution;
41+
$this->save();
42+
}
43+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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\Relations\HasMany;
9+
use Illuminate\Database\Eloquent\SoftDeletes;
10+
11+
class ServiceAgreement extends Model
12+
{
13+
use BelongsToTenant;
14+
use SoftDeletes;
15+
16+
protected $table = 'service_agreements';
17+
18+
protected $fillable = [
19+
'tenant_id', 'contact_id', 'title', 'description', 'agreement_type',
20+
'status', 'start_date', 'end_date', 'value', 'billing_cycle',
21+
'auto_renew', 'terms', 'signed_at',
22+
];
23+
24+
protected $casts = [
25+
'start_date' => 'date',
26+
'end_date' => 'date',
27+
'signed_at' => 'date',
28+
'value' => 'decimal:2',
29+
'auto_renew' => 'boolean',
30+
];
31+
32+
public function contact(): BelongsTo
33+
{
34+
return $this->belongsTo(Contact::class);
35+
}
36+
37+
public function serviceItems(): HasMany
38+
{
39+
return $this->hasMany(ServiceAgreementItem::class);
40+
}
41+
42+
public function maintenanceLogs(): HasMany
43+
{
44+
return $this->hasMany(MaintenanceLog::class);
45+
}
46+
47+
public function activate(): void
48+
{
49+
$this->status = 'active';
50+
$this->save();
51+
}
52+
53+
public function terminate(): void
54+
{
55+
$this->status = 'terminated';
56+
$this->save();
57+
}
58+
59+
public function getIsExpiredAttribute(): bool
60+
{
61+
return $this->end_date !== null
62+
&& $this->end_date->isPast()
63+
&& $this->status !== 'terminated';
64+
}
65+
66+
public function getIsExpiringAttribute(): bool
67+
{
68+
return $this->end_date !== null
69+
&& $this->end_date->diffInDays(now()) <= 30
70+
&& $this->end_date->isFuture()
71+
&& $this->status === 'active';
72+
}
73+
74+
public function getDaysRemainingAttribute(): ?int
75+
{
76+
if ($this->end_date === null) {
77+
return null;
78+
}
79+
if ($this->end_date->isPast()) {
80+
return 0;
81+
}
82+
return (int) now()->diffInDays($this->end_date);
83+
}
84+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
9+
class ServiceAgreementItem extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $table = 'service_agreement_items';
14+
15+
protected $fillable = [
16+
'tenant_id', 'service_agreement_id', 'description',
17+
'quantity', 'unit_price', 'total_price',
18+
];
19+
20+
protected $casts = [
21+
'quantity' => 'integer',
22+
'unit_price' => 'decimal:2',
23+
'total_price' => 'decimal:2',
24+
];
25+
26+
public function agreement(): BelongsTo
27+
{
28+
return $this->belongsTo(ServiceAgreement::class, 'service_agreement_id');
29+
}
30+
31+
public function calculateTotal(): void
32+
{
33+
$this->total_price = $this->quantity * $this->unit_price;
34+
$this->save();
35+
}
36+
}
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\ServiceAgreement;
7+
8+
class ServiceAgreementPolicy
9+
{
10+
public function viewAny(User $user): bool { return $user->can('finance.view'); }
11+
public function view(User $user, ServiceAgreement $serviceAgreement): bool { return $user->can('finance.view'); }
12+
public function create(User $user): bool { return $user->can('finance.create'); }
13+
public function update(User $user, ServiceAgreement $serviceAgreement): bool { return $user->can('finance.create'); }
14+
public function delete(User $user, ServiceAgreement $serviceAgreement): bool { return $user->can('finance.delete'); }
15+
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@
6363
use App\Modules\Finance\Models\TaxGroup;
6464
use App\Modules\Finance\Models\TaxGroupItem;
6565
use App\Modules\Finance\Policies\TaxPolicy;
66+
use App\Modules\Finance\Models\ServiceAgreement;
67+
use App\Modules\Finance\Models\ServiceAgreementItem;
68+
use App\Modules\Finance\Models\MaintenanceLog;
69+
use App\Modules\Finance\Policies\ServiceAgreementPolicy;
6670
use Illuminate\Support\Facades\Gate;
6771
use Illuminate\Support\ServiceProvider;
6872

@@ -112,6 +116,10 @@ public function boot(): void
112116
Gate::policy(TaxGroup::class, TaxPolicy::class);
113117
Gate::policy(TaxGroupItem::class, TaxPolicy::class);
114118

119+
Gate::policy(ServiceAgreement::class, ServiceAgreementPolicy::class);
120+
Gate::policy(ServiceAgreementItem::class, ServiceAgreementPolicy::class);
121+
Gate::policy(MaintenanceLog::class, ServiceAgreementPolicy::class);
122+
115123
if ($this->app->runningInConsole()) {
116124
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);
117125
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
use App\Modules\Finance\Http\Controllers\ReturnRequestController;
3636
use App\Modules\Finance\Http\Controllers\TaxRateController;
3737
use App\Modules\Finance\Http\Controllers\TaxGroupController;
38+
use App\Modules\Finance\Http\Controllers\ServiceAgreementController;
3839

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

@@ -254,4 +255,12 @@
254255
Route::delete('tax-groups/{taxGroup}/rates/{item}', [TaxGroupController::class, 'removeRate'])->name('tax-groups.rates.remove');
255256
Route::resource('tax-groups', TaxGroupController::class)->except(['edit', 'update']);
256257

258+
// Service Agreements — custom actions BEFORE resource
259+
Route::post('service-agreements/{serviceAgreement}/activate', [ServiceAgreementController::class, 'activate'])->name('service-agreements.activate');
260+
Route::post('service-agreements/{serviceAgreement}/terminate', [ServiceAgreementController::class, 'terminate'])->name('service-agreements.terminate');
261+
Route::post('service-agreements/{serviceAgreement}/items', [ServiceAgreementController::class, 'addItem'])->name('service-agreements.items.add');
262+
Route::post('service-agreements/{serviceAgreement}/logs', [ServiceAgreementController::class, 'addLog'])->name('service-agreements.logs.add');
263+
Route::post('service-agreements/{serviceAgreement}/logs/{log}/complete', [ServiceAgreementController::class, 'completeLog'])->name('service-agreements.logs.complete');
264+
Route::resource('service-agreements', ServiceAgreementController::class)->except(['edit', 'update']);
265+
257266
});

0 commit comments

Comments
 (0)