|
3 | 3 | namespace App\Modules\Finance\Http\Controllers; |
4 | 4 |
|
5 | 5 | use App\Http\Controllers\Controller; |
6 | | -use App\Modules\Finance\Http\Requests\StoreCreditNoteRequest; |
7 | | -use App\Modules\Finance\Http\Resources\CreditNoteResource; |
| 6 | +use App\Modules\Finance\Models\Bill; |
8 | 7 | use App\Modules\Finance\Models\Contact; |
9 | 8 | use App\Modules\Finance\Models\CreditNote; |
10 | | -use App\Modules\Finance\Models\CreditNoteItem; |
11 | 9 | use App\Modules\Finance\Models\Invoice; |
12 | 10 | use Illuminate\Http\RedirectResponse; |
13 | 11 | use Illuminate\Http\Request; |
14 | | -use Illuminate\Support\Facades\DB; |
15 | 12 | use Inertia\Inertia; |
16 | 13 | use Inertia\Response; |
17 | 14 |
|
18 | 15 | class CreditNoteController extends Controller |
19 | 16 | { |
20 | | - public function index(Request $request): Response |
| 17 | + public function index(): Response |
21 | 18 | { |
22 | 19 | $this->authorize('viewAny', CreditNote::class); |
23 | 20 |
|
24 | | - $creditNotes = CreditNote::with(['contact', 'invoice']) |
25 | | - ->when($request->status, fn ($q) => $q->where('status', $request->status)) |
26 | | - ->when($request->contact_id, fn ($q) => $q->where('contact_id', $request->contact_id)) |
27 | | - ->when($request->search, fn ($q) => $q->where('number', 'like', "%{$request->search}%")) |
28 | | - ->latest('issue_date') |
29 | | - ->paginate(25) |
30 | | - ->withQueryString(); |
31 | | - |
32 | | - return Inertia::render('Finance/CreditNotes/Index', [ |
33 | | - 'creditNotes' => CreditNoteResource::collection($creditNotes), |
34 | | - 'contacts' => Contact::customers()->active()->orderBy('name')->get(['id', 'name']), |
35 | | - 'filters' => $request->only(['status', 'contact_id', 'search']), |
36 | | - 'breadcrumbs' => [ |
37 | | - ['label' => 'Finance'], |
38 | | - ['label' => 'Credit Notes', 'href' => route('finance.credit-notes.index')], |
39 | | - ], |
40 | | - ]); |
| 21 | + $creditNotes = CreditNote::with(['contact']) |
| 22 | + ->orderByDesc('issue_date') |
| 23 | + ->paginate(25); |
| 24 | + |
| 25 | + return Inertia::render('Finance/CreditNotes/Index', compact('creditNotes')); |
41 | 26 | } |
42 | 27 |
|
43 | 28 | public function create(Request $request): Response |
44 | 29 | { |
45 | 30 | $this->authorize('create', CreditNote::class); |
46 | 31 |
|
47 | | - $sourceInvoice = null; |
48 | | - if ($request->invoice_id) { |
49 | | - $invoice = Invoice::with(['items', 'contact'])->find($request->invoice_id); |
50 | | - if ($invoice) { |
51 | | - $sourceInvoice = [ |
52 | | - 'id' => $invoice->id, |
53 | | - 'number' => $invoice->number, |
54 | | - 'contact' => $invoice->contact ? [ |
55 | | - 'id' => $invoice->contact->id, 'name' => $invoice->contact->name, |
56 | | - ] : null, |
57 | | - 'items' => $invoice->items->map(fn ($item) => [ |
58 | | - 'description' => $item->description, |
59 | | - 'quantity' => $item->quantity, |
60 | | - 'unit_price' => $item->unit_price, |
61 | | - 'tax_rate' => $item->tax_rate, |
62 | | - ]), |
63 | | - ]; |
64 | | - } |
65 | | - } |
66 | | - |
67 | | - $invoices = Invoice::with('contact') |
68 | | - ->latest('issue_date') |
69 | | - ->get(['id', 'number', 'contact_id']) |
70 | | - ->map(fn ($invoice) => [ |
71 | | - 'id' => $invoice->id, |
72 | | - 'number' => $invoice->number, |
73 | | - 'contact_name' => $invoice->contact?->name, |
74 | | - ]); |
| 32 | + $contacts = Contact::orderBy('name')->get(['id', 'name', 'type']); |
| 33 | + $invoices = Invoice::where('status', 'sent') |
| 34 | + ->orderByDesc('issue_date')->get(['id', 'number']); |
| 35 | + $bills = Bill::where('status', 'received') |
| 36 | + ->orderByDesc('issue_date')->get(['id', 'number']); |
| 37 | + $type = $request->get('type', 'sale'); |
75 | 38 |
|
76 | | - return Inertia::render('Finance/CreditNotes/Create', [ |
77 | | - 'contacts' => Contact::customers()->active()->orderBy('name')->get(['id', 'name']), |
78 | | - 'invoices' => $invoices, |
79 | | - 'sourceInvoice' => $sourceInvoice, |
80 | | - 'breadcrumbs' => [ |
81 | | - ['label' => 'Finance'], |
82 | | - ['label' => 'Credit Notes', 'href' => route('finance.credit-notes.index')], |
83 | | - ['label' => 'New Credit Note'], |
84 | | - ], |
85 | | - ]); |
| 39 | + return Inertia::render('Finance/CreditNotes/Create', compact('contacts', 'invoices', 'bills', 'type')); |
86 | 40 | } |
87 | 41 |
|
88 | | - public function store(StoreCreditNoteRequest $request): RedirectResponse |
| 42 | + public function store(Request $request): RedirectResponse |
89 | 43 | { |
90 | 44 | $this->authorize('create', CreditNote::class); |
91 | 45 |
|
92 | | - $data = $request->validated(); |
93 | | - |
94 | | - $creditNote = DB::transaction(function () use ($data) { |
95 | | - $creditNote = CreditNote::create([ |
96 | | - 'tenant_id' => auth()->user()->tenant_id, |
97 | | - 'contact_id' => $data['contact_id'] ?? null, |
98 | | - 'invoice_id' => $data['invoice_id'] ?? null, |
99 | | - 'issue_date' => $data['issue_date'], |
100 | | - 'reason' => $data['reason'] ?? null, |
101 | | - 'notes' => $data['notes'] ?? null, |
102 | | - 'created_by' => auth()->id(), |
103 | | - ]); |
| 46 | + $data = $request->validate([ |
| 47 | + 'reference' => 'required|string|max:100', |
| 48 | + 'contact_id' => 'nullable|exists:contacts,id', |
| 49 | + 'original_invoice_id' => 'nullable|exists:invoices,id', |
| 50 | + 'original_bill_id' => 'nullable|exists:bills,id', |
| 51 | + 'type' => 'required|in:sale,purchase', |
| 52 | + 'issue_date' => 'required|date', |
| 53 | + 'currency_code' => 'required|string|size:3', |
| 54 | + 'exchange_rate' => 'required|numeric|min:0.000001', |
| 55 | + 'notes' => 'nullable|string', |
| 56 | + 'items' => 'required|array|min:1', |
| 57 | + 'items.*.description' => 'required|string', |
| 58 | + 'items.*.quantity' => 'required|numeric|min:0.01', |
| 59 | + 'items.*.unit_price' => 'required|numeric|min:0', |
| 60 | + 'items.*.tax_rate' => 'required|numeric|min:0|max:100', |
| 61 | + ]); |
104 | 62 |
|
105 | | - $creditNote->update([ |
106 | | - 'number' => 'CN-' . now()->format('Y') . '-' . str_pad((string) $creditNote->id, 5, '0', STR_PAD_LEFT), |
107 | | - ]); |
| 63 | + $cn = CreditNote::create([ |
| 64 | + 'tenant_id' => auth()->user()->tenant_id, |
| 65 | + 'reference' => $data['reference'], |
| 66 | + 'contact_id' => $data['contact_id'] ?? null, |
| 67 | + 'original_invoice_id' => $data['original_invoice_id'] ?? null, |
| 68 | + 'original_bill_id' => $data['original_bill_id'] ?? null, |
| 69 | + 'type' => $data['type'], |
| 70 | + 'status' => 'draft', |
| 71 | + 'issue_date' => $data['issue_date'], |
| 72 | + 'currency_code' => $data['currency_code'], |
| 73 | + 'exchange_rate' => $data['exchange_rate'], |
| 74 | + 'notes' => $data['notes'] ?? null, |
| 75 | + 'subtotal' => 0, |
| 76 | + 'tax_total' => 0, |
| 77 | + 'total' => 0, |
| 78 | + 'amount_applied' => 0, |
| 79 | + ]); |
108 | 80 |
|
109 | | - foreach ($data['items'] as $item) { |
110 | | - CreditNoteItem::create([ |
111 | | - 'credit_note_id' => $creditNote->id, |
112 | | - 'description' => $item['description'], |
113 | | - 'quantity' => $item['quantity'], |
114 | | - 'unit_price' => $item['unit_price'], |
115 | | - 'tax_rate' => $item['tax_rate'], |
116 | | - ]); |
117 | | - } |
| 81 | + foreach ($data['items'] as $item) { |
| 82 | + $cn->items()->create([ |
| 83 | + 'description' => $item['description'], |
| 84 | + 'quantity' => $item['quantity'], |
| 85 | + 'unit_price' => $item['unit_price'], |
| 86 | + 'tax_rate' => $item['tax_rate'], |
| 87 | + 'line_total' => round($item['quantity'] * $item['unit_price'], 2), |
| 88 | + ]); |
| 89 | + } |
118 | 90 |
|
119 | | - return $creditNote; |
120 | | - }); |
| 91 | + // Recalculate totals explicitly after items are created |
| 92 | + $cn->refresh()->load('items'); |
| 93 | + $subtotal = $cn->items->sum('line_total'); |
| 94 | + $tax_total = $cn->items->sum(fn ($i) => $i->line_total * $i->tax_rate / 100); |
| 95 | + $cn->update([ |
| 96 | + 'subtotal' => $subtotal, |
| 97 | + 'tax_total' => $tax_total, |
| 98 | + 'total' => $subtotal + $tax_total, |
| 99 | + ]); |
121 | 100 |
|
122 | | - return redirect()->route('finance.credit-notes.show', $creditNote) |
| 101 | + return redirect()->route('finance.credit-notes.show', $cn) |
123 | 102 | ->with('success', 'Credit note created.'); |
124 | 103 | } |
125 | 104 |
|
126 | 105 | public function show(CreditNote $creditNote): Response |
127 | 106 | { |
128 | 107 | $this->authorize('view', $creditNote); |
129 | 108 |
|
130 | | - $creditNote->load(['contact', 'invoice', 'items', 'creator']); |
| 109 | + $creditNote->load(['contact', 'invoice', 'bill', 'items']); |
131 | 110 |
|
132 | | - return Inertia::render('Finance/CreditNotes/Show', [ |
133 | | - 'creditNote' => new CreditNoteResource($creditNote), |
134 | | - 'breadcrumbs' => [ |
135 | | - ['label' => 'Finance'], |
136 | | - ['label' => 'Credit Notes', 'href' => route('finance.credit-notes.index')], |
137 | | - ['label' => $creditNote->number ?? "Credit Note #{$creditNote->id}"], |
138 | | - ], |
139 | | - ]); |
| 111 | + return Inertia::render('Finance/CreditNotes/Show', compact('creditNote')); |
140 | 112 | } |
141 | 113 |
|
142 | 114 | public function issue(CreditNote $creditNote): RedirectResponse |
143 | 115 | { |
144 | 116 | $this->authorize('update', $creditNote); |
145 | 117 |
|
146 | | - try { |
147 | | - $creditNote->transitionTo('issued'); |
148 | | - } catch (\DomainException $e) { |
149 | | - return back()->withErrors(['status' => $e->getMessage()]); |
150 | | - } |
| 118 | + abort_unless($creditNote->status === 'draft', 422, 'Only draft credit notes can be issued.'); |
| 119 | + $creditNote->update(['status' => 'issued']); |
151 | 120 |
|
152 | 121 | return back()->with('success', 'Credit note issued.'); |
153 | 122 | } |
154 | 123 |
|
155 | | - public function apply(CreditNote $creditNote): RedirectResponse |
| 124 | + public function void(CreditNote $creditNote): RedirectResponse |
156 | 125 | { |
157 | 126 | $this->authorize('update', $creditNote); |
158 | 127 |
|
159 | | - try { |
160 | | - $creditNote->transitionTo('applied'); |
161 | | - } catch (\DomainException $e) { |
162 | | - return back()->withErrors(['status' => $e->getMessage()]); |
163 | | - } |
164 | | - |
165 | | - return back()->with('success', 'Credit note applied.'); |
166 | | - } |
167 | | - |
168 | | - public function cancel(CreditNote $creditNote): RedirectResponse |
169 | | - { |
170 | | - $this->authorize('update', $creditNote); |
171 | | - |
172 | | - try { |
173 | | - $creditNote->transitionTo('cancelled'); |
174 | | - } catch (\DomainException $e) { |
175 | | - return back()->withErrors(['status' => $e->getMessage()]); |
176 | | - } |
| 128 | + abort_unless(in_array($creditNote->status, ['draft', 'issued']), 422, 'Cannot void applied credit notes.'); |
| 129 | + $creditNote->update(['status' => 'void']); |
177 | 130 |
|
178 | | - return back()->with('success', 'Credit note cancelled.'); |
| 131 | + return back()->with('success', 'Credit note voided.'); |
179 | 132 | } |
180 | 133 |
|
181 | 134 | public function destroy(CreditNote $creditNote): RedirectResponse |
182 | 135 | { |
183 | 136 | $this->authorize('delete', $creditNote); |
184 | 137 |
|
| 138 | + abort_unless($creditNote->status === 'draft', 422, 'Only draft credit notes can be deleted.'); |
185 | 139 | $creditNote->delete(); |
186 | 140 |
|
187 | 141 | return redirect()->route('finance.credit-notes.index') |
|
0 commit comments