Skip to content

Commit 7212d64

Browse files
committed
feat(finance): Phase 116 — Customer Groups & Segmentation
Implements B2B customer categorization with shared pricing rules: migrations (customer_groups, customer_group_members), CustomerGroup model with discount calculation, CRUD controller, policy (RBAC), routes, Inertia pages, TypeScript type, sidebar link, and 10 Pest tests. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent ce7308f commit 7212d64

12 files changed

Lines changed: 610 additions & 0 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
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\CustomerGroup;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Inertia\Inertia;
11+
use Inertia\Response;
12+
13+
class CustomerGroupController extends Controller
14+
{
15+
public function index(Request $request): Response
16+
{
17+
$this->authorize('viewAny', CustomerGroup::class);
18+
19+
$customerGroups = CustomerGroup::latest()
20+
->paginate(20)
21+
->withQueryString();
22+
23+
return Inertia::render('Finance/CustomerGroups/Index', [
24+
'customerGroups' => $customerGroups,
25+
]);
26+
}
27+
28+
public function store(Request $request): RedirectResponse
29+
{
30+
$this->authorize('create', CustomerGroup::class);
31+
32+
$validated = $request->validate([
33+
'name' => 'required|string|max:255',
34+
'description' => 'nullable|string',
35+
'discount_percent' => 'nullable|numeric|min:0|max:100',
36+
'credit_limit' => 'nullable|numeric|min:0',
37+
'payment_term_id' => 'nullable|exists:payment_terms,id',
38+
'currency' => 'nullable|string|max:3',
39+
'is_active' => 'boolean',
40+
]);
41+
42+
$customerGroup = CustomerGroup::create([
43+
...$validated,
44+
'tenant_id' => auth()->user()->tenant_id,
45+
]);
46+
47+
return redirect()->route('finance.customer-groups.show', $customerGroup)
48+
->with('success', 'Customer group created successfully.');
49+
}
50+
51+
public function show(CustomerGroup $customerGroup): Response
52+
{
53+
$this->authorize('view', $customerGroup);
54+
55+
$customerGroup->load('paymentTerm');
56+
$members = $customerGroup->members()->paginate(10);
57+
58+
return Inertia::render('Finance/CustomerGroups/Show', [
59+
'customerGroup' => $customerGroup,
60+
'members' => $members,
61+
]);
62+
}
63+
64+
public function update(Request $request, CustomerGroup $customerGroup): RedirectResponse
65+
{
66+
$this->authorize('update', $customerGroup);
67+
68+
$validated = $request->validate([
69+
'name' => 'required|string|max:255',
70+
'description' => 'nullable|string',
71+
'discount_percent' => 'nullable|numeric|min:0|max:100',
72+
'credit_limit' => 'nullable|numeric|min:0',
73+
'payment_term_id' => 'nullable|exists:payment_terms,id',
74+
'currency' => 'nullable|string|max:3',
75+
'is_active' => 'boolean',
76+
]);
77+
78+
$customerGroup->update($validated);
79+
80+
return redirect()->back()->with('success', 'Customer group updated successfully.');
81+
}
82+
83+
public function addMember(Request $request, CustomerGroup $customerGroup): RedirectResponse
84+
{
85+
$this->authorize('update', $customerGroup);
86+
87+
$validated = $request->validate([
88+
'contact_id' => 'required|exists:contacts,id',
89+
]);
90+
91+
$customerGroup->members()->syncWithoutDetaching([$validated['contact_id']]);
92+
93+
return redirect()->back()->with('success', 'Contact added to group successfully.');
94+
}
95+
96+
public function removeMember(CustomerGroup $customerGroup, Contact $contact): RedirectResponse
97+
{
98+
$this->authorize('update', $customerGroup);
99+
100+
$customerGroup->members()->detach($contact->id);
101+
102+
return redirect()->back()->with('success', 'Contact removed from group successfully.');
103+
}
104+
105+
public function destroy(CustomerGroup $customerGroup): RedirectResponse
106+
{
107+
$this->authorize('delete', $customerGroup);
108+
109+
$customerGroup->delete();
110+
111+
return redirect()->route('finance.customer-groups.index')
112+
->with('success', 'Customer group deleted successfully.');
113+
}
114+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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\BelongsToMany;
9+
use Illuminate\Database\Eloquent\SoftDeletes;
10+
11+
class CustomerGroup extends Model
12+
{
13+
use BelongsToTenant, SoftDeletes;
14+
15+
protected $fillable = [
16+
'tenant_id', 'name', 'description', 'discount_percent',
17+
'credit_limit', 'payment_term_id', 'currency', 'is_active',
18+
];
19+
20+
protected $casts = [
21+
'discount_percent' => 'float',
22+
'credit_limit' => 'float',
23+
'is_active' => 'boolean',
24+
];
25+
26+
public function members(): BelongsToMany
27+
{
28+
return $this->belongsToMany(
29+
Contact::class,
30+
'customer_group_members',
31+
'customer_group_id',
32+
'contact_id'
33+
)->withTimestamps();
34+
}
35+
36+
public function paymentTerm(): BelongsTo
37+
{
38+
return $this->belongsTo(PaymentTerm::class);
39+
}
40+
41+
public function getMemberCountAttribute(): int
42+
{
43+
return $this->members()->count();
44+
}
45+
46+
public function getHasDiscountAttribute(): bool
47+
{
48+
return $this->discount_percent > 0;
49+
}
50+
51+
public function calculateDiscount(float $amount): float
52+
{
53+
return $amount * ($this->discount_percent / 100);
54+
}
55+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Finance\Models\CustomerGroup;
7+
8+
class CustomerGroupPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('finance.view');
13+
}
14+
15+
public function view(User $user, CustomerGroup $customerGroup): bool
16+
{
17+
return $user->can('finance.view');
18+
}
19+
20+
public function create(User $user): bool
21+
{
22+
return $user->can('finance.create');
23+
}
24+
25+
public function update(User $user, CustomerGroup $customerGroup): bool
26+
{
27+
return $user->can('finance.create');
28+
}
29+
30+
public function delete(User $user, CustomerGroup $customerGroup): bool
31+
{
32+
return $user->can('finance.delete');
33+
}
34+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@
9595
use App\Modules\Finance\Policies\PettyCashPolicy;
9696
use App\Modules\Finance\Models\BankTransfer;
9797
use App\Modules\Finance\Policies\BankTransferPolicy;
98+
use App\Modules\Finance\Models\CustomerGroup;
99+
use App\Modules\Finance\Policies\CustomerGroupPolicy;
98100
use Illuminate\Support\Facades\Gate;
99101
use Illuminate\Support\ServiceProvider;
100102

@@ -170,6 +172,7 @@ public function boot(): void
170172
Gate::policy(PettyCashFund::class, PettyCashPolicy::class);
171173
Gate::policy(PettyCashTransaction::class, PettyCashPolicy::class);
172174
Gate::policy(BankTransfer::class, BankTransferPolicy::class);
175+
Gate::policy(CustomerGroup::class, CustomerGroupPolicy::class);
173176
if ($this->app->runningInConsole()) {
174177
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);
175178
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
use App\Modules\Finance\Http\Controllers\SupportTicketController;
4444
use App\Modules\Finance\Http\Controllers\CurrencyController;
4545
use App\Modules\Finance\Http\Controllers\PaymentTermController;
46+
use App\Modules\Finance\Http\Controllers\CustomerGroupController;
4647

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

@@ -307,6 +308,11 @@
307308
Route::post('support-tickets/{supportTicket}/comments', [SupportTicketController::class, 'addComment'])->name('support-tickets.comments.add');
308309
Route::resource('support-tickets', SupportTicketController::class)->except(['edit', 'update']);
309310

311+
// Customer Groups — custom member actions BEFORE resource
312+
Route::post('customer-groups/{customerGroup}/members', [CustomerGroupController::class, 'addMember'])->name('finance.customer-groups.members.add');
313+
Route::delete('customer-groups/{customerGroup}/members/{contact}', [CustomerGroupController::class, 'removeMember'])->name('finance.customer-groups.members.remove');
314+
Route::resource('customer-groups', CustomerGroupController::class)->except(['create', 'edit']);
315+
310316
});
311317

312318

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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('customer_groups', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->string('name');
15+
$table->text('description')->nullable();
16+
$table->decimal('discount_percent', 5, 2)->default(0);
17+
$table->decimal('credit_limit', 15, 2)->default(0);
18+
$table->unsignedBigInteger('payment_term_id')->nullable();
19+
$table->string('currency', 3)->default('USD');
20+
$table->boolean('is_active')->default(true);
21+
$table->timestamps();
22+
$table->softDeletes();
23+
});
24+
}
25+
26+
public function down(): void
27+
{
28+
Schema::dropIfExists('customer_groups');
29+
}
30+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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('customer_group_members', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('customer_group_id');
14+
$table->unsignedBigInteger('contact_id');
15+
$table->timestamps();
16+
$table->unique(['customer_group_id', 'contact_id']);
17+
});
18+
}
19+
20+
public function down(): void
21+
{
22+
Schema::dropIfExists('customer_group_members');
23+
}
24+
};

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ const navItems: NavItem[] = [
108108
{ label: 'Delivery Notes', href: '/finance/delivery-notes', icon: <span /> },
109109
{ label: 'Credit Notes', href: '/finance/credit-notes', icon: <span /> },
110110
{ label: 'Contacts', href: '/finance/contacts', icon: <span /> },
111+
{ label: 'Customer Groups', href: '/finance/customer-groups', icon: <span /> },
111112
{ label: 'Journal Entries', href: '/finance/journal-entries', icon: <span /> },
112113
{ label: 'Chart of Accounts', href: '/finance/accounts', icon: <span /> },
113114
{ label: 'Bills (AP)', href: '/finance/bills', icon: <span /> },
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { Head, Link, router } from '@inertiajs/react';
2+
import { CustomerGroup } from '@/types/finance';
3+
4+
interface Props {
5+
customerGroups: {
6+
data: CustomerGroup[];
7+
links: { url: string | null; label: string; active: boolean }[];
8+
};
9+
}
10+
11+
export default function Index({ customerGroups }: Props) {
12+
return (
13+
<>
14+
<Head title="Customer Groups" />
15+
<div className="p-6">
16+
<div className="mb-6 flex items-center justify-between">
17+
<h1 className="text-2xl font-bold text-slate-900">Customer Groups</h1>
18+
<button
19+
onClick={() => {
20+
const name = prompt('Group name:');
21+
if (name) {
22+
router.post('/finance/customer-groups', { name });
23+
}
24+
}}
25+
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
26+
>
27+
New Group
28+
</button>
29+
</div>
30+
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm">
31+
<table className="min-w-full divide-y divide-slate-200">
32+
<thead className="bg-slate-50">
33+
<tr>
34+
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">Name</th>
35+
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">Discount</th>
36+
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">Currency</th>
37+
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">Status</th>
38+
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-slate-500">Actions</th>
39+
</tr>
40+
</thead>
41+
<tbody className="divide-y divide-slate-200 bg-white">
42+
{customerGroups.data.map((group) => (
43+
<tr key={group.id} className="hover:bg-slate-50">
44+
<td className="px-6 py-4 text-sm font-medium text-slate-900">{group.name}</td>
45+
<td className="px-6 py-4 text-sm text-slate-500">{group.discount_percent}%</td>
46+
<td className="px-6 py-4 text-sm text-slate-500">{group.currency}</td>
47+
<td className="px-6 py-4 text-sm">
48+
<span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-medium ${group.is_active ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-600'}`}>
49+
{group.is_active ? 'Active' : 'Inactive'}
50+
</span>
51+
</td>
52+
<td className="px-6 py-4 text-sm">
53+
<Link
54+
href={`/finance/customer-groups/${group.id}`}
55+
className="text-indigo-600 hover:text-indigo-800"
56+
>
57+
View
58+
</Link>
59+
</td>
60+
</tr>
61+
))}
62+
{customerGroups.data.length === 0 && (
63+
<tr>
64+
<td colSpan={5} className="px-6 py-8 text-center text-sm text-slate-500">
65+
No customer groups found.
66+
</td>
67+
</tr>
68+
)}
69+
</tbody>
70+
</table>
71+
</div>
72+
</div>
73+
</>
74+
);
75+
}

0 commit comments

Comments
 (0)