Skip to content

Commit c76c3d7

Browse files
committed
feat(finance): Phase 65 — Return & Refund Management
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 0f051a9 commit c76c3d7

14 files changed

Lines changed: 1050 additions & 0 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
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\Invoice;
8+
use App\Modules\Finance\Models\ReturnRequest;
9+
use App\Modules\Finance\Models\ReturnRequestItem;
10+
use Illuminate\Http\RedirectResponse;
11+
use Illuminate\Http\Request;
12+
use Illuminate\Support\Facades\DB;
13+
use Inertia\Inertia;
14+
use Inertia\Response;
15+
16+
class ReturnRequestController extends Controller
17+
{
18+
public function index(Request $request): Response
19+
{
20+
$this->authorize('viewAny', ReturnRequest::class);
21+
22+
$returnRequests = ReturnRequest::with(['contact', 'invoice'])
23+
->when($request->status, fn ($q) => $q->where('status', $request->status))
24+
->latest()
25+
->paginate(15)
26+
->withQueryString();
27+
28+
return Inertia::render('Finance/ReturnRequests/Index', [
29+
'returnRequests' => $returnRequests,
30+
'filters' => $request->only(['status']),
31+
]);
32+
}
33+
34+
public function create(): Response
35+
{
36+
$this->authorize('create', ReturnRequest::class);
37+
38+
return Inertia::render('Finance/ReturnRequests/Create', [
39+
'contacts' => Contact::orderBy('name')->get(['id', 'name']),
40+
'invoices' => Invoice::latest()->limit(50)->get(['id', 'number']),
41+
]);
42+
}
43+
44+
public function store(Request $request): RedirectResponse
45+
{
46+
$this->authorize('create', ReturnRequest::class);
47+
48+
$data = $request->validate([
49+
'invoice_id' => ['nullable', 'exists:invoices,id'],
50+
'contact_id' => ['nullable', 'exists:contacts,id'],
51+
'reason' => ['required', 'string'],
52+
'refund_amount' => ['required', 'numeric', 'min:0'],
53+
'items' => ['required', 'array', 'min:1'],
54+
'items.*.product_name' => ['required', 'string', 'max:255'],
55+
'items.*.quantity' => ['required', 'integer', 'min:1'],
56+
'items.*.unit_price' => ['required', 'numeric', 'min:0'],
57+
'items.*.reason' => ['nullable', 'string'],
58+
]);
59+
60+
$returnRequest = DB::transaction(function () use ($data, $request) {
61+
$rr = ReturnRequest::create([
62+
'tenant_id' => auth()->user()->tenant_id,
63+
'invoice_id' => $data['invoice_id'] ?? null,
64+
'contact_id' => $data['contact_id'] ?? null,
65+
'reason' => $data['reason'],
66+
'refund_amount' => $data['refund_amount'],
67+
'status' => 'pending',
68+
'notes' => $request->notes,
69+
]);
70+
71+
foreach ($data['items'] as $item) {
72+
ReturnRequestItem::create([
73+
'tenant_id' => auth()->user()->tenant_id,
74+
'return_request_id' => $rr->id,
75+
'invoice_item_id' => $item['invoice_item_id'] ?? null,
76+
'product_name' => $item['product_name'],
77+
'quantity' => $item['quantity'],
78+
'unit_price' => $item['unit_price'],
79+
'reason' => $item['reason'] ?? null,
80+
]);
81+
}
82+
83+
return $rr;
84+
});
85+
86+
return redirect()->route('finance.return-requests.show', $returnRequest)
87+
->with('success', 'Return request created.');
88+
}
89+
90+
public function show(ReturnRequest $returnRequest): Response
91+
{
92+
$this->authorize('view', $returnRequest);
93+
94+
$returnRequest->load(['items', 'contact', 'invoice', 'approvedBy']);
95+
96+
return Inertia::render('Finance/ReturnRequests/Show', [
97+
'returnRequest' => $returnRequest,
98+
]);
99+
}
100+
101+
public function destroy(ReturnRequest $returnRequest): RedirectResponse
102+
{
103+
$this->authorize('delete', $returnRequest);
104+
105+
$returnRequest->delete();
106+
107+
return redirect()->route('finance.return-requests.index')
108+
->with('success', 'Return request deleted.');
109+
}
110+
111+
public function approve(ReturnRequest $returnRequest): RedirectResponse
112+
{
113+
$this->authorize('create', $returnRequest);
114+
115+
$returnRequest->approve(auth()->user());
116+
117+
return back()->with('success', 'Return request approved.');
118+
}
119+
120+
public function reject(ReturnRequest $returnRequest): RedirectResponse
121+
{
122+
$this->authorize('create', $returnRequest);
123+
124+
$returnRequest->reject();
125+
126+
return back()->with('success', 'Return request rejected.');
127+
}
128+
129+
public function markRefunded(ReturnRequest $returnRequest): RedirectResponse
130+
{
131+
$this->authorize('create', $returnRequest);
132+
133+
$returnRequest->markRefunded();
134+
135+
return back()->with('success', 'Return request marked as refunded.');
136+
}
137+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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+
use Illuminate\Database\Eloquent\Relations\HasMany;
10+
use Illuminate\Database\Eloquent\SoftDeletes;
11+
12+
class ReturnRequest extends Model
13+
{
14+
use BelongsToTenant;
15+
use SoftDeletes;
16+
17+
protected $table = 'return_requests';
18+
19+
protected $fillable = [
20+
'tenant_id', 'invoice_id', 'contact_id', 'reason', 'status',
21+
'refund_amount', 'notes', 'approved_by', 'approved_at', 'refunded_at',
22+
];
23+
24+
protected $casts = [
25+
'approved_at' => 'datetime',
26+
'refunded_at' => 'datetime',
27+
'refund_amount' => 'decimal:2',
28+
];
29+
30+
public function invoice(): BelongsTo
31+
{
32+
return $this->belongsTo(Invoice::class);
33+
}
34+
35+
public function contact(): BelongsTo
36+
{
37+
return $this->belongsTo(Contact::class);
38+
}
39+
40+
public function approvedBy(): BelongsTo
41+
{
42+
return $this->belongsTo(User::class, 'approved_by');
43+
}
44+
45+
public function items(): HasMany
46+
{
47+
return $this->hasMany(ReturnRequestItem::class);
48+
}
49+
50+
public function approve(User $user): void
51+
{
52+
$this->status = 'approved';
53+
$this->approved_by = $user->id;
54+
$this->approved_at = now();
55+
$this->save();
56+
}
57+
58+
public function reject(): void
59+
{
60+
$this->status = 'rejected';
61+
$this->save();
62+
}
63+
64+
public function markRefunded(): void
65+
{
66+
$this->status = 'refunded';
67+
$this->refunded_at = now();
68+
$this->save();
69+
}
70+
71+
public function getTotalRequestedAttribute(): float
72+
{
73+
if ($this->relationLoaded('items')) {
74+
return (float) $this->items->sum(fn ($item) => $item->quantity * $item->unit_price);
75+
}
76+
return 0.0;
77+
}
78+
}
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\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 ReturnRequestItem extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $table = 'return_request_items';
14+
15+
protected $fillable = [
16+
'tenant_id', 'return_request_id', 'invoice_item_id', 'product_name',
17+
'quantity', 'unit_price', 'reason',
18+
];
19+
20+
protected $casts = [
21+
'quantity' => 'integer',
22+
'unit_price' => 'decimal:2',
23+
];
24+
25+
public function returnRequest(): BelongsTo
26+
{
27+
return $this->belongsTo(ReturnRequest::class);
28+
}
29+
30+
public function invoiceItem(): BelongsTo
31+
{
32+
return $this->belongsTo(InvoiceItem::class);
33+
}
34+
}
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\ReturnRequest;
7+
8+
class ReturnRequestPolicy
9+
{
10+
public function viewAny(User $user): bool { return $user->can('finance.view'); }
11+
public function view(User $user, ReturnRequest $returnRequest): bool { return $user->can('finance.view'); }
12+
public function create(User $user): bool { return $user->can('finance.create'); }
13+
public function update(User $user, ReturnRequest $returnRequest): bool { return $user->can('finance.create'); }
14+
public function delete(User $user, ReturnRequest $returnRequest): bool { return $user->can('finance.delete'); }
15+
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@
5353
use App\Modules\Finance\Policies\ContractPolicy;
5454
use App\Modules\Finance\Policies\CommissionPolicy;
5555
use App\Modules\Finance\Policies\CommissionRulePolicy;
56+
use App\Modules\Finance\Models\ReturnRequest;
57+
use App\Modules\Finance\Models\ReturnRequestItem;
58+
use App\Modules\Finance\Policies\ReturnRequestPolicy;
5659
use Illuminate\Support\Facades\Gate;
5760
use Illuminate\Support\ServiceProvider;
5861

@@ -92,6 +95,9 @@ public function boot(): void
9295
Gate::policy(CommissionRule::class, CommissionRulePolicy::class);
9396
Gate::policy(Contract::class, ContractPolicy::class);
9497

98+
Gate::policy(ReturnRequest::class, ReturnRequestPolicy::class);
99+
Gate::policy(ReturnRequestItem::class, ReturnRequestPolicy::class);
100+
95101
if ($this->app->runningInConsole()) {
96102
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);
97103
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
use App\Modules\Finance\Http\Controllers\CommissionController;
3232
use App\Modules\Finance\Http\Controllers\CommissionRuleController;
3333
use App\Modules\Finance\Http\Controllers\ContractController;
34+
use App\Modules\Finance\Http\Controllers\ReturnRequestController;
3435

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

@@ -231,4 +232,10 @@
231232
Route::resource('contracts', ContractController::class)->except(['show']);
232233
Route::get('contracts/{contract}', [ContractController::class, 'show'])->name('contracts.show');
233234

235+
// Return Requests — custom actions BEFORE resource
236+
Route::post('return-requests/{returnRequest}/approve', [ReturnRequestController::class, 'approve'])->name('return-requests.approve');
237+
Route::post('return-requests/{returnRequest}/reject', [ReturnRequestController::class, 'reject'])->name('return-requests.reject');
238+
Route::post('return-requests/{returnRequest}/mark-refunded', [ReturnRequestController::class, 'markRefunded'])->name('return-requests.mark-refunded');
239+
Route::resource('return-requests', ReturnRequestController::class)->except(['edit', 'update']);
240+
234241
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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('return_requests', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->foreignId('invoice_id')->nullable()->nullOnDelete()->constrained('invoices');
15+
$table->foreignId('contact_id')->nullable()->nullOnDelete()->constrained('contacts');
16+
$table->text('reason');
17+
$table->string('status', 20)->default('pending');
18+
$table->decimal('refund_amount', 10, 2)->default(0);
19+
$table->text('notes')->nullable();
20+
$table->unsignedBigInteger('approved_by')->nullable();
21+
$table->timestamp('approved_at')->nullable();
22+
$table->timestamp('refunded_at')->nullable();
23+
$table->softDeletes();
24+
$table->timestamps();
25+
$table->index('tenant_id');
26+
});
27+
}
28+
29+
public function down(): void
30+
{
31+
Schema::dropIfExists('return_requests');
32+
}
33+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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('return_request_items', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->foreignId('return_request_id')->constrained()->cascadeOnDelete();
15+
$table->foreignId('invoice_item_id')->nullable()->nullOnDelete()->constrained('invoice_items');
16+
$table->string('product_name');
17+
$table->unsignedInteger('quantity')->default(1);
18+
$table->decimal('unit_price', 10, 2)->default(0);
19+
$table->text('reason')->nullable();
20+
$table->timestamps();
21+
$table->index('tenant_id');
22+
});
23+
}
24+
25+
public function down(): void
26+
{
27+
Schema::dropIfExists('return_request_items');
28+
}
29+
};

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ const navItems: NavItem[] = [
118118
{ label: 'Sub Plans', href: '/finance/subscription-plans', icon: <span /> },
119119
{ label: 'Commissions', href: '/finance/commissions', icon: <span /> },
120120
{ label: 'Contracts', href: '/finance/contracts', icon: <span /> },
121+
{ label: 'Returns', href: '/finance/return-requests', icon: <span /> },
121122
],
122123
},
123124
{

0 commit comments

Comments
 (0)