Skip to content

Commit cef9cc1

Browse files
committed
feat: Phase 10 WIP — Credit Notes backend (models, policy, controller, migrations)
- CreditNote + CreditNoteItem models with HasLineItemTotals + HasStatusTransitions - CreditNotePolicy, StoreCreditNoteRequest, CreditNoteResource, CreditNoteController - Migrations for credit_notes and credit_note_items tables - Lifecycle: draft → issued → applied/cancelled; optional link to source invoice https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 78b8a85 commit cef9cc1

8 files changed

Lines changed: 448 additions & 0 deletions

File tree

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Http\Requests\StoreCreditNoteRequest;
7+
use App\Modules\Finance\Http\Resources\CreditNoteResource;
8+
use App\Modules\Finance\Models\Contact;
9+
use App\Modules\Finance\Models\CreditNote;
10+
use App\Modules\Finance\Models\CreditNoteItem;
11+
use App\Modules\Finance\Models\Invoice;
12+
use Illuminate\Http\RedirectResponse;
13+
use Illuminate\Http\Request;
14+
use Illuminate\Support\Facades\DB;
15+
use Inertia\Inertia;
16+
use Inertia\Response;
17+
18+
class CreditNoteController extends Controller
19+
{
20+
public function index(Request $request): Response
21+
{
22+
$this->authorize('viewAny', CreditNote::class);
23+
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+
]);
41+
}
42+
43+
public function create(Request $request): Response
44+
{
45+
$this->authorize('create', CreditNote::class);
46+
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+
]);
75+
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+
]);
86+
}
87+
88+
public function store(StoreCreditNoteRequest $request): RedirectResponse
89+
{
90+
$this->authorize('create', CreditNote::class);
91+
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+
]);
104+
105+
$creditNote->update([
106+
'number' => 'CN-' . now()->format('Y') . '-' . str_pad((string) $creditNote->id, 5, '0', STR_PAD_LEFT),
107+
]);
108+
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+
}
118+
119+
return $creditNote;
120+
});
121+
122+
return redirect()->route('finance.credit-notes.show', $creditNote)
123+
->with('success', 'Credit note created.');
124+
}
125+
126+
public function show(CreditNote $creditNote): Response
127+
{
128+
$this->authorize('view', $creditNote);
129+
130+
$creditNote->load(['contact', 'invoice', 'items', 'creator']);
131+
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+
]);
140+
}
141+
142+
public function issue(CreditNote $creditNote): RedirectResponse
143+
{
144+
$this->authorize('update', $creditNote);
145+
146+
try {
147+
$creditNote->transitionTo('issued');
148+
} catch (\DomainException $e) {
149+
return back()->withErrors(['status' => $e->getMessage()]);
150+
}
151+
152+
return back()->with('success', 'Credit note issued.');
153+
}
154+
155+
public function apply(CreditNote $creditNote): RedirectResponse
156+
{
157+
$this->authorize('update', $creditNote);
158+
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+
}
177+
178+
return back()->with('success', 'Credit note cancelled.');
179+
}
180+
181+
public function destroy(CreditNote $creditNote): RedirectResponse
182+
{
183+
$this->authorize('delete', $creditNote);
184+
185+
$creditNote->delete();
186+
187+
return redirect()->route('finance.credit-notes.index')
188+
->with('success', 'Credit note deleted.');
189+
}
190+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Requests;
4+
5+
use Illuminate\Foundation\Http\FormRequest;
6+
use Illuminate\Validation\Rule;
7+
8+
class StoreCreditNoteRequest extends FormRequest
9+
{
10+
public function authorize(): bool { return true; }
11+
12+
public function rules(): array
13+
{
14+
return [
15+
'contact_id' => ['nullable', Rule::exists('contacts', 'id')],
16+
'invoice_id' => ['nullable', Rule::exists('invoices', 'id')],
17+
'issue_date' => ['required', 'date'],
18+
'reason' => ['nullable', 'string'],
19+
'notes' => ['nullable', 'string'],
20+
'items' => ['required', 'array', 'min:1'],
21+
'items.*.description' => ['required', 'string'],
22+
'items.*.quantity' => ['required', 'numeric', 'min:0.01'],
23+
'items.*.unit_price' => ['required', 'numeric', 'min:0'],
24+
'items.*.tax_rate' => ['required', 'numeric', 'min:0', 'max:100'],
25+
];
26+
}
27+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Resources;
4+
5+
use Illuminate\Http\Request;
6+
use Illuminate\Http\Resources\Json\JsonResource;
7+
8+
class CreditNoteResource extends JsonResource
9+
{
10+
public function toArray(Request $request): array
11+
{
12+
return [
13+
'id' => $this->id,
14+
'number' => $this->number,
15+
'status' => $this->status,
16+
'issue_date' => $this->issue_date?->toDateString(),
17+
'reason' => $this->reason,
18+
'notes' => $this->notes,
19+
'contact' => $this->whenLoaded('contact', fn () => $this->contact ? [
20+
'id' => $this->contact->id, 'name' => $this->contact->name,
21+
] : null),
22+
'invoice' => $this->whenLoaded('invoice', fn () => $this->invoice ? [
23+
'id' => $this->invoice->id, 'number' => $this->invoice->number,
24+
] : null),
25+
'items' => $this->whenLoaded('items', fn () => $this->items->map(fn ($item) => [
26+
'id' => $item->id,
27+
'description' => $item->description,
28+
'quantity' => $item->quantity,
29+
'unit_price' => $item->unit_price,
30+
'tax_rate' => $item->tax_rate,
31+
'line_total' => $item->line_total,
32+
])),
33+
'subtotal' => $this->whenLoaded('items', fn () => $this->subtotal),
34+
'tax_total' => $this->whenLoaded('items', fn () => $this->tax_total),
35+
'total' => $this->whenLoaded('items', fn () => $this->total),
36+
'transitions' => $this->availableTransitions(),
37+
'created_by' => $this->whenLoaded('creator', fn () => $this->creator?->name),
38+
'created_at' => $this->created_at,
39+
];
40+
}
41+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use App\Modules\Core\Traits\HasAuditLog;
8+
use App\Modules\Finance\Traits\HasLineItemTotals;
9+
use App\Modules\Finance\Traits\HasStatusTransitions;
10+
use Illuminate\Database\Eloquent\Model;
11+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
12+
use Illuminate\Database\Eloquent\Relations\HasMany;
13+
use Illuminate\Database\Eloquent\SoftDeletes;
14+
15+
class CreditNote extends Model
16+
{
17+
use BelongsToTenant;
18+
use HasAuditLog;
19+
use SoftDeletes;
20+
use HasLineItemTotals;
21+
use HasStatusTransitions;
22+
23+
protected $fillable = [
24+
'tenant_id', 'contact_id', 'invoice_id', 'number',
25+
'issue_date', 'status', 'reason', 'notes', 'created_by',
26+
];
27+
28+
protected $casts = [
29+
'issue_date' => 'date',
30+
];
31+
32+
protected $attributes = ['status' => 'draft'];
33+
34+
protected function getTransitions(): array
35+
{
36+
return [
37+
'draft' => ['issued', 'cancelled'],
38+
'issued' => ['applied', 'cancelled'],
39+
'applied' => [],
40+
'cancelled' => [],
41+
];
42+
}
43+
44+
public function contact(): BelongsTo
45+
{
46+
return $this->belongsTo(Contact::class);
47+
}
48+
49+
public function invoice(): BelongsTo
50+
{
51+
return $this->belongsTo(Invoice::class);
52+
}
53+
54+
public function items(): HasMany
55+
{
56+
return $this->hasMany(CreditNoteItem::class);
57+
}
58+
59+
public function creator(): BelongsTo
60+
{
61+
return $this->belongsTo(User::class, 'created_by');
62+
}
63+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
7+
8+
class CreditNoteItem extends Model
9+
{
10+
protected $table = 'credit_note_items';
11+
12+
protected $fillable = [
13+
'credit_note_id', 'description', 'quantity', 'unit_price', 'tax_rate',
14+
];
15+
16+
protected $casts = [
17+
'quantity' => 'decimal:2',
18+
'unit_price' => 'decimal:2',
19+
'tax_rate' => 'decimal:2',
20+
];
21+
22+
public function creditNote(): BelongsTo
23+
{
24+
return $this->belongsTo(CreditNote::class);
25+
}
26+
27+
public function getLineTotalAttribute(): float
28+
{
29+
return (float) $this->quantity * (float) $this->unit_price * (1 + (float) $this->tax_rate / 100);
30+
}
31+
}
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\CreditNote;
7+
8+
class CreditNotePolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('finance.view');
13+
}
14+
15+
public function view(User $user, CreditNote $creditNote): 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, CreditNote $creditNote): bool
26+
{
27+
return $user->can('finance.update');
28+
}
29+
30+
public function delete(User $user, CreditNote $creditNote): bool
31+
{
32+
return $user->can('finance.delete') && $creditNote->status === 'draft';
33+
}
34+
}

0 commit comments

Comments
 (0)