Skip to content

Commit eef2a00

Browse files
committed
feat(finance): Phase 143 — Finance Customer Credits
Adds CustomerCredit module with migration, model (BelongsToTenant, SoftDeletes), policy (RBAC finance.view/create/delete), controller (CRUD + issue/expire/cancel), routes, FinanceServiceProvider registration, React stubs, and Pest feature tests. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent d7f9166 commit eef2a00

11 files changed

Lines changed: 978 additions & 0 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\CustomerCredit;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class CustomerCreditController extends Controller
13+
{
14+
public function index(): Response
15+
{
16+
$this->authorize('viewAny', CustomerCredit::class);
17+
$customerCredits = CustomerCredit::where('tenant_id', app('tenant')->id)
18+
->latest()
19+
->paginate(20);
20+
return Inertia::render('Finance/CustomerCredits/Index', compact('customerCredits'));
21+
}
22+
23+
public function create(): Response
24+
{
25+
$this->authorize('create', CustomerCredit::class);
26+
return Inertia::render('Finance/CustomerCredits/Create');
27+
}
28+
29+
public function store(Request $request): RedirectResponse
30+
{
31+
$this->authorize('create', CustomerCredit::class);
32+
$validated = $request->validate([
33+
'customer_name' => 'required|string|max:255',
34+
'credit_amount' => 'required|numeric|min:0',
35+
'expiry_date' => 'nullable|date',
36+
]);
37+
$validated['tenant_id'] = app('tenant')->id;
38+
$validated['created_by'] = auth()->id();
39+
CustomerCredit::create($validated);
40+
return redirect()->route('finance.customer-credits.index')
41+
->with('success', 'Customer credit created.');
42+
}
43+
44+
public function show(CustomerCredit $customerCredit): Response
45+
{
46+
$this->authorize('view', $customerCredit);
47+
return Inertia::render('Finance/CustomerCredits/Show', compact('customerCredit'));
48+
}
49+
50+
public function edit(CustomerCredit $customerCredit): Response
51+
{
52+
$this->authorize('update', $customerCredit);
53+
return Inertia::render('Finance/CustomerCredits/Edit', compact('customerCredit'));
54+
}
55+
56+
public function update(Request $request, CustomerCredit $customerCredit): RedirectResponse
57+
{
58+
$this->authorize('update', $customerCredit);
59+
$validated = $request->validate([
60+
'customer_name' => 'sometimes|required|string|max:255',
61+
'credit_amount' => 'sometimes|required|numeric|min:0',
62+
'expiry_date' => 'nullable|date',
63+
]);
64+
$customerCredit->update($validated);
65+
return redirect()->route('finance.customer-credits.index')
66+
->with('success', 'Customer credit updated.');
67+
}
68+
69+
public function destroy(CustomerCredit $customerCredit): RedirectResponse
70+
{
71+
$this->authorize('delete', $customerCredit);
72+
$customerCredit->delete();
73+
return redirect()->route('finance.customer-credits.index')
74+
->with('success', 'Customer credit deleted.');
75+
}
76+
77+
public function issue(CustomerCredit $customerCredit): RedirectResponse
78+
{
79+
$this->authorize('issue', $customerCredit);
80+
$customerCredit->issue(auth()->id());
81+
return redirect()->route('finance.customer-credits.index')
82+
->with('success', 'Customer credit issued.');
83+
}
84+
85+
public function expire(CustomerCredit $customerCredit): RedirectResponse
86+
{
87+
$this->authorize('expire', $customerCredit);
88+
$customerCredit->expire();
89+
return redirect()->route('finance.customer-credits.index')
90+
->with('success', 'Customer credit expired.');
91+
}
92+
93+
public function cancel(CustomerCredit $customerCredit): RedirectResponse
94+
{
95+
$this->authorize('cancel', $customerCredit);
96+
$customerCredit->cancel();
97+
return redirect()->route('finance.customer-credits.index')
98+
->with('success', 'Customer credit cancelled.');
99+
}
100+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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\Casts\Attribute;
8+
use Illuminate\Database\Eloquent\Model;
9+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
10+
use Illuminate\Database\Eloquent\SoftDeletes;
11+
12+
class CustomerCredit extends Model
13+
{
14+
use BelongsToTenant, SoftDeletes;
15+
16+
protected $fillable = [
17+
'tenant_id',
18+
'credit_number',
19+
'customer_name',
20+
'customer_code',
21+
'credit_amount',
22+
'used_amount',
23+
'currency',
24+
'reason',
25+
'status',
26+
'expiry_date',
27+
'reference_type',
28+
'reference_id',
29+
'notes',
30+
'issued_by',
31+
'issued_at',
32+
'created_by',
33+
];
34+
35+
protected $attributes = [
36+
'status' => 'active',
37+
'currency' => 'USD',
38+
'used_amount' => 0,
39+
];
40+
41+
protected $casts = [
42+
'credit_amount' => 'decimal:2',
43+
'used_amount' => 'decimal:2',
44+
'expiry_date' => 'date',
45+
'issued_at' => 'datetime',
46+
];
47+
48+
// Relations
49+
50+
public function issuedBy(): BelongsTo
51+
{
52+
return $this->belongsTo(User::class, 'issued_by');
53+
}
54+
55+
public function createdBy(): BelongsTo
56+
{
57+
return $this->belongsTo(User::class, 'created_by');
58+
}
59+
60+
// Accessors
61+
62+
protected function remainingAmount(): Attribute
63+
{
64+
return Attribute::make(
65+
get: fn () => max(0, (float) $this->credit_amount - (float) $this->used_amount),
66+
);
67+
}
68+
69+
protected function isActive(): Attribute
70+
{
71+
return Attribute::make(
72+
get: fn () => $this->status === 'active',
73+
);
74+
}
75+
76+
protected function isExhausted(): Attribute
77+
{
78+
return Attribute::make(
79+
get: fn () => $this->status === 'exhausted',
80+
);
81+
}
82+
83+
protected function isExpired(): Attribute
84+
{
85+
return Attribute::make(
86+
get: fn () => $this->status === 'expired'
87+
|| ($this->status === 'active' && $this->expiry_date && $this->expiry_date < now()->startOfDay()),
88+
);
89+
}
90+
91+
// Methods
92+
93+
public function generateCreditNumber(): string
94+
{
95+
return 'CC-' . date('Y') . '-' . str_pad((string) $this->id, 5, '0', STR_PAD_LEFT);
96+
}
97+
98+
public function issue(int $userId): void
99+
{
100+
$this->issued_by = $userId;
101+
$this->issued_at = now();
102+
if (is_null($this->credit_number)) {
103+
$this->credit_number = $this->generateCreditNumber();
104+
}
105+
$this->save();
106+
}
107+
108+
public function apply(float $amount): void
109+
{
110+
$this->used_amount = (float) $this->used_amount + $amount;
111+
if ((float) $this->used_amount >= (float) $this->credit_amount) {
112+
$this->status = 'exhausted';
113+
}
114+
$this->save();
115+
}
116+
117+
public function expire(): void
118+
{
119+
$this->status = 'expired';
120+
$this->save();
121+
}
122+
123+
public function cancel(): void
124+
{
125+
$this->status = 'cancelled';
126+
$this->save();
127+
}
128+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Finance\Models\CustomerCredit;
7+
8+
class CustomerCreditPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('finance.view');
13+
}
14+
15+
public function view(User $user, CustomerCredit $customerCredit): 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, CustomerCredit $customerCredit): bool
26+
{
27+
return $user->can('finance.create');
28+
}
29+
30+
public function issue(User $user, CustomerCredit $customerCredit): bool
31+
{
32+
return $user->can('finance.create');
33+
}
34+
35+
public function apply(User $user, CustomerCredit $customerCredit): bool
36+
{
37+
return $user->can('finance.create');
38+
}
39+
40+
public function expire(User $user, CustomerCredit $customerCredit): bool
41+
{
42+
return $user->can('finance.delete');
43+
}
44+
45+
public function cancel(User $user, CustomerCredit $customerCredit): bool
46+
{
47+
return $user->can('finance.delete');
48+
}
49+
50+
public function delete(User $user, CustomerCredit $customerCredit): bool
51+
{
52+
return $user->can('finance.delete');
53+
}
54+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@
115115
use App\Modules\Finance\Models\PaymentSchedule;
116116
use App\Modules\Finance\Models\PaymentScheduleItem;
117117
use App\Modules\Finance\Policies\PaymentSchedulePolicy;
118+
use App\Modules\Finance\Models\CustomerCredit;
119+
use App\Modules\Finance\Policies\CustomerCreditPolicy;
118120
use Illuminate\Support\Facades\Gate;
119121
use Illuminate\Support\ServiceProvider;
120122

@@ -201,6 +203,7 @@ public function boot(): void
201203
Gate::policy(VendorPayment::class, VendorPaymentPolicy::class);
202204
Gate::policy(PaymentSchedule::class, PaymentSchedulePolicy::class);
203205
Gate::policy(PaymentScheduleItem::class, PaymentSchedulePolicy::class);
206+
Gate::policy(CustomerCredit::class, CustomerCreditPolicy::class);
204207
if ($this->app->runningInConsole()) {
205208
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);
206209
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,3 +428,12 @@
428428
Route::post('payment-schedules/{payment_schedule}/cancel', [PaymentScheduleController::class, 'cancel'])->name('payment-schedules.cancel');
429429
Route::resource('payment-schedules', PaymentScheduleController::class);
430430
});
431+
432+
// Customer Credits
433+
use App\Modules\Finance\Http\Controllers\CustomerCreditController;
434+
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
435+
Route::post('customer-credits/{customer_credit}/issue', [CustomerCreditController::class, 'issue'])->name('customer-credits.issue');
436+
Route::post('customer-credits/{customer_credit}/expire', [CustomerCreditController::class, 'expire'])->name('customer-credits.expire');
437+
Route::post('customer-credits/{customer_credit}/cancel', [CustomerCreditController::class, 'cancel'])->name('customer-credits.cancel');
438+
Route::resource('customer-credits', CustomerCreditController::class);
439+
});
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::dropIfExists('customer_credits');
12+
Schema::create('customer_credits', function (Blueprint $table) {
13+
$table->id();
14+
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
15+
$table->string('credit_number')->nullable();
16+
$table->string('customer_name');
17+
$table->string('customer_code')->nullable();
18+
$table->decimal('credit_amount', 15, 2);
19+
$table->decimal('used_amount', 15, 2)->default(0);
20+
$table->string('currency')->default('USD');
21+
$table->string('reason')->nullable();
22+
$table->string('status')->default('active'); // active/exhausted/expired/cancelled
23+
$table->date('expiry_date')->nullable();
24+
$table->string('reference_type')->nullable(); // Invoice, ReturnRequest, etc.
25+
$table->unsignedBigInteger('reference_id')->nullable();
26+
$table->text('notes')->nullable();
27+
$table->foreignId('issued_by')->nullable()->constrained('users')->nullOnDelete();
28+
$table->timestamp('issued_at')->nullable();
29+
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
30+
$table->timestamps();
31+
$table->softDeletes();
32+
});
33+
}
34+
35+
public function down(): void
36+
{
37+
Schema::dropIfExists('customer_credits');
38+
}
39+
};

0 commit comments

Comments
 (0)