From 8d32534b6d0dcb1dad81c2d76439dec5afd02385 Mon Sep 17 00:00:00 2001 From: "sweep-ai[bot]" <128439645+sweep-ai[bot]@users.noreply.github.com> Date: Wed, 25 Dec 2024 00:01:39 +0000 Subject: [PATCH] Add Invoice Dispute Management Feature --- .../Controllers/InvoiceDisputeController.php | 60 +++++++++++++ app/Models/Invoice.php | 15 ++++ app/Models/InvoiceDispute.php | 50 +++++++++++ app/Services/DisputeService.php | 90 +++++++++++++++++++ ...0_000001_create_invoice_disputes_table.php | 31 +++++++ 5 files changed, 246 insertions(+) create mode 100644 app/Http/Controllers/InvoiceDisputeController.php create mode 100644 app/Models/InvoiceDispute.php create mode 100644 app/Services/DisputeService.php create mode 100644 database/migrations/2024_01_20_000001_create_invoice_disputes_table.php diff --git a/app/Http/Controllers/InvoiceDisputeController.php b/app/Http/Controllers/InvoiceDisputeController.php new file mode 100644 index 00000000..90a4b098 --- /dev/null +++ b/app/Http/Controllers/InvoiceDisputeController.php @@ -0,0 +1,60 @@ + + +disputeService = $disputeService; + } + + public function store(Request $request, Invoice $invoice) + { + $validated = $request->validate([ + 'reason' => 'required|string|max:255', + 'description' => 'required|string' + ]); + + $dispute = $this->disputeService->createDispute($invoice, $validated); + + return response()->json($dispute, 201); + } + + public function update(Request $request, InvoiceDispute $dispute) + { + $validated = $request->validate([ + 'status' => 'required|in:under_review,resolved,rejected', + 'resolution_notes' => 'required_if:status,resolved,rejected|string' + ]); + + $dispute = $this->disputeService->updateDisputeStatus( + $dispute, + $validated['status'], + $validated['resolution_notes'] ?? null + ); + + return response()->json($dispute); + } + + public function addMessage(Request $request, InvoiceDispute $dispute) + { + $validated = $request->validate([ + 'message' => 'required|string', + 'attachments' => 'nullable|array' + ]); + + $message = $this->disputeService->addMessage($dispute, $validated); + + return response()->json($message, 201); + } +} \ No newline at end of file diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index 806b193e..7f572779 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -48,6 +48,21 @@ protected static function boot() }); } + public function disputes() + { + return $this->hasMany(InvoiceDispute::class); + } + + public function activeDispute() + { + return $this->disputes()->whereIn('status', ['open', 'under_review'])->latest()->first(); + } + + public function isDisputed() + { + return $this->activeDispute() !== null; + } + protected $fillable = [ 'customer_id', 'invoice_number', diff --git a/app/Models/InvoiceDispute.php b/app/Models/InvoiceDispute.php new file mode 100644 index 00000000..b6b92031 --- /dev/null +++ b/app/Models/InvoiceDispute.php @@ -0,0 +1,50 @@ + + + 'datetime' + ]; + + public function invoice() + { + return $this->belongsTo(Invoice::class); + } + + public function customer() + { + return $this->belongsTo(Customer::class); + } + + public function resolver() + { + return $this->belongsTo(User::class, 'resolved_by'); + } + + public function messages() + { + return $this->hasMany(DisputeMessage::class); + } +} \ No newline at end of file diff --git a/app/Services/DisputeService.php b/app/Services/DisputeService.php new file mode 100644 index 00000000..2b4c77c6 --- /dev/null +++ b/app/Services/DisputeService.php @@ -0,0 +1,90 @@ + + +isDisputed()) { + throw new \Exception('Invoice already has an active dispute'); + } + + $dispute = InvoiceDispute::create([ + 'invoice_id' => $invoice->id, + 'customer_id' => $invoice->customer_id, + 'status' => 'open', + 'reason' => $data['reason'], + 'description' => $data['description'] + ]); + + $invoice->update(['status' => 'disputed']); + $this->sendDisputeNotifications($dispute, 'created'); + + return $dispute; + } + + public function updateDisputeStatus(InvoiceDispute $dispute, string $status, string $notes = null) + { + $dispute->update([ + 'status' => $status, + 'resolution_notes' => $notes, + 'resolved_at' => in_array($status, ['resolved', 'rejected']) ? now() : null, + 'resolved_by' => in_array($status, ['resolved', 'rejected']) ? auth()->id() : null + ]); + + if ($status === 'resolved') { + $dispute->invoice->update(['status' => 'pending']); + } + + $this->sendDisputeNotifications($dispute, 'status_updated'); + + return $dispute; + } + + public function addMessage(InvoiceDispute $dispute, array $data) + { + $message = $dispute->messages()->create([ + 'user_id' => auth()->id(), + 'message' => $data['message'], + 'attachments' => $data['attachments'] ?? null + ]); + + $this->sendDisputeNotifications($dispute, 'new_message'); + + return $message; + } + + protected function sendDisputeNotifications(InvoiceDispute $dispute, string $type) + { + $customer = $dispute->customer; + $adminUsers = User::where('team_id', $customer->team_id) + ->whereHas('roles', function($q) { + $q->where('name', 'admin'); + })->get(); + + switch($type) { + case 'created': + Mail::to($adminUsers)->send(new DisputeCreated($dispute)); + break; + case 'status_updated': + Mail::to($customer->email)->send(new DisputeStatusUpdated($dispute)); + break; + case 'new_message': + $recipient = auth()->id() === $customer->id ? $adminUsers : $customer; + Mail::to($recipient)->send(new DisputeMessageReceived($dispute)); + break; + } + } +} \ No newline at end of file diff --git a/database/migrations/2024_01_20_000001_create_invoice_disputes_table.php b/database/migrations/2024_01_20_000001_create_invoice_disputes_table.php new file mode 100644 index 00000000..508a5e2b --- /dev/null +++ b/database/migrations/2024_01_20_000001_create_invoice_disputes_table.php @@ -0,0 +1,31 @@ + + +id(); + $table->foreignId('invoice_id')->constrained()->onDelete('cascade'); + $table->foreignId('customer_id')->constrained(); + $table->string('status')->default('open'); + $table->string('reason'); + $table->text('description'); + $table->text('resolution_notes')->nullable(); + $table->timestamp('resolved_at')->nullable(); + $table->foreignId('resolved_by')->nullable()->constrained('users'); + $table->timestamps(); + }); + } + + public function down() + { + Schema::dropIfExists('invoice_disputes'); + } +}; \ No newline at end of file